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:
1 parent
af8137dfd7
commit
d16eb85ee9
7 files changed
+322
-9
No files matched your search
+52
-2
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user