|
|
"""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]
|