운영 백테스트(+1,873,140%)과 live/paper 체결 규칙을 맞추고, 캔들 증분 sync· tail 신호 갱신·일일 체결 상한·슬리피지를 반영한다. docs/live 차트 생성 스크립트와 .env.example·README를 갱신한다. Co-authored-by: Cursor <cursoragent@cursor.com>
1074 lines
41 KiB
Python
1074 lines
41 KiB
Python
"""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
|
||
|