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

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