alphaforge-quant-system / event_study.py
Premchan369's picture
Add event study module - earnings, macro events, event-driven alpha with abnormal returns
72ce2c5 verified
"""event_study.py — Event-Driven Alpha & Event Study Analysis
Detects and analyzes market events (earnings, macro releases, M&A, FDA decisions)
to generate abnormal returns and event-driven trading signals.
References:
- MacKinlay 1997: "Event Studies in Economics and Finance"
- Engelberg et al. 2012: "Market Madness? The Case of Mad Money"
- Savor & Wilson 2014: "Asset Pricing: A Tale of Two Days"
"""
import numpy as np, pandas as pd
from scipy import stats
class EventStudyAnalyzer:
"""Event study analysis for earnings, macro, and corporate events."""
def __init__(self, estimation_window=252, event_window=(-5, 20)):
self.est_window = estimation_window
self.ev_window = event_window
def abnormal_returns(self, prices, market_prices, event_dates):
"""Compute abnormal returns around event dates."""
ret = prices.pct_change().dropna()
mkt_ret = market_prices.pct_change().dropna()
# Market model: R_i = alpha + beta * R_m + epsilon
common = ret.index.intersection(mkt_ret.index)
y = ret.loc[common].values
X = np.column_stack([np.ones(len(common)), mkt_ret.loc[common].values])
beta = np.linalg.lstsq(X, y, rcond=None)[0]
expected = beta[0] + beta[1] * mkt_ret
abnormal = ret - expected.reindex(ret.index, fill_value=0)
results = []
for date in event_dates:
try:
idx = ret.index.get_loc(pd.Timestamp(date))
start = max(0, idx + self.ev_window[0])
end = min(len(ret), idx + self.ev_window[1] + 1)
ar = abnormal.iloc[start:end]
car = ar.cumsum()
results.append({
'event_date': date,
'car_day0': float(ar.iloc[self.ev_window[0]*-1] if len(ar) > self.ev_window[0]*-1 else np.nan),
'car_window': float(car.iloc[-1] - car.iloc[0]),
'max_car': float(car.max()),
'min_car': float(car.min()),
'volatility_event': float(ar.std()),
't_stat': float(ar.mean() / (ar.std() + 1e-10) * np.sqrt(len(ar)))
})
except Exception:
continue
return pd.DataFrame(results)
def earnings_event_signal(self, prices, earnings_dates, surprise_threshold=0.05):
"""Generate post-earnings drift (PED) signal.
Positive earnings surprise → buy for 1-60 days.
"""
ret = prices.pct_change().dropna()
signals = pd.Series(0.0, index=prices.index)
for ed in earnings_dates:
try:
idx = ret.index.get_loc(pd.Timestamp(ed))
if idx + 1 < len(ret):
# 1-day reaction as proxy for surprise
day1_ret = ret.iloc[idx + 1]
if abs(day1_ret) > surprise_threshold:
# Signal for next 5 days
for d in range(1, 6):
if idx + 1 + d < len(signals):
signals.iloc[idx + 1 + d] = np.sign(day1_ret) * 0.2
except Exception:
continue
return signals
def macro_event_signal(self, prices, macro_dates, volatility_threshold=0.015):
"""Signal around macro events (FOMC, NFP, CPI).
Pre-event volatility compression → post-event volatility expansion.
"""
ret = prices.pct_change().dropna()
vol = ret.rolling(20).std()
signals = pd.Series(0.0, index=prices.index)
for md in macro_dates:
try:
idx = ret.index.get_loc(pd.Timestamp(md))
if idx >= 20 and idx + 5 < len(ret):
pre_vol = vol.iloc[idx - 5]
post_vol = vol.iloc[idx:idx+5].mean()
if pre_vol < volatility_threshold and post_vol > pre_vol * 1.5:
signals.iloc[idx:idx+5] = np.sign(ret.iloc[idx]) * 0.3
except Exception:
continue
return signals
def merger_arbitrage_signal(self, acquirer_prices, target_prices, spread_threshold=0.02):
"""Merger arbitrage: long target, short acquirer when deal spread exists."""
target_ret = target_prices.pct_change().dropna()
acquirer_ret = acquirer_prices.pct_change().dropna()
spread = (target_prices / acquirer_prices - 1).dropna()
signal = pd.Series(0.0, index=target_prices.index)
signal[spread > spread_threshold] = 1.0 # Long target
signal[spread < -spread_threshold] = -0.5 # Short acquirer
return signal
def event_calendar(self, ticker):
"""Generate synthetic event calendar for a ticker."""
# In production, this would call an API (EarningsWhispers, etc.)
today = pd.Timestamp.now()
return pd.DataFrame({
'date': [today + pd.DateOffset(days=30), today + pd.DateOffset(days=60), today + pd.DateOffset(days=90)],
'event': ['Q3 Earnings', 'FOMC Meeting', 'Q4 GDP Release'],
'type': ['earnings', 'macro', 'macro'],
'impact': ['high', 'high', 'medium']
})
def report(self, prices, market_prices, event_dates, event_type='earnings'):
"""Full event study report."""
ar = self.abnormal_returns(prices, market_prices, event_dates)
if ar.empty:
return "## Event Study\n\nNo valid events found in data range."
mean_car = ar['car_window'].mean()
t_stat = ar['car_window'].mean() / (ar['car_window'].std() + 1e-10) * np.sqrt(len(ar))
p_val = 2 * (1 - stats.t.cdf(abs(t_stat), len(ar)-1))
return f"""## Event Study: {event_type.upper()}
| Statistic | Value |
|-----------|-------|
| Events analyzed | {len(ar)} |
| Mean CAR (window) | {mean_car*100:.2f}% |
| Median CAR | {ar['car_window'].median()*100:.2f}% |
| Day-0 abnormal return | {ar['car_day0'].mean()*100:.2f}% |
| t-statistic | {t_stat:.2f} |
| p-value | {p_val:.4f} |
| Significant? | {'✅ Yes' if p_val < 0.05 else '❌ No'} |
**Interpretation:** {mean_car > 0 and 'Positive' or 'Negative'} post-event drift of {abs(mean_car)*100:.2f}% on average.
"""
if __name__ == '__main__':
np.random.seed(42)
prices = pd.Series(np.cumprod(1 + np.random.normal(0.0005, 0.015, 500)),
index=pd.date_range('2022-01-01', periods=500, freq='B'))
market = pd.Series(np.cumprod(1 + np.random.normal(0.0003, 0.012, 500)),
index=pd.date_range('2022-01-01', periods=500, freq='B'))
events = ['2022-03-15', '2022-06-15', '2022-09-15', '2022-12-15']
esa = EventStudyAnalyzer()
print(esa.report(prices, market, events))