The live loop gated trading + EOD flatten on weekday()<5 only, so a market HOLIDAY read as a normal session. On a full closure the EOD flatten (15:55-16:00 window) would cancel the protective bracket/stop legs and then fail to fill the market sell (exchange shut) — leaving positions NAKED over the holiday weekend. Tomorrow is Juneteenth (Fri 2026-06-19, NYSE closed), with 3 open positions. - runtime/market_calendar.py: NYSE full closures + early closes (no external dep), is_trading_day / is_regular_hours / session_close_hm. 2026 confirmed. - _is_rth() now uses is_regular_hours() (holiday + early-close aware). - EOD flatten skips when session_close_hm() is None (weekend/holiday) and uses the real close (13:00 on early-close days) — so it only flattens into an OPEN market that can actually fill the sell, never orphaning the stops. tests/unit/runtime/test_market_calendar.py: 6 passed. Verified Juneteenth -> not a trading day -> EOD skipped -> the 3 GTC stops stay in place. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
72 lines
2.7 KiB
Python
72 lines
2.7 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
|
|
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
|