feat: Ground Truth 2단계 1년 수익 시뮬 및 sim 차트 추가

분할 매수/매도 PnL 시뮬, 체결 타점·거래시작 마커, x축 unix 변환 수정.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-09 10:06:43 +09:00
parent 6f008012c2
commit 75399ce79c
14 changed files with 16989 additions and 41 deletions

View File

@@ -47,6 +47,12 @@ GROUND_TRUTH_CHART_V1_FILE=docs/02_ground_truth/ground_truth_chart_v1.html
GROUND_TRUTH_CHART_V2_FILE=docs/02_ground_truth/ground_truth_chart_v2.html GROUND_TRUTH_CHART_V2_FILE=docs/02_ground_truth/ground_truth_chart_v2.html
GROUND_TRUTH_CHART_V3_FILE=docs/02_ground_truth/ground_truth_chart_v3.html GROUND_TRUTH_CHART_V3_FILE=docs/02_ground_truth/ground_truth_chart_v3.html
# --- Ground Truth 2단계 (수익 시뮬, 최근 1년) ---
GT_SIM_LOOKBACK_DAYS=365
GROUND_TRUTH_CHART_SIM_V1_FILE=docs/02_ground_truth/ground_truth_chart_sim_v1.html
GROUND_TRUTH_CHART_SIM_V2_FILE=docs/02_ground_truth/ground_truth_chart_sim_v2.html
GROUND_TRUTH_CHART_SIM_V3_FILE=docs/02_ground_truth/ground_truth_chart_sim_v3.html
# --- 매매 기법 (2단계) --- # --- 매매 기법 (2단계) ---
TECHNIQUES_DIR=data/techniques TECHNIQUES_DIR=data/techniques
ANALYSIS_REPORT_JSON=docs/03_analysis/comparison_report.json ANALYSIS_REPORT_JSON=docs/03_analysis/comparison_report.json

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,609 @@
<!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_sim_v1_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; overflow: visible; }
#overview .u-wrap, #overview .uplot { overflow: visible !important; }
#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; }
.sim-panel { margin: 12px 24px 0; padding: 16px 20px; background: #fff; border: 1px solid #ddd; border-radius: 4px; }
.sim-panel h2 { margin: 0 0 12px; font-size: 16px; }
.sim-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.sim-card { padding: 10px 12px; background: #fafafa; border: 1px solid #eee; border-radius: 4px; }
.sim-card .label { font-size: 12px; color: #777; margin-bottom: 4px; }
.sim-card .value { font-size: 18px; font-weight: bold; }
.sim-card .value.positive { color: #2e7d32; }
.sim-card .value.negative { color: #c62828; }
.sim-note { margin-top: 10px; font-size: 12px; color: #666; line-height: 1.5; }
#trade-table-wrap { margin: 12px 24px 0; background: #fff; border: 1px solid #ddd; border-radius: 4px; overflow: hidden; }
#trade-table-wrap summary { padding: 10px 16px; cursor: pointer; font-size: 14px; background: #fafafa; border-bottom: 1px solid #eee; }
.trade-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.trade-table th, .trade-table td { padding: 6px 10px; border-bottom: 1px solid #eee; text-align: right; }
.trade-table th:first-child, .trade-table td:first-child { text-align: left; }
.trade-table th { background: #f5f5f5; position: sticky; top: 0; }
.trade-table tr.skipped td { color: #999; }
.trade-scroll { max-height: 240px; overflow: auto; }
</style>
</head>
<body>
<header>
<h1 id="title">Ground Truth Chart</h1>
<div class="meta" id="meta"></div>
</header>
<section class="sim-panel" id="sim-panel">
<h2>2단계 수익 시뮬레이션 (최근 1년 · 초기 40만원)</h2>
<div class="sim-grid" id="sim-grid"></div>
<div class="sim-note" id="sim-note"></div>
</section>
<details id="trade-table-wrap">
<summary>체결 내역 (<span id="trade-count">0</span>건)</summary>
<div class="trade-scroll">
<table class="trade-table">
<thead>
<tr>
<th>시각</th><th>구분</th><th>유형</th><th>가격</th><th>주문금액</th>
<th>수수료</th><th>현금</th><th>코인</th><th>비고</th>
</tr>
</thead>
<tbody id="trade-body"></tbody>
</table>
</div>
</details>
<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;
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
let axisMeasureCtx = null;
function fmtPrice(v) {
return Math.round(v).toLocaleString("ko-KR");
}
function measureTextWidth(text, font) {
if (!axisMeasureCtx) {
const c = document.createElement("canvas");
axisMeasureCtx = c.getContext("2d");
}
axisMeasureCtx.font = font;
return axisMeasureCtx.measureText(text).width;
}
function yAxisLabelWidth() {
const vals = DATA.close;
if (!vals || !vals.length) return 88;
const samples = new Set([vals[0], vals[vals.length - 1]]);
let lo = vals[0], hi = vals[0];
for (let i = 1; i < vals.length; i++) {
if (vals[i] < lo) lo = vals[i];
if (vals[i] > hi) hi = vals[i];
}
samples.add(lo);
samples.add(hi);
samples.add((lo + hi) / 2);
let maxW = 0;
samples.forEach(v => {
maxW = Math.max(maxW, measureTextWidth(fmtPrice(v), AXIS_FONT));
});
return Math.ceil(maxW) + 20;
}
function updateLegInfo() {
const total = DATA.buy_markers.length;
const el = document.getElementById("leg-info");
if (!total) { el.textContent = "타점 없음"; return; }
el.textContent = `타점 ${currentLegIdx + 1} / ${total}`;
}
const MARKER_FONT = "bold 18px Malgun Gothic, Arial, sans-serif";
const SIM_START_COLOR = "#7b1fa2";
function markerSuffix(signalType) {
if (signalType === "pullback") return "*";
if (signalType === "breakout") return "^";
if (signalType === "div_bull" || signalType === "div_bear") return "d";
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 drawSimStartMarker(u, marker) {
if (!marker) return;
const ctx = u.ctx;
const x = u.valToPos(marker.time, "x", true);
const y = u.valToPos(marker.price, "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const color = SIM_START_COLOR;
const s = 10;
const gap = 14;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x, y - gap);
ctx.lineTo(x - s, y - gap - 18);
ctx.lineTo(x + s, y - gap - 18);
ctx.closePath();
ctx.fill();
const label = marker.label || "거래시작";
ctx.font = MARKER_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const labelY = y - gap - 18 - 6;
ctx.lineWidth = 3;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.85)";
ctx.strokeText(label, x, labelY);
ctx.fillStyle = color;
ctx.fillText(label, x, labelY);
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
}
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 = 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();
const label = (up ? "B" : "S") + m.marker_id + markerSuffix(m.signal_type);
drawMarkerLabel(ctx, label, x, y, color, up);
};
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 yAxisW = yAxisLabelWidth();
const opts = {
width: document.getElementById("overview").clientWidth,
height: 480,
padding: [14, 10, 14, 10],
scales: { x: { time: true } },
axes: [
{ gap: 6 },
{
side: 3,
size: yAxisW,
gap: 10,
font: AXIS_FONT,
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) => {
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"));
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 = "";
const priceAxisW = yAxisLabelWidth();
detailChart = LightweightCharts.createChart(wrap, {
layout: { background: { color: "#fff" }, textColor: "#333", fontSize: 14 },
grid: { vertLines: { color: "#eee" }, horzLines: { color: "#eee" } },
rightPriceScale: { visible: false },
leftPriceScale: {
borderVisible: true,
minimumWidth: priceAxisW,
scaleMargins: { top: 0.08, bottom: 0.08 },
},
timeScale: { timeVisible: true, secondsVisible: false },
width: wrap.clientWidth,
height: 360,
});
detailSeries = detailChart.addCandlestickSeries({
priceScaleId: "left",
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 (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,
text: "B" + m.marker_id + markerSuffix(m.signal_type),
});
});
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", size: 3,
text: "S" + m.marker_id + markerSuffix(m.signal_type),
});
});
}
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 fmtMoney(v) {
return Math.round(v).toLocaleString("ko-KR") + "원";
}
function fmtPct(v) {
const sign = v > 0 ? "+" : "";
return sign + v.toFixed(2) + "%";
}
function renderSimPanel() {
const p = DATA.sim_pnl;
const retClass = p.total_return_pct >= 0 ? "positive" : "negative";
document.getElementById("sim-grid").innerHTML = [
["초기 자본", fmtMoney(p.initial_cash_krw), ""],
["최종 평가액", fmtMoney(p.final_equity_krw), retClass],
["손익", fmtMoney(p.total_pnl_krw), retClass],
["수익률", fmtPct(p.total_return_pct), retClass],
["현금 잔고", fmtMoney(p.final_cash_krw), ""],
["보유 코인", p.final_coin_qty.toFixed(8), ""],
["코인 평가", fmtMoney(p.final_coin_value_krw), ""],
["매수/매도", `${p.buys_executed}/${p.sells_executed}`, ""],
].map(([label, value, cls]) =>
`<div class="sim-card"><div class="label">${label}</div><div class="value ${cls}">${value}</div></div>`
).join("");
document.getElementById("sim-note").textContent =
`시뮬 기간: ${p.period_from} ~ ${p.period_to} (${p.sim_lookback_days}일) | ` +
`신호 ${p.signals_in_period}건 | 분할매수/매도 클러스터 적용 | ` +
`스킵 매수 ${p.buys_skipped} / 매도 ${p.sells_skipped} | 수수료 ${(p.fee_rate * 100).toFixed(2)}%`;
const tbody = document.getElementById("trade-body");
tbody.innerHTML = "";
(p.trades || []).forEach(t => {
const tr = document.createElement("tr");
if (t.skipped) tr.className = "skipped";
tr.innerHTML = `
<td>${t.datetime}</td>
<td>${t.side === "buy" ? "매수" : "매도"}</td>
<td>${t.signal_type}</td>
<td>${fmtPrice(t.price)}</td>
<td>${t.order_krw ? fmtMoney(t.order_krw) : "-"}</td>
<td>${t.fee_krw ? fmtMoney(t.fee_krw) : "-"}</td>
<td>${fmtMoney(t.cash_after)}</td>
<td>${t.coin_after.toFixed(8)}</td>
<td>${t.skipped ? (t.skip_reason || "스킵") : "분할 " + t.cluster_size}</td>`;
tbody.appendChild(tr);
});
document.getElementById("trade-count").textContent = String((p.trades || []).length);
}
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}${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;
const tierKey = (m.chart_tier || "v3").toLowerCase();
const legend = tierKey === "v1"
? "B=스윙매수 S=스윙매도"
: 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} (${markerRange}) | ${legend}${legendExtra}`;
if (simMode) renderSimPanel();
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

@@ -0,0 +1,609 @@
<!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_sim_v2_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; overflow: visible; }
#overview .u-wrap, #overview .uplot { overflow: visible !important; }
#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; }
.sim-panel { margin: 12px 24px 0; padding: 16px 20px; background: #fff; border: 1px solid #ddd; border-radius: 4px; }
.sim-panel h2 { margin: 0 0 12px; font-size: 16px; }
.sim-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.sim-card { padding: 10px 12px; background: #fafafa; border: 1px solid #eee; border-radius: 4px; }
.sim-card .label { font-size: 12px; color: #777; margin-bottom: 4px; }
.sim-card .value { font-size: 18px; font-weight: bold; }
.sim-card .value.positive { color: #2e7d32; }
.sim-card .value.negative { color: #c62828; }
.sim-note { margin-top: 10px; font-size: 12px; color: #666; line-height: 1.5; }
#trade-table-wrap { margin: 12px 24px 0; background: #fff; border: 1px solid #ddd; border-radius: 4px; overflow: hidden; }
#trade-table-wrap summary { padding: 10px 16px; cursor: pointer; font-size: 14px; background: #fafafa; border-bottom: 1px solid #eee; }
.trade-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.trade-table th, .trade-table td { padding: 6px 10px; border-bottom: 1px solid #eee; text-align: right; }
.trade-table th:first-child, .trade-table td:first-child { text-align: left; }
.trade-table th { background: #f5f5f5; position: sticky; top: 0; }
.trade-table tr.skipped td { color: #999; }
.trade-scroll { max-height: 240px; overflow: auto; }
</style>
</head>
<body>
<header>
<h1 id="title">Ground Truth Chart</h1>
<div class="meta" id="meta"></div>
</header>
<section class="sim-panel" id="sim-panel">
<h2>2단계 수익 시뮬레이션 (최근 1년 · 초기 40만원)</h2>
<div class="sim-grid" id="sim-grid"></div>
<div class="sim-note" id="sim-note"></div>
</section>
<details id="trade-table-wrap">
<summary>체결 내역 (<span id="trade-count">0</span>건)</summary>
<div class="trade-scroll">
<table class="trade-table">
<thead>
<tr>
<th>시각</th><th>구분</th><th>유형</th><th>가격</th><th>주문금액</th>
<th>수수료</th><th>현금</th><th>코인</th><th>비고</th>
</tr>
</thead>
<tbody id="trade-body"></tbody>
</table>
</div>
</details>
<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;
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
let axisMeasureCtx = null;
function fmtPrice(v) {
return Math.round(v).toLocaleString("ko-KR");
}
function measureTextWidth(text, font) {
if (!axisMeasureCtx) {
const c = document.createElement("canvas");
axisMeasureCtx = c.getContext("2d");
}
axisMeasureCtx.font = font;
return axisMeasureCtx.measureText(text).width;
}
function yAxisLabelWidth() {
const vals = DATA.close;
if (!vals || !vals.length) return 88;
const samples = new Set([vals[0], vals[vals.length - 1]]);
let lo = vals[0], hi = vals[0];
for (let i = 1; i < vals.length; i++) {
if (vals[i] < lo) lo = vals[i];
if (vals[i] > hi) hi = vals[i];
}
samples.add(lo);
samples.add(hi);
samples.add((lo + hi) / 2);
let maxW = 0;
samples.forEach(v => {
maxW = Math.max(maxW, measureTextWidth(fmtPrice(v), AXIS_FONT));
});
return Math.ceil(maxW) + 20;
}
function updateLegInfo() {
const total = DATA.buy_markers.length;
const el = document.getElementById("leg-info");
if (!total) { el.textContent = "타점 없음"; return; }
el.textContent = `타점 ${currentLegIdx + 1} / ${total}`;
}
const MARKER_FONT = "bold 18px Malgun Gothic, Arial, sans-serif";
const SIM_START_COLOR = "#7b1fa2";
function markerSuffix(signalType) {
if (signalType === "pullback") return "*";
if (signalType === "breakout") return "^";
if (signalType === "div_bull" || signalType === "div_bear") return "d";
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 drawSimStartMarker(u, marker) {
if (!marker) return;
const ctx = u.ctx;
const x = u.valToPos(marker.time, "x", true);
const y = u.valToPos(marker.price, "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const color = SIM_START_COLOR;
const s = 10;
const gap = 14;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x, y - gap);
ctx.lineTo(x - s, y - gap - 18);
ctx.lineTo(x + s, y - gap - 18);
ctx.closePath();
ctx.fill();
const label = marker.label || "거래시작";
ctx.font = MARKER_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const labelY = y - gap - 18 - 6;
ctx.lineWidth = 3;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.85)";
ctx.strokeText(label, x, labelY);
ctx.fillStyle = color;
ctx.fillText(label, x, labelY);
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
}
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 = 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();
const label = (up ? "B" : "S") + m.marker_id + markerSuffix(m.signal_type);
drawMarkerLabel(ctx, label, x, y, color, up);
};
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 yAxisW = yAxisLabelWidth();
const opts = {
width: document.getElementById("overview").clientWidth,
height: 480,
padding: [14, 10, 14, 10],
scales: { x: { time: true } },
axes: [
{ gap: 6 },
{
side: 3,
size: yAxisW,
gap: 10,
font: AXIS_FONT,
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) => {
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"));
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 = "";
const priceAxisW = yAxisLabelWidth();
detailChart = LightweightCharts.createChart(wrap, {
layout: { background: { color: "#fff" }, textColor: "#333", fontSize: 14 },
grid: { vertLines: { color: "#eee" }, horzLines: { color: "#eee" } },
rightPriceScale: { visible: false },
leftPriceScale: {
borderVisible: true,
minimumWidth: priceAxisW,
scaleMargins: { top: 0.08, bottom: 0.08 },
},
timeScale: { timeVisible: true, secondsVisible: false },
width: wrap.clientWidth,
height: 360,
});
detailSeries = detailChart.addCandlestickSeries({
priceScaleId: "left",
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 (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,
text: "B" + m.marker_id + markerSuffix(m.signal_type),
});
});
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", size: 3,
text: "S" + m.marker_id + markerSuffix(m.signal_type),
});
});
}
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 fmtMoney(v) {
return Math.round(v).toLocaleString("ko-KR") + "원";
}
function fmtPct(v) {
const sign = v > 0 ? "+" : "";
return sign + v.toFixed(2) + "%";
}
function renderSimPanel() {
const p = DATA.sim_pnl;
const retClass = p.total_return_pct >= 0 ? "positive" : "negative";
document.getElementById("sim-grid").innerHTML = [
["초기 자본", fmtMoney(p.initial_cash_krw), ""],
["최종 평가액", fmtMoney(p.final_equity_krw), retClass],
["손익", fmtMoney(p.total_pnl_krw), retClass],
["수익률", fmtPct(p.total_return_pct), retClass],
["현금 잔고", fmtMoney(p.final_cash_krw), ""],
["보유 코인", p.final_coin_qty.toFixed(8), ""],
["코인 평가", fmtMoney(p.final_coin_value_krw), ""],
["매수/매도", `${p.buys_executed}/${p.sells_executed}`, ""],
].map(([label, value, cls]) =>
`<div class="sim-card"><div class="label">${label}</div><div class="value ${cls}">${value}</div></div>`
).join("");
document.getElementById("sim-note").textContent =
`시뮬 기간: ${p.period_from} ~ ${p.period_to} (${p.sim_lookback_days}일) | ` +
`신호 ${p.signals_in_period}건 | 분할매수/매도 클러스터 적용 | ` +
`스킵 매수 ${p.buys_skipped} / 매도 ${p.sells_skipped} | 수수료 ${(p.fee_rate * 100).toFixed(2)}%`;
const tbody = document.getElementById("trade-body");
tbody.innerHTML = "";
(p.trades || []).forEach(t => {
const tr = document.createElement("tr");
if (t.skipped) tr.className = "skipped";
tr.innerHTML = `
<td>${t.datetime}</td>
<td>${t.side === "buy" ? "매수" : "매도"}</td>
<td>${t.signal_type}</td>
<td>${fmtPrice(t.price)}</td>
<td>${t.order_krw ? fmtMoney(t.order_krw) : "-"}</td>
<td>${t.fee_krw ? fmtMoney(t.fee_krw) : "-"}</td>
<td>${fmtMoney(t.cash_after)}</td>
<td>${t.coin_after.toFixed(8)}</td>
<td>${t.skipped ? (t.skip_reason || "스킵") : "분할 " + t.cluster_size}</td>`;
tbody.appendChild(tr);
});
document.getElementById("trade-count").textContent = String((p.trades || []).length);
}
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}${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;
const tierKey = (m.chart_tier || "v3").toLowerCase();
const legend = tierKey === "v1"
? "B=스윙매수 S=스윙매도"
: 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} (${markerRange}) | ${legend}${legendExtra}`;
if (simMode) renderSimPanel();
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

@@ -0,0 +1,609 @@
<!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_sim_v3_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; overflow: visible; }
#overview .u-wrap, #overview .uplot { overflow: visible !important; }
#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; }
.sim-panel { margin: 12px 24px 0; padding: 16px 20px; background: #fff; border: 1px solid #ddd; border-radius: 4px; }
.sim-panel h2 { margin: 0 0 12px; font-size: 16px; }
.sim-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.sim-card { padding: 10px 12px; background: #fafafa; border: 1px solid #eee; border-radius: 4px; }
.sim-card .label { font-size: 12px; color: #777; margin-bottom: 4px; }
.sim-card .value { font-size: 18px; font-weight: bold; }
.sim-card .value.positive { color: #2e7d32; }
.sim-card .value.negative { color: #c62828; }
.sim-note { margin-top: 10px; font-size: 12px; color: #666; line-height: 1.5; }
#trade-table-wrap { margin: 12px 24px 0; background: #fff; border: 1px solid #ddd; border-radius: 4px; overflow: hidden; }
#trade-table-wrap summary { padding: 10px 16px; cursor: pointer; font-size: 14px; background: #fafafa; border-bottom: 1px solid #eee; }
.trade-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.trade-table th, .trade-table td { padding: 6px 10px; border-bottom: 1px solid #eee; text-align: right; }
.trade-table th:first-child, .trade-table td:first-child { text-align: left; }
.trade-table th { background: #f5f5f5; position: sticky; top: 0; }
.trade-table tr.skipped td { color: #999; }
.trade-scroll { max-height: 240px; overflow: auto; }
</style>
</head>
<body>
<header>
<h1 id="title">Ground Truth Chart</h1>
<div class="meta" id="meta"></div>
</header>
<section class="sim-panel" id="sim-panel">
<h2>2단계 수익 시뮬레이션 (최근 1년 · 초기 40만원)</h2>
<div class="sim-grid" id="sim-grid"></div>
<div class="sim-note" id="sim-note"></div>
</section>
<details id="trade-table-wrap">
<summary>체결 내역 (<span id="trade-count">0</span>건)</summary>
<div class="trade-scroll">
<table class="trade-table">
<thead>
<tr>
<th>시각</th><th>구분</th><th>유형</th><th>가격</th><th>주문금액</th>
<th>수수료</th><th>현금</th><th>코인</th><th>비고</th>
</tr>
</thead>
<tbody id="trade-body"></tbody>
</table>
</div>
</details>
<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;
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
let axisMeasureCtx = null;
function fmtPrice(v) {
return Math.round(v).toLocaleString("ko-KR");
}
function measureTextWidth(text, font) {
if (!axisMeasureCtx) {
const c = document.createElement("canvas");
axisMeasureCtx = c.getContext("2d");
}
axisMeasureCtx.font = font;
return axisMeasureCtx.measureText(text).width;
}
function yAxisLabelWidth() {
const vals = DATA.close;
if (!vals || !vals.length) return 88;
const samples = new Set([vals[0], vals[vals.length - 1]]);
let lo = vals[0], hi = vals[0];
for (let i = 1; i < vals.length; i++) {
if (vals[i] < lo) lo = vals[i];
if (vals[i] > hi) hi = vals[i];
}
samples.add(lo);
samples.add(hi);
samples.add((lo + hi) / 2);
let maxW = 0;
samples.forEach(v => {
maxW = Math.max(maxW, measureTextWidth(fmtPrice(v), AXIS_FONT));
});
return Math.ceil(maxW) + 20;
}
function updateLegInfo() {
const total = DATA.buy_markers.length;
const el = document.getElementById("leg-info");
if (!total) { el.textContent = "타점 없음"; return; }
el.textContent = `타점 ${currentLegIdx + 1} / ${total}`;
}
const MARKER_FONT = "bold 18px Malgun Gothic, Arial, sans-serif";
const SIM_START_COLOR = "#7b1fa2";
function markerSuffix(signalType) {
if (signalType === "pullback") return "*";
if (signalType === "breakout") return "^";
if (signalType === "div_bull" || signalType === "div_bear") return "d";
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 drawSimStartMarker(u, marker) {
if (!marker) return;
const ctx = u.ctx;
const x = u.valToPos(marker.time, "x", true);
const y = u.valToPos(marker.price, "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const color = SIM_START_COLOR;
const s = 10;
const gap = 14;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x, y - gap);
ctx.lineTo(x - s, y - gap - 18);
ctx.lineTo(x + s, y - gap - 18);
ctx.closePath();
ctx.fill();
const label = marker.label || "거래시작";
ctx.font = MARKER_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const labelY = y - gap - 18 - 6;
ctx.lineWidth = 3;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.85)";
ctx.strokeText(label, x, labelY);
ctx.fillStyle = color;
ctx.fillText(label, x, labelY);
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
}
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 = 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();
const label = (up ? "B" : "S") + m.marker_id + markerSuffix(m.signal_type);
drawMarkerLabel(ctx, label, x, y, color, up);
};
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 yAxisW = yAxisLabelWidth();
const opts = {
width: document.getElementById("overview").clientWidth,
height: 480,
padding: [14, 10, 14, 10],
scales: { x: { time: true } },
axes: [
{ gap: 6 },
{
side: 3,
size: yAxisW,
gap: 10,
font: AXIS_FONT,
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) => {
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"));
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 = "";
const priceAxisW = yAxisLabelWidth();
detailChart = LightweightCharts.createChart(wrap, {
layout: { background: { color: "#fff" }, textColor: "#333", fontSize: 14 },
grid: { vertLines: { color: "#eee" }, horzLines: { color: "#eee" } },
rightPriceScale: { visible: false },
leftPriceScale: {
borderVisible: true,
minimumWidth: priceAxisW,
scaleMargins: { top: 0.08, bottom: 0.08 },
},
timeScale: { timeVisible: true, secondsVisible: false },
width: wrap.clientWidth,
height: 360,
});
detailSeries = detailChart.addCandlestickSeries({
priceScaleId: "left",
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 (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,
text: "B" + m.marker_id + markerSuffix(m.signal_type),
});
});
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", size: 3,
text: "S" + m.marker_id + markerSuffix(m.signal_type),
});
});
}
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 fmtMoney(v) {
return Math.round(v).toLocaleString("ko-KR") + "원";
}
function fmtPct(v) {
const sign = v > 0 ? "+" : "";
return sign + v.toFixed(2) + "%";
}
function renderSimPanel() {
const p = DATA.sim_pnl;
const retClass = p.total_return_pct >= 0 ? "positive" : "negative";
document.getElementById("sim-grid").innerHTML = [
["초기 자본", fmtMoney(p.initial_cash_krw), ""],
["최종 평가액", fmtMoney(p.final_equity_krw), retClass],
["손익", fmtMoney(p.total_pnl_krw), retClass],
["수익률", fmtPct(p.total_return_pct), retClass],
["현금 잔고", fmtMoney(p.final_cash_krw), ""],
["보유 코인", p.final_coin_qty.toFixed(8), ""],
["코인 평가", fmtMoney(p.final_coin_value_krw), ""],
["매수/매도", `${p.buys_executed}/${p.sells_executed}`, ""],
].map(([label, value, cls]) =>
`<div class="sim-card"><div class="label">${label}</div><div class="value ${cls}">${value}</div></div>`
).join("");
document.getElementById("sim-note").textContent =
`시뮬 기간: ${p.period_from} ~ ${p.period_to} (${p.sim_lookback_days}일) | ` +
`신호 ${p.signals_in_period}건 | 분할매수/매도 클러스터 적용 | ` +
`스킵 매수 ${p.buys_skipped} / 매도 ${p.sells_skipped} | 수수료 ${(p.fee_rate * 100).toFixed(2)}%`;
const tbody = document.getElementById("trade-body");
tbody.innerHTML = "";
(p.trades || []).forEach(t => {
const tr = document.createElement("tr");
if (t.skipped) tr.className = "skipped";
tr.innerHTML = `
<td>${t.datetime}</td>
<td>${t.side === "buy" ? "매수" : "매도"}</td>
<td>${t.signal_type}</td>
<td>${fmtPrice(t.price)}</td>
<td>${t.order_krw ? fmtMoney(t.order_krw) : "-"}</td>
<td>${t.fee_krw ? fmtMoney(t.fee_krw) : "-"}</td>
<td>${fmtMoney(t.cash_after)}</td>
<td>${t.coin_after.toFixed(8)}</td>
<td>${t.skipped ? (t.skip_reason || "스킵") : "분할 " + t.cluster_size}</td>`;
tbody.appendChild(tr);
});
document.getElementById("trade-count").textContent = String((p.trades || []).length);
}
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}${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;
const tierKey = (m.chart_tier || "v3").toLowerCase();
const legend = tierKey === "v1"
? "B=스윙매수 S=스윙매도"
: 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} (${markerRange}) | ${legend}${legendExtra}`;
if (simMode) renderSimPanel();
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

@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""2단계: Ground Truth 타점 기준 1년 수익 시뮬 + sim 차트 생성."""
from __future__ import annotations
import argparse
import json
import logging
import sys
from dataclasses import replace
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
from deepcoin.config import Settings, load_settings
from deepcoin.data.candle_loader import load_candles
from deepcoin.data.intervals import interval_label
from deepcoin.ground_truth.chart import render_ground_truth_sim_chart
from deepcoin.ground_truth.ground_truth import GtParams, build_ground_truth, save_ground_truth
from deepcoin.ground_truth.pnl import simulate_gt_signals_pnl
TIER_DESCRIPTIONS = {
"v1": "스윙만 (최소 매수·매도)",
"v2": "스윙 + 눌림목",
"v3": "스윙 + 눌림목 + 돌파 + 다이버전스",
}
def _configure_logging(verbose: bool) -> None:
"""로깅 레벨을 설정한다."""
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
def _base_params(settings: Settings, args: argparse.Namespace) -> GtParams:
"""CLI·환경 설정을 반영한 공통 GT 파라미터."""
return GtParams(
interval_min=args.interval or settings.gt_interval_min,
lookback_days=args.days or settings.gt_lookback_days,
zigzag_reversal_pct=args.zigzag or settings.gt_zigzag_reversal_pct,
min_leg_pct=args.min_leg or settings.gt_min_leg_pct,
pullback_min_pct=settings.gt_pullback_min_pct,
pullback_local_order=settings.gt_pullback_local_order,
breakout_buffer_pct=settings.gt_breakout_buffer_pct,
breakout_consolidation_bars=settings.gt_breakout_consolidation_bars,
breakout_min_rally_pct=settings.gt_breakout_min_rally_pct,
div_local_order=settings.gt_div_local_order,
div_min_bars_between=settings.gt_div_min_bars_between,
div_min_rsi_diff=settings.gt_div_min_rsi_diff,
div_min_future_move_pct=settings.gt_div_min_future_move_pct,
)
def _tier_targets(settings: Settings) -> list[tuple[str, Path, Path, Path]]:
"""티어별 (tier, json, chart, sim_chart) 경로."""
return [
(
"v1",
settings.ground_truth_v1_file,
settings.ground_truth_chart_v1_file,
settings.ground_truth_chart_sim_v1_file,
),
(
"v2",
settings.ground_truth_v2_file,
settings.ground_truth_chart_v2_file,
settings.ground_truth_chart_sim_v2_file,
),
(
"v3",
settings.ground_truth_file,
settings.ground_truth_chart_v3_file,
settings.ground_truth_chart_sim_v3_file,
),
]
def _load_or_build_gt(
settings: Settings,
params: GtParams,
json_path: Path,
rebuild: bool,
) -> dict[str, Any]:
"""GT JSON을 로드하거나 새로 생성한다."""
if json_path.exists() and not rebuild:
with json_path.open(encoding="utf-8") as fp:
return json.load(fp)
result = build_ground_truth(
db_path=settings.db_path,
symbol=settings.symbol,
coin_name=settings.coin_name,
params=params,
initial_cash_krw=settings.gt_initial_cash_krw,
fee_rate=settings.gt_trading_fee_rate,
)
save_ground_truth(result, json_path)
return result
def _print_sim_summary(
tier: str,
sim_pnl: dict[str, Any],
sim_chart_path: Path,
) -> None:
"""티어별 시뮬 요약을 출력한다."""
print(f"\n=== 2단계 시뮬 {tier.upper()} ({TIER_DESCRIPTIONS[tier]}) ===")
print(
f"기간: {sim_pnl['period_from']} ~ {sim_pnl['period_to']} "
f"({sim_pnl['sim_lookback_days']}일)"
)
print(
f"초기 {sim_pnl['initial_cash_krw']:,.0f}원 → "
f"최종 {sim_pnl['final_equity_krw']:,.0f}"
f"({sim_pnl['total_return_pct']:+.2f}%)"
)
print(
f"현금 {sim_pnl['final_cash_krw']:,.0f}원 + "
f"코인 {sim_pnl['final_coin_qty']:.8f} "
f"(평가 {sim_pnl['final_coin_value_krw']:,.0f}원)"
)
print(
f"체결 매수 {sim_pnl['buys_executed']} / 매도 {sim_pnl['sells_executed']} | "
f"스킵 매수 {sim_pnl['buys_skipped']} / 매도 {sim_pnl['sells_skipped']}"
)
print(f"sim 차트: {sim_chart_path}")
def main() -> int:
"""CLI 진입점."""
parser = argparse.ArgumentParser(description="Ground Truth 1년 수익 시뮬 (2단계)")
parser.add_argument("--interval", type=int, default=None, help="GT 인터벌(분)")
parser.add_argument("--days", type=int, default=None, help="GT 타점 기간(일). 기본 730")
parser.add_argument(
"--sim-days",
type=int,
default=None,
help="시뮬 기간(일). 기본 GT_SIM_LOOKBACK_DAYS 또는 365",
)
parser.add_argument("--zigzag", type=float, default=None, help="ZigZag 되돌림 %%")
parser.add_argument("--min-leg", type=float, default=None, help="최소 레그 수익률 %%")
parser.add_argument(
"--tier",
choices=("v1", "v2", "v3", "all"),
default="all",
help="대상 GT 버전",
)
parser.add_argument(
"--rebuild-gt",
action="store_true",
help="GT JSON을 다시 생성 (없으면 자동 생성)",
)
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args()
_configure_logging(args.verbose)
settings = load_settings()
base = _base_params(settings, args)
sim_days = args.sim_days or settings.gt_sim_lookback_days
tiers = _tier_targets(settings)
if args.tier != "all":
tiers = [t for t in tiers if t[0] == args.tier]
logging.info(
"2단계 시뮬: %s %s, GT %s일, sim %s일, 초기=%s",
settings.symbol,
interval_label(base.interval_min),
base.lookback_days,
sim_days,
f"{settings.gt_initial_cash_krw:,.0f}",
)
print("\n=== Ground Truth 2단계 수익 시뮬 ===")
print(f"초기 자본: {settings.gt_initial_cash_krw:,.0f}원 | 시뮬 기간: 최근 {sim_days}")
for tier, json_path, _chart_path, sim_chart_path in tiers:
params = replace(base, chart_tier=tier)
gt_result = _load_or_build_gt(settings, params, json_path, args.rebuild_gt)
df = load_candles(
db_path=settings.db_path,
symbol=settings.symbol,
interval_min=params.interval_min,
lookback_days=base.lookback_days,
)
last_close = float(df["close"].iloc[-1])
sim_pnl = simulate_gt_signals_pnl(
signals=gt_result.get("signals") or [],
initial_cash_krw=settings.gt_initial_cash_krw,
fee_rate=settings.gt_trading_fee_rate,
sim_lookback_days=sim_days,
data_end=gt_result["meta"]["data_to"],
last_mark_price=last_close,
)
gt_result["sim_pnl"] = sim_pnl
save_ground_truth(gt_result, json_path)
render_ground_truth_sim_chart(
db_path=settings.db_path,
symbol=settings.symbol,
gt_result=gt_result,
sim_pnl=sim_pnl,
output_path=sim_chart_path,
chart_lookback_days=settings.download_days,
)
_print_sim_summary(tier, sim_pnl, sim_chart_path)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -68,6 +68,10 @@ class Settings:
ground_truth_chart_v1_file: Path ground_truth_chart_v1_file: Path
ground_truth_chart_v2_file: Path ground_truth_chart_v2_file: Path
ground_truth_chart_v3_file: Path ground_truth_chart_v3_file: Path
ground_truth_chart_sim_v1_file: Path
ground_truth_chart_sim_v2_file: Path
ground_truth_chart_sim_v3_file: Path
gt_sim_lookback_days: int
gt_initial_cash_krw: float gt_initial_cash_krw: float
gt_trading_fee_rate: float gt_trading_fee_rate: float
# Techniques (2단계) # Techniques (2단계)
@@ -121,6 +125,24 @@ def load_settings(env_path: Path | None = None) -> Settings:
gt_chart_v3 = _resolve_project_path( gt_chart_v3 = _resolve_project_path(
os.getenv("GROUND_TRUTH_CHART_V3_FILE", "docs/02_ground_truth/ground_truth_chart_v3.html") os.getenv("GROUND_TRUTH_CHART_V3_FILE", "docs/02_ground_truth/ground_truth_chart_v3.html")
) )
gt_chart_sim_v1 = _resolve_project_path(
os.getenv(
"GROUND_TRUTH_CHART_SIM_V1_FILE",
"docs/02_ground_truth/ground_truth_chart_sim_v1.html",
)
)
gt_chart_sim_v2 = _resolve_project_path(
os.getenv(
"GROUND_TRUTH_CHART_SIM_V2_FILE",
"docs/02_ground_truth/ground_truth_chart_sim_v2.html",
)
)
gt_chart_sim_v3 = _resolve_project_path(
os.getenv(
"GROUND_TRUTH_CHART_SIM_V3_FILE",
"docs/02_ground_truth/ground_truth_chart_sim_v3.html",
)
)
tech_dir_raw = os.getenv("TECHNIQUES_DIR", "data/techniques") tech_dir_raw = os.getenv("TECHNIQUES_DIR", "data/techniques")
tech_dir = Path(tech_dir_raw) tech_dir = Path(tech_dir_raw)
@@ -165,6 +187,10 @@ def load_settings(env_path: Path | None = None) -> Settings:
ground_truth_chart_v1_file=gt_chart_v1, ground_truth_chart_v1_file=gt_chart_v1,
ground_truth_chart_v2_file=gt_chart_v2, ground_truth_chart_v2_file=gt_chart_v2,
ground_truth_chart_v3_file=gt_chart_v3, ground_truth_chart_v3_file=gt_chart_v3,
ground_truth_chart_sim_v1_file=gt_chart_sim_v1,
ground_truth_chart_sim_v2_file=gt_chart_sim_v2,
ground_truth_chart_sim_v3_file=gt_chart_sim_v3,
gt_sim_lookback_days=int(os.getenv("GT_SIM_LOOKBACK_DAYS", "365")),
gt_initial_cash_krw=float(os.getenv("GT_INITIAL_CASH_KRW", "400000")), gt_initial_cash_krw=float(os.getenv("GT_INITIAL_CASH_KRW", "400000")),
gt_trading_fee_rate=float(os.getenv("GT_TRADING_FEE_RATE", "0.0005")), gt_trading_fee_rate=float(os.getenv("GT_TRADING_FEE_RATE", "0.0005")),
techniques_dir=tech_dir, techniques_dir=tech_dir,

View File

@@ -22,6 +22,149 @@ def _data_js_path(html_path: Path) -> Path:
return html_path.with_name(f"{html_path.stem}_data.js") return html_path.with_name(f"{html_path.stem}_data.js")
def _to_unix_seconds(dt_series: pd.Series) -> list[int]:
"""datetime Series를 uPlot/LWC용 unix 초 리스트로 변환한다.
pandas datetime64[ns/us/ms] 단위 차이에 관계없이 올바른 초 단위를 반환한다.
Args:
dt_series: datetime 컬럼.
Returns:
unix epoch 초 리스트.
"""
parsed = pd.to_datetime(dt_series)
seconds = (parsed - pd.Timestamp("1970-01-01")) / pd.Timedelta(seconds=1)
return seconds.astype(int).tolist()
def _markers_from_executed_trades(
sim_pnl: dict[str, Any],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""시뮬에서 실제 체결된 매수·매도만 마커로 변환한다."""
buy_markers: list[dict[str, Any]] = []
sell_markers: list[dict[str, Any]] = []
for trade in sim_pnl.get("trades") or []:
if trade.get("skipped"):
continue
side = trade["side"]
signal_type = trade.get("signal_type") or (
"swing_low" if side == "buy" else "swing_high"
)
marker = {
"time": int(pd.Timestamp(trade["datetime"]).timestamp()),
"price": trade["price"],
"marker_id": trade.get("marker_id") or trade.get("trade_id"),
"signal_type": signal_type,
}
if side == "buy":
buy_markers.append(marker)
else:
sell_markers.append(marker)
return buy_markers, sell_markers
def _markers_from_gt_signals(
gt_result: dict[str, Any],
sim_period_from_ts: int | None = None,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""GT 신호에서 마커를 구성한다 (1단계 차트용)."""
buy_markers: list[dict[str, Any]] = []
sell_markers: list[dict[str, Any]] = []
for sig in gt_result.get("signals") or []:
ts = int(pd.Timestamp(sig["datetime"]).timestamp())
if sim_period_from_ts is not None and ts < sim_period_from_ts:
continue
marker = {
"time": ts,
"price": sig["price"],
"marker_id": sig.get("marker_id", sig.get("leg_id")),
"signal_type": sig.get(
"signal_type",
"swing_low" if sig["side"] == "buy" else "swing_high",
),
}
if sig["side"] == "buy":
buy_markers.append(marker)
else:
sell_markers.append(marker)
return buy_markers, sell_markers
def _sim_start_marker(
df: pd.DataFrame,
sim_pnl: dict[str, Any],
) -> dict[str, Any] | None:
"""1년 시뮬 매매 시작 시점 마커를 구성한다."""
period_from = sim_pnl.get("period_from")
if not period_from:
return None
start_ts = pd.Timestamp(period_from)
parsed = pd.to_datetime(df["datetime"])
idx = int(parsed.searchsorted(start_ts, side="left"))
if idx >= len(df):
idx = len(df) - 1
row = df.iloc[idx]
dt_str = str(row["datetime"])
return {
"time": int(pd.Timestamp(dt_str).timestamp()),
"price": float(row["close"]),
"datetime": dt_str,
"label": "거래시작",
}
def _build_chart_payload(
df: pd.DataFrame,
gt_result: dict[str, Any],
chart_days: int,
gt_lookback_days: int,
sim_pnl: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""차트 HTML용 JSON payload를 구성한다."""
times = _to_unix_seconds(df["datetime"])
if sim_pnl is not None:
buy_markers, sell_markers = _markers_from_executed_trades(sim_pnl)
else:
buy_markers, sell_markers = _markers_from_gt_signals(gt_result)
chart_meta = {
**gt_result["meta"],
"chart_lookback_days": chart_days,
"gt_lookback_days": gt_lookback_days,
"chart_data_from": str(df["datetime"].min()),
"chart_data_to": str(df["datetime"].max()),
"gt_marker_count": len(buy_markers),
}
if sim_pnl is not None:
chart_meta["sim_period_from"] = sim_pnl.get("period_from")
chart_meta["sim_period_to"] = sim_pnl.get("period_to")
chart_meta["sim_lookback_days"] = sim_pnl.get("sim_lookback_days")
payload: dict[str, Any] = {
"times": times,
"open": df["open"].astype(float).tolist(),
"high": df["high"].astype(float).tolist(),
"low": df["low"].astype(float).tolist(),
"close": df["close"].astype(float).tolist(),
"buy_markers": buy_markers,
"sell_markers": sell_markers,
"meta": chart_meta,
"bar_count": len(df),
}
if sim_pnl is not None:
payload["sim_pnl"] = sim_pnl
start_marker = _sim_start_marker(df, sim_pnl)
if start_marker is not None:
payload["sim_start_marker"] = start_marker
return payload
def render_ground_truth_chart( def render_ground_truth_chart(
db_path: Path, db_path: Path,
symbol: str, symbol: str,
@@ -54,42 +197,7 @@ def render_ground_truth_chart(
if max_candles > 0 and len(df) > max_candles: if max_candles > 0 and len(df) > max_candles:
df = df.iloc[-max_candles:].reset_index(drop=True) df = df.iloc[-max_candles:].reset_index(drop=True)
times = (pd.to_datetime(df["datetime"]).astype("int64") // 10**9).astype(int).tolist() payload = _build_chart_payload(df, gt_result, chart_days, gt_lookback_days)
buy_markers = []
sell_markers = []
for sig in gt_result.get("signals") or []:
ts = int(pd.Timestamp(sig["datetime"]).timestamp())
marker = {
"time": ts,
"price": sig["price"],
"marker_id": sig.get("marker_id", sig.get("leg_id")),
"signal_type": sig.get("signal_type", "swing_low" if sig["side"] == "buy" else "swing_high"),
}
if sig["side"] == "buy":
buy_markers.append(marker)
else:
sell_markers.append(marker)
chart_meta = {
**gt_result["meta"],
"chart_lookback_days": chart_days,
"gt_lookback_days": gt_lookback_days,
"chart_data_from": str(df["datetime"].min()),
"chart_data_to": str(df["datetime"].max()),
"gt_marker_count": len(buy_markers),
}
payload = {
"times": times,
"open": df["open"].astype(float).tolist(),
"high": df["high"].astype(float).tolist(),
"low": df["low"].astype(float).tolist(),
"close": df["close"].astype(float).tolist(),
"buy_markers": buy_markers,
"sell_markers": sell_markers,
"meta": chart_meta,
"bar_count": len(df),
}
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
data_path = _data_js_path(output_path) data_path = _data_js_path(output_path)
@@ -132,6 +240,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
#detail-wrap { margin: 0 24px 12px; display: none; } #detail-wrap { margin: 0 24px 12px; display: none; }
#detail-wrap h2 { font-size: 15px; margin: 0 0 8px; } #detail-wrap h2 { font-size: 15px; margin: 0 0 8px; }
#detail { height: 360px; background: #fff; border: 1px solid #ddd; overflow: visible; } #detail { height: 360px; background: #fff; border: 1px solid #ddd; overflow: visible; }
__EXTRA_STYLES__
</style> </style>
</head> </head>
<body> <body>
@@ -139,6 +248,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<h1 id="title">Ground Truth Chart</h1> <h1 id="title">Ground Truth Chart</h1>
<div class="meta" id="meta"></div> <div class="meta" id="meta"></div>
</header> </header>
__EXTRA_BODY__
<div class="toolbar"> <div class="toolbar">
<div class="toolbar-group"> <div class="toolbar-group">
<button id="btn-home" class="home" title="전체 2년 화면으로 복귀">홈</button> <button id="btn-home" class="home" title="전체 2년 화면으로 복귀">홈</button>
@@ -223,6 +333,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
} }
const MARKER_FONT = "bold 18px Malgun Gothic, Arial, sans-serif"; const MARKER_FONT = "bold 18px Malgun Gothic, Arial, sans-serif";
const SIM_START_COLOR = "#7b1fa2";
function markerSuffix(signalType) { function markerSuffix(signalType) {
if (signalType === "pullback") return "*"; if (signalType === "pullback") return "*";
@@ -243,6 +354,37 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
ctx.fillText(label, lx, ly); ctx.fillText(label, lx, ly);
} }
function drawSimStartMarker(u, marker) {
if (!marker) return;
const ctx = u.ctx;
const x = u.valToPos(marker.time, "x", true);
const y = u.valToPos(marker.price, "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const color = SIM_START_COLOR;
const s = 10;
const gap = 14;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x, y - gap);
ctx.lineTo(x - s, y - gap - 18);
ctx.lineTo(x + s, y - gap - 18);
ctx.closePath();
ctx.fill();
const label = marker.label || "거래시작";
ctx.font = MARKER_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const labelY = y - gap - 18 - 6;
ctx.lineWidth = 3;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.85)";
ctx.strokeText(label, x, labelY);
ctx.fillStyle = color;
ctx.fillText(label, x, labelY);
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
}
function drawMarkers(u, buys, sells) { function drawMarkers(u, buys, sells) {
if (!showMarkers) return; if (!showMarkers) return;
const ctx = u.ctx; const ctx = u.ctx;
@@ -326,7 +468,10 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
], ],
cursor: { drag: { x: true, y: false, setScale: true } }, cursor: { drag: { x: true, y: false, setScale: true } },
hooks: { 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")); overviewPlot = new uPlot(opts, [DATA.times, DATA.close], document.getElementById("overview"));
@@ -381,6 +526,14 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
const t0 = DATA.times[startIdx]; const t0 = DATA.times[startIdx];
const t1 = DATA.times[end - 1]; const t1 = DATA.times[end - 1];
const markers = []; 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) { if (showMarkers) {
DATA.buy_markers.forEach(m => { DATA.buy_markers.forEach(m => {
if (m.time >= t0 && m.time <= t1) markers.push({ if (m.time >= t0 && m.time <= t1) markers.push({
@@ -503,17 +656,21 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
} }
} }
__EXTRA_SCRIPT__
function init() { function init() {
DATA = window.CHART_DATA; DATA = window.CHART_DATA;
if (!DATA) throw new Error("차트 데이터 JS 없음"); if (!DATA) throw new Error("차트 데이터 JS 없음");
const m = DATA.meta; const m = DATA.meta;
const simMode = !!DATA.sim_pnl;
const chartDays = m.chart_lookback_days || m.lookback_days; const chartDays = m.chart_lookback_days || m.lookback_days;
const gtDays = m.gt_lookback_days || m.lookback_days; const gtDays = m.gt_lookback_days || m.lookback_days;
const chartLabel = chartDays >= 365 ? `${Math.round(chartDays / 365)}년` : `${chartDays}일`; const chartLabel = chartDays >= 365 ? `${Math.round(chartDays / 365)}년` : `${chartDays}일`;
const gtLabel = gtDays >= 365 ? `${Math.round(gtDays / 365)}년` : `${gtDays}일`; const gtLabel = gtDays >= 365 ? `${Math.round(gtDays / 365)}년` : `${gtDays}일`;
const tier = m.chart_tier ? ` ${m.chart_tier.toUpperCase()}` : ""; const tier = m.chart_tier ? ` ${m.chart_tier.toUpperCase()}` : "";
const simSuffix = simMode ? " · 2단계 시뮬" : "";
document.getElementById("title").textContent = 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}`; document.getElementById("btn-all").textContent = `전체 ${chartLabel}`;
const chartFrom = m.chart_data_from || m.data_from; const chartFrom = m.chart_data_from || m.data_from;
const chartTo = m.chart_data_to || m.data_to; const chartTo = m.chart_data_to || m.data_to;
@@ -523,8 +680,13 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
: tierKey === "v2" : tierKey === "v2"
? "B/S=스윙 B*=눌림목" ? "B/S=스윙 B*=눌림목"
: "B/S=스윙 B*=눌림 B^=돌파 Bd/Sd=다이버전스"; : "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 = 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(); updateLegInfo();
document.getElementById("status").textContent = document.getElementById("status").textContent =
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`; `전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
@@ -575,5 +737,169 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
def _html_template(data_js_name: str) -> str: def _html_template(data_js_name: str) -> str:
"""차트 HTML 템플릿을 생성한다.""" """1단계 GT 차트 HTML 템플릿을 생성한다."""
return _HTML_TEMPLATE.replace("__DATA_JS_NAME__", data_js_name) return _build_html_template(data_js_name, sim_mode=False)
_SIM_EXTRA_STYLES = """
.sim-panel { margin: 12px 24px 0; padding: 16px 20px; background: #fff; border: 1px solid #ddd; border-radius: 4px; }
.sim-panel h2 { margin: 0 0 12px; font-size: 16px; }
.sim-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.sim-card { padding: 10px 12px; background: #fafafa; border: 1px solid #eee; border-radius: 4px; }
.sim-card .label { font-size: 12px; color: #777; margin-bottom: 4px; }
.sim-card .value { font-size: 18px; font-weight: bold; }
.sim-card .value.positive { color: #2e7d32; }
.sim-card .value.negative { color: #c62828; }
.sim-note { margin-top: 10px; font-size: 12px; color: #666; line-height: 1.5; }
#trade-table-wrap { margin: 12px 24px 0; background: #fff; border: 1px solid #ddd; border-radius: 4px; overflow: hidden; }
#trade-table-wrap summary { padding: 10px 16px; cursor: pointer; font-size: 14px; background: #fafafa; border-bottom: 1px solid #eee; }
.trade-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.trade-table th, .trade-table td { padding: 6px 10px; border-bottom: 1px solid #eee; text-align: right; }
.trade-table th:first-child, .trade-table td:first-child { text-align: left; }
.trade-table th { background: #f5f5f5; position: sticky; top: 0; }
.trade-table tr.skipped td { color: #999; }
.trade-scroll { max-height: 240px; overflow: auto; }
"""
_SIM_EXTRA_BODY = """
<section class="sim-panel" id="sim-panel">
<h2>2단계 수익 시뮬레이션 (최근 1년 · 초기 40만원)</h2>
<div class="sim-grid" id="sim-grid"></div>
<div class="sim-note" id="sim-note"></div>
</section>
<details id="trade-table-wrap">
<summary>체결 내역 (<span id="trade-count">0</span>건)</summary>
<div class="trade-scroll">
<table class="trade-table">
<thead>
<tr>
<th>시각</th><th>구분</th><th>유형</th><th>가격</th><th>주문금액</th>
<th>수수료</th><th>현금</th><th>코인</th><th>비고</th>
</tr>
</thead>
<tbody id="trade-body"></tbody>
</table>
</div>
</details>
"""
_SIM_EXTRA_SCRIPT = """
function fmtMoney(v) {
return Math.round(v).toLocaleString("ko-KR") + "";
}
function fmtPct(v) {
const sign = v > 0 ? "+" : "";
return sign + v.toFixed(2) + "%";
}
function renderSimPanel() {
const p = DATA.sim_pnl;
const retClass = p.total_return_pct >= 0 ? "positive" : "negative";
document.getElementById("sim-grid").innerHTML = [
["초기 자본", fmtMoney(p.initial_cash_krw), ""],
["최종 평가액", fmtMoney(p.final_equity_krw), retClass],
["손익", fmtMoney(p.total_pnl_krw), retClass],
["수익률", fmtPct(p.total_return_pct), retClass],
["현금 잔고", fmtMoney(p.final_cash_krw), ""],
["보유 코인", p.final_coin_qty.toFixed(8), ""],
["코인 평가", fmtMoney(p.final_coin_value_krw), ""],
["매수/매도", `${p.buys_executed}/${p.sells_executed}건`, ""],
].map(([label, value, cls]) =>
`<div class="sim-card"><div class="label">${label}</div><div class="value ${cls}">${value}</div></div>`
).join("");
document.getElementById("sim-note").textContent =
`시뮬 기간: ${p.period_from} ~ ${p.period_to} (${p.sim_lookback_days}일) | ` +
`신호 ${p.signals_in_period}건 | 분할매수/매도 클러스터 적용 | ` +
`스킵 매수 ${p.buys_skipped} / 매도 ${p.sells_skipped} | 수수료 ${(p.fee_rate * 100).toFixed(2)}%`;
const tbody = document.getElementById("trade-body");
tbody.innerHTML = "";
(p.trades || []).forEach(t => {
const tr = document.createElement("tr");
if (t.skipped) tr.className = "skipped";
tr.innerHTML = `
<td>${t.datetime}</td>
<td>${t.side === "buy" ? "매수" : "매도"}</td>
<td>${t.signal_type}</td>
<td>${fmtPrice(t.price)}</td>
<td>${t.order_krw ? fmtMoney(t.order_krw) : "-"}</td>
<td>${t.fee_krw ? fmtMoney(t.fee_krw) : "-"}</td>
<td>${fmtMoney(t.cash_after)}</td>
<td>${t.coin_after.toFixed(8)}</td>
<td>${t.skipped ? (t.skip_reason || "스킵") : "분할 " + t.cluster_size}</td>`;
tbody.appendChild(tr);
});
document.getElementById("trade-count").textContent = String((p.trades || []).length);
}
"""
def _build_html_template(data_js_name: str, sim_mode: bool) -> str:
"""GT/시뮬 차트 HTML 템플릿을 생성한다."""
html = _HTML_TEMPLATE.replace("__DATA_JS_NAME__", data_js_name)
if sim_mode:
html = (
html.replace("__EXTRA_STYLES__", _SIM_EXTRA_STYLES)
.replace("__EXTRA_BODY__", _SIM_EXTRA_BODY)
.replace("__EXTRA_SCRIPT__", _SIM_EXTRA_SCRIPT)
)
else:
html = (
html.replace("__EXTRA_STYLES__", "")
.replace("__EXTRA_BODY__", "")
.replace("__EXTRA_SCRIPT__", "")
)
return html
def _sim_html_template(data_js_name: str) -> str:
"""2단계 sim 차트 HTML 템플릿을 생성한다."""
return _build_html_template(data_js_name, sim_mode=True)
def render_ground_truth_sim_chart(
db_path: Path,
symbol: str,
gt_result: dict[str, Any],
sim_pnl: dict[str, Any],
output_path: Path,
chart_lookback_days: int | None = None,
max_candles: int = DEFAULT_MAX_CANDLES,
) -> Path:
"""GT 타점 + 2단계 시뮬 수익 결과가 표시된 HTML 차트를 생성한다.
Args:
db_path: SQLite 경로.
symbol: 코인 심볼.
gt_result: build_ground_truth 결과.
sim_pnl: simulate_gt_signals_pnl 결과.
output_path: HTML 출력 경로.
chart_lookback_days: 차트 표시 일수.
max_candles: 0이면 전체.
Returns:
HTML 저장 경로.
"""
interval_min = gt_result["meta"]["interval_min"]
gt_lookback_days = gt_result["meta"]["lookback_days"]
chart_days = chart_lookback_days if chart_lookback_days is not None else gt_lookback_days
df = load_candles(db_path, symbol, interval_min, lookback_days=chart_days)
if max_candles > 0 and len(df) > max_candles:
df = df.iloc[-max_candles:].reset_index(drop=True)
payload = _build_chart_payload(
df, gt_result, chart_days, gt_lookback_days, sim_pnl=sim_pnl
)
output_path.parent.mkdir(parents=True, exist_ok=True)
data_path = _data_js_path(output_path)
with data_path.open("w", encoding="utf-8") as fp:
fp.write("window.CHART_DATA=")
json.dump(payload, fp, ensure_ascii=False, separators=(",", ":"))
fp.write(";")
data_js_name = data_path.name
output_path.write_text(_sim_html_template(data_js_name), encoding="utf-8")
return output_path

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from datetime import datetime, timedelta
from typing import Any from typing import Any
@@ -22,6 +23,28 @@ class LegPnl:
btc_qty: float btc_qty: float
@dataclass
class SignalTrade:
"""신호 1건 실행 기록."""
trade_id: int
side: str
signal_type: str
marker_id: int | None
datetime: str
price: float
cash_before: float
cash_after: float
coin_before: float
coin_after: float
order_krw: float
order_coin: float
fee_krw: float
cluster_size: int
skipped: bool
skip_reason: str | None = None
def simulate_gt_pnl( def simulate_gt_pnl(
legs: list[dict[str, Any]], legs: list[dict[str, Any]],
initial_cash_krw: float = 400_000.0, initial_cash_krw: float = 400_000.0,
@@ -99,3 +122,272 @@ def simulate_gt_pnl(
"period_to": period_to, "period_to": period_to,
"leg_pnls": [asdict(x) for x in leg_pnls], "leg_pnls": [asdict(x) for x in leg_pnls],
} }
def _parse_signal_dt(value: str) -> datetime:
"""GT signal datetime 문자열을 파싱한다."""
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
def _cluster_signals(signals: list[dict[str, Any]]) -> list[tuple[str, list[dict[str, Any]]]]:
"""연속 동일 side 신호를 클러스터로 묶는다."""
ordered = sorted(signals, key=lambda s: (s["bar_index"], s.get("marker_id", 0)))
clusters: list[tuple[str, list[dict[str, Any]]]] = []
current_side: str | None = None
current: list[dict[str, Any]] = []
for sig in ordered:
side = sig["side"]
if current_side is None:
current_side = side
current = [sig]
continue
if side == current_side:
current.append(sig)
continue
clusters.append((current_side, current))
current_side = side
current = [sig]
if current_side and current:
clusters.append((current_side, current))
return clusters
def simulate_gt_signals_pnl(
signals: list[dict[str, Any]],
initial_cash_krw: float = 400_000.0,
fee_rate: float = 0.0005,
min_order_krw: float = 5_000.0,
sim_lookback_days: int = 365,
data_end: str | None = None,
last_mark_price: float | None = None,
) -> dict[str, Any]:
"""GT 매수·매도 신호를 시간순 실행한 2단계 포트폴리오 시뮬레이션.
- 시뮬 기간: data_end 기준 최근 sim_lookback_days
- 연속 매수: 가용 원화를 매수 신호 수로 균등 분할
- 연속 매도: 보유 코인을 매도 신호 수로 균등 분할
- 원화 부족 시 매수 스킵, 코인 없으면 매도 스킵
Args:
signals: GT signals 리스트.
initial_cash_krw: 시뮬 시작 원화.
fee_rate: 편도 수수료율.
min_order_krw: 최소 주문 금액.
sim_lookback_days: 시뮬 기간(일).
data_end: 데이터 종료 시각 문자열. None이면 마지막 신호 시각.
last_mark_price: 미청산 코인 평가 가격. None이면 마지막 체결가.
Returns:
요약 + 체결/스킵 내역 dict.
"""
if not signals:
return _empty_signal_pnl(initial_cash_krw, fee_rate, sim_lookback_days)
end_dt = _parse_signal_dt(data_end) if data_end else max(
_parse_signal_dt(s["datetime"]) for s in signals
)
start_dt = end_dt - timedelta(days=sim_lookback_days)
start_str = start_dt.strftime("%Y-%m-%d %H:%M:%S")
period_signals = [
s for s in signals if _parse_signal_dt(s["datetime"]) >= start_dt
]
if not period_signals:
return _empty_signal_pnl(
initial_cash_krw,
fee_rate,
sim_lookback_days,
period_from=start_str,
period_to=end_dt.strftime("%Y-%m-%d %H:%M:%S"),
)
cash = float(initial_cash_krw)
coin_qty = 0.0
trades: list[SignalTrade] = []
trade_id = 0
buys_executed = 0
sells_executed = 0
buys_skipped = 0
sells_skipped = 0
mark_price = float(last_mark_price or period_signals[-1]["price"])
for side, cluster in _cluster_signals(period_signals):
cluster_size = len(cluster)
if side == "buy":
budget = cash
per_buy = budget / cluster_size if cluster_size else 0.0
for sig in cluster:
trade_id += 1
price = float(sig["price"])
cash_before = cash
coin_before = coin_qty
order_krw = min(per_buy, cash)
if order_krw < min_order_krw:
buys_skipped += 1
trades.append(
SignalTrade(
trade_id=trade_id,
side="buy",
signal_type=str(sig.get("signal_type", "buy")),
marker_id=sig.get("marker_id"),
datetime=sig["datetime"],
price=price,
cash_before=round(cash_before, 0),
cash_after=round(cash, 0),
coin_before=round(coin_before, 8),
coin_after=round(coin_qty, 8),
order_krw=0.0,
order_coin=0.0,
fee_krw=0.0,
cluster_size=cluster_size,
skipped=True,
skip_reason="원화 부족",
)
)
continue
fee = order_krw * fee_rate
bought = (order_krw - fee) / price
cash -= order_krw
coin_qty += bought
buys_executed += 1
trades.append(
SignalTrade(
trade_id=trade_id,
side="buy",
signal_type=str(sig.get("signal_type", "buy")),
marker_id=sig.get("marker_id"),
datetime=sig["datetime"],
price=price,
cash_before=round(cash_before, 0),
cash_after=round(cash, 0),
coin_before=round(coin_before, 8),
coin_after=round(coin_qty, 8),
order_krw=round(order_krw, 0),
order_coin=round(bought, 8),
fee_krw=round(fee, 0),
cluster_size=cluster_size,
skipped=False,
)
)
else:
budget_coin = coin_qty
per_sell = budget_coin / cluster_size if cluster_size else 0.0
for sig in cluster:
trade_id += 1
price = float(sig["price"])
cash_before = cash
coin_before = coin_qty
order_coin = min(per_sell, coin_qty)
order_krw = order_coin * price
if order_coin <= 0 or order_krw < min_order_krw:
sells_skipped += 1
trades.append(
SignalTrade(
trade_id=trade_id,
side="sell",
signal_type=str(sig.get("signal_type", "sell")),
marker_id=sig.get("marker_id"),
datetime=sig["datetime"],
price=price,
cash_before=round(cash_before, 0),
cash_after=round(cash, 0),
coin_before=round(coin_before, 8),
coin_after=round(coin_qty, 8),
order_krw=0.0,
order_coin=0.0,
fee_krw=0.0,
cluster_size=cluster_size,
skipped=True,
skip_reason="코인 부족",
)
)
continue
gross = order_coin * price
fee = gross * fee_rate
cash += gross - fee
coin_qty -= order_coin
sells_executed += 1
trades.append(
SignalTrade(
trade_id=trade_id,
side="sell",
signal_type=str(sig.get("signal_type", "sell")),
marker_id=sig.get("marker_id"),
datetime=sig["datetime"],
price=price,
cash_before=round(cash_before, 0),
cash_after=round(cash, 0),
coin_before=round(coin_before, 8),
coin_after=round(coin_qty, 8),
order_krw=round(gross, 0),
order_coin=round(order_coin, 8),
fee_krw=round(fee, 0),
cluster_size=cluster_size,
skipped=False,
)
)
coin_value = coin_qty * mark_price
final_equity = cash + coin_value
total_pnl = final_equity - initial_cash_krw
total_return_pct = total_pnl / initial_cash_krw * 100.0
return {
"mode": "signal_split",
"initial_cash_krw": initial_cash_krw,
"final_cash_krw": round(cash, 0),
"final_coin_qty": round(coin_qty, 8),
"final_mark_price": round(mark_price, 2),
"final_coin_value_krw": round(coin_value, 0),
"final_equity_krw": round(final_equity, 0),
"total_pnl_krw": round(total_pnl, 0),
"total_return_pct": round(total_return_pct, 2),
"fee_rate": fee_rate,
"sim_lookback_days": sim_lookback_days,
"period_from": start_str,
"period_to": end_dt.strftime("%Y-%m-%d %H:%M:%S"),
"signals_in_period": len(period_signals),
"buys_executed": buys_executed,
"sells_executed": sells_executed,
"buys_skipped": buys_skipped,
"sells_skipped": sells_skipped,
"trades": [asdict(t) for t in trades],
}
def _empty_signal_pnl(
initial_cash_krw: float,
fee_rate: float,
sim_lookback_days: int,
period_from: str | None = None,
period_to: str | None = None,
) -> dict[str, Any]:
"""신호가 없을 때의 빈 시뮬 결과."""
return {
"mode": "signal_split",
"initial_cash_krw": initial_cash_krw,
"final_cash_krw": initial_cash_krw,
"final_coin_qty": 0.0,
"final_mark_price": 0.0,
"final_coin_value_krw": 0.0,
"final_equity_krw": initial_cash_krw,
"total_pnl_krw": 0.0,
"total_return_pct": 0.0,
"fee_rate": fee_rate,
"sim_lookback_days": sim_lookback_days,
"period_from": period_from,
"period_to": period_to,
"signals_in_period": 0,
"buys_executed": 0,
"sells_executed": 0,
"buys_skipped": 0,
"sells_skipped": 0,
"trades": [],
}