Files
momentum-system/tests/unit/test_exit_manager_sanity.py
momentum devandClaude Fable 5 d16eb85ee9 money-path: daily self-restart + SIGTERM + positive-evidence exit gate
Plan 003 steps 1-3.
- Loop exit(0) after a SUCCESSFUL EOD flatten (TRADING_EXIT_AFTER_EOD_FLATTEN,
  default on); compose restarts fresh. Restart-churn guard: _rebuild_daily_state
  detects today's eod_flatten event and seeds _session_closed/_eod_done so a
  post-flatten restart neither re-flattens nor re-exits. SIGTERM = clean drain.
- Exit manager: _FETCH_FAILED sentinel distinguishes a failed get_orders from a
  genuinely-empty snapshot. A failed fetch skips naked-stop enforcement (catastrophe
  still exits, re-fetching); an empty-but-successful snapshot needs TWO consecutive
  reads before 'naked' is trusted (Alpaca silently returns [] for symbols=). Costs
  ~one 12s pass on the all-naked case only; isolated naked positions unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:20:37 -04:00

98 lines
4.1 KiB
Python

"""Exit-manager positive-evidence gate (plan 003, step 3).
No destructive naked-stop flatten may fire on an AMBIGUOUS orders read:
- a FAILED get_orders (sentinel) suppresses naked enforcement, but the
catastrophe backstop still runs (flattening via a re-fetch, not the sentinel);
- a genuinely-empty-but-successful snapshot needs TWO consecutive empty reads
before it is trusted (Alpaca can silently return [] for a symbols= query);
- a real naked position with a non-empty snapshot is still enforced (regression).
"""
import pandas as pd
from runtime.exit_manager import LiveExitManager
from storage.event_store import EventStore
from strategy.exits import ExitConfig
class _Broker:
def __init__(self, positions, orders_result, market_open=True):
self._positions = positions
self._orders_result = orders_result # a list, or an Exception to raise
self.market_open = market_open
self.closed = []
self.canceled = []
self.get_orders_calls = 0
def get_positions(self):
return self._positions
def get_orders(self, status="open", limit=200, nested=True, symbols=None):
self.get_orders_calls += 1
if isinstance(self._orders_result, Exception):
raise self._orders_result
return list(self._orders_result)
def get_clock(self):
return {"is_open": self.market_open}
def cancel_order(self, order_id):
self.canceled.append(order_id)
def close_position(self, symbol, qty=None, percentage=None):
self.closed.append((symbol, qty))
return {"id": "c"}
def _cfg():
# grace=1 isolates the positive-evidence gate from the multi-pass grace
return ExitConfig(enforce_stop_grace_passes=1, catastrophe_pct=0.10)
def _pos(sym, entry, current):
return {"symbol": sym, "avg_entry_price": str(entry), "qty": "100",
"current_price": str(current), "side": "long"}
def test_fetch_failed_skips_naked_but_catastrophe_still_exits():
store = EventStore(":memory:")
# AAA is a mild naked position (should NOT flatten on a failed fetch);
# BBB is catastrophically underwater (MUST flatten regardless of stop status).
broker = _Broker([_pos("AAA", 10.0, 9.9), _pos("BBB", 10.0, 8.5)],
orders_result=RuntimeError("api down"))
mgr = LiveExitManager(broker, store, lambda s: pd.DataFrame(), cfg=_cfg(), session_id="t")
mgr.manage()
closed = {s for s, _ in broker.closed}
assert "AAA" not in closed, "naked enforcement must be suppressed on a failed fetch"
assert "BBB" in closed, "catastrophe backstop must still exit a deep-drawdown position"
def test_stop_leg_returns_none_for_sentinel():
from runtime.exit_manager import _FETCH_FAILED
store = EventStore(":memory:")
mgr = LiveExitManager(_Broker([], []), store, lambda s: pd.DataFrame(),
cfg=_cfg(), session_id="t")
assert mgr._stop_leg("AAA", _FETCH_FAILED) == (None, None)
assert mgr._stop_leg("AAA", None) == (None, None)
def test_empty_snapshot_needs_two_consecutive_reads():
store = EventStore(":memory:")
broker = _Broker([_pos("AAA", 10.0, 9.95)], orders_result=[]) # empty-but-successful
mgr = LiveExitManager(broker, store, lambda s: pd.DataFrame(), cfg=_cfg(), session_id="t")
mgr.manage() # pass 1: first empty read — untrusted, no flatten
assert broker.closed == [], "one empty snapshot must not flatten a protected book"
mgr.manage() # pass 2: second consecutive empty read — trusted, naked enforced
assert ("AAA", None) in broker.closed, "two consecutive empty reads confirm naked"
def test_real_naked_still_enforced():
store = EventStore(":memory:")
# a non-empty snapshot with a leg for a DIFFERENT symbol: AAA is genuinely naked
other_leg = {"id": "s1", "symbol": "ZZZ", "type": "stop", "side": "sell",
"stop_price": "9.0", "status": "held"}
broker = _Broker([_pos("AAA", 10.0, 9.95)], orders_result=[other_leg])
mgr = LiveExitManager(broker, store, lambda s: pd.DataFrame(), cfg=_cfg(), session_id="t")
mgr.manage()
assert ("AAA", None) in broker.closed, "a confirmed-naked position must still be flattened"