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_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
16
.gitignore
vendored
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user