928 lines
27 KiB
Python
928 lines
27 KiB
Python
# ---
|
|
# jupyter:
|
|
# jupytext:
|
|
# cell_metadata_filter: tags,-all
|
|
# text_representation:
|
|
# extension: .py
|
|
# format_name: percent
|
|
# format_version: '1.3'
|
|
# jupytext_version: 1.19.3
|
|
# kernelspec:
|
|
# display_name: Python 3 (ipykernel)
|
|
# language: python
|
|
# name: python3
|
|
# ---
|
|
|
|
# %% [markdown]
|
|
# # Event Studies
|
|
#
|
|
# **Chapter 8: Feature Engineering**
|
|
# **Section Reference**: 8.6 - Combining Features and Controlling Search
|
|
# **Docker image**: `ml4t`
|
|
#
|
|
# ## Purpose
|
|
#
|
|
# Event studies measure abnormal returns around specific events (signal triggers,
|
|
# macro announcements, earnings) to assess their predictive power. This is a key
|
|
# validation technique for trading signals.
|
|
#
|
|
# ## Learning Objectives
|
|
#
|
|
# 1. Understand event study methodology (MacKinlay 1997)
|
|
# 2. Implement correct abnormal return computation
|
|
# 3. Calculate CAAR with proper confidence bands
|
|
# 4. Use event studies for signal validation
|
|
# 5. Recognize common pitfalls (clustering, overlapping windows)
|
|
#
|
|
# ## Key Concepts
|
|
#
|
|
# **Event Study Workflow**:
|
|
# 1. Define events (signal triggers, announcements)
|
|
# 2. Estimate "normal" returns in estimation window
|
|
# 3. Calculate abnormal returns in event window
|
|
# 4. Aggregate across events (CAAR)
|
|
# 5. Test statistical significance
|
|
#
|
|
# ## References
|
|
#
|
|
# - MacKinlay, A.C. (1997). "Event Studies in Economics and Finance"
|
|
# - Boehmer et al. (1991). Event-induced variance adjustments
|
|
#
|
|
# ## Data Policy
|
|
#
|
|
# All examples use **real ETF data**.
|
|
|
|
# %%
|
|
"""Event Studies — measure abnormal returns around signal triggers and macro announcements."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import warnings
|
|
from datetime import datetime
|
|
|
|
import numpy as np
|
|
import plotly.graph_objects as go
|
|
import polars as pl
|
|
from scipy import stats
|
|
|
|
from utils.reproducibility import set_global_seeds
|
|
|
|
warnings.filterwarnings("ignore")
|
|
|
|
# %% tags=["parameters"]
|
|
START_DATE = "2018-01-01"
|
|
END_DATE = "2024-01-01"
|
|
SEED = 42
|
|
|
|
# %%
|
|
set_global_seeds(SEED)
|
|
|
|
# %% [markdown]
|
|
# ## 1. Data Loading
|
|
#
|
|
# We use ETF data to demonstrate event studies. Events will be generated from
|
|
# momentum breakouts (trading signal) as a validation example.
|
|
|
|
# %%
|
|
from data import load_etfs
|
|
|
|
etfs = load_etfs()
|
|
|
|
|
|
# Select liquid ETFs for event study
|
|
SYMBOLS = ["SPY", "QQQ", "IWM", "TLT", "GLD"]
|
|
|
|
# Filter
|
|
etf_filtered = (
|
|
etfs.filter(pl.col("symbol").is_in(SYMBOLS))
|
|
.filter(
|
|
(pl.col("timestamp") >= datetime.strptime(START_DATE, "%Y-%m-%d"))
|
|
& (pl.col("timestamp") < datetime.strptime(END_DATE, "%Y-%m-%d"))
|
|
)
|
|
.sort(["symbol", "timestamp"])
|
|
)
|
|
|
|
print(f"ETF data: {len(etf_filtered):,} rows")
|
|
print(f"Symbols: {etf_filtered['symbol'].n_unique()}")
|
|
print(f"Date range: {etf_filtered['timestamp'].min()} to {etf_filtered['timestamp'].max()}")
|
|
|
|
# %%
|
|
# Compute daily returns
|
|
returns_df = (
|
|
etf_filtered.select(["timestamp", "symbol", "close"])
|
|
.with_columns(pl.col("close").pct_change().over("symbol").alias("return"))
|
|
.drop_nulls()
|
|
)
|
|
|
|
print(f"Returns: {len(returns_df):,} observations")
|
|
|
|
# Benchmark: SPY as market proxy
|
|
benchmark_returns = (
|
|
returns_df.filter(pl.col("symbol") == "SPY")
|
|
.select(["timestamp", "return"])
|
|
.rename({"return": "benchmark_return"})
|
|
)
|
|
|
|
print(f"Benchmark: {len(benchmark_returns):,} days")
|
|
|
|
# %% [markdown]
|
|
# ## 2. Generate Events
|
|
#
|
|
# For demonstration, we generate events from **momentum breakouts** (new 20-day highs).
|
|
# In practice, events could be:
|
|
# - Trading signal triggers
|
|
# - Earnings announcements
|
|
# - FOMC meetings
|
|
# - Index rebalances
|
|
|
|
|
|
# %%
|
|
def generate_momentum_breakout_events(
|
|
prices: pl.DataFrame,
|
|
lookback: int = 20,
|
|
min_gap_days: int = 21,
|
|
) -> pl.DataFrame:
|
|
"""
|
|
Generate events when price makes a new N-day high.
|
|
|
|
Parameters
|
|
----------
|
|
prices : DataFrame with timestamp, symbol, close
|
|
lookback : Days to look back for high
|
|
min_gap_days : Minimum gap between events (avoid clustering)
|
|
|
|
Returns
|
|
-------
|
|
DataFrame with timestamp, symbol, event_type
|
|
"""
|
|
events = []
|
|
|
|
for symbol in prices["symbol"].unique().to_list():
|
|
if symbol == "SPY": # Skip benchmark
|
|
continue
|
|
|
|
symbol_data = prices.filter(pl.col("symbol") == symbol).sort("timestamp")
|
|
close_prices = symbol_data["close"].to_numpy()
|
|
timestamps = symbol_data["timestamp"].to_list()
|
|
|
|
last_event_idx = -min_gap_days - 1
|
|
|
|
for i in range(lookback, len(close_prices) - 30): # Leave room for event window
|
|
# Check if new high
|
|
if close_prices[i] >= max(close_prices[i - lookback : i]):
|
|
# Check minimum gap
|
|
if i - last_event_idx >= min_gap_days:
|
|
events.append(
|
|
{
|
|
"timestamp": timestamps[i],
|
|
"symbol": symbol,
|
|
"event_type": "momentum_breakout",
|
|
}
|
|
)
|
|
last_event_idx = i
|
|
|
|
return pl.DataFrame(events)
|
|
|
|
|
|
# %%
|
|
# Generate events
|
|
events_df = generate_momentum_breakout_events(
|
|
etf_filtered.select(["timestamp", "symbol", "close"]),
|
|
lookback=20,
|
|
min_gap_days=30, # At least 30 days between events per symbol
|
|
)
|
|
|
|
print(f"Generated {len(events_df)} events")
|
|
print("\nEvents by symbol:")
|
|
events_df.group_by("symbol").len().sort("symbol")
|
|
|
|
# %% [markdown]
|
|
# ## 3. Event Study: Manual Implementation
|
|
#
|
|
# **Key Fix**: Use proper indexing with MultiIndex or aligned DataFrames,
|
|
# not `get_loc()` on potentially non-unique indices.
|
|
#
|
|
# The manual implementation shows the mechanics:
|
|
# 1. For each event, extract estimation and event windows
|
|
# 2. Estimate market model (CAPM) in estimation window
|
|
# 3. Calculate abnormal returns in event window
|
|
# 4. Aggregate across events
|
|
|
|
|
|
# %% [markdown]
|
|
# ### 3a. Market Model Estimation
|
|
#
|
|
# For each event, estimate the CAPM parameters ($\alpha$, $\beta$) from
|
|
# the pre-event estimation window. This establishes the "normal return"
|
|
# baseline.
|
|
|
|
|
|
# %%
|
|
def _estimate_market_model(
|
|
returns_wide: pl.DataFrame,
|
|
event_idx: int,
|
|
symbol: str,
|
|
estimation_window: tuple[int, int],
|
|
min_estimation_obs: int,
|
|
) -> tuple[float, float] | None:
|
|
"""Estimate market model alpha and beta from estimation window."""
|
|
est_start = event_idx + estimation_window[0]
|
|
est_end = event_idx + estimation_window[1]
|
|
|
|
if est_start < 0:
|
|
return None
|
|
|
|
est_slice = returns_wide.slice(est_start, est_end - est_start + 1)
|
|
asset_est = est_slice[symbol].to_numpy()
|
|
bench_est = est_slice["benchmark_return"].to_numpy()
|
|
|
|
valid = np.isfinite(asset_est) & np.isfinite(bench_est)
|
|
if np.sum(valid) < min_estimation_obs:
|
|
return None
|
|
|
|
try:
|
|
slope, intercept, _, _, _ = stats.linregress(bench_est[valid], asset_est[valid])
|
|
return intercept, slope # alpha, beta
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
# %% [markdown]
|
|
# ### 3b. Abnormal Return Computation
|
|
#
|
|
# Given estimated $\alpha$ and $\beta$, compute abnormal returns in the event
|
|
# window: $AR_t = R_{actual} - (\alpha + \beta \cdot R_{market})$.
|
|
|
|
|
|
# %%
|
|
def _compute_abnormal_returns(
|
|
returns_wide: pl.DataFrame,
|
|
event_idx: int,
|
|
event_date,
|
|
symbol: str,
|
|
alpha: float,
|
|
beta: float,
|
|
event_window: tuple[int, int],
|
|
) -> tuple[list[dict], dict | None]:
|
|
"""Compute abnormal returns in the event window."""
|
|
evt_start = event_idx + event_window[0]
|
|
evt_end = event_idx + event_window[1]
|
|
|
|
if evt_end >= len(returns_wide):
|
|
return [], None
|
|
|
|
evt_slice = returns_wide.slice(evt_start, evt_end - evt_start + 1)
|
|
asset_evt = evt_slice[symbol].to_numpy()
|
|
bench_evt = evt_slice["benchmark_return"].to_numpy()
|
|
evt_dates = evt_slice["timestamp"].to_list()
|
|
|
|
ars = []
|
|
car = 0.0
|
|
for i, (date, r_actual, r_market) in enumerate(
|
|
zip(evt_dates, asset_evt, bench_evt, strict=False)
|
|
):
|
|
if not (np.isfinite(r_actual) and np.isfinite(r_market)):
|
|
continue
|
|
r_expected = alpha + beta * r_market
|
|
ar = r_actual - r_expected
|
|
car += ar
|
|
day_relative = event_window[0] + i
|
|
ars.append(
|
|
{
|
|
"event_date": event_date,
|
|
"symbol": symbol,
|
|
"day": day_relative,
|
|
"ar": ar,
|
|
"car_to_day": car,
|
|
}
|
|
)
|
|
|
|
event_car = {
|
|
"event_date": event_date,
|
|
"symbol": symbol,
|
|
"car": car,
|
|
"alpha": alpha,
|
|
"beta": beta,
|
|
}
|
|
return ars, event_car
|
|
|
|
|
|
# %% [markdown]
|
|
# ### 3c. Aggregation: AAR and CAAR
|
|
#
|
|
# Average across events to get the Average Abnormal Return (AAR) per relative
|
|
# day, then cumulate to get the CAAR with correct standard errors:
|
|
#
|
|
# $$\text{SE}(\text{CAAR}_t) = \sqrt{\sum_{s=1}^{t} \frac{\sigma_s^2}{n_s}}$$
|
|
|
|
|
|
# %%
|
|
def _aggregate_to_caar(
|
|
all_ars: list[dict],
|
|
event_cars: list[dict],
|
|
) -> tuple[pl.DataFrame, pl.DataFrame, pl.DataFrame]:
|
|
"""Aggregate abnormal returns to AAR and CAAR."""
|
|
ar_df = pl.DataFrame(all_ars) if all_ars else pl.DataFrame()
|
|
car_df = pl.DataFrame(event_cars) if event_cars else pl.DataFrame()
|
|
|
|
if len(ar_df) > 0:
|
|
daily_aar = (
|
|
ar_df.group_by("day")
|
|
.agg(
|
|
[
|
|
pl.col("ar").mean().alias("aar"),
|
|
pl.col("ar").std().alias("std"),
|
|
pl.len().alias("n"),
|
|
]
|
|
)
|
|
.sort("day")
|
|
.with_columns((pl.col("std") / pl.col("n").sqrt()).alias("se"))
|
|
)
|
|
# CAAR and its standard error
|
|
daily_aar = daily_aar.with_columns(pl.col("aar").cum_sum().alias("caar"))
|
|
daily_aar = daily_aar.with_columns(
|
|
(pl.col("std").pow(2) / pl.col("n")).cum_sum().sqrt().alias("caar_se")
|
|
)
|
|
daily_aar = daily_aar.with_columns(
|
|
[
|
|
(pl.col("aar") / pl.col("se")).alias("t_stat"),
|
|
(pl.col("caar") / pl.col("caar_se")).alias("caar_t_stat"),
|
|
]
|
|
)
|
|
else:
|
|
daily_aar = pl.DataFrame()
|
|
|
|
return ar_df, car_df, daily_aar
|
|
|
|
|
|
# %% [markdown]
|
|
# ### 3d. Event Study Wrapper
|
|
#
|
|
# The wrapper orchestrates the three stages: estimate market model, compute
|
|
# abnormal returns, and aggregate to CAAR.
|
|
|
|
|
|
# %%
|
|
def compute_event_study(
|
|
returns_df: pl.DataFrame,
|
|
benchmark_df: pl.DataFrame,
|
|
events_df: pl.DataFrame,
|
|
estimation_window: tuple[int, int] = (-60, -6),
|
|
event_window: tuple[int, int] = (-5, 10),
|
|
min_estimation_obs: int = 30,
|
|
) -> dict:
|
|
"""
|
|
Compute event study using the three-stage pipeline above.
|
|
|
|
Parameters
|
|
----------
|
|
returns_df : Long-format returns (timestamp, symbol, return)
|
|
benchmark_df : Benchmark returns (timestamp, benchmark_return)
|
|
events_df : Events (timestamp, symbol)
|
|
estimation_window : (start, end) days relative to event
|
|
event_window : (start, end) days relative to event
|
|
min_estimation_obs : Minimum observations in estimation window
|
|
|
|
Returns
|
|
-------
|
|
Dict with:
|
|
- abnormal_returns: DataFrame of AR by event-day
|
|
- event_cars: DataFrame of CAR by event
|
|
- daily_aar: DataFrame of AAR by relative day
|
|
"""
|
|
# Create wide-format returns for efficient lookup
|
|
returns_wide = returns_df.pivot(on="symbol", index="timestamp", values="return").sort(
|
|
"timestamp"
|
|
)
|
|
returns_wide = returns_wide.join(benchmark_df, on="timestamp", how="inner")
|
|
|
|
dates = returns_wide["timestamp"].to_list()
|
|
date_to_idx = {d: i for i, d in enumerate(dates)}
|
|
symbols = [c for c in returns_wide.columns if c not in ["timestamp", "benchmark_return"]]
|
|
|
|
all_ars = []
|
|
event_cars = []
|
|
|
|
for row in events_df.iter_rows(named=True):
|
|
event_date = row["timestamp"]
|
|
symbol = row["symbol"]
|
|
if symbol not in symbols or event_date not in date_to_idx:
|
|
continue
|
|
event_idx = date_to_idx[event_date]
|
|
|
|
# Check event window upper bound
|
|
if event_idx + event_window[1] >= len(dates):
|
|
continue
|
|
|
|
# Stage 1: Estimate market model
|
|
model = _estimate_market_model(
|
|
returns_wide, event_idx, symbol, estimation_window, min_estimation_obs
|
|
)
|
|
if model is None:
|
|
continue
|
|
alpha, beta = model
|
|
|
|
# Stage 2: Compute abnormal returns
|
|
ars, event_car = _compute_abnormal_returns(
|
|
returns_wide, event_idx, event_date, symbol, alpha, beta, event_window
|
|
)
|
|
all_ars.extend(ars)
|
|
if event_car:
|
|
event_cars.append(event_car)
|
|
|
|
# Stage 3: Aggregate
|
|
ar_df, car_df, daily_aar = _aggregate_to_caar(all_ars, event_cars)
|
|
|
|
return {
|
|
"abnormal_returns": ar_df,
|
|
"event_cars": car_df,
|
|
"daily_aar": daily_aar,
|
|
"n_events": len(car_df),
|
|
}
|
|
|
|
|
|
# %%
|
|
# Run event study
|
|
result = compute_event_study(
|
|
returns_df.select(["timestamp", "symbol", "return"]),
|
|
benchmark_returns,
|
|
events_df,
|
|
estimation_window=(-60, -6),
|
|
event_window=(-5, 10),
|
|
)
|
|
|
|
print(f"Processed {result['n_events']} events")
|
|
|
|
if len(result["event_cars"]) > 0:
|
|
print("\nCAR Summary:")
|
|
cars = result["event_cars"]["car"].to_numpy()
|
|
print(f" Mean CAR: {np.mean(cars) * 100:.2f}%")
|
|
print(f" Median CAR: {np.median(cars) * 100:.2f}%")
|
|
print(f" Std CAR: {np.std(cars) * 100:.2f}%")
|
|
|
|
# %% [markdown]
|
|
# ## 4. Visualize CAAR with Correct Confidence Bands
|
|
#
|
|
# **Key Fix**: The variance of CAAR is the **cumulative sum** of daily AAR variances,
|
|
# not a rolling calculation.
|
|
#
|
|
# $$\text{Var}(\text{CAAR}_t) = \sum_{s=1}^{t} \text{Var}(\text{AAR}_s)$$
|
|
#
|
|
# $$\text{SE}(\text{CAAR}_t) = \sqrt{\sum_{s=1}^{t} \frac{\sigma_s^2}{n_s}}$$
|
|
|
|
# %%
|
|
if len(result["daily_aar"]) > 0:
|
|
daily_aar = result["daily_aar"]
|
|
|
|
fig = go.Figure()
|
|
|
|
days = daily_aar["day"].to_list()
|
|
caar = daily_aar["caar"].to_numpy() * 100 # Convert to percent
|
|
caar_se = daily_aar["caar_se"].to_numpy() * 100
|
|
|
|
# CAAR line
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=days,
|
|
y=caar,
|
|
mode="lines+markers",
|
|
name="CAAR",
|
|
line=dict(color="steelblue", width=2),
|
|
)
|
|
)
|
|
|
|
# 95% confidence band (CORRECT formula)
|
|
upper = caar + 1.96 * caar_se
|
|
lower = caar - 1.96 * caar_se
|
|
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=days + days[::-1],
|
|
y=np.concatenate([upper, lower[::-1]]).tolist(),
|
|
fill="toself",
|
|
fillcolor="rgba(70, 130, 180, 0.2)",
|
|
line=dict(width=0),
|
|
name="95% CI",
|
|
)
|
|
)
|
|
|
|
# Event day marker
|
|
fig.add_vline(x=0, line_dash="dash", line_color="red", annotation_text="Event Day")
|
|
fig.add_hline(y=0, line_dash="dot", line_color="gray")
|
|
|
|
fig.update_layout(
|
|
title="Cumulative Average Abnormal Return (CAAR) - Momentum Breakouts",
|
|
xaxis_title="Days Relative to Event",
|
|
yaxis_title="CAAR (%)",
|
|
height=500,
|
|
)
|
|
|
|
fig.show()
|
|
|
|
# %%
|
|
# Print daily AAR and CAAR table
|
|
if len(result["daily_aar"]) > 0:
|
|
daily_aar = result["daily_aar"]
|
|
print("\nDaily AAR and CAAR:")
|
|
print("-" * 80)
|
|
print(f"{'Day':>5} {'AAR (%)':>10} {'t-stat':>10} {'CAAR (%)':>12} {'CAAR t':>10} {'n':>8}")
|
|
print("-" * 80)
|
|
|
|
for row in daily_aar.iter_rows(named=True):
|
|
sig = "*" if abs(row["t_stat"]) > 1.96 else ""
|
|
caar_sig = "*" if abs(row["caar_t_stat"]) > 1.96 else ""
|
|
print(
|
|
f"{row['day']:>5} {row['aar'] * 100:>10.3f} {row['t_stat']:>10.2f}{sig}"
|
|
f" {row['caar'] * 100:>12.3f} {row['caar_t_stat']:>10.2f}{caar_sig} {row['n']:>8}"
|
|
)
|
|
|
|
print("-" * 80)
|
|
print("* = significant at 5%")
|
|
|
|
# %% [markdown]
|
|
# ## 4b. Library Alternative: EventStudyAnalysis
|
|
#
|
|
# The manual implementation above teaches the MacKinlay (1997) mechanics.
|
|
# The `ml4t-diagnostic` library adds robust variance adjustment (BMP test,
|
|
# Boehmer et al. 1991) and non-parametric testing (Corrado rank test).
|
|
|
|
# %%
|
|
from ml4t.diagnostic.config import EventConfig
|
|
from ml4t.diagnostic.config.event_config import WindowSettings
|
|
from ml4t.diagnostic.evaluation import EventStudyAnalysis
|
|
|
|
# Prepare data in library format
|
|
# Returns: date, asset, return
|
|
lib_returns = returns_df.select(
|
|
pl.col("timestamp").alias("date"),
|
|
pl.col("symbol").alias("asset"),
|
|
pl.col("return"),
|
|
)
|
|
|
|
# Benchmark: date, return
|
|
lib_benchmark = benchmark_returns.rename({"timestamp": "date", "benchmark_return": "return"})
|
|
|
|
# Events: date, asset
|
|
lib_events = events_df.select(
|
|
pl.col("timestamp").alias("date"),
|
|
pl.col("symbol").alias("asset"),
|
|
)
|
|
|
|
# Configure event study
|
|
config = EventConfig(
|
|
window=WindowSettings(
|
|
estimation_start=-60,
|
|
estimation_end=-6,
|
|
event_start=-5,
|
|
event_end=10,
|
|
),
|
|
model="market_model",
|
|
min_estimation_obs=30,
|
|
)
|
|
|
|
# Run library event study
|
|
lib_analysis = EventStudyAnalysis(
|
|
returns=lib_returns,
|
|
events=lib_events,
|
|
benchmark=lib_benchmark,
|
|
config=config,
|
|
)
|
|
lib_result = lib_analysis.run()
|
|
|
|
# %%
|
|
# Compare results
|
|
print("=== Library EventStudyAnalysis Results ===\n")
|
|
print(lib_result.summary())
|
|
|
|
# The library adds tests not in the manual implementation:
|
|
print("\n=== Additional Statistical Tests ===")
|
|
if hasattr(lib_result, "bmp_test"):
|
|
print(f"BMP test (robust to event-induced variance): t={lib_result.bmp_test['t_stat']:.2f}")
|
|
if hasattr(lib_result, "corrado_test"):
|
|
print(f"Corrado rank test (non-parametric): z={lib_result.corrado_test['z_stat']:.2f}")
|
|
|
|
# %% [markdown]
|
|
# The manual implementation teaches the market model ($R_i = \alpha + \beta R_m$)
|
|
# and CAAR computation. The library adds:
|
|
#
|
|
# | Feature | Manual | Library |
|
|
# |---------|--------|---------|
|
|
# | Market model | Yes | Yes |
|
|
# | Mean-adjusted model | No | Yes |
|
|
# | BMP test (robust variance) | No | Yes |
|
|
# | Corrado rank test | No | Yes |
|
|
# | Event clustering handling | No | Yes |
|
|
|
|
# %% [markdown]
|
|
# ## 5. CAR Distribution
|
|
#
|
|
# Examining the distribution of individual event CARs reveals whether the
|
|
# aggregate effect is driven by many small effects or few large ones.
|
|
|
|
# %%
|
|
if len(result["event_cars"]) > 0:
|
|
cars = result["event_cars"]["car"].to_numpy() * 100
|
|
|
|
fig = go.Figure()
|
|
|
|
fig.add_trace(
|
|
go.Histogram(
|
|
x=cars,
|
|
nbinsx=25,
|
|
marker_color="steelblue",
|
|
name="CAR Distribution",
|
|
)
|
|
)
|
|
|
|
# Mean and zero lines
|
|
fig.add_vline(x=0, line_dash="dash", line_color="red", annotation_text="Zero")
|
|
fig.add_vline(
|
|
x=np.mean(cars),
|
|
line_dash="solid",
|
|
line_color="green",
|
|
annotation_text=f"Mean: {np.mean(cars):.2f}%",
|
|
)
|
|
|
|
fig.update_layout(
|
|
title="Distribution of Event CARs",
|
|
xaxis_title="Cumulative Abnormal Return (%)",
|
|
yaxis_title="Frequency",
|
|
height=400,
|
|
)
|
|
|
|
fig.show()
|
|
|
|
# Statistical test: Mean CAR = 0
|
|
t_stat, p_value = stats.ttest_1samp(cars, 0)
|
|
|
|
print("\nStatistical Test (H0: Mean CAR = 0):")
|
|
print(f" Mean CAR: {np.mean(cars):.2f}%")
|
|
print(f" Median CAR: {np.median(cars):.2f}%")
|
|
print(f" T-statistic: {t_stat:.2f}")
|
|
print(f" P-value: {p_value:.4f}")
|
|
print(f" Significant at 5%: {'Yes' if p_value < 0.05 else 'No'}")
|
|
|
|
# %% [markdown]
|
|
# **Interpretation**: A right-skewed CAR distribution with a statistically significant
|
|
# positive mean suggests that momentum breakouts are followed by genuine abnormal
|
|
# returns -- not just a few outlier events. If the distribution were bimodal or
|
|
# heavily skewed by a handful of events, the aggregate CAAR would be unreliable
|
|
# for strategy design. The mean/median comparison also matters: if the median is
|
|
# near zero but the mean is positive, a few large events drive the result.
|
|
|
|
# %% [markdown]
|
|
# ## 6. Event Study Heatmap
|
|
#
|
|
# Visualize abnormal returns across events and days to identify patterns.
|
|
|
|
# %%
|
|
if len(result["abnormal_returns"]) > 0:
|
|
ar_df = result["abnormal_returns"]
|
|
|
|
# Create unique event identifier (date + symbol can have multiple events)
|
|
ar_df = ar_df.with_columns(
|
|
(pl.col("event_date").dt.strftime("%Y-%m-%d") + "_" + pl.col("symbol")).alias("event_id")
|
|
)
|
|
|
|
# Pivot to wide format (event x day)
|
|
ar_pivot = ar_df.pivot(on="day", index="event_id", values="ar").sort("event_id")
|
|
|
|
# Convert to numpy for heatmap
|
|
day_cols = sorted([c for c in ar_pivot.columns if c != "event_id"], key=lambda x: int(x))
|
|
ar_matrix = ar_pivot.select(day_cols).to_numpy() * 100
|
|
|
|
# Limit to 30 events for readability
|
|
if len(ar_matrix) > 30:
|
|
ar_matrix = ar_matrix[:30]
|
|
event_ids = ar_pivot["event_id"].to_list()[:30]
|
|
else:
|
|
event_ids = ar_pivot["event_id"].to_list()
|
|
|
|
fig = go.Figure(
|
|
data=go.Heatmap(
|
|
z=ar_matrix,
|
|
x=day_cols,
|
|
y=event_ids,
|
|
colorscale="RdBu",
|
|
zmid=0,
|
|
colorbar=dict(title="AR (%)"),
|
|
)
|
|
)
|
|
|
|
fig.add_vline(x=0, line_dash="dash", line_color="yellow", line_width=2)
|
|
|
|
fig.update_layout(
|
|
title="Abnormal Returns Heatmap (Events x Days)",
|
|
xaxis_title="Days Relative to Event",
|
|
yaxis_title="Event Date",
|
|
height=600,
|
|
)
|
|
|
|
fig.show()
|
|
|
|
# %% [markdown]
|
|
# ## 7. Caveats and Best Practices
|
|
#
|
|
# ### Event Clustering
|
|
#
|
|
# When multiple events occur on the same day (e.g., sector-wide announcements),
|
|
# the cross-sectional correlation inflates the t-statistics. Solutions:
|
|
# - Use portfolio-level returns
|
|
# - Adjust standard errors for clustering
|
|
# - Aggregate to one "event" per day
|
|
#
|
|
# ### Overlapping Windows
|
|
#
|
|
# If events are close together, estimation and event windows may overlap,
|
|
# contaminating the "normal return" estimate. Solutions:
|
|
# - Enforce minimum gap between events (we used 30 days)
|
|
# - Use shorter estimation windows
|
|
# - Use calendar-time portfolio approach
|
|
#
|
|
# ### Confounding Events
|
|
#
|
|
# Other events in the window (earnings, macro news) can confound results.
|
|
# Solutions:
|
|
# - Screen for confounding events
|
|
# - Use matched controls
|
|
# - Analyze subsamples
|
|
|
|
# %% [markdown]
|
|
# ## 8. Using Event Studies for Signal Validation
|
|
#
|
|
# Event studies validate trading signals by testing whether signal-generated
|
|
# "events" produce abnormal returns.
|
|
|
|
|
|
# %%
|
|
def validate_signal_with_event_study(
|
|
returns_df: pl.DataFrame,
|
|
benchmark_df: pl.DataFrame,
|
|
signal_df: pl.DataFrame,
|
|
signal_column: str = "signal",
|
|
threshold: float = 2.0,
|
|
event_window: tuple[int, int] = (-5, 10),
|
|
) -> dict:
|
|
"""
|
|
Validate a trading signal using event study methodology.
|
|
|
|
Parameters
|
|
----------
|
|
returns_df : Long-format returns
|
|
benchmark_df : Benchmark returns
|
|
signal_df : Signal values (timestamp, symbol, signal)
|
|
signal_column : Column name for signal
|
|
threshold : Z-score threshold for event trigger
|
|
event_window : Days around event to analyze
|
|
|
|
Returns
|
|
-------
|
|
Dict with long and short event study results
|
|
"""
|
|
# Z-score signals cross-sectionally
|
|
signal_zscored = signal_df.with_columns(
|
|
(
|
|
(pl.col(signal_column) - pl.col(signal_column).mean().over("timestamp"))
|
|
/ pl.col(signal_column).std().over("timestamp")
|
|
).alias("zscore")
|
|
)
|
|
|
|
# Generate events from extreme signals
|
|
long_events = (
|
|
signal_zscored.filter(pl.col("zscore") > threshold)
|
|
.select(["timestamp", "symbol"])
|
|
.with_columns(pl.lit("long_signal").alias("event_type"))
|
|
)
|
|
|
|
short_events = (
|
|
signal_zscored.filter(pl.col("zscore") < -threshold)
|
|
.select(["timestamp", "symbol"])
|
|
.with_columns(pl.lit("short_signal").alias("event_type"))
|
|
)
|
|
|
|
print(f"Long signal events: {len(long_events)}")
|
|
print(f"Short signal events: {len(short_events)}")
|
|
|
|
results = {}
|
|
|
|
if len(long_events) > 10:
|
|
results["long"] = compute_event_study(
|
|
returns_df, benchmark_df, long_events, event_window=event_window
|
|
)
|
|
|
|
if len(short_events) > 10:
|
|
results["short"] = compute_event_study(
|
|
returns_df, benchmark_df, short_events, event_window=event_window
|
|
)
|
|
|
|
return results
|
|
|
|
|
|
# %%
|
|
# Example: Validate momentum signal
|
|
# Create momentum signal
|
|
prices_wide = (
|
|
etf_filtered.select(["timestamp", "symbol", "close"])
|
|
.pivot(on="symbol", index="timestamp", values="close")
|
|
.sort("timestamp")
|
|
)
|
|
|
|
symbols = [c for c in prices_wide.columns if c != "timestamp"]
|
|
|
|
# 21-day momentum
|
|
momentum = prices_wide.select(
|
|
pl.col("timestamp"), *[(pl.col(s) / pl.col(s).shift(21) - 1).alias(s) for s in symbols]
|
|
)
|
|
|
|
# Melt to long format
|
|
momentum_long = (
|
|
momentum.unpivot(index="timestamp", variable_name="symbol", value_name="momentum")
|
|
.drop_nulls()
|
|
.filter(pl.col("momentum").is_finite())
|
|
)
|
|
|
|
print(f"Momentum signal: {len(momentum_long):,} observations")
|
|
|
|
# %%
|
|
# Validate momentum signal
|
|
validation = validate_signal_with_event_study(
|
|
returns_df.select(["timestamp", "symbol", "return"]),
|
|
benchmark_returns,
|
|
momentum_long,
|
|
signal_column="momentum",
|
|
threshold=1.5,
|
|
)
|
|
|
|
if "long" in validation and len(validation["long"]["daily_aar"]) > 0:
|
|
print("\nLong Signal Validation:")
|
|
aar_long = validation["long"]["daily_aar"]
|
|
final_caar = aar_long["caar"].to_numpy()[-1] * 100
|
|
final_t = aar_long["caar_t_stat"].to_numpy()[-1]
|
|
print(f" Final CAAR: {final_caar:.2f}%")
|
|
print(f" CAAR t-stat: {final_t:.2f}")
|
|
print(f" Significant: {'Yes' if abs(final_t) > 1.96 else 'No'}")
|
|
|
|
if "short" in validation and len(validation["short"]["daily_aar"]) > 0:
|
|
print("\nShort Signal Validation:")
|
|
aar_short = validation["short"]["daily_aar"]
|
|
final_caar = aar_short["caar"].to_numpy()[-1] * 100
|
|
final_t = aar_short["caar_t_stat"].to_numpy()[-1]
|
|
print(f" Final CAAR: {final_caar:.2f}%")
|
|
print(f" CAAR t-stat: {final_t:.2f}")
|
|
print(f" Significant: {'Yes' if abs(final_t) > 1.96 else 'No'}")
|
|
|
|
# %% [markdown]
|
|
# ## 9. Summary
|
|
#
|
|
# ### Methodology
|
|
#
|
|
# - **Estimation window**: 60 days before event (excluding 5-day gap)
|
|
# - **Event window**: 5 days before to 10 days after
|
|
# - **Model**: Market model ($R_i = \alpha + \beta \cdot R_{market}$)
|
|
#
|
|
# ### Key Formulas
|
|
#
|
|
# | Metric | Formula |
|
|
# |--------|---------|
|
|
# | Abnormal Return | $AR = R_{actual} - (\alpha + \beta \cdot R_{market})$ |
|
|
# | CAR | Cumulative sum of AR over event window |
|
|
# | CAAR | Average CAR across events |
|
|
# | CAAR SE | $\sqrt{\sum_{s=1}^{t} \text{Var}(AAR_s) / n}$ |
|
|
#
|
|
# ### Interpretation Guide
|
|
#
|
|
# | Pattern | Meaning | Trading Implication |
|
|
# |---------|---------|---------------------|
|
|
# | Pre-event drift | Information leakage | Limited post-event alpha |
|
|
# | Event-day jump | Clean announcement | Event timing matters |
|
|
# | Post-event drift | Underreaction | Post-event momentum |
|
|
# | Reversal | Overreaction | Mean-reversion |
|
|
#
|
|
# ### Caveats
|
|
#
|
|
# - Event clustering inflates t-stats
|
|
# - Overlapping windows contaminate estimates
|
|
# - Confounding events require screening
|
|
|
|
# %% [markdown]
|
|
# ## Key Takeaways
|
|
#
|
|
# 1. **Proper indexing matters**: Use aligned DataFrames, not `get_loc()` on
|
|
# potentially non-unique indices.
|
|
#
|
|
# 2. **CAAR variance is cumulative**: SE(CAAR_t) = sqrt(sum of daily variances),
|
|
# not a simple propagation formula.
|
|
#
|
|
# 3. **Event studies validate signals**: Signal-triggered "events" should produce
|
|
# significant abnormal returns if the signal has predictive power.
|
|
#
|
|
# 4. **Watch for clustering**: Events on the same day violate independence
|
|
# assumptions underlying the t-tests.
|
|
#
|
|
# 5. **Minimum gap prevents overlap**: Enforce at least 20-30 day gaps between
|
|
# events per symbol to keep estimation windows clean.
|
|
#
|
|
# ### Next Notebook
|
|
#
|
|
# - `case_study_feature_summary` — cross-case-study feature inventory
|