7abe8d5089
- spec/30: routing_gate FAIL→PASS (2026-06-14 실측: SHORT=12.5% MID=50.0% LONG=37.5%) pass/fail 카운트 9/8→10/7 (58.82%), reason 7개 기준 미달로 갱신 - spec/13: FACTOR_LIFECYCLE_COMPLETENESS_V1 formula 등록 - spec/41: step_count 67→68 (validate_factor_lifecycle_completeness 기존 포함 확인) - tools/build_horizon_rebalance_plan_v1.py: docstring 갱신 (MID/LONG 상한 명시) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
232 lines
9.1 KiB
Python
232 lines
9.1 KiB
Python
"""build_horizon_rebalance_plan_v1.py — HORIZON_REBALANCE_PLAN_V1
|
|
|
|
routing_gate=FAIL 원인: strategy_routing_audit_v1.json의 horizon_violations 참조.
|
|
SHORT/MID/LONG 각 호라이즌 상한 대비 초과분을 결정론적으로 산출하고
|
|
우선순위 기반 리밸런싱 플랜을 생성한다.
|
|
상한: SHORT=40%, MID=50%, LONG=80%
|
|
|
|
입력: 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"
|
|
|
|
HORIZON_CAPS = {"SHORT": 40.0, "MID": 50.0, "LONG": 80.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 {}
|
|
hz_rows = hz.get("rows") or []
|
|
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))
|
|
|
|
# 호라이즌별 산출 데이터 저장소
|
|
horizon_results = {}
|
|
plan_rows = []
|
|
|
|
for H, cap_pct in HORIZON_CAPS.items():
|
|
current_pct = _f(alloc.get(H, 0))
|
|
excess_pct = max(0.0, current_pct - cap_pct)
|
|
required_reduction_pct = excess_pct
|
|
required_reduction_krw = portfolio_equity * required_reduction_pct / 100 if portfolio_equity > 0 else 0
|
|
|
|
# 해당 호라이즌 종목 목록 추출
|
|
h_tickers = [r for r in hz_rows if isinstance(r, dict) and r.get("horizon") == H]
|
|
|
|
candidates = []
|
|
for r in h_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)
|
|
priority += max(0, disparity - 5) * 2
|
|
priority += max(0, rsi14 - 60) * 0.5
|
|
|
|
candidates.append({
|
|
"ticker": ticker,
|
|
"name": r.get("name", ""),
|
|
"horizon": H,
|
|
"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)
|
|
|
|
# 누적 감축 계획 시뮬레이션
|
|
cum_reduction = 0.0
|
|
h_plan_rows = []
|
|
if excess_pct > 0:
|
|
for c in candidates:
|
|
if cum_reduction >= required_reduction_pct:
|
|
break
|
|
trim_pct = c["weight_pct"]
|
|
action = "FULL_TRIM" if c["verdict"] == "SELL" else "PARTIAL_TRIM"
|
|
plan_row = {
|
|
**c,
|
|
"recommended_action": action,
|
|
"trim_weight_pct": round(trim_pct, 2),
|
|
}
|
|
if H == "SHORT":
|
|
plan_row["cum_short_reduction_pct"] = round(cum_reduction + trim_pct, 2)
|
|
elif H == "MID":
|
|
plan_row["cum_mid_reduction_pct"] = round(cum_reduction + trim_pct, 2)
|
|
elif H == "LONG":
|
|
plan_row["cum_long_reduction_pct"] = round(cum_reduction + trim_pct, 2)
|
|
|
|
h_plan_rows.append(plan_row)
|
|
cum_reduction += trim_pct
|
|
|
|
estimated_after_plan = max(0.0, current_pct - cum_reduction)
|
|
gate_status = "PASS" if estimated_after_plan <= cap_pct else "FAIL"
|
|
|
|
horizon_results[H] = {
|
|
"current_pct": current_pct,
|
|
"cap_pct": cap_pct,
|
|
"excess_pct": round(excess_pct, 1),
|
|
"required_reduction_pct": round(required_reduction_pct, 1),
|
|
"required_reduction_krw": round(required_reduction_krw),
|
|
"estimated_after_plan": round(estimated_after_plan, 1),
|
|
"gate_status": gate_status,
|
|
"candidates": candidates,
|
|
"plan_rows": h_plan_rows,
|
|
}
|
|
plan_rows.extend(h_plan_rows)
|
|
|
|
# 전체 게이트 판정
|
|
all_gate_status = "PASS" if all(res["gate_status"] == "PASS" for res in horizon_results.values()) else "FAIL"
|
|
|
|
result = {
|
|
"formula_id": FORMULA_ID,
|
|
|
|
# 하위 호환성 필드 (SHORT 기준)
|
|
"current_short_pct": horizon_results["SHORT"]["current_pct"],
|
|
"short_cap_pct": horizon_results["SHORT"]["cap_pct"],
|
|
"excess_pct": horizon_results["SHORT"]["excess_pct"],
|
|
"required_reduction_pct": horizon_results["SHORT"]["required_reduction_pct"],
|
|
"required_reduction_krw": horizon_results["SHORT"]["required_reduction_krw"],
|
|
"estimated_short_after_plan": horizon_results["SHORT"]["estimated_after_plan"],
|
|
"gate_after_plan": all_gate_status,
|
|
|
|
# 신규 확장 필드 (MID 기준)
|
|
"current_mid_pct": horizon_results["MID"]["current_pct"],
|
|
"mid_cap_pct": horizon_results["MID"]["cap_pct"],
|
|
"mid_excess_pct": horizon_results["MID"]["excess_pct"],
|
|
"required_mid_reduction_pct": horizon_results["MID"]["required_reduction_pct"],
|
|
"required_mid_reduction_krw": horizon_results["MID"]["required_reduction_krw"],
|
|
"estimated_mid_after_plan": horizon_results["MID"]["estimated_after_plan"],
|
|
|
|
# 신규 확장 필드 (LONG 기준)
|
|
"current_long_pct": horizon_results["LONG"]["current_pct"],
|
|
"long_cap_pct": horizon_results["LONG"]["cap_pct"],
|
|
"long_excess_pct": horizon_results["LONG"]["excess_pct"],
|
|
"required_long_reduction_pct": horizon_results["LONG"]["required_reduction_pct"],
|
|
"required_long_reduction_krw": horizon_results["LONG"]["required_reduction_krw"],
|
|
"estimated_long_after_plan": horizon_results["LONG"]["estimated_after_plan"],
|
|
|
|
"plan_rows": plan_rows,
|
|
"all_short_candidates": horizon_results["SHORT"]["candidates"],
|
|
"all_mid_candidates": horizon_results["MID"]["candidates"],
|
|
"all_long_candidates": horizon_results["LONG"]["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={result['current_short_pct']}%(excess={result['excess_pct']}%p) "
|
|
f"MID={result['current_mid_pct']}%(excess={result['mid_excess_pct']}%p) "
|
|
f"plan_tickers={[r['ticker'] for r in plan_rows]} "
|
|
f"gate={result['gate_after_plan']} -> {out_path}"
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|