Files
momentum devandClaude Opus 4.8 16715369c0 EOD flatten: close-relative + holiday-aware window (no naked carry over closures)
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 <noreply@anthropic.com>
2026-06-22 11:45:19 -04:00

112 lines
4.4 KiB
Python

"""NYSE trading-calendar helpers — no external dependency.
``pandas_market_calendars`` isn't installed, and the live loop previously gated
trading on ``weekday() < 5`` alone. That treated market holidays as normal
sessions: on a full closure the EOD flatten would still cancel the protective
bracket legs (it runs in the 15:50-16:00 window) and then fail to fill the
market sell (exchange closed), leaving positions NAKED over the holiday — the
exact naked-stop failure class we already fixed elsewhere.
Dates are hardcoded NYSE full-day closures + early closes (1pm). Verified for
2026 against the NYSE 2026 holiday calendar; 2027 is computed from the same
rules and should be re-confirmed when the year is current. Update annually.
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
_ET = ZoneInfo("America/New_York")
# Full-day market closures (exchange shut).
_HOLIDAYS: frozenset[date] = frozenset({
# 2026 — confirmed
date(2026, 1, 1), date(2026, 1, 19), date(2026, 2, 16), date(2026, 4, 3),
date(2026, 5, 25), date(2026, 6, 19), date(2026, 7, 3), date(2026, 9, 7),
date(2026, 11, 26), date(2026, 12, 25),
# 2027 — computed from NYSE rules, re-confirm when current
date(2027, 1, 1), date(2027, 1, 18), date(2027, 2, 15), date(2027, 3, 26),
date(2027, 5, 31), date(2027, 6, 18), date(2027, 7, 5), date(2027, 9, 6),
date(2027, 11, 25), date(2027, 12, 24),
})
# Early-close days: regular session ends 13:00 ET instead of 16:00.
_EARLY_CLOSE: frozenset[date] = frozenset({
date(2026, 7, 2), date(2026, 11, 27), date(2026, 12, 24),
date(2027, 11, 26),
})
def is_market_holiday(d: date) -> bool:
"""True if the exchange is fully closed on this calendar date."""
return d in _HOLIDAYS
def is_early_close(d: date) -> bool:
"""True if this is a 1pm-ET early-close session."""
return d in _EARLY_CLOSE
def is_trading_day(d: date) -> bool:
"""A regular weekday that isn't a full-closure holiday."""
return d.weekday() < 5 and d not in _HOLIDAYS
def session_close_hm(d: date) -> tuple[int, int] | None:
"""(hour, minute) ET of the regular-session close, or None if not a
trading day. 13:00 on early-close days, else 16:00."""
if not is_trading_day(d):
return None
return (13, 0) if d in _EARLY_CLOSE else (16, 0)
def is_regular_hours(now_et: datetime | None = None) -> bool:
"""True iff a regular trading session is open right now (ET), honouring
weekends, full closures, and early closes."""
n = now_et or datetime.now(_ET)
close = session_close_hm(n.date())
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)