Files
stonks-oracle/tests/test_v3_posterior.py

472 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit tests for v3 posterior assembly and regime classification.
Tests for compute_v3_posterior and classify_regime_v3 functions.
Requirements validated: 5.15.7, 6.16.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.16.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)