Initial commit
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
"""Exchange-calendar wrapper: US/KRX sessions, close times and briefing schedule.
|
||||
|
||||
All date arithmetic goes through `exchange_calendars` (XNYS / XKRX) and `zoneinfo`; no UTC
|
||||
offsets are hardcoded, so DST switches, holidays and early closes are handled automatically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from datetime import date, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import exchange_calendars as xc
|
||||
import pandas as pd
|
||||
|
||||
from us2kr.config import ET, KST
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
ET_TZ = ZoneInfo(ET)
|
||||
KST_TZ = ZoneInfo(KST)
|
||||
|
||||
_US_CAL = "XNYS"
|
||||
_KR_CAL = "XKRX"
|
||||
|
||||
# KRX closures announced by KRX / the Korean government but missing from the pinned exchange_calendars
|
||||
# data (4.13.2). Election days and 임시공휴일 are decided after a library release, so the data lags.
|
||||
# Drop an entry once a release marks it as a holiday (the override is harmless while both agree).
|
||||
KNOWN_KRX_CLOSURES: frozenset[date] = frozenset(
|
||||
{
|
||||
date(2025, 10, 10), # 임시공휴일 (추석 연휴 연장)
|
||||
date(2026, 6, 3), # 제9회 전국동시지방선거
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class MarketClock:
|
||||
"""Session / briefing-time calculator backed by XNYS and XKRX calendars.
|
||||
|
||||
Calendars are built once for a window around `anchor` (default: today) and rebuilt
|
||||
transparently if a query falls outside that window (long-running daemon safety).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
run_offset_minutes: int = 75,
|
||||
*,
|
||||
anchor: date | None = None,
|
||||
years_back: int = 2,
|
||||
years_ahead: int = 2,
|
||||
extra_krx_holidays: Iterable[date] | None = None,
|
||||
) -> None:
|
||||
if run_offset_minutes < 0:
|
||||
raise ValueError("run_offset_minutes must be >= 0")
|
||||
self.run_offset = timedelta(minutes=run_offset_minutes)
|
||||
self._years_back = years_back
|
||||
self._years_ahead = years_ahead
|
||||
# Ad-hoc KRX closures on top of the library data (KNOWN_KRX_CLOSURES + operator-supplied dates).
|
||||
self._krx_closures: frozenset[date] = KNOWN_KRX_CLOSURES | frozenset(extra_krx_holidays or ())
|
||||
self._build(anchor or datetime.now(tz=KST_TZ).date())
|
||||
|
||||
# ------------------------------------------------------------------ calendars
|
||||
|
||||
# Pad the requested range so Jan 1 .. Dec 31 of the window always lie strictly inside
|
||||
# [first_session, last_session] of both calendars (no exchange closes > ~5 days in a row).
|
||||
_PAD = timedelta(days=10)
|
||||
|
||||
def _build(self, anchor: date) -> None:
|
||||
start = date(anchor.year - self._years_back, 1, 1) - self._PAD
|
||||
end = date(anchor.year + self._years_ahead, 12, 31) + self._PAD
|
||||
self._xnys = xc.get_calendar(_US_CAL, start=start, end=end)
|
||||
self._xkrx = xc.get_calendar(_KR_CAL, start=start, end=end)
|
||||
# Bounds common to both calendars (sessions are tz-naive Timestamps in v4).
|
||||
self._first = max(self._xnys.first_session, self._xkrx.first_session).date()
|
||||
self._last = min(self._xnys.last_session, self._xkrx.last_session).date()
|
||||
log.debug("calendars built: %s .. %s", self._first, self._last)
|
||||
|
||||
@staticmethod
|
||||
def _as_date(d: date | datetime) -> date:
|
||||
return d.date() if isinstance(d, datetime) else d
|
||||
|
||||
def _ts(self, d: date | datetime) -> pd.Timestamp:
|
||||
"""Normalise to a tz-naive midnight Timestamp inside the calendar range.
|
||||
|
||||
May rebuild `self._xnys` / `self._xkrx`, so callers must evaluate this BEFORE
|
||||
binding a calendar method (`ts = self._ts(d); self._xnys.f(ts)`).
|
||||
"""
|
||||
d = self._as_date(d)
|
||||
if not (self._first <= d <= self._last):
|
||||
log.info("date %s outside calendar window %s..%s; rebuilding", d, self._first, self._last)
|
||||
self._build(d)
|
||||
return pd.Timestamp(d)
|
||||
|
||||
@staticmethod
|
||||
def _require_aware(now: datetime) -> datetime:
|
||||
if now.tzinfo is None or now.utcoffset() is None:
|
||||
raise ValueError("`now` must be a tz-aware datetime")
|
||||
return now
|
||||
|
||||
# ------------------------------------------------------------------ sessions
|
||||
|
||||
def is_us_session(self, d: date) -> bool:
|
||||
"""True if XNYS trades on calendar date `d`."""
|
||||
ts = self._ts(d)
|
||||
return bool(self._xnys.is_session(ts))
|
||||
|
||||
def is_krx_session(self, d: date) -> bool:
|
||||
"""True if KRX trades on calendar date `d` (library data + known ad-hoc closures)."""
|
||||
if self._as_date(d) in self._krx_closures:
|
||||
return False
|
||||
ts = self._ts(d)
|
||||
return bool(self._xkrx.is_session(ts))
|
||||
|
||||
def next_krx_session(self, on_or_after: date) -> date:
|
||||
"""Earliest KRX session on or after `on_or_after` (ad-hoc closures respected)."""
|
||||
d = self._as_date(on_or_after)
|
||||
while True:
|
||||
ts = self._ts(d)
|
||||
d = self._xkrx.date_to_session(ts, direction="next").date()
|
||||
if d not in self._krx_closures:
|
||||
return d
|
||||
d += timedelta(days=1)
|
||||
|
||||
def us_session_close(self, session: date) -> datetime:
|
||||
"""Regular-session close as tz-aware America/New_York datetime (early closes respected)."""
|
||||
ts = self._ts(session)
|
||||
if not self._xnys.is_session(ts):
|
||||
raise ValueError(f"{self._as_date(session)} is not an XNYS session")
|
||||
close_utc: pd.Timestamp = self._xnys.session_close(ts)
|
||||
return close_utc.to_pydatetime().astimezone(ET_TZ)
|
||||
|
||||
def previous_us_session(self, before: date) -> date:
|
||||
"""Latest US session strictly before `before`."""
|
||||
ts = self._ts(self._as_date(before) - timedelta(days=1))
|
||||
return self._xnys.date_to_session(ts, direction="previous").date()
|
||||
|
||||
def next_us_session(self, after: date) -> date:
|
||||
"""Earliest US session strictly after `after`."""
|
||||
ts = self._ts(self._as_date(after) + timedelta(days=1))
|
||||
return self._xnys.date_to_session(ts, direction="next").date()
|
||||
|
||||
def latest_completed_session(self, now: datetime) -> date:
|
||||
"""Latest US session whose regular close is <= `now`."""
|
||||
now = self._require_aware(now)
|
||||
today_et = now.astimezone(ET_TZ).date()
|
||||
ts = self._ts(today_et)
|
||||
candidate = self._xnys.date_to_session(ts, direction="previous").date()
|
||||
while self.us_session_close(candidate) > now:
|
||||
candidate = self.previous_us_session(candidate)
|
||||
return candidate
|
||||
|
||||
# ------------------------------------------------------------------ briefing schedule
|
||||
|
||||
def briefing_time_for(self, session: date) -> datetime:
|
||||
"""Briefing run time = session close + run offset, expressed in Asia/Seoul."""
|
||||
return (self.us_session_close(session) + self.run_offset).astimezone(KST_TZ)
|
||||
|
||||
def krx_date_for(self, session: date) -> date:
|
||||
"""Korean calendar date on which the briefing for `session` is delivered."""
|
||||
return self.briefing_time_for(session).date()
|
||||
|
||||
def next_briefing(self, now: datetime) -> tuple[date, datetime]:
|
||||
"""(session, run_at KST) for the earliest briefing strictly after `now`."""
|
||||
now = self._require_aware(now)
|
||||
today_et = now.astimezone(ET_TZ).date()
|
||||
session = self.previous_us_session(today_et)
|
||||
while self.briefing_time_for(session) <= now:
|
||||
session = self.next_us_session(session)
|
||||
return session, self.briefing_time_for(session)
|
||||
|
||||
def catchup_candidate(self, now: datetime, window_hours: int) -> date | None:
|
||||
"""Session whose briefing time lies in (now - window, now]; None when nothing was missed."""
|
||||
now = self._require_aware(now)
|
||||
window = timedelta(hours=window_hours)
|
||||
session = self.latest_completed_session(now)
|
||||
while True:
|
||||
run_at = self.briefing_time_for(session)
|
||||
if run_at <= now:
|
||||
return session if run_at > now - window else None
|
||||
session = self.previous_us_session(session)
|
||||
Reference in New Issue
Block a user