WBS-7.3 F05/F10 완료: 실행 의사결정(F05) + 포트폴리오 라우팅(F10) 포팅

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 23:17:43 +09:00
parent 2f0e294638
commit 468ad73c52
3 changed files with 512 additions and 2 deletions
+253
View File
@@ -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
}
+23 -2
View File
@@ -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
@@ -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"