Files
set50-system/backend/app/event_study.py
2026-08-23 14:42:02 +07:00

193 lines
8.5 KiB
Python

"""Deterministic event-study primitives with fail-closed readiness gates."""
from __future__ import annotations
import math
from datetime import date, datetime, timezone
from statistics import fmean
from typing import Any, Mapping, Sequence
class EventStudyError(ValueError):
"""Raised when an event study cannot be computed safely."""
def _parse_date(value: str) -> date:
try:
if "T" in value:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.date()
return date.fromisoformat(value)
except (TypeError, ValueError) as exc:
raise EventStudyError("date must be ISO-8601") from exc
def _canonical_timestamp(value: str) -> str:
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except (TypeError, ValueError):
return value
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc).isoformat()
def assess_backtest_readiness(vintages: Sequence[Mapping[str, Any]], min_events: int = 12) -> dict[str, Any]:
if isinstance(min_events, bool) or min_events < 1:
raise EventStudyError("min_events must be positive")
independent_keys: set[tuple[str, str, str]] = set()
for item in vintages:
source_id = str(item.get("source_id") or "")
published_at = _canonical_timestamp(str(item.get("published_at") or ""))
if source_id and published_at:
independent_keys.add(("release", source_id, published_at))
continue
identifier = str(item.get("vintage_id") or item.get("event_id") or "")
if identifier:
independent_keys.add(("id", identifier, ""))
available = len(independent_keys)
if available < min_events:
return {
"status": "blocked",
"reason": "insufficient_vintages",
"available_events": available,
"required_events": min_events,
}
return {
"status": "ready",
"reason": None,
"available_events": available,
"required_events": min_events,
}
def _price_map(symbol: str, rows: Sequence[Mapping[str, Any]]) -> dict[date, float]:
if not rows:
raise EventStudyError(f"missing prices for {symbol}")
values: dict[date, float] = {}
for row in rows:
try:
trading_day = _parse_date(str(row["date"]))
close = float(row["close"])
except (KeyError, TypeError, ValueError) as exc:
raise EventStudyError(f"invalid price row for {symbol}") from exc
if not math.isfinite(close) or close <= 0:
raise EventStudyError(f"invalid close for {symbol}")
if trading_day in values:
raise EventStudyError(f"duplicate price date for {symbol}")
values[trading_day] = close
return dict(sorted(values.items()))
def _window_return(series: dict[date, float], event_date: date, window: int, symbol: str, execution_lag_sessions: int) -> float:
dates = list(series)
anchor_candidates = [index for index, trading_day in enumerate(dates) if trading_day >= event_date]
if not anchor_candidates:
raise EventStudyError(f"missing post-event prices for {symbol}")
first_session = anchor_candidates[0]
if execution_lag_sessions == 0:
anchor = first_session
elif dates[first_session] > event_date:
anchor = first_session + execution_lag_sessions - 1
else:
anchor = first_session + execution_lag_sessions
end = anchor + window
if end >= len(dates):
raise EventStudyError(f"insufficient price history for {symbol} window {window}")
return series[dates[end]] / series[dates[anchor]] - 1.0
def run_event_study(
events: Sequence[Mapping[str, Any]],
prices: Mapping[str, Sequence[Mapping[str, Any]]],
*,
benchmark_prices: Sequence[Mapping[str, Any]] | None = None,
windows: Sequence[int] = (1, 3, 5, 20),
cost_bps: float = 0.0,
min_events: int = 12,
execution_lag_sessions: int = 1,
) -> dict[str, Any]:
"""Calculate weighted post-publication returns from point-in-time events."""
readiness = assess_backtest_readiness(events, min_events=min_events)
if readiness["status"] != "ready":
raise EventStudyError(
f"insufficient vintages: {readiness['available_events']}/{readiness['required_events']}"
)
if not windows or any(isinstance(window, bool) or int(window) != window or window <= 0 for window in windows):
raise EventStudyError("windows must contain positive integers")
if isinstance(execution_lag_sessions, bool) or int(execution_lag_sessions) != execution_lag_sessions or execution_lag_sessions < 0:
raise EventStudyError("execution_lag_sessions must be a non-negative integer")
execution_lag_sessions = int(execution_lag_sessions)
try:
cost_bps = float(cost_bps)
except (TypeError, ValueError) as exc:
raise EventStudyError("cost_bps must be finite and non-negative") from exc
if not math.isfinite(cost_bps) or cost_bps < 0:
raise EventStudyError("cost_bps must be finite and non-negative")
normalized_prices = {str(symbol).upper(): _price_map(str(symbol).upper(), rows) for symbol, rows in prices.items()}
normalized_benchmark = _price_map("benchmark", benchmark_prices) if benchmark_prices is not None else None
seen_event_ids: set[str] = set()
event_rows: list[dict[str, Any]] = []
for event in events:
event_id = str(event.get("event_id", ""))
if not event_id or event_id in seen_event_ids:
raise EventStudyError("event_id must be unique and non-empty")
seen_event_ids.add(event_id)
event_date = _parse_date(str(event.get("published_at", "")))
signals = event.get("signals")
if not isinstance(signals, Sequence) or isinstance(signals, (str, bytes)) or not signals:
raise EventStudyError(f"event {event_id} has no signals")
event_rows.append({"event_id": event_id, "event_date": event_date, "signals": signals})
window_results: dict[str, Any] = {}
for window in windows:
gross_returns: list[float] = []
net_returns: list[float] = []
benchmark_returns: list[float] = []
for event in event_rows:
gross = 0.0
turnover = 0.0
for signal in event["signals"]:
symbol = str(signal.get("symbol", "")).upper()
try:
weight = float(signal["target_weight"])
except (KeyError, TypeError, ValueError) as exc:
raise EventStudyError(f"invalid target weight for {symbol}") from exc
if not symbol or not math.isfinite(weight):
raise EventStudyError(f"invalid target weight for {symbol}")
if symbol not in normalized_prices:
raise EventStudyError(f"missing prices for {symbol}")
gross += weight * _window_return(normalized_prices[symbol], event["event_date"], int(window), symbol, execution_lag_sessions)
turnover += abs(weight)
cost = turnover * cost_bps / 10000.0
gross_returns.append(gross)
net_returns.append(gross - cost)
if normalized_benchmark is not None:
benchmark_returns.append(_window_return(normalized_benchmark, event["event_date"], int(window), "benchmark", execution_lag_sessions))
average_gross = fmean(gross_returns)
average_net = fmean(net_returns)
average_benchmark = fmean(benchmark_returns) if benchmark_returns else None
window_results[str(window)] = {
"window_sessions": int(window),
"event_count": len(event_rows),
"gross_return": round(average_gross, 8),
"net_return": round(average_net, 8),
"benchmark_return": round(average_benchmark, 8) if average_benchmark is not None else None,
"active_return": round(average_net - average_benchmark, 8) if average_benchmark is not None else None,
"hit_rate": round(sum(value > 0 for value in net_returns) / len(net_returns), 8),
"cost_bps": cost_bps,
"execution_lag_sessions": execution_lag_sessions,
"event_returns": [round(value, 8) for value in net_returns],
}
return {
"status": "ready",
"event_count": len(event_rows),
"windows": window_results,
"min_events": min_events,
}