feat: 선물 sim 차트 추가 및 GT/선물 마커 UI 개선

선물 1년 PnL 시뮬(40만원)과 sim 차트 v1~v3를 추가하고, 현물·선물 GT 차트 마커를 종가선 밀착 삼각형으로 통일한다. 동시각 L↓/S↓ 겹침은 가로 stagger로 분리하며 동그라미 표시는 제거한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-09 19:58:12 +09:00
parent 2dedfae82d
commit 5d27f90560
48 changed files with 6772 additions and 392 deletions

View File

@@ -1,399 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>Ground Truth Chart</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.min.css" />
<script src="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.iife.min.js"></script>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="ground_truth_chart_data.js"></script>
<style>
body { font-family: "Malgun Gothic", Arial, sans-serif; margin: 0; background: #f5f5f5; color: #333; }
header { padding: 16px 24px; background: #fff; border-bottom: 1px solid #ddd; }
h1 { margin: 0 0 6px; font-size: 20px; }
.meta { font-size: 13px; color: #666; }
.toolbar { padding: 10px 24px; background: #fff; border-bottom: 1px solid #eee; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.toolbar-group { display: flex; gap: 6px; align-items: center; padding-right: 12px; border-right: 1px solid #e0e0e0; }
.toolbar-group:last-of-type { border-right: none; }
.toolbar button { padding: 6px 12px; border: 1px solid #bbb; background: #fff; cursor: pointer; border-radius: 4px; font-size: 13px; white-space: nowrap; }
.toolbar button:hover { background: #f0f4f8; }
.toolbar button.active { background: #1565c0; color: #fff; border-color: #1565c0; }
.toolbar button.home { background: #2e7d32; color: #fff; border-color: #2e7d32; font-weight: bold; }
.toolbar button.home:hover { background: #1b5e20; }
.toolbar .leg-info { font-size: 12px; color: #555; min-width: 90px; }
#status { font-size: 12px; color: #888; margin-left: auto; }
#overview { height: 480px; margin: 12px 24px; background: #fff; border: 1px solid #ddd; }
#detail-wrap { margin: 0 24px 12px; display: none; }
#detail-wrap h2 { font-size: 15px; margin: 0 0 8px; }
#detail { height: 360px; background: #fff; border: 1px solid #ddd; }
</style>
</head>
<body>
<header>
<h1 id="title">Ground Truth Chart</h1>
<div class="meta" id="meta"></div>
</header>
<div class="toolbar">
<div class="toolbar-group">
<button id="btn-home" class="home" title="전체 2년 화면으로 복귀"></button>
<button id="btn-prev-leg" title="이전 매수·매도 타점">◀ 이전</button>
<button id="btn-next-leg" title="다음 매수·매도 타점">다음 ▶</button>
<span class="leg-info" id="leg-info">타점 - / -</span>
</div>
<div class="toolbar-group">
<button id="btn-all" class="btn-period active">전체</button>
<button id="btn-365d" class="btn-period">1년</button>
<button id="btn-30d" class="btn-period">30일</button>
<button id="btn-7d" class="btn-period">7일</button>
<button id="btn-3d" class="btn-period">3일</button>
</div>
<div class="toolbar-group">
<button id="btn-zoom-in" title="확대">+ 확대</button>
<button id="btn-zoom-out" title="축소"> 축소</button>
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
</div>
<div class="toolbar-group">
<button id="btn-markers" title="매수·매도 마커 표시/숨김">마커 숨김</button>
<button id="btn-toggle-detail" title="상세 캔들 패널 표시/숨김">상세 패널</button>
</div>
<span id="status">데이터 로딩 중…</span>
</div>
<div id="overview"></div>
<div id="detail-wrap">
<h2 id="detail-title">상세 캔들</h2>
<div id="detail"></div>
</div>
<script>
let DATA = null;
let overviewPlot = null;
let detailChart = null;
let detailSeries = null;
let currentMode = "overview";
let currentLegIdx = 0;
let showMarkers = true;
let detailVisible = false;
let lastDetailStart = 0;
function fmtPrice(v) {
return Math.round(v).toLocaleString("ko-KR");
}
function updateLegInfo() {
const total = DATA.buy_markers.length;
const el = document.getElementById("leg-info");
if (!total) { el.textContent = "타점 없음"; return; }
el.textContent = `타점 ${currentLegIdx + 1} / ${total}`;
}
function drawMarkers(u, buys, sells) {
if (!showMarkers) return;
const ctx = u.ctx;
const drawOne = (m, color, up) => {
const x = u.valToPos(m.time, "x", true);
const y = u.valToPos(m.price, "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
ctx.fillStyle = color;
ctx.beginPath();
const s = 5;
if (up) {
ctx.moveTo(x, y + 10); ctx.lineTo(x - s, y + 18); ctx.lineTo(x + s, y + 18);
} else {
ctx.moveTo(x, y - 10); ctx.lineTo(x - s, y - 18); ctx.lineTo(x + s, y - 18);
}
ctx.closePath(); ctx.fill();
ctx.fillStyle = "#333";
ctx.font = "10px Malgun Gothic, Arial";
let suffix = "";
if (m.signal_type === "pullback") suffix = "*";
else if (m.signal_type === "breakout") suffix = "^";
else if (m.signal_type === "div_bull" || m.signal_type === "div_bear") suffix = "d";
const label = (up ? "B" : "S") + m.marker_id + suffix;
ctx.fillText(label, x + 6, y + (up ? 14 : -12));
};
buys.forEach(m => {
let color = "#2e7d32";
if (m.signal_type === "breakout") color = "#ef6c00";
else if (m.signal_type === "div_bull") color = "#7b1fa2";
drawOne(m, color, true);
});
sells.forEach(m => {
const color = m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828";
drawOne(m, color, false);
});
}
function overviewXRange() {
if (!overviewPlot) return { min: DATA.times[0], max: DATA.times[DATA.times.length - 1] };
const s = overviewPlot.scales.x;
return { min: s.min, max: s.max };
}
function setOverviewXRange(min, max) {
const t0 = DATA.times[0];
const t1 = DATA.times[DATA.times.length - 1];
overviewPlot.setScale("x", {
min: Math.max(t0, min),
max: Math.min(t1, max),
});
}
function fitOverview() {
setOverviewXRange(DATA.times[0], DATA.times[DATA.times.length - 1]);
}
function zoomOverview(factor) {
const { min, max } = overviewXRange();
const mid = (min + max) / 2;
const half = Math.max((max - min) * factor / 2, 3600);
setOverviewXRange(mid - half, mid + half);
}
function buildOverview(keepRange) {
const prev = keepRange ? overviewXRange() : null;
if (overviewPlot) { overviewPlot.destroy(); overviewPlot = null; }
const opts = {
width: document.getElementById("overview").clientWidth,
height: 480,
scales: { x: { time: true } },
axes: [
{},
{ values: (u, vals) => vals.map(v => fmtPrice(v)) },
],
series: [
{},
{ label: "종가", stroke: "#1565c0", width: 1 },
],
cursor: { drag: { x: true, y: false, setScale: true } },
hooks: {
draw: [(u) => drawMarkers(u, DATA.buy_markers, DATA.sell_markers)],
},
};
overviewPlot = new uPlot(opts, [DATA.times, DATA.close], document.getElementById("overview"));
if (prev && keepRange) setOverviewXRange(prev.min, prev.max);
else fitOverview();
}
function sliceLastDays(days) {
const cutoff = DATA.times[DATA.times.length - 1] - days * 86400;
let start = 0;
for (let i = DATA.times.length - 1; i >= 0; i--) {
if (DATA.times[i] < cutoff) { start = i + 1; break; }
}
return { start, end: DATA.times.length };
}
function buildDetailCandles(startIdx, endIdx) {
lastDetailStart = startIdx;
const end = endIdx || DATA.times.length;
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
const wrap = document.getElementById("detail");
wrap.innerHTML = "";
detailChart = LightweightCharts.createChart(wrap, {
layout: { background: { color: "#fff" }, textColor: "#333" },
grid: { vertLines: { color: "#eee" }, horzLines: { color: "#eee" } },
timeScale: { timeVisible: true, secondsVisible: false },
width: wrap.clientWidth,
height: 360,
});
detailSeries = detailChart.addCandlestickSeries({
upColor: "#c62828", downColor: "#1565c0",
borderUpColor: "#c62828", borderDownColor: "#1565c0",
wickUpColor: "#c62828", wickDownColor: "#1565c0",
});
const candles = [];
for (let i = startIdx; i < end; i++) {
candles.push({
time: DATA.times[i],
open: DATA.open[i], high: DATA.high[i],
low: DATA.low[i], close: DATA.close[i],
});
}
detailSeries.setData(candles);
const t0 = DATA.times[startIdx];
const t1 = DATA.times[end - 1];
const markers = [];
if (showMarkers) {
DATA.buy_markers.forEach(m => {
if (m.time >= t0 && m.time <= t1) markers.push({
time: m.time, position: "belowBar",
color: m.signal_type === "breakout" ? "#ef6c00"
: m.signal_type === "div_bull" ? "#7b1fa2" : "#2e7d32",
shape: "arrowUp",
text: "B" + m.marker_id + (m.signal_type === "pullback" ? "*"
: m.signal_type === "breakout" ? "^" : m.signal_type === "div_bull" ? "d" : ""),
});
});
DATA.sell_markers.forEach(m => {
if (m.time >= t0 && m.time <= t1) markers.push({
time: m.time, position: "aboveBar",
color: m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828",
shape: "arrowDown",
text: "S" + m.marker_id + (m.signal_type === "div_bear" ? "d" : ""),
});
});
}
markers.sort((a, b) => a.time - b.time);
detailSeries.setMarkers(markers);
detailChart.timeScale().fitContent();
}
function setActive(btnId) {
document.querySelectorAll(".btn-period").forEach(b => b.classList.remove("active"));
const el = document.getElementById(btnId);
if (el) el.classList.add("active");
}
function goHome() {
currentMode = "overview";
setActive("btn-all");
document.getElementById("overview").style.display = "block";
if (!overviewPlot) buildOverview(false);
else fitOverview();
document.getElementById("status").textContent =
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
}
function nearestSellAfter(buyTime) {
let best = null;
for (const s of DATA.sell_markers) {
if (s.time >= buyTime && (!best || s.time < best.time)) best = s;
}
return best || DATA.sell_markers[DATA.sell_markers.length - 1];
}
function jumpToLeg(idx) {
const total = DATA.buy_markers.length;
if (!total) return;
currentLegIdx = Math.max(0, Math.min(idx, total - 1));
updateLegInfo();
const buy = DATA.buy_markers[currentLegIdx];
const sell = nearestSellAfter(buy.time);
const span = sell ? Math.max(sell.time - buy.time, 86400) : 86400 * 3;
const pad = span * 0.4;
const vmin = buy.time - pad;
const vmax = (sell ? sell.time : buy.time) + pad;
currentMode = "overview";
setActive("btn-all");
document.getElementById("overview").style.display = "block";
if (!overviewPlot) buildOverview(false);
setOverviewXRange(vmin, vmax);
let start = 0;
for (let i = 0; i < DATA.times.length; i++) {
if (DATA.times[i] >= vmin) { start = i; break; }
}
let end = DATA.times.length;
for (let i = DATA.times.length - 1; i >= 0; i--) {
if (DATA.times[i] <= vmax) { end = i + 1; break; }
}
const buyLabel = buy.signal_type === "pullback" ? "눌림목 매수"
: buy.signal_type === "breakout" ? "돌파 매수"
: buy.signal_type === "div_bull" ? "다이버전스 매수" : "스윙 매수";
document.getElementById("detail-title").textContent =
`B${buy.marker_id} ${buyLabel}${new Date(buy.time * 1000).toLocaleString("ko-KR")}`;
buildDetailCandles(start, end);
const sellText = sell ? ` → 매도 ${fmtPrice(sell.price)}` : "";
document.getElementById("status").textContent =
`B${buy.marker_id} ${buyLabel} ${fmtPrice(buy.price)}${sellText}`;
}
function showPeriod(days, btnId, label) {
currentMode = "detail";
setActive(btnId);
detailVisible = true;
document.getElementById("btn-toggle-detail").textContent = "상세 숨김";
const { start } = sliceLastDays(days);
document.getElementById("detail-title").textContent =
`${label} 3분봉 캔들 (${(DATA.times.length - start).toLocaleString()}봉)`;
buildDetailCandles(start);
document.getElementById("overview").style.display = "block";
if (!overviewPlot) buildOverview(false);
const t0 = DATA.times[start];
setOverviewXRange(t0, DATA.times[DATA.times.length - 1]);
document.getElementById("status").textContent = `${label} 구간 표시`;
}
function applyZoom(factor) {
if (currentMode === "detail" && detailChart) {
const ts = detailChart.timeScale();
const r = ts.getVisibleLogicalRange();
if (!r) return;
const mid = (r.from + r.to) / 2;
const half = Math.max((r.to - r.from) * factor / 2, 10);
ts.setVisibleLogicalRange({ from: mid - half, to: mid + half });
} else if (overviewPlot) {
zoomOverview(factor);
}
}
function applyFit() {
if (currentMode === "detail" && detailChart) {
detailChart.timeScale().fitContent();
} else if (overviewPlot) {
fitOverview();
}
}
function init() {
DATA = window.CHART_DATA;
if (!DATA) throw new Error("ground_truth_chart_data.js 없음");
const m = DATA.meta;
const chartDays = m.chart_lookback_days || m.lookback_days;
const gtDays = m.gt_lookback_days || m.lookback_days;
const chartLabel = chartDays >= 365 ? `${Math.round(chartDays / 365)}` : `${chartDays}`;
const gtLabel = gtDays >= 365 ? `${Math.round(gtDays / 365)}` : `${gtDays}`;
document.getElementById("title").textContent =
`${m.symbol} Ground Truth (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}`;
document.getElementById("btn-all").textContent = `전체 ${chartLabel}`;
const chartFrom = m.chart_data_from || m.data_from;
const chartTo = m.chart_data_to || m.data_to;
document.getElementById("meta").textContent =
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | 매수 ${DATA.buy_markers.length} / 매도 ${DATA.sell_markers.length} (${gtLabel}) | B*=눌림 B^=돌파 Bd/Sd=다이버전스`;
updateLegInfo();
document.getElementById("status").textContent =
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
buildOverview(false);
document.getElementById("btn-home").onclick = goHome;
document.getElementById("btn-prev-leg").onclick = () => jumpToLeg(currentLegIdx - 1);
document.getElementById("btn-next-leg").onclick = () => jumpToLeg(currentLegIdx + 1);
document.getElementById("btn-all").onclick = goHome;
document.getElementById("btn-365d").onclick = () => showPeriod(365, "btn-365d", "최근 1년");
document.getElementById("btn-30d").onclick = () => showPeriod(30, "btn-30d", "최근 30일");
document.getElementById("btn-7d").onclick = () => showPeriod(7, "btn-7d", "최근 7일");
document.getElementById("btn-3d").onclick = () => showPeriod(3, "btn-3d", "최근 3일");
document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6);
document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4);
document.getElementById("btn-fit").onclick = applyFit;
document.getElementById("btn-markers").onclick = () => {
showMarkers = !showMarkers;
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";
if (overviewPlot) buildOverview(true);
if (detailChart) buildDetailCandles(lastDetailStart);
};
document.getElementById("btn-toggle-detail").onclick = () => {
detailVisible = !detailVisible;
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
document.getElementById("btn-toggle-detail").textContent = detailVisible ? "상세 숨김" : "상세 패널";
if (detailVisible && !detailChart) {
const { start } = sliceLastDays(7);
buildDetailCandles(start);
}
};
document.getElementById("overview").addEventListener("dblclick", () => {
if (currentMode === "overview") fitOverview();
});
window.addEventListener("resize", () => {
if (overviewPlot) buildOverview(true);
});
}
try { init(); } catch (err) {
document.getElementById("status").textContent = "데이터 로드 실패: " + err;
}
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -27,6 +27,7 @@
#detail-wrap { margin: 0 24px 12px; display: none; }
#detail-wrap h2 { font-size: 15px; margin: 0 0 8px; }
#detail { height: 360px; background: #fff; border: 1px solid #ddd; overflow: visible; }
</style>
</head>
<body>
@@ -34,6 +35,7 @@
<h1 id="title">Ground Truth Chart</h1>
<div class="meta" id="meta"></div>
</header>
<div class="toolbar">
<div class="toolbar-group">
<button id="btn-home" class="home" title="전체 2년 화면으로 복귀"></button>
@@ -117,7 +119,11 @@
el.textContent = `타점 ${currentLegIdx + 1} / ${total}`;
}
const MARKER_FONT = "bold 18px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif";
const SIM_START_COLOR = "#7b1fa2";
const ARROW_HALF = 6;
const ARROW_HEIGHT = 8;
const LABEL_OFFSET_X = 8;
function markerSuffix(signalType) {
if (signalType === "pullback") return "*";
@@ -126,37 +132,85 @@
return "";
}
function drawMarkerLabel(ctx, label, x, y, color, up) {
ctx.font = MARKER_FONT;
const lx = x + 10;
const ly = y + (up ? 28 : -20);
ctx.lineWidth = 3;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.85)";
ctx.strokeText(label, lx, ly);
ctx.fillStyle = color;
ctx.fillText(label, lx, ly);
function markerChartPrice(m) {
if (m.chart_price != null) return m.chart_price;
let lo = 0;
let hi = DATA.times.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (DATA.times[mid] < m.time) lo = mid + 1;
else hi = mid;
}
return DATA.close[lo];
}
function drawMarkerLabel(ctx, label, x, labelY, color) {
ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle";
ctx.lineWidth = 2;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY);
ctx.fillStyle = color;
ctx.fillText(label, lx, labelY);
ctx.textBaseline = "alphabetic";
}
function drawTriangleOnLine(ctx, x, lineY, up, color) {
ctx.fillStyle = color;
ctx.beginPath();
if (up) {
ctx.moveTo(x - ARROW_HALF, lineY);
ctx.lineTo(x + ARROW_HALF, lineY);
ctx.lineTo(x, lineY + ARROW_HEIGHT);
} else {
ctx.moveTo(x - ARROW_HALF, lineY);
ctx.lineTo(x + ARROW_HALF, lineY);
ctx.lineTo(x, lineY - ARROW_HEIGHT);
}
ctx.closePath();
ctx.fill();
}
function drawSimStartMarker(u, marker) {
if (!marker) return;
const ctx = u.ctx;
const x = u.valToPos(marker.time, "x", true);
const lineY = u.valToPos(markerChartPrice(marker), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const color = SIM_START_COLOR;
drawTriangleOnLine(ctx, x, lineY, false, color);
const label = marker.label || "거래시작";
ctx.font = MARKER_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const labelY = lineY - ARROW_HEIGHT - 6;
ctx.lineWidth = 2;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY);
ctx.fillStyle = color;
ctx.fillText(label, x, labelY);
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
}
const LABEL_GAP = 12;
function drawMarkers(u, buys, sells) {
if (!showMarkers) return;
const ctx = u.ctx;
const drawOne = (m, color, up) => {
const x = u.valToPos(m.time, "x", true);
const y = u.valToPos(m.price, "y", true);
const lineY = u.valToPos(markerChartPrice(m), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
ctx.fillStyle = color;
ctx.beginPath();
const s = 8;
const gap = 12;
if (up) {
ctx.moveTo(x, y + gap); ctx.lineTo(x - s, y + gap + 16); ctx.lineTo(x + s, y + gap + 16);
} else {
ctx.moveTo(x, y - gap); ctx.lineTo(x - s, y - gap - 16); ctx.lineTo(x + s, y - gap - 16);
}
ctx.closePath(); ctx.fill();
drawTriangleOnLine(ctx, x, lineY, up, color);
const label = (up ? "B" : "S") + m.marker_id + markerSuffix(m.signal_type);
drawMarkerLabel(ctx, label, x, y, color, up);
const labelY = up
? lineY + ARROW_HEIGHT + LABEL_GAP
: lineY - ARROW_HEIGHT - LABEL_GAP;
drawMarkerLabel(ctx, label, x, labelY, color);
};
buys.forEach(m => {
let color = "#2e7d32";
@@ -203,7 +257,7 @@
const opts = {
width: document.getElementById("overview").clientWidth,
height: 480,
padding: [14, 10, 14, 10],
padding: [28, 10, 28, 10],
scales: { x: { time: true } },
axes: [
{ gap: 6 },
@@ -221,7 +275,10 @@
],
cursor: { drag: { x: true, y: false, setScale: true } },
hooks: {
draw: [(u) => drawMarkers(u, DATA.buy_markers, DATA.sell_markers)],
draw: [(u) => {
drawSimStartMarker(u, DATA.sim_start_marker);
drawMarkers(u, DATA.buy_markers, DATA.sell_markers);
}],
},
};
overviewPlot = new uPlot(opts, [DATA.times, DATA.close], document.getElementById("overview"));
@@ -276,13 +333,21 @@
const t0 = DATA.times[startIdx];
const t1 = DATA.times[end - 1];
const markers = [];
if (DATA.sim_start_marker) {
const sm = DATA.sim_start_marker;
if (sm.time >= t0 && sm.time <= t1) markers.push({
time: sm.time, position: "aboveBar",
color: SIM_START_COLOR, shape: "arrowDown", size: 3,
text: sm.label || "거래시작",
});
}
if (showMarkers) {
DATA.buy_markers.forEach(m => {
if (m.time >= t0 && m.time <= t1) markers.push({
time: m.time, position: "belowBar",
color: m.signal_type === "breakout" ? "#ef6c00"
: m.signal_type === "div_bull" ? "#7b1fa2" : "#2e7d32",
shape: "arrowUp", size: 3,
shape: "arrowUp", size: 5,
text: "B" + m.marker_id + markerSuffix(m.signal_type),
});
});
@@ -290,7 +355,7 @@
if (m.time >= t0 && m.time <= t1) markers.push({
time: m.time, position: "aboveBar",
color: m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828",
shape: "arrowDown", size: 3,
shape: "arrowDown", size: 5,
text: "S" + m.marker_id + markerSuffix(m.signal_type),
});
});
@@ -398,17 +463,21 @@
}
}
function init() {
DATA = window.CHART_DATA;
if (!DATA) throw new Error("차트 데이터 JS 없음");
const m = DATA.meta;
const simMode = !!DATA.sim_pnl;
const chartDays = m.chart_lookback_days || m.lookback_days;
const gtDays = m.gt_lookback_days || m.lookback_days;
const chartLabel = chartDays >= 365 ? `${Math.round(chartDays / 365)}` : `${chartDays}`;
const gtLabel = gtDays >= 365 ? `${Math.round(gtDays / 365)}` : `${gtDays}`;
const tier = m.chart_tier ? ` ${m.chart_tier.toUpperCase()}` : "";
const simSuffix = simMode ? " · 2단계 시뮬" : "";
document.getElementById("title").textContent =
`${m.symbol} Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}`;
`${m.symbol} Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}${simSuffix}`;
document.getElementById("btn-all").textContent = `전체 ${chartLabel}`;
const chartFrom = m.chart_data_from || m.data_from;
const chartTo = m.chart_data_to || m.data_to;
@@ -418,8 +487,13 @@
: tierKey === "v2"
? "B/S=스윙 B*=눌림목"
: "B/S=스윙 B*=눌림 B^=돌파 Bd/Sd=다이버전스";
const markerRange = simMode && m.sim_period_from
? `체결 ${DATA.buy_markers.length}/${DATA.sell_markers.length} · ${m.sim_period_from.slice(0, 16)} ~ ${(m.sim_period_to || chartTo).slice(0, 16)}`
: gtLabel;
const legendExtra = simMode ? " | ▼보라=거래시작" : "";
document.getElementById("meta").textContent =
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | 매수 ${DATA.buy_markers.length} / 매도 ${DATA.sell_markers.length} (${gtLabel}) | ${legend}`;
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | 매수 ${DATA.buy_markers.length} / 매도 ${DATA.sell_markers.length} (${markerRange}) | ${legend}${legendExtra}`;
if (simMode) renderSimPanel();
updateLegInfo();
document.getElementById("status").textContent =
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;

File diff suppressed because one or more lines are too long

View File

@@ -27,6 +27,7 @@
#detail-wrap { margin: 0 24px 12px; display: none; }
#detail-wrap h2 { font-size: 15px; margin: 0 0 8px; }
#detail { height: 360px; background: #fff; border: 1px solid #ddd; overflow: visible; }
</style>
</head>
<body>
@@ -34,6 +35,7 @@
<h1 id="title">Ground Truth Chart</h1>
<div class="meta" id="meta"></div>
</header>
<div class="toolbar">
<div class="toolbar-group">
<button id="btn-home" class="home" title="전체 2년 화면으로 복귀"></button>
@@ -117,7 +119,11 @@
el.textContent = `타점 ${currentLegIdx + 1} / ${total}`;
}
const MARKER_FONT = "bold 18px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif";
const SIM_START_COLOR = "#7b1fa2";
const ARROW_HALF = 6;
const ARROW_HEIGHT = 8;
const LABEL_OFFSET_X = 8;
function markerSuffix(signalType) {
if (signalType === "pullback") return "*";
@@ -126,37 +132,85 @@
return "";
}
function drawMarkerLabel(ctx, label, x, y, color, up) {
ctx.font = MARKER_FONT;
const lx = x + 10;
const ly = y + (up ? 28 : -20);
ctx.lineWidth = 3;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.85)";
ctx.strokeText(label, lx, ly);
ctx.fillStyle = color;
ctx.fillText(label, lx, ly);
function markerChartPrice(m) {
if (m.chart_price != null) return m.chart_price;
let lo = 0;
let hi = DATA.times.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (DATA.times[mid] < m.time) lo = mid + 1;
else hi = mid;
}
return DATA.close[lo];
}
function drawMarkerLabel(ctx, label, x, labelY, color) {
ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle";
ctx.lineWidth = 2;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY);
ctx.fillStyle = color;
ctx.fillText(label, lx, labelY);
ctx.textBaseline = "alphabetic";
}
function drawTriangleOnLine(ctx, x, lineY, up, color) {
ctx.fillStyle = color;
ctx.beginPath();
if (up) {
ctx.moveTo(x - ARROW_HALF, lineY);
ctx.lineTo(x + ARROW_HALF, lineY);
ctx.lineTo(x, lineY + ARROW_HEIGHT);
} else {
ctx.moveTo(x - ARROW_HALF, lineY);
ctx.lineTo(x + ARROW_HALF, lineY);
ctx.lineTo(x, lineY - ARROW_HEIGHT);
}
ctx.closePath();
ctx.fill();
}
function drawSimStartMarker(u, marker) {
if (!marker) return;
const ctx = u.ctx;
const x = u.valToPos(marker.time, "x", true);
const lineY = u.valToPos(markerChartPrice(marker), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const color = SIM_START_COLOR;
drawTriangleOnLine(ctx, x, lineY, false, color);
const label = marker.label || "거래시작";
ctx.font = MARKER_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const labelY = lineY - ARROW_HEIGHT - 6;
ctx.lineWidth = 2;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY);
ctx.fillStyle = color;
ctx.fillText(label, x, labelY);
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
}
const LABEL_GAP = 12;
function drawMarkers(u, buys, sells) {
if (!showMarkers) return;
const ctx = u.ctx;
const drawOne = (m, color, up) => {
const x = u.valToPos(m.time, "x", true);
const y = u.valToPos(m.price, "y", true);
const lineY = u.valToPos(markerChartPrice(m), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
ctx.fillStyle = color;
ctx.beginPath();
const s = 8;
const gap = 12;
if (up) {
ctx.moveTo(x, y + gap); ctx.lineTo(x - s, y + gap + 16); ctx.lineTo(x + s, y + gap + 16);
} else {
ctx.moveTo(x, y - gap); ctx.lineTo(x - s, y - gap - 16); ctx.lineTo(x + s, y - gap - 16);
}
ctx.closePath(); ctx.fill();
drawTriangleOnLine(ctx, x, lineY, up, color);
const label = (up ? "B" : "S") + m.marker_id + markerSuffix(m.signal_type);
drawMarkerLabel(ctx, label, x, y, color, up);
const labelY = up
? lineY + ARROW_HEIGHT + LABEL_GAP
: lineY - ARROW_HEIGHT - LABEL_GAP;
drawMarkerLabel(ctx, label, x, labelY, color);
};
buys.forEach(m => {
let color = "#2e7d32";
@@ -203,7 +257,7 @@
const opts = {
width: document.getElementById("overview").clientWidth,
height: 480,
padding: [14, 10, 14, 10],
padding: [28, 10, 28, 10],
scales: { x: { time: true } },
axes: [
{ gap: 6 },
@@ -221,7 +275,10 @@
],
cursor: { drag: { x: true, y: false, setScale: true } },
hooks: {
draw: [(u) => drawMarkers(u, DATA.buy_markers, DATA.sell_markers)],
draw: [(u) => {
drawSimStartMarker(u, DATA.sim_start_marker);
drawMarkers(u, DATA.buy_markers, DATA.sell_markers);
}],
},
};
overviewPlot = new uPlot(opts, [DATA.times, DATA.close], document.getElementById("overview"));
@@ -276,13 +333,21 @@
const t0 = DATA.times[startIdx];
const t1 = DATA.times[end - 1];
const markers = [];
if (DATA.sim_start_marker) {
const sm = DATA.sim_start_marker;
if (sm.time >= t0 && sm.time <= t1) markers.push({
time: sm.time, position: "aboveBar",
color: SIM_START_COLOR, shape: "arrowDown", size: 3,
text: sm.label || "거래시작",
});
}
if (showMarkers) {
DATA.buy_markers.forEach(m => {
if (m.time >= t0 && m.time <= t1) markers.push({
time: m.time, position: "belowBar",
color: m.signal_type === "breakout" ? "#ef6c00"
: m.signal_type === "div_bull" ? "#7b1fa2" : "#2e7d32",
shape: "arrowUp", size: 3,
shape: "arrowUp", size: 5,
text: "B" + m.marker_id + markerSuffix(m.signal_type),
});
});
@@ -290,7 +355,7 @@
if (m.time >= t0 && m.time <= t1) markers.push({
time: m.time, position: "aboveBar",
color: m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828",
shape: "arrowDown", size: 3,
shape: "arrowDown", size: 5,
text: "S" + m.marker_id + markerSuffix(m.signal_type),
});
});
@@ -398,17 +463,21 @@
}
}
function init() {
DATA = window.CHART_DATA;
if (!DATA) throw new Error("차트 데이터 JS 없음");
const m = DATA.meta;
const simMode = !!DATA.sim_pnl;
const chartDays = m.chart_lookback_days || m.lookback_days;
const gtDays = m.gt_lookback_days || m.lookback_days;
const chartLabel = chartDays >= 365 ? `${Math.round(chartDays / 365)}` : `${chartDays}`;
const gtLabel = gtDays >= 365 ? `${Math.round(gtDays / 365)}` : `${gtDays}`;
const tier = m.chart_tier ? ` ${m.chart_tier.toUpperCase()}` : "";
const simSuffix = simMode ? " · 2단계 시뮬" : "";
document.getElementById("title").textContent =
`${m.symbol} Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}`;
`${m.symbol} Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}${simSuffix}`;
document.getElementById("btn-all").textContent = `전체 ${chartLabel}`;
const chartFrom = m.chart_data_from || m.data_from;
const chartTo = m.chart_data_to || m.data_to;
@@ -418,8 +487,13 @@
: tierKey === "v2"
? "B/S=스윙 B*=눌림목"
: "B/S=스윙 B*=눌림 B^=돌파 Bd/Sd=다이버전스";
const markerRange = simMode && m.sim_period_from
? `체결 ${DATA.buy_markers.length}/${DATA.sell_markers.length} · ${m.sim_period_from.slice(0, 16)} ~ ${(m.sim_period_to || chartTo).slice(0, 16)}`
: gtLabel;
const legendExtra = simMode ? " | ▼보라=거래시작" : "";
document.getElementById("meta").textContent =
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | 매수 ${DATA.buy_markers.length} / 매도 ${DATA.sell_markers.length} (${gtLabel}) | ${legend}`;
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | 매수 ${DATA.buy_markers.length} / 매도 ${DATA.sell_markers.length} (${markerRange}) | ${legend}${legendExtra}`;
if (simMode) renderSimPanel();
updateLegInfo();
document.getElementById("status").textContent =
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;

File diff suppressed because one or more lines are too long

View File

@@ -27,6 +27,7 @@
#detail-wrap { margin: 0 24px 12px; display: none; }
#detail-wrap h2 { font-size: 15px; margin: 0 0 8px; }
#detail { height: 360px; background: #fff; border: 1px solid #ddd; overflow: visible; }
</style>
</head>
<body>
@@ -34,6 +35,7 @@
<h1 id="title">Ground Truth Chart</h1>
<div class="meta" id="meta"></div>
</header>
<div class="toolbar">
<div class="toolbar-group">
<button id="btn-home" class="home" title="전체 2년 화면으로 복귀"></button>
@@ -117,7 +119,11 @@
el.textContent = `타점 ${currentLegIdx + 1} / ${total}`;
}
const MARKER_FONT = "bold 18px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif";
const SIM_START_COLOR = "#7b1fa2";
const ARROW_HALF = 6;
const ARROW_HEIGHT = 8;
const LABEL_OFFSET_X = 8;
function markerSuffix(signalType) {
if (signalType === "pullback") return "*";
@@ -126,37 +132,85 @@
return "";
}
function drawMarkerLabel(ctx, label, x, y, color, up) {
ctx.font = MARKER_FONT;
const lx = x + 10;
const ly = y + (up ? 28 : -20);
ctx.lineWidth = 3;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.85)";
ctx.strokeText(label, lx, ly);
ctx.fillStyle = color;
ctx.fillText(label, lx, ly);
function markerChartPrice(m) {
if (m.chart_price != null) return m.chart_price;
let lo = 0;
let hi = DATA.times.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (DATA.times[mid] < m.time) lo = mid + 1;
else hi = mid;
}
return DATA.close[lo];
}
function drawMarkerLabel(ctx, label, x, labelY, color) {
ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle";
ctx.lineWidth = 2;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY);
ctx.fillStyle = color;
ctx.fillText(label, lx, labelY);
ctx.textBaseline = "alphabetic";
}
function drawTriangleOnLine(ctx, x, lineY, up, color) {
ctx.fillStyle = color;
ctx.beginPath();
if (up) {
ctx.moveTo(x - ARROW_HALF, lineY);
ctx.lineTo(x + ARROW_HALF, lineY);
ctx.lineTo(x, lineY + ARROW_HEIGHT);
} else {
ctx.moveTo(x - ARROW_HALF, lineY);
ctx.lineTo(x + ARROW_HALF, lineY);
ctx.lineTo(x, lineY - ARROW_HEIGHT);
}
ctx.closePath();
ctx.fill();
}
function drawSimStartMarker(u, marker) {
if (!marker) return;
const ctx = u.ctx;
const x = u.valToPos(marker.time, "x", true);
const lineY = u.valToPos(markerChartPrice(marker), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const color = SIM_START_COLOR;
drawTriangleOnLine(ctx, x, lineY, false, color);
const label = marker.label || "거래시작";
ctx.font = MARKER_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const labelY = lineY - ARROW_HEIGHT - 6;
ctx.lineWidth = 2;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY);
ctx.fillStyle = color;
ctx.fillText(label, x, labelY);
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
}
const LABEL_GAP = 12;
function drawMarkers(u, buys, sells) {
if (!showMarkers) return;
const ctx = u.ctx;
const drawOne = (m, color, up) => {
const x = u.valToPos(m.time, "x", true);
const y = u.valToPos(m.price, "y", true);
const lineY = u.valToPos(markerChartPrice(m), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
ctx.fillStyle = color;
ctx.beginPath();
const s = 8;
const gap = 12;
if (up) {
ctx.moveTo(x, y + gap); ctx.lineTo(x - s, y + gap + 16); ctx.lineTo(x + s, y + gap + 16);
} else {
ctx.moveTo(x, y - gap); ctx.lineTo(x - s, y - gap - 16); ctx.lineTo(x + s, y - gap - 16);
}
ctx.closePath(); ctx.fill();
drawTriangleOnLine(ctx, x, lineY, up, color);
const label = (up ? "B" : "S") + m.marker_id + markerSuffix(m.signal_type);
drawMarkerLabel(ctx, label, x, y, color, up);
const labelY = up
? lineY + ARROW_HEIGHT + LABEL_GAP
: lineY - ARROW_HEIGHT - LABEL_GAP;
drawMarkerLabel(ctx, label, x, labelY, color);
};
buys.forEach(m => {
let color = "#2e7d32";
@@ -203,7 +257,7 @@
const opts = {
width: document.getElementById("overview").clientWidth,
height: 480,
padding: [14, 10, 14, 10],
padding: [28, 10, 28, 10],
scales: { x: { time: true } },
axes: [
{ gap: 6 },
@@ -221,7 +275,10 @@
],
cursor: { drag: { x: true, y: false, setScale: true } },
hooks: {
draw: [(u) => drawMarkers(u, DATA.buy_markers, DATA.sell_markers)],
draw: [(u) => {
drawSimStartMarker(u, DATA.sim_start_marker);
drawMarkers(u, DATA.buy_markers, DATA.sell_markers);
}],
},
};
overviewPlot = new uPlot(opts, [DATA.times, DATA.close], document.getElementById("overview"));
@@ -276,13 +333,21 @@
const t0 = DATA.times[startIdx];
const t1 = DATA.times[end - 1];
const markers = [];
if (DATA.sim_start_marker) {
const sm = DATA.sim_start_marker;
if (sm.time >= t0 && sm.time <= t1) markers.push({
time: sm.time, position: "aboveBar",
color: SIM_START_COLOR, shape: "arrowDown", size: 3,
text: sm.label || "거래시작",
});
}
if (showMarkers) {
DATA.buy_markers.forEach(m => {
if (m.time >= t0 && m.time <= t1) markers.push({
time: m.time, position: "belowBar",
color: m.signal_type === "breakout" ? "#ef6c00"
: m.signal_type === "div_bull" ? "#7b1fa2" : "#2e7d32",
shape: "arrowUp", size: 3,
shape: "arrowUp", size: 5,
text: "B" + m.marker_id + markerSuffix(m.signal_type),
});
});
@@ -290,7 +355,7 @@
if (m.time >= t0 && m.time <= t1) markers.push({
time: m.time, position: "aboveBar",
color: m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828",
shape: "arrowDown", size: 3,
shape: "arrowDown", size: 5,
text: "S" + m.marker_id + markerSuffix(m.signal_type),
});
});
@@ -398,17 +463,21 @@
}
}
function init() {
DATA = window.CHART_DATA;
if (!DATA) throw new Error("차트 데이터 JS 없음");
const m = DATA.meta;
const simMode = !!DATA.sim_pnl;
const chartDays = m.chart_lookback_days || m.lookback_days;
const gtDays = m.gt_lookback_days || m.lookback_days;
const chartLabel = chartDays >= 365 ? `${Math.round(chartDays / 365)}` : `${chartDays}`;
const gtLabel = gtDays >= 365 ? `${Math.round(gtDays / 365)}` : `${gtDays}`;
const tier = m.chart_tier ? ` ${m.chart_tier.toUpperCase()}` : "";
const simSuffix = simMode ? " · 2단계 시뮬" : "";
document.getElementById("title").textContent =
`${m.symbol} Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}`;
`${m.symbol} Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}${simSuffix}`;
document.getElementById("btn-all").textContent = `전체 ${chartLabel}`;
const chartFrom = m.chart_data_from || m.data_from;
const chartTo = m.chart_data_to || m.data_to;
@@ -418,8 +487,13 @@
: tierKey === "v2"
? "B/S=스윙 B*=눌림목"
: "B/S=스윙 B*=눌림 B^=돌파 Bd/Sd=다이버전스";
const markerRange = simMode && m.sim_period_from
? `체결 ${DATA.buy_markers.length}/${DATA.sell_markers.length} · ${m.sim_period_from.slice(0, 16)} ~ ${(m.sim_period_to || chartTo).slice(0, 16)}`
: gtLabel;
const legendExtra = simMode ? " | ▼보라=거래시작" : "";
document.getElementById("meta").textContent =
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | 매수 ${DATA.buy_markers.length} / 매도 ${DATA.sell_markers.length} (${gtLabel}) | ${legend}`;
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | 매수 ${DATA.buy_markers.length} / 매도 ${DATA.sell_markers.length} (${markerRange}) | ${legend}${legendExtra}`;
if (simMode) renderSimPanel();
updateLegInfo();
document.getElementById("status").textContent =
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;

File diff suppressed because one or more lines are too long