"""Unit tests for regime detector (services/aggregation/regime.py). Tests specific (R, V_r) → regime classification, threshold adjustments per regime, and insufficient data fallback to uncertainty. Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7, 7.9 """ from __future__ import annotations import pytest from services.aggregation.regime import ( MarketRegime, classify_regime, compute_ema, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_uptrend_prices(n: int = 120) -> list[float]: """Generate prices with EMA_20 > EMA_100 (uptrend, R=+1).""" # Start low, end high — recent prices much higher than old ones return [100.0 + i * 0.5 for i in range(n)] def _make_downtrend_prices(n: int = 120) -> list[float]: """Generate prices with EMA_20 < EMA_100 (downtrend, R=-1).""" # Start high, end low — recent prices much lower than old ones return [200.0 - i * 0.5 for i in range(n)] def _make_flat_prices(n: int = 120) -> list[float]: """Generate flat prices where EMA_20 ≈ EMA_100 (R=0).""" return [100.0] * n def _make_low_vol_returns(n: int = 120) -> list[float]: """Generate returns with σ_20 / σ_100 < 1.0 (low recent volatility).""" # First 100 returns have higher variance, last 20 have lower variance base = [0.02 * ((-1) ** i) for i in range(n - 20)] recent = [0.005 * ((-1) ** i) for i in range(20)] return base + recent def _make_high_vol_returns(n: int = 120) -> list[float]: """Generate returns with σ_20 / σ_100 > 1.5 (panic volatility).""" # First 100 returns have low variance, last 20 have very high variance base = [0.005 * ((-1) ** i) for i in range(n - 20)] recent = [0.08 * ((-1) ** i) for i in range(20)] return base + recent def _make_moderate_vol_returns(n: int = 120) -> list[float]: """Generate returns with V_r between 1.0 and 1.2.""" # Slightly higher recent volatility base = [0.01 * ((-1) ** i) for i in range(n - 20)] recent = [0.012 * ((-1) ** i) for i in range(20)] return base + recent # --------------------------------------------------------------------------- # compute_ema # --------------------------------------------------------------------------- class TestComputeEma: """Test EMA computation.""" def test_single_value(self): assert compute_ema([100.0], 1) == pytest.approx(100.0) def test_constant_values(self): """EMA of constant values equals that constant.""" assert compute_ema([50.0] * 20, 20) == pytest.approx(50.0) def test_empty_raises(self): with pytest.raises(ValueError): compute_ema([], 10) def test_zero_period_raises(self): with pytest.raises(ValueError): compute_ema([1.0, 2.0], 0) # --------------------------------------------------------------------------- # Regime classification: specific (R, V_r) → expected regime # --------------------------------------------------------------------------- class TestRegimeClassification: """Test specific (R, V_r) → expected regime classification (Req 7.3).""" def test_trend_following_uptrend(self): """R=+1, V_r < 1.2 → trend_following.""" prices = _make_uptrend_prices() returns = _make_moderate_vol_returns() result = classify_regime(prices, returns) assert result.regime == MarketRegime.TREND_FOLLOWING assert result.trend_indicator == 1.0 def test_trend_following_downtrend(self): """R=-1, V_r < 1.2 → trend_following.""" prices = _make_downtrend_prices() returns = _make_moderate_vol_returns() result = classify_regime(prices, returns) assert result.regime == MarketRegime.TREND_FOLLOWING assert result.trend_indicator == -1.0 def test_panic_regime(self): """V_r > 1.5 → panic (regardless of R).""" prices = _make_uptrend_prices() returns = _make_high_vol_returns() result = classify_regime(prices, returns) assert result.regime == MarketRegime.PANIC def test_mean_reversion_regime(self): """R=0, V_r < 1.0 → mean_reversion.""" prices = _make_flat_prices() returns = _make_low_vol_returns() result = classify_regime(prices, returns) assert result.regime == MarketRegime.MEAN_REVERSION def test_uncertainty_regime(self): """R=0, V_r between 1.0 and 1.5 → uncertainty.""" prices = _make_flat_prices() # Returns with V_r between 1.0 and 1.5 but not < 1.0 # Use moderate vol that gives V_r ≈ 1.1 with flat prices returns = _make_moderate_vol_returns() result = classify_regime(prices, returns) # With flat prices R=0, and moderate vol V_r ≈ 1.1 (> 1.0) # This falls into uncertainty (R=0 AND V_r >= 1.0) assert result.regime == MarketRegime.UNCERTAINTY # --------------------------------------------------------------------------- # Threshold adjustments per regime (Req 7.4, 7.5, 7.6, 7.7) # --------------------------------------------------------------------------- class TestRegimeThresholds: """Test threshold adjustments per regime.""" def test_panic_threshold(self): """Panic regime → threshold ±0.10 (Req 7.4).""" prices = _make_uptrend_prices() returns = _make_high_vol_returns() result = classify_regime(prices, returns) assert result.regime == MarketRegime.PANIC assert result.bullish_threshold == pytest.approx(0.10) assert result.bearish_threshold == pytest.approx(-0.10) def test_mean_reversion_threshold(self): """Mean-reversion regime → threshold ±0.20 (Req 7.5).""" prices = _make_flat_prices() returns = _make_low_vol_returns() result = classify_regime(prices, returns) assert result.regime == MarketRegime.MEAN_REVERSION assert result.bullish_threshold == pytest.approx(0.20) assert result.bearish_threshold == pytest.approx(-0.20) def test_trend_following_threshold(self): """Trend-following regime → threshold ±0.15 (Req 7.6).""" prices = _make_uptrend_prices() returns = _make_moderate_vol_returns() result = classify_regime(prices, returns) assert result.regime == MarketRegime.TREND_FOLLOWING assert result.bullish_threshold == pytest.approx(0.15) assert result.bearish_threshold == pytest.approx(-0.15) def test_uncertainty_contradiction_multiplier(self): """Uncertainty regime → contradiction multiplier 0.6 (Req 7.7).""" prices = _make_flat_prices() returns = _make_moderate_vol_returns() result = classify_regime(prices, returns) assert result.regime == MarketRegime.UNCERTAINTY assert result.contradiction_penalty_multiplier == pytest.approx(0.6) def test_non_uncertainty_contradiction_multiplier(self): """Non-uncertainty regimes → contradiction multiplier 0.4.""" prices = _make_uptrend_prices() returns = _make_moderate_vol_returns() result = classify_regime(prices, returns) assert result.regime == MarketRegime.TREND_FOLLOWING assert result.contradiction_penalty_multiplier == pytest.approx(0.4) # --------------------------------------------------------------------------- # Insufficient data fallback to uncertainty (Req 7.9) # --------------------------------------------------------------------------- class TestInsufficientDataFallback: """Test fallback to uncertainty when data is insufficient.""" def test_too_few_prices(self): """Fewer than 100 closing prices → uncertainty.""" prices = [100.0] * 50 # only 50 days returns = [0.01] * 100 result = classify_regime(prices, returns) assert result.regime == MarketRegime.UNCERTAINTY def test_too_few_returns(self): """Fewer than 100 returns → uncertainty.""" prices = [100.0] * 120 returns = [0.01] * 50 # only 50 returns result = classify_regime(prices, returns) assert result.regime == MarketRegime.UNCERTAINTY def test_empty_prices(self): """Empty price list → uncertainty.""" result = classify_regime([], [0.01] * 100) assert result.regime == MarketRegime.UNCERTAINTY def test_empty_returns(self): """Empty return list → uncertainty.""" result = classify_regime([100.0] * 120, []) assert result.regime == MarketRegime.UNCERTAINTY def test_zero_sigma_returns_uncertainty(self): """All identical returns (σ=0) → uncertainty.""" prices = _make_uptrend_prices() returns = [0.0] * 120 # zero standard deviation result = classify_regime(prices, returns) assert result.regime == MarketRegime.UNCERTAINTY def test_default_uncertainty_values(self): """Default uncertainty has standard threshold values.""" result = classify_regime([], []) assert result.regime == MarketRegime.UNCERTAINTY assert result.bullish_threshold == pytest.approx(0.15) assert result.bearish_threshold == pytest.approx(-0.15) assert result.contradiction_penalty_multiplier == pytest.approx(0.6) assert result.trend_indicator == 0.0 assert result.volatility_ratio == 1.0