""" 1단계: walk-forward·민감도·실거래 한도 가정 시뮬·Go/No-Go 리포트. """ from __future__ import annotations import json from pathlib import Path from typing import Any import numpy as np import pandas as pd from config import ( GT_INITIAL_CASH_KRW, LIVE_ORDER_KRW, LIVE_SLIPPAGE_PCT, MATCH_HOLDOUT_RATIO, MATCH_MIN_EV_VALID, MATCH_MIN_FIRES_HOLDOUT, MATCH_MIN_PROFIT_FACTOR, MATCH_TRAIN_RATIO, SIM_FEE_STRESS_MULT, SIM_GO_MIN_HOLDOUT_EV, SIM_GO_MIN_HOLDOUT_PF, SIM_GO_WF_POSITIVE_RATIO, SIM_HYBRID_MAX_MDD_PCT, SIM_HYBRID_MIN_HOLDOUT_PNL_PCT, SIM_OPTION_C_MIN_GT_CAPTURE, SIM_OPTION_C_PHASE2_FEE_STRESS_RATIO, SIM_OPTION_C_PHASE2_TARGET_PNL_PCT, SIM_OPTION_C_TARGET_PNL_PCT, SIM_WALK_FORWARD_MIN_MONTHS, TRADING_FEE_RATE, ) from deepcoin.ground_truth.ground_truth import ( load_ground_truth, order_trades_chronological, ) from deepcoin.ground_truth.gt_allocation import simulate_portfolio_summary from deepcoin.ground_truth.gt_model import ( default_model, model_to_dict, summarize_leg_weights, weight_policy_summary, ) from deepcoin.matching.portfolio_sim import ( fires_to_trade_list, simulate_fixed_order_portfolio, simulate_sized_portfolio, sort_fires_chronological, ) from deepcoin.matching.select_rules import _rule_metrics, _split_train_valid_holdout from deepcoin.paths import resolve_ground_truth_file from deepcoin.paths import ( ANALYSIS_GT_CALIBRATION_JSON, MATCHING_FIRE_OUTCOMES, MATCHING_MATCHED_RULES, MATCHING_SIMULATION_HTML, MATCHING_SIMULATION_JSON, resolve_ground_truth_file, ) def _fee_adjust_ret(series: pd.Series, mult: float) -> pd.Series: """ 수수료 스트레스: 왕복 수수료 %p를 (mult-1)배 추가 차감. Args: series: forward_ret_pct. mult: 수수료 배수 (2.0 = 2배). Returns: 조정된 수익률 %. """ extra = TRADING_FEE_RATE * 2 * 100 * (mult - 1.0) return series - extra def walk_forward_by_month(outcomes: pd.DataFrame) -> list[dict[str, Any]]: """ 규칙·월별 EV·PF 집계. Args: outcomes: fire_outcomes. Returns: 월별 행 dict 리스트. """ if outcomes.empty: return [] df = outcomes.copy() df["ts"] = pd.to_datetime(df["dt"]) df["month"] = df["ts"].dt.to_period("M").astype(str) rows: list[dict[str, Any]] = [] for (rid, month), grp in df.groupby(["rule_id", "month"]): m = _rule_metrics(grp) rows.append( { "rule_id": rid, "side": grp["side"].iloc[0], "month": month, **m, } ) return rows def walk_forward_summary(wf_rows: list[dict[str, Any]]) -> dict[str, Any]: """ 규칙별 월별 EV 양수 비율 요약. Args: wf_rows: walk_forward_by_month 결과. Returns: rule_id → {positive_ratio, months, ...}. """ if not wf_rows: return {} df = pd.DataFrame(wf_rows) out: dict[str, Any] = {} for rid, grp in df.groupby("rule_id"): n = len(grp) pos = int((grp["ev_pct"] > 0).sum()) out[rid] = { "months": n, "positive_months": pos, "positive_ratio": round(pos / n, 4) if n else 0.0, "mean_ev_pct": round(float(grp["ev_pct"].mean()), 4), } return out def simulate_live_order_cap( outcomes: pd.DataFrame, *, rule_ids: set[str] | None = None, holdout_only: bool = True, ) -> dict[str, Any]: """ GT 복리 배분·슬리피지 가정으로 체결 가능한 발화 집계 (일·금액 한도 없음). Args: outcomes: fire_outcomes (split 컬럼 있으면 holdout 필터 가능). rule_ids: None이면 전 규칙, 지정 시 해당 rule만. holdout_only: True면 split==holdout 만. Returns: 규칙별·전체 요약. """ if outcomes.empty: return {"rules": {}, "note": "발화 없음"} df = outcomes.copy() if holdout_only and "split" in df.columns: df = df[df["split"] == "holdout"] if rule_ids is not None: df = df[df["rule_id"].isin(rule_ids)] slip = LIVE_SLIPPAGE_PCT trades = fires_to_trade_list(sort_fires_chronological(df), apply_dynamic_sizing=True) executed_dts = { t["dt"] for t in trades if t.get("action") == "sell" or float(t.get("amount_krw") or 0) > 0 } if not executed_dts: return {"rules": {}, "taken_count": 0, "total_count": int(len(df))} taken = df[df["dt"].astype(str).isin(executed_dts)].copy() taken["adj_ret_pct"] = taken["forward_ret_pct"] - slip by_rule: dict[str, Any] = {} for rid, grp in taken.groupby("rule_id"): g = grp.copy() g["forward_ret_pct"] = g["adj_ret_pct"] by_rule[rid] = { "taken_count": int(len(grp)), "total_count": int((df["rule_id"] == rid).sum()), "metrics": _rule_metrics(g), } return { "assumptions": { "slippage_pct": slip, "sizing": "gt_model_compound_no_daily_cap", }, "taken_count": int(len(taken)), "total_count": int(len(df)), "rules": by_rule, "portfolio_adj_ev_pct": round(float(taken["adj_ret_pct"].mean()), 4), } def evaluate_go_no_go( matched: dict[str, Any], wf_summary: dict[str, Any], fee_stress: dict[str, Any], live_cap: dict[str, Any], ) -> dict[str, Any]: """ monitor_rules·holdout·walk-forward·수수료 스트레스 기준 Go/No-Go. Args: matched: matched_rules.json 내용. wf_summary: walk_forward_summary. fee_stress: 규칙별 fee 2x EV. live_cap: simulate_live_order_cap. Returns: go, checks, monitor_rules 판정. """ rules = matched.get("monitor_rules") or matched.get("selected") or [] checks: list[dict[str, Any]] = [] all_go = True for rule in rules: rid = rule["rule_id"] h = rule.get("metrics", {}).get("holdout", {}) ev_h = float(h.get("ev_pct", -999)) pf_h = float(h.get("profit_factor", 0)) wf = wf_summary.get(rid, {}) wf_ratio = float(wf.get("positive_ratio", 0)) wf_months = int(wf.get("months", 0)) stress_ev = fee_stress.get(rid, {}).get("ev_pct", -999) c_holdout = ev_h >= SIM_GO_MIN_HOLDOUT_EV and pf_h >= SIM_GO_MIN_HOLDOUT_PF c_wf = wf_months >= SIM_WALK_FORWARD_MIN_MONTHS and wf_ratio >= SIM_GO_WF_POSITIVE_RATIO c_fee = stress_ev >= SIM_GO_MIN_HOLDOUT_EV ok = c_holdout and c_wf and c_fee if not ok: all_go = False checks.append( { "rule_id": rid, "side": rule.get("side"), "pass": ok, "holdout_ev": ev_h, "holdout_pf": pf_h, "wf_positive_ratio": wf_ratio, "fee_stress_ev": stress_ev, } ) return { "go": all_go and len(checks) > 0, "checks": checks, "live_cap_taken_ratio": round( live_cap.get("taken_count", 0) / max(live_cap.get("total_count", 1), 1), 4, ), } def portfolio_holdout_from_steps( steps: list[dict[str, Any]], holdout_start: pd.Timestamp, *, initial_if_empty: float = GT_INITIAL_CASH_KRW, trade_count: int = 0, note: str = "", ) -> dict[str, Any]: """ 포트폴리오 step에서 holdout 구간 자산 증감. Args: steps: simulate_portfolio_steps 결과. holdout_start: holdout 시작 시각. initial_if_empty: step 없을 때 시작 자산. trade_count: holdout 발화 수. note: 설명. Returns: holdout pnl 요약 dict. """ if not steps: return {"pnl_pct": 0.0, "note": "steps empty"} assets = [(pd.to_datetime(s["dt"]), float(s["total_asset_krw"])) for s in steps] pre = [a for d, a in assets if d < holdout_start] in_h = [a for d, a in assets if d >= holdout_start] asset_start = pre[-1] if pre else float(initial_if_empty) asset_end = in_h[-1] if in_h else assets[-1][1] ho_pnl_pct = ( (asset_end - asset_start) / asset_start * 100.0 if asset_start > 0 else 0.0 ) return { "initial_asset_krw": round(asset_start, 0), "final_asset_krw": round(asset_end, 0), "pnl_krw": round(asset_end - asset_start, 0), "pnl_pct": round(ho_pnl_pct, 2), "trade_count": int(trade_count), "note": note, } def evaluate_hybrid_sizing_go( base_go: dict[str, Any], hybrid_full: dict[str, Any], hybrid_holdout: dict[str, Any], hybrid_fee_stress: dict[str, Any], ) -> dict[str, Any]: """ hybrid DD tier 배분 승격 Go/No-Go (규칙 Go + holdout·MDD·수수료 스트레스). Args: base_go: monitor 규칙 evaluate_go_no_go 결과. hybrid_full: 전기간 hybrid 포트폴리오 요약. hybrid_holdout: holdout 구간 자산 증감. hybrid_fee_stress: 수수료 스트레스 hybrid 포트폴리오. Returns: go, checks, primary_sizing 권장. """ from config import ( SIM_HYBRID_MAX_MDD_PCT, SIM_HYBRID_MIN_HOLDOUT_PNL_PCT, SIM_OPTION_C_TARGET_PNL_PCT, SIM_PRIMARY_SIZING, ) base_ok = bool(base_go.get("go")) ho_pnl = float(hybrid_holdout.get("pnl_pct", -999)) full_pnl = float(hybrid_full.get("pnl_pct", 0)) mdd = float(hybrid_full.get("max_drawdown_pct", 999)) stress_pnl = float(hybrid_fee_stress.get("pnl_pct", -999)) c_base = base_ok c_holdout = ho_pnl >= SIM_HYBRID_MIN_HOLDOUT_PNL_PCT c_mdd = mdd <= SIM_HYBRID_MAX_MDD_PCT c_fee = stress_pnl > 0.0 c_target = full_pnl >= SIM_OPTION_C_TARGET_PNL_PCT all_go = c_base and c_holdout and c_mdd and c_fee if SIM_PRIMARY_SIZING == "hybrid": primary = "hybrid" elif SIM_PRIMARY_SIZING == "causal_tier": primary = "causal_tier" else: primary = "hybrid" if all_go else "causal_tier" return { "go": all_go, "primary_sizing": primary, "checks": [ {"name": "monitor_rules_go", "pass": c_base}, {"name": "hybrid_holdout_pnl", "pass": c_holdout, "value": ho_pnl}, {"name": "hybrid_max_mdd", "pass": c_mdd, "value": mdd}, {"name": "hybrid_fee_stress_pnl", "pass": c_fee, "value": stress_pnl}, { "name": "option_c_target_300pct", "pass": c_target, "value": full_pnl, "optional": True, }, ], } def simulate_hybrid_order_cap( outcomes: pd.DataFrame, ohlc_df: pd.DataFrame, *, rule_ids: set[str] | None = None, holdout_only: bool = True, fee_rate: float = TRADING_FEE_RATE, dd_large_pct: float | None = None, dd_medium_pct: float | None = None, ) -> dict[str, Any]: """ hybrid tier 복리 배분·슬리피지 가정 체결 가능 발화 집계. Args: outcomes: fire_outcomes. ohlc_df: 3m OHLC (drawdown). rule_ids: monitor rule_id 필터. holdout_only: holdout만. fee_rate: 수수료율. Returns: simulate_live_order_cap과 동일 구조. """ from deepcoin.ground_truth.causal_gt_hybrid import build_monitor_hybrid_sized_trades if outcomes.empty: return {"rules": {}, "note": "발화 없음"} df = outcomes.copy() if holdout_only and "split" in df.columns: df = df[df["split"] == "holdout"] if rule_ids is not None: df = df[df["rule_id"].isin(rule_ids)] slip = LIVE_SLIPPAGE_PCT sized, _ = build_monitor_hybrid_sized_trades( sort_fires_chronological(df), ohlc_df, enhanced=False, fee_rate=fee_rate, dd_large_pct=dd_large_pct, dd_medium_pct=dd_medium_pct, ) executed_dts = { t["dt"] for t in sized if t.get("action") == "sell" or float(t.get("amount_krw") or 0) > 0 } if not executed_dts: return {"rules": {}, "taken_count": 0, "total_count": int(len(df))} taken = df[df["dt"].astype(str).isin(executed_dts)].copy() taken["adj_ret_pct"] = taken["forward_ret_pct"] - slip by_rule: dict[str, Any] = {} for rid, grp in taken.groupby("rule_id"): g = grp.copy() g["forward_ret_pct"] = g["adj_ret_pct"] by_rule[rid] = { "taken_count": int(len(grp)), "total_count": int((df["rule_id"] == rid).sum()), "metrics": _rule_metrics(g), } return { "assumptions": { "slippage_pct": slip, "sizing": "hybrid_dd_tier_compound", }, "taken_count": int(len(taken)), "total_count": int(len(df)), "rules": by_rule, "portfolio_adj_ev_pct": round(float(taken["adj_ret_pct"].mean()), 4), } def build_simulation_report( outcomes_path: Path | None = None, matched_path: Path | None = None, ) -> dict[str, Any]: """ 시뮬레이션 리포트 dict 생성. Args: outcomes_path: fire_outcomes.csv. matched_path: matched_rules.json. Returns: simulation_report 전체 dict. """ op = outcomes_path or MATCHING_FIRE_OUTCOMES mp = matched_path or MATCHING_MATCHED_RULES if not op.is_file(): raise FileNotFoundError(f"fire_outcomes 없음: {op} — 04_match_rules.py 먼저 실행") outcomes = pd.read_csv(op) matched: dict[str, Any] = {} if mp.is_file(): matched = json.loads(mp.read_text(encoding="utf-8")) outcomes["split"] = _split_train_valid_holdout(outcomes) wf_rows = walk_forward_by_month(outcomes) wf_sum = walk_forward_summary(wf_rows) fee_stress: dict[str, Any] = {} for rid in outcomes["rule_id"].unique(): sub = outcomes[outcomes["rule_id"] == rid] adj = _fee_adjust_ret(sub["forward_ret_pct"], SIM_FEE_STRESS_MULT) fee_stress[rid] = _rule_metrics( sub.assign(forward_ret_pct=adj) ) monitor_ids = {r["rule_id"] for r in matched.get("monitor_rules", [])} live_cap = simulate_live_order_cap( outcomes, rule_ids=monitor_ids, holdout_only=True ) go = evaluate_go_no_go(matched, wf_sum, fee_stress, live_cap) portfolio_compare: dict[str, Any] = {} gt_data = load_ground_truth(resolve_ground_truth_file()) or {} gt_trades = gt_data.get("trades") or [] mark = (gt_data.get("summary") or {}).get("mark_price") gt_chrono = order_trades_chronological(gt_trades) if gt_trades else [] from deepcoin.ground_truth.gt_signal_rules import gt_signal_rule_ids from config import GT_SIGNAL_CAUSAL, SIM_CAUSAL_TIER from deepcoin.matching.position_sizing import load_gt_allocation_analysis gt_alloc_analysis = load_gt_allocation_analysis(gt_trades) if gt_trades else {} if gt_chrono: if not any(float(t.get("amount_krw") or 0) > 0 for t in gt_chrono): from deepcoin.ground_truth.ground_truth import allocate_gt_order_amounts allocate_gt_order_amounts(gt_chrono) portfolio_compare["ground_truth_chrono"] = simulate_portfolio_summary( gt_chrono, last_price=float(mark) if mark else None, use_amount_krw=True, ) # 전기간 monitor 규칙 — 100만원에서 복리 (holdout만 X) all_monitor = outcomes[outcomes["rule_id"].isin(monitor_ids)] if not all_monitor.empty: sim_trades_full = fires_to_trade_list(sort_fires_chronological(all_monitor)) portfolio_compare["sim_sized"] = simulate_sized_portfolio( sim_trades_full, last_price=float(mark) if mark else None, ) portfolio_compare["sim_fixed_order"] = simulate_fixed_order_portfolio( fires_to_trade_list(all_monitor, apply_dynamic_sizing=False), last_price=float(mark) if mark else None, ) # GT 모델 일반화 규칙 (ZigZag+BB 매수 / ZigZag 고점 매도) gt_buy_rule = "gt_model_buy_zigzag_bb" gt_sell_rule = "gt_model_sell_zigzag_peak" gt_pair_ids = {gt_buy_rule, gt_sell_rule} if gt_pair_ids.issubset(set(outcomes["rule_id"].unique())): gt_pair_fires = outcomes[outcomes["rule_id"].isin(gt_pair_ids)] gt_pair_trades = fires_to_trade_list(sort_fires_chronological(gt_pair_fires)) portfolio_compare["sim_gt_model"] = simulate_sized_portfolio( gt_pair_trades, last_price=float(mark) if mark else None, ) # 인과 GT leg 엔진 (split_buy + peak_sell, 캘리브레이션 파라미터) cg_df = None try: from config import CHART_LOOKBACK_DAYS, MATCH_PRIMARY_INTERVAL, SYMBOL from deepcoin.data.mtf_bb import load_frames_from_db from deepcoin.ground_truth.causal_gt_calibrate import load_causal_gt_params from deepcoin.ground_truth.causal_gt_trades import simulate_causal_gt_portfolio from deepcoin.ops.monitor import Monitor cg_params = load_causal_gt_params() mon_cg = Monitor(cooldown_file=None) cg_frames = load_frames_from_db(mon_cg, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS) cg_df = cg_frames[MATCH_PRIMARY_INTERVAL] portfolio_compare["sim_causal_gt"] = simulate_causal_gt_portfolio( cg_df, last_price=float(mark) if mark else None, **cg_params, ) # Phase 3: monitor buy + 인과 peak sell + drawdown tier from deepcoin.ground_truth.causal_gt_hybrid import ( simulate_causal_gt_hybrid_portfolio, simulate_monitor_tier_enhanced_portfolio, ) from deepcoin.ground_truth.hybrid_dd_calibrate import load_hybrid_dd_params dd_params = load_hybrid_dd_params() buy_only = all_monitor[all_monitor["side"] == "buy"] portfolio_compare["sim_causal_hybrid"] = simulate_causal_gt_hybrid_portfolio( buy_only, cg_df, monitor_fires=all_monitor, last_price=float(mark) if mark else None, cg_params=cg_params, dd_large_pct=dd_params.get("dd_large_pct"), dd_medium_pct=dd_params.get("dd_medium_pct"), ) portfolio_compare["hybrid_dd_params"] = dd_params portfolio_compare["sim_tier_enhanced"] = simulate_monitor_tier_enhanced_portfolio( all_monitor, cg_df, last_price=float(mark) if mark else None, ) except Exception as exc: portfolio_compare["sim_causal_gt"] = { "pnl_pct": 0.0, "note": f"causal_gt sim skipped: {exc}", } portfolio_compare["sim_causal_hybrid"] = { "pnl_pct": 0.0, "note": f"causal_hybrid sim skipped: {exc}", } portfolio_compare["sim_tier_enhanced"] = { "pnl_pct": 0.0, "note": f"tier_enhanced sim skipped: {exc}", } holdout = outcomes[ outcomes["rule_id"].isin(monitor_ids) & (outcomes["split"] == "holdout") ] if not holdout.empty and not all_monitor.empty: full_trades = fires_to_trade_list(sort_fires_chronological(all_monitor)) if full_trades: from deepcoin.ground_truth.gt_allocation import simulate_portfolio_steps steps = simulate_portfolio_steps(full_trades, use_amount_krw=True) if steps: outcomes_ts = outcomes.copy() outcomes_ts["ts"] = pd.to_datetime(outcomes_ts["dt"]) h0 = outcomes_ts["ts"].quantile(1.0 - MATCH_HOLDOUT_RATIO) portfolio_compare["sim_sized_holdout"] = portfolio_holdout_from_steps( steps, h0, trade_count=int(len(holdout)), note="전기간 복리(causal tier) 후 holdout 구간 자산 증감", ) go_hybrid: dict[str, Any] = {"go": False, "note": "hybrid sim unavailable"} go_option_c_phase2: dict[str, Any] = {"go": False, "note": "phase2 unavailable"} if ( cg_df is not None and not all_monitor.empty and portfolio_compare.get("sim_causal_hybrid", {}).get("sizing_mode") ): from deepcoin.ground_truth.causal_gt_hybrid import build_monitor_hybrid_sized_trades from deepcoin.ground_truth.gt_allocation import simulate_portfolio_steps from deepcoin.ground_truth.hybrid_dd_calibrate import load_hybrid_dd_params from deepcoin.matching.option_c_phase2 import ( evaluate_option_c_phase2_go, simulate_hybrid_slippage_stress, walk_forward_portfolio_by_month, walk_forward_portfolio_summary, ) dd_params = portfolio_compare.get("hybrid_dd_params") or load_hybrid_dd_params() dd_large = dd_params.get("dd_large_pct") dd_medium = dd_params.get("dd_medium_pct") hybrid_full = portfolio_compare["sim_causal_hybrid"] sized_h, _ = build_monitor_hybrid_sized_trades( sort_fires_chronological(all_monitor), cg_df, enhanced=False, dd_large_pct=dd_large, dd_medium_pct=dd_medium, ) steps_h = simulate_portfolio_steps(sized_h, use_amount_krw=True) if steps_h and not holdout.empty: outcomes_ts = outcomes.copy() outcomes_ts["ts"] = pd.to_datetime(outcomes_ts["dt"]) h0 = outcomes_ts["ts"].quantile(1.0 - MATCH_HOLDOUT_RATIO) portfolio_compare["sim_hybrid_holdout"] = portfolio_holdout_from_steps( steps_h, h0, trade_count=int(len(holdout)), note="전기간 복리(hybrid DD tier) 후 holdout 구간 자산 증감", ) stress_fee = TRADING_FEE_RATE * SIM_FEE_STRESS_MULT sized_stress, _ = build_monitor_hybrid_sized_trades( sort_fires_chronological(all_monitor), cg_df, enhanced=False, fee_rate=stress_fee, dd_large_pct=dd_large, dd_medium_pct=dd_medium, ) portfolio_compare["sim_hybrid_fee_stress"] = simulate_portfolio_summary( sized_stress, fee_rate=stress_fee, last_price=float(mark) if mark else None, use_amount_krw=True, ) portfolio_compare["sim_hybrid_slippage_stress"] = simulate_hybrid_slippage_stress( sized_h, last_price=float(mark) if mark else None, fee_rate=TRADING_FEE_RATE, ) wf_rows = walk_forward_portfolio_by_month(steps_h) wf_port = walk_forward_portfolio_summary(wf_rows) portfolio_compare["hybrid_portfolio_walk_forward"] = wf_rows portfolio_compare["hybrid_portfolio_wf_summary"] = wf_port gt_pnl_for_phase2 = float( (portfolio_compare.get("ground_truth_chrono") or {}).get("pnl_pct", 0) ) go_hybrid = evaluate_hybrid_sizing_go( go, hybrid_full, portfolio_compare.get("sim_hybrid_holdout") or {}, portfolio_compare.get("sim_hybrid_fee_stress") or {}, ) go_option_c_phase2 = evaluate_option_c_phase2_go( go_hybrid, hybrid_full, portfolio_compare.get("sim_hybrid_holdout") or {}, portfolio_compare.get("sim_hybrid_fee_stress") or {}, portfolio_compare.get("sim_hybrid_slippage_stress") or {}, wf_port, gt_pnl_for_phase2, ) primary = go_hybrid.get("primary_sizing", "causal_tier") portfolio_compare["primary_sizing"] = primary if primary == "hybrid": portfolio_compare["sim_primary"] = { **hybrid_full, "sizing_mode": "primary_hybrid_dd_tier", "sizing_note": ( "권장: monitor + past-leg·drawdown tier (검증 통과, 미래 미사용)" ), } live_cap = simulate_hybrid_order_cap( outcomes, cg_df, rule_ids=monitor_ids, holdout_only=True, dd_large_pct=dd_large, dd_medium_pct=dd_medium, ) else: portfolio_compare["sim_primary"] = portfolio_compare.get("sim_sized") or {} if portfolio_compare.get("sim_sized") and portfolio_compare.get("ground_truth_chrono"): gt_pnl = float(portfolio_compare["ground_truth_chrono"].get("pnl_pct", 0)) sim_pnl = float(portfolio_compare["sim_sized"].get("pnl_pct", 0)) portfolio_compare["gt_capture_ratio"] = round( sim_pnl / gt_pnl if abs(gt_pnl) > 1e-6 else 0.0, 4, ) portfolio_compare["gt_pnl_pct"] = gt_pnl portfolio_compare["sim_sized_pnl_pct"] = sim_pnl if portfolio_compare.get("sim_gt_model"): gtp = float(portfolio_compare["sim_gt_model"].get("pnl_pct", 0)) portfolio_compare["gt_model_capture_ratio"] = round( gtp / gt_pnl if abs(gt_pnl) > 1e-6 else 0.0, 4, ) if portfolio_compare.get("sim_causal_gt"): cgp = float(portfolio_compare["sim_causal_gt"].get("pnl_pct", 0)) portfolio_compare["causal_gt_capture_ratio"] = round( cgp / gt_pnl if abs(gt_pnl) > 1e-6 else 0.0, 4, ) portfolio_compare["sim_causal_gt_pnl_pct"] = cgp if portfolio_compare.get("sim_causal_hybrid"): chp = float(portfolio_compare["sim_causal_hybrid"].get("pnl_pct", 0)) portfolio_compare["causal_hybrid_capture_ratio"] = round( chp / gt_pnl if abs(gt_pnl) > 1e-6 else 0.0, 4, ) portfolio_compare["sim_causal_hybrid_pnl_pct"] = chp if portfolio_compare.get("sim_tier_enhanced"): tep = float(portfolio_compare["sim_tier_enhanced"].get("pnl_pct", 0)) portfolio_compare["tier_enhanced_capture_ratio"] = round( tep / gt_pnl if abs(gt_pnl) > 1e-6 else 0.0, 4, ) portfolio_compare["sim_tier_enhanced_pnl_pct"] = tep portfolio_compare["causal_gt_params"] = {} try: from deepcoin.ground_truth.causal_gt_calibrate import load_causal_gt_params portfolio_compare["causal_gt_params"] = load_causal_gt_params() except Exception: pass portfolio_compare["gt_allocation_analysis"] = gt_alloc_analysis portfolio_compare["causal_mode"] = { "gt_signal_causal": GT_SIGNAL_CAUSAL, "sim_causal_tier": SIM_CAUSAL_TIER, "note": "인과적: t 시점까지 데이터만 사용 (운영 정합)", } gt_portfolio: dict[str, Any] = {} if ANALYSIS_GT_CALIBRATION_JSON.is_file(): cal = json.loads(ANALYSIS_GT_CALIBRATION_JSON.read_text(encoding="utf-8")) gt_portfolio = cal.get("final", {}) else: from deepcoin.matching.gt_asset_calibration import ( portfolio_asset_ratio, ) gt_data_cal = load_ground_truth(resolve_ground_truth_file()) or {} trades = gt_data_cal.get("trades") or [] mark_cal = (gt_data_cal.get("summary") or {}).get("mark_price") if trades: gt_portfolio = { "portfolio": portfolio_asset_ratio(trades, set(), mark_cal), "note": "캘리브레이션 미실행 — scripts/04_calibrate_gt_assets.py", } summaries = matched.get("all_rule_summaries") or matched.get("monitor_rules") or [] leg_weight_check = summarize_leg_weights(gt_trades) if gt_trades else {} invalid_legs = [lid for lid, info in leg_weight_check.items() if not info.get("valid", True)] return { "label_mode": matched.get("label_mode"), "train_ratio": MATCH_TRAIN_RATIO, "holdout_ratio": MATCH_HOLDOUT_RATIO, "outcomes_rows": int(len(outcomes)), "walk_forward": wf_rows, "walk_forward_summary": wf_sum, "fee_stress_mult": SIM_FEE_STRESS_MULT, "fee_stress_by_rule": fee_stress, "live_order_cap_sim": live_cap, "go_no_go": go, "go_no_go_hybrid": go_hybrid, "go_no_go_option_c_phase2": go_option_c_phase2, "portfolio_compare": portfolio_compare, "gt_model": gt_data.get("model") or model_to_dict(default_model()), "gt_weight_policy": weight_policy_summary(default_model()), "gt_leg_weight_validation": { "legs": leg_weight_check, "invalid_leg_ids": invalid_legs, "all_valid": len(invalid_legs) == 0, }, "monitor_rules": matched.get("monitor_rules", []), "gt_portfolio_calibration": gt_portfolio, "criteria": { "min_holdout_ev": SIM_GO_MIN_HOLDOUT_EV, "min_holdout_pf": SIM_GO_MIN_HOLDOUT_PF, "wf_positive_ratio": SIM_GO_WF_POSITIVE_RATIO, "wf_min_months": SIM_WALK_FORWARD_MIN_MONTHS, "hybrid_min_holdout_pnl_pct": SIM_HYBRID_MIN_HOLDOUT_PNL_PCT, "hybrid_max_mdd_pct": SIM_HYBRID_MAX_MDD_PCT, "option_c_target_pnl_pct": SIM_OPTION_C_TARGET_PNL_PCT, "option_c_phase2_target_pnl_pct": SIM_OPTION_C_PHASE2_TARGET_PNL_PCT, "option_c_phase2_fee_stress_ratio": SIM_OPTION_C_PHASE2_FEE_STRESS_RATIO, "option_c_min_gt_capture": SIM_OPTION_C_MIN_GT_CAPTURE, }, } def write_simulation_html(report: dict[str, Any], out_path: Path) -> Path: """ simulation_report.html 저장 (ground_truth 차트 동일 스타일). Args: report: build_simulation_report 결과. out_path: HTML 경로. Returns: out_path. """ from deepcoin.matching.simulation_html import write_simulation_report_html return write_simulation_report_html(report, out_path) def run_simulation_report( outcomes_path: Path | None = None, matched_path: Path | None = None, ) -> dict[str, Any]: """ 시뮬 리포트 생성·저장·요약 출력. Args: outcomes_path: fire_outcomes.csv. matched_path: matched_rules.json. Returns: report dict. """ report = build_simulation_report(outcomes_path, matched_path) MATCHING_SIMULATION_JSON.parent.mkdir(parents=True, exist_ok=True) MATCHING_SIMULATION_JSON.write_text( json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8", ) write_simulation_html(report, MATCHING_SIMULATION_HTML) go = report["go_no_go"]["go"] go_h = report.get("go_no_go_hybrid") or {} pc_early = report.get("portfolio_compare") or {} print(f"[시뮬] 저장: {MATCHING_SIMULATION_JSON}") print(f"[시뮬] 저장: {MATCHING_SIMULATION_HTML}") print(f"[시뮬] Go/No-Go (규칙): {'GO' if go else 'NO-GO'}") print( f"[시뮬] Go/No-Go (hybrid tier): {'GO' if go_h.get('go') else 'NO-GO'} " f"· primary={pc_early.get('primary_sizing', '-')}" ) for c in go_h.get("checks", []): mark = "OK" if c.get("pass") else "NG" opt = " (optional)" if c.get("optional") else "" print(f" [hybrid {mark}] {c.get('name')}: {c.get('value', '-')}{opt}") go_p2 = report.get("go_no_go_option_c_phase2") or {} print( f"[시뮬] Option C 2차(+1000%): {'GO' if go_p2.get('go') else 'NO-GO'}" ) for c in go_p2.get("checks", []): mark = "OK" if c.get("pass") else "NG" print(f" [phase2 {mark}] {c.get('name')}: {c.get('value', '-')}") for c in report["go_no_go"].get("checks", []): mark = "OK" if c["pass"] else "NG" print( f" [{mark}] {c['rule_id']}: holdout EV={c['holdout_ev']} " f"WF+={c['wf_positive_ratio']} fee2x EV={c['fee_stress_ev']}" ) cal = report.get("gt_portfolio_calibration") or {} port = cal.get("portfolio") or {} pc = report.get("portfolio_compare") or {} if pc.get("gt_capture_ratio") is not None: print( f"[시뮬] GT 대비 sim_sized(전기간 복리): {pc.get('sim_sized_pnl_pct')}% " f"/ GT {pc.get('gt_pnl_pct')}% " f"(capture={pc.get('gt_capture_ratio'):.2%})" ) if pc.get("gt_model_capture_ratio") is not None: print( f"[시뮬] GT 대비 sim_gt_model: " f"{pc.get('sim_gt_model', {}).get('pnl_pct')}% " f"(capture={pc.get('gt_model_capture_ratio'):.2%})" ) if pc.get("sim_causal_gt_pnl_pct") is not None: scg = pc.get("sim_causal_gt") or {} print( f"[시뮬] GT 대비 sim_causal_gt(인과 leg): " f"{pc.get('sim_causal_gt_pnl_pct')}% " f"(capture={pc.get('causal_gt_capture_ratio', 0):.2%}, " f"legs={scg.get('leg_count', '-')}, trades={scg.get('trade_count', '-')})" ) if pc.get("sim_causal_hybrid_pnl_pct") is not None: sch = pc.get("sim_causal_hybrid") or {} print( f"[시뮬] GT 대비 sim_causal_hybrid(monitor+DD tier): " f"{pc.get('sim_causal_hybrid_pnl_pct')}% " f"(capture={pc.get('causal_hybrid_capture_ratio', 0):.2%}, " f"MDD={sch.get('max_drawdown_pct', '-')}%)" ) if pc.get("sim_primary"): sp = pc["sim_primary"] print( f"[시뮬] 권장 primary ({pc.get('primary_sizing')}): " f"{sp.get('pnl_pct')}% · MDD={sp.get('max_drawdown_pct', '-')}%" ) ho_h = pc.get("sim_hybrid_holdout") or {} if ho_h.get("pnl_pct") is not None: print( f"[시뮬] hybrid holdout: {ho_h.get('pnl_pct')}% " f"({ho_h.get('initial_asset_krw')}→{ho_h.get('final_asset_krw')})" ) if pc.get("sim_tier_enhanced_pnl_pct") is not None: ste = pc.get("sim_tier_enhanced") or {} ast = ste.get("alloc_stats") or {} print( f"[시뮬] GT 대비 sim_tier_enhanced(conviction tier): " f"{pc.get('sim_tier_enhanced_pnl_pct')}% " f"(capture={pc.get('tier_enhanced_capture_ratio', 0):.2%}, " f"large_buys={ast.get('large_tier_buy_count', '-')}, " f"avg_buy={ast.get('buy_amount_avg_krw', '-')})" ) if pc.get("sim_sized", {}).get("max_drawdown_pct") is not None: print( f"[시뮬] sim_sized MDD: {pc['sim_sized']['max_drawdown_pct']}% " f"(GT MDD: {pc.get('ground_truth_chrono', {}).get('max_drawdown_pct')}%)" ) if port.get("asset_ratio") is not None: met = cal.get("targets_met", port.get("target_met_90")) print( f"[시뮬] GT 총자산 대비 leg subset 비율: {port['asset_ratio']:.2%} " f"({port.get('legs_covered')}/{port.get('legs_total')} leg) " f"목표90%={'달성' if met else '미달'}" ) return report