Files
set50-system/backend/app/event_study.py
Kunthawat Greethong ead9aeb25c chore: pre-existing in-tree work (event-study/research/vintages/prices + migration script + integrity docs)
Committing the prior uncommitted working-tree state that predates this session's
data-source work (was already modified/untracked at session start) so the tree
is clean before push. Includes: event-study + research report integrity/forward
observation work, prices tests, research hash migration script, and the
2026-08-23/24 engineering-log + test-evidence notes. Verified green as part of
the full 362-test suite.
2026-08-29 09:19:24 +07:00

227 lines
10 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
from zoneinfo import ZoneInfo
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 _parse_known_at(value: Any, symbol: str) -> datetime:
if not isinstance(value, str):
raise EventStudyError(f"known_at is required for {symbol}")
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise EventStudyError(f"invalid known_at for {symbol}") from exc
if parsed.tzinfo is None:
raise EventStudyError(f"known_at must include a timezone for {symbol}")
return parsed.astimezone(timezone.utc)
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]], *, require_known_at: bool = False) -> dict[date, tuple[float, datetime | None]]:
if not rows:
raise EventStudyError(f"missing prices for {symbol}")
values: dict[date, tuple[float, datetime | None]] = {}
for row in rows:
try:
session_date_value = row.get("session_date")
if session_date_value is None:
session_date_value = row["date"]
trading_day = _parse_date(str(session_date_value))
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}")
known_at = _parse_known_at(row.get("known_at"), symbol) if require_known_at else None
if known_at is not None:
timezone_name = row.get("market_timezone")
if not isinstance(timezone_name, str) or not timezone_name.strip():
raise EventStudyError(f"market_timezone is required for {symbol}")
try:
market_date = known_at.astimezone(ZoneInfo(timezone_name)).date()
except Exception as exc:
raise EventStudyError(f"invalid market_timezone for {symbol}") from exc
if market_date > trading_day:
raise EventStudyError(f"known_at is after session date for {symbol}")
values[trading_day] = (close, known_at)
return dict(sorted(values.items()))
def _window_return(series: dict[date, tuple[float, datetime | None]], 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]][0] / series[dates[anchor]][0] - 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,
require_price_known_at: bool = False,
) -> 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")
if not isinstance(require_price_known_at, bool):
raise EventStudyError("require_price_known_at must be boolean")
normalized_prices = {
str(symbol).upper(): _price_map(str(symbol).upper(), rows, require_known_at=require_price_known_at)
for symbol, rows in prices.items()
}
normalized_benchmark = _price_map("benchmark", benchmark_prices, require_known_at=require_price_known_at) 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,
"price_known_at_required": require_price_known_at,
}