"""build_horizon_rebalance_plan_v1.py — HORIZON_REBALANCE_PLAN_V1 routing_gate=FAIL 원인: SHORT 호라이즌 71.4% > 상한 40%. 어떤 종목을 어떤 순서로 줄여야 하는지 결정론적으로 산출한다. 입력: horizon_classification_v1.json + final_judgment_gate_v1.json + strategy_routing_audit_v1.json 출력: Temp/horizon_rebalance_plan_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 / "horizon_rebalance_plan_v1.json" FORMULA_ID = "HORIZON_REBALANCE_PLAN_V1" SHORT_CAP_PCT = 40.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 = 0.0) -> float: try: return float(v) except Exception: return default def _extract_harness(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 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) if not json_path.is_absolute(): json_path = ROOT / json_path out_path = Path(args.out) if not out_path.is_absolute(): out_path = ROOT / args.out payload = _load(json_path) harness = _extract_harness(payload) hz = _load(TEMP / "horizon_classification_v1.json") fj = _load(TEMP / "final_judgment_gate_v1.json") routing = _load(TEMP / "strategy_routing_audit_v1.json") alloc = hz.get("allocation_pct") or {} short_pct = _f(alloc.get("SHORT", 0)) excess_pct = max(0.0, short_pct - SHORT_CAP_PCT) # SHORT 종목 목록 (horizon_classification) hz_rows = hz.get("rows") or [] short_tickers = [r for r in hz_rows if isinstance(r, dict) and r.get("horizon") == "SHORT"] # final_judgment_gate의 verdict와 confidence 병합 fj_map = {r.get("ticker"): r for r in (fj.get("rows") or []) if isinstance(r, dict)} # 총 포트폴리오 자산 total_asset = _f(harness.get("total_asset_krw", 0)) portfolio_equity = total_asset - _f(harness.get("settlement_cash_d2_krw", 0)) # single_position_weight_json에서 비중 정보 조회 spwj = harness.get("single_position_weight_json") if isinstance(spwj, str): try: spwj = json.loads(spwj) except Exception: spwj = [] weight_map = {} for item in (spwj if isinstance(spwj, list) else []): if isinstance(item, dict): weight_map[str(item.get("ticker", ""))] = _f(item.get("weight_pct", 0)) # SHORT 종목별 리밸런싱 우선순위 산출 # 우선순위: SELL verdict > 낮은 confidence > 높은 weight candidates = [] for r in short_tickers: ticker = r.get("ticker", "") fj_row = fj_map.get(ticker, {}) verdict = str(fj_row.get("action_verdict", "UNKNOWN")) conf = _f(fj_row.get("effective_confidence", 50)) weight_pct = weight_map.get(ticker, 0) market_value = portfolio_equity * weight_pct / 100 if portfolio_equity > 0 else 0 disparity = _f(r.get("disparity_pct", 0)) rsi14 = _f(r.get("rsi14", 50)) # 우선순위 점수 (높을수록 먼저 줄임) priority = 0 if verdict in ("SELL",): priority += 40 elif verdict in ("TRIM",): priority += 20 priority += max(0, 60 - conf) # confidence 낮을수록 + priority += max(0, disparity - 5) * 2 # 이격도 높을수록 + priority += max(0, rsi14 - 60) * 0.5 # RSI 과매수일수록 + candidates.append({ "ticker": ticker, "name": r.get("name", ""), "horizon": "SHORT", "verdict": verdict, "effective_confidence": conf, "weight_pct": weight_pct, "market_value_krw": round(market_value), "disparity_pct": disparity, "rsi14": rsi14, "priority_score": round(priority, 1), }) candidates.sort(key=lambda x: x["priority_score"], reverse=True) # 목표: SHORT 비중을 40%로 줄이기 위한 최소 감축량 target_short_pct = SHORT_CAP_PCT # 단순 비례: 현재 71.4% → 40% = 31.4%p 감축 필요 # 각 종목의 비중을 합산해 필요 감축 시뮬레이션 required_reduction_pct = excess_pct # 31.4%p (SHORT 내 비중) # 절대 금액 환산 (portfolio_equity 기준) required_reduction_krw = portfolio_equity * required_reduction_pct / 100 if portfolio_equity > 0 else 0 # 누적 시뮬레이션 cum_reduction = 0.0 plan_rows = [] for c in candidates: if cum_reduction >= required_reduction_pct: break # 해당 종목 전량 매도 시 감축 pct (portfolio_equity 기준) trim_pct = c["weight_pct"] # 포트폴리오 비중 = 감축 효과 action = "FULL_TRIM" if verdict == "SELL" else "PARTIAL_TRIM" plan_rows.append({ **c, "recommended_action": action, "trim_weight_pct": round(trim_pct, 2), "cum_short_reduction_pct": round(cum_reduction + trim_pct, 2), }) cum_reduction += trim_pct result = { "formula_id": FORMULA_ID, "current_short_pct": short_pct, "short_cap_pct": SHORT_CAP_PCT, "excess_pct": round(excess_pct, 1), "required_reduction_pct": round(required_reduction_pct, 1), "required_reduction_krw": round(required_reduction_krw), "estimated_short_after_plan": round(max(0, short_pct - cum_reduction), 1), "gate_after_plan": "PASS" if max(0, short_pct - cum_reduction) <= SHORT_CAP_PCT else "FAIL", "plan_rows": plan_rows, "all_short_candidates": candidates, "note": ( "포트폴리오 total_asset 기준 시뮬레이션. " "실제 weight_pct는 prices_json 기준이며 " "당일 종가 변동에 따라 달라질 수 있음." ), } out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print( f"[{FORMULA_ID}] SHORT={short_pct}% excess={excess_pct}%p " f"plan_tickers={[r['ticker'] for r in plan_rows]} " f"after_plan={result['estimated_short_after_plan']}% " f"gate={result['gate_after_plan']} -> {out_path}" ) return 0 if __name__ == "__main__": raise SystemExit(main())