HIGH: - Circuit breaker fails CLOSED: a repeated unreadable equity (API 401/429/5xx or missing field) now halts new entries after 3 consecutive failures instead of silently reporting 'no breach' and continuing to trade through a drawdown. - execute(): map order statuses. Terminal-fail (rejected/expired/...) -> ok=False (never armed as a resting entry); record ANY fill (partial too), not only an exact 'filled'; require a real broker order id. approve_order only arms genuinely OPEN statuses. - sync: account/positions/orders failures now emit a BrokerHealthChangedEvent (degraded) and step_sync surfaces 'DEGRADED', instead of a success-shaped None that left risk gates reading stale snapshots as current. MEDIUM: - _flatten_all records a position closed only on a CONFIRMED close; the breaker event reports 'halted_flatten_incomplete' + the unclosed symbols when a close fails, instead of claiming the book is flat. - submit_exit_order prefers close_position (handles short sign + fractional qty; a plain sell of abs(qty) would double a short). LOW: - get_orders(nested=true) so bracket child legs aren't dropped from the snapshot. + tests/unit/test_execution_safety.py (4 regressions). 75/75 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
73 lines
2.4 KiB
Python
73 lines
2.4 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_breaker_fails_closed_when_equity_unreadable():
|
|
store = EventStore(":memory:")
|
|
svc = TradingExecutionService(
|
|
store,
|
|
executor=AlpacaPaperExecutor(store, client=_FakeClient(raise_account=True)),
|
|
settings=ExecutionSettings(max_daily_loss_pct=0.03),
|
|
session_id="cb",
|
|
)
|
|
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 -> fail CLOSED
|
|
assert svc._halted is True
|