# GENERATED by scripts/generate_market_calendar.py — do not edit. # Source of truth: scripts/market_calendar/ and config/market-calendar-dependency.yaml. """Shared exchange-calendar and timezone contract for cash-equity skills. The module is vendored into each standalone skill by ``scripts/generate_market_calendar.py``. Runtime code must not read repository configuration: packaged skills contain this self-contained module plus an exact runtime dependency declaration. """ from __future__ import annotations from dataclasses import dataclass from datetime import date, datetime, timedelta from typing import Any, Optional from zoneinfo import ZoneInfo class CalendarUnavailableError(RuntimeError): """Raised when an exchange calendar cannot be loaded or queried safely.""" @dataclass(frozen=True) class Session: """One regular cash-equity session in the venue's IANA timezone.""" venue: str session_date: date timezone: str market_open: datetime market_close: datetime break_start: Optional[datetime] = None break_end: Optional[datetime] = None _VENUES = { "XNYS": ("NYSE", "America/New_York"), # pandas-market-calendars exposes NASDAQ as an alias of its NYSE calendar. # Both US cash venues intentionally share this session set. "XNAS": ("NASDAQ", "America/New_York"), "XTKS": ("JPX", "Asia/Tokyo"), "XLON": ("LSE", "Europe/London"), } def supported_venues() -> tuple[str, ...]: """Return supported venue identifiers in deterministic order.""" return tuple(sorted(_VENUES)) def _venue_contract(venue: str) -> tuple[str, str, str]: if not isinstance(venue, str): raise ValueError("venue must be a string") normalized = venue.strip().upper() try: provider_name, timezone_name = _VENUES[normalized] except KeyError as exc: raise ValueError( f"Unsupported venue {venue!r}; expected one of {', '.join(supported_venues())}" ) from exc return normalized, provider_name, timezone_name def _require_date(value: date, *, name: str) -> date: if type(value) is not date: raise TypeError(f"{name} must be datetime.date (not datetime)") return value def require_aware(value: datetime, *, name: str = "timestamp") -> datetime: """Reject naive datetimes at the shared boundary.""" if not isinstance(value, datetime) or value.tzinfo is None: raise ValueError(f"{name} must be a timezone-aware datetime") if value.utcoffset() is None: raise ValueError(f"{name} must have a resolvable UTC offset") return value def parse_aware_datetime(value: str) -> datetime: """Parse an offset-bearing ISO-8601 timestamp, accepting a trailing ``Z``.""" if not isinstance(value, str) or not value.strip(): raise ValueError("as-of must be a non-empty ISO-8601 timestamp") normalized = value.strip() if normalized.endswith(("Z", "z")): normalized = normalized[:-1] + "+00:00" try: parsed = datetime.fromisoformat(normalized) except ValueError as exc: raise ValueError(f"Invalid ISO-8601 as-of timestamp: {value!r}") from exc return require_aware(parsed, name="as-of") def _provider_calendar(venue: str) -> tuple[str, str, Any]: normalized, provider_name, timezone_name = _venue_contract(venue) try: import pandas_market_calendars as market_calendars except (ImportError, TypeError) as exc: raise CalendarUnavailableError( "pandas-market-calendars==5.2.2 is required; install the skill's requirements.txt" ) from exc try: calendar = market_calendars.get_calendar(provider_name) except Exception as exc: raise CalendarUnavailableError( f"Could not load exchange calendar for {normalized} ({provider_name}): {exc}" ) from exc return normalized, timezone_name, calendar def _schedule(venue: str, start_date: date, end_date: date): normalized, timezone_name, calendar = _provider_calendar(venue) try: schedule = calendar.schedule( start_date=start_date, end_date=end_date, tz=timezone_name, market_times="all", ) except Exception as exc: raise CalendarUnavailableError( f"Could not query exchange calendar for {normalized} " f"from {start_date} through {end_date}: {exc}" ) from exc return normalized, timezone_name, schedule def _as_datetime(value: Any, *, name: str) -> Optional[datetime]: if value is None or str(value) == "NaT": return None converter = getattr(value, "to_pydatetime", None) converted = converter() if converter is not None else value if not isinstance(converted, datetime): raise CalendarUnavailableError(f"Calendar returned invalid {name}: {value!r}") return require_aware(converted, name=name) def _session_from_row( venue: str, timezone_name: str, session_date: date, row: Any, ) -> Session: market_open = _as_datetime(row.get("market_open"), name="market_open") market_close = _as_datetime(row.get("market_close"), name="market_close") if market_open is None or market_close is None: raise CalendarUnavailableError( f"Calendar returned an incomplete session for {venue} on {session_date}" ) break_start = _as_datetime(row.get("break_start"), name="break_start") break_end = _as_datetime(row.get("break_end"), name="break_end") if (break_start is None) != (break_end is None): raise CalendarUnavailableError( f"Calendar returned an incomplete break for {venue} on {session_date}" ) return Session( venue=venue, session_date=session_date, timezone=timezone_name, market_open=market_open, market_close=market_close, break_start=break_start, break_end=break_end, ) def session_for_date(venue: str, session_date: date) -> Optional[Session]: """Return a session, or ``None`` for a valid holiday/weekend.""" session_date = _require_date(session_date, name="session_date") normalized, timezone_name, schedule = _schedule(venue, session_date, session_date) if schedule.empty: return None return _session_from_row(normalized, timezone_name, session_date, schedule.iloc[0]) def is_open_at(venue: str, timestamp: datetime) -> bool: """Return whether ``timestamp`` is inside regular trading hours.""" timestamp = require_aware(timestamp) normalized, _, timezone_name = _venue_contract(venue) local = timestamp.astimezone(ZoneInfo(timezone_name)) session = session_for_date(normalized, local.date()) if session is None: return False if not (session.market_open <= local < session.market_close): return False if session.break_start is not None and session.break_end is not None: if session.break_start <= local < session.break_end: return False return True def next_session_on_or_after( venue: str, start_date: date, *, max_search_days: int = 370, ) -> Session: """Return the first session on/after ``start_date`` within a finite horizon.""" start_date = _require_date(start_date, name="start_date") if not isinstance(max_search_days, int) or isinstance(max_search_days, bool): raise TypeError("max_search_days must be an integer") if max_search_days < 0: raise ValueError("max_search_days must be non-negative") end_date = start_date + timedelta(days=max_search_days) normalized, timezone_name, schedule = _schedule(venue, start_date, end_date) if schedule.empty: raise CalendarUnavailableError( f"No {normalized} session found within {max_search_days} days of {start_date}" ) label = schedule.index[0] label_date = label.date() if hasattr(label, "date") else date.fromisoformat(str(label)[:10]) return _session_from_row(normalized, timezone_name, label_date, schedule.iloc[0]) def next_session_after( venue: str, session_date: date, *, max_search_days: int = 370, ) -> Session: """Return the first session strictly after ``session_date``.""" session_date = _require_date(session_date, name="session_date") if max_search_days <= 0: raise ValueError("max_search_days must be positive") return next_session_on_or_after( venue, session_date + timedelta(days=1), max_search_days=max_search_days - 1, ) def previous_session_on_or_before( venue: str, end_date: date, *, max_search_days: int = 370, ) -> Session: """Return the last session on/before ``end_date`` within a finite horizon.""" end_date = _require_date(end_date, name="end_date") if not isinstance(max_search_days, int) or isinstance(max_search_days, bool): raise TypeError("max_search_days must be an integer") if max_search_days < 0: raise ValueError("max_search_days must be non-negative") start_date = end_date - timedelta(days=max_search_days) normalized, timezone_name, schedule = _schedule(venue, start_date, end_date) if schedule.empty: raise CalendarUnavailableError( f"No {normalized} session found within {max_search_days} days of {end_date}" ) label = schedule.index[-1] label_date = label.date() if hasattr(label, "date") else date.fromisoformat(str(label)[:10]) return _session_from_row(normalized, timezone_name, label_date, schedule.iloc[-1]) def previous_session_before( venue: str, session_date: date, *, max_search_days: int = 370, ) -> Session: """Return the last session strictly before ``session_date``.""" session_date = _require_date(session_date, name="session_date") if not isinstance(max_search_days, int) or isinstance(max_search_days, bool): raise TypeError("max_search_days must be an integer") if max_search_days <= 0: raise ValueError("max_search_days must be positive") return previous_session_on_or_before( venue, session_date - timedelta(days=1), max_search_days=max_search_days - 1, ) def current_or_next_session(venue: str, timestamp: datetime) -> Session: """Return today's not-yet-closed session, otherwise the next session.""" timestamp = require_aware(timestamp) normalized, _, timezone_name = _venue_contract(venue) local = timestamp.astimezone(ZoneInfo(timezone_name)) session = session_for_date(normalized, local.date()) if session is not None and local < session.market_close: return session return next_session_after(normalized, local.date()) def count_sessions( venue: str, start_date: date, end_date: date, *, include_start: bool, include_end: bool, ) -> int: """Count sessions with explicit date-boundary inclusion semantics.""" start_date = _require_date(start_date, name="start_date") end_date = _require_date(end_date, name="end_date") if start_date > end_date: raise ValueError("start_date must be on or before end_date") first = start_date if include_start else start_date + timedelta(days=1) last = end_date if include_end else end_date - timedelta(days=1) if first > last: return 0 _, _, schedule = _schedule(venue, first, last) return int(len(schedule.index))