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,227 @@
|
||||
"""PORTFOLIO_ALPHA_CONFIDENCE_PER_TICKER_V1 — 종목별 알파 신뢰도 산출기.
|
||||
|
||||
data_feed의 시그널들을 결합하여 종목별 PAC(Portfolio Alpha Confidence) 점수를 산출.
|
||||
|
||||
점수 구성 (-100 ~ +100):
|
||||
entry_freshness 35점: MA20 이격도 기반 (낮을수록 신선한 진입)
|
||||
breakout_quality 25점: Breakout_Score 기반
|
||||
flow_acceleration 20점: Val_Surge_Pct (거래량 급등 비율)
|
||||
fundamental_signal 10점: fundamental_multifactor_v3 점수 기반
|
||||
rs_slope 10점: RS_Line_20D_Slope 기반
|
||||
|
||||
라벨:
|
||||
BULLISH ≥ +30
|
||||
NEUTRAL ≥ -10
|
||||
BEARISH < -10
|
||||
|
||||
출력: Temp/portfolio_alpha_confidence_per_ticker_v1.json
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_JSON = ROOT / "GatherTradingData.json"
|
||||
DEFAULT_FUND = ROOT / "Temp" / "fundamental_multifactor_v3.json"
|
||||
DEFAULT_OUT = ROOT / "Temp" / "portfolio_alpha_confidence_per_ticker_v1.json"
|
||||
|
||||
|
||||
def _load(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
d = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
return d if isinstance(d, dict) else {}
|
||||
|
||||
|
||||
def _rows(v: Any) -> list[dict[str, Any]]:
|
||||
if isinstance(v, list):
|
||||
return [r for r in v if isinstance(r, dict)]
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return _rows(json.loads(v))
|
||||
except Exception:
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def _f(v: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(v)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _entry_freshness_score(disparity_pct: float, rsi14: float) -> float:
|
||||
"""진입 신선도: 이격도 낮고 RSI 적정 구간이면 좋음."""
|
||||
# disparity가 0~5% 이내면 신선함: +35
|
||||
# 10% 이상이면 오버슈팅: -35
|
||||
d_score = 0.0
|
||||
if disparity_pct <= 5.0:
|
||||
d_score = 35.0
|
||||
elif disparity_pct <= 10.0:
|
||||
d_score = 20.0
|
||||
elif disparity_pct <= 20.0:
|
||||
d_score = 0.0
|
||||
else:
|
||||
d_score = -20.0
|
||||
|
||||
# RSI: 30~60이면 좋음
|
||||
r_score = 0.0
|
||||
if 30 <= rsi14 <= 60:
|
||||
r_score = 15.0
|
||||
elif 20 <= rsi14 < 30 or 60 < rsi14 <= 70:
|
||||
r_score = 5.0
|
||||
elif rsi14 > 70:
|
||||
r_score = -10.0
|
||||
|
||||
return d_score + r_score
|
||||
|
||||
|
||||
def _breakout_quality_score(breakout_score: float) -> float:
|
||||
"""브레이크아웃 품질 (0~100 스케일의 breakout_score → -25~+25)."""
|
||||
if breakout_score >= 60:
|
||||
return 25.0
|
||||
elif breakout_score >= 40:
|
||||
return 10.0
|
||||
elif breakout_score >= 20:
|
||||
return 0.0
|
||||
else:
|
||||
return -15.0
|
||||
|
||||
|
||||
def _flow_accel_score(val_surge_pct: float) -> float:
|
||||
"""거래량 급등 비율 → 모멘텀 점수."""
|
||||
if val_surge_pct >= 3.0:
|
||||
return 20.0
|
||||
elif val_surge_pct >= 1.5:
|
||||
return 10.0
|
||||
elif val_surge_pct >= 0.5:
|
||||
return 5.0
|
||||
else:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _fund_signal_score(grade: str) -> float:
|
||||
"""펀더멘털 등급 → 알파 기여도."""
|
||||
grade_map = {"A": 10.0, "B": 7.0, "C": 3.0, "D": -3.0, "F": -8.0, "ETF": 0.0}
|
||||
return grade_map.get(grade, 0.0)
|
||||
|
||||
|
||||
def _rs_slope_score(rs_slope: float) -> float:
|
||||
"""RS Line 20일 기울기 → 상대강도 기여도."""
|
||||
if rs_slope > 0.5:
|
||||
return 10.0
|
||||
elif rs_slope > 0:
|
||||
return 5.0
|
||||
elif rs_slope > -0.5:
|
||||
return 0.0
|
||||
else:
|
||||
return -8.0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--json", default=str(DEFAULT_JSON))
|
||||
ap.add_argument("--fund", default=str(DEFAULT_FUND))
|
||||
ap.add_argument("--out", default=str(DEFAULT_OUT))
|
||||
args = ap.parse_args()
|
||||
jp = Path(args.json)
|
||||
fp = Path(args.fund)
|
||||
op = Path(args.out)
|
||||
if not jp.is_absolute():
|
||||
jp = ROOT / jp
|
||||
if not fp.is_absolute():
|
||||
fp = ROOT / fp
|
||||
if not op.is_absolute():
|
||||
op = ROOT / op
|
||||
|
||||
payload = _load(jp)
|
||||
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
|
||||
df_list = _rows(data.get("data_feed"))
|
||||
|
||||
# 펀더멘털 등급 조회
|
||||
fund_rows = _rows(_load(fp).get("rows"))
|
||||
fund_map = {str(r.get("ticker") or ""): r for r in fund_rows}
|
||||
|
||||
rows = []
|
||||
vals: list[float] = []
|
||||
for r in df_list:
|
||||
t = str(r.get("Ticker") or r.get("ticker") or "")
|
||||
name = r.get("Name") or r.get("name") or ""
|
||||
|
||||
disparity = _f(r.get("Disparity"))
|
||||
rsi14 = _f(r.get("RSI14"), 50.0)
|
||||
breakout = _f(r.get("Breakout_Score"))
|
||||
val_surge = _f(r.get("Val_Surge_Pct"))
|
||||
rs_slope = _f(r.get("RS_Line_20D_Slope"))
|
||||
|
||||
fund_info = fund_map.get(t, {})
|
||||
grade = str(fund_info.get("grade") or "F")
|
||||
|
||||
s_entry = _entry_freshness_score(abs(disparity), rsi14)
|
||||
s_breakout = _breakout_quality_score(breakout)
|
||||
s_flow = _flow_accel_score(val_surge)
|
||||
s_fund = _fund_signal_score(grade)
|
||||
s_rs = _rs_slope_score(rs_slope)
|
||||
|
||||
pac = round(s_entry + s_breakout + s_flow + s_fund + s_rs, 2)
|
||||
pac = max(-100.0, min(100.0, pac))
|
||||
vals.append(pac)
|
||||
|
||||
label = "BULLISH" if pac >= 30 else ("NEUTRAL" if pac >= -10 else "BEARISH")
|
||||
rows.append({
|
||||
"ticker": t,
|
||||
"name": name,
|
||||
"pac_score": pac,
|
||||
"pac_label": label,
|
||||
"breakdown": {
|
||||
"entry_freshness": round(s_entry, 2),
|
||||
"breakout_quality": round(s_breakout, 2),
|
||||
"flow_accel": round(s_flow, 2),
|
||||
"fundamental": round(s_fund, 2),
|
||||
"rs_slope": round(s_rs, 2),
|
||||
},
|
||||
"fundamental_grade": grade,
|
||||
"formula_id": "PORTFOLIO_ALPHA_CONFIDENCE_PER_TICKER_V1",
|
||||
})
|
||||
|
||||
mean = sum(vals) / len(vals) if vals else 0.0
|
||||
var = sum((v - mean) ** 2 for v in vals) / len(vals) if vals else 0.0
|
||||
std = var ** 0.5
|
||||
label_diversity = len({r["pac_label"] for r in rows})
|
||||
|
||||
label_summary: dict[str, int] = {}
|
||||
for r in rows:
|
||||
lbl = r["pac_label"]
|
||||
label_summary[lbl] = label_summary.get(lbl, 0) + 1
|
||||
|
||||
gate = "PASS" if (std >= 5.0 and label_diversity >= 2) else (
|
||||
"CAUTION" if rows else "FAIL"
|
||||
)
|
||||
|
||||
out = {
|
||||
"formula_id": "PORTFOLIO_ALPHA_CONFIDENCE_PER_TICKER_V1",
|
||||
"gate": gate,
|
||||
"rows": rows,
|
||||
"row_count": len(rows),
|
||||
"stddev": round(std, 2),
|
||||
"label_diversity": label_diversity,
|
||||
"label_summary": label_summary,
|
||||
}
|
||||
op.parent.mkdir(parents=True, exist_ok=True)
|
||||
op.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps({
|
||||
"formula_id": out["formula_id"],
|
||||
"gate": gate,
|
||||
"stddev": out["stddev"],
|
||||
"label_summary": label_summary,
|
||||
}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user