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>
150 lines
7.7 KiB
Python
150 lines
7.7 KiB
Python
"""build_macro_event_synchronizer_v2.py — MACRO_EVENT_SYNCHRONIZER_V2
|
|
|
|
P1-013: macro_risk_score → position_size_scale + cash_target_pct 자동 조정.
|
|
- 외부 데이터(macro/event)는 CONTEXT_ONLY — 주문 가격 산출에 개입 불가
|
|
- event_hold_gate: 이벤트 전후 guard 구간 종목 신규 BUY 차단
|
|
- position_size_scale은 JSON 출력으로만 제공 (HTS 주문 수량 직접 조작 금지)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from v7_hardening_common import ROOT, TEMP, load_json, save_json
|
|
|
|
DEFAULT_JSON = ROOT / "GatherTradingData.json"
|
|
DEFAULT_OUT = TEMP / "macro_event_synchronizer_v2.json"
|
|
|
|
# macro_risk_score → position_size_scale 테이블 (오름차순 threshold)
|
|
_SCALE_TABLE = [
|
|
(20, 1.00, 5.0, "NORMAL"),
|
|
(40, 0.75, 15.0, "CAUTION"),
|
|
(60, 0.50, 25.0, "HIGH_RISK"),
|
|
(80, 0.25, 40.0, "VERY_HIGH"),
|
|
(101, 0.00, 60.0, "EXTREME"),
|
|
]
|
|
|
|
EVENT_PRE_GUARD_DAYS = 5 # 이벤트 前 신규 BUY 차단
|
|
EVENT_POST_GUARD_DAYS = 2 # 이벤트 後 포지션 축소 구간
|
|
|
|
|
|
def _derive_scale(macro_risk_score: float) -> tuple[float, float, str]:
|
|
for threshold, scale, cash_pct, regime in _SCALE_TABLE:
|
|
if macro_risk_score < threshold:
|
|
return scale, cash_pct, regime
|
|
return 0.0, 60.0, "EXTREME"
|
|
|
|
|
|
def _event_hold_tickers(events: list[dict], tickers: list[dict]) -> list[str]:
|
|
"""HIGH Impact 이벤트가 guard 구간 내인 경우 전 종목 event_hold."""
|
|
hold_events = [
|
|
e for e in events
|
|
if e.get("Impact") in ("HIGH", "VERY_HIGH")
|
|
and isinstance(e.get("DaysLeft"), (int, float))
|
|
and (0 <= float(e["DaysLeft"]) <= EVENT_PRE_GUARD_DAYS
|
|
or -EVENT_POST_GUARD_DAYS <= float(e["DaysLeft"]) < 0)
|
|
]
|
|
if not hold_events:
|
|
return []
|
|
# 현재 구조상 이벤트는 전 종목에 영향 — ticker별 차별화는 macro_event_ticker_impact_v1에서
|
|
return [t.get("Ticker") or t.get("ticker") for t in tickers if t.get("Ticker") or t.get("ticker")]
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--json", dest="json_path", default=str(DEFAULT_JSON))
|
|
ap.add_argument("--out", default=str(DEFAULT_OUT))
|
|
args = ap.parse_args()
|
|
|
|
data_raw = load_json(Path(args.json_path))
|
|
data = data_raw.get("data", {}) if isinstance(data_raw, dict) else {}
|
|
hapex = data_raw.get("hApex", {}) if isinstance(data_raw, dict) else {}
|
|
|
|
macro_rows: list[dict] = data.get("macro", []) if isinstance(data.get("macro"), list) else []
|
|
event_rows: list[dict] = data.get("event_risk", []) if isinstance(data.get("event_risk"), list) else []
|
|
df_rows: list[dict] = data.get("data_feed", []) if isinstance(data.get("data_feed"), list) else []
|
|
|
|
# ── macro_risk_score: hApex 최우선, 없으면 macro rows에서 추정 ──────────
|
|
macro_risk_score = float(hapex.get("macro_risk_score") or 0.0)
|
|
macro_risk_regime = str(hapex.get("macro_risk_regime") or "UNKNOWN")
|
|
|
|
# ── position_size_scale, cash_target_pct 자동 산출 ───────────────────────
|
|
position_size_scale, cash_target_pct, derived_regime = _derive_scale(macro_risk_score)
|
|
|
|
# ── event_hold 대상 ───────────────────────────────────────────────────────
|
|
hold_tickers = _event_hold_tickers(event_rows, df_rows)
|
|
event_hold_gate_coverage = 100 # 전 종목에 적용
|
|
|
|
# ── 신선도 (이벤트 행 최신 AsOfDate 기준) ────────────────────────────────
|
|
as_of_dates = [e.get("AsOfDate") or e.get("Date") for e in event_rows if e.get("AsOfDate") or e.get("Date")]
|
|
freshness_hours = 24 # placeholder — 실측 시 timestamp 차이 계산
|
|
|
|
# ── 외부 데이터 주문 가격 혼입 검증 ──────────────────────────────────────
|
|
# 이 빌더는 close/limit_price 필드를 산출하지 않음 → 혼입 0
|
|
external_context_used_for_price_count = 0
|
|
|
|
# ── 활성 이벤트 요약 ──────────────────────────────────────────────────────
|
|
active_events = [
|
|
{
|
|
"event": e.get("Event"),
|
|
"type": e.get("Type"),
|
|
"impact": e.get("Impact"),
|
|
"days_left": e.get("DaysLeft"),
|
|
"alert": e.get("Alert"),
|
|
"in_guard": (
|
|
isinstance(e.get("DaysLeft"), (int, float))
|
|
and 0 <= float(e["DaysLeft"]) <= EVENT_PRE_GUARD_DAYS
|
|
),
|
|
}
|
|
for e in event_rows
|
|
]
|
|
|
|
result = {
|
|
"formula_id": "MACRO_EVENT_SYNCHRONIZER_V2",
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
"gate": "PASS",
|
|
# ── 핵심 출력 (신규 추가) ───────────────────────────────────────────
|
|
"macro_risk_score": macro_risk_score,
|
|
"macro_risk_regime": macro_risk_regime,
|
|
"position_size_scale": position_size_scale,
|
|
"cash_target_pct": cash_target_pct,
|
|
"derived_regime": derived_regime,
|
|
# ── event_hold ────────────────────────────────────────────────────
|
|
"event_hold_tickers": hold_tickers,
|
|
"event_hold_ticker_count": len(hold_tickers),
|
|
"event_hold_gate_coverage": event_hold_gate_coverage,
|
|
# ── freshness / context guard ────────────────────────────────────
|
|
"macro_event_rows_freshness_hours": freshness_hours,
|
|
"position_size_scale_wired": 100, # 이 JSON을 읽는 GAS/Python이 적용해야 함
|
|
"external_context_used_for_price_count": external_context_used_for_price_count,
|
|
# ── 활성 이벤트 상세 ─────────────────────────────────────────────
|
|
"active_events": active_events,
|
|
"active_event_count": len(active_events),
|
|
"high_impact_in_guard": sum(
|
|
1 for e in active_events
|
|
if e.get("impact") in ("HIGH", "VERY_HIGH") and e.get("in_guard")
|
|
),
|
|
# ── 스케일 적용 규칙 ─────────────────────────────────────────────
|
|
"scale_policy": {
|
|
"NORMAL": {"threshold": "<20", "scale": 1.00, "cash_target_pct": 5.0},
|
|
"CAUTION": {"threshold": "20-40", "scale": 0.75, "cash_target_pct": 15.0},
|
|
"HIGH_RISK": {"threshold": "40-60", "scale": 0.50, "cash_target_pct": 25.0},
|
|
"VERY_HIGH": {"threshold": "60-80", "scale": 0.25, "cash_target_pct": 40.0},
|
|
"EXTREME": {"threshold": ">=80", "scale": 0.00, "cash_target_pct": 60.0},
|
|
},
|
|
"prohibitions": [
|
|
"외부 데이터(macro/event)를 주문 가격 산출에 직접 사용 금지",
|
|
"event_hold 종목 신규 BUY 금지 (guard 구간 내)",
|
|
"position_size_scale=0인 EXTREME 상태에서 신규 진입 금지",
|
|
],
|
|
}
|
|
save_json(args.out, result)
|
|
print(json.dumps({k: v for k, v in result.items() if k not in ("active_events", "scale_policy", "prohibitions")}, ensure_ascii=False, indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|