eqdp-brief / src /returns.py
Palani-Unison
Initial commit: flip web layer from Next.js to Streamlit
f6dac2a
Raw
History Blame Contribute Delete
5.06 kB
"""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