"""Unit tests for v3 posterior assembly and regime classification. Tests for compute_v3_posterior and classify_regime_v3 functions. Requirements validated: 5.1–5.7, 6.1–6.8 """ from __future__ import annotations import math import pytest from services.aggregation.bayesian import V3Posterior, compute_v3_posterior from services.aggregation.regime import ( _V3_REGIME_PARAMS, MarketRegime, V3RegimeClassification, classify_regime_v3, ) from services.aggregation.worker import EvidenceCluster # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_cluster(cluster_llr: float, n_eff: float = 1.0) -> EvidenceCluster: """Create a minimal EvidenceCluster with given LLR and n_eff.""" return EvidenceCluster( cluster_id="test", units=[], llrs=[], n_eff=n_eff, cluster_llr=cluster_llr ) def _make_regime( regime: MarketRegime = MarketRegime.UNCERTAINTY, gamma: float = 0.80 ) -> V3RegimeClassification: """Create a V3RegimeClassification with specified regime and gamma.""" return V3RegimeClassification( regime=regime, trend_z=0.0, vol_ratio=1.0, evidence_multiplier=gamma, confidence_multiplier=0.85, phi_decay=0.50, atr_multiplier=2.0, ) # --------------------------------------------------------------------------- # Test: Empty clusters → P_up = 0.50 (neutral prior) # Requirements: 5.1, 5.3 # --------------------------------------------------------------------------- class TestEmptyClusters: """No evidence → log_odds = logit(0.50) = 0 → P_up = 0.50.""" def test_empty_clusters_gives_neutral_posterior(self): regime = _make_regime() result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.50) assert isinstance(result, V3Posterior) assert result.p_up == pytest.approx(0.50, abs=1e-9) assert result.p_down == pytest.approx(0.50, abs=1e-9) assert result.log_odds == pytest.approx(0.0, abs=1e-9) assert result.strength == pytest.approx(0.0, abs=1e-9) assert result.direction == "neutral" assert result.n_eff_total == 0.0 def test_empty_clusters_with_default_prior(self): regime = _make_regime() result = compute_v3_posterior(clusters=[], regime=regime) assert result.p_up == pytest.approx(0.50, abs=1e-9) # --------------------------------------------------------------------------- # Test: All bullish clusters → P_up > 0.50 # Requirements: 5.1, 5.2 # --------------------------------------------------------------------------- class TestAllBullishClusters: """Positive cluster LLRs with uncertainty regime (gamma=0.80) → P_up > 0.50.""" def test_bullish_clusters_give_p_up_above_half(self): clusters = [_make_cluster(1.0), _make_cluster(0.5)] regime = _make_regime(MarketRegime.UNCERTAINTY, gamma=0.80) result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50) assert result.p_up > 0.50 assert result.direction in ("bullish", "neutral") # depends on threshold assert result.log_odds > 0.0 def test_bullish_computes_correct_log_odds(self): """Verify log_odds = logit(0.50) + gamma * sum(LLR_c).""" clusters = [_make_cluster(1.0), _make_cluster(0.5)] regime = _make_regime(MarketRegime.UNCERTAINTY, gamma=0.80) result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50) expected_log_odds = 0.0 + 0.80 * (1.0 + 0.5) # = 1.2 assert result.log_odds == pytest.approx(expected_log_odds, abs=1e-9) # --------------------------------------------------------------------------- # Test: All bearish clusters → P_up < 0.50 # Requirements: 5.1, 5.2 # --------------------------------------------------------------------------- class TestAllBearishClusters: """Negative cluster LLRs → P_up < 0.50.""" def test_bearish_clusters_give_p_up_below_half(self): clusters = [_make_cluster(-1.0), _make_cluster(-0.5)] regime = _make_regime(MarketRegime.UNCERTAINTY, gamma=0.80) result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50) assert result.p_up < 0.50 assert result.log_odds < 0.0 def test_bearish_computes_correct_log_odds(self): clusters = [_make_cluster(-1.0), _make_cluster(-0.5)] regime = _make_regime(MarketRegime.UNCERTAINTY, gamma=0.80) result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50) expected_log_odds = 0.0 + 0.80 * (-1.0 + -0.5) # = -1.2 assert result.log_odds == pytest.approx(expected_log_odds, abs=1e-9) # --------------------------------------------------------------------------- # Test: Regime direction thresholds at boundary values # Requirements: 5.5 # --------------------------------------------------------------------------- class TestDirectionThresholds: """Test direction classification at panic regime boundaries (0.68/0.32).""" def _compute_with_target_p_up(self, target_p_up: float) -> V3Posterior: """Compute posterior that results in a specific P_up value. We reverse-engineer the cluster LLR needed to produce the target P_up under panic regime with gamma=0.70 and p_prior=0.50. """ # logit(target) = logit(0.50) + gamma * cluster_llr # logit(target) = 0.0 + 0.70 * cluster_llr # cluster_llr = logit(target) / 0.70 logit_target = math.log(target_p_up / (1.0 - target_p_up)) cluster_llr = logit_target / 0.70 clusters = [_make_cluster(cluster_llr)] regime = _make_regime(MarketRegime.PANIC, gamma=0.70) return compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50) def test_panic_bullish_at_068(self): """P_up = 0.68 → bullish in panic regime (threshold is 0.68).""" result = self._compute_with_target_p_up(0.68) assert result.p_up == pytest.approx(0.68, abs=1e-6) assert result.direction == "bullish" def test_panic_neutral_at_067(self): """P_up = 0.67 → neutral in panic regime (below 0.68 threshold).""" result = self._compute_with_target_p_up(0.67) assert result.p_up == pytest.approx(0.67, abs=1e-6) assert result.direction == "neutral" def test_panic_bearish_at_032(self): """P_up = 0.32 → bearish in panic regime (threshold is 0.32).""" result = self._compute_with_target_p_up(0.32) assert result.p_up == pytest.approx(0.32, abs=1e-6) assert result.direction == "bearish" def test_panic_neutral_at_033(self): """P_up = 0.33 → neutral in panic regime (above 0.32 threshold).""" result = self._compute_with_target_p_up(0.33) assert result.p_up == pytest.approx(0.33, abs=1e-6) assert result.direction == "neutral" def test_trend_following_thresholds(self): """Trend following thresholds: bullish >= 0.60, bearish <= 0.40.""" # Bullish at 0.60 logit_target = math.log(0.60 / 0.40) cluster_llr = logit_target / 1.10 # gamma for trend_following clusters = [_make_cluster(cluster_llr)] regime = _make_regime(MarketRegime.TREND_FOLLOWING, gamma=1.10) result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50) assert result.p_up == pytest.approx(0.60, abs=1e-6) assert result.direction == "bullish" def test_mean_reversion_thresholds(self): """Mean reversion thresholds: bullish >= 0.63, bearish <= 0.37.""" # Use slightly above 0.63 to avoid floating-point boundary issues target = 0.631 logit_target = math.log(target / (1.0 - target)) cluster_llr = logit_target / 0.90 # gamma for mean_reversion clusters = [_make_cluster(cluster_llr)] regime = _make_regime(MarketRegime.MEAN_REVERSION, gamma=0.90) result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50) assert result.p_up >= 0.63 assert result.direction == "bullish" # --------------------------------------------------------------------------- # Test: Prior clamp [0.40, 0.60] # Requirements: 5.6 # --------------------------------------------------------------------------- class TestPriorClamp: """Prior values outside [0.40, 0.60] are clamped.""" def test_prior_below_040_clamped(self): """p_prior=0.30 → clamped to 0.40.""" regime = _make_regime() result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.30) # logit(0.40) ≈ -0.4055 expected_log_odds = math.log(0.40 / 0.60) assert result.log_odds == pytest.approx(expected_log_odds, abs=1e-9) # P_up should be 0.40 with no evidence assert result.p_up == pytest.approx(0.40, abs=1e-6) def test_prior_above_060_clamped(self): """p_prior=0.80 → clamped to 0.60.""" regime = _make_regime() result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.80) expected_log_odds = math.log(0.60 / 0.40) assert result.log_odds == pytest.approx(expected_log_odds, abs=1e-9) assert result.p_up == pytest.approx(0.60, abs=1e-6) def test_prior_at_040_not_clamped(self): """p_prior=0.40 is within bounds, no clamping.""" regime = _make_regime() result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.40) assert result.p_up == pytest.approx(0.40, abs=1e-6) def test_prior_at_060_not_clamped(self): """p_prior=0.60 is within bounds, no clamping.""" regime = _make_regime() result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.60) assert result.p_up == pytest.approx(0.60, abs=1e-6) def test_prior_at_050_standard(self): """p_prior=0.50 is standard neutral prior.""" regime = _make_regime() result = compute_v3_posterior(clusters=[], regime=regime, p_prior=0.50) assert result.p_up == pytest.approx(0.50, abs=1e-9) # --------------------------------------------------------------------------- # Test: Regime classification with known inputs # Requirements: 6.1–6.8 # --------------------------------------------------------------------------- class TestRegimeClassificationKnownInputs: """Test classify_regime_v3 with known inputs mapping to specific regimes.""" def _make_prices_and_returns( self, n: int = 120 ) -> tuple[list[float], list[float]]: """Generate flat price series and near-zero returns.""" prices = [100.0] * n returns = [0.001] * n return prices, returns def test_high_vol_ratio_triggers_panic(self): """vol_ratio > 1.5 → panic.""" # Generate returns where sigma_20 >> sigma_100 # sigma_20 high, sigma_100 low → vol_ratio > 1.5 prices = [100.0] * 120 # Low-vol returns for sigma_100 context returns_low = [0.001] * 80 # High-vol returns for sigma_20 returns_high = [0.05, -0.05] * 10 # stdev ≈ 0.0526 returns = returns_low + returns_high # With flat prices, trend_z ≈ 0, but vol_ratio > 1.5 → panic result = classify_regime_v3(prices, returns, atr_20=1.0) assert result.regime == MarketRegime.PANIC def test_trend_following_regime(self): """trend_z >= 0.75, vol_ratio < 1.3 → trend_following. |trend_z| >= 0.75 AND vol_ratio < 1.3 → trend_following. """ # Trending price series: EMA_20 significantly above EMA_100 prices = [100.0 + i * 0.5 for i in range(120)] # Varied returns with stable vol (sigma_20 ≈ sigma_100 → vol_ratio near 1.0) returns = [0.005 + (i % 5) * 0.001 for i in range(120)] # ATR chosen so trend_z ≈ 1.77 (well above 0.75 threshold) result = classify_regime_v3(prices, returns, atr_20=10.0) assert result.regime == MarketRegime.TREND_FOLLOWING assert abs(result.trend_z) >= 0.75 assert result.vol_ratio < 1.3 def test_mean_reversion_regime(self): """|trend_z| < 0.50 AND vol_ratio < 1.0 → mean_reversion.""" # Flat prices → trend_z ≈ 0 prices = [100.0] * 120 # Returns with decreasing volatility (sigma_20 < sigma_100) # High vol early, low vol recently returns_early = [0.03, -0.03] * 40 # high vol for sigma_100 returns_recent = [0.001] * 40 # low vol for sigma_20 returns = returns_early + returns_recent # Large ATR so trend_z stays small result = classify_regime_v3(prices, returns, atr_20=50.0) assert result.regime == MarketRegime.MEAN_REVERSION assert abs(result.trend_z) < 0.50 assert result.vol_ratio < 1.0 def test_uncertainty_regime_default(self): """When conditions don't match any specific regime → uncertainty. |trend_z| between 0.50 and 0.75 OR vol_ratio between 1.0 and 1.3. """ # Mild trend + moderate vol → uncertainty # Slightly trending prices but not enough for trend_following prices = [100.0 + i * 0.1 for i in range(120)] # Uniform returns → vol_ratio ≈ 1.0 returns = [0.01] * 120 # ATR chosen so |trend_z| is between 0.50 and 0.75 # We need to find ATR such that it lands in uncertainty # With mild trend, vol_ratio ≈ 1.0 (not < 1.0), so mean_reversion won't fire # And trend_z might be < 0.75, so trend_following won't fire result = classify_regime_v3(prices, returns, atr_20=5.0) # With uniform returns, stdev is 0 → sigma_100 = 0 # We need non-trivial returns. Let's use a better approach. # Use returns that give vol_ratio between 1.0 and 1.3 returns_varied = [0.01 + (i % 3) * 0.002 for i in range(120)] result = classify_regime_v3(prices, returns_varied, atr_20=5.0) # This should fall through to uncertainty since conditions are moderate assert result.regime == MarketRegime.UNCERTAINTY def test_data_insufficient_returns_uncertainty(self): """Fewer than 100 closing prices → default uncertainty.""" prices = [100.0] * 50 # < 100 returns = [0.01] * 50 result = classify_regime_v3(prices, returns, atr_20=1.0) assert result.regime == MarketRegime.UNCERTAINTY assert result.trend_z == 0.0 assert result.vol_ratio == 1.0 assert result.evidence_multiplier == 0.80 def test_atr_zero_returns_uncertainty(self): """ATR_20 <= 0 → default uncertainty (Req 6.8).""" prices = [100.0] * 120 returns = [0.01] * 120 result = classify_regime_v3(prices, returns, atr_20=0.0) assert result.regime == MarketRegime.UNCERTAINTY def test_insufficient_returns_data(self): """Fewer than 100 daily returns → default uncertainty.""" prices = [100.0] * 120 returns = [0.01] * 50 # < 100 result = classify_regime_v3(prices, returns, atr_20=1.0) assert result.regime == MarketRegime.UNCERTAINTY # --------------------------------------------------------------------------- # Test: Regime parameters are correctly assigned # Requirements: 6.6, 6.7 # --------------------------------------------------------------------------- class TestRegimeParameters: """Verify regime parameters (gamma, conf_mult, phi, atr_mult) are assigned.""" def test_panic_parameters(self): gamma, conf_mult, phi, atr_mult, min_edge = _V3_REGIME_PARAMS[ MarketRegime.PANIC ] assert gamma == 0.70 assert conf_mult == 0.70 assert phi == 0.35 assert atr_mult == 2.5 assert min_edge == 0.0100 def test_trend_following_parameters(self): gamma, conf_mult, phi, atr_mult, min_edge = _V3_REGIME_PARAMS[ MarketRegime.TREND_FOLLOWING ] assert gamma == 1.10 assert conf_mult == 1.00 assert phi == 0.80 assert atr_mult == 1.8 assert min_edge == 0.0035 def test_mean_reversion_parameters(self): gamma, conf_mult, phi, atr_mult, min_edge = _V3_REGIME_PARAMS[ MarketRegime.MEAN_REVERSION ] assert gamma == 0.90 assert conf_mult == 0.95 assert phi == 0.55 assert atr_mult == 1.4 assert min_edge == 0.0050 def test_uncertainty_parameters(self): gamma, conf_mult, phi, atr_mult, min_edge = _V3_REGIME_PARAMS[ MarketRegime.UNCERTAINTY ] assert gamma == 0.80 assert conf_mult == 0.85 assert phi == 0.50 assert atr_mult == 2.0 assert min_edge == 0.0075 # --------------------------------------------------------------------------- # Test: Posterior output fields # Requirements: 5.4, 5.5 # --------------------------------------------------------------------------- class TestPosteriorOutputFields: """Verify derived fields (strength, n_eff_total, regime) are correct.""" def test_strength_computed_correctly(self): """strength = abs(2 × P_up - 1).""" clusters = [_make_cluster(1.5)] regime = _make_regime(MarketRegime.UNCERTAINTY, gamma=0.80) result = compute_v3_posterior(clusters=clusters, regime=regime, p_prior=0.50) expected_strength = abs(2.0 * result.p_up - 1.0) assert result.strength == pytest.approx(expected_strength, abs=1e-9) def test_n_eff_total_sums_clusters(self): """n_eff_total = sum of cluster n_eff values.""" clusters = [ _make_cluster(0.5, n_eff=2.0), _make_cluster(0.3, n_eff=1.5), _make_cluster(-0.2, n_eff=3.0), ] regime = _make_regime() result = compute_v3_posterior(clusters=clusters, regime=regime) assert result.n_eff_total == pytest.approx(6.5, abs=1e-9) def test_regime_string_matches_input(self): """regime field should match the input regime's value.""" regime = _make_regime(MarketRegime.PANIC, gamma=0.70) result = compute_v3_posterior(clusters=[], regime=regime) assert result.regime == "panic" def test_p_down_is_complement(self): """p_down = 1 - p_up.""" clusters = [_make_cluster(0.8)] regime = _make_regime() result = compute_v3_posterior(clusters=clusters, regime=regime) assert result.p_down == pytest.approx(1.0 - result.p_up, abs=1e-10)