From 90310420782e66b5b14132b89fe78277747bd095 Mon Sep 17 00:00:00 2001 From: dsyoon Date: Tue, 9 Jun 2026 08:51:23 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Ground=20Truth=20=EC=B0=A8=ED=8A=B8=20v?= =?UTF-8?q?1/v2/v3=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EC=B0=A8=ED=8A=B8?= =?UTF-8?q?=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 스윙만(v1), 눌림목(v2), 전체 신호(v3)로 GT를 단계별 생성하고 마커·Y축 가독성을 개선한다. Co-authored-by: Cursor --- .env.example | 8 +- .gitignore | 16 --- scripts/02_ground_truth.py | 140 ++++++++++++++-------- src/deepcoin/config.py | 46 +++++-- src/deepcoin/ground_truth/chart.py | 135 ++++++++++++++++----- src/deepcoin/ground_truth/ground_truth.py | 80 +++++++++---- 6 files changed, 299 insertions(+), 126 deletions(-) diff --git a/.env.example b/.env.example index a9a7eba..56597e3 100644 --- a/.env.example +++ b/.env.example @@ -40,8 +40,12 @@ GT_DIV_LOCAL_ORDER=20 GT_DIV_MIN_BARS_BETWEEN=1500 GT_DIV_MIN_RSI_DIFF=5.0 GT_DIV_MIN_FUTURE_MOVE_PCT=4.0 -GROUND_TRUTH_FILE=data/ground_truth/ground_truth_trades.json -GROUND_TRUTH_CHART_FILE=docs/02_ground_truth/ground_truth_chart.html +GROUND_TRUTH_FILE=data/ground_truth/ground_truth_trades_v3.json +GROUND_TRUTH_V1_FILE=data/ground_truth/ground_truth_trades_v1.json +GROUND_TRUTH_V2_FILE=data/ground_truth/ground_truth_trades_v2.json +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_V3_FILE=docs/02_ground_truth/ground_truth_chart_v3.html # --- 매매 기법 (2단계) --- TECHNIQUES_DIR=data/techniques diff --git a/.gitignore b/.gitignore index 1cce449..acf62f6 100644 --- a/.gitignore +++ b/.gitignore @@ -96,19 +96,3 @@ ENV/ # Rope project settings .ropeproject - -# 재생성 산출물 (스크립트 실행 시 로컬 생성) -docs/02_ground_truth/*.html -docs/02_ground_truth/ground_truth_chart_data.js -data/ground_truth/ground_truth_trades.json -data/techniques/*.json -docs/03_analysis/*.html -docs/03_analysis/comparison_report.json -docs/03_analysis/latest/ -docs/04_matching/simulation_report.html -docs/04_matching/backtest_summary.html -docs/04_matching/gt_overlap_report.json -data/ops/live_sizing_state.json -data/ops/portfolio_snapshots.jsonl -data/ops/live_trades.jsonl -docs/05_ops/live_verification_*.md diff --git a/scripts/02_ground_truth.py b/scripts/02_ground_truth.py index a976c47..c88288d 100644 --- a/scripts/02_ground_truth.py +++ b/scripts/02_ground_truth.py @@ -6,18 +6,26 @@ from __future__ import annotations import argparse 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 load_settings +from deepcoin.config import Settings, load_settings from deepcoin.data.intervals import interval_label from deepcoin.ground_truth.chart import render_ground_truth_chart from deepcoin.ground_truth.ground_truth import GtParams, build_ground_truth, save_ground_truth +TIER_DESCRIPTIONS = { + "v1": "스윙만 (최소 매수·매도)", + "v2": "스윙 + 눌림목", + "v3": "스윙 + 눌림목 + 돌파 + 다이버전스", +} + def _configure_logging(verbose: bool) -> None: """로깅 레벨을 설정한다.""" @@ -29,21 +37,9 @@ def _configure_logging(verbose: bool) -> None: ) -def main() -> int: - """CLI 진입점.""" - parser = argparse.ArgumentParser(description="Ground Truth 스윙 레그 생성 (1단계)") - parser.add_argument("--interval", type=int, default=None, help="GT 인터벌(분)") - parser.add_argument("--days", type=int, default=None, help="GT·타점 기간(일). 기본 730(2년)") - parser.add_argument("--zigzag", type=float, default=None, help="ZigZag 되돌림 %%") - parser.add_argument("--min-leg", type=float, default=None, help="최소 레그 수익률 %%") - parser.add_argument("--no-chart", action="store_true", help="HTML 차트 생략") - parser.add_argument("-v", "--verbose", action="store_true") - args = parser.parse_args() - - _configure_logging(args.verbose) - settings = load_settings() - - params = GtParams( +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, @@ -59,34 +55,33 @@ def main() -> int: div_min_future_move_pct=settings.gt_div_min_future_move_pct, ) - logging.info( - "GT 생성: %s %s, %s일, ZigZag=%s%%, min_leg=%s%%, 초기=%s원", - settings.symbol, - interval_label(params.interval_min), - params.lookback_days, - params.zigzag_reversal_pct, - params.min_leg_pct, - f"{settings.gt_initial_cash_krw:,.0f}", - ) - 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, - ) +def _tier_targets(settings: Settings, tier_arg: str) -> list[tuple[str, Path, Path]]: + """생성할 티어 목록 (tier, json_path, chart_path).""" + all_tiers: dict[str, tuple[Path, Path]] = { + "v1": (settings.ground_truth_v1_file, settings.ground_truth_chart_v1_file), + "v2": (settings.ground_truth_v2_file, settings.ground_truth_chart_v2_file), + "v3": (settings.ground_truth_file, settings.ground_truth_chart_v3_file), + } + if tier_arg == "all": + return [(t, *paths) for t, paths in all_tiers.items()] + return [(tier_arg, *all_tiers[tier_arg])] - out_json = save_ground_truth(result, settings.ground_truth_file) + +def _print_tier_summary( + tier: str, + result: dict[str, Any], + json_path: Path, + chart_path: Path | None, +) -> None: + """티어별 GT 요약을 출력한다.""" summary = result["summary"] meta = result["meta"] pnl = result["pnl"] - print("\n=== Ground Truth 완료 (1단계 벤치마크) ===") + print(f"\n=== Ground Truth {tier.upper()} ({TIER_DESCRIPTIONS[tier]}) ===") print(f"대상: {meta['symbol']} ({meta['interval_label']})") print(f"GT·수익 기간: {meta['data_from']} ~ {meta['data_to']} ({meta['bar_count']}봉)") - print(f"차트·타점: 최근 {settings.download_days}일 (2년)") print(f"피벗: {meta['pivot_count']}개 → 레그: {summary['leg_count']}개") print( f"매수 타점: {summary['buy_count']}개 " @@ -99,22 +94,71 @@ def main() -> int: period = "" if pnl.get("period_from"): period = f" ({pnl['period_from'][:10]} ~ {pnl['period_to'][:10]})" - print(f"\n=== 누적 수익{period} — 초기 {pnl['initial_cash_krw']:,.0f}원 ===") - print(f"최종 자산: {pnl['final_cash_krw']:,.0f}원") - print(f"손익: {pnl['total_pnl_krw']:+,.0f}원") - print(f"기간 수익률: {pnl['total_return_pct']:+.2f}%") - print(f"거래 레그: {pnl['legs_traded']}건") - print(f"JSON: {out_json}") + print(f"누적 수익{period}: {pnl['final_cash_krw']:,.0f}원 ({pnl['total_return_pct']:+.2f}%)") + print(f"JSON: {json_path}") + if chart_path: + print(f"차트: {chart_path}") - if not args.no_chart: - chart_path = render_ground_truth_chart( + +def main() -> int: + """CLI 진입점.""" + parser = argparse.ArgumentParser(description="Ground Truth 스윙 레그 생성 (1단계)") + parser.add_argument("--interval", type=int, default=None, help="GT 인터벌(분)") + parser.add_argument("--days", type=int, default=None, help="GT·타점 기간(일). 기본 730(2년)") + parser.add_argument("--zigzag", type=float, default=None, help="ZigZag 되돌림 %%") + parser.add_argument("--min-leg", type=float, default=None, help="최소 레그 수익률 %%") + parser.add_argument("--no-chart", action="store_true", help="HTML 차트 생략") + parser.add_argument( + "--tier", + choices=("v1", "v2", "v3", "all"), + default="all", + help="생성할 GT 버전 (v1=스윙만, v2=+눌림, v3=전체, all=3종)", + ) + parser.add_argument("-v", "--verbose", action="store_true") + args = parser.parse_args() + + _configure_logging(args.verbose) + settings = load_settings() + base = _base_params(settings, args) + tiers = _tier_targets(settings, args.tier) + + logging.info( + "GT 생성: %s %s, %s일, ZigZag=%s%%, min_leg=%s%%, 초기=%s원, tier=%s", + settings.symbol, + interval_label(base.interval_min), + base.lookback_days, + base.zigzag_reversal_pct, + base.min_leg_pct, + f"{settings.gt_initial_cash_krw:,.0f}", + args.tier, + ) + + print("\n=== Ground Truth 완료 (1단계 벤치마크) ===") + print(f"차트·타점 표시: 최근 {settings.download_days}일") + + for tier, json_path, chart_path in tiers: + params = replace(base, chart_tier=tier) + result = build_ground_truth( db_path=settings.db_path, symbol=settings.symbol, - gt_result=result, - output_path=settings.ground_truth_chart_file, - chart_lookback_days=settings.download_days, + coin_name=settings.coin_name, + params=params, + initial_cash_krw=settings.gt_initial_cash_krw, + fee_rate=settings.gt_trading_fee_rate, ) - print(f"차트: {chart_path}") + save_ground_truth(result, json_path) + + rendered: Path | None = None + if not args.no_chart: + rendered = render_ground_truth_chart( + db_path=settings.db_path, + symbol=settings.symbol, + gt_result=result, + output_path=chart_path, + chart_lookback_days=settings.download_days, + ) + + _print_tier_summary(tier, result, json_path, rendered) return 0 diff --git a/src/deepcoin/config.py b/src/deepcoin/config.py index 3f448e9..417af26 100644 --- a/src/deepcoin/config.py +++ b/src/deepcoin/config.py @@ -13,6 +13,14 @@ from deepcoin.data.intervals import DEFAULT_DOWNLOAD_INTERVALS _PROJECT_ROOT = Path(__file__).resolve().parents[2] +def _resolve_project_path(raw: str) -> Path: + """프로젝트 루트 기준 상대 경로를 절대 경로로 변환한다.""" + path = Path(raw) + if not path.is_absolute(): + path = _PROJECT_ROOT / path + return path + + def _parse_int_list(raw: str) -> list[int]: """쉼표 구분 정수 목록을 파싱한다. @@ -55,7 +63,11 @@ class Settings: gt_div_min_rsi_diff: float gt_div_min_future_move_pct: float ground_truth_file: Path - ground_truth_chart_file: Path + ground_truth_v1_file: Path + ground_truth_v2_file: Path + ground_truth_chart_v1_file: Path + ground_truth_chart_v2_file: Path + ground_truth_chart_v3_file: Path gt_initial_cash_krw: float gt_trading_fee_rate: float # Techniques (2단계) @@ -91,14 +103,24 @@ def load_settings(env_path: Path | None = None) -> Settings: if not db_path.is_absolute(): db_path = _PROJECT_ROOT / db_path - gt_file_raw = os.getenv("GROUND_TRUTH_FILE", "data/ground_truth/ground_truth_trades.json") - gt_chart_raw = os.getenv("GROUND_TRUTH_CHART_FILE", "docs/02_ground_truth/ground_truth_chart.html") - gt_file = Path(gt_file_raw) - gt_chart = Path(gt_chart_raw) - if not gt_file.is_absolute(): - gt_file = _PROJECT_ROOT / gt_file - if not gt_chart.is_absolute(): - gt_chart = _PROJECT_ROOT / gt_chart + gt_file = _resolve_project_path( + os.getenv("GROUND_TRUTH_FILE", "data/ground_truth/ground_truth_trades_v3.json") + ) + gt_v1_file = _resolve_project_path( + os.getenv("GROUND_TRUTH_V1_FILE", "data/ground_truth/ground_truth_trades_v1.json") + ) + gt_v2_file = _resolve_project_path( + os.getenv("GROUND_TRUTH_V2_FILE", "data/ground_truth/ground_truth_trades_v2.json") + ) + gt_chart_v1 = _resolve_project_path( + os.getenv("GROUND_TRUTH_CHART_V1_FILE", "docs/02_ground_truth/ground_truth_chart_v1.html") + ) + gt_chart_v2 = _resolve_project_path( + os.getenv("GROUND_TRUTH_CHART_V2_FILE", "docs/02_ground_truth/ground_truth_chart_v2.html") + ) + gt_chart_v3 = _resolve_project_path( + os.getenv("GROUND_TRUTH_CHART_V3_FILE", "docs/02_ground_truth/ground_truth_chart_v3.html") + ) tech_dir_raw = os.getenv("TECHNIQUES_DIR", "data/techniques") tech_dir = Path(tech_dir_raw) @@ -138,7 +160,11 @@ def load_settings(env_path: Path | None = None) -> Settings: gt_div_min_rsi_diff=float(os.getenv("GT_DIV_MIN_RSI_DIFF", "5.0")), gt_div_min_future_move_pct=float(os.getenv("GT_DIV_MIN_FUTURE_MOVE_PCT", "4.0")), ground_truth_file=gt_file, - ground_truth_chart_file=gt_chart, + ground_truth_v1_file=gt_v1_file, + ground_truth_v2_file=gt_v2_file, + ground_truth_chart_v1_file=gt_chart_v1, + ground_truth_chart_v2_file=gt_chart_v2, + ground_truth_chart_v3_file=gt_chart_v3, 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")), techniques_dir=tech_dir, diff --git a/src/deepcoin/ground_truth/chart.py b/src/deepcoin/ground_truth/chart.py index 6ef6425..41ceb70 100644 --- a/src/deepcoin/ground_truth/chart.py +++ b/src/deepcoin/ground_truth/chart.py @@ -15,8 +15,11 @@ DEFAULT_MAX_CANDLES = 0 def _data_js_path(html_path: Path) -> Path: - """HTML과 짝을 이루는 데이터 JS 경로 (file:// 프로토콜 호환).""" - return html_path.with_name("ground_truth_chart_data.js") + """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 render_ground_truth_chart( @@ -95,7 +98,8 @@ def render_ground_truth_chart( json.dump(payload, fp, ensure_ascii=False, separators=(",", ":")) fp.write(";") - output_path.write_text(_HTML_TEMPLATE, encoding="utf-8") + data_js_name = data_path.name + output_path.write_text(_html_template(data_js_name), encoding="utf-8") return output_path @@ -107,7 +111,7 @@ _HTML_TEMPLATE = """ - + @@ -175,10 +180,41 @@ _HTML_TEMPLATE = """ 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"); @@ -186,6 +222,27 @@ _HTML_TEMPLATE = """ el.textContent = `타점 ${currentLegIdx + 1} / ${total}`; } + const MARKER_FONT = "bold 18px Malgun Gothic, Arial, sans-serif"; + + 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 drawMarkers(u, buys, sells) { if (!showMarkers) return; const ctx = u.ctx; @@ -195,21 +252,16 @@ _HTML_TEMPLATE = """ if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return; ctx.fillStyle = color; ctx.beginPath(); - const s = 5; + const s = 8; + const gap = 12; if (up) { - ctx.moveTo(x, y + 10); ctx.lineTo(x - s, y + 18); ctx.lineTo(x + s, y + 18); + ctx.moveTo(x, y + gap); ctx.lineTo(x - s, y + gap + 16); ctx.lineTo(x + s, y + gap + 16); } else { - ctx.moveTo(x, y - 10); ctx.lineTo(x - s, y - 18); ctx.lineTo(x + s, y - 18); + ctx.moveTo(x, y - gap); ctx.lineTo(x - s, y - gap - 16); ctx.lineTo(x + s, y - gap - 16); } ctx.closePath(); ctx.fill(); - ctx.fillStyle = "#333"; - ctx.font = "10px Malgun Gothic, Arial"; - let suffix = ""; - if (m.signal_type === "pullback") suffix = "*"; - else if (m.signal_type === "breakout") suffix = "^"; - else if (m.signal_type === "div_bull" || m.signal_type === "div_bear") suffix = "d"; - const label = (up ? "B" : "S") + m.marker_id + suffix; - ctx.fillText(label, x + 6, y + (up ? 14 : -12)); + 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"; @@ -252,13 +304,21 @@ _HTML_TEMPLATE = """ 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: [ - {}, - { values: (u, vals) => vals.map(v => fmtPrice(v)) }, + { gap: 6 }, + { + side: 3, + size: yAxisW, + gap: 10, + font: AXIS_FONT, + values: (u, vals) => vals.map(v => fmtPrice(v)), + }, ], series: [ {}, @@ -289,14 +349,22 @@ _HTML_TEMPLATE = """ 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" }, + 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", @@ -319,17 +387,16 @@ _HTML_TEMPLATE = """ time: m.time, position: "belowBar", color: m.signal_type === "breakout" ? "#ef6c00" : m.signal_type === "div_bull" ? "#7b1fa2" : "#2e7d32", - shape: "arrowUp", - text: "B" + m.marker_id + (m.signal_type === "pullback" ? "*" - : m.signal_type === "breakout" ? "^" : m.signal_type === "div_bull" ? "d" : ""), + 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", - text: "S" + m.marker_id + (m.signal_type === "div_bear" ? "d" : ""), + shape: "arrowDown", size: 3, + text: "S" + m.marker_id + markerSuffix(m.signal_type), }); }); } @@ -438,19 +505,26 @@ _HTML_TEMPLATE = """ function init() { DATA = window.CHART_DATA; - if (!DATA) throw new Error("ground_truth_chart_data.js 없음"); + if (!DATA) throw new Error("차트 데이터 JS 없음"); const m = DATA.meta; const chartDays = m.chart_lookback_days || m.lookback_days; const gtDays = m.gt_lookback_days || m.lookback_days; const chartLabel = chartDays >= 365 ? `${Math.round(chartDays / 365)}년` : `${chartDays}일`; const gtLabel = gtDays >= 365 ? `${Math.round(gtDays / 365)}년` : `${gtDays}일`; + const tier = m.chart_tier ? ` ${m.chart_tier.toUpperCase()}` : ""; document.getElementById("title").textContent = - `${m.symbol} Ground Truth (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}`; + `${m.symbol} Ground Truth${tier} (${m.interval_label}) — 차트 ${chartLabel} / GT ${gtLabel}`; document.getElementById("btn-all").textContent = `전체 ${chartLabel}`; const chartFrom = m.chart_data_from || m.data_from; const chartTo = m.chart_data_to || m.data_to; + const tierKey = (m.chart_tier || "v3").toLowerCase(); + const legend = tierKey === "v1" + ? "B=스윙매수 S=스윙매도" + : tierKey === "v2" + ? "B/S=스윙 B*=눌림목" + : "B/S=스윙 B*=눌림 B^=돌파 Bd/Sd=다이버전스"; document.getElementById("meta").textContent = - `차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | 매수 ${DATA.buy_markers.length} / 매도 ${DATA.sell_markers.length} (${gtLabel}) | B*=눌림 B^=돌파 Bd/Sd=다이버전스`; + `차트 ${chartFrom} ~ ${chartTo} (${DATA.bar_count.toLocaleString()}봉) | 매수 ${DATA.buy_markers.length} / 매도 ${DATA.sell_markers.length} (${gtLabel}) | ${legend}`; updateLegInfo(); document.getElementById("status").textContent = `전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`; @@ -498,3 +572,8 @@ _HTML_TEMPLATE = """ """ + + +def _html_template(data_js_name: str) -> str: + """차트 HTML 템플릿을 생성한다.""" + return _HTML_TEMPLATE.replace("__DATA_JS_NAME__", data_js_name) diff --git a/src/deepcoin/ground_truth/ground_truth.py b/src/deepcoin/ground_truth/ground_truth.py index 9a0cb09..2af125f 100644 --- a/src/deepcoin/ground_truth/ground_truth.py +++ b/src/deepcoin/ground_truth/ground_truth.py @@ -36,6 +36,22 @@ class GtParams: div_min_bars_between: int = 1500 div_min_rsi_diff: float = 5.0 div_min_future_move_pct: float = 4.0 + chart_tier: str = "v3" + + +def _tier_flags(tier: str) -> tuple[bool, bool, bool]: + """차트 버전별 보조 신호 포함 여부 (눌림목, 돌파, 다이버전스). + + v1: ZigZag 스윙만 (레그당 1매수·1매도 최소) + v2: 스윙 + 눌림목 + v3: v2 + 돌파 + 다이버전스 + """ + tier = tier.lower() + if tier == "v1": + return False, False, False + if tier == "v2": + return True, False, False + return True, True, True @dataclass @@ -87,27 +103,46 @@ def build_ground_truth( pivots = find_zigzag_pivots(df, reversal_pct=params.zigzag_reversal_pct) legs = _pivots_to_legs(pivots, min_leg_pct=params.min_leg_pct) leg_dicts = [asdict(leg) for leg in legs] - pullback_buys = find_pullback_buy_pivots( - df, - legs=legs, - min_pullback_pct=params.pullback_min_pct, - local_order=params.pullback_local_order, - ) - breakout_buys = find_breakout_buy_pivots( - df, - legs=legs, - pullback_buys=pullback_buys, - breakout_buffer_pct=params.breakout_buffer_pct, - consolidation_bars=params.breakout_consolidation_bars, - min_rally_to_sell_pct=params.breakout_min_rally_pct, - ) - div_buys, div_sells = find_divergence_signals( - df, - local_order=params.div_local_order, - min_bars_between=params.div_min_bars_between, - min_rsi_diff=params.div_min_rsi_diff, - min_future_move_pct=params.div_min_future_move_pct, - ) + include_pullback, include_breakout, include_divergence = _tier_flags(params.chart_tier) + + pullback_buys: list[Pivot] = [] + if include_pullback: + pullback_buys = find_pullback_buy_pivots( + df, + legs=legs, + min_pullback_pct=params.pullback_min_pct, + local_order=params.pullback_local_order, + ) + + breakout_buys = [] + if include_breakout: + breakout_buys = find_breakout_buy_pivots( + df, + legs=legs, + pullback_buys=pullback_buys, + breakout_buffer_pct=params.breakout_buffer_pct, + consolidation_bars=params.breakout_consolidation_bars, + min_rally_to_sell_pct=params.breakout_min_rally_pct, + ) + + div_buys: list = [] + div_sells: list = [] + if include_divergence: + div_buys, div_sells = find_divergence_signals( + df, + local_order=params.div_local_order, + min_bars_between=params.div_min_bars_between, + min_rsi_diff=params.div_min_rsi_diff, + min_future_move_pct=params.div_min_future_move_pct, + ) + + mode_map = { + "v1": "optimal_swing_legs", + "v2": "optimal_swing_legs_with_pullback", + "v3": "optimal_swing_legs_with_pullback_breakout_divergence", + } + mode = mode_map.get(params.chart_tier.lower(), mode_map["v3"]) + signals = _build_signals(legs, pullback_buys, breakout_buys, div_buys, div_sells) summary = _summarize(legs, signals) pnl = simulate_gt_pnl(leg_dicts, initial_cash_krw=initial_cash_krw, fee_rate=fee_rate) @@ -119,7 +154,8 @@ def build_ground_truth( "interval_min": params.interval_min, "interval_label": interval_label(params.interval_min), "lookback_days": params.lookback_days, - "mode": "optimal_swing_legs_with_pullback_breakout_divergence", + "chart_tier": params.chart_tier.lower(), + "mode": mode, "zigzag_reversal_pct": params.zigzag_reversal_pct, "min_leg_pct": params.min_leg_pct, "pullback_min_pct": params.pullback_min_pct,