1"""XNYS session calendar on a dense regular-hours minute grid.23Conventions (verified against FinData on 2026-11-02, docs/PHASE_0_REPORT.md):4- bars are stamped at bar OPEN in UTC; the bar opening at 09:30 ET is complete at 09:31 ET;5- regular session = 09:30 .. 16:00 ET inclusive -> slots 0..390 (391 bars); the slot 390 bar is the 16:00 closing6 print, treated separately (never a forecast target, never a feature bar for same-day context);7- early close at 13:00 ET -> slots 0..210 (211 bars); slot 210 is the closing print;8- DST handled through zoneinfo via exchange_calendars (opens/closes are tz-aware UTC timestamps).9"""10from __future__ import annotations1112from dataclasses import dataclass13from functools import lru_cache1415import numpy as np16import pandas as pd1718REGULAR_SLOTS = 391 # 09:30 .. 16:00 inclusive19LAST_TRADING_SLOT = 389 # 15:59 bar (last bar that can be a target)20EARLY_CLOSE_SLOTS = 211212223@dataclass(frozen=True)24class Session:25 date: pd.Timestamp # session date (naive, ET calendar date)26 open_utc: pd.Timestamp # open instant (09:30 ET) as UTC27 close_utc: pd.Timestamp # close instant (16:00 or 13:00 ET) as UTC, == open of the closing print bar28 n_slots: int # 391 or 21129 early_close: bool3031 def slot_of(self, ts_utc: pd.Timestamp) -> int:32 """Slot index of a bar-open UTC timestamp, or -1 if outside the regular session grid."""33 k = int((ts_utc - self.open_utc).total_seconds() // 60)34 if k < 0 or k >= self.n_slots or (ts_utc - self.open_utc).total_seconds() % 60 != 0:35 return -136 return k373839@lru_cache(maxsize=4)40def _xnys():41 import exchange_calendars as xc42 return xc.get_calendar("XNYS")434445def sessions(start: str, end: str) -> list[Session]:46 cal = _xnys()47 sched = cal.schedule.loc[pd.Timestamp(start):pd.Timestamp(end)]48 out = []49 for date, row in sched.iterrows():50 o = pd.Timestamp(row["open"]).tz_convert("UTC")51 c = pd.Timestamp(row["close"]).tz_convert("UTC")52 n = int((c - o).total_seconds() // 60) + 153 out.append(Session(pd.Timestamp(date).tz_localize(None), o, c, n, n != REGULAR_SLOTS))54 return out555657def session_frame(start: str, end: str) -> pd.DataFrame:58 rows = [{"session_date": s.date.date(), "open_utc": s.open_utc, "close_utc": s.close_utc, "n_slots": s.n_slots,59 "early_close": s.early_close} for s in sessions(start, end)]60 return pd.DataFrame(rows)616263def slot_index(ts_utc: np.ndarray, open_utc: np.ndarray, n_slots: np.ndarray) -> np.ndarray:64 """Vectorised slot index (-1 outside the regular grid). All arrays are datetime64[ns]/int aligned per row."""65 delta = (ts_utc.astype("datetime64[s]") - open_utc.astype("datetime64[s]")).astype("int64")66 k = delta // 6067 ok = (delta >= 0) & (delta % 60 == 0) & (k < n_slots)68 return np.where(ok, k, -1).astype(np.int64)697071def minute_of_session_features(slot: np.ndarray, n_slots: int) -> dict[str, np.ndarray]:72 """Calendar features that are known at origin: normalised minute of session and time to close."""73 s = slot.astype(np.float64)74 return {"minute_frac": s / (REGULAR_SLOTS - 1), "time_to_close": (n_slots - 1 - s) / (REGULAR_SLOTS - 1)}75