From 468ad73c52636264fc614e9148b76940d5134ac1 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Mon, 22 Jun 2026 23:17:43 +0900 Subject: [PATCH] =?UTF-8?q?WBS-7.3=20F05/F10=20=EC=99=84=EB=A3=8C:=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EC=9D=98=EC=82=AC=EA=B2=B0=EC=A0=95(F05)?= =?UTF-8?q?=20+=20=ED=8F=AC=ED=8A=B8=ED=8F=B4=EB=A6=AC=EC=98=A4=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=8C=85(F10)=20=ED=8F=AC=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F05 (execution_decision_v1.py): - calc_exit_sell_action(): 7단계 우선순위 계층(정지/시간_종료, 강_상대약세, 추적정지, 중_약세, 익절, 시간정지) - safe_float() 헬퍼로 JavaScript Number.isFinite() 의미론 보장 - tp2→tp1→closeProtectLimit 가격 폴백 체인 - 17개 parity 테스트 PASS (우선순위, 가격 추적, 검증 상태) F10 (routing_decision_v1.py): - run_route_flow(): 5개 게이트 순차 필터링 1. Stop_Breach: EXIT_100 또는 P4 인트라데이 락시 TRIM_50 2. Relative_Stop: 베타조정 시장정지(절대_바닥, 상대_초과, 시간조건) 3. Intraday_Lock: P4 제약(BUY→WATCH, ADD→TRIM_50, 허용목록 강제) 4. Heat_Gate: 포트폴리오 열기제어(BLOCK_NEW_BUY/HALVE_NEW_BUY_QUANTITY) 5. Mean_Reversion: MRG001(close/ma20 > 1.10이면 BUY 차단) - 17개 parity 테스트 PASS (5개 게이트 모두 테스트됨) 마이그레이션 레저 업데이트: - F05: TODO → DONE - F10: TODO → DONE - 누적 parity 테스트: 64/64 PASS Co-Authored-By: Claude Haiku 4.5 --- formulas/routing_decision_v1.py | 253 ++++++++++++++++++ governance/gas_logic_migration_ledger_v1.yaml | 25 +- .../parity/test_routing_decision_parity_v1.py | 236 ++++++++++++++++ 3 files changed, 512 insertions(+), 2 deletions(-) create mode 100644 formulas/routing_decision_v1.py create mode 100644 tests/parity/test_routing_decision_parity_v1.py diff --git a/formulas/routing_decision_v1.py b/formulas/routing_decision_v1.py new file mode 100644 index 0000000..68c13d7 --- /dev/null +++ b/formulas/routing_decision_v1.py @@ -0,0 +1,253 @@ +""" +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 + } diff --git a/governance/gas_logic_migration_ledger_v1.yaml b/governance/gas_logic_migration_ledger_v1.yaml index 3d83291..f70e4b0 100644 --- a/governance/gas_logic_migration_ledger_v1.yaml +++ b/governance/gas_logic_migration_ledger_v1.yaml @@ -106,7 +106,17 @@ findings: classification: decision_logic migration_action: MIGRATE_DECISIONS_ROUTING target_file: formulas/execution_decision_v1.py - status: TODO + status: DONE + resolved_2026_06_22: > + formulas/execution_decision_v1.py:calc_exit_sell_action() implemented with 7-tier priority hierarchy + (stop/time exit, strong relative weakness, trailing stop, medium weakness, profit-taking, time stops). + safe_float() helper ensures JavaScript Number.isFinite() semantics (handles None, NaN, ±Infinity). + 17 parity tests in tests/parity/test_execution_decision_parity_v1.py PASS: + - Priority 1: STOP_OR_TIME_EXIT_READY, rwPartial >= 4 → EXIT_100 + - Priority 5: profitPct tiers (50%, 30%, 20%, 10%) → PROFIT_TRIM_50/35/25, TAKE_PROFIT_TIER1 + - Priority 4/6: trailing stops, time stops, RW signal levels → TRIM_70/50/33/25 + - Price fallback chain: tp2Price → tp1Price → closeProtectLimit (Tier2→Tier1→Close) + Full validation (SIGNAL_CONFIRMED / NO_SELL_ACTION) matches GAS pattern. - id: F06 file: src/gas_adapter_parts/gdf_01_price_metrics.gs @@ -164,7 +174,18 @@ findings: classification: decision_logic migration_action: MIGRATE_DECISIONS_ROUTING target_file: formulas/routing_decision_v1.py - status: TODO + status: DONE + resolved_2026_06_22: > + formulas/routing_decision_v1.py:run_route_flow() implemented with 5-gate sequential filtering: + Gate 1: Stop_Breach (EXIT_100 or TRIM_50 if P4 intraday lock) + Gate 2: Relative_Stop (beta-adjusted market stop with absolute floor, relative excess, time conditions) + Gate 3: Intraday_Lock (P4: downgrade BUY→WATCH, ADD→TRIM_50, enforce allowlist) + Gate 4: Heat_Gate (portfolio heat control: BLOCK_NEW_BUY or HALVE_NEW_BUY_QUANTITY) + Gate 5: Mean_Reversion (MRG001: block if close/ma20 > 1.10 on BUY actions) + 17 parity tests in tests/parity/test_routing_decision_parity_v1.py PASS: + - All 5 gates tested with pass/fail/skip/inactive transitions + - Multi-holding routing with different outcomes verified + Full test suite: 64/64 parity tests PASS (F05/F10/F15/F07 combined). - id: F11 file: src/gas_adapter_parts/gdf_03_portfolio_gates.gs diff --git a/tests/parity/test_routing_decision_parity_v1.py b/tests/parity/test_routing_decision_parity_v1.py new file mode 100644 index 0000000..fca6627 --- /dev/null +++ b/tests/parity/test_routing_decision_parity_v1.py @@ -0,0 +1,236 @@ +""" +Parity test for routing_decision_v1.py against GAS source. + +F10: Portfolio routing through multi-gate decision framework. +Tests run_route_flow() with all 5 gates: stop_breach, relative_stop, +intraday_lock, heat_gate, mean_reversion. + +Source: src/gas_adapter_parts/gdf_03_portfolio_gates.gs:runRouteFlow_ +""" + +import pytest +from formulas.routing_decision_v1 import run_route_flow + + +class TestRoutingDecisionGates: + """Test routing decision multi-gate filtering.""" + + def test_gate1_stop_breach_normal(self): + """Gate 1: stop breach without intraday lock → EXIT_100.""" + holdings = [{"ticker": "000660", "stopBreach": True, "close": 95, "stopPrice": 98}] + df_map = {"000660": {"finalAction": "HOLD", "ret20d": 0.10}} + h1_ctx = {"intradayLock": False, "kospiRet20d": 0.05} + + result = run_route_flow(holdings, df_map, h1_ctx) + + assert result["decisions"][0]["final_action"] == "EXIT_100" + gates = result["traces"][0]["gates"] + assert gates[0]["gate"] == "STOP_BREACH" + assert gates[0]["result"] == "FORCE_EXIT" + + def test_gate1_stop_breach_with_intraday_lock(self): + """Gate 1: stop breach with intraday lock → TRIM_50.""" + holdings = [{"ticker": "005380", "stopBreach": True, "close": 50, "stopPrice": 52}] + df_map = {"005380": {"finalAction": "HOLD"}} + h1_ctx = {"intradayLock": True, "kospiRet20d": 0.05} + + result = run_route_flow(holdings, df_map, h1_ctx) + + assert result["decisions"][0]["final_action"] == "TRIM_50" + gates = result["traces"][0]["gates"] + assert gates[0]["result"] == "DOWNGRADE_P4" + + def test_gate1_no_breach(self): + """Gate 1: no stop breach → PASS.""" + holdings = [{"ticker": "051910", "stopBreach": False, "close": 100, "stopPrice": 90}] + df_map = {"051910": {"finalAction": "BUY_TIER1"}} + h1_ctx = {"intradayLock": False, "kospiRet20d": 0.05} + + result = run_route_flow(holdings, df_map, h1_ctx) + + # Gate 1 passes, checks other gates + gates = result["traces"][0]["gates"] + assert gates[0]["result"] == "PASS" + + def test_gate2_relative_stop_abs_floor(self): + """Gate 2: profit < -20% → TRIM_50.""" + holdings = [{"ticker": "006800", "stopBreach": False, "close": 80, "profitPct": -25, "holdingDays": 30}] + df_map = {"006800": {"finalAction": "HOLD", "ret20d": -0.10, "atr20": 5.0}} + h1_ctx = {"intradayLock": False, "kospiRet20d": 0.05} + + result = run_route_flow(holdings, df_map, h1_ctx) + + decisions = result["decisions"][0] + assert decisions["final_action"] == "TRIM_50" + gates = result["traces"][0]["gates"] + rel_gate = [g for g in gates if g["gate"] == "RELATIVE_STOP"][0] + assert rel_gate["result"] == "TRIM_50" + assert "ABS_FLOOR" in rel_gate["reason"] + + def test_gate2_relative_stop_time_stop(self): + """Gate 2: holding >= 60 days + excess < 0 → TRIM_50.""" + holdings = [{"ticker": "035720", "stopBreach": False, "close": 100, "profitPct": 5, "holdingDays": 65}] + df_map = {"035720": {"finalAction": "HOLD", "ret20d": 0.05, "atr20": 4.0}} + h1_ctx = {"intradayLock": False, "kospiRet20d": 0.10} + + result = run_route_flow(holdings, df_map, h1_ctx) + + decisions = result["decisions"][0] + assert decisions["final_action"] == "TRIM_50" + + def test_gate2_relative_stop_skip(self): + """Gate 2: insufficient data (no atr20) → SKIP.""" + holdings = [{"ticker": "000020", "stopBreach": False, "close": 100, "holdingDays": 30}] + df_map = {"000020": {"finalAction": "HOLD", "ret20d": 0.10}} # no atr20 + h1_ctx = {"intradayLock": False, "kospiRet20d": 0.05} + + result = run_route_flow(holdings, df_map, h1_ctx) + + gates = result["traces"][0]["gates"] + rel_gate = [g for g in gates if g["gate"] == "RELATIVE_STOP"][0] + assert rel_gate["result"] == "SKIP" + + def test_gate3_intraday_lock_downgrade_buy(self): + """Gate 3: intraday lock with BUY → downgrade to WATCH.""" + holdings = [{"ticker": "011170", "stopBreach": False, "close": 100}] + df_map = {"011170": {"finalAction": "BUY_TIER1", "ret20d": 0.10, "atr20": 3.0}} + h1_ctx = {"intradayLock": True, "kospiRet20d": 0.05} + + result = run_route_flow(holdings, df_map, h1_ctx) + + decisions = result["decisions"][0] + assert decisions["final_action"] == "WATCH" + gates = result["traces"][0]["gates"] + intraday_gate = [g for g in gates if g["gate"] == "INTRADAY_LOCK"][0] + assert "DOWNGRADE" in intraday_gate["result"] + + def test_gate3_intraday_lock_downgrade_add(self): + """Gate 3: intraday lock with ADD → downgrade to TRIM_50.""" + holdings = [{"ticker": "017670", "stopBreach": False, "close": 100}] + df_map = {"017670": {"finalAction": "ADD_POSITION", "ret20d": 0.10, "atr20": 3.0}} + h1_ctx = {"intradayLock": True, "kospiRet20d": 0.05} + + result = run_route_flow(holdings, df_map, h1_ctx) + + decisions = result["decisions"][0] + assert decisions["final_action"] == "TRIM_50" + + def test_gate3_intraday_lock_allowlist_pass(self): + """Gate 3: intraday lock with allowed action (HOLD) → PASS.""" + holdings = [{"ticker": "015760", "stopBreach": False, "close": 100}] + df_map = {"015760": {"finalAction": "HOLD", "ret20d": 0.10, "atr20": 3.0}} + h1_ctx = {"intradayLock": True, "kospiRet20d": 0.05} + + result = run_route_flow(holdings, df_map, h1_ctx) + + decisions = result["decisions"][0] + assert decisions["final_action"] == "HOLD" + gates = result["traces"][0]["gates"] + intraday_gate = [g for g in gates if g["gate"] == "INTRADAY_LOCK"][0] + assert intraday_gate["result"] == "PASS" + + def test_gate4_heat_gate_block_new_buy(self): + """Gate 4: heat_gate=BLOCK_NEW_BUY with BUY → WATCH.""" + holdings = [{"ticker": "021240", "stopBreach": False, "close": 100}] + df_map = {"021240": {"finalAction": "BUY_TIER2", "ret20d": 0.10, "atr20": 3.0, "close": 100, "ma20": 95}} + h1_ctx = {"intradayLock": False, "heatGate": "BLOCK_NEW_BUY", "kospiRet20d": 0.05} + + result = run_route_flow(holdings, df_map, h1_ctx) + + decisions = result["decisions"][0] + assert decisions["final_action"] == "WATCH" + gates = result["traces"][0]["gates"] + heat_gate = [g for g in gates if g["gate"] == "HEAT_GATE"][0] + assert heat_gate["result"] == "BLOCK_BUY" + + def test_gate4_heat_gate_halve_qty(self): + """Gate 4: heat_gate=HALVE_NEW_BUY_QUANTITY with BUY → HALVE_QTY.""" + holdings = [{"ticker": "030000", "stopBreach": False, "close": 100}] + df_map = {"030000": {"finalAction": "BUY_TIER3", "ret20d": 0.10, "atr20": 3.0, "close": 100, "ma20": 95}} + h1_ctx = {"intradayLock": False, "heatGate": "HALVE_NEW_BUY_QUANTITY", "kospiRet20d": 0.05} + + result = run_route_flow(holdings, df_map, h1_ctx) + + gates = result["traces"][0]["gates"] + heat_gate = [g for g in gates if g["gate"] == "HEAT_GATE"][0] + assert heat_gate["result"] == "HALVE_QTY" + + def test_gate4_heat_gate_hold_pass(self): + """Gate 4: heat_gate with HOLD → PASS (not BUY).""" + holdings = [{"ticker": "045570", "stopBreach": False, "close": 100}] + df_map = {"045570": {"finalAction": "HOLD", "ret20d": 0.10, "atr20": 3.0}} + h1_ctx = {"intradayLock": False, "heatGate": "BLOCK_NEW_BUY", "kospiRet20d": 0.05} + + result = run_route_flow(holdings, df_map, h1_ctx) + + gates = result["traces"][0]["gates"] + heat_gate = [g for g in gates if g["gate"] == "HEAT_GATE"][0] + assert heat_gate["result"] == "PASS" + + def test_gate5_mean_reversion_block(self): + """Gate 5: close/ma20 > 1.10 with BUY → WATCH.""" + holdings = [{"ticker": "034220", "stopBreach": False, "close": 115}] + df_map = {"034220": {"finalAction": "BUY_TIER1", "ret20d": 0.10, "atr20": 3.0, "close": 115, "ma20": 100}} + h1_ctx = {"intradayLock": False, "kospiRet20d": 0.05} + + result = run_route_flow(holdings, df_map, h1_ctx) + + decisions = result["decisions"][0] + assert decisions["final_action"] == "WATCH" + gates = result["traces"][0]["gates"] + mrg_gate = [g for g in gates if g["gate"] == "MEAN_REVERSION"][0] + assert mrg_gate["result"] == "BLOCK" + + def test_gate5_mean_reversion_pass(self): + """Gate 5: close/ma20 <= 1.10 with BUY → PASS.""" + holdings = [{"ticker": "018880", "stopBreach": False, "close": 109}] + df_map = {"018880": {"finalAction": "BUY_TIER2", "ret20d": 0.10, "atr20": 3.0, "close": 109, "ma20": 100}} + h1_ctx = {"intradayLock": False, "kospiRet20d": 0.05} + + result = run_route_flow(holdings, df_map, h1_ctx) + + gates = result["traces"][0]["gates"] + mrg_gate = [g for g in gates if g["gate"] == "MEAN_REVERSION"][0] + assert mrg_gate["result"] == "PASS" + + def test_gate5_mean_reversion_skip(self): + """Gate 5: insufficient data (no ma20) with BUY → SKIP.""" + holdings = [{"ticker": "003550", "stopBreach": False, "close": 115}] + df_map = {"003550": {"finalAction": "BUY_TIER1", "ret20d": 0.10, "atr20": 3.0, "close": 115}} # no ma20 + h1_ctx = {"intradayLock": False, "kospiRet20d": 0.05} + + result = run_route_flow(holdings, df_map, h1_ctx) + + gates = result["traces"][0]["gates"] + mrg_gate = [g for g in gates if g["gate"] == "MEAN_REVERSION"][0] + assert mrg_gate["result"] == "SKIP" + + def test_gate5_mean_reversion_hold_pass(self): + """Gate 5: HOLD action (not BUY) → PASS.""" + holdings = [{"ticker": "010820", "stopBreach": False, "close": 115}] + df_map = {"010820": {"finalAction": "HOLD", "ret20d": 0.10, "atr20": 3.0, "close": 115, "ma20": 100}} + h1_ctx = {"intradayLock": False, "kospiRet20d": 0.05} + + result = run_route_flow(holdings, df_map, h1_ctx) + + gates = result["traces"][0]["gates"] + mrg_gate = [g for g in gates if g["gate"] == "MEAN_REVERSION"][0] + assert mrg_gate["result"] == "PASS" + + def test_multiple_holdings(self): + """Test multi-holding routing with different outcomes.""" + holdings = [ + {"ticker": "000660", "stopBreach": True, "close": 95, "stopPrice": 98}, + {"ticker": "005380", "stopBreach": False, "close": 100}, + ] + df_map = { + "000660": {"finalAction": "HOLD"}, + "005380": {"finalAction": "HOLD", "ret20d": 0.10, "atr20": 3.0}, + } + h1_ctx = {"intradayLock": False, "kospiRet20d": 0.05} + + result = run_route_flow(holdings, df_map, h1_ctx) + + assert len(result["decisions"]) == 2 + assert result["decisions"][0]["final_action"] == "EXIT_100" + assert result["decisions"][1]["final_action"] == "HOLD"