Python 56.2%
TeX 43.7%
1"""High-frequency event study around macro events.23Implements the event-study methodology of MacKinlay (1997) with4Patell (1976) and Boehmer-Musumeci-Poulsen (1991) test statistics.5"""67from __future__ import annotations89import pandas as pd10import numpy as np11from scipy import stats12from dataclasses import dataclass131415# ---------------------------------------------------------------------------16# Data classes17# ---------------------------------------------------------------------------1819@dataclass20class EventStudyResult:21 """Container for event-study output."""22 cars: pd.DataFrame # [event_date, window, CAR]23 test_stats: pd.DataFrame # [window, mean_CAR, t_stat, p_value]24 event_type: str25 n_events: int262728# ---------------------------------------------------------------------------29# Core event study30# ---------------------------------------------------------------------------3132def run_event_study(33 prices: pd.DataFrame,34 event_dates: list,35 event_type: str = "FOMC",36 window: tuple[int, int] = (-5, 5),37 estimation_window: int = 60,38) -> EventStudyResult:39 """Event study methodology following MacKinlay (1997).4041 1. For each event date, find the row in *prices* closest to that date.42 2. Estimation window: ``[t - estimation_window, t + window[0] - 1]``43 for the normal-return (constant-mean) model.44 3. Event window: ``[t + window[0], t + window[1]]``.45 4. Abnormal return = actual return - expected return.46 5. CAR = cumulative sum of abnormal returns in the event window.47 6. Test statistics: cross-sectional *t*-test of CARs.4849 Parameters50 ----------51 prices : pd.DataFrame52 Each column is a market's price series with a DatetimeIndex.53 event_dates : list54 Dates (or date-like objects) of the events.55 event_type : str56 Label for the event category (e.g. ``"FOMC"``).57 window : tuple[int, int]58 (pre, post) trading-day offsets around each event.59 estimation_window : int60 Number of trading days used to estimate the normal-return model.6162 Returns63 -------64 EventStudyResult65 """66 # Compute simple returns67 returns = prices.pct_change().dropna(how="all")6869 # Ensure DatetimeIndex70 if not isinstance(returns.index, pd.DatetimeIndex):71 returns.index = pd.to_datetime(returns.index)7273 event_dates_dt = pd.to_datetime(event_dates)7475 all_cars: list[dict] = []76 all_ars: list[pd.DataFrame] = []7778 for ev_date in event_dates_dt:79 # Find closest available trading day80 idx = returns.index.searchsorted(ev_date)81 if idx >= len(returns.index):82 idx = len(returns.index) - 183 t0 = idx # position index8485 est_start = t0 - estimation_window + window[0] - 186 est_end = t0 + window[0] - 187 ev_start = t0 + window[0]88 ev_end = t0 + window[1]8990 # Boundary checks91 if est_start < 0 or ev_end >= len(returns):92 continue9394 # Estimation-window returns → constant-mean model95 est_returns = returns.iloc[est_start:est_end + 1]96 expected = est_returns.mean() # per column97 est_std = est_returns.std()9899 # Event-window abnormal returns100 ev_returns = returns.iloc[ev_start:ev_end + 1]101 ar = ev_returns.subtract(expected, axis=1)102 car = ar.cumsum()103104 # Store CAR at end of window (last row)105 final_car = car.iloc[-1]106 for col in final_car.index:107 all_cars.append({108 "event_date": ev_date,109 "market": col,110 "window": f"[{window[0]},{window[1]}]",111 "CAR": final_car[col],112 })113114 all_ars.append(ar)115116 cars_df = pd.DataFrame(all_cars)117 n_events = int(cars_df["event_date"].nunique()) if len(cars_df) > 0 else 0118119 # Cross-sectional test statistics per market120 test_rows: list[dict] = []121 if len(cars_df) > 0:122 for market, grp in cars_df.groupby("market"):123 car_vals = grp["CAR"].dropna()124 if len(car_vals) < 2:125 continue126 t_stat, p_val = stats.ttest_1samp(car_vals, 0)127 test_rows.append({128 "market": market,129 "window": f"[{window[0]},{window[1]}]",130 "mean_CAR": float(car_vals.mean()),131 "t_stat": float(t_stat),132 "p_value": float(p_val),133 "n_events": len(car_vals),134 })135136 test_df = pd.DataFrame(test_rows)137138 return EventStudyResult(139 cars=cars_df,140 test_stats=test_df,141 event_type=event_type,142 n_events=n_events,143 )144145146# ---------------------------------------------------------------------------147# Patell (1976) test148# ---------------------------------------------------------------------------149150def compute_patell_test(151 abnormal_returns: pd.DataFrame,152 std_errors: pd.Series,153) -> float:154 """Patell (1976) standardised-residual test.155156 Parameters157 ----------158 abnormal_returns : pd.DataFrame159 Abnormal returns (events x time).160 std_errors : pd.Series161 Estimation-window standard errors for each event.162163 Returns164 -------165 float166 Patell *z*-statistic.167 """168 # Standardise each event's AR by its estimation-window std error169 sar = abnormal_returns.div(std_errors, axis=0)170 # Cumulative standardised AR171 csar = sar.sum(axis=1) # sum across event window per event172 n = len(csar)173 if n == 0:174 return np.nan175 z = csar.mean() / (csar.std() / np.sqrt(n))176 return float(z)177178179# ---------------------------------------------------------------------------180# BMP (1991) test181# ---------------------------------------------------------------------------182183def compute_bmp_test(184 standardized_ars: pd.DataFrame,185) -> tuple[float, float]:186 """Boehmer, Musumeci, Poulsen (1991) test for event-induced variance.187188 Uses cross-sectional variance of standardised abnormal returns to189 produce a *t*-statistic robust to event-induced variance changes.190191 Parameters192 ----------193 standardized_ars : pd.DataFrame194 Standardised abnormal returns (events x time-steps in window).195196 Returns197 -------198 tuple[float, float]199 (BMP *t*-statistic, two-sided *p*-value).200 """201 # Average SAR across the event window for each event202 avg_sar = standardized_ars.mean(axis=1)203 n = len(avg_sar)204 if n < 2:205 return (np.nan, np.nan)206207 mean_sar = avg_sar.mean()208 se = avg_sar.std(ddof=1) / np.sqrt(n)209 t_stat = mean_sar / se if se > 0 else np.nan210 p_value = float(2 * stats.t.sf(abs(t_stat), df=n - 1)) if not np.isnan(t_stat) else np.nan211 return (float(t_stat), p_value)212