You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

151 lines
5.1 KiB
Python

"""
Trading-session window math (NYSE / XNYS).
`session_window(date, "premarket"|"intraday"|"post"|"full_session")` returns
the (start_utc, end_utc) bounds the news aggregator filters on. NYSE holidays
and short-day closes (1pm ET on day-after-Thanksgiving etc.) are handled by
pandas_market_calendars when available; falls back to weekday-only logic if
the dependency is missing.
"""
from __future__ import annotations
import logging
from datetime import date, datetime, time, timedelta, timezone
from functools import lru_cache
from typing import Literal
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__)
ET = ZoneInfo("America/New_York")
WindowName = Literal["premarket", "intraday", "post", "full_session"]
# Default ET clock-time bounds. Short days (early close) override regular_close
# via the market-calendar lookup below. `post` window ends at the *next*
# trading day's premarket start (04:00 ET) to avoid overlap.
_DEFAULT_PREMARKET_START_T = time(4, 0)
_DEFAULT_REGULAR_OPEN_T = time(9, 30)
_DEFAULT_REGULAR_CLOSE_T = time(16, 0)
def session_window(session_date: date, window: WindowName) -> tuple[datetime, datetime]:
"""Return UTC bounds for the given window on the given ET session date."""
if not _is_trading_day(session_date):
raise ValueError(
f"{session_date} is not a NYSE trading day; choose the next/prev session"
)
open_dt_et, close_dt_et = _session_bounds_et(session_date)
prev_close_dt_et = _prev_session_close_et(session_date)
next_premarket_start_et = _next_session_premarket_start_et(session_date)
if window == "premarket":
start_et = prev_close_dt_et
end_et = open_dt_et
elif window == "intraday":
start_et = open_dt_et
end_et = close_dt_et
elif window == "post":
# Post-market ends at next session's premarket start (04:00 ET) to avoid
# overlap with the next session's `premarket` window.
start_et = close_dt_et
end_et = next_premarket_start_et
elif window == "full_session":
start_et = prev_close_dt_et
end_et = next_premarket_start_et
else:
raise ValueError(f"Unknown window: {window}")
return _to_utc(start_et), _to_utc(end_et)
def _to_utc(dt: datetime) -> datetime:
if dt.tzinfo is None:
dt = dt.replace(tzinfo=ET)
return dt.astimezone(timezone.utc)
# ---------------------------------------------------------------------------
# Market-calendar lookups (cached)
# ---------------------------------------------------------------------------
@lru_cache(maxsize=1)
def _calendar():
try:
import pandas_market_calendars as mcal # type: ignore
return mcal.get_calendar("XNYS")
except Exception as e:
logger.warning(
"pandas_market_calendars not available (%s); falling back to weekday-only session logic",
e,
)
return None
def _is_trading_day(d: date) -> bool:
cal = _calendar()
if cal is None:
return d.weekday() < 5
schedule = cal.schedule(start_date=d, end_date=d)
return not schedule.empty
def _session_bounds_et(d: date) -> tuple[datetime, datetime]:
"""Regular open/close in ET. Honors short days when calendar is available."""
cal = _calendar()
if cal is not None:
schedule = cal.schedule(start_date=d, end_date=d)
if not schedule.empty:
row = schedule.iloc[0]
open_utc = row["market_open"].to_pydatetime()
close_utc = row["market_close"].to_pydatetime()
return open_utc.astimezone(ET), close_utc.astimezone(ET)
return (
datetime.combine(d, _DEFAULT_REGULAR_OPEN_T, tzinfo=ET),
datetime.combine(d, _DEFAULT_REGULAR_CLOSE_T, tzinfo=ET),
)
def _prev_session_close_et(d: date) -> datetime:
prev = _prev_trading_day(d)
_, close_dt_et = _session_bounds_et(prev)
return close_dt_et
def _next_session_open_et(d: date) -> datetime:
nxt = _next_trading_day(d)
open_dt_et, _ = _session_bounds_et(nxt)
return open_dt_et
def _next_session_premarket_start_et(d: date) -> datetime:
"""Next trading day's premarket window start (04:00 ET)."""
nxt = _next_trading_day(d)
return datetime.combine(nxt, _DEFAULT_PREMARKET_START_T, tzinfo=ET)
def _prev_trading_day(d: date, max_lookback: int = 10) -> date:
cal = _calendar()
if cal is not None:
schedule = cal.schedule(start_date=d - timedelta(days=max_lookback), end_date=d - timedelta(days=1))
if not schedule.empty:
return schedule.index[-1].date()
candidate = d - timedelta(days=1)
while candidate.weekday() >= 5:
candidate -= timedelta(days=1)
return candidate
def _next_trading_day(d: date, max_lookahead: int = 10) -> date:
cal = _calendar()
if cal is not None:
schedule = cal.schedule(start_date=d + timedelta(days=1), end_date=d + timedelta(days=max_lookahead))
if not schedule.empty:
return schedule.index[0].date()
candidate = d + timedelta(days=1)
while candidate.weekday() >= 5:
candidate += timedelta(days=1)
return candidate