From 16715369c031db6d72ce164d3b73b8ebe567fe24 Mon Sep 17 00:00:00 2001 From: momentum dev Date: Mon, 22 Jun 2026 11:45:19 -0400 Subject: [PATCH] EOD flatten: close-relative + holiday-aware window (no naked carry over closures) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnosed the ATPC -$651 drag: on 6/18 (last session before Juneteenth) the EOD flatten FIRED at 15:55 but every close was rejected — Alpaca 403 "insufficient qty... held_for_orders" because the bracket stop/TP legs reserved the shares. That root cause was already fixed by 4520c69 (cancel legs before close; verified the current self-fetch finds the active leg). What remained were two real naked-carry holes this closes: - Early-close half-days NEVER flattened: the window was a fixed [15:55, close), but half-days close at 13:00 (15:55 > 13:00) — and half-days (Jul 3, Black Friday, Dec 24) are exactly the pre-holiday sessions. The window is now computed RELATIVE TO THE ACTUAL CLOSE, so it fires on early closes too. - No margin / silent failure before a multi-day gap. Before a weekend/holiday closure the flatten lead widens (TRADING_PRE_CLOSURE_FLATTEN_LEAD_MIN, def 20) so the book is flat with room to retry, and a flatten that can't fully close before a multi-day gap now SCREAMS ("‼ NAKED-CARRY RISK (N-day gap ahead)") instead of failing silently like 6/18 did. New calendar helpers next_trading_day / days_to_next_session / eod_flatten_status (pure, unit-tested: normal window, pre-holiday widening, early-close firing, closed-day skip). Normal full days are unchanged (still 15:55-16:00). Dormant until the next pre-closure session, so no effect on today's behavior. Co-Authored-By: Claude Opus 4.8 --- run_live_paper.py | 45 +++++++++++++++------ runtime/market_calendar.py | 42 ++++++++++++++++++- tests/unit/runtime/test_market_calendar.py | 47 ++++++++++++++++++++++ 3 files changed, 120 insertions(+), 14 deletions(-) diff --git a/run_live_paper.py b/run_live_paper.py index be448e9..b4793bd 100644 --- a/run_live_paper.py +++ b/run_live_paper.py @@ -55,7 +55,12 @@ from config import OllamaConfig from research.multi_schema import open_research_db from runtime.exit_manager import LiveExitManager from runtime.halt_guard import is_halt_gap -from runtime.market_calendar import is_regular_hours, session_close_hm +from runtime.market_calendar import ( + days_to_next_session, + eod_flatten_status, + is_regular_hours, + session_close_hm, +) from runtime.triggers import ArmedTriggerBook from runtime.watcher import Watcher, WatcherConfig from strategy.exits import ExitConfig @@ -807,11 +812,17 @@ def main(argv: list[str] | None = None) -> int: # --- end-of-day auto-flatten (a day-trading book shouldn't gap overnight) -- eod_enabled = _flag("TRADING_EOD_FLATTEN_ENABLED", "1") + # Legacy absolute time (15:55) is kept ONLY to derive the normal lead minutes + # (16:00 - 15:55 = 5) — the window itself is now relative to the ACTUAL close + # so it fires on early-close half-days too. Before a multi-day closure the + # lead widens so the book is flat with margin (the ATPC naked-holiday case). try: eod_h, eod_m = (int(x) for x in os.environ.get("TRADING_EOD_FLATTEN_TIME", "15:55").split(":")) except Exception: # noqa: BLE001 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} def step_eod_flatten(): @@ -822,23 +833,31 @@ def main(argv: list[str] | None = None) -> int: now_et = datetime.now(ZoneInfo("America/New_York")) except Exception: # noqa: BLE001 return None - cur = (now_et.hour, now_et.minute) - # only inside [flatten_time, close): after the close a market order just - # queues uselessly, so don't fire then (e.g. on a post-close restart). - # Skip entirely on weekends/holidays — flattening into a closed market - # cancels the protective bracket legs but can't fill the sell, which is - # how positions went naked over the Juneteenth-style holiday weekend. - close_hm = session_close_hm(now_et.date()) - if close_hm is None or not ((eod_h, eod_m) <= cur < close_hm): + # Fire inside [close - lead, close): after the close a market order just + # queues uselessly. The lead WIDENS before a multi-day closure so a thin + # name has time to fill and a transient failure still has a wide retry + # window. Skips weekends/holidays (session_close_hm -> None) — flattening + # into a closed market can't fill the sell. + due, pre_closure = eod_flatten_status(now_et, _normal_lead, _pre_lead) + if not due: return None - res = rt["execution"].close_session("eod_flatten") + reason = "eod_flatten_preclose" if pre_closure else "eod_flatten" + res = rt["execution"].close_session(reason) # 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"] + ok = not res["errors"] + _eod_done["v"] = ok closed = ", ".join(res["closed_positions"]) or "none" - return (f"EOD FLATTEN closed [{closed}]" - + (f" errors={res['errors']} (will retry)" if res["errors"] else "")) + tag = "PRE-HOLIDAY EOD FLATTEN" if pre_closure else "EOD FLATTEN" + if res["errors"]: + # A failed flatten before a MULTI-DAY gap = naked carry across the + # closure (the silent 6/18 Juneteenth failure). Scream about it. + gap = days_to_next_session(now_et.date()) + sev = f"‼ NAKED-CARRY RISK ({gap}-day gap ahead): " if pre_closure else "" + print(f"[{datetime.now():%H:%M:%S}] {sev}{tag} could not flatten: {res['errors']}", flush=True) + return f"{sev}{tag} closed [{closed}] errors={res['errors']} (will retry)" + return f"{tag} closed [{closed}]" def step_news(): """Land latest Alpaca/Benzinga news for the tracked universe into diff --git a/runtime/market_calendar.py b/runtime/market_calendar.py index c83df83..1133dc5 100644 --- a/runtime/market_calendar.py +++ b/runtime/market_calendar.py @@ -14,7 +14,7 @@ rules and should be re-confirmed when the year is current. Update annually. from __future__ import annotations -from datetime import date, datetime +from datetime import date, datetime, timedelta from zoneinfo import ZoneInfo _ET = ZoneInfo("America/New_York") @@ -69,3 +69,43 @@ def is_regular_hours(now_et: datetime | None = None) -> bool: if close is None: return False return (9, 30) <= (n.hour, n.minute) < close + + +def next_trading_day(d: date) -> date: + """The first trading day strictly after ``d`` (skips weekends + holidays).""" + nd = d + timedelta(days=1) + while not is_trading_day(nd): + nd += timedelta(days=1) + return nd + + +def days_to_next_session(d: date) -> int: + """Calendar days from ``d`` to the next session. 1 on a normal weeknight; 3 + over a weekend; 4+ before a holiday-extended weekend. >1 == a MULTI-DAY + closure follows, i.e. carrying overnight means carrying across a gap.""" + return (next_trading_day(d) - d).days + + +def eod_flatten_status( + now_et: datetime, + normal_lead_min: int = 5, + pre_closure_lead_min: int = 20, +) -> tuple[bool, bool]: + """(should_fire, is_pre_closure) for the end-of-day flatten. + + The window is ``[close - lead, close)`` and is computed RELATIVE TO THE + ACTUAL CLOSE — so it fires on early-close (1pm) half-days too, which a fixed + "15:55" never did (15:55 > 13:00 left half-day positions naked into the + holiday they precede). Before a MULTI-DAY closure (weekend/holiday) the lead + widens to ``pre_closure_lead_min`` so the book is flat with margin to spare + and a transient hiccup still has a wide retry window — never carry 3-4 days + naked. Returns (False, _) outside the window or on a non-trading day.""" + close = session_close_hm(now_et.date()) + if close is None: + return (False, False) + close_min = close[0] * 60 + close[1] + pre_closure = days_to_next_session(now_et.date()) > 1 + lead = max(normal_lead_min, pre_closure_lead_min) if pre_closure else normal_lead_min + start_min = close_min - lead + cur_min = now_et.hour * 60 + now_et.minute + return (start_min <= cur_min < close_min, pre_closure) diff --git a/tests/unit/runtime/test_market_calendar.py b/tests/unit/runtime/test_market_calendar.py index 5a65cd8..de36594 100644 --- a/tests/unit/runtime/test_market_calendar.py +++ b/tests/unit/runtime/test_market_calendar.py @@ -4,16 +4,63 @@ from datetime import date, datetime from zoneinfo import ZoneInfo from runtime.market_calendar import ( + days_to_next_session, + eod_flatten_status, is_early_close, is_market_holiday, is_regular_hours, is_trading_day, + next_trading_day, session_close_hm, ) ET = ZoneInfo("America/New_York") +def _et(d, h, m): + return datetime(d.year, d.month, d.day, h, m, tzinfo=ET) + + +def test_next_trading_day_skips_holiday_weekend(): + assert next_trading_day(date(2026, 6, 18)) == date(2026, 6, 22) # skip Juneteenth + wknd + assert next_trading_day(date(2026, 6, 16)) == date(2026, 6, 17) # plain weeknight + assert next_trading_day(date(2026, 6, 26)) == date(2026, 6, 29) # Fri -> Mon + + +def test_days_to_next_session(): + assert days_to_next_session(date(2026, 6, 16)) == 1 # Tue -> Wed + assert days_to_next_session(date(2026, 6, 18)) == 4 # Thu -> Mon (Juneteenth) + assert days_to_next_session(date(2026, 6, 26)) == 3 # Fri -> Mon + + +def test_eod_flatten_normal_day_window(): + d = date(2026, 6, 16) # Tue, next session is tomorrow + assert eod_flatten_status(_et(d, 15, 40)) == (False, False) # before window + assert eod_flatten_status(_et(d, 15, 55)) == (True, False) # in [15:55, 16:00) + assert eod_flatten_status(_et(d, 16, 0)) == (False, False) # at/after close + + +def test_eod_flatten_widens_before_holiday(): + d = date(2026, 6, 18) # Thu before the 4-day Juneteenth gap -> pre-closure + # 15:45 is INSIDE the widened [15:40, 16:00) window but OUTSIDE the normal 5-min one + assert eod_flatten_status(_et(d, 15, 45)) == (True, True) + assert eod_flatten_status(_et(d, 15, 45), normal_lead_min=5, pre_closure_lead_min=5)[0] is False + assert eod_flatten_status(_et(d, 15, 30))[0] is False # before even the wide window + + +def test_eod_flatten_fires_on_early_close_halfday(): + # July 2 2026: early close 13:00 AND precedes the July 3 holiday + weekend. + # A fixed 15:55 would NEVER fire (15:55 > 13:00) -> the naked-carry bug. + d = date(2026, 7, 2) + assert eod_flatten_status(_et(d, 12, 45)) == (True, True) # in [12:40, 13:00) + assert eod_flatten_status(_et(d, 15, 55))[0] is False # well after the 1pm close + + +def test_eod_flatten_skips_closed_days(): + assert eod_flatten_status(_et(date(2026, 6, 19), 15, 55)) == (False, False) # Juneteenth + assert eod_flatten_status(_et(date(2026, 6, 20), 15, 55)) == (False, False) # Sat + + def test_juneteenth_2026_is_a_holiday(): # the case that prompted this: Fri Jun 19 2026 is a weekday but NYSE is shut assert date(2026, 6, 19).weekday() < 5