"""Unit tests for the probabilistic (Bayesian) pipeline. Tests cover: - Regime-to-prior mapping - Likelihood ratio computation - Log-odds accumulation and sigmoid round-trip - Shannon entropy computation - Entropy gating (SKIP on high entropy) - EV_R computation - BUY / WATCH / SKIP verdict thresholds - Edge cases (no signals, boundary values) Requirements: 6.1–6.9, 14.1–14.5 """ from __future__ import annotations from datetime import datetime, timezone from services.aggregation.regime import MarketRegime, RegimeClassification from services.signal_engine.config import ProbabilisticConfig from services.signal_engine.models import ( ConfluenceSignal, NormalizedInput, SignalDirection, Verdict, ) from services.signal_engine.probabilistic import ( _compute_ev_r, _compute_likelihood_ratios, _logit, _regime_to_prior, _shannon_entropy, _sigmoid, run_probabilistic_pipeline, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_normalized( macro_bias: float = 0.5, valuation_score: float | None = 0.7, earnings_proximity_days: int | None = 30, ) -> NormalizedInput: return NormalizedInput( ticker="TEST", evaluated_at=datetime.now(tz=timezone.utc), bars={}, macro_bias=macro_bias, valuation_score=valuation_score, earnings_proximity_days=earnings_proximity_days, ) def _make_regime( regime: MarketRegime = MarketRegime.TREND_FOLLOWING, trend_indicator: float = 1.0, ) -> RegimeClassification: return RegimeClassification( regime=regime, trend_indicator=trend_indicator, volatility_ratio=1.0, bullish_threshold=0.15, bearish_threshold=-0.15, contradiction_penalty_multiplier=0.4, ) def _make_confluence( signal_type: str = "fibonacci", direction: SignalDirection = SignalDirection.BULLISH, confluence_score: float = 0.8, active_timeframes: list[str] | None = None, ) -> ConfluenceSignal: if active_timeframes is None: active_timeframes = ["D", "W"] return ConfluenceSignal( signal_type=signal_type, direction=direction, confluence_score=confluence_score, active_timeframes=active_timeframes, per_timeframe={tf: confluence_score for tf in active_timeframes}, ) DEFAULT_CONFIG = ProbabilisticConfig() # --------------------------------------------------------------------------- # Regime → prior mapping # --------------------------------------------------------------------------- class TestRegimeToPrior: def test_trend_following_bullish(self): regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0) assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.58 def test_trend_following_bearish(self): regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=-1.0) assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.42 def test_trend_following_zero_indicator(self): """Zero trend_indicator is not positive → bear prior.""" regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=0.0) assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.42 def test_mean_reversion(self): regime = _make_regime(MarketRegime.MEAN_REVERSION) assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.50 def test_panic(self): regime = _make_regime(MarketRegime.PANIC) assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.42 def test_uncertainty(self): regime = _make_regime(MarketRegime.UNCERTAINTY) assert _regime_to_prior(regime, DEFAULT_CONFIG) == 0.50 # --------------------------------------------------------------------------- # Logit / sigmoid helpers # --------------------------------------------------------------------------- class TestLogitSigmoid: def test_logit_sigmoid_round_trip(self): for p in [0.1, 0.25, 0.5, 0.75, 0.9]: assert abs(_sigmoid(_logit(p)) - p) < 1e-10 def test_logit_at_half(self): assert abs(_logit(0.5)) < 1e-10 def test_sigmoid_at_zero(self): assert abs(_sigmoid(0.0) - 0.5) < 1e-10 def test_sigmoid_large_positive(self): assert _sigmoid(1000) == 1.0 def test_sigmoid_large_negative(self): assert _sigmoid(-1000) == 0.0 # --------------------------------------------------------------------------- # Shannon entropy # --------------------------------------------------------------------------- class TestShannonEntropy: def test_max_at_half(self): assert abs(_shannon_entropy(0.5) - 1.0) < 1e-10 def test_zero_at_boundaries(self): assert _shannon_entropy(0.0) == 0.0 assert _shannon_entropy(1.0) == 0.0 def test_symmetric(self): assert abs(_shannon_entropy(0.3) - _shannon_entropy(0.7)) < 1e-10 def test_in_range(self): for p in [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]: h = _shannon_entropy(p) assert 0.0 <= h <= 1.0 # --------------------------------------------------------------------------- # Likelihood ratio computation # --------------------------------------------------------------------------- class TestLikelihoodRatios: def test_bullish_signal_produces_lr_gt_1(self): sig = _make_confluence(direction=SignalDirection.BULLISH, confluence_score=0.8) lrs = _compute_likelihood_ratios([sig]) assert len(lrs) == 1 assert lrs[0].lr > 1.0 assert lrs[0].log_lr > 0.0 def test_bearish_signal_produces_lr_lt_1(self): sig = _make_confluence(direction=SignalDirection.BEARISH, confluence_score=0.8) lrs = _compute_likelihood_ratios([sig]) assert len(lrs) == 1 assert lrs[0].lr < 1.0 assert lrs[0].log_lr < 0.0 def test_neutral_signal_produces_lr_gt_1(self): """Neutral signals still contribute based on strength.""" sig = _make_confluence(direction=SignalDirection.NEUTRAL, confluence_score=0.8) lrs = _compute_likelihood_ratios([sig]) assert len(lrs) == 1 # Neutral is treated as bullish evidence (no inversion) assert lrs[0].lr > 1.0 def test_empty_signals(self): lrs = _compute_likelihood_ratios([]) assert lrs == [] def test_cluster_assignment(self): sig = _make_confluence(signal_type="ma_stack") lrs = _compute_likelihood_ratios([sig]) assert lrs[0].cluster == "momentum" # --------------------------------------------------------------------------- # EV_R computation # --------------------------------------------------------------------------- class TestEvR: def test_ev_r_high_p_up(self): signals = [_make_confluence(confluence_score=0.8)] ev_r = _compute_ev_r(0.8, signals) # E[win_R] = 0.8 * 2.0 = 1.6 # EV_R = 0.8 * 1.6 - 0.2 * 1.0 = 1.28 - 0.2 = 1.08 assert abs(ev_r - 1.08) < 1e-10 def test_ev_r_at_half(self): signals = [_make_confluence(confluence_score=0.5)] ev_r = _compute_ev_r(0.5, signals) # E[win_R] = 0.5 * 2.0 = 1.0 # EV_R = 0.5 * 1.0 - 0.5 * 1.0 = 0.0 assert abs(ev_r) < 1e-10 def test_ev_r_no_signals(self): ev_r = _compute_ev_r(0.7, []) # E[win_R] = 1.0 (fallback) # EV_R = 0.7 * 1.0 - 0.3 * 1.0 = 0.4 assert abs(ev_r - 0.4) < 1e-10 def test_ev_r_monotonic_with_p_up(self): signals = [_make_confluence(confluence_score=0.8)] ev_low = _compute_ev_r(0.5, signals) ev_high = _compute_ev_r(0.8, signals) assert ev_high > ev_low # --------------------------------------------------------------------------- # Full pipeline — verdict tests # --------------------------------------------------------------------------- class TestProbabilisticPipeline: def test_no_signals_returns_prior_based_result(self): """With no signals, P_up equals the prior.""" normalized = _make_normalized() regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0) result = run_probabilistic_pipeline(normalized, [], regime, DEFAULT_CONFIG) assert abs(result.p_up - 0.58) < 1e-6 assert result.prior == 0.58 def test_buy_verdict_with_strong_signals(self): """Strong bullish signals + favorable conditions → BUY.""" normalized = _make_normalized(macro_bias=0.5, valuation_score=0.7) regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0) # Multiple strong bullish signals from different clusters signals = [ _make_confluence("fibonacci", SignalDirection.BULLISH, 0.9), _make_confluence("ma_stack", SignalDirection.BULLISH, 0.85), _make_confluence("rsi", SignalDirection.BULLISH, 0.8), _make_confluence("valuation", SignalDirection.BULLISH, 0.75), ] result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG) # With strong signals and bull prior, P_up should be high assert result.p_up >= 0.60 # Verdict depends on all conditions being met assert result.verdict in (Verdict.BUY, Verdict.WATCH) def test_skip_on_high_entropy(self): """P_up near 0.5 → high entropy → SKIP.""" normalized = _make_normalized() regime = _make_regime(MarketRegime.MEAN_REVERSION) # prior = 0.50 # No signals → P_up stays at 0.50 → entropy = 1.0 > 0.95 result = run_probabilistic_pipeline(normalized, [], regime, DEFAULT_CONFIG) assert result.verdict == Verdict.SKIP assert result.entropy > 0.95 assert any("high_entropy" in r for r in result.reasoning) def test_skip_on_low_p_up(self): """Bearish signals → low P_up → SKIP.""" normalized = _make_normalized() regime = _make_regime(MarketRegime.PANIC) # prior = 0.42 signals = [ _make_confluence("fibonacci", SignalDirection.BEARISH, 0.9), _make_confluence("ma_stack", SignalDirection.BEARISH, 0.85), ] result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG) assert result.p_up < 0.55 assert result.verdict == Verdict.SKIP def test_watch_verdict(self): """Moderate signals → WATCH (P_up >= 0.55 but not all BUY conditions).""" normalized = _make_normalized(macro_bias=-0.1) # macro_bias <= 0 blocks BUY regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0) signals = [ _make_confluence("fibonacci", SignalDirection.BULLISH, 0.8), _make_confluence("ma_stack", SignalDirection.BULLISH, 0.75), ] result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG) # P_up should be above 0.55 with bull prior + bullish signals if result.p_up >= 0.55 and result.entropy <= 0.95: assert result.verdict == Verdict.WATCH def test_macro_bias_blocks_buy(self): """macro_bias <= 0 prevents BUY even with high P_up.""" normalized = _make_normalized(macro_bias=0.0, valuation_score=0.8) regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0) signals = [ _make_confluence("fibonacci", SignalDirection.BULLISH, 0.9), _make_confluence("ma_stack", SignalDirection.BULLISH, 0.9), _make_confluence("rsi", SignalDirection.BULLISH, 0.85), _make_confluence("valuation", SignalDirection.BULLISH, 0.8), ] result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG) assert result.verdict != Verdict.BUY def test_valuation_blocks_buy(self): """valuation_score < 0.5 prevents BUY.""" normalized = _make_normalized(macro_bias=0.5, valuation_score=0.3) regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0) signals = [ _make_confluence("fibonacci", SignalDirection.BULLISH, 0.9), _make_confluence("ma_stack", SignalDirection.BULLISH, 0.9), _make_confluence("rsi", SignalDirection.BULLISH, 0.85), _make_confluence("valuation", SignalDirection.BULLISH, 0.8), ] result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG) assert result.verdict != Verdict.BUY def test_result_fields_populated(self): """All ProbabilisticResult fields are populated correctly.""" normalized = _make_normalized() regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0) signals = [_make_confluence("fibonacci", SignalDirection.BULLISH, 0.7)] result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG) assert 0.0 <= result.p_up <= 1.0 assert 0.0 <= result.entropy <= 1.0 assert isinstance(result.ev_r, float) assert result.prior == 0.58 assert result.posterior == result.p_up assert result.regime == "trend_following" assert len(result.likelihood_ratios) == 1 assert len(result.reasoning) > 0 def test_none_valuation_treated_as_zero(self): """None valuation_score is treated as 0.0 for verdict logic.""" normalized = _make_normalized(valuation_score=None) regime = _make_regime(MarketRegime.TREND_FOLLOWING, trend_indicator=1.0) signals = [ _make_confluence("fibonacci", SignalDirection.BULLISH, 0.9), _make_confluence("ma_stack", SignalDirection.BULLISH, 0.9), _make_confluence("rsi", SignalDirection.BULLISH, 0.85), ] result = run_probabilistic_pipeline(normalized, signals, regime, DEFAULT_CONFIG) # valuation_score=None → 0.0 < 0.5 → BUY blocked assert result.verdict != Verdict.BUY