[Sprint-3] Complete WBS-2.1, 3.2, 4.1, 5.1 - Fundamental V2, Engine V2, Performance Ledger, and CI/CD

This commit is contained in:
2026-06-13 15:38:28 +09:00
parent 0748c224da
commit 08f4fa2293
5 changed files with 473 additions and 358 deletions
+184
View File
@@ -0,0 +1,184 @@
"""build_rebalance_engine_v2.py — REBALANCE_ENGINE_V2 (Score-Based Allocation)
개선 사항:
1. Equal Weight 탈피: SS001_Norm_Score 를 가중치로 사용하여 종목별 목표 비중 동적 산출.
2. 리스크 예산 연동: 신호가 강한 종목에 더 많은 비중을 배분.
3. 로드맵 WBS-3.2 완결.
로직:
ticker_target_pct = bucket_target_pct * (SS001_Score / sum(Scores_in_bucket))
"""
from __future__ import annotations
import argparse
import json
import math
import os
from pathlib import Path
from typing import Any
# V1 모듈 재사용을 위해 sys.path 추가 (필요시)
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.append(str(ROOT))
# V1에서 유틸리티 함수 및 설정 상속 (직접 정의)
TEMP = ROOT / "Temp"
DEFAULT_JSON = ROOT / "GatherTradingData.json"
DEFAULT_OUT = TEMP / "rebalance_engine_v2.json"
FORMULA_ID = "REBALANCE_ENGINE_V2"
BUCKET_CONFIG: dict[str, dict] = {
"Core": {"target": 66.0, "min": 60.0, "max": 72.0},
"Satellite": {"target": 17.5, "min": 10.0, "max": 25.0},
"Cash": {"target": 16.5, "min": 10.0, "max": 22.0},
}
CORE_TICKERS_BASE: set[str] = {"005930", "000660", "000270"}
REGIME_BANDS: dict[str, dict] = {
"RISK_ON": {"label": "RISK_ON ±15%p", "expand": 15.0, "contract": 15.0},
"NEUTRAL": {"label": "NEUTRAL ±5%p", "expand": 5.0, "contract": 5.0},
"RISK_OFF": {"label": "RISK_OFF +2/10%p", "expand": 2.0, "contract": 10.0},
"_DEFAULT": {"label": "NEUTRAL ±5%p", "expand": 5.0, "contract": 5.0},
}
TX_COST_ROUNDTRIP = 0.007
COST_BENEFIT_THRESHOLD = 0.005
MIN_ACTIONABLE_DRIFT_PCT = 1.2
STAGE_RATIOS = [0.30, 0.30, 0.40]
def _load(path: Path) -> Any:
if not path.exists(): return {}
try: return json.loads(path.read_text(encoding="utf-8"))
except: return {}
def _f(v: Any, default: float = 0.0) -> float:
try: return float(v)
except: return default
def _detect_force_signal(row: dict) -> str:
combined = " ".join([str(row.get(k) or "").upper() for k in ["Sell_Reason", "Final_Action", "Sell_Action"]])
if "ABS_FLOOR" in combined: return "ABS_FLOOR"
if any(k in combined for k in ["TIME_STOP", "TIME_EXIT"]): return "TIME_STOP"
return ""
def _extract_df_rows(payload: Any) -> list[dict]:
return payload.get("data", {}).get("data_feed", [])
def _extract_portfolio_totals(payload: Any) -> tuple[float, float]:
settings = payload.get("data", {}).get("settings", {})
return _f(settings.get("total_asset_krw")), _f(settings.get("settlement_cash_d2_krw"))
def _extract_regime(payload: Any) -> str:
macro = payload.get("data", {}).get("macro", [])
for r in macro:
if str(r.get("Symbol")) == "REGIME_PRELIM":
return str(r.get("Close")).upper()
return "NEUTRAL"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--json", default=str(DEFAULT_JSON))
ap.add_argument("--out", default=str(DEFAULT_OUT))
args = ap.parse_args()
payload = _load(Path(args.json))
df_rows = _extract_df_rows(payload)
regime = _extract_regime(payload)
total_asset, cash_krw = _extract_portfolio_totals(payload)
# 1. 종목별 데이터 추출 (점수 포함)
holdings = []
bucket_scores: dict[str, float] = {}
for row in df_rows:
ticker = str(row.get("Ticker", ""))
mv = _f(row.get("Account_Market_Value"))
if mv <= 0: continue
# 신호 점수 추출 (SS001_Norm_Score)
score = _f(row.get("SS001_Norm_Score"), 50.0) # 기본값 50
bucket = "Core" if ticker in CORE_TICKERS_BASE else "Satellite"
holdings.append({
"ticker": ticker,
"name": row.get("Name"),
"bucket": bucket,
"weight_pct": _f(row.get("Weight_Pct")),
"score": score,
"close": _f(row.get("Close")),
"qty": _f(row.get("Account_Holding_Qty")),
"force_signal": _detect_force_signal(row)
})
bucket_scores[bucket] = bucket_scores.get(bucket, 0.0) + score
# 2. 버킷별 목표 비중 및 종목별 목표 비중 계산 (고도화 로직)
ticker_rows = []
band = REGIME_BANDS.get(regime, REGIME_BANDS["_DEFAULT"])
for h in holdings:
bucket_target = BUCKET_CONFIG[h["bucket"]]["target"]
total_score_in_bucket = bucket_scores[h["bucket"]]
# [V2 핵심] 점수 비례 목표 비중 산출
if total_score_in_bucket > 0:
target_pct = round(bucket_target * (h["score"] / total_score_in_bucket), 2)
else:
target_pct = round(bucket_target / 1, 2) # fallback
current_pct = h["weight_pct"]
drift = round(current_pct - target_pct, 2)
# 밴드 설정
b_min = round(target_pct - band["contract"], 2)
b_max = round(target_pct + band["expand"], 2)
# 액션 결정
action = "HOLD"
if h["force_signal"]:
action = "SELL"
elif drift > MIN_ACTIONABLE_DRIFT_PCT:
action = "SELL"
elif drift < -MIN_ACTIONABLE_DRIFT_PCT:
action = "BUY"
ticker_rows.append({
"ticker": h["ticker"],
"name": h["name"],
"bucket": h["bucket"],
"score": h["score"],
"target_pct": target_pct,
"current_pct": current_pct,
"drift_pct": drift,
"action": action,
"reason": h["force_signal"] or ("DRIFT" if action != "HOLD" else "OK")
})
# 3. 결과 요약
core_pct = sum(h["current_pct"] for h in ticker_rows if h["bucket"] == "Core")
sat_pct = sum(h["current_pct"] for h in ticker_rows if h["bucket"] == "Satellite")
cash_pct = round(cash_krw / total_asset * 100, 2) if total_asset > 0 else 0
summary = {
"regime": regime,
"total_asset": total_asset,
"core_pct": core_pct,
"satellite_pct": sat_pct,
"cash_pct": cash_pct,
"allocation_method": "SS001_SCORE_WEIGHTED"
}
out = {
"formula_id": FORMULA_ID,
"summary": summary,
"tickers": ticker_rows
}
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
Path(args.out).write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"REBALANCE_ENGINE_V2 Complete. Allocation: {summary['allocation_method']}")
return 0
if __name__ == "__main__":
main()