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)):
return None
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"
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.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
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."""
+43 -7
View File
@@ -18,6 +18,7 @@ import functools
import json
import logging
import os
import time
import threading
from dataclasses import dataclass
from datetime import datetime, timedelta
@@ -295,14 +296,49 @@ class TradingExecutionService:
logger.warning("broker positions unreadable in entry guard: %s", exc)
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:
"""Cancel unfilled entries and market-close every open position.
Used when the daily-loss circuit breaker trips: stop the bleeding by
cancelling resting entries and flattening the book. Best-effort — errors
are collected, not raised, so one bad symbol can't block the rest.
``close_position`` also cancels the position's resting bracket legs.
"""
Used at EOD and when the daily-loss circuit breaker trips: stop the
bleeding by cancelling resting entries and flattening the book.
Best-effort — errors are collected, not raised, so one bad symbol can't
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": []}
# 1) cancel unfilled (armed) entries we are tracking
for order_id in list(self._armed):
@@ -323,7 +359,7 @@ class TradingExecutionService:
result["errors"].append(f"close {symbol}: no broker client to flatten")
continue
try:
client.close_position(symbol)
self._release_and_close(client, symbol)
result["closed_positions"].append(symbol) # only on a confirmed close
except Exception as exc: # noqa: BLE001
result["errors"].append(f"close {symbol}: {exc}")
@@ -816,7 +852,7 @@ class TradingExecutionService:
sym = p.get("symbol")
if sym and sym not in result["closed_positions"]:
try:
client.close_position(sym)
self._release_and_close(client, sym)
result["closed_positions"].append(sym)
except Exception as exc: # noqa: BLE001
result["errors"].append(f"close {sym}: {exc}")