Files
Bithumb/src/deepcoin/ground_truth/chart.py
xavis 2783826a03 feat(spot): fractal_swing live 운영 — 슬리피지·증분 sync·신호 tail 갱신
운영 백테스트(+1,873,140%)과 live/paper 체결 규칙을 맞추고, 캔들 증분 sync·
tail 신호 갱신·일일 체결 상한·슬리피지를 반영한다. docs/live 차트 생성 스크립트와
.env.example·README를 갱신한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 08:26:11 +09:00

1074 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Ground Truth 차트 HTML 생성 (전체 기간 지원)."""
from __future__ import annotations
import bisect
import json
from pathlib import Path
from typing import Any
import pandas as pd
from deepcoin.data.candle_loader import load_candles
# 0이면 제한 없이 전체 봉 표시
DEFAULT_MAX_CANDLES = 0
def _data_js_path(html_path: Path) -> Path:
"""HTML과 짝을 이루는 데이터 JS 경로 (file:// 프로토콜 호환).
예: ground_truth_chart_v3.html → ground_truth_chart_v3_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 _close_at_timestamp(times: list[int], closes: list[float], ts: int) -> float:
"""차트 종가 배열에서 시각에 해당하는 종가를 반환한다.
Args:
times: unix 초 리스트.
closes: 종가 리스트.
ts: 조회 시각(unix 초).
Returns:
해당 봉 종가. 정확히 일치하는 봉이 없으면 가장 가까운 봉 종가.
"""
if not times:
return 0.0
idx = bisect.bisect_left(times, ts)
if idx >= len(times):
return closes[-1]
if idx > 0 and times[idx] != ts:
if abs(times[idx - 1] - ts) <= abs(times[idx] - ts):
idx -= 1
return closes[idx]
def _enrich_markers_chart_price(
markers: list[dict[str, Any]],
times: list[int],
closes: list[float],
) -> list[dict[str, Any]]:
"""마커에 종가 선(chart) 위치용 chart_price를 추가한다."""
return [
{**marker, "chart_price": _close_at_timestamp(times, closes, marker["time"])}
for marker in markers
]
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"])
closes = df["close"].astype(float).tolist()
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)
buy_markers = _enrich_markers_chart_price(buy_markers, times, closes)
sell_markers = _enrich_markers_chart_price(sell_markers, times, closes)
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": closes,
"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(
db_path: Path,
symbol: str,
gt_result: dict[str, Any],
output_path: Path,
chart_lookback_days: int | None = None,
max_candles: int = DEFAULT_MAX_CANDLES,
) -> Path:
"""GT 타점이 표시된 HTML 차트를 생성한다.
대용량(3분봉 2년 등)은 종가 라인으로 전체 기간을 표시하고,
JSON 데이터는 별도 파일로 분리한다.
Args:
db_path: SQLite 경로.
symbol: 코인 심볼.
gt_result: build_ground_truth 결과.
output_path: HTML 출력 경로.
chart_lookback_days: 차트에 표시할 일수. None이면 GT lookback과 동일.
max_candles: 0이면 전체, 양수면 최근 N봉만.
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)
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(_html_template(data_js_name), encoding="utf-8")
return output_path
_HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>DeepCoin 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="__DATA_JS_NAME__"></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; }
__EXTRA_STYLES__
</style>
</head>
<body>
<header>
<h1 id="title">DeepCoin Chart</h1>
<div class="meta" id="meta"></div>
</header>
__EXTRA_BODY__
<div class="toolbar">
<div class="toolbar-group">
<button id="btn-home" class="home" title="전체 기간 화면으로 복귀">홈</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 24px Malgun Gothic, Arial, sans-serif";
const SIM_START_COLOR = "#7b1fa2";
const ARROW_HALF = 12;
const ARROW_HEIGHT = 16;
const LABEL_OFFSET_X = 16;
function markerSuffix(signalType) {
if (signalType === "pullback") return "*";
if (signalType === "breakout") return "^";
if (signalType === "div_bull" || signalType === "div_bear") return "d";
return "";
}
function markerChartPrice(m) {
if (m.chart_price != null) return m.chart_price;
let lo = 0;
let hi = DATA.times.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (DATA.times[mid] < m.time) lo = mid + 1;
else hi = mid;
}
return DATA.close[lo];
}
function drawMarkerLabel(ctx, label, x, labelY, color) {
ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle";
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY);
ctx.fillStyle = color;
ctx.fillText(label, lx, labelY);
ctx.textBaseline = "alphabetic";
}
function drawTriangleOnLine(ctx, x, lineY, up, color) {
ctx.fillStyle = color;
ctx.beginPath();
if (up) {
ctx.moveTo(x - ARROW_HALF, lineY);
ctx.lineTo(x + ARROW_HALF, lineY);
ctx.lineTo(x, lineY + ARROW_HEIGHT);
} else {
ctx.moveTo(x - ARROW_HALF, lineY);
ctx.lineTo(x + ARROW_HALF, lineY);
ctx.lineTo(x, lineY - ARROW_HEIGHT);
}
ctx.closePath();
ctx.fill();
}
function drawSimStartMarker(u, marker) {
if (!marker) return;
const ctx = u.ctx;
const x = u.valToPos(marker.time, "x", true);
const lineY = u.valToPos(markerChartPrice(marker), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const color = SIM_START_COLOR;
drawTriangleOnLine(ctx, x, lineY, false, color);
const label = marker.label || "거래시작";
ctx.font = MARKER_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const labelY = lineY - ARROW_HEIGHT - 12;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY);
ctx.fillStyle = color;
ctx.fillText(label, x, labelY);
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
}
const LABEL_GAP = 24;
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 lineY = u.valToPos(markerChartPrice(m), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
drawTriangleOnLine(ctx, x, lineY, up, color);
const label = (up ? "B" : "S") + m.marker_id + markerSuffix(m.signal_type);
const labelY = up
? lineY + ARROW_HEIGHT + LABEL_GAP
: lineY - ARROW_HEIGHT - LABEL_GAP;
drawMarkerLabel(ctx, label, x, labelY, color);
};
buys.forEach(m => {
let color = "#2e7d32";
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: [40, 10, 40, 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: 6,
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: 10,
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: 10,
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();
}
}
__EXTRA_SCRIPT__
function resolveChartPresentation(m, simMode, chartLabel, gtLabel, simPnl) {
const stage = m.stage || "";
const techniqueName = m.technique_name || "";
const techniqueId = m.technique_id || "";
const simDays = (simPnl && simPnl.sim_lookback_days) || 1095;
const simLabel = simDays >= 365
? `최근 ${Math.round(simDays / 365)}년`
: `최근 ${simDays}일`;
const initCash = (simPnl && simPnl.initial_cash_krw) || 0;
const initLabel = initCash ? ` · 초기 ${Math.round(initCash).toLocaleString()}원` : "";
if (stage === "spot_2_causal_sim_best") {
const bench = m.stage1_benchmark_return_pct;
const benchNote = Number.isFinite(bench)
? ` | 1단계 v3 GT sim ${bench >= 0 ? "+" : ""}${bench.toFixed(2)}%`
: "";
return {
pageTitle: `${m.symbol} 인과 sim — ${techniqueName}`,
title: `${m.symbol} 2단계 인과 sim · 최고 수익 (${techniqueName}, ${m.interval_label}) — 차트 ${chartLabel}`,
panelTitle: `2단계 인과 sim · 최고 수익 기법 (${simLabel}${initLabel})`,
legend: "B=매수 S=매도 | 과거 데이터만 · 인과 기법 신호",
simNoteExtra: `기법 ${techniqueId} | 미래 데이터 미사용${benchNote}`,
};
}
if (stage === "spot_3_operations") {
const opsNote = m.ops_note || "";
return {
pageTitle: `${m.symbol} 운영 백테스트 — ${techniqueName}`,
title: `${m.symbol} 3단계 운영 백테스트 (${techniqueName}, ${m.interval_label}) — live 동일 규칙 · 차트 ${chartLabel}`,
panelTitle: `3단계 운영 백테스트 sim (${simLabel}${initLabel})`,
legend: "B=매수 S=매도 | 실제 체결만 표시",
simNoteExtra: `기법 ${techniqueId} | ${opsNote} | live/paper와 동일 체결 규칙`,
};
}
if (stage === "spot_2_causal_sim" || techniqueId) {
return {
pageTitle: `${m.symbol} 인과 sim — ${techniqueName}`,
title: `${m.symbol} 2단계 인과 sim (${techniqueName}, ${m.interval_label}) — 차트 ${chartLabel}`,
panelTitle: `2단계 인과 sim (${simLabel}${initLabel})`,
legend: "B=매수 S=매도 | 과거 데이터만 · 인과 기법 신호",
simNoteExtra: `기법 ${techniqueId} | 미래 데이터 미사용`,
};
}
if (simMode) {
const tier = m.chart_tier ? ` ${String(m.chart_tier).toUpperCase()}` : "";
const tierKey = (m.chart_tier || "v3").toLowerCase();
const legend = tierKey === "v1"
? "B=스윙매수 S=스윙매도"
: tierKey === "v2"
? "B/S=스윙 B*=눌림목"
: "B/S=스윙 B*=눌림 B^=돌파 Bd/Sd=다이버전스";
return {
pageTitle: `${m.symbol} GT sim${tier}`,
title: `${m.symbol} Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel} · 1단계 sim`,
panelTitle: `1단계 GT sim (${simLabel}${initLabel}) · 사후 최적 타점`,
legend: legend,
simNoteExtra: "사후 GT 타점 · 미래 데이터 사용 (벤치마크)",
};
}
const tier = m.chart_tier ? ` ${String(m.chart_tier).toUpperCase()}` : "";
const tierKey = (m.chart_tier || "v3").toLowerCase();
const legend = tierKey === "v1"
? "B=스윙매수 S=스윙매도"
: tierKey === "v2"
? "B/S=스윙 B*=눌림목"
: "B/S=스윙 B*=눌림 B^=돌파 Bd/Sd=다이버전스";
return {
pageTitle: `${m.symbol} Ground Truth${tier}`,
title: `${m.symbol} Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}`,
panelTitle: "",
legend: legend,
simNoteExtra: "",
};
}
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 pres = resolveChartPresentation(m, simMode, chartLabel, gtLabel, DATA.sim_pnl);
document.title = pres.pageTitle;
document.getElementById("title").textContent = pres.title;
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 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 ? " | ▼보라=거래시작" : "";
let metaLine =
`차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | 매수 ${DATA.buy_markers.length} / 매도 ${DATA.sell_markers.length} (${markerRange}) | ${pres.legend}${legendExtra}`;
if (m.comparison_label) {
metaLine += ` | 대조: ${m.comparison_label}`;
}
document.getElementById("meta").textContent = metaLine;
window.__SIM_NOTE_EXTRA__ = pres.simNoteExtra || "";
if (simMode) {
const panelTitle = document.getElementById("sim-panel-title");
if (panelTitle && pres.panelTitle) {
panelTitle.textContent = pres.panelTitle;
}
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>"""
def _html_template(data_js_name: str) -> str:
"""1단계 GT 차트 HTML 템플릿을 생성한다."""
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(200px, 1fr)); gap: 12px; }
.sim-card { padding: 10px 12px; background: #fafafa; border: 1px solid #eee; border-radius: 4px; min-width: 0; overflow: hidden; }
.sim-card .label { font-size: 12px; color: #777; margin-bottom: 4px; }
.sim-card .value { font-size: 18px; font-weight: bold; line-height: 1.25; max-width: 100%; }
.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 id="sim-panel-title">수익 sim</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 n = Number(v);
if (!Number.isFinite(n)) return "-";
const sign = n > 0 ? "+" : n < 0 ? "-" : "";
const formatted = Math.abs(n).toLocaleString("ko-KR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return sign + formatted + "%";
}
function fitSimCardValues() {
document.querySelectorAll(".sim-card .value").forEach(el => {
const card = el.closest(".sim-card");
if (!card) return;
const maxW = Math.max(card.clientWidth - 24, 80);
let size = 18;
el.style.fontSize = size + "px";
el.style.whiteSpace = "nowrap";
while (el.scrollWidth > maxW && size > 9) {
size -= 1;
el.style.fontSize = size + "px";
}
if (el.scrollWidth > maxW) {
el.style.whiteSpace = "normal";
el.style.wordBreak = "keep-all";
el.style.overflowWrap = "anywhere";
el.style.fontSize = "10px";
}
});
}
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)}%` +
(p.buy_sizing_rule ? ` | 매수 ${p.buy_sizing_rule}` : "") +
(window.__SIM_NOTE_EXTRA__ ? ` | ${window.__SIM_NOTE_EXTRA__}` : "");
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);
fitSimCardValues();
}
"""
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