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

+52 -2
View File
@@ -27,6 +27,7 @@ from __future__ import annotations
import argparse
import json
import os
import signal
import sys
import threading
import time
@@ -82,6 +83,36 @@ def _flag(name: str, default: str = "1") -> bool:
return os.environ.get(name, default).strip().lower() in {"1", "true", "yes", "on"}
# The process is ONE trading session. A dozen memory-only daily flags (_eod_done,
# _session_closed, entry caps, benches...) are set once and never reset on date
# rollover, so an uninterrupted day-2 of uptime would take zero entries and never
# EOD-flatten. Instead of resetting them all, the loop EXITS cleanly after a
# successful EOD flatten (and on SIGTERM) — compose's `restart: unless-stopped`
# brings back a fresh process whose boot recovery (_rebuild_daily_state) restores
# any state that matters. Set from the scheduler thread and the signal handler;
# consumed by the main loop so the existing `finally:` cleanup runs.
_clean_exit_requested = threading.Event()
def _request_exit_after_eod(ok: bool) -> bool:
"""Request a clean process exit after a SUCCESSFUL EOD flatten.
Gated on TRADING_EXIT_AFTER_EOD_FLATTEN (default on). A FAILED flatten must
keep retrying inside the window, so ``ok=False`` never requests exit.
Returns whether exit was requested."""
if not ok or not _flag("TRADING_EXIT_AFTER_EOD_FLATTEN", "1"):
return False
_clean_exit_requested.set()
return True
def _sigterm_handler(signum, frame): # noqa: ARG001 — signal API shape
"""SIGTERM (docker stop) = clean shutdown: drain through the main loop's
``finally:`` (threads joined, DB connections closed) instead of dying mid-pass."""
print("[stop] SIGTERM received — draining for clean shutdown", flush=True)
_clean_exit_requested.set()
def _now_session_date():
session_date, _, _, _ = classify_session(datetime.now(timezone.utc))
return session_date
@@ -1061,7 +1092,14 @@ def main(argv: list[str] | None = None) -> int:
eod_h, eod_m = 15, 55
_normal_lead = max(1, 16 * 60 - (eod_h * 60 + eod_m)) # 15:55 -> 5
_pre_lead = int(os.environ.get("TRADING_PRE_CLOSURE_FLATTEN_LEAD_MIN", "20"))
_eod_done = {"v": False}
# Restart-churn guard: the daily-restart exit fires INSIDE the EOD window and
# compose restarts within seconds — the fresh process must see today's
# already-successful flatten (event-store truth) or it would flatten-nothing,
# exit again, and churn until the window closes.
_eod_done = {"v": rt["execution"] is not None
and rt["execution"].eod_flatten_done_today()}
if _eod_done["v"]:
print("[boot] EOD flatten already succeeded today — no re-flatten, no re-exit")
def step_eod_flatten():
if not eod_enabled or _eod_done["v"] or rt["execution"] is None:
@@ -1103,6 +1141,13 @@ def main(argv: list[str] | None = None) -> int:
query_session_pnl(rt["store"]))
except Exception: # noqa: BLE001
pass
# the process is the session: a successful flatten (including "already
# flat, nothing to close") ends it — exit cleanly, compose restarts fresh
if _request_exit_after_eod(ok):
msg = ("♻️ EOD flatten complete — exiting for daily restart "
"(compose brings the loop back fresh).")
print(f"[{datetime.now():%H:%M:%S}] {msg}", flush=True)
send_telegram(msg)
return f"{tag} closed [{closed}]"
# --- open catch-up flatten: a day-trading book should be FLAT by the open;
@@ -1243,10 +1288,13 @@ def main(argv: list[str] | None = None) -> int:
if ollama_cfg.enabled and ollama_cfg.enrichment_enabled:
threading.Thread(target=enrich_loop, name="enrich", daemon=True).start()
print(f"[boot] news enrichment thread started (every {ollama_cfg.enrichment_interval_seconds}s, model {ollama_cfg.model})")
# SIGTERM (docker stop) drains through the loop's finally: instead of a
# mid-pass kill — leaves the book protected by broker-side stop legs.
signal.signal(signal.SIGTERM, _sigterm_handler)
try:
# one immediate full pass, then interval-driven
run_pass()
while True:
while not _clean_exit_requested.is_set():
results = scheduler.run_pending()
if results:
stamp = datetime.now().strftime("%H:%M:%S")
@@ -1256,6 +1304,8 @@ def main(argv: list[str] | None = None) -> int:
)
print(f"[{stamp}] {line}")
time.sleep(1.0)
# clean exit (EOD daily restart or SIGTERM): fall through to finally
print("[stop] clean exit — shutting down (compose restarts the loop fresh)")
except KeyboardInterrupt:
print("\n[stop] shutting down")
finally: