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_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

16
.gitignore vendored
View File

@@ -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

View File

@@ -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}")
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:
chart_path = render_ground_truth_chart(
rendered = render_ground_truth_chart(
db_path=settings.db_path,
symbol=settings.symbol,
gt_result=result,
output_path=settings.ground_truth_chart_file,
output_path=chart_path,
chart_lookback_days=settings.download_days,
)
print(f"차트: {chart_path}")
_print_tier_summary(tier, result, json_path, rendered)
return 0

View File

@@ -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,

View File

@@ -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 = """<!DOCTYPE html>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.min.css" />
<script src="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.iife.min.js"></script>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="ground_truth_chart_data.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; }
@@ -123,10 +127,11 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
.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; }
#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; }
#detail { height: 360px; background: #fff; border: 1px solid #ddd; overflow: visible; }
</style>
</head>
<body>
@@ -175,10 +180,41 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
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 = """<!DOCTYPE html>
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 = """<!DOCTYPE html>
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 = """<!DOCTYPE html>
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 = """<!DOCTYPE html>
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 = """<!DOCTYPE html>
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 = """<!DOCTYPE html>
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 = """<!DOCTYPE html>
</script>
</body>
</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_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,12 +103,19 @@ 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]
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,
@@ -101,6 +124,10 @@ def build_ground_truth(
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,
@@ -108,6 +135,14 @@ def build_ground_truth(
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,