[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:
@@ -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()
|
||||
Reference in New Issue
Block a user