""" Portfolio routing decision with multi-gate filtering. F10 porting: Evaluates holding positions through 5 sequential gates (stop breach, relative stop, intraday lock, heat, mean reversion) and returns final routing action per holding. Ported from: src/gas_adapter_parts/gdf_03_portfolio_gates.gs:runRouteFlow_ Parity reference: tests/parity/test_routing_decision_parity_v1.py """ import re from typing import Any, Optional def is_finite(value: Any) -> bool: """Check if value is a finite number.""" try: import math return isinstance(value, (int, float)) and math.isfinite(value) except: return False def run_route_flow( holdings: list[dict[str, Any]], df_map: dict[str, dict[str, Any]], h1_context: dict[str, Any] ) -> dict[str, Any]: """ Route holdings through multi-gate decision framework. Gates: 1. Stop_Breach: Direct stop loss trigger → EXIT_100 or TRIM_50 2. Relative_Stop: Market beta-adjusted stop → TRIM_50 3. Intraday_Lock: P4 constraints (blocked keywords, allowlist) 4. Heat_Gate: Portfolio heat control (BLOCK_NEW_BUY, HALVE_QTY) 5. Mean_Reversion: Mean-reversion gate (MRG001) Args: holdings: List of holding dicts with keys: ticker, stopPrice, close, profitPct, etc. df_map: Dict mapping ticker → data_feed row dict h1_context: Market context dict with keys: intradayLock, heatGate, kospiRet20d, etc. Returns: Dict: { "routes": [{"ticker": str, "final_action": str, ...}, ...], "traces": [{"ticker": str, "gates": [...]}, ...], "lock": bool } """ routes = [] traces = [] intraday_lock = bool(h1_context.get("intradayLock")) heat_gate = str(h1_context.get("heatGate", "")) kospi_ret20d = float(h1_context.get("kospiRet20d", 0)) for h in holdings: ticker = str(h.get("ticker", "")) df = df_map.get(ticker, {}) base_final_action = str(df.get("finalAction", "INSUFFICIENT_DATA")).upper() final_action = base_final_action trace_gates = [] # Gate 1: Stop_Price Breach stop_breach = bool(h.get("stopBreach")) if stop_breach: if intraday_lock: final_action = "TRIM_50" # P4: EXIT_100 → TRIM_50 trace_gates.append({ "gate": "STOP_BREACH", "result": "DOWNGRADE_P4", "reason": "intraday_lock: stop_breach→TRIM_50" }) else: final_action = "EXIT_100" trace_gates.append({ "gate": "STOP_BREACH", "result": "FORCE_EXIT", "reason": f"breach: close={h.get('close')} ≤ stop={h.get('stopPrice')}" }) else: trace_gates.append({ "gate": "STOP_BREACH", "result": "PASS", "reason": "no_breach" }) # Gate 2: Relative_Stop (beta-adjusted) if final_action != "EXIT_100": ret20d = float(df.get("ret20d", float("nan"))) atr20 = float(df.get("atr20", float("nan"))) close = float(h.get("close", 0)) or float(df.get("close", 0)) profit_pct = float(h.get("profitPct", float("nan"))) holding_days = int(h.get("holdingDays", 0)) if is_finite(ret20d) and is_finite(atr20) and close > 0: # Beta calculation if abs(kospi_ret20d) >= 0.5: beta = min(3.0, max(0.3, ret20d / kospi_ret20d)) else: beta = 1.0 excess = ret20d - beta * kospi_ret20d sigma = (atr20 / close * 100) * (20 ** 0.5) # sqrt(20) thresh = -2.0 * sigma # Trigger conditions abs_floor = is_finite(profit_pct) and profit_pct < -20.0 rel_break = excess < thresh time_stop = holding_days >= 60 and excess < 0 if abs_floor or rel_break or time_stop: rs_type = "ABS_FLOOR" if abs_floor else ("REL_EXCESS" if rel_break else "TIME_STOP") trace_gates.append({ "gate": "RELATIVE_STOP", "result": "TRIM_50", "reason": f"{rs_type}: excess={excess:.2f} thr={thresh:.2f}" }) if final_action == "HOLD" or "BUY" in final_action: final_action = "TRIM_50" else: trace_gates.append({ "gate": "RELATIVE_STOP", "result": "PASS", "reason": f"excess={excess:.2f} thr={thresh:.2f}" }) else: trace_gates.append({ "gate": "RELATIVE_STOP", "result": "SKIP", "reason": "insufficient_data" }) else: trace_gates.append({ "gate": "RELATIVE_STOP", "result": "INACTIVE", "reason": "stop_breach_exit_100" }) # Gate 3: Intraday_Lock (P4 constraints) if intraday_lock: # Downgrade blocked keywords blocked_keywords = ["BUY", "ADD"] allowed_actions = ["HOLD", "WATCH", "TRIM_25", "TRIM_33", "TRIM_50", "EXIT_100"] if any(keyword in final_action for keyword in blocked_keywords): downgraded = "WATCH" if "BUY" in final_action else "TRIM_50" trace_gates.append({ "gate": "INTRADAY_LOCK", "result": "DOWNGRADE", "reason": f"P4: {final_action}→{downgraded}" }) final_action = downgraded # Force allowlist check if final_action not in allowed_actions: trace_gates.append({ "gate": "INTRADAY_LOCK", "result": "FORCE_WATCH", "reason": f"P4_ALLOWLIST: {final_action}→WATCH" }) final_action = "WATCH" else: trace_gates.append({ "gate": "INTRADAY_LOCK", "result": "PASS", "reason": "action_in_allowlist" }) else: trace_gates.append({ "gate": "INTRADAY_LOCK", "result": "INACTIVE", "reason": "post_market" }) # Gate 4: Heat_Gate (portfolio heat control) if "BUY" in final_action: if heat_gate == "BLOCK_NEW_BUY": trace_gates.append({ "gate": "HEAT_GATE", "result": "BLOCK_BUY", "reason": "total_heat>=10%: BUY→WATCH" }) final_action = "WATCH" elif heat_gate == "HALVE_NEW_BUY_QUANTITY": trace_gates.append({ "gate": "HEAT_GATE", "result": "HALVE_QTY", "reason": "total_heat>=7%: qty 50% reduction" }) else: trace_gates.append({ "gate": "HEAT_GATE", "result": "PASS", "reason": heat_gate or "ok" }) else: trace_gates.append({ "gate": "HEAT_GATE", "result": "PASS", "reason": heat_gate or "not_buy" }) # Gate 5: Mean_Reversion (MRG001) if "BUY" in final_action: mrg_close = float(df.get("close", 0)) mrg_ma20 = float(df.get("ma20", 0)) if mrg_close > 0 and mrg_ma20 > 0: dev_ratio = mrg_close / mrg_ma20 mrg_threshold = 1.10 # 10% deviation threshold if dev_ratio > mrg_threshold: trace_gates.append({ "gate": "MEAN_REVERSION", "result": "BLOCK", "reason": f"MRG001: close/ma20={dev_ratio:.3f} > {mrg_threshold}" }) final_action = "WATCH" else: trace_gates.append({ "gate": "MEAN_REVERSION", "result": "PASS", "reason": f"close/ma20={dev_ratio:.3f}" }) else: trace_gates.append({ "gate": "MEAN_REVERSION", "result": "SKIP", "reason": "insufficient_data" }) else: trace_gates.append({ "gate": "MEAN_REVERSION", "result": "PASS", "reason": "not_buy" }) routes.append({ "ticker": ticker, "final_action": final_action, "base_action": base_final_action, }) traces.append({ "ticker": ticker, "gates": trace_gates, }) return { "decisions": routes, "traces": traces, "lock": True }