Files
QuantEngineByItz/tools/build_performance_readiness_replay_bridge_v1.py
T
kjh2064 ee3e799de1 feat: 리밸런싱 엔진 V1 + GAS 버그 수정 (2026-06-13)
주요 변경:
- 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>
2026-06-13 13:20:14 +09:00

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())