"""build_rebalance_engine_v1.py — REBALANCE_ENGINE_V1 리밸런싱 엔진: bucket drift 측정 → 레짐 적응 밴드 → 비용효익 게이트 → 3단계 분할 실행 계획. ABS_FLOOR / TIME_STOP 강제 매도 신호 통합. P3: 레짐 적응 밴드 (Risk-On ±15%p, Neutral ±5%p, Risk-Off +2%p/−10%p) P4: 비용효익 게이트 (|drift| − TX_COST_ROUNDTRIP > 0.5%p) P5: 3단계 분할 실행 (Stage1 30%, Stage2 30%, Stage3 40%) P6: ABS_FLOOR / TIME_STOP 강제 매도 통합 입력: GatherTradingData.json + Temp/computed_harness_v1.json (optional) 출력: Temp/rebalance_engine_v1.json """ from __future__ import annotations import argparse import json import math from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] TEMP = ROOT / "Temp" DEFAULT_JSON = ROOT / "GatherTradingData.json" DEFAULT_OUT = TEMP / "rebalance_engine_v1.json" FORMULA_ID = "REBALANCE_ENGINE_V1" # ── 버킷 목표 (gdf_01_price_metrics.gs THRESHOLDS 와 동기화) ────────────────── BUCKET_CONFIG: dict[str, dict] = { "Core": {"target": 66.0, "min": 60.0, "max": 72.0}, "Satellite": {"target": 17.5, "min": 10.0, "max": 25.0}, "Cash": {"target": 16.5, "min": 10.0, "max": 22.0}, } # 직접 코어 주도주 (gdc_02_account_satellite.gs isCoreLeader 기준) CORE_TICKERS_BASE: set[str] = {"005930", "000660", "000270"} # ── 레짐 적응 밴드 (P3) ────────────────────────────────────────────────────── # expand = 목표 대비 상단 확장%p, contract = 목표 대비 하단 축소%p (절대값) REGIME_BANDS: dict[str, dict] = { "RISK_ON": {"label": "RISK_ON ±15%p", "expand": 15.0, "contract": 15.0}, "SECULAR_LEADER_RISK_ON": {"label": "RISK_ON ±15%p", "expand": 15.0, "contract": 15.0}, "NEUTRAL": {"label": "NEUTRAL ±5%p", "expand": 5.0, "contract": 5.0}, "RISK_OFF_CANDIDATE": {"label": "RISK_OFF_CANDIDATE +2/−10%p", "expand": 2.0, "contract": 10.0}, "RISK_OFF": {"label": "RISK_OFF +2/−10%p", "expand": 2.0, "contract": 10.0}, "EVENT_SHOCK": {"label": "RISK_OFF +2/−10%p", "expand": 2.0, "contract": 10.0}, "_DEFAULT": {"label": "NEUTRAL ±5%p", "expand": 5.0, "contract": 5.0}, } # ── 비용효익 게이트 (P4) ───────────────────────────────────────────────────── TX_COST_ONE_WAY = 0.0035 # 0.35% (수수료 + 세금) TX_COST_ROUNDTRIP = TX_COST_ONE_WAY * 2 # 0.70% COST_BENEFIT_THRESHOLD = 0.005 # 0.50%p — 이보다 클 때만 주문 생성 MIN_ACTIONABLE_DRIFT_PCT = (TX_COST_ROUNDTRIP + COST_BENEFIT_THRESHOLD) * 100 # 1.20%p # ── 3단계 분할 실행 비율 (P5) ──────────────────────────────────────────────── STAGE_RATIOS = [0.30, 0.30, 0.40] # Stage1, Stage2, Stage3 # 매도 지정가 할인율 (직전 종가의 0.2% 아래로 지정) LIMIT_PRICE_DISCOUNT = 0.002 # ── 유틸 ───────────────────────────────────────────────────────────────────── def _load(path: Path) -> Any: if not path.exists(): return {} try: return json.loads(path.read_text(encoding="utf-8")) except Exception: return {} def _f(v: Any, default: float = 0.0) -> float: try: return float(v) except (TypeError, ValueError): return default def _i(v: Any, default: int = 0) -> int: try: return int(float(v)) except (TypeError, ValueError): return default def _s(v: Any, default: str = "") -> str: return str(v) if v is not None else default def _round2(v: float) -> float: return round(v, 2) def _band_for_regime(regime: str) -> dict: return REGIME_BANDS.get(regime.upper(), REGIME_BANDS["_DEFAULT"]) def _detect_force_signal(row: dict) -> str: """Sell_Reason 또는 Final_Action에서 ABS_FLOOR / TIME_STOP 강제 신호 추출.""" combined = " ".join([ _s(row.get("Sell_Reason")), _s(row.get("Final_Action")), _s(row.get("Sell_Action")), ]).upper() if "ABS_FLOOR" in combined: return "ABS_FLOOR" if "TIME_STOP" in combined or "TIME_EXIT" in combined or "TIME_TRIM" in combined: return "TIME_STOP" return "" def _stage_qty_split(total_qty: int) -> list[int]: """3단계 수량 분할: [30%, 30%, 40%]. 각 stage는 최소 1주.""" if total_qty <= 0: return [0, 0, 0] if total_qty < 3: return [total_qty, 0, 0] s1 = max(1, math.floor(total_qty * STAGE_RATIOS[0])) s2 = max(1, math.floor(total_qty * STAGE_RATIOS[1])) s3 = total_qty - s1 - s2 if s3 < 0: s2 = total_qty - s1 s3 = 0 return [s1, s2, max(0, s3)] def _extract_trim_guidance(hc: dict) -> dict: tg = hc.get("regime_trim_guidance_json") or {} if isinstance(tg, str): try: tg = json.loads(tg) except Exception: tg = {} return tg if isinstance(tg, dict) else {} def _extract_df_rows(payload: Any) -> list[dict]: """GatherTradingData.json 에서 data_feed 행 배열 추출.""" if not isinstance(payload, dict): return [] data = payload.get("data") or {} df = data.get("data_feed") or [] if not isinstance(df, list): return [] return [r for r in df if isinstance(r, dict)] def _build_snap_position_map(payload: Any, total_krw: float) -> dict[str, dict]: """ account_snapshot 행 배열을 티커별로 합산한 맵 반환. 동일 티커 복수 행(소수 분리 계좌 포함) → qty·MV·원가 합산. GAS readAccountSnapshotMap_ 의 _mergePositionRecord_ 와 동일 로직. 반환: { ticker: { qty, market_value, weight_pct, position_type, name } } """ data = payload.get("data") or {} if isinstance(payload, dict) else {} snap = data.get("account_snapshot") or [] if not isinstance(snap, list): return {} merged: dict[str, dict] = {} for row in snap: if not isinstance(row, dict): continue if _s(row.get("parse_status")).strip().upper() != "CAPTURE_READ_OK": continue # 연금저축 계좌는 주문 불가 — 건너뜀 if "연금저축" in _s(row.get("account_type")): continue ticker = _s(row.get("ticker")).strip() if not ticker: continue # US 주식(알파벳만): Naver 가격 없음 → weight 0 → 건너뜀 if ticker.replace(".", "").isalpha(): continue qty = _f(row.get("holding_quantity")) if qty <= 0: continue mv = _f(row.get("market_value")) cost = _f(row.get("total_cost")) pl = _f(row.get("profit_loss")) name = _s(row.get("name")) pt = _s(row.get("position_type")).lower() if ticker not in merged: merged[ticker] = { "qty": qty, "market_value": mv, "total_cost": cost, "profit_loss": pl, "name": name, "position_type": pt, } else: ex = merged[ticker] ex["qty"] += qty ex["market_value"] += mv ex["total_cost"] += cost ex["profit_loss"] += pl # "(소수)" 없는 이름 우선 if "소수" in ex["name"] and "소수" not in name: ex["name"] = name if not ex["position_type"] and pt: ex["position_type"] = pt # weight_pct 계산 for v in merged.values(): v["weight_pct"] = _round2(v["market_value"] / total_krw * 100) if total_krw > 0 else 0.0 return merged def _extract_portfolio_totals(payload: Any) -> tuple[float, float]: """ (total_portfolio_krw, cash_krw) 추출 우선순위: 1. settings.total_asset_krw / settings.settlement_cash_d2_krw (가장 정확) 2. account_snapshot cash row 의 settlement_cash_d2 3. holdings 합산 (fallback) """ data = payload.get("data") or {} if isinstance(payload, dict) else {} settings = data.get("settings") or {} # settings 에서 읽기 (ChatGPT → settings 탭에 기록된 총자산) total_krw = _f(settings.get("total_asset_krw")) cash_krw = _f(settings.get("settlement_cash_d2_krw")) if total_krw <= 0: # account_snapshot cash row fallback snap = data.get("account_snapshot") or [] if isinstance(snap, list): for row in snap: if isinstance(row, dict) and _s(row.get("position_type")).lower() == "cash": cash_krw = _f(row.get("settlement_cash_d2") or row.get("immediate_cash")) break # holdings 합산 (최후 fallback) df_rows = _extract_df_rows(payload) holdings_total = sum(_f(r.get("Account_Market_Value")) for r in df_rows) total_krw = holdings_total + cash_krw return total_krw, cash_krw _REGIME_NORMALIZE: dict[str, str] = { "BREAKDOWN": "RISK_OFF", "EVENT_SHOCK": "RISK_OFF", "RISK_OFF_CANDIDATE": "RISK_OFF_CANDIDATE", "RISK_OFF": "RISK_OFF", "NEUTRAL": "NEUTRAL", "RISK_ON": "RISK_ON", "SECULAR_LEADER_RISK_ON": "RISK_ON", } def _normalize_regime(raw: str) -> str: return _REGIME_NORMALIZE.get(raw.upper(), raw.upper()) def _extract_regime(harness: Any, payload: Any) -> str: """ 레짐 추출 우선순위: 1. payload.data.macro[Symbol=REGIME_PRELIM].Close — GAS가 실제 사용하는 소스(최신) 2. payload.data.settings.prev_market_regime — ChatGPT 설정값 3. payload.data._harness_context.market_regime_state — harness (최대 수일 stale 가능) 4. payload.data._harness_context.regime_transition_json.current_regime 5. computed_harness_v1.json 의 market_regime 키 6. 기본값 "NEUTRAL" BREAKDOWN → RISK_OFF 정규화. GAS gdc_01_fetch_fundamentals.gs:2237 과 동일한 소스 사용. """ data = payload.get("data") or {} if isinstance(payload, dict) else {} # 1. macro 시트 REGIME_PRELIM 행 (GAS 와 동일한 소스 — 항상 최신) macro_rows = data.get("macro") or [] if isinstance(macro_rows, list): for r in macro_rows: if isinstance(r, dict) and _s(r.get("Symbol")).strip() == "REGIME_PRELIM": v = _s(r.get("Close")).strip() if v: return _normalize_regime(v) # 2. settings.prev_market_regime settings = data.get("settings") or {} v = settings.get("prev_market_regime") or settings.get("REGIME_PRELIM") if v and isinstance(v, str) and v.strip(): return _normalize_regime(v.strip()) # 3-4. _harness_context (수일 stale 가능 — fallback 전용) hc = data.get("_harness_context") or {} if isinstance(hc, dict): v = hc.get("market_regime_state") if v and isinstance(v, str): return _normalize_regime(v) rt = hc.get("regime_transition_json") or {} if isinstance(rt, str): try: rt = json.loads(rt) except Exception: rt = {} if isinstance(rt, dict): v = rt.get("current_regime") if v and isinstance(v, str): return _normalize_regime(v) # 5. computed_harness_v1.json if isinstance(harness, dict): for key in ("market_regime", "Market_Regime", "regime"): v = harness.get(key) if v and isinstance(v, str): return _normalize_regime(v) return "NEUTRAL" def _assign_bucket(ticker: str, row: dict, snap_position_type: str = "") -> str: """ 우선순위: snap_position_type (account_snapshot) > row.position_type > CORE_TICKERS_BASE. ETF/펀드 코드(알파벳 포함)는 Satellite. """ for pt_raw in (snap_position_type, _s(row.get("position_type") or row.get("Position_Type"))): pt = pt_raw.lower().strip() if pt == "core": return "Core" if pt == "satellite": return "Satellite" return "Core" if ticker in CORE_TICKERS_BASE else "Satellite" def _compute_limit_price(close: float, action: str) -> float: """매도 지정가: 종가의 (1 - LIMIT_PRICE_DISCOUNT). 매수는 종가 그대로.""" if close <= 0: return 0.0 if action == "SELL": return round(close * (1 - LIMIT_PRICE_DISCOUNT)) return round(close) # ── 메인 엔진 ───────────────────────────────────────────────────────────────── def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--json", default=str(DEFAULT_JSON)) ap.add_argument("--harness", default=str(TEMP / "computed_harness_v1.json")) ap.add_argument("--out", default=str(DEFAULT_OUT)) args = ap.parse_args() json_path = Path(args.json) harness_path = Path(args.harness) out_path = Path(args.out) for p in (json_path, harness_path, out_path): if not p.is_absolute(): p = ROOT / p payload = _load(json_path) harness = _load(harness_path) df_rows = _extract_df_rows(payload) regime = _extract_regime(harness, payload) band = _band_for_regime(regime) total_portfolio_krw, cash_krw = _extract_portfolio_totals(payload) # harness_context 에서 현금 바닥 목표 읽기 (BUCKET_CONFIG.Cash.min 보다 우선) hc_data = (payload.get("data") or {}).get("_harness_context") or {} cash_floor_pct = float(hc_data.get("cash_floor_min_pct") or BUCKET_CONFIG["Cash"]["min"]) cash_shortfall_krw = float(hc_data.get("cash_shortfall_min_krw") or 0) # Cash 버킷 최소값을 harness 현금바닥으로 override BUCKET_CONFIG["Cash"]["min"] = max(BUCKET_CONFIG["Cash"]["min"], cash_floor_pct) # ── 1. account_snapshot 합산 맵 (Weight_Pct 재계산 기준) ───────────────── # GAS readAccountSnapshotMap_ 에 소수 분리 행 덮어쓰기 버그가 있는 경우를 대비, # Python 엔진은 account_snapshot 을 직접 합산하여 올바른 weight_pct 를 사용한다. snap_map = _build_snap_position_map(payload, total_portfolio_krw) # ── 2. 보유 종목 필터링 (snap_map 우선, data_feed 보조) ─────────────────── holdings = [] seen_tickers: set[str] = set() # snap_map 에 있는 종목이 authoritative (GAS 소수 분리 버그 무관) df_row_map = {_s(r.get("Ticker")).strip(): r for r in df_rows if _s(r.get("Ticker")).strip()} for ticker, sp in snap_map.items(): if sp["weight_pct"] <= 0: continue seen_tickers.add(ticker) df_row = df_row_map.get(ticker, {}) # SS001_Norm_Score: 0~100 신호 강도. 0은 데이터 없음 → 최소 가중치 1.0으로 폴백 ss001_raw = _f(df_row.get("SS001_Norm_Score"), default=-1.0) ss001_norm = max(1.0, ss001_raw) if ss001_raw > 0 else 1.0 holdings.append({ "ticker": ticker, "name": sp["name"] or _s(df_row.get("Name")), "bucket": _assign_bucket(ticker, df_row, sp.get("position_type", "")), "weight_pct": sp["weight_pct"], "acct_mv_krw": sp["market_value"], "holding_qty": int(round(sp["qty"])), # 주문 수량은 정수 "close": _f(df_row.get("Close")), "final_action": _s(df_row.get("Final_Action")), "sell_reason": _s(df_row.get("Sell_Reason")), "force_signal": _detect_force_signal(df_row), "ss001_norm": ss001_norm, }) # data_feed 에만 있는 보유 종목 보완 (snap 에 없는 경우 — 비정상이지만 방어) for row in df_rows: ticker = _s(row.get("Ticker")).strip() if not ticker or ticker in seen_tickers: continue weight_pct = _f(row.get("Weight_Pct")) acct_mv = _f(row.get("Account_Market_Value")) if weight_pct <= 0 and acct_mv <= 0: continue ss001_raw = _f(row.get("SS001_Norm_Score"), default=-1.0) ss001_norm = max(1.0, ss001_raw) if ss001_raw > 0 else 1.0 holdings.append({ "ticker": ticker, "name": _s(row.get("Name")), "bucket": _assign_bucket(ticker, row), "weight_pct": weight_pct, "acct_mv_krw": acct_mv, "holding_qty": _i(row.get("Account_Holding_Qty")), "close": _f(row.get("Close")), "final_action": _s(row.get("Final_Action")), "sell_reason": _s(row.get("Sell_Reason")), "force_signal": _detect_force_signal(row), "ss001_norm": ss001_norm, }) # ── 3. 버킷별 현재 비중 집계 ───────────────────────────────────────────── # weight_pct 는 account_snapshot MV / total_asset_krw 기반 (재계산 완료). # Cash: settings.settlement_cash_d2_krw / total_asset_krw 로 직접 계산 (정확). core_pct = sum(h["weight_pct"] for h in holdings if h["bucket"] == "Core") sat_pct = sum(h["weight_pct"] for h in holdings if h["bucket"] == "Satellite") if total_portfolio_krw > 0 and cash_krw > 0: cash_pct = _round2(cash_krw / total_portfolio_krw * 100) else: cash_pct = max(0.0, 100.0 - core_pct - sat_pct) bucket_current = {"Core": core_pct, "Satellite": sat_pct, "Cash": cash_pct} # ── 3. 버킷 drift + 밴드 계산 ──────────────────────────────────────────── bucket_rows: list[dict] = [] rebalance_needed = False for bname, bcfg in BUCKET_CONFIG.items(): target = bcfg["target"] current = _round2(bucket_current[bname]) drift = _round2(current - target) band_min = _round2(target - band["contract"]) band_max = _round2(target + band["expand"]) if current < band_min: drift_status = "BREACH_LOW" rebalance_needed = True elif current > band_max: drift_status = "BREACH_HIGH" rebalance_needed = True elif abs(drift) >= MIN_ACTIONABLE_DRIFT_PCT / 2: drift_status = "WARN" else: drift_status = "NORMAL" bucket_rows.append({ "bucket": bname, "target_pct": target, "current_pct": current, "drift_pct": drift, "band_min": band_min, "band_max": band_max, "regime_band": band["label"], "drift_status": drift_status, }) # ── 4. 종목별 분석 ─────────────────────────────────────────────────────── # [WBS-3.2] 신호 가중 목표배분 (SS001_Norm_Score 기반) # ticker_target_pct = bucket_target × (ss001_norm / Σ ss001_norm in bucket) # 폴백: ss001_norm 모두 1.0 → equal_weight 와 동일 bucket_ss001_total: dict[str, float] = {} for h in holdings: bucket_ss001_total[h["bucket"]] = ( bucket_ss001_total.get(h["bucket"], 0.0) + h.get("ss001_norm", 1.0) ) ticker_rows: list[dict] = [] for h in holdings: bname = h["bucket"] bcfg = BUCKET_CONFIG.get(bname, BUCKET_CONFIG["Satellite"]) ss001_w = h.get("ss001_norm", 1.0) bucket_ss001 = max(bucket_ss001_total.get(bname, ss001_w), 0.01) target_pct = _round2(bcfg["target"] * ss001_w / bucket_ss001) current_pct = _round2(h["weight_pct"]) drift = _round2(current_pct - target_pct) band_min = _round2(target_pct - band["contract"]) band_max = _round2(target_pct + band["expand"]) # 드리프트 상태 force = h["force_signal"] if force: drift_status = "FORCE_" + force action = "SELL" gate_status = "FORCE_OVERRIDE" elif current_pct > band_max: drift_status = "BREACH_HIGH" action = "SELL" if abs(drift) >= MIN_ACTIONABLE_DRIFT_PCT else "WATCH" gate_status = "PASS" if abs(drift) >= MIN_ACTIONABLE_DRIFT_PCT else "BLOCKED_BY_COST" elif current_pct < band_min: drift_status = "BREACH_LOW" action = "BUY" if abs(drift) >= MIN_ACTIONABLE_DRIFT_PCT else "WATCH" gate_status = "PASS" if abs(drift) >= MIN_ACTIONABLE_DRIFT_PCT else "BLOCKED_BY_COST" elif abs(drift) >= MIN_ACTIONABLE_DRIFT_PCT / 2: drift_status = "WARN" gate_status = "BLOCKED_BY_COST" action = "WATCH" else: drift_status = "NORMAL" gate_status = "BLOCKED_BY_COST" action = "HOLD" # 3단계 분할 수량 계산 (P5) — SELL/BUY 액션에만 적용 stage_qtys: list[int] = [0, 0, 0] limit_prices: list[float] = [0.0, 0.0, 0.0] trade_value_krw = 0.0 cost_est_krw = 0.0 net_benefit_pct = 0.0 if action in ("SELL", "BUY") and h["holding_qty"] > 0 and h["close"] > 0: # 매도/매수 수량: 드리프트 해소에 필요한 수량 # |drift|%p ÷ current_pct * 보유수량 근사 if action == "SELL" and current_pct > 0: adjust_ratio = min(abs(drift) / current_pct, 1.0) adjust_qty = max(1, round(h["holding_qty"] * adjust_ratio)) else: adjust_qty = max(1, round(h["holding_qty"] * 0.10)) # BUY: 10% 추가 근사 stage_qtys = _stage_qty_split(adjust_qty) limit_price = _compute_limit_price(h["close"], action) limit_prices = [limit_price, limit_price, limit_price] total_action_qty = sum(stage_qtys) trade_value_krw = _round2(total_action_qty * limit_price) cost_est_krw = _round2(trade_value_krw * TX_COST_ROUNDTRIP) # 비용 차감 후 드리프트 개선 효과 net_benefit_pct = _round2(abs(drift) - TX_COST_ROUNDTRIP * 100) ticker_rows.append({ "ticker": h["ticker"], "name": h["name"], "bucket": bname, "target_pct": target_pct, "current_pct": current_pct, "drift_pct": drift, "band_min": band_min, "band_max": band_max, "regime_band": band["label"], "drift_status": drift_status, "force_signal": force, "gate_status": gate_status, "action": action, "stage1_qty": stage_qtys[0], "stage1_price": limit_prices[0], "stage2_qty": stage_qtys[1], "stage2_price": limit_prices[1], "stage3_qty": stage_qtys[2], "stage3_price": limit_prices[2], "trade_value_krw": trade_value_krw, "cost_est_krw": cost_est_krw, "net_benefit_pct": net_benefit_pct, "close": h["close"], }) # ── 5. ORDERS 생성 (P4: gate_status=PASS 또는 FORCE_OVERRIDE のみ) ──────── order_rows: list[dict] = [] order_no = 1 # Priority: FORCE_OVERRIDE > BREACH_HIGH(SELL) > BREACH_LOW(BUY) priority_map = {"FORCE_OVERRIDE": 0, "PASS": 1} sorted_tickers = sorted( [t for t in ticker_rows if t["gate_status"] in ("PASS", "FORCE_OVERRIDE")], key=lambda t: (priority_map.get(t["gate_status"], 9), -abs(t["drift_pct"])) ) for t in sorted_tickers: for stage_idx, (qty, price) in enumerate(zip( [t["stage1_qty"], t["stage2_qty"], t["stage3_qty"]], [t["stage1_price"], t["stage2_price"], t["stage3_price"]], ), start=1): if qty <= 0: continue reason = t["force_signal"] if t["force_signal"] else t["drift_status"] order_rows.append({ "order_no": order_no, "ticker": t["ticker"], "name": t["name"], "bucket": t["bucket"], "action": t["action"], "stage": stage_idx, "qty": qty, "limit_price_krw": price, "trade_value_krw": round(qty * price), "reason": reason, }) order_no += 1 # ── 6. SUMMARY ─────────────────────────────────────────────────────────── summary = { "run_date": _s((payload.get("metadata") or {}).get("converted_at", "")), "regime": regime, "regime_band": band["label"], "total_portfolio_krw": total_portfolio_krw, "cash_krw": cash_krw, "core_pct": _round2(core_pct), "satellite_pct": _round2(sat_pct), "cash_pct": _round2(cash_pct), "target_core_pct": BUCKET_CONFIG["Core"]["target"], "target_sat_pct": BUCKET_CONFIG["Satellite"]["target"], "target_cash_pct": BUCKET_CONFIG["Cash"]["target"], "rebalance_needed": rebalance_needed, "holdings_count": len(holdings), "orders_count": len(order_rows), "min_actionable_drift_pct": MIN_ACTIONABLE_DRIFT_PCT, "cash_floor_min_pct": cash_floor_pct, "cash_shortfall_krw": cash_shortfall_krw, "regime_trim_lock": bool(hc_data.get("regime_trim_lock", False)), "new_buy_gate": _s((_extract_trim_guidance(hc_data)).get("new_buy_gate", "")), } # ── 7. 출력 ────────────────────────────────────────────────────────────── out = { "formula_id": FORMULA_ID, "metadata": { "per_ticker_target_method": "signal_weighted_ss001_v1", "regime_source": "macro.REGIME_PRELIM > settings > harness_context > computed_harness", }, "summary": summary, "buckets": bucket_rows, "tickers": ticker_rows, "orders": order_rows, } out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8") action_count = len([t for t in ticker_rows if t["action"] not in ("HOLD", "WATCH")]) print(f"[{FORMULA_ID}] regime={regime} rebalance_needed={rebalance_needed} " f"holdings={len(holdings)} action_tickers={action_count} orders={len(order_rows)}") print(f" core={core_pct:.1f}% sat={sat_pct:.1f}% cash={cash_pct:.1f}%") print(f" -> {out_path}") return 0 if __name__ == "__main__": raise SystemExit(main())