"""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)