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.
198 lines
6.8 KiB
Python
198 lines
6.8 KiB
Python
"""Point-in-time Form 4 cluster helpers for leakage-safe residual 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 Form4ClusterEntry:
|
|
symbol: str
|
|
filing_date: dt.date
|
|
as_of_date: dt.date
|
|
total_value: float
|
|
owner_count: int
|
|
transaction_count: int
|
|
event_day_count: int
|
|
max_purchase_pct: float
|
|
median_purchase_pct: float
|
|
weighted_purchase_pct: float
|
|
max_lag_days: int | None = None
|
|
min_lag_days: int | None = None
|
|
transaction_span_days: int = 0
|
|
officer_count: int = 0
|
|
director_count: int = 0
|
|
ten_percent_owner_count: int = 0
|
|
other_role_count: int = 0
|
|
ceo_count: int = 0
|
|
cfo_count: int = 0
|
|
c_suite_count: int = 0
|
|
role_weight_score: float = 0.0
|
|
has_officer_or_director: bool = False
|
|
|
|
|
|
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_int(value: Any) -> int | None:
|
|
if value is None:
|
|
return None
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
class PointInTimeForm4Calendar:
|
|
"""Latest-known same-day Form 4 cluster events by filing date."""
|
|
|
|
def __init__(self, entries: Iterable[Form4ClusterEntry]) -> None:
|
|
grouped: dict[dt.date, list[Form4ClusterEntry]] = {}
|
|
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.owner_count,
|
|
-item.total_value,
|
|
-item.weighted_purchase_pct,
|
|
),
|
|
))
|
|
for filing_date, filing_entries in grouped.items()
|
|
}
|
|
|
|
@classmethod
|
|
def from_parquet(cls, path: Path) -> PointInTimeForm4Calendar:
|
|
table = pq.read_table(str(path))
|
|
rows = table.to_pylist()
|
|
entries: list[Form4ClusterEntry] = []
|
|
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"))
|
|
total_value = _coerce_float(row.get("total_value"))
|
|
owner_count = _coerce_int(row.get("owner_count"))
|
|
transaction_count = _coerce_int(row.get("transaction_count"))
|
|
event_day_count = _coerce_int(row.get("event_day_count"))
|
|
max_purchase_pct = _coerce_float(row.get("max_purchase_pct"))
|
|
median_purchase_pct = _coerce_float(row.get("median_purchase_pct"))
|
|
weighted_purchase_pct = _coerce_float(row.get("weighted_purchase_pct"))
|
|
if (
|
|
not symbol
|
|
or filing_date is None
|
|
or as_of_date is None
|
|
or total_value is None
|
|
or owner_count is None
|
|
or transaction_count is None
|
|
or event_day_count is None
|
|
or max_purchase_pct is None
|
|
or median_purchase_pct is None
|
|
or weighted_purchase_pct is None
|
|
):
|
|
continue
|
|
entries.append(
|
|
Form4ClusterEntry(
|
|
symbol=symbol,
|
|
filing_date=filing_date,
|
|
as_of_date=as_of_date,
|
|
total_value=total_value,
|
|
owner_count=owner_count,
|
|
transaction_count=transaction_count,
|
|
event_day_count=event_day_count,
|
|
max_purchase_pct=max_purchase_pct,
|
|
median_purchase_pct=median_purchase_pct,
|
|
weighted_purchase_pct=weighted_purchase_pct,
|
|
max_lag_days=_coerce_int(row.get("max_lag_days")),
|
|
min_lag_days=_coerce_int(row.get("min_lag_days")),
|
|
transaction_span_days=_coerce_int(row.get("transaction_span_days")) or 0,
|
|
officer_count=_coerce_int(row.get("officer_count")) or 0,
|
|
director_count=_coerce_int(row.get("director_count")) or 0,
|
|
ten_percent_owner_count=_coerce_int(row.get("ten_percent_owner_count")) or 0,
|
|
other_role_count=_coerce_int(row.get("other_role_count")) or 0,
|
|
ceo_count=_coerce_int(row.get("ceo_count")) or 0,
|
|
cfo_count=_coerce_int(row.get("cfo_count")) or 0,
|
|
c_suite_count=_coerce_int(row.get("c_suite_count")) or 0,
|
|
role_weight_score=_coerce_float(row.get("role_weight_score")) or 0.0,
|
|
has_officer_or_director=bool(row.get("has_officer_or_director") or False),
|
|
)
|
|
)
|
|
logger.info(
|
|
"pit_form4_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[Form4ClusterEntry]:
|
|
symbol_filter = {
|
|
str(symbol).strip().upper()
|
|
for symbol in (symbols or [])
|
|
if str(symbol).strip()
|
|
}
|
|
rows: list[Form4ClusterEntry] = []
|
|
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_form4_calendar(path_str: str) -> PointInTimeForm4Calendar | None:
|
|
path = Path(path_str)
|
|
if not path.exists():
|
|
logger.info("pit_form4_calendar_missing", path=str(path))
|
|
return None
|
|
return PointInTimeForm4Calendar.from_parquet(path)
|
|
|
|
|
|
__all__ = [
|
|
"Form4ClusterEntry",
|
|
"PointInTimeForm4Calendar",
|
|
"load_pit_form4_calendar",
|
|
]
|