Files
QuantEngineByItz/tools/build_capital_style_allocation_v1.py
kjh2064 ee3e799de1 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>
2026-06-13 13:20:14 +09:00

325 lines
14 KiB
Python

#!/usr/bin/env python3
"""
build_capital_style_allocation_v1.py
───────────────────────────────────────────────────────────────────────────────
CAPITAL_STYLE_ALLOCATION_V1 — 투자성향별 자금 유동성 가중 엔진
4개 투자성향(SCALP/SWING/MOMENTUM/POSITION)에 대해 기존 신호 빌딩블록을
성향별 다른 가중치로 융합하여 결정론적 conviction_score(0~100)와
recommended_position_pct를 산출한다.
신호 출처 (LLM 계산 0%):
- technical_score : data_feed RSI14/Disparity/Ret5D 기반 규칙 계산
- smart_money_score : Temp/smart_money_flow_signal_v2.json (이미 0~100)
- fundamental_score : Temp/fundamental_multifactor_v3.json (이미 0~100)
- macro_event_score : Temp/macro_event_ticker_impact_v1.json ([-100,+100]→[0,100])
- liquidity_modifier : Temp/liquidity_flow_signal_v1.json (DEEP/MODERATE/THIN/FROZEN→배수)
가중치 출처: EXPERT_PRIOR (spec/calibration_registry.yaml 등록)
SCALP: tech=0.50, smart=0.30, fund=0.05, macro=0.15
SWING: tech=0.30, smart=0.35, fund=0.15, macro=0.20
MOMENTUM: tech=0.15, smart=0.25, fund=0.40, macro=0.20
POSITION: tech=0.10, smart=0.20, fund=0.55, macro=0.15
출력: Temp/capital_style_allocation_v1.json
사용법: python tools/build_capital_style_allocation_v1.py
"""
from __future__ import annotations
import argparse
import json
import math
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
sys.stdout = open(sys.stdout.fileno(), mode="w", encoding="utf-8", buffering=1)
# ─── 가중치 정의 (EXPERT_PRIOR — calibration_registry.yaml 등록) ───────────
W_STYLE: dict[str, dict[str, float]] = {
"SCALP": {"technical": 0.50, "smartmoney": 0.30, "fundamental": 0.05, "macro_event": 0.15},
"SWING": {"technical": 0.30, "smartmoney": 0.35, "fundamental": 0.15, "macro_event": 0.20},
"MOMENTUM": {"technical": 0.15, "smartmoney": 0.25, "fundamental": 0.40, "macro_event": 0.20},
"POSITION": {"technical": 0.10, "smartmoney": 0.20, "fundamental": 0.55, "macro_event": 0.15},
}
# ─── 포지션 사이즈 맵핑 (EXPERT_PRIOR) ────────────────────────────────────
def conviction_to_pct(score: float) -> float:
if score >= 80: return 7.0
if score >= 65: return 5.0
if score >= 50: return 3.0
if score >= 35: return 1.5
return 0.0
# ─── 유동성 배수 ──────────────────────────────────────────────────────────
LIQUIDITY_MODIFIER: dict[str, float] = {
"DEEP": 1.00,
"MODERATE": 0.90,
"THIN": 0.75,
"FROZEN": 0.00,
}
def _load(path: Path) -> dict | list:
if not path.exists():
return {}
try:
d = json.loads(path.read_text(encoding="utf-8"))
return d
except Exception:
return {}
def _rows(v: object) -> list[dict]:
if isinstance(v, list):
return [x for x in v if isinstance(x, dict)]
if isinstance(v, dict):
for key in ("rows", "data", "tickers"):
c = v.get(key)
if isinstance(c, list):
return [x for x in c if isinstance(x, dict)]
return []
def _f(v: object, default: float = 0.0) -> float:
try:
return float(v) # type: ignore[arg-type]
except Exception:
return default
def _clamp(v: float, lo: float = 0.0, hi: float = 100.0) -> float:
return max(lo, min(hi, v))
# ─── 기술적 점수 산출 (결정론적 규칙) ────────────────────────────────────
def compute_technical_score(rsi14: float, disparity: float, ret5d: float,
volume: float, avg_vol5d: float) -> float:
"""
기본 50점 기준, 아래 조건을 가산/감산.
RSI14 < 35 → +20 (과매도 반등 기회)
RSI14 > 70 → -25 (과매수 추격 위험)
Disparity < 3% → +15 (MA20 근접 — 눌림목)
Disparity > 10% → -20 (MA20 과이격)
Ret5D < -5% → +10 (단기 급락 반등 후보)
거래량 확인(volume >= avgVol5d*1.2 AND Ret5D > 0) → +10 (수급 확인 돌파)
"""
score = 50.0
if rsi14 < 35:
score += 20.0
elif rsi14 > 70:
score -= 25.0
if disparity < 3.0:
score += 15.0
elif disparity > 10.0:
score -= 20.0
if ret5d < -5.0:
score += 10.0
if avg_vol5d > 0 and volume >= avg_vol5d * 1.2 and ret5d > 0:
score += 10.0
return _clamp(score)
# ─── 메인 ─────────────────────────────────────────────────────────────────
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--json", default=str(ROOT / "GatherTradingData.json"))
ap.add_argument("--smart", default=str(ROOT / "Temp/smart_money_flow_signal_v2.json"))
ap.add_argument("--fund", default=str(ROOT / "Temp/fundamental_multifactor_v3.json"))
ap.add_argument("--macro", default=str(ROOT / "Temp/macro_event_ticker_impact_v1.json"))
ap.add_argument("--liquidity",default=str(ROOT / "Temp/liquidity_flow_signal_v1.json"))
ap.add_argument("--out", default=str(ROOT / "Temp/capital_style_allocation_v1.json"))
args = ap.parse_args()
def rp(s: str) -> Path:
p = Path(s)
return p if p.is_absolute() else ROOT / p
# ── 입력 로드 ────────────────────────────────────────────────────────
payload = _load(rp(args.json))
smart_raw = _load(rp(args.smart))
fund_raw = _load(rp(args.fund))
macro_raw = _load(rp(args.macro))
liq_raw = _load(rp(args.liquidity))
data = payload.get("data", {}) if isinstance(payload, dict) else {}
df_list = _rows(data.get("data_feed")) if isinstance(data, dict) else []
# ── 신호 인덱스 구성 ──────────────────────────────────────────────────
smart_map: dict[str, float] = {}
for r in _rows(smart_raw):
t = str(r.get("ticker") or "")
smart_map[t] = _f(r.get("smart_money_score"), 50.0)
fund_map: dict[str, float] = {}
for r in _rows(fund_raw):
t = str(r.get("ticker") or "")
fund_map[t] = _f(r.get("score"), 50.0)
macro_map: dict[str, dict] = {}
macro_tickers = _rows(macro_raw) if isinstance(macro_raw, list) else _rows(macro_raw.get("tickers") if isinstance(macro_raw, dict) else [])
for r in macro_tickers:
t = str(r.get("ticker") or "")
macro_map[t] = r
liq_map: dict[str, str] = {}
exec_map: dict[str, str] = {}
for r in _rows(liq_raw):
t = str(r.get("ticker") or "")
liq_map[t] = str(r.get("liquidity_label") or "MODERATE")
exec_map[t] = str(r.get("execution_mode") or "")
# ── 종목별 신호 산출 + 성향별 conviction ───────────────────────────
rows_out: list[dict] = []
errors: list[str] = []
for r in df_list:
ticker = str(r.get("Ticker") or r.get("ticker") or "")
name = str(r.get("Name") or r.get("name") or "")
if not ticker:
continue
# 기술지표
rsi14 = _f(r.get("RSI14"), 50.0)
disparity = _f(r.get("Disparity"), 0.0)
ret5d = _f(r.get("Ret5D"), 0.0)
volume = _f(r.get("Volume"), 0.0)
avg5d = _f(r.get("AvgVolume_5D"),0.0)
# 4개 신호 정규화 [0, 100]
tech_score = compute_technical_score(rsi14, disparity, ret5d, volume, avg5d)
smart_score = _clamp(smart_map.get(ticker, 50.0))
fund_score = _clamp(fund_map.get(ticker, 50.0))
macro_info = macro_map.get(ticker, {})
macro_impact = _f(macro_info.get("primary_impact_score"), 0.0)
macro_gate = str(macro_info.get("primary_gate") or "NEUTRAL")
if macro_gate == "AVOID_NEW_BUY":
macro_score = 0.0
else:
macro_score = _clamp((macro_impact + 100.0) / 2.0) # [-100,+100]→[0,100]
# 유동성 배수
liq_label = liq_map.get(ticker, "MODERATE")
exec_mode = exec_map.get(ticker, "")
if exec_mode == "FROZEN" or liq_label == "FROZEN":
liq_modifier = 0.0
else:
liq_modifier = LIQUIDITY_MODIFIER.get(liq_label, 0.90)
signal_breakdown = {
"technical_score": round(tech_score, 2),
"smart_money_score": round(smart_score, 2),
"fundamental_score": round(fund_score, 2),
"macro_event_score": round(macro_score, 2),
"macro_gate": macro_gate,
"liquidity_label": liq_label,
"liquidity_modifier": liq_modifier,
}
# 4개 성향별 conviction 산출
style_rows: list[dict] = []
for style, weights in W_STYLE.items():
raw = (weights["technical"] * tech_score
+ weights["smartmoney"] * smart_score
+ weights["fundamental"] * fund_score
+ weights["macro_event"] * macro_score)
conviction = _clamp(round(raw * liq_modifier, 2))
rec_pct = conviction_to_pct(conviction)
# 범위 검증
if not (0.0 <= conviction <= 100.0):
errors.append(f"{ticker}.{style}: conviction={conviction} out of [0,100]")
if not (0.0 <= rec_pct <= 7.0):
errors.append(f"{ticker}.{style}: recommended_pct={rec_pct} out of [0,7]")
style_rows.append({
"style": style,
"conviction_score": conviction,
"recommended_pct": rec_pct,
"raw_weighted_score": round(raw, 2),
})
rows_out.append({
"ticker": ticker,
"name": name,
"signal_breakdown": signal_breakdown,
"styles": style_rows,
"formula_id": "CAPITAL_STYLE_ALLOCATION_V1",
})
gate = "PASS" if not errors and rows_out else ("FAIL" if errors else "NO_DATA")
best_summary: dict[str, object] = {}
if rows_out:
ranked: list[tuple[float, float, dict[str, object], dict[str, object]]] = []
for row in rows_out:
styles = [s for s in (row.get("styles") or []) if isinstance(s, dict)]
if not styles:
continue
best_style = max(styles, key=lambda s: _f(s.get("conviction_score"), 0.0))
best_conviction = _f(best_style.get("conviction_score"), 0.0)
best_pct = _f(best_style.get("recommended_pct"), 0.0)
ranked.append((best_conviction, best_pct, row, best_style))
if ranked:
ranked.sort(key=lambda item: (item[0], item[1], str(item[2].get("ticker") or "")), reverse=True)
best_conviction, best_pct, best_row, best_style = ranked[0]
best_summary = {
"capital_style_conviction": round(best_conviction, 2),
"capital_style_label": best_style.get("style") or "UNKNOWN",
"capital_style_ticker": best_row.get("ticker") or "",
"capital_style_name": best_row.get("name") or "",
"capital_style_recommended_pct": round(best_pct, 2),
}
result = {
"formula_id": "CAPITAL_STYLE_ALLOCATION_V1",
"gate": gate,
"ticker_count": len(rows_out),
"style_list": list(W_STYLE.keys()),
"weights": W_STYLE,
"rows": rows_out,
"errors": errors,
**best_summary,
"meta": {
"weight_source": "EXPERT_PRIOR",
"sample_n": 0,
"llm_computed": False,
"deterministic": True,
"unvalidated_weight_label": "UNVALIDATED_WEIGHT",
"calibration_note": "spec/calibration_registry.yaml 등록. "
"실측 30건 누적 후 PROVISIONAL→CALIBRATED 승격 필요.",
},
}
out_path = rp(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
sep = "=" * 70
print(sep)
print(" CAPITAL_STYLE_ALLOCATION_V1")
print(sep)
print(f" gate={gate} tickers={len(rows_out)} errors={len(errors)}")
for r in rows_out[:3]:
best = max(r["styles"], key=lambda s: s["conviction_score"])
print(f" {r['ticker']:<10} {r['name'][:12]:<12} "
f"best_style={best['style']:<10} conviction={best['conviction_score']:.1f} "
f"rec_pct={best['recommended_pct']:.1f}%")
if len(rows_out) > 3:
print(f" ... 외 {len(rows_out)-3}개")
if errors:
print(f"\n [!] 오류 {len(errors)}건:")
for e in errors[:5]:
print(f" {e}")
print(f"\n → 저장: {out_path}")
print(f" {'CAPITAL_ALLOC_BUILD_OK' if gate=='PASS' else 'CAPITAL_ALLOC_BUILD_FAIL'}\n")
return 0 if gate == "PASS" else 1
if __name__ == "__main__":
raise SystemExit(main())