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>
150 lines
5.5 KiB
Python
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
|