feat: Ground Truth 차트 v1/v2/v3 분리 및 차트 UI 개선

스윙만(v1), 눌림목(v2), 전체 신호(v3)로 GT를 단계별 생성하고 마커·Y축 가독성을 개선한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dsyoon
2026-06-09 08:51:23 +09:00
parent df3c9aecb9
commit 9031042078
6 changed files with 299 additions and 126 deletions

View File

@@ -40,8 +40,12 @@ GT_DIV_LOCAL_ORDER=20
GT_DIV_MIN_BARS_BETWEEN=1500 GT_DIV_MIN_BARS_BETWEEN=1500
GT_DIV_MIN_RSI_DIFF=5.0 GT_DIV_MIN_RSI_DIFF=5.0
GT_DIV_MIN_FUTURE_MOVE_PCT=4.0 GT_DIV_MIN_FUTURE_MOVE_PCT=4.0
GROUND_TRUTH_FILE=data/ground_truth/ground_truth_trades.json GROUND_TRUTH_FILE=data/ground_truth/ground_truth_trades_v3.json
GROUND_TRUTH_CHART_FILE=docs/02_ground_truth/ground_truth_chart.html 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단계) --- # --- 매매 기법 (2단계) ---
TECHNIQUES_DIR=data/techniques TECHNIQUES_DIR=data/techniques

16
.gitignore vendored
View File

@@ -96,19 +96,3 @@ ENV/
# Rope project settings # Rope project settings
.ropeproject .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

View File

@@ -6,18 +6,26 @@ from __future__ import annotations
import argparse import argparse
import logging import logging
import sys import sys
from dataclasses import replace
from pathlib import Path from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src" SRC = ROOT / "src"
if str(SRC) not in sys.path: if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC)) 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.data.intervals import interval_label
from deepcoin.ground_truth.chart import render_ground_truth_chart from deepcoin.ground_truth.chart import render_ground_truth_chart
from deepcoin.ground_truth.ground_truth import GtParams, build_ground_truth, save_ground_truth 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: def _configure_logging(verbose: bool) -> None:
"""로깅 레벨을 설정한다.""" """로깅 레벨을 설정한다."""
@@ -29,21 +37,9 @@ def _configure_logging(verbose: bool) -> None:
) )
def main() -> int: def _base_params(settings: Settings, args: argparse.Namespace) -> GtParams:
"""CLI 진입점.""" """CLI·환경 설정을 반영한 공통 GT 파라미터."""
parser = argparse.ArgumentParser(description="Ground Truth 스윙 레그 생성 (1단계)") return GtParams(
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(
interval_min=args.interval or settings.gt_interval_min, interval_min=args.interval or settings.gt_interval_min,
lookback_days=args.days or settings.gt_lookback_days, lookback_days=args.days or settings.gt_lookback_days,
zigzag_reversal_pct=args.zigzag or settings.gt_zigzag_reversal_pct, 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, 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( def _tier_targets(settings: Settings, tier_arg: str) -> list[tuple[str, Path, Path]]:
db_path=settings.db_path, """생성할 티어 목록 (tier, json_path, chart_path)."""
symbol=settings.symbol, all_tiers: dict[str, tuple[Path, Path]] = {
coin_name=settings.coin_name, "v1": (settings.ground_truth_v1_file, settings.ground_truth_chart_v1_file),
params=params, "v2": (settings.ground_truth_v2_file, settings.ground_truth_chart_v2_file),
initial_cash_krw=settings.gt_initial_cash_krw, "v3": (settings.ground_truth_file, settings.ground_truth_chart_v3_file),
fee_rate=settings.gt_trading_fee_rate, }
) 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"] summary = result["summary"]
meta = result["meta"] meta = result["meta"]
pnl = result["pnl"] 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"대상: {meta['symbol']} ({meta['interval_label']})")
print(f"GT·수익 기간: {meta['data_from']} ~ {meta['data_to']} ({meta['bar_count']}봉)") 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"피벗: {meta['pivot_count']}개 → 레그: {summary['leg_count']}")
print( print(
f"매수 타점: {summary['buy_count']}" f"매수 타점: {summary['buy_count']}"
@@ -99,22 +94,71 @@ def main() -> int:
period = "" period = ""
if pnl.get("period_from"): if pnl.get("period_from"):
period = f" ({pnl['period_from'][:10]} ~ {pnl['period_to'][:10]})" period = f" ({pnl['period_from'][:10]} ~ {pnl['period_to'][:10]})"
print(f"\n=== 누적 수익{period} — 초기 {pnl['initial_cash_krw']:,.0f}===") print(f"누적 수익{period}: {pnl['final_cash_krw']:,.0f}({pnl['total_return_pct']:+.2f}%)")
print(f"최종 자산: {pnl['final_cash_krw']:,.0f}") print(f"JSON: {json_path}")
print(f"손익: {pnl['total_pnl_krw']:+,.0f}") if chart_path:
print(f"기간 수익률: {pnl['total_return_pct']:+.2f}%") print(f"차트: {chart_path}")
print(f"거래 레그: {pnl['legs_traded']}")
print(f"JSON: {out_json}")
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,
coin_name=settings.coin_name,
params=params,
initial_cash_krw=settings.gt_initial_cash_krw,
fee_rate=settings.gt_trading_fee_rate,
)
save_ground_truth(result, json_path)
rendered: Path | None = None
if not args.no_chart: if not args.no_chart:
chart_path = render_ground_truth_chart( rendered = render_ground_truth_chart(
db_path=settings.db_path, db_path=settings.db_path,
symbol=settings.symbol, symbol=settings.symbol,
gt_result=result, gt_result=result,
output_path=settings.ground_truth_chart_file, output_path=chart_path,
chart_lookback_days=settings.download_days, chart_lookback_days=settings.download_days,
) )
print(f"차트: {chart_path}")
_print_tier_summary(tier, result, json_path, rendered)
return 0 return 0

View File

@@ -13,6 +13,14 @@ from deepcoin.data.intervals import DEFAULT_DOWNLOAD_INTERVALS
_PROJECT_ROOT = Path(__file__).resolve().parents[2] _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]: def _parse_int_list(raw: str) -> list[int]:
"""쉼표 구분 정수 목록을 파싱한다. """쉼표 구분 정수 목록을 파싱한다.
@@ -55,7 +63,11 @@ class Settings:
gt_div_min_rsi_diff: float gt_div_min_rsi_diff: float
gt_div_min_future_move_pct: float gt_div_min_future_move_pct: float
ground_truth_file: Path 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_initial_cash_krw: float
gt_trading_fee_rate: float gt_trading_fee_rate: float
# Techniques (2단계) # Techniques (2단계)
@@ -91,14 +103,24 @@ def load_settings(env_path: Path | None = None) -> Settings:
if not db_path.is_absolute(): if not db_path.is_absolute():
db_path = _PROJECT_ROOT / db_path db_path = _PROJECT_ROOT / db_path
gt_file_raw = os.getenv("GROUND_TRUTH_FILE", "data/ground_truth/ground_truth_trades.json") gt_file = _resolve_project_path(
gt_chart_raw = os.getenv("GROUND_TRUTH_CHART_FILE", "docs/02_ground_truth/ground_truth_chart.html") os.getenv("GROUND_TRUTH_FILE", "data/ground_truth/ground_truth_trades_v3.json")
gt_file = Path(gt_file_raw) )
gt_chart = Path(gt_chart_raw) gt_v1_file = _resolve_project_path(
if not gt_file.is_absolute(): os.getenv("GROUND_TRUTH_V1_FILE", "data/ground_truth/ground_truth_trades_v1.json")
gt_file = _PROJECT_ROOT / gt_file )
if not gt_chart.is_absolute(): gt_v2_file = _resolve_project_path(
gt_chart = _PROJECT_ROOT / gt_chart 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_raw = os.getenv("TECHNIQUES_DIR", "data/techniques")
tech_dir = Path(tech_dir_raw) 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_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")), gt_div_min_future_move_pct=float(os.getenv("GT_DIV_MIN_FUTURE_MOVE_PCT", "4.0")),
ground_truth_file=gt_file, 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_initial_cash_krw=float(os.getenv("GT_INITIAL_CASH_KRW", "400000")),
gt_trading_fee_rate=float(os.getenv("GT_TRADING_FEE_RATE", "0.0005")), gt_trading_fee_rate=float(os.getenv("GT_TRADING_FEE_RATE", "0.0005")),
techniques_dir=tech_dir, techniques_dir=tech_dir,

View File

@@ -15,8 +15,11 @@ DEFAULT_MAX_CANDLES = 0
def _data_js_path(html_path: Path) -> Path: def _data_js_path(html_path: Path) -> Path:
"""HTML과 짝을 이루는 데이터 JS 경로 (file:// 프로토콜 호환).""" """HTML과 짝을 이루는 데이터 JS 경로 (file:// 프로토콜 호환).
return html_path.with_name("ground_truth_chart_data.js")
예: 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( def render_ground_truth_chart(
@@ -95,7 +98,8 @@ def render_ground_truth_chart(
json.dump(payload, fp, ensure_ascii=False, separators=(",", ":")) json.dump(payload, fp, ensure_ascii=False, separators=(",", ":"))
fp.write(";") 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 return output_path
@@ -107,7 +111,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.min.css" /> <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://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="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="ground_truth_chart_data.js"></script> <script src="__DATA_JS_NAME__"></script>
<style> <style>
body { font-family: "Malgun Gothic", Arial, sans-serif; margin: 0; background: #f5f5f5; color: #333; } 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; } header { padding: 16px 24px; background: #fff; border-bottom: 1px solid #ddd; }
@@ -123,10 +127,11 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
.toolbar button.home:hover { background: #1b5e20; } .toolbar button.home:hover { background: #1b5e20; }
.toolbar .leg-info { font-size: 12px; color: #555; min-width: 90px; } .toolbar .leg-info { font-size: 12px; color: #555; min-width: 90px; }
#status { font-size: 12px; color: #888; margin-left: auto; } #status { font-size: 12px; color: #888; margin-left: auto; }
#overview { height: 480px; margin: 12px 24px; background: #fff; border: 1px solid #ddd; } #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 { margin: 0 24px 12px; display: none; }
#detail-wrap h2 { font-size: 15px; margin: 0 0 8px; } #detail-wrap h2 { font-size: 15px; margin: 0 0 8px; }
#detail { height: 360px; background: #fff; border: 1px solid #ddd; } #detail { height: 360px; background: #fff; border: 1px solid #ddd; overflow: visible; }
</style> </style>
</head> </head>
<body> <body>
@@ -175,10 +180,41 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
let detailVisible = false; let detailVisible = false;
let lastDetailStart = 0; let lastDetailStart = 0;
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
let axisMeasureCtx = null;
function fmtPrice(v) { function fmtPrice(v) {
return Math.round(v).toLocaleString("ko-KR"); 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() { function updateLegInfo() {
const total = DATA.buy_markers.length; const total = DATA.buy_markers.length;
const el = document.getElementById("leg-info"); const el = document.getElementById("leg-info");
@@ -186,6 +222,27 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
el.textContent = `타점 ${currentLegIdx + 1} / ${total}`; 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) { function drawMarkers(u, buys, sells) {
if (!showMarkers) return; if (!showMarkers) return;
const ctx = u.ctx; const ctx = u.ctx;
@@ -195,21 +252,16 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return; if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.beginPath(); ctx.beginPath();
const s = 5; const s = 8;
const gap = 12;
if (up) { 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 { } 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.closePath(); ctx.fill();
ctx.fillStyle = "#333"; const label = (up ? "B" : "S") + m.marker_id + markerSuffix(m.signal_type);
ctx.font = "10px Malgun Gothic, Arial"; drawMarkerLabel(ctx, label, x, y, color, up);
let suffix = "";
if (m.signal_type === "pullback") suffix = "*";
else if (m.signal_type === "breakout") suffix = "^";
else if (m.signal_type === "div_bull" || m.signal_type === "div_bear") suffix = "d";
const label = (up ? "B" : "S") + m.marker_id + suffix;
ctx.fillText(label, x + 6, y + (up ? 14 : -12));
}; };
buys.forEach(m => { buys.forEach(m => {
let color = "#2e7d32"; let color = "#2e7d32";
@@ -252,13 +304,21 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
function buildOverview(keepRange) { function buildOverview(keepRange) {
const prev = keepRange ? overviewXRange() : null; const prev = keepRange ? overviewXRange() : null;
if (overviewPlot) { overviewPlot.destroy(); overviewPlot = null; } if (overviewPlot) { overviewPlot.destroy(); overviewPlot = null; }
const yAxisW = yAxisLabelWidth();
const opts = { const opts = {
width: document.getElementById("overview").clientWidth, width: document.getElementById("overview").clientWidth,
height: 480, height: 480,
padding: [14, 10, 14, 10],
scales: { x: { time: true } }, scales: { x: { time: true } },
axes: [ axes: [
{}, { gap: 6 },
{ values: (u, vals) => vals.map(v => fmtPrice(v)) }, {
side: 3,
size: yAxisW,
gap: 10,
font: AXIS_FONT,
values: (u, vals) => vals.map(v => fmtPrice(v)),
},
], ],
series: [ series: [
{}, {},
@@ -289,14 +349,22 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none"; document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
const wrap = document.getElementById("detail"); const wrap = document.getElementById("detail");
wrap.innerHTML = ""; wrap.innerHTML = "";
const priceAxisW = yAxisLabelWidth();
detailChart = LightweightCharts.createChart(wrap, { 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" } }, 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 }, timeScale: { timeVisible: true, secondsVisible: false },
width: wrap.clientWidth, width: wrap.clientWidth,
height: 360, height: 360,
}); });
detailSeries = detailChart.addCandlestickSeries({ detailSeries = detailChart.addCandlestickSeries({
priceScaleId: "left",
upColor: "#c62828", downColor: "#1565c0", upColor: "#c62828", downColor: "#1565c0",
borderUpColor: "#c62828", borderDownColor: "#1565c0", borderUpColor: "#c62828", borderDownColor: "#1565c0",
wickUpColor: "#c62828", wickDownColor: "#1565c0", wickUpColor: "#c62828", wickDownColor: "#1565c0",
@@ -319,17 +387,16 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
time: m.time, position: "belowBar", time: m.time, position: "belowBar",
color: m.signal_type === "breakout" ? "#ef6c00" color: m.signal_type === "breakout" ? "#ef6c00"
: m.signal_type === "div_bull" ? "#7b1fa2" : "#2e7d32", : m.signal_type === "div_bull" ? "#7b1fa2" : "#2e7d32",
shape: "arrowUp", shape: "arrowUp", size: 3,
text: "B" + m.marker_id + (m.signal_type === "pullback" ? "*" text: "B" + m.marker_id + markerSuffix(m.signal_type),
: m.signal_type === "breakout" ? "^" : m.signal_type === "div_bull" ? "d" : ""),
}); });
}); });
DATA.sell_markers.forEach(m => { DATA.sell_markers.forEach(m => {
if (m.time >= t0 && m.time <= t1) markers.push({ if (m.time >= t0 && m.time <= t1) markers.push({
time: m.time, position: "aboveBar", time: m.time, position: "aboveBar",
color: m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828", color: m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828",
shape: "arrowDown", shape: "arrowDown", size: 3,
text: "S" + m.marker_id + (m.signal_type === "div_bear" ? "d" : ""), text: "S" + m.marker_id + markerSuffix(m.signal_type),
}); });
}); });
} }
@@ -438,19 +505,26 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
function init() { function init() {
DATA = window.CHART_DATA; 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 m = DATA.meta;
const chartDays = m.chart_lookback_days || m.lookback_days; const chartDays = m.chart_lookback_days || m.lookback_days;
const gtDays = m.gt_lookback_days || m.lookback_days; const gtDays = m.gt_lookback_days || m.lookback_days;
const chartLabel = chartDays >= 365 ? `${Math.round(chartDays / 365)}년` : `${chartDays}일`; const chartLabel = chartDays >= 365 ? `${Math.round(chartDays / 365)}년` : `${chartDays}일`;
const gtLabel = gtDays >= 365 ? `${Math.round(gtDays / 365)}년` : `${gtDays}일`; const gtLabel = gtDays >= 365 ? `${Math.round(gtDays / 365)}년` : `${gtDays}일`;
const tier = m.chart_tier ? ` ${m.chart_tier.toUpperCase()}` : "";
document.getElementById("title").textContent = 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}`; document.getElementById("btn-all").textContent = `전체 ${chartLabel}`;
const chartFrom = m.chart_data_from || m.data_from; const chartFrom = m.chart_data_from || m.data_from;
const chartTo = m.chart_data_to || m.data_to; const chartTo = m.chart_data_to || m.data_to;
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 = 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(); updateLegInfo();
document.getElementById("status").textContent = document.getElementById("status").textContent =
`전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`; `전체 ${DATA.bar_count.toLocaleString()}봉 | 드래그=줌, 더블클릭=리셋`;
@@ -498,3 +572,8 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
</script> </script>
</body> </body>
</html>""" </html>"""
def _html_template(data_js_name: str) -> str:
"""차트 HTML 템플릿을 생성한다."""
return _HTML_TEMPLATE.replace("__DATA_JS_NAME__", data_js_name)

View File

@@ -36,6 +36,22 @@ class GtParams:
div_min_bars_between: int = 1500 div_min_bars_between: int = 1500
div_min_rsi_diff: float = 5.0 div_min_rsi_diff: float = 5.0
div_min_future_move_pct: float = 4.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 @dataclass
@@ -87,12 +103,19 @@ def build_ground_truth(
pivots = find_zigzag_pivots(df, reversal_pct=params.zigzag_reversal_pct) pivots = find_zigzag_pivots(df, reversal_pct=params.zigzag_reversal_pct)
legs = _pivots_to_legs(pivots, min_leg_pct=params.min_leg_pct) legs = _pivots_to_legs(pivots, min_leg_pct=params.min_leg_pct)
leg_dicts = [asdict(leg) for leg in legs] leg_dicts = [asdict(leg) for leg in legs]
include_pullback, include_breakout, include_divergence = _tier_flags(params.chart_tier)
pullback_buys: list[Pivot] = []
if include_pullback:
pullback_buys = find_pullback_buy_pivots( pullback_buys = find_pullback_buy_pivots(
df, df,
legs=legs, legs=legs,
min_pullback_pct=params.pullback_min_pct, min_pullback_pct=params.pullback_min_pct,
local_order=params.pullback_local_order, local_order=params.pullback_local_order,
) )
breakout_buys = []
if include_breakout:
breakout_buys = find_breakout_buy_pivots( breakout_buys = find_breakout_buy_pivots(
df, df,
legs=legs, legs=legs,
@@ -101,6 +124,10 @@ def build_ground_truth(
consolidation_bars=params.breakout_consolidation_bars, consolidation_bars=params.breakout_consolidation_bars,
min_rally_to_sell_pct=params.breakout_min_rally_pct, 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( div_buys, div_sells = find_divergence_signals(
df, df,
local_order=params.div_local_order, local_order=params.div_local_order,
@@ -108,6 +135,14 @@ def build_ground_truth(
min_rsi_diff=params.div_min_rsi_diff, min_rsi_diff=params.div_min_rsi_diff,
min_future_move_pct=params.div_min_future_move_pct, 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) signals = _build_signals(legs, pullback_buys, breakout_buys, div_buys, div_sells)
summary = _summarize(legs, signals) summary = _summarize(legs, signals)
pnl = simulate_gt_pnl(leg_dicts, initial_cash_krw=initial_cash_krw, fee_rate=fee_rate) 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_min": params.interval_min,
"interval_label": interval_label(params.interval_min), "interval_label": interval_label(params.interval_min),
"lookback_days": params.lookback_days, "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, "zigzag_reversal_pct": params.zigzag_reversal_pct,
"min_leg_pct": params.min_leg_pct, "min_leg_pct": params.min_leg_pct,
"pullback_min_pct": params.pullback_min_pct, "pullback_min_pct": params.pullback_min_pct,