Files
QuantEngineByItz/tools/build_strategy_routing_audit_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

217 lines
9.5 KiB
Python

"""build_strategy_routing_audit_v1.py — STRATEGY_ROUTING_AUDIT_V1
프롬프트 §3.5 Strategy Routing Harness — 투자기간별 전략 분기 검증.
검증 항목:
- selected_horizon (지배적 투자기간)
- horizon_conflict_count (호라이즌 상한 초과 위반 수)
- 4성향(SCALP/SWING/MOMENTUM/POSITION) 분리 점수 검증
- 롱 호라이즌 보유 종목이 펀더멘털 데이터 없이 장기 분류됐는지 점검
- routing_confidence (라우팅 신뢰도 추정)
- selected_strategy / rejected_strategies
- required_conditions / failed_conditions
산출물: Temp/strategy_routing_audit_v1.json
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
TEMP = ROOT / "Temp"
DEFAULT_JSON = ROOT / "GatherTradingData.json"
DEFAULT_OUT = TEMP / "strategy_routing_audit_v1.json"
FORMULA_ID = "STRATEGY_ROUTING_AUDIT_V1"
NA = "not_available"
# 호라이즌 상한 (% 기준)
HORIZON_CAPS = {"SHORT": 40, "MID": 50, "LONG": 80, "UNKNOWN": 0}
def _load(path: Path) -> Any:
if not path.exists():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
def _f(v: Any, default: float | None = None) -> float | None:
try:
return float(v)
except Exception:
return default
def _extract_harness_root(payload: Any) -> dict[str, Any]:
if not isinstance(payload, dict):
return {}
h = payload.get("hApex")
dc = (payload.get("data") or {}).get("_harness_context")
if isinstance(h, dict) and isinstance(dc, dict):
m = dict(dc); m.update(h); return m
return h if isinstance(h, dict) else dc if isinstance(dc, dict) else payload
def _map_style_to_horizon(style: str) -> str:
return {"SCALP": "SHORT", "SWING": "SHORT", "MOMENTUM": "MID", "POSITION": "LONG"}.get(style, "UNKNOWN")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--json", default=str(DEFAULT_JSON))
ap.add_argument("--out", default=str(DEFAULT_OUT))
args = ap.parse_args()
json_path = Path(args.json); json_path = json_path if json_path.is_absolute() else ROOT / json_path
out_path = Path(args.out); out_path = out_path if out_path.is_absolute() else ROOT / args.out
payload = _load(json_path)
harness = _extract_harness_root(payload)
horizon_cls = _load(TEMP / "horizon_classification_v1.json")
cap_style = _load(TEMP / "capital_style_allocation_v1.json")
fund = _load(TEMP / "fundamental_multifactor_v3.json")
fj = _load(TEMP / "final_judgment_gate_v1.json")
regime = str(harness.get("regime_label") or harness.get("market_regime") or NA)
# ── 1) 호라이즌 배분 및 위반 ──────────────────────────────────────────────
alloc = horizon_cls.get("allocation_pct") or {}
dominant_horizon = max(alloc, key=lambda k: alloc.get(k, 0)) if alloc else NA
horizon_violations: list[dict] = []
for bucket, cap in HORIZON_CAPS.items():
current = _f(alloc.get(bucket), 0)
if current is not None and current > cap:
horizon_violations.append({
"bucket": bucket,
"current_pct": current,
"cap_pct": cap,
"excess_pct": round(current - cap, 1),
})
horizon_conflict_count = len(horizon_violations)
# ── 2) 장기 보유 종목 펀더멘털 검증 ──────────────────────────────────────
fund_rows = {r["ticker"]: r for r in (fund.get("rows") or []) if isinstance(r, dict)}
hz_rows = horizon_cls.get("rows") or []
long_without_fund: list[str] = []
for r in hz_rows:
if not isinstance(r, dict):
continue
if r.get("horizon") == "LONG":
t = r.get("ticker", "")
fr = fund_rows.get(t, {})
grade = fr.get("grade", "UNKNOWN")
dq = str(fr.get("data_quality", ""))
if dq == "PARTIAL" or grade in ("D", "F", "UNKNOWN"):
long_without_fund.append(t)
# ── 3) 4성향(SCALP/SWING/MOMENTUM/POSITION) 분리 점수 ──────────────────
cap_rows = cap_style.get("rows") or []
style_distribution: dict[str, int] = {"SCALP": 0, "SWING": 0, "MOMENTUM": 0, "POSITION": 0}
style_horizon_mismatches: list[dict] = []
per_ticker_styles: list[dict] = []
for r in cap_rows:
if not isinstance(r, dict):
continue
ticker = r.get("ticker", "")
styles = r.get("styles") or []
if not styles:
continue
# 최적 성향 = 최고 conviction_score
best = max(styles, key=lambda s: _f(s.get("conviction_score"), 0) or 0, default={})
best_style = best.get("style", "UNKNOWN")
best_conv = _f(best.get("conviction_score"))
expected_horizon = _map_style_to_horizon(best_style)
# 실제 호라이즌 (horizon_classification 기준)
actual_hz = next((hr.get("horizon") for hr in hz_rows if isinstance(hr, dict) and hr.get("ticker") == ticker), NA)
mismatch = (expected_horizon != actual_hz and actual_hz != NA and actual_hz != "ETF")
if best_style in style_distribution:
style_distribution[best_style] += 1
per_ticker_styles.append({
"ticker": ticker,
"best_style": best_style,
"conviction_score": best_conv,
"expected_horizon": expected_horizon,
"actual_horizon": actual_hz,
"mismatch": mismatch,
})
if mismatch:
style_horizon_mismatches.append({"ticker": ticker, "style": best_style,
"expected": expected_horizon, "actual": actual_hz})
# ── 4) 라우팅 신뢰도 ────────────────────────────────────────────────────
# routing_confidence: 0=conflict 많음/100=완전 정합
conflict_penalty = horizon_conflict_count * 20
mismatch_penalty = len(style_horizon_mismatches) * 10
fund_penalty = len(long_without_fund) * 15
routing_confidence = max(0, 100 - conflict_penalty - mismatch_penalty - fund_penalty)
# ── 5) 선택 전략 / 거부 전략 ─────────────────────────────────────────────
dominant_style = cap_style.get("capital_style_label") or NA
selected_strategy = f"{dominant_style}_{dominant_horizon}"
all_strategies = [f"{s}_{_map_style_to_horizon(s)}" for s in ("SCALP", "SWING", "MOMENTUM", "POSITION")]
rejected_strategies = [s for s in all_strategies if s != selected_strategy]
rejection_reasons = {
"SCALP_SHORT": "현금부족+BREACH 종목 → 신규진입 차단",
"SWING_SHORT": "현금부족+BREACH 종목 → 신규진입 차단",
"MOMENTUM_MID": f"SHORT 비중 {alloc.get('SHORT',0)}% 과다 → MID 진입 여력 없음",
"POSITION_LONG": "펀더멘털 결측(ROE/OPM/OCF/FCF) → long_horizon_allowed=false",
}
# ── 6) required / failed conditions ─────────────────────────────────────
required_conditions = [
"data_quality_gate PASS",
"cash_floor 충족",
"horizon_conflict_count == 0",
"long_without_fund_count == 0",
"fundamental_claim_allowed",
]
failed_conditions = []
if horizon_conflict_count > 0:
# 실제 위반 버킷을 정확히 보고 (SHORT 고정 라벨 버그 수정)
violation_labels = [f"{v['bucket']} {v['current_pct']}% > cap {v['cap_pct']}%" for v in horizon_violations]
failed_conditions.append(f"horizon_conflict_count={horizon_conflict_count} ({'; '.join(violation_labels)})")
if long_without_fund:
failed_conditions.append(f"long_without_fund={long_without_fund}")
cash_status = str(harness.get("cash_floor_status") or "")
if cash_status in ("BELOW_FLOOR", "강제 차단"):
failed_conditions.append(f"cash_floor_status={cash_status}")
result = {
"formula_id": FORMULA_ID,
"market_regime": regime,
"selected_horizon": dominant_horizon,
"horizon_allocation_pct": alloc,
"horizon_conflict_count": horizon_conflict_count,
"horizon_violations": horizon_violations,
"selected_strategy": selected_strategy,
"rejected_strategies": rejected_strategies,
"rejection_reasons": rejection_reasons,
"routing_confidence": routing_confidence,
"style_distribution": style_distribution,
"style_horizon_mismatches": style_horizon_mismatches,
"long_without_fundamental_data": long_without_fund,
"required_conditions": required_conditions,
"failed_conditions": failed_conditions,
"per_ticker_styles": per_ticker_styles,
"gate": "FAIL" if failed_conditions else "PASS",
}
out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(
f"[{FORMULA_ID}] horizon={dominant_horizon} regime={regime} "
f"conflicts={horizon_conflict_count} style_mismatches={len(style_horizon_mismatches)} "
f"routing_confidence={routing_confidence} gate={result['gate']} -> {out_path}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())