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>
98 lines
4.1 KiB
Python
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"
|