feat: math core v3 engine upgrade
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user