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.

179 lines
6.2 KiB
Python

"""Point-in-time 13D/13G ownership filing helpers for residual idle-cash sleeves."""
from __future__ import annotations
import datetime as dt
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 OwnershipFilingEntry:
symbol: str
filing_date: dt.date
as_of_date: dt.date
form_type: str
owner_name: str | None
owner_key: str | None
percent_owned: float | None
aggregate_shares: float | None
purpose_text: str | None
purpose_housekeeping_flag: bool
activist_flag: bool
is_amendment: bool
prior_percent_owned: float | None
percent_delta_points: float | None
prior_form_group: str | None
is_initial_for_owner: bool
is_13g_to_13d_transition: bool
ownership_strength_score: int | 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
def _coerce_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
if value is None:
return False
text = str(value).strip().lower()
return text in {"1", "true", "t", "yes", "y"}
class PointInTimeOwnershipCalendar:
"""Latest-known 13D/13G ownership events keyed by filing date."""
def __init__(self, entries: Iterable[OwnershipFilingEntry]) -> None:
grouped: dict[dt.date, list[OwnershipFilingEntry]] = {}
for entry in entries:
grouped.setdefault(entry.filing_date, []).append(entry)
self._entries_by_filing_date = {
filing_date: tuple(
sorted(
filing_entries,
key=lambda item: (
item.symbol,
-(item.percent_delta_points or 0.0),
-(item.percent_owned or 0.0),
not item.activist_flag,
),
)
)
for filing_date, filing_entries in grouped.items()
}
@classmethod
def from_parquet(cls, path: Path) -> PointInTimeOwnershipCalendar:
table = pq.read_table(str(path))
rows = table.to_pylist()
entries: list[OwnershipFilingEntry] = []
for row in rows:
symbol = str(row.get("symbol") or "").strip().upper()
filing_date = _coerce_date(row.get("filing_date"))
as_of_date = _coerce_date(row.get("as_of_date") or row.get("filing_date"))
form_type = str(row.get("form_type") or "").strip().upper()
if not symbol or filing_date is None or as_of_date is None or not form_type:
continue
entries.append(
OwnershipFilingEntry(
symbol=symbol,
filing_date=filing_date,
as_of_date=as_of_date,
form_type=form_type,
owner_name=str(row.get("owner_name") or "").strip() or None,
owner_key=str(row.get("owner_key") or "").strip().lower() or None,
percent_owned=_coerce_float(row.get("percent_owned")),
aggregate_shares=_coerce_float(row.get("aggregate_shares")),
purpose_text=str(row.get("purpose_text") or "").strip() or None,
purpose_housekeeping_flag=_coerce_bool(row.get("purpose_housekeeping_flag")),
activist_flag=_coerce_bool(row.get("activist_flag")),
is_amendment=_coerce_bool(row.get("is_amendment")),
prior_percent_owned=_coerce_float(row.get("prior_percent_owned")),
percent_delta_points=_coerce_float(row.get("percent_delta_points")),
prior_form_group=str(row.get("prior_form_group") or "").strip().upper() or None,
is_initial_for_owner=_coerce_bool(row.get("is_initial_for_owner")),
is_13g_to_13d_transition=_coerce_bool(row.get("is_13g_to_13d_transition")),
ownership_strength_score=(
int(row.get("ownership_strength_score"))
if row.get("ownership_strength_score") is not None
else None
),
)
)
logger.info(
"pit_ownership_calendar_loaded",
path=str(path),
rows=len(entries),
filing_dates=len({entry.filing_date for entry in entries}),
symbols=len({entry.symbol for entry in entries}),
)
return cls(entries)
def get_events_between(
self,
*,
start_filing_date: dt.date,
end_filing_date: dt.date,
symbols: Iterable[str] | None = None,
) -> list[OwnershipFilingEntry]:
symbol_filter = {
str(symbol).strip().upper()
for symbol in (symbols or [])
if str(symbol).strip()
}
rows: list[OwnershipFilingEntry] = []
current = start_filing_date
while current <= end_filing_date:
for entry in self._entries_by_filing_date.get(current, ()):
if symbol_filter and entry.symbol not in symbol_filter:
continue
rows.append(entry)
current += dt.timedelta(days=1)
return rows
@lru_cache(maxsize=8)
def load_pit_ownership_calendar(path_str: str) -> PointInTimeOwnershipCalendar | None:
path = Path(path_str)
if not path.exists():
logger.info("pit_ownership_calendar_missing", path=str(path))
return None
return PointInTimeOwnershipCalendar.from_parquet(path)
__all__ = [
"OwnershipFilingEntry",
"PointInTimeOwnershipCalendar",
"load_pit_ownership_calendar",
]