Spaces:
Sleeping
Sleeping
File size: 5,063 Bytes
f6dac2a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 | """CAPM-adjusted abnormal returns.
Implements docs/METHODOLOGY.md §4. The contract:
R_{i,t} = α_i + β_i · R_{market,t} + ε_{i,t} (estimation)
AR_{i,t} = R_{i,t} − (α_i + β_i · R_{market,t}) (test window)
CAR_{i,[t1,t2]} = Σ AR_{i,t}
β is estimated on a 252-trading-day window ending 30 days before the event
to avoid information leakage. The estimation window and the test window are
disjoint by construction.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import pandas as pd
import statsmodels.api as sm
from src.constants import CAPM_BETA_WINDOW_DAYS, CAPM_GAP_DAYS
@dataclass(frozen=True, slots=True)
class BetaEstimate:
"""Result of a single CAPM β estimation."""
ticker: str
window_end: pd.Timestamp
window_days: int
alpha: float
beta: float
r_squared: float
n_obs: int
def expected_return(self, market_return: float) -> float:
return self.alpha + self.beta * market_return
def daily_returns(prices: pd.Series) -> pd.Series:
"""Simple daily return from an adjusted-close price series, indexed by date.
NaN for the first row (no prior price). Float64 throughout.
"""
if not prices.index.is_monotonic_increasing:
prices = prices.sort_index()
rets = prices.astype("float64").pct_change()
rets.name = "ret"
return rets
def estimate_beta(
stock_returns: pd.Series,
market_returns: pd.Series,
*,
window_end: pd.Timestamp,
window_days: int = CAPM_BETA_WINDOW_DAYS,
gap_days: int = CAPM_GAP_DAYS,
ticker: str | None = None,
) -> BetaEstimate:
"""Estimate (α, β, R²) on the trailing window ending ``window_end - gap_days``.
Inputs are tz-naive return series indexed by date. Both must be aligned;
we re-align here defensively. Raises ValueError if too few observations.
"""
end = pd.Timestamp(window_end).tz_localize(None)
cutoff_end = end - pd.Timedelta(days=gap_days)
# We use calendar days for the window; trading days fall out via .reindex_like.
cutoff_start = cutoff_end - pd.Timedelta(days=int(window_days * 1.6))
aligned = pd.concat(
[stock_returns.rename("y"), market_returns.rename("x")], axis=1
).dropna()
aligned = aligned.loc[
(aligned.index >= cutoff_start) & (aligned.index <= cutoff_end)
]
# Take the last `window_days` trading days.
aligned = aligned.tail(window_days)
if len(aligned) < window_days // 2:
raise ValueError(
f"insufficient data for β: got {len(aligned)} obs, "
f"need ≥{window_days // 2} for window ending {cutoff_end.date()}"
)
y = aligned["y"].to_numpy(dtype="float64")
x = aligned["x"].to_numpy(dtype="float64")
X = sm.add_constant(x)
model = sm.OLS(y, X).fit()
alpha_hat, beta_hat = float(model.params[0]), float(model.params[1])
r_squared = float(model.rsquared)
return BetaEstimate(
ticker=ticker or "",
window_end=end,
window_days=window_days,
alpha=alpha_hat,
beta=beta_hat,
r_squared=r_squared,
n_obs=len(aligned),
)
def abnormal_returns(
stock_returns: pd.Series,
market_returns: pd.Series,
estimate: BetaEstimate,
) -> pd.Series:
"""Pointwise AR_{t} = R_{i,t} − (α + β · R_{m,t})."""
aligned = pd.concat(
[stock_returns.rename("ri"), market_returns.rename("rm")], axis=1
).dropna()
expected = estimate.alpha + estimate.beta * aligned["rm"]
ar = aligned["ri"] - expected
ar.name = "ar"
return ar
def cumulative_abnormal_return(
ar: pd.Series,
event_date: pd.Timestamp,
window: tuple[int, int],
) -> tuple[float, pd.Series]:
"""CAR over a [t1, t2] trading-day window relative to ``event_date``.
Returns (CAR scalar, per-day AR series labelled by relative day t).
Uses trading days from the AR index; if the event date is not a trading
day, the next available trading day is treated as t = 0.
"""
if not ar.index.is_monotonic_increasing:
ar = ar.sort_index()
event = pd.Timestamp(event_date).tz_localize(None)
# Find the first trading day ≥ event_date as t=0
on_or_after = ar.index[ar.index >= event]
if len(on_or_after) == 0:
raise ValueError(f"no trading days on or after {event.date()} in AR series")
t0 = on_or_after[0]
t0_idx = int(np.flatnonzero(ar.index == t0)[0])
t1, t2 = window
lo = max(t0_idx + t1, 0)
hi = min(t0_idx + t2 + 1, len(ar))
slice_ = ar.iloc[lo:hi].copy()
slice_.index = pd.RangeIndex(start=t1, stop=t1 + len(slice_), name="t")
return float(slice_.sum()), slice_
def market_adjusted_returns(
stock_returns: pd.Series, market_returns: pd.Series
) -> pd.Series:
"""Phase-1 baseline: AR = R_i − R_market (no β scaling)."""
aligned = pd.concat(
[stock_returns.rename("ri"), market_returns.rename("rm")], axis=1
).dropna()
out = aligned["ri"] - aligned["rm"]
out.name = "ar_mkt"
return out
|