"""Unit tests for v3 EV gate, return distribution, and eligibility. Tests the return distribution computation, regime-specific min_edge thresholds, mode escalation logic, posterior state projection, and divergence detection. Requirements validated: 11.1–11.7, 12.1–12.7, 13.1–13.5 """ from __future__ import annotations import math import pytest from services.aggregation.projection import compute_v3_projection from services.aggregation.regime import MarketRegime, V3RegimeClassification from services.recommendation.eligibility import ( ReturnDistribution, V3Eligibility, compute_return_distribution, compute_v3_eligibility, ) # --------------------------------------------------------------------------- # Helper: construct a V3RegimeClassification for tests # --------------------------------------------------------------------------- def _make_regime(regime: MarketRegime, phi: float = 0.50) -> V3RegimeClassification: """Create a minimal V3RegimeClassification for testing.""" params = { MarketRegime.PANIC: (0.70, 0.70, 0.35, 2.5), MarketRegime.TREND_FOLLOWING: (1.10, 1.00, 0.80, 1.8), MarketRegime.MEAN_REVERSION: (0.90, 0.95, 0.55, 1.4), MarketRegime.UNCERTAINTY: (0.80, 0.85, 0.50, 2.0), } gamma, conf_mult, phi_val, atr_mult = params[regime] return V3RegimeClassification( regime=regime, trend_z=0.0, vol_ratio=1.0, evidence_multiplier=gamma, confidence_multiplier=conf_mult, phi_decay=phi_val, atr_multiplier=atr_mult, ) # --------------------------------------------------------------------------- # EV positive → eligible (Req 12.1–12.7) # --------------------------------------------------------------------------- class TestEVPositiveEligible: """Tests that positive EV with passing quality gates → eligible=True.""" def test_strong_signal_trend_following(self): """a_projected=2.0, conf=0.8, vol=0.25, h=7, costs=0.001, trend_following → eligible.""" result = compute_return_distribution( a_projected=2.0, confidence=0.8, realized_vol_20d=0.25, horizon_days=7, costs=0.001, regime="trend_following", confidence_actual=0.8, contradiction=0.1, n_eff_total=5.0, data_quality=0.8, ) assert isinstance(result, ReturnDistribution) assert result.ev_long > 0.0 assert result.ev_long > result.min_edge assert result.eligible is True # Verify min_edge for trend_following assert result.min_edge == pytest.approx(0.0035, abs=1e-9) def test_sigma_h_formula(self): """Verify sigma_h = realized_vol * sqrt(horizon / 252).""" result = compute_return_distribution( a_projected=2.0, confidence=0.8, realized_vol_20d=0.25, horizon_days=7, costs=0.001, regime="trend_following", confidence_actual=0.8, contradiction=0.1, n_eff_total=5.0, data_quality=0.8, ) expected_sigma_h = 0.25 * math.sqrt(7 / 252.0) assert result.sigma_h == pytest.approx(expected_sigma_h, rel=1e-9) def test_mu_h_formula(self): """Verify mu_h = tanh(A_projected / 3.0) * confidence * sigma_h.""" result = compute_return_distribution( a_projected=2.0, confidence=0.8, realized_vol_20d=0.25, horizon_days=7, costs=0.001, regime="trend_following", confidence_actual=0.8, contradiction=0.1, n_eff_total=5.0, data_quality=0.8, ) sigma_h = 0.25 * math.sqrt(7 / 252.0) expected_mu_h = math.tanh(2.0 / 3.0) * 0.8 * sigma_h assert result.mu_h == pytest.approx(expected_mu_h, rel=1e-9) # --------------------------------------------------------------------------- # EV negative → ineligible (Req 12.3–12.5) # --------------------------------------------------------------------------- class TestEVNegativeIneligible: """Tests that weak signals with negative or sub-threshold EV → ineligible.""" def test_weak_signal_high_costs(self): """a_projected=0.01, conf=0.3, costs=0.01 → EV < min_edge → ineligible.""" result = compute_return_distribution( a_projected=0.01, confidence=0.3, realized_vol_20d=0.25, horizon_days=7, costs=0.01, regime="uncertainty", confidence_actual=0.3, contradiction=0.5, n_eff_total=1.0, data_quality=0.4, ) # Weak signal: tanh(0.01/3) ≈ 0.0033, * 0.3 * sigma_h is tiny # Costs + CVaR should dominate → EV negative assert result.ev_long < result.min_edge assert result.eligible is False def test_zero_alpha_negative_ev(self): """a_projected=0 → mu_h=0, then costs + CVaR push EV negative.""" result = compute_return_distribution( a_projected=0.0, confidence=0.5, realized_vol_20d=0.25, horizon_days=7, costs=0.001, regime="trend_following", confidence_actual=0.7, contradiction=0.1, n_eff_total=5.0, data_quality=0.8, ) # mu_h = tanh(0) * ... = 0. EV = 0 - costs - 0.10*CVaR < 0 assert result.mu_h == pytest.approx(0.0, abs=1e-12) assert result.ev_long < 0.0 assert result.eligible is False def test_quality_gate_fails_despite_positive_ev(self): """Strong EV but low n_eff → ineligible (quality gate blocks).""" result = compute_return_distribution( a_projected=3.0, confidence=0.9, realized_vol_20d=0.25, horizon_days=7, costs=0.001, regime="trend_following", confidence_actual=0.9, contradiction=0.1, n_eff_total=1.0, # Below 2.0 threshold data_quality=0.8, ) # EV should be positive, but n_eff < 2.0 fails quality gate assert result.ev_long > 0.0 assert result.eligible is False # --------------------------------------------------------------------------- # Regime-specific min_edge thresholds (Req 12.4) # --------------------------------------------------------------------------- class TestRegimeMinEdge: """Tests that regime-specific min_edge values are correct.""" def test_panic_min_edge_strictest(self): """Panic regime has min_edge = 0.0100 (strictest).""" result = compute_return_distribution( a_projected=1.0, confidence=0.5, realized_vol_20d=0.25, horizon_days=7, costs=0.001, regime="panic", confidence_actual=0.7, contradiction=0.2, n_eff_total=3.0, data_quality=0.8, ) assert result.min_edge == pytest.approx(0.0100, abs=1e-9) def test_trend_following_min_edge_most_lenient(self): """Trend following has min_edge = 0.0035 (most lenient).""" result = compute_return_distribution( a_projected=1.0, confidence=0.5, realized_vol_20d=0.25, horizon_days=7, costs=0.001, regime="trend_following", confidence_actual=0.7, contradiction=0.2, n_eff_total=3.0, data_quality=0.8, ) assert result.min_edge == pytest.approx(0.0035, abs=1e-9) def test_mean_reversion_min_edge(self): """Mean reversion has min_edge = 0.0050.""" result = compute_return_distribution( a_projected=1.0, confidence=0.5, realized_vol_20d=0.25, horizon_days=7, costs=0.001, regime="mean_reversion", confidence_actual=0.7, contradiction=0.2, n_eff_total=3.0, data_quality=0.8, ) assert result.min_edge == pytest.approx(0.0050, abs=1e-9) def test_uncertainty_min_edge(self): """Uncertainty has min_edge = 0.0075.""" result = compute_return_distribution( a_projected=1.0, confidence=0.5, realized_vol_20d=0.25, horizon_days=7, costs=0.001, regime="uncertainty", confidence_actual=0.7, contradiction=0.2, n_eff_total=3.0, data_quality=0.8, ) assert result.min_edge == pytest.approx(0.0075, abs=1e-9) def test_unknown_regime_defaults_to_uncertainty(self): """Unknown regime string → falls back to uncertainty min_edge.""" result = compute_return_distribution( a_projected=1.0, confidence=0.5, realized_vol_20d=0.25, horizon_days=7, costs=0.001, regime="nonexistent_regime", confidence_actual=0.7, contradiction=0.2, n_eff_total=3.0, data_quality=0.8, ) assert result.min_edge == pytest.approx(0.0075, abs=1e-9) # --------------------------------------------------------------------------- # Mode escalation: live vs paper vs informational (Req 13.1–13.5) # --------------------------------------------------------------------------- class TestModeEscalation: """Tests for v3 mode escalation logic.""" def test_live_eligible(self): """BUY with high conf/low contra/high n_eff/EV >> min_edge → live.""" result = compute_v3_eligibility( p_up=0.75, ev_long=0.020, # >> 2 * 0.0035 = 0.0070 min_edge=0.0035, confidence=0.80, contradiction=0.10, strength=0.50, n_eff_total=6.0, data_quality=0.85, regime="trend_following", has_existing_position=False, risk_engine_passed=True, ) assert isinstance(result, V3Eligibility) assert result.action == "BUY" assert result.mode == "live" assert result.eligible is True def test_paper_eligible(self): """BUY with moderate confidence → paper.""" result = compute_v3_eligibility( p_up=0.70, ev_long=0.010, # > min_edge but < 2 * min_edge for live min_edge=0.0035, confidence=0.65, # >= 0.60 for paper but < 0.75 for live contradiction=0.15, strength=0.40, n_eff_total=4.0, data_quality=0.80, regime="trend_following", has_existing_position=False, risk_engine_passed=True, ) assert result.action == "BUY" assert result.mode == "paper" assert result.eligible is True def test_informational_low_confidence(self): """BUY with low confidence → informational.""" result = compute_v3_eligibility( p_up=0.70, ev_long=0.010, min_edge=0.0035, confidence=0.55, # >= regime min but < 0.60 for paper contradiction=0.15, strength=0.40, n_eff_total=4.0, data_quality=0.80, regime="trend_following", has_existing_position=False, risk_engine_passed=True, ) assert result.action == "BUY" assert result.mode == "informational" def test_hold_always_informational(self): """HOLD action is always informational regardless of quality.""" result = compute_v3_eligibility( p_up=0.55, # Below bullish threshold for trend_following (0.60) ev_long=0.020, min_edge=0.0035, confidence=0.90, contradiction=0.05, strength=0.50, n_eff_total=10.0, data_quality=0.95, regime="trend_following", has_existing_position=True, risk_engine_passed=True, ) assert result.action == "HOLD" assert result.mode == "informational" def test_watch_when_ineligible(self): """Low confidence below regime min → WATCH.""" result = compute_v3_eligibility( p_up=0.80, ev_long=0.020, min_edge=0.0035, confidence=0.40, # Below trend_following min of 0.55 contradiction=0.10, strength=0.60, n_eff_total=5.0, data_quality=0.80, regime="trend_following", has_existing_position=False, risk_engine_passed=True, ) assert result.action == "WATCH" assert result.eligible is False def test_risk_engine_blocks_live(self): """Risk engine failure blocks live but allows paper.""" result = compute_v3_eligibility( p_up=0.75, ev_long=0.020, min_edge=0.0035, confidence=0.80, contradiction=0.10, strength=0.50, n_eff_total=6.0, data_quality=0.85, regime="trend_following", has_existing_position=False, risk_engine_passed=False, ) # Both live and paper require risk_engine_passed assert result.action == "BUY" assert result.mode == "informational" def test_sell_on_negative_ev_with_position(self): """Existing position with negative EV → SELL.""" result = compute_v3_eligibility( p_up=0.35, # bearish ev_long=-0.005, min_edge=0.0035, confidence=0.70, contradiction=0.15, strength=0.30, n_eff_total=5.0, data_quality=0.80, regime="trend_following", has_existing_position=True, risk_engine_passed=True, ) assert result.action == "SELL" # --------------------------------------------------------------------------- # Projection decay convergence (Req 11.1–11.4) # --------------------------------------------------------------------------- class TestProjectionDecay: """Tests for compute_v3_projection decay behavior.""" def test_evidence_accumulates(self): """cluster_llrs=[1.0, 0.5] → A_t = phi*A_prev + 1.5.""" regime = _make_regime(MarketRegime.TREND_FOLLOWING) result = compute_v3_projection( a_prev=0.0, cluster_llrs=[1.0, 0.5], regime=regime, p_prior=0.50, projection_horizon=1, ) # A_t = 0.80 * 0.0 + 1.5 = 1.5 assert result.a_t == pytest.approx(1.5, abs=1e-9) # P_up_projected should be > 0.5 (bullish evidence) assert result.p_up_projected > 0.5 def test_projection_horizon_decays(self): """Higher projection_horizon → stronger decay → closer to prior.""" regime = _make_regime(MarketRegime.TREND_FOLLOWING) # phi=0.80, horizon=5 → phi^5 = 0.32768 result_h1 = compute_v3_projection( a_prev=0.0, cluster_llrs=[2.0], regime=regime, p_prior=0.50, projection_horizon=1, ) result_h5 = compute_v3_projection( a_prev=0.0, cluster_llrs=[2.0], regime=regime, p_prior=0.50, projection_horizon=5, ) # Longer horizon → more decay → projected_strength should be lower assert result_h5.projected_strength < result_h1.projected_strength # Both still bullish assert result_h1.p_up_projected > 0.5 assert result_h5.p_up_projected > 0.5 def test_panic_decays_faster_than_trend(self): """Panic (phi=0.35) decays much faster than trend_following (phi=0.80).""" regime_panic = _make_regime(MarketRegime.PANIC) regime_trend = _make_regime(MarketRegime.TREND_FOLLOWING) result_panic = compute_v3_projection( a_prev=0.0, cluster_llrs=[2.0], regime=regime_panic, p_prior=0.50, projection_horizon=3, ) result_trend = compute_v3_projection( a_prev=0.0, cluster_llrs=[2.0], regime=regime_trend, p_prior=0.50, projection_horizon=3, ) # Trend should retain more signal after projection assert result_trend.projected_strength > result_panic.projected_strength def test_phi_regime_stored(self): """Result stores the correct phi_regime value.""" regime = _make_regime(MarketRegime.MEAN_REVERSION) result = compute_v3_projection( a_prev=0.0, cluster_llrs=[1.0], regime=regime, p_prior=0.50, projection_horizon=1, ) assert result.phi_regime == pytest.approx(0.55, abs=1e-9) def test_a_prev_contributes(self): """Non-zero a_prev gets decayed and added to new evidence.""" regime = _make_regime(MarketRegime.UNCERTAINTY) # phi=0.50 result = compute_v3_projection( a_prev=2.0, cluster_llrs=[1.0], regime=regime, p_prior=0.50, projection_horizon=1, ) # A_t = 0.50 * 2.0 + 1.0 = 2.0 assert result.a_t == pytest.approx(2.0, abs=1e-9) # --------------------------------------------------------------------------- # Divergence flag behavior (Req 11.6) # --------------------------------------------------------------------------- class TestDivergenceFlag: """Tests for divergence detection between current and projected P_up.""" def test_no_divergence_same_direction(self): """Bullish current and projected → diverges=False.""" regime = _make_regime(MarketRegime.TREND_FOLLOWING) result = compute_v3_projection( a_prev=0.0, cluster_llrs=[2.0], regime=regime, p_prior=0.50, projection_horizon=1, ) # Both current P_up_t and projected should be > 0.5 (bullish) assert result.diverges is False def test_divergence_strong_decay(self): """Bullish current but projected decay crosses 0.5 boundary → diverges=True.""" # Use panic regime (phi=0.35) with small evidence and large projection horizon regime = _make_regime(MarketRegime.PANIC) # A_t = 0.35 * 0 + 0.1 = 0.1 → P_up_t > 0.5 (bullish) # A_projected = 0.35^20 * 0.1 ≈ 0 → P_up_projected ≈ 0.5 # Need negative catalyst to push projected below 0.5 result = compute_v3_projection( a_prev=0.0, cluster_llrs=[0.5], # Mild bullish evidence regime=regime, p_prior=0.50, projection_horizon=20, known_catalyst_llr=-1.0, # Bearish catalyst flips projected direction ) # Current: A_t = 0.5, P_up_t = sigmoid(0.5) > 0.5 (bullish) # Projected: phi^20 * 0.5 - 1.0 = practically -1.0 → P_up_projected < 0.5 assert result.diverges is True def test_no_divergence_both_bearish(self): """Bearish current and projected → diverges=False.""" regime = _make_regime(MarketRegime.TREND_FOLLOWING) result = compute_v3_projection( a_prev=0.0, cluster_llrs=[-2.0], regime=regime, p_prior=0.50, projection_horizon=1, ) # Both should be < 0.5 (bearish) assert result.p_up_projected < 0.5 assert result.diverges is False def test_known_catalyst_shifts_projection(self): """known_catalyst_llr adds to projected alpha.""" regime = _make_regime(MarketRegime.TREND_FOLLOWING) result_no_catalyst = compute_v3_projection( a_prev=0.0, cluster_llrs=[1.0], regime=regime, p_prior=0.50, projection_horizon=1, known_catalyst_llr=0.0, ) result_with_catalyst = compute_v3_projection( a_prev=0.0, cluster_llrs=[1.0], regime=regime, p_prior=0.50, projection_horizon=1, known_catalyst_llr=1.0, ) # Catalyst boosts projected P_up assert result_with_catalyst.p_up_projected > result_no_catalyst.p_up_projected # --------------------------------------------------------------------------- # Default vol (Req 12.7) # --------------------------------------------------------------------------- class TestDefaultVolatility: """Tests that realized_vol_20d=0 defaults to 0.25.""" def test_zero_vol_uses_default(self): """realized_vol_20d=0 → uses 0.25 default.""" result_zero = compute_return_distribution( a_projected=2.0, confidence=0.8, realized_vol_20d=0, horizon_days=7, costs=0.001, regime="trend_following", confidence_actual=0.8, contradiction=0.1, n_eff_total=5.0, data_quality=0.8, ) result_default = compute_return_distribution( a_projected=2.0, confidence=0.8, realized_vol_20d=0.25, horizon_days=7, costs=0.001, regime="trend_following", confidence_actual=0.8, contradiction=0.1, n_eff_total=5.0, data_quality=0.8, ) assert result_zero.sigma_h == pytest.approx(result_default.sigma_h, abs=1e-12) assert result_zero.ev_long == pytest.approx(result_default.ev_long, abs=1e-12) def test_negative_vol_uses_default(self): """realized_vol_20d=-0.1 → uses 0.25 default.""" result = compute_return_distribution( a_projected=2.0, confidence=0.8, realized_vol_20d=-0.1, horizon_days=7, costs=0.001, regime="trend_following", confidence_actual=0.8, contradiction=0.1, n_eff_total=5.0, data_quality=0.8, ) expected_sigma_h = 0.25 * math.sqrt(7 / 252.0) assert result.sigma_h == pytest.approx(expected_sigma_h, rel=1e-9)