"""Point-in-time dividend calendar helpers for leakage-safe ex-div scheduling.""" from __future__ import annotations import datetime as dt from bisect import bisect_right from dataclasses import dataclass from functools import lru_cache from pathlib import Path from typing import Any, Iterable import pyarrow.parquet as pq from libs.common.logging import get_logger logger = get_logger(__name__) @dataclass(frozen=True) class DividendCalendarEntry: symbol: str as_of_date: dt.date ex_dividend_date: dt.date amount: float declaration_date: dt.date | None = None record_date: dt.date | None = None payment_date: dt.date | None = None source: str | None = None def _coerce_date(value: Any) -> dt.date | None: if value is None: return None if isinstance(value, dt.datetime): return value.date() if isinstance(value, dt.date): return value text = str(value).strip() if not text: return None try: return dt.date.fromisoformat(text[:10]) except ValueError: return None def _coerce_float(value: Any) -> float | None: if value is None: return None try: return float(value) except (TypeError, ValueError): return None class PointInTimeDividendCalendar: """Latest-known dividend schedule for each symbol as of a prior date.""" def __init__(self, entries: Iterable[DividendCalendarEntry]) -> None: grouped: dict[str, list[DividendCalendarEntry]] = {} for entry in entries: grouped.setdefault(entry.symbol, []).append(entry) self._entries_by_symbol: dict[str, tuple[list[dt.date], list[DividendCalendarEntry]]] = {} for symbol, symbol_entries in grouped.items(): symbol_entries.sort(key=lambda entry: (entry.as_of_date, entry.ex_dividend_date)) as_of_dates = [entry.as_of_date for entry in symbol_entries] self._entries_by_symbol[symbol] = (as_of_dates, symbol_entries) @classmethod def from_parquet(cls, path: Path) -> PointInTimeDividendCalendar: table = pq.read_table(str(path)) rows = table.to_pylist() entries: list[DividendCalendarEntry] = [] for row in rows: symbol = str(row.get("symbol") or row.get("ticker") or "").strip().upper() if not symbol: continue as_of_date = _coerce_date( row.get("as_of_date") or row.get("declaration_date") or row.get("known_as_of_date") ) ex_dividend_date = _coerce_date( row.get("ex_dividend_date") or row.get("ex_date") ) amount = _coerce_float(row.get("amount") or row.get("dividend_amount")) if as_of_date is None or ex_dividend_date is None or amount is None: continue entries.append( DividendCalendarEntry( symbol=symbol, as_of_date=as_of_date, ex_dividend_date=ex_dividend_date, amount=amount, declaration_date=_coerce_date(row.get("declaration_date")), record_date=_coerce_date(row.get("record_date")), payment_date=_coerce_date(row.get("payment_date")), source=str(row.get("source")) if row.get("source") is not None else None, ) ) logger.info( "pit_dividend_calendar_loaded", path=str(path), rows=len(entries), symbols=len({entry.symbol for entry in entries}), ) return cls(entries) def get_known_upcoming_ex_dividends( self, as_of_date: dt.date, allowed_ex_dates: Iterable[dt.date], symbols: Iterable[str] | None = None, ) -> dict[str, DividendCalendarEntry]: allowed = set(allowed_ex_dates) if not allowed: return {} symbol_filter = { str(symbol).strip().upper() for symbol in (symbols or []) if str(symbol).strip() } result: dict[str, DividendCalendarEntry] = {} for symbol, (as_of_dates, entries) in self._entries_by_symbol.items(): if symbol_filter and symbol not in symbol_filter: continue idx = bisect_right(as_of_dates, as_of_date) - 1 if idx < 0: continue entry = entries[idx] if entry.ex_dividend_date in allowed: result[symbol] = entry return result @lru_cache(maxsize=8) def load_pit_dividend_calendar(path_str: str) -> PointInTimeDividendCalendar | None: path = Path(path_str) if not path.exists(): logger.info("pit_dividend_calendar_missing", path=str(path)) return None return PointInTimeDividendCalendar.from_parquet(path) __all__ = [ "DividendCalendarEntry", "PointInTimeDividendCalendar", "load_pit_dividend_calendar", ]