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,324 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user