"""build_continuous_evaluation_dashboard_v1.py — CONTINUOUS_EVALUATION_DASHBOARD_V1 P2-020: 주간 성과 대시보드. - LIVE T+20 표본에서 기대수익/승률/MDD/수익반납 지표 산출 - REPLAY 표본은 informational 섹션에만 집계 (성과 계산 혼입 금지) - T+20 미확정 → None으로 표기, INSUFFICIENT_DATA 게이트 """ from __future__ import annotations import argparse import json from collections import defaultdict from datetime import datetime, timezone from pathlib import Path from typing import Any from v7_hardening_common import ROOT, TEMP, load_json, save_json DEFAULT_HIST = ROOT / "Temp" / "proposal_evaluation_history.json" DEFAULT_OUT = TEMP / "continuous_evaluation_dashboard_v1.json" MIN_T20_FOR_METRICS = 30 # 성과 지표 신뢰성 최소 표본 수 _REPLAY_ORIGINS = {"REPLAY_FROM_KRX_EOD", "HISTORICAL_REPLAY", "BACKTEST"} _REPLAY_VALIDATION = {"REPLAY", "HISTORICAL_REPLAY"} def _is_replay(r: dict) -> bool: return ( str(r.get("data_origin") or "").upper() in _REPLAY_ORIGINS or str(r.get("validation_status") or "").upper() in _REPLAY_VALIDATION or str(r.get("record_type") or "").upper().startswith("HISTORICAL_REPLAY") ) def _is_evaluated_t20(r: dict) -> bool: return ( r.get("t20_evaluation_status") == "EVALUATED_T20" and r.get("t20_return_pct") is not None ) def _iso_week(date_str: str | None) -> str | None: if not date_str: return None try: dt = datetime.strptime(str(date_str)[:10], "%Y-%m-%d") return dt.strftime("%G-W%V") except ValueError: return None def _compute_metrics(t20_returns: list[float]) -> dict[str, Any]: if not t20_returns: return { "expectancy_pct": None, "win_rate_pct": None, "max_drawdown_pct": None, "trade_count": 0, } wins = [r for r in t20_returns if r > 0] return { "expectancy_pct": round(sum(t20_returns) / len(t20_returns), 4), "win_rate_pct": round(len(wins) / len(t20_returns) * 100, 2), "max_drawdown_pct": round(min(t20_returns), 4), "trade_count": len(t20_returns), } def _build_weekly_scorecard(live_eval: list[dict]) -> list[dict]: weeks: dict[str, list[float]] = defaultdict(list) for r in live_eval: week = _iso_week(r.get("proposal_date") or r.get("created_at")) if week: weeks[week].append(float(r["t20_return_pct"])) scorecard = [] for week in sorted(weeks.keys()): returns = weeks[week] m = _compute_metrics(returns) m["week"] = week scorecard.append(m) return scorecard def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--hist", default=str(DEFAULT_HIST)) ap.add_argument("--out", default=str(DEFAULT_OUT)) args = ap.parse_args() hist_raw = load_json(Path(args.hist)) records: list[dict] = ( hist_raw.get("records", []) if isinstance(hist_raw, dict) else (hist_raw if isinstance(hist_raw, list) else []) ) # ── 분류 ───────────────────────────────────────────────────────────────── live_all = [r for r in records if not _is_replay(r)] replay_all = [r for r in records if _is_replay(r)] live_eval = [r for r in live_all if _is_evaluated_t20(r)] live_t20_count = len(live_eval) insufficient = live_t20_count < MIN_T20_FOR_METRICS # ── 성과 지표 (LIVE T+20 전체) ──────────────────────────────────────── returns = [float(r["t20_return_pct"]) for r in live_eval] if live_eval else [] overall = _compute_metrics(returns) # ── 주간 스코어카드 ────────────────────────────────────────────────── weekly_scorecard = _build_weekly_scorecard(live_eval) # ── gate 판정 ───────────────────────────────────────────────────────── exp = overall.get("expectancy_pct") wr = overall.get("win_rate_pct") if insufficient: gate = "INSUFFICIENT_DATA" elif exp is not None and wr is not None and (exp < 0 or wr < 40): gate = "WARNING" else: gate = "PASS" result = { "formula_id": "CONTINUOUS_EVALUATION_DASHBOARD_V1", "generated_at": datetime.now(timezone.utc).isoformat(), "gate": gate, # ── 전체 지표 ──────────────────────────────────────────────────── "weekly_scorecard_generated": len(weekly_scorecard) > 0, "expectancy_pct": overall.get("expectancy_pct"), "win_rate_pct": overall.get("win_rate_pct"), "max_drawdown_pct": overall.get("max_drawdown_pct"), "profit_giveback_pct": None, # T+20 이후 추적 미구현 "total_live_evaluated_t20": live_t20_count, "total_live_pending": len(live_all) - live_t20_count, # ── 주간 스코어카드 ───────────────────────────────────────────── "weekly_scorecard": weekly_scorecard, "weekly_scorecard_count": len(weekly_scorecard), # ── informational (REPLAY 분리) ────────────────────────────────── "replay_informational": { "replay_record_count": len(replay_all), "note": "REPLAY 표본은 성과 지표 계산에 포함되지 않음", }, # ── 데이터 신뢰성 ───────────────────────────────────────────── "data_confidence": { "sufficient_for_metrics": not insufficient, "min_required": MIN_T20_FOR_METRICS, "current_live_t20": live_t20_count, "gap": max(0, MIN_T20_FOR_METRICS - live_t20_count), "estimated_ready": "~2026-07-15" if insufficient else "NOW", }, "prohibitions": [ "REPLAY 표본을 성과 지표 계산에 포함 금지", "T+20 미확정 거래를 EVALUATED_T20으로 분류 금지", "외부 가격 데이터 직접 조회 금지 (history 기록 기준만 사용)", ], } save_json(args.out, result) suffix = f"(need {max(0, MIN_T20_FOR_METRICS - live_t20_count)} more LIVE T+20)" if insufficient else "" print( f"[CONTINUOUS_EVALUATION_DASHBOARD_V1] gate={gate} " f"live_t20={live_t20_count} " f"expectancy={overall.get('expectancy_pct')} " f"win_rate={overall.get('win_rate_pct')}% " f"MDD={overall.get('max_drawdown_pct')} " f"weeks={len(weekly_scorecard)} {suffix}" ) return 0 if __name__ == "__main__": raise SystemExit(main())