"""UTC/Eastern conversion and NYSE calendar helpers.""" from __future__ import annotations import datetime as dt from zoneinfo import ZoneInfo import exchange_calendars as xcals _EASTERN = ZoneInfo("America/New_York") _UTC = ZoneInfo("UTC") _XNYS = None def _get_xnys() -> xcals.ExchangeCalendar: global _XNYS if _XNYS is None: _XNYS = xcals.get_calendar("XNYS") return _XNYS def utc_now() -> dt.datetime: return dt.datetime.now(tz=_UTC) def to_eastern(d: dt.datetime) -> dt.datetime: if d.tzinfo is None: raise ValueError("Naive datetime rejected; must be timezone-aware.") return d.astimezone(_EASTERN) def to_utc(d: dt.datetime) -> dt.datetime: if d.tzinfo is None: raise ValueError("Naive datetime rejected; must be timezone-aware.") return d.astimezone(_UTC) def is_trading_day(date: dt.date) -> bool: cal = _get_xnys() return cal.is_session(date.isoformat()) def previous_trading_day(date: dt.date) -> dt.date: cal = _get_xnys() idx = cal.sessions.get_loc(date.isoformat()) if date.isoformat() in cal.sessions else None if idx is None: # Find previous session prev = cal.previous_session(date.isoformat()) return prev.date() if idx > 0: return cal.sessions[idx - 1].date() raise ValueError(f"No previous trading day before {date}") def next_trading_day(date: dt.date) -> dt.date: cal = _get_xnys() return cal.next_session(date.isoformat()).date() def filing_time_bucket(filed_at: dt.datetime, event_date: dt.date | None = None) -> str: """Classify filing time as pre_market, regular_hours, post_market, or unknown. Cross-midnight fix: if the filing was accepted after midnight ET but event_date is the prior calendar day, the news was released post-market on event_date (common for companies filing 8-Ks after close that get SEC-accepted 00:00–09:30 ET the next morning). """ if filed_at.tzinfo is None: return "unknown" eastern = to_eastern(filed_at) # Cross-midnight: SEC accepted on the calendar day after event_date → post_market of event_date if event_date is not None and eastern.date() > event_date: return "post_market" hour = eastern.hour minute = eastern.minute total_minutes = hour * 60 + minute # Pre-market: before 9:30 ET if total_minutes < 9 * 60 + 30: return "pre_market" # Regular hours: 9:30–16:00 ET if total_minutes <= 16 * 60: return "regular_hours" # Post-market: after 16:00 ET return "post_market" def trading_days_between(start: dt.date, end: dt.date) -> list[dt.date]: cal = _get_xnys() sessions = cal.sessions_in_range(start.isoformat(), end.isoformat()) return [s.date() for s in sessions]