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>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user