You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
150 lines
4.9 KiB
Python
150 lines
4.9 KiB
Python
"""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",
|
|
]
|