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>
150 lines
5.6 KiB
Python
150 lines
5.6 KiB
Python
"""build_truth_reconciliation_gate_v1.py — TRUTH_RECONCILIATION_GATE_V1
|
|
|
|
P0-T3: 동일 지표가 파일마다 다른 값을 가지면 자동 FAIL.
|
|
감시 지표: prediction_match_rate_pct, t20_pass_rate, value_damage_pct_avg,
|
|
gs_coverage_pct, portfolio_alpha_confidence, performance_readiness_score
|
|
허용 오차: 비율 지표 ±0.5%p, 금액 지표 ±1원
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
TEMP = ROOT / "Temp"
|
|
DEFAULT_OUT = TEMP / "truth_reconciliation_gate_v1.json"
|
|
|
|
TOLERANCE_RATE = 0.5 # %p
|
|
TOLERANCE_KRW = 1.0 # 원
|
|
|
|
# 감시 지표: (정규화된 metric_id, json_pointer_list, 단위, 제외 파일 패턴)
|
|
# 위양성 방지: 같은 key명이 다른 개념에 쓰이는 파일은 명시 제외
|
|
MONITORED_METRICS: list[tuple[str, list[str], str, set[str]]] = [
|
|
("prediction_match_rate_pct",
|
|
["prediction_match_rate_pct", "t5_ap_combined"],
|
|
"rate",
|
|
# v5 = legacy v5.todo.batch 파일 (builder 없음), v7 = 다른 블렌드 점수
|
|
{"prediction_accuracy_harness_v5", "smart_cash_recovery_v7"}),
|
|
("t20_pass_rate",
|
|
["t20_pass_rate"], # pass_rate_pct는 제외 (completion_gap과 혼동)
|
|
"rate",
|
|
{"completion_gap", "phase_checks"}), # 완료기준 통과율 파일 제외
|
|
("value_damage_pct_avg",
|
|
["value_damage_pct_avg"],
|
|
"rate",
|
|
# 다른 목적함수 + 구버전 아카이브 파일 제외 (현재 파이프라인 외 레거시)
|
|
{"dynamic_value_preservation", "cash_raise_value_optimizer",
|
|
"cash_raise_value_preservation", "value_preserving_cash_raise_v1",
|
|
"hts_sell_blueprint",
|
|
"smart_cash_recovery_v7.json"}), # v7 non-authoritative (2026-05-31 legacy)
|
|
("gs_strict_coverage_pct", # gs_coverage_pct 대신 strict 전용 포인터
|
|
["gs_coverage_pct"],
|
|
"rate",
|
|
{"gs_native_coverage_lock"}), # native coverage는 다른 개념
|
|
("portfolio_alpha_confidence",
|
|
["portfolio_alpha_confidence", "alpha_confidence"],
|
|
"rate",
|
|
set()),
|
|
("performance_readiness_score",
|
|
["performance_readiness_score", "blended_performance_readiness_score"],
|
|
"rate",
|
|
set()),
|
|
]
|
|
|
|
|
|
def _load(p: Path) -> dict[str, Any]:
|
|
if not p.exists():
|
|
return {}
|
|
try:
|
|
obj = json.loads(p.read_text(encoding="utf-8"))
|
|
return obj if isinstance(obj, dict) else {}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _extract(d: dict[str, Any], pointers: list[str]) -> float | None:
|
|
for ptr in pointers:
|
|
v = d.get(ptr)
|
|
if v is not None:
|
|
try:
|
|
f = float(v)
|
|
if f != 0.0 or ptr in d:
|
|
return f
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return None
|
|
|
|
|
|
def main() -> int:
|
|
# 모든 Temp JSON 로드
|
|
json_files = list(TEMP.glob("*.json"))
|
|
# 제외: 보고서/golden/binary
|
|
exclude_patterns = {"formula_golden", "formula_behavioral", "formula_gas_parity", "engine_audit_2026"}
|
|
candidates = [f for f in json_files if not any(ex in f.name for ex in exclude_patterns)]
|
|
|
|
observations: dict[str, list[dict[str, Any]]] = {m[0]: [] for m in MONITORED_METRICS}
|
|
|
|
for f in candidates:
|
|
d = _load(f)
|
|
if not d:
|
|
continue
|
|
rel = str(f.relative_to(ROOT))
|
|
for metric_id, pointers, unit, exclude_patterns in MONITORED_METRICS:
|
|
# 제외 패턴 파일 스킵
|
|
if any(ep in f.name for ep in exclude_patterns):
|
|
continue
|
|
val = _extract(d, pointers)
|
|
if val is not None:
|
|
observations[metric_id].append({"file": rel, "value": val})
|
|
|
|
conflicts: list[dict[str, Any]] = []
|
|
for metric_id, pointers, unit, _ in MONITORED_METRICS:
|
|
obs = observations[metric_id]
|
|
if len(obs) < 2:
|
|
continue
|
|
values = [o["value"] for o in obs]
|
|
min_v, max_v = min(values), max(values)
|
|
tol = TOLERANCE_RATE if unit == "rate" else TOLERANCE_KRW
|
|
if (max_v - min_v) > tol:
|
|
conflicts.append({
|
|
"metric_id": metric_id,
|
|
"min": min_v,
|
|
"max": max_v,
|
|
"spread": round(max_v - min_v, 4),
|
|
"tolerance": tol,
|
|
"unit": unit,
|
|
"observations": sorted(obs, key=lambda x: x["value"]),
|
|
})
|
|
|
|
gate = "PASS" if not conflicts else "FAIL"
|
|
result = {
|
|
"formula_id": "TRUTH_RECONCILIATION_GATE_V1",
|
|
"gate": gate,
|
|
"conflict_count": len(conflicts),
|
|
"conflicts": conflicts,
|
|
"monitored_metrics": [m[0] for m in MONITORED_METRICS],
|
|
"excluded_per_metric": {m[0]: list(m[3]) for m in MONITORED_METRICS if m[3]},
|
|
"files_scanned": len(candidates),
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
|
|
DEFAULT_OUT.parent.mkdir(parents=True, exist_ok=True)
|
|
DEFAULT_OUT.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
summary = {k: v for k, v in result.items() if k != "conflicts"}
|
|
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
|
if gate == "PASS":
|
|
print("TRUTH_RECONCILIATION_GATE_V1_PASS")
|
|
else:
|
|
print(f"TRUTH_RECONCILIATION_GATE_V1_FAIL ({len(conflicts)} conflicts)")
|
|
for c in conflicts:
|
|
print(f" {c['metric_id']}: spread={c['spread']} (tol={c['tolerance']})")
|
|
for o in c["observations"]:
|
|
print(f" {o['file']}: {o['value']}")
|
|
return 0 if gate == "PASS" else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|