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
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",
|
|
]
|