feat: math core v3 engine upgrade

This commit is contained in:
Celes Renata
2026-06-27 12:21:41 +00:00
parent 365bc5d4b7
commit b4bf0f2361
34 changed files with 11693 additions and 3 deletions
+471
View File
@@ -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.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)