""" Ground truth(450타점) vs 규칙 발화·시뮬 결과 비교 리포트. """ 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 MATCH_GT_TOLERANCE_MIN from deepcoin.ground_truth.ground_truth import load_ground_truth from deepcoin.matching.select_rules import ( _rule_metrics, _split_train_valid_holdout, gt_overlap_report, ) from deepcoin.paths import ( MATCHING_FIRE_OUTCOMES, MATCHING_GT_COMPARISON_HTML, MATCHING_GT_COMPARISON_JSON, MATCHING_MATCHED_RULES, MATCHING_SIMULATION_JSON, resolve_ground_truth_file, ) def _precision_near_gt( fire_ts: pd.Series, gt_ts: pd.Series, tolerance: pd.Timedelta, ) -> dict[str, Any]: """ 발화 시각이 GT 타점 ±허용 내인 비율(precision proxy). Args: fire_ts: 규칙 발화 시각. gt_ts: GT 시각. tolerance: 허용 timedelta. Returns: near_count, fire_count, precision. """ if fire_ts.empty: return {"near_count": 0, "fire_count": 0, "precision": 0.0} gt_sorted = gt_ts.sort_values() near = 0 for fts in fire_ts: if (gt_sorted - fts).abs().min() <= tolerance: near += 1 n = len(fire_ts) return { "near_count": near, "fire_count": n, "precision": round(near / n, 4) if n else 0.0, } def _matched_pairs( fires: pd.DataFrame, gt_df: pd.DataFrame, rule_id: str, tolerance: pd.Timedelta, ) -> pd.DataFrame: """ GT 타점별 가장 가까운 동일 rule·side 발화와 수익률 쌍을 만듭니다. Args: fires: fire_outcomes. gt_df: GT trades DataFrame. rule_id: 규칙 ID. tolerance: 매칭 허용. Returns: 매칭된 행 DataFrame. """ sub = fires[fires["rule_id"] == rule_id].copy() if sub.empty: return pd.DataFrame() side = sub["side"].iloc[0] g = gt_df[gt_df["action"] == side].copy() g["ts"] = pd.to_datetime(g["dt"]) sub["ts"] = pd.to_datetime(sub["dt"]) rows: list[dict[str, Any]] = [] for _, gt_row in g.iterrows(): gts = pd.Timestamp(gt_row["ts"]) delta = (sub["ts"] - gts).abs() if delta.empty or delta.min() > tolerance: continue idx = delta.idxmin() fr = sub.loc[idx] rows.append( { "side": side, "rule_id": rule_id, "gt_dt": str(gt_row["dt"]), "fire_dt": str(fr["dt"]), "delta_min": round(delta.min().total_seconds() / 60, 2), "gt_forward_pct": float(gt_row.get("forward_return_pct") or 0), "sim_leg_gt_pct": float(fr["forward_ret_pct"]), "split": fr.get("split"), } ) return pd.DataFrame(rows) def build_gt_comparison_report( outcomes_path: Path | None = None, matched_path: Path | None = None, gt_path: Path | None = None, sim_path: Path | None = None, tolerance_min: int = MATCH_GT_TOLERANCE_MIN, ) -> dict[str, Any]: """ GT vs 발화·시뮬 비교 dict 생성. Args: outcomes_path: fire_outcomes.csv. matched_path: matched_rules.json. gt_path: ground_truth_trades.json. sim_path: simulation_report.json. tolerance_min: GT 매칭 허용(분). Returns: gt_comparison_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}") outcomes = pd.read_csv(op) outcomes["ts"] = pd.to_datetime(outcomes["dt"]) outcomes["split"] = _split_train_valid_holdout(outcomes) matched: dict[str, Any] = {} if mp.is_file(): matched = json.loads(mp.read_text(encoding="utf-8")) sim_report: dict[str, Any] = {} sp = sim_path or MATCHING_SIMULATION_JSON if sp.is_file(): sim_report = json.loads(sp.read_text(encoding="utf-8")) gt_data = load_ground_truth(gt_path or resolve_ground_truth_file()) or {} gt_trades = gt_data.get("trades") or [] gt_df = pd.DataFrame(gt_trades) tol = pd.Timedelta(minutes=tolerance_min) gt_baseline: dict[str, Any] = { "total": len(gt_df), "buy": int((gt_df["action"] == "buy").sum()) if not gt_df.empty else 0, "sell": int((gt_df["action"] == "sell").sum()) if not gt_df.empty else 0, } for side in ("buy", "sell"): sub = gt_df[gt_df["action"] == side] if not gt_df.empty else pd.DataFrame() if sub.empty or "forward_return_pct" not in sub.columns: gt_baseline[side] = {} continue r = sub["forward_return_pct"].astype(float) gt_baseline[side] = { "mean_forward_pct": round(float(r.mean()), 4), "median_forward_pct": round(float(r.median()), 4), "win_rate": round(float((r > 0).mean()), 4), "count": int(len(r)), } all_fires = outcomes.copy() if "rule_id" not in all_fires.columns: all_fires["rule_id"] = "all" overlap_all = gt_overlap_report( all_fires.drop_duplicates(subset=["dt", "side"]), gt_trades, tolerance_min=tolerance_min, ) per_rule: list[dict[str, Any]] = [] pair_stats: list[dict[str, Any]] = [] for rid in sorted(outcomes["rule_id"].unique()): sub = outcomes[outcomes["rule_id"] == rid] side = str(sub["side"].iloc[0]) gt_side = gt_df[gt_df["action"] == side] gt_ts = pd.to_datetime(gt_side["dt"]) if not gt_side.empty else pd.Series(dtype="datetime64[ns]") fire_ts = sub["ts"] ov = gt_overlap_report(sub, gt_trades, tolerance_min=tolerance_min) prec = _precision_near_gt(fire_ts, gt_ts, tol) m_all = _rule_metrics(sub) m_hold = _rule_metrics(sub[sub["split"] == "holdout"]) pairs = _matched_pairs(outcomes, gt_df, rid, tol) pair_row: dict[str, Any] = {"rule_id": rid, "side": side, "pair_count": len(pairs)} if len(pairs) >= 2: corr = pairs["gt_forward_pct"].corr(pairs["sim_leg_gt_pct"]) pair_row["corr_gt_vs_sim"] = round(float(corr), 4) if pd.notna(corr) else None pair_row["mean_abs_diff_pct"] = round( float((pairs["gt_forward_pct"] - pairs["sim_leg_gt_pct"]).abs().mean()), 4, ) pair_row["mean_delta_min"] = round(float(pairs["delta_min"].mean()), 2) pair_stats.append(pair_row) near_mask = [] for fts in fire_ts: near_mask.append( not gt_ts.empty and (gt_ts - fts).abs().min() <= tol ) sub_near = sub.loc[near_mask] if near_mask else sub.iloc[0:0] sub_far = sub.loc[[not x for x in near_mask]] if near_mask else sub per_rule.append( { "rule_id": rid, "side": side, "fire_count": int(len(sub)), "gt_recall": ov.get(side, {}).get("recall", 0), "gt_matched": ov.get(side, {}).get("matched", 0), "gt_count": ov.get(side, {}).get("gt_count", 0), "precision_near_gt": prec["precision"], "fires_near_gt": prec["near_count"], "sim_ev_all_pct": m_all.get("ev_pct"), "sim_ev_near_gt_pct": _rule_metrics(sub_near).get("ev_pct") if len(sub_near) else None, "sim_ev_far_gt_pct": _rule_metrics(sub_far).get("ev_pct") if len(sub_far) else None, "sim_win_rate": m_all.get("win_rate"), "sim_profit_factor": m_all.get("profit_factor"), "holdout_ev_pct": m_hold.get("ev_pct"), "holdout_count": m_hold.get("count"), } ) monitor_ids = [r["rule_id"] for r in matched.get("monitor_rules", [])] monitor_summary = [r for r in per_rule if r["rule_id"] in monitor_ids] go = sim_report.get("go_no_go", {}) return { "tolerance_min": tolerance_min, "label_mode": matched.get("label_mode"), "gt_baseline": gt_baseline, "gt_overlap_all_fires_dedup": overlap_all, "gt_overlap_matched_json": matched.get("gt_overlap"), "per_rule": per_rule, "pair_alignment": pair_stats, "monitor_rules": monitor_summary, "simulation_go_no_go": { "go": go.get("go"), "checks": go.get("checks", []), "live_cap_taken_ratio": go.get("live_cap_taken_ratio"), }, "notes": [ "gt_overlap_matched_json: 04 선별 시 전 규칙 발화 합산(중복 dt 제거 전) 기준.", "per_rule.gt_recall: 해당 규칙 발화만으로 GT 타점 커버.", "precision_near_gt: 발화 중 GT±tolerance 내 비율(낮을수록 잡음 많음).", "gt_forward_pct vs sim_leg_gt_pct: leg_gt 라벨과 GT JSON forward_return_pct 정의 차이 가능.", ], } def write_gt_comparison_html(report: dict[str, Any], out_path: Path) -> Path: """ gt_comparison_report.html 저장. Args: report: build_gt_comparison_report 결과. out_path: HTML 경로. Returns: out_path. """ def _rows(items: list[dict], cols: list[str]) -> str: lines = [] for it in items: cells = "".join(f"
허용 오차: ±{report.get('tolerance_min')}분 · 라벨: {report.get('label_mode')}
시뮬 Go/No-Go: {go_flag}
총 {gb.get('total')}건 (매수 {gb.get('buy')} / 매도 {gb.get('sell')})
| 구분 | 건수 | 평균 forward% | 중앙값 | 승률 |
|---|---|---|---|---|
| 매수 GT | {gb.get('buy', {}).get('count', '')} | {gb.get('buy', {}).get('mean_forward_pct', '')} | {gb.get('buy', {}).get('median_forward_pct', '')} | {gb.get('buy', {}).get('win_rate', '')} |
| 매도 GT | {gb.get('sell', {}).get('count', '')} | {gb.get('sell', {}).get('mean_forward_pct', '')} | {gb.get('sell', {}).get('median_forward_pct', '')} | {gb.get('sell', {}).get('win_rate', '')} |
| {c} | ' for c in pr_cols)}
|---|
| {c} | ' for c in pr_cols)}
|---|
| rule | side | pairs | corr | mean|diff|% | mean Δmin |
|---|---|---|---|---|---|
| {p['rule_id']} | {p['side']} | {p['pair_count']} | " f"{p.get('corr_gt_vs_sim','')} | {p.get('mean_abs_diff_pct','')} | " f"{p.get('mean_delta_min','')} |
{json.dumps(go, ensure_ascii=False, indent=2)}