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>
This commit is contained in:
momentum devandClaude Opus 4.8 committed 2026-06-18 16:08:33 -04:00
1 parent 0da1ba733b
commit 4520c6935a
3 files changed
+95 -9

No files matched your search

+5 -2
View File
@@ -648,10 +648,13 @@ def main(argv: list[str] | None = None) -> int:
if now_et.weekday() >= 5 or not ((eod_h, eod_m) <= cur < (16, 0)): if now_et.weekday() >= 5 or not ((eod_h, eod_m) <= cur < (16, 0)):
return None return None
res = rt["execution"].close_session("eod_flatten") res = rt["execution"].close_session("eod_flatten")
_eod_done["v"] = True # only mark done if it actually succeeded — otherwise RETRY on the next
# pass within the window (a flatten that errored once left positions naked
# overnight). close_session re-blocks entries each call, so retrying is safe.
_eod_done["v"] = not res["errors"]
closed = ", ".join(res["closed_positions"]) or "none" closed = ", ".join(res["closed_positions"]) or "none"
return (f"EOD FLATTEN closed [{closed}]" return (f"EOD FLATTEN closed [{closed}]"
+ (f" errors={res['errors']}" if res["errors"] else "")) + (f" errors={res['errors']} (will retry)" if res["errors"] else ""))
scheduler = Scheduler() scheduler = Scheduler()
scheduler.add("discover", step_discover, scheduler.add("discover", step_discover,
+47
View File
@@ -80,6 +80,53 @@ def test_close_session_flattens_and_blocks_new_entries():
assert svc._daily_loss_breach() is True # no new entries after 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(): def test_breaker_pauses_then_recovers_when_equity_unreadable():
"""Equity-read outage PAUSES new entries (recoverable), not a permanent halt: """Equity-read outage PAUSES new entries (recoverable), not a permanent halt:
a transient DNS/network blip must not end the trading day.""" a transient DNS/network blip must not end the trading day."""
+43 -7
View File
@@ -18,6 +18,7 @@ import functools
import json import json
import logging import logging
import os import os
import time
import threading import threading
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -295,14 +296,49 @@ class TradingExecutionService:
logger.warning("broker positions unreadable in entry guard: %s", exc) logger.warning("broker positions unreadable in entry guard: %s", exc)
return None return None
# order states that still reserve position quantity (held_for_orders)
_RESERVING = {"held", "new", "accepted", "pending_new",
"accepted_for_bidding", "partially_filled"}
def _release_and_close(self, client, symbol: str) -> None:
"""Market-close a position, cancelling its resting bracket legs FIRST.
A bracket stop/take-profit leg RESERVES the full quantity, so a bare
close_position is rejected 403 'insufficient qty available' — which is
exactly why the EOD flatten failed and left positions naked overnight.
Cancel the symbol's working orders to free the shares, then close (with
retry so a not-yet-settled cancel doesn't abort the flatten)."""
if hasattr(client, "get_orders"):
try:
for o in client.get_orders(status="open", nested=True, symbols=[symbol]):
for x in [o, *(o.get("legs") or [])]:
sym = x.get("symbol") or o.get("symbol")
if (sym == symbol and x.get("status") in self._RESERVING
and x.get("id")):
try:
client.cancel_order(x["id"])
except Exception: # noqa: BLE001
pass
except Exception: # noqa: BLE001
pass
last_exc = None
for attempt in range(4):
try:
client.close_position(symbol)
return
except Exception as exc: # noqa: BLE001
last_exc = exc
time.sleep(0.3 * (attempt + 1))
raise last_exc if last_exc else RuntimeError("flatten failed")
def _flatten_all(self, reason: str) -> dict: def _flatten_all(self, reason: str) -> dict:
"""Cancel unfilled entries and market-close every open position. """Cancel unfilled entries and market-close every open position.
Used when the daily-loss circuit breaker trips: stop the bleeding by Used at EOD and when the daily-loss circuit breaker trips: stop the
cancelling resting entries and flattening the book. Best-effort — errors bleeding by cancelling resting entries and flattening the book.
are collected, not raised, so one bad symbol can't block the rest. Best-effort — errors are collected, not raised, so one bad symbol can't
``close_position`` also cancels the position's resting bracket legs. block the rest. Resting bracket legs are cancelled before each close so
""" the held quantity is freed (else close_position 403s)."""
result: dict = {"cancelled_entries": 0, "closed_positions": [], "errors": []} result: dict = {"cancelled_entries": 0, "closed_positions": [], "errors": []}
# 1) cancel unfilled (armed) entries we are tracking # 1) cancel unfilled (armed) entries we are tracking
for order_id in list(self._armed): for order_id in list(self._armed):
@@ -323,7 +359,7 @@ class TradingExecutionService:
result["errors"].append(f"close {symbol}: no broker client to flatten") result["errors"].append(f"close {symbol}: no broker client to flatten")
continue continue
try: try:
client.close_position(symbol) self._release_and_close(client, symbol)
result["closed_positions"].append(symbol) # only on a confirmed close result["closed_positions"].append(symbol) # only on a confirmed close
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
result["errors"].append(f"close {symbol}: {exc}") result["errors"].append(f"close {symbol}: {exc}")
@@ -816,7 +852,7 @@ class TradingExecutionService:
sym = p.get("symbol") sym = p.get("symbol")
if sym and sym not in result["closed_positions"]: if sym and sym not in result["closed_positions"]:
try: try:
client.close_position(sym) self._release_and_close(client, sym)
result["closed_positions"].append(sym) result["closed_positions"].append(sym)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
result["errors"].append(f"close {sym}: {exc}") result["errors"].append(f"close {sym}: {exc}")