Files
momentum-system/tests/unit/test_execution_safety.py
T
momentum devandClaude Opus 4.8 4520c6935a EOD flatten: cancel bracket legs before close (was naked overnight)
The end-of-day flatten fired at 15:55 but FAILED on every position with the
held-qty 403: close_session/_flatten_all called close_position while the DAY
stop legs still reserved the shares (held_for_orders) — the same bug fixed in
the exit manager, but on the EOD path. The flatten then marked itself done and
never retried, leaving the whole book naked overnight (3 positions, no stops,
two underwater).

- Add _release_and_close(): cancel a symbol's resting bracket legs to free the
  qty, THEN close_position with retry. Route both _flatten_all and the
  close_session belt-and-suspenders loop through it.
- step_eod_flatten only marks _eod_done when the flatten had NO errors — so a
  failed flatten RETRIES within the [15:55,16:00) window instead of giving up.

Tests: EOD flatten cancels the leg before closing (no 403); 45 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:08:33 -04:00

150 lines
5.5 KiB
Python

"""Execution/broker safety regressions (from the silent-failure audit).
Locks in: a rejected or id-less order is never reported ok; partial fills are
recorded; and the daily-loss circuit breaker fails CLOSED when equity can't be
read (rather than silently disabling itself).
"""
from alpaca_paper.execution import AlpacaPaperExecutor, ExecutionRequest
from storage.event_store import EventStore
from trading_execution import ExecutionSettings, TradingExecutionService
class _FakeClient:
def __init__(self, resp=None, account=None, raise_account=False):
self.resp = resp or {}
self.account = account or {"equity": "100000", "last_equity": "100000"}
self.raise_account = raise_account
def submit_order(self, **kw):
return self.resp
def cancel_order(self, order_id):
pass
def get_account(self):
if self.raise_account:
raise RuntimeError("API down")
return self.account
def _req(symbol="X"):
return ExecutionRequest(
symbol=symbol, side="buy", quantity=10, entry_price=1.0,
stop_loss_price=0.9, take_profit_price=1.2, order_type="limit",
)
def test_rejected_order_is_not_ok():
ex = AlpacaPaperExecutor(
EventStore(":memory:"),
client=_FakeClient({"id": "o1", "status": "rejected", "filled_qty": "0"}),
)
r = ex.execute(_req())
assert r.ok is False and r.status == "rejected"
def test_order_with_no_broker_id_is_not_ok():
ex = AlpacaPaperExecutor(EventStore(":memory:"), client=_FakeClient({"status": "new"}))
assert ex.execute(_req()).ok is False
def test_partial_fill_is_recorded():
store = EventStore(":memory:")
ex = AlpacaPaperExecutor(store, client=_FakeClient(
{"id": "o2", "status": "partially_filled", "filled_qty": "5", "filled_avg_price": "1.01"}))
r = ex.execute(_req("Y"))
assert r.ok is True
assert len(store.query_events(event_type="order_filled", limit=None)) == 1
def test_close_session_flattens_and_blocks_new_entries():
store = EventStore(":memory:")
closed = []
class _C(_FakeClient):
def get_positions(self, fresh=False):
return [{"symbol": "AAA", "qty": "100"}, {"symbol": "BBB", "qty": "50"}]
def close_position(self, symbol, qty=None, percentage=None):
closed.append(symbol)
return {"id": "c", "status": "accepted"}
svc = TradingExecutionService(
store, executor=AlpacaPaperExecutor(store, client=_C()),
settings=ExecutionSettings(max_daily_loss_pct=0.03), session_id="eod",
)
res = svc.close_session("eod_flatten")
assert set(res["closed_positions"]) == {"AAA", "BBB"} # flattened the book
assert svc._session_closed is True
assert svc._daily_loss_breach() is True # no new entries after
def test_eod_flatten_cancels_legs_before_close():
"""EOD flatten must cancel resting bracket legs BEFORE close_position — else
held_for_orders 403s and the book is left naked overnight (the live bug)."""
store = EventStore(":memory:")
class _C:
def __init__(self):
self.reserved = True # stop leg holds the qty until cancelled
self.closed = []
self.canceled = []
def get_account(self):
return {"equity": "100000", "last_equity": "100000"}
def get_positions(self, fresh=False):
return [] if self.closed else [{"symbol": "AAA", "qty": "100"}]
def get_orders(self, status="open", nested=True, symbols=None):
if self.reserved:
return [{"symbol": "AAA", "side": "sell", "type": "stop",
"status": "held", "id": "st1"}]
return []
def cancel_order(self, order_id):
self.canceled.append(order_id)
self.reserved = False
def submit_order(self, **kw):
return {"id": "o", "status": "new", "filled_qty": "0"}
def close_position(self, symbol, qty=None, percentage=None):
if self.reserved:
raise RuntimeError("403 insufficient qty available for order")
self.closed.append(symbol)
return {"id": "c", "status": "accepted"}
cli = _C()
svc = TradingExecutionService(
store, executor=AlpacaPaperExecutor(store, client=cli),
settings=ExecutionSettings(max_daily_loss_pct=0.5), session_id="eod",
)
res = svc.close_session("eod_flatten")
assert cli.canceled == ["st1"] # cancelled the leg first
assert "AAA" in res["closed_positions"] # then actually closed
assert res["errors"] == [] # no 403
def test_breaker_pauses_then_recovers_when_equity_unreadable():
"""Equity-read outage PAUSES new entries (recoverable), not a permanent halt:
a transient DNS/network blip must not end the trading day."""
store = EventStore(":memory:")
client = _FakeClient(raise_account=True)
svc = TradingExecutionService(
store,
executor=AlpacaPaperExecutor(store, client=client),
settings=ExecutionSettings(max_daily_loss_pct=0.03),
session_id="cb",
)
svc._equity_fail_limit = 3 # tighten for the test
assert svc._daily_loss_breach() is False # 1 failure
assert svc._daily_loss_breach() is False # 2 failures
assert svc._daily_loss_breach() is True # 3 -> PAUSE (recoverable)
assert svc._data_halt is True and svc._halted is False # NOT a permanent halt
# equity readable again -> resume
client.raise_account = False
assert svc._daily_loss_breach() is False
assert svc._data_halt is False