feat: Ground Truth 차트 v1/v2/v3 분리 및 차트 UI 개선
스윙만(v1), 눌림목(v2), 전체 신호(v3)로 GT를 단계별 생성하고 마커·Y축 가독성을 개선한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
16
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user