money-path: daily self-restart + SIGTERM + positive-evidence exit gate

Plan 003 steps 1-3.
- Loop exit(0) after a SUCCESSFUL EOD flatten (TRADING_EXIT_AFTER_EOD_FLATTEN,
  default on); compose restarts fresh. Restart-churn guard: _rebuild_daily_state
  detects today's eod_flatten event and seeds _session_closed/_eod_done so a
  post-flatten restart neither re-flattens nor re-exits. SIGTERM = clean drain.
- Exit manager: _FETCH_FAILED sentinel distinguishes a failed get_orders from a
  genuinely-empty snapshot. A failed fetch skips naked-stop enforcement (catastrophe
  still exits, re-fetching); an empty-but-successful snapshot needs TWO consecutive
  reads before 'naked' is trusted (Alpaca silently returns [] for symbols=). Costs
  ~one 12s pass on the all-naked case only; isolated naked positions unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Fable 5 committed 2026-07-13 22:20:37 -04:00
1 parent af8137dfd7
commit d16eb85ee9
7 files changed
+322 -9

No files matched your search

+42 -4
View File
@@ -24,6 +24,10 @@ from strategy.exits import ExitConfig, TRAIL_NONE, catastrophe_triggered, manage
logger = logging.getLogger(__name__)
# Sentinel for "the orders fetch FAILED" — distinct from None / an empty list so
# a transient broker/API error is never mistaken for "every position is naked".
_FETCH_FAILED = object()
@dataclass
class _Tracked:
@@ -53,6 +57,7 @@ class LiveExitManager:
self._tracked: dict[str, _Tracked] = {}
self._naked_passes: dict[str, int] = {} # consecutive passes a held position had no live stop
self._open_anchored: set[str] = set() # breakeven carries re-anchored to today's opening range
self._empty_snapshot_streak = 0 # consecutive empty-but-successful order snapshots (Alpaca can silently return [])
# -- broker helpers ----------------------------------------------------
@@ -65,19 +70,26 @@ class LiveExitManager:
# scope to the held symbols — Alpaca's `limit` keeps only the most-recent
# orders, so on a busy day an unscoped query drops older positions' stop
# legs and we'd misread a protected position as naked.
# POSITIVE-EVIDENCE CONTRACT: a FAILED call returns the _FETCH_FAILED
# sentinel (NOT None), so it's distinguishable from a genuinely-empty
# but successful response — the caller must never read a fetch failure
# as "every position is naked" and market-flatten a protected book.
try:
return self.client.get_orders(status="all", limit=500, nested=True,
symbols=list(symbols) if symbols else None)
except Exception: # noqa: BLE001
return None
return _FETCH_FAILED
def _stop_leg(self, symbol: str, orders):
"""(order_id, stop_price) of the live sell-stop protecting symbol.
Uses pre-fetched ``orders`` (status="all" — the protective leg is a
'held' child of the FILLED entry, which status="open" wouldn't surface).
A failed fetch (``_FETCH_FAILED``) or ``None`` yields (None, None) — the
pass loop guards naked enforcement on the sentinel separately, so this
only ever means "no stop found in what we could read".
"""
if orders is None:
if orders is None or orders is _FETCH_FAILED:
return (None, None)
for o in orders:
for c in [o, *(o.get("legs") or [])]:
@@ -184,6 +196,23 @@ class LiveExitManager:
orders = self._all_orders(held.keys())
mkt_open = self._market_open() # once per pass — gates flatten-vs-rest + trailing
# POSITIVE-EVIDENCE GATE — never let an ambiguous read command a naked flatten:
# - fetch FAILED (sentinel): skip naked enforcement this pass entirely; the
# catastrophe stop still runs but flattens with orders=None so the helper
# re-fetches instead of consuming the sentinel.
# - fetch SUCCEEDED but empty while positions are held (Alpaca's documented
# silently-empty symbols= response): require TWO consecutive empty reads
# before trusting "naked" — one blip can't flatten a protected book.
fetch_failed = orders is _FETCH_FAILED
orders_for_flatten = None if fetch_failed else orders
if fetch_failed:
logger.warning("orders fetch failed — skipping naked-stop enforcement this pass")
else:
self._empty_snapshot_streak = (self._empty_snapshot_streak + 1) if not orders else 0
empty_untrusted = (not fetch_failed) and (not orders) and self._empty_snapshot_streak < 2
# naked enforcement is suppressed when we can't positively confirm nakedness
naked_ok = not fetch_failed and not empty_untrusted
actions: list[str] = []
for sym, p in held.items():
entry = float(p.get("avg_entry_price") or 0.0)
@@ -202,7 +231,11 @@ class LiveExitManager:
loss_pct = (entry - cur) / entry * 100
try:
if mkt_open:
self._flatten(sym, orders)
# orders_for_flatten is None on a failed fetch so the helper
# re-fetches rather than acting on the sentinel; a true
# catastrophe still exits even when stop status is unknowable
# (a deep-drawdown position is worse than a redundant cancel).
self._flatten(sym, orders_for_flatten)
self._emit(sym, f"CATASTROPHE exit {sym} @~{cur:.4f} "
f"({loss_pct:.1f}% below entry)", "exit_catastrophe",
{"price": cur, "entry": entry, "stop": cur_stop})
@@ -231,7 +264,7 @@ class LiveExitManager:
# Flatten after a short grace (consecutive passes) so a just-filled
# bracket's legs can attach first.
grace = self.cfg.enforce_stop_grace_passes
if grace and entry > 0 and leg_id is None:
if grace and entry > 0 and leg_id is None and naked_ok:
n = self._naked_passes.get(sym, 0) + 1
self._naked_passes[sym] = n
if n >= grace:
@@ -259,6 +292,11 @@ class LiveExitManager:
logger.warning("naked-stop enforcement failed %s: %s", sym, exc)
self._naked_passes.pop(sym, None)
continue # don't run trail logic on a naked position
# Couldn't positively confirm the stop this pass (failed/untrusted-empty
# fetch) and no leg was found: skip the symbol — don't reset the naked
# counter on unverified evidence, and don't trail against a None stop.
if leg_id is None and not naked_ok:
continue
self._naked_passes.pop(sym, None) # has a live stop
# TRAILING runs only while the session is OPEN: after-hours there are no new