ee3e799de1
주요 변경: - tools/build_rebalance_engine_v1.py: REBALANCE_ENGINE_V1 신규 * account_snapshot 직접 합산(_build_snap_position_map) → 소수주 분리 행 병합 * 레짐 소스 macro.REGIME_PRELIM 최우선 (GAS 와 동일) - src/gas_adapter_parts/gdf_06_rebalance.gs: runRebalanceSheet_() 신규 * Logger.log / getSpreadsheet_() 로 run_all 연동 수정 - src/gas_adapter_parts/gdc_01_fetch_fundamentals.gs * _mergePositionRecord_(): 소수주 중복 행 합산 신규 * parseInt → parseFloat (qty, availQty) - src/gas_adapter_parts/gdf_01_price_metrics.gs * 미보유 종목 SELL_READY → WATCH_EXIT_SIGNAL - spec/41_release_dag.yaml: build_rebalance_sheet 노드 추가 (step_count 63) - spec/51_formula_lifecycle_registry.yaml: REBALANCE_ENGINE_V1 등록 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
145 lines
6.8 KiB
Python
145 lines
6.8 KiB
Python
"""build_performance_readiness_replay_bridge_v1.py — P1-009: Performance Readiness Replay Bridge
|
|
|
|
REPLAY 표본(REPLAY_BACKFILL/REPLAY_FROM_KRX_EOD)은 성과 지표 집계 혼입 금지(spec/29).
|
|
LIVE/PAPER 표본이 30건 이상 축적돼야 PERFORMANCE_READY 판정.
|
|
현재 인프라 구축 단계: gate=WATCH_PENDING_LIVE_SAMPLE.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import statistics
|
|
from collections import Counter
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from v7_hardening_common import ROOT, TEMP, load_json, save_json
|
|
|
|
DEFAULT_HIST = ROOT / "Temp" / "proposal_evaluation_history.json"
|
|
DEFAULT_OUT = TEMP / "performance_readiness_replay_bridge_v1.json"
|
|
|
|
LIVE_SAMPLE_MIN = 30 # gate PERFORMANCE_READY 조건
|
|
LIVE_T20_PASS_RATE_MIN = 60.0
|
|
|
|
_REPLAY_ORIGINS = {"REPLAY_FROM_KRX_EOD", "REPLAY_BACKFILL"}
|
|
_REPLAY_VALIDATION = {"REPLAY_BACKFILL"}
|
|
|
|
|
|
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 _pass_rate(records: list[dict], outcome_key: str) -> float:
|
|
matched = [r for r in records if r.get(outcome_key) == "MATCHED"]
|
|
return round(len(matched) / len(records) * 100.0, 2) if records else 0.0
|
|
|
|
|
|
def _avg_return(records: list[dict], ret_key: str) -> float | None:
|
|
vals = [r[ret_key] for r in records if r.get(ret_key) is not None]
|
|
return round(statistics.mean(vals), 4) if vals else None
|
|
|
|
|
|
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_t20 = [r for r in live_all if r.get("t20_evaluation_status") == "EVALUATED_T20"]
|
|
replay_t20 = [r for r in replay_all if r.get("t20_evaluation_status") == "EVALUATED_T20"]
|
|
|
|
live_t5 = [r for r in live_all if r.get("t5_evaluation_status") == "EVALUATED_T5"]
|
|
replay_t5 = [r for r in replay_all if r.get("t5_evaluation_status") == "EVALUATED_T5"]
|
|
|
|
live_t20_count = len(live_t20)
|
|
replay_t20_count = len(replay_t20)
|
|
|
|
# ── LIVE 성과 지표만 집계 (spec/29: REPLAY 혼입 금지) ──────────────────
|
|
live_t20_pass_rate = _pass_rate(live_t20, "t20_outcome")
|
|
live_t20_avg_ret = _avg_return(live_t20, "t20_return_pct")
|
|
live_t5_pass_rate = _pass_rate(live_t5, "t5_outcome")
|
|
|
|
# ── REPLAY 정보용 통계 (성과 지표로 사용 금지) ─────────────────────────
|
|
replay_t20_pass_rate = _pass_rate(replay_t20, "t20_outcome") # informational only
|
|
replay_t20_avg_ret = _avg_return(replay_t20, "t20_return_pct") # informational only
|
|
|
|
# ── gate 판정 ────────────────────────────────────────────────────────────
|
|
if live_t20_count >= LIVE_SAMPLE_MIN and live_t20_pass_rate >= LIVE_T20_PASS_RATE_MIN:
|
|
gate = "PERFORMANCE_READY"
|
|
readiness_score = min(100.0, live_t20_pass_rate)
|
|
elif live_t20_count >= LIVE_SAMPLE_MIN:
|
|
gate = "WATCH_LIVE_BELOW_THRESHOLD"
|
|
readiness_score = live_t20_pass_rate
|
|
elif live_t20_count > 0:
|
|
gate = "WATCH_PENDING_LIVE_SAMPLE"
|
|
# 부분 반영: live 표본이 일부 있으면 비례 가산
|
|
readiness_score = min(50.0, live_t20_count / LIVE_SAMPLE_MIN * 50.0)
|
|
else:
|
|
gate = "WATCH_PENDING_LIVE_SAMPLE"
|
|
readiness_score = 0.0
|
|
|
|
# ── replay vs live gap (live 표본 있을 때만 계산) ─────────────────────
|
|
replay_vs_live_gap_pct: float | None = None
|
|
if live_t20_count >= 5 and live_t20_avg_ret is not None and replay_t20_avg_ret is not None:
|
|
replay_vs_live_gap_pct = round(abs(replay_t20_avg_ret - live_t20_avg_ret), 4)
|
|
|
|
result = {
|
|
"formula_id": "PERFORMANCE_READINESS_REPLAY_BRIDGE_V1",
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
"gate": gate,
|
|
"readiness_gate": gate,
|
|
"performance_readiness_score": round(readiness_score, 2),
|
|
# ── live 집계 (성과 지표) ──────────────────────────────────────────
|
|
"live": {
|
|
"total_records": len(live_all),
|
|
"t20_count": live_t20_count,
|
|
"t5_count": len(live_t5),
|
|
"t20_pass_rate_pct": live_t20_pass_rate,
|
|
"t5_pass_rate_pct": live_t5_pass_rate,
|
|
"t20_avg_return_pct": live_t20_avg_ret,
|
|
"sample_gate": "PASS" if live_t20_count >= LIVE_SAMPLE_MIN else f"PENDING({live_t20_count}/{LIVE_SAMPLE_MIN})",
|
|
},
|
|
# ── replay 집계 (정보용 — 성과 지표로 사용 금지) ─────────────────
|
|
"replay_informational": {
|
|
"total_records": len(replay_all),
|
|
"t20_count": replay_t20_count,
|
|
"t5_count": len(replay_t5),
|
|
"t20_pass_rate_pct": replay_t20_pass_rate,
|
|
"t20_avg_return_pct": replay_t20_avg_ret,
|
|
"note": "REPLAY 표본은 성과지표 산출 금지(spec/29). 인프라 상태 확인용만.",
|
|
},
|
|
# ── 종합 ──────────────────────────────────────────────────────────
|
|
"replay_vs_live_gap_pct": replay_vs_live_gap_pct,
|
|
"required_live_t20_count": LIVE_SAMPLE_MIN,
|
|
"required_live_t20_pass_rate_pct": LIVE_T20_PASS_RATE_MIN,
|
|
"targets": {
|
|
"live_t20_count": f">={LIVE_SAMPLE_MIN}",
|
|
"live_t20_pass_rate_pct": f">={LIVE_T20_PASS_RATE_MIN}",
|
|
"replay_vs_live_gap_pct": "<=10 (live 집계 가능 시)",
|
|
},
|
|
"prohibitions": [
|
|
"REPLAY 표본 성과지표 혼입 금지",
|
|
"REPLAY T20를 operational_t20_count에 가산 금지",
|
|
"live < 30 상태에서 PERFORMANCE_READY 판정 금지",
|
|
],
|
|
}
|
|
|
|
save_json(args.out, result)
|
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|