ee3e799de1
주요 변경: - 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>
325 lines
14 KiB
Python
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())
|