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:
1 parent
0da1ba733b
commit
4520c6935a
3 files changed
+95
-9
No files matched your search
+5
-2
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
Reference in new issue
Block a user