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