portfolio-engine / tests /test_models.py
engineportf's picture
Initial Deployment from Local Engine
208fbf8 verified
Raw
History Blame Contribute Delete
7.71 kB
import sys
import os
# Bulletproof pathing: Force Python to look in both the current folder AND the parent folder
# This ensures it finds models.py regardless of whether this file is in a /tests subfolder or flat.
_this_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _this_dir)
sys.path.insert(0, os.path.dirname(_this_dir))
import pandas as pd
import numpy as np
from models import model_capm, garch_scale_covariance, regime_stress_covariance, get_conditional_erp
from core_types import CovarianceResult
from config import DEFAULT_CONFIG
# ─────────────────────────────────────────────
# 1. CAPM MODEL TESTS
# ─────────────────────────────────────────────
def test_model_capm():
"""Tests if the Capital Asset Pricing Model calculates Beta correctly."""
dates = pd.date_range("2023-01-01", periods=5)
# Give the market actual variance (1%, 2%, 3%, 4%, 5%)
# If this is flat (e.g. 1% every day), variance is 0 and Beta defaults to 1.0
spy_rets = pd.Series([0.01, 0.02, 0.03, 0.04, 0.05], index=dates)
# Stock A moves exactly twice as fast as the market (Beta = 2.0)
# Stock B moves exactly half as fast as the market (Beta = 0.5)
returns_df = pd.DataFrame({
'STOCK_A': spy_rets * 2.0,
'STOCK_B': spy_rets * 0.5
}, index=dates)
rfr = 0.04 # 4% Risk Free Rate
# Act: Run your CAPM function (explicitly setting periods to 252 for daily)
forecast = model_capm(returns_df, spy_rets, rfr, periods=252, silent=True)
exp_rets, betas = forecast.expected_returns, forecast.betas
# Assert: Check the Betas using np.isclose to avoid floating-point drift
assert np.isclose(betas['STOCK_A'], 2.0)
assert np.isclose(betas['STOCK_B'], 0.5)
# Assert: Check # CAPM Expected Return = RFR + Beta * ERP
expected_a = rfr + (2.0 * get_conditional_erp(rfr))
expected_b = rfr + (0.5 * get_conditional_erp(rfr))
assert np.isclose(exp_rets['STOCK_A'], expected_a)
assert np.isclose(exp_rets['STOCK_B'], expected_b)
def test_capm_monthly_vs_daily():
"""Monthly betas should be approximately equal to daily betas for the same asset."""
rng = np.random.default_rng(42)
dates = pd.date_range("2020-01-01", periods=756, freq="B") # ~3 years
spy_daily = pd.Series(rng.normal(0.0005, 0.01, 756), index=dates)
stock_daily = spy_daily * 1.5 + rng.normal(0, 0.005, 756)
# CAPM on daily
daily_df = pd.DataFrame({'STOCK': stock_daily}, index=dates)
daily_forecast = model_capm(daily_df, spy_daily, 0.04, periods=252, silent=True)
daily_betas = daily_forecast.betas
# CAPM on monthly
monthly_spy = spy_daily.resample('ME').apply(lambda x: (1+x).prod()-1)
monthly_stock = stock_daily.resample('ME').apply(lambda x: (1+x).prod()-1)
monthly_df = pd.DataFrame({'STOCK': monthly_stock}, index=monthly_spy.index)
monthly_forecast = model_capm(monthly_df, monthly_spy, 0.04, periods=12, silent=True)
monthly_betas = monthly_forecast.betas
# Betas should be within 0.20 of each other (monthly has less data = more noise)
assert abs(daily_betas['STOCK'] - monthly_betas['STOCK']) < 0.20
# ─────────────────────────────────────────────
# 2. RISK MODEL COVARIANCE & STRESS TESTS
# ─────────────────────────────────────────────
def test_garch_stationarity():
"""Ensures alpha + beta < 1 for all assets after fitting the GARCH(1,1) model."""
rng = np.random.default_rng(42)
# Create a stationary return series with a volatility spike to give GARCH something to fit
rets = rng.normal(0, 0.01, size=1000)
rets[500:550] *= 5
returns_df = pd.DataFrame({'ASSET_X': rets})
base_cov_df = returns_df.cov() * 252
vols = np.sqrt(np.diag(base_cov_df))
base_corr_df = pd.DataFrame(base_cov_df.values / np.outer(vols, vols), index=base_cov_df.index, columns=base_cov_df.columns)
base_cov = CovarianceResult(covariance=base_cov_df, correlation=base_corr_df, volatility=pd.Series(vols, index=base_cov_df.index))
scaled_cov_res, garch_info = garch_scale_covariance(returns_df, base_cov, silent=True)
assert 'ASSET_X' in garch_info
alpha = garch_info['ASSET_X']['alpha']
beta = garch_info['ASSET_X']['beta']
# Strict stationarity requirement for mean-reverting volatility (or exactly 1.0 for EWMA fallback)
assert alpha + beta <= 1.0
def test_garch_dynamic_correlation_structure():
"""Verifies that Factor-GARCH scaling generates valid correlation matrices that dynamically adapt."""
rng = np.random.default_rng(42)
# Create highly correlated synthetic returns
dates = pd.date_range("2020-01-01", periods=500, freq="B")
spy = rng.normal(0, 0.01, size=500)
aapl = spy * 1.2 + rng.normal(0, 0.005, size=500)
tlt = spy * -0.3 + rng.normal(0, 0.005, size=500)
returns_df = pd.DataFrame({'SPY': spy, 'AAPL': aapl, 'TLT': tlt}, index=dates)
base_cov_df = returns_df.cov() * 252
# Extract base correlation matrix
vols = np.sqrt(np.diag(base_cov_df))
outer_vols = np.outer(vols, vols)
base_corr_df = pd.DataFrame(base_cov_df.values / outer_vols, index=base_cov_df.index, columns=base_cov_df.columns)
base_cov = CovarianceResult(covariance=base_cov_df, correlation=base_corr_df, volatility=pd.Series(vols, index=base_cov_df.index))
# Run GARCH scaler
scaled_cov_res, _ = garch_scale_covariance(returns_df, base_cov, silent=True)
# Extract scaled correlation matrix
scaled_cov = scaled_cov_res.covariance
# Extract scaled correlation matrix
scaled_vols = np.sqrt(np.diag(scaled_cov))
scaled_outer = np.outer(scaled_vols, scaled_vols)
scaled_corr = scaled_cov.values / scaled_outer
# Assert correlations are valid [-1, 1]
assert np.all(scaled_corr >= -1.0 - 1e-5)
assert np.all(scaled_corr <= 1.0 + 1e-5)
# Assert Factor-GARCH allows correlation structure to diverge from uniform CCC
assert not np.allclose(base_corr_df, scaled_corr, atol=1e-8)
def test_regime_stress_cov_is_psd():
"""Verifies that the regime stressor maintains Positive Semi-Definiteness even under extreme skew."""
rng = np.random.default_rng(42)
# Create a random valid covariance matrix
A = rng.random((5, 5))
base_cov_values = np.dot(A, A.transpose())
tickers = ["A", "B", "C", "D", "E"]
base_cov_df = pd.DataFrame(base_cov_values, index=tickers, columns=tickers)
vols = np.sqrt(np.diag(base_cov_df))
base_corr_df = pd.DataFrame(base_cov_df.values / np.outer(vols, vols), index=tickers, columns=tickers)
base_cov = CovarianceResult(covariance=base_cov_df, correlation=base_corr_df, volatility=pd.Series(vols, index=tickers))
# Apply extreme regime stress (3.0 = severe crash multiplier)
stressed_cov_res = regime_stress_covariance(base_cov, regime_severity=3.0)
stressed_cov = stressed_cov_res.covariance
# Check eigenvalues of the resulting matrix
eigvals = np.linalg.eigvalsh(stressed_cov.values)
# All eigenvalues must be >= 0 (allowing for tiny IEEE floating point errors)
assert np.all(eigvals >= -1e-8)