Files
momentum-system/runtime/market_calendar.py
momentum devandClaude Opus 4.8 1acf77adb1 Holiday-aware market calendar: don't strip stops on closed days
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>
2026-06-18 23:10:07 -04:00

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