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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user