eqdp-brief / tests /test_returns.py
Palani-Unison
Initial commit: flip web layer from Next.js to Streamlit
f6dac2a
Raw
History Blame Contribute Delete
5.18 kB
"""Day-3 tests — CAPM β, AR, CAR using toy fixtures (no network)."""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from src.returns import (
BetaEstimate,
abnormal_returns,
cumulative_abnormal_return,
daily_returns,
estimate_beta,
market_adjusted_returns,
)
def _toy_returns(
n: int,
*,
beta: float,
alpha: float = 0.0,
sigma: float = 0.0,
seed: int = 0,
) -> tuple[pd.Series, pd.Series]:
"""Construct (stock_ret, market_ret) where stock = α + β·market + ε."""
rng = np.random.default_rng(seed)
dates = pd.bdate_range("2022-01-03", periods=n, name="date")
market_arr = rng.normal(0, 0.01, size=n)
market = pd.Series(market_arr, index=dates, name="rm")
eps = rng.normal(0, sigma, size=n) if sigma > 0 else np.zeros(n)
stock = pd.Series(alpha + beta * market_arr + eps, index=dates, name="ri")
return stock, market
def test_daily_returns_matches_pct_change() -> None:
prices = pd.Series(
[100.0, 101.0, 99.99, 105.0],
index=pd.bdate_range("2024-01-02", periods=4),
)
rets = daily_returns(prices)
assert pd.isna(rets.iloc[0])
assert rets.iloc[1] == pytest.approx(0.01)
assert rets.iloc[2] == pytest.approx((99.99 - 101.0) / 101.0)
def test_estimate_beta_recovers_known_beta_no_noise() -> None:
stock, market = _toy_returns(400, beta=1.5, alpha=0.0001, sigma=0.0)
# Pick window_end after the data ends so the trailing 252 days fit inside.
window_end = stock.index[-1] + pd.Timedelta(days=60)
est = estimate_beta(
stock,
market,
window_end=window_end,
window_days=252,
gap_days=30,
ticker="TOY",
)
assert est.beta == pytest.approx(1.5, abs=1e-9)
assert est.alpha == pytest.approx(0.0001, abs=1e-9)
assert est.r_squared == pytest.approx(1.0, abs=1e-9)
assert est.ticker == "TOY"
assert est.n_obs == 252
def test_estimate_beta_recovers_with_small_noise() -> None:
stock, market = _toy_returns(400, beta=0.8, sigma=0.001)
window_end = stock.index[-1] + pd.Timedelta(days=60)
est = estimate_beta(stock, market, window_end=window_end, gap_days=30)
assert est.beta == pytest.approx(0.8, abs=0.05)
assert est.r_squared > 0.9
def test_estimate_beta_raises_on_too_few_obs() -> None:
stock, market = _toy_returns(50, beta=1.0)
with pytest.raises(ValueError, match="insufficient data"):
estimate_beta(
stock, market, window_end=pd.Timestamp("2024-01-01"), window_days=252
)
def test_abnormal_returns_zero_when_data_matches_model() -> None:
stock, market = _toy_returns(400, beta=1.2, alpha=0.0002, sigma=0.0)
window_end = stock.index[-1] + pd.Timedelta(days=60)
est = estimate_beta(stock, market, window_end=window_end)
ar = abnormal_returns(stock, market, est)
assert ar.abs().max() < 1e-12
def test_abnormal_returns_isolates_stock_specific_shock() -> None:
stock, market = _toy_returns(400, beta=1.0, sigma=0.0)
# Inject a +5% shock on a single day inside the estimation window
shock_date = stock.index[100]
stock.loc[shock_date] += 0.05
# Estimate β on data BEFORE the shock so the model isn't fit to it.
pre_shock_end = stock.index[80]
window_end = pre_shock_end + pd.Timedelta(days=31)
# Need fewer obs than 252 since pre_shock window is short — use 60-day β.
est = estimate_beta(
stock, market, window_end=window_end, window_days=60, gap_days=30
)
ar = abnormal_returns(stock, market, est)
assert ar.loc[shock_date] == pytest.approx(0.05, abs=1e-3)
def test_cumulative_abnormal_return_sums_window() -> None:
dates = pd.bdate_range("2024-01-02", periods=20)
ar = pd.Series(np.full(20, 0.01), index=dates, name="ar")
car, slice_ = cumulative_abnormal_return(
ar, event_date=dates[10], window=(-2, 5)
)
assert car == pytest.approx(0.01 * 8, abs=1e-12)
assert slice_.index[0] == -2
assert slice_.index[-1] == 5
def test_cumulative_abnormal_return_handles_event_off_calendar() -> None:
dates = pd.bdate_range("2024-01-02", periods=20)
ar = pd.Series(np.arange(20, dtype=float) * 0.001, index=dates)
# Event on Saturday — should skip to Monday (next bdate)
saturday = dates[5] + pd.Timedelta(days=1)
car, slice_ = cumulative_abnormal_return(ar, event_date=saturday, window=(0, 1))
# Next trading day after the saturday is the next bdate in dates
assert slice_.iloc[0] == ar.loc[ar.index >= saturday].iloc[0]
def test_market_adjusted_baseline() -> None:
stock, market = _toy_returns(50, beta=1.5, sigma=0.0)
out = market_adjusted_returns(stock, market)
# AR_market = R_i - R_m = (1.5 - 1) * R_m
assert (out / market).iloc[1:].mean() == pytest.approx(0.5, abs=1e-9)
def test_beta_estimate_expected_return() -> None:
est = BetaEstimate(
ticker="X",
window_end=pd.Timestamp("2024-01-01"),
window_days=252,
alpha=0.0001,
beta=1.2,
r_squared=0.85,
n_obs=252,
)
assert est.expected_return(0.01) == pytest.approx(0.0001 + 1.2 * 0.01)