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
+629
View File
@@ -0,0 +1,629 @@
"""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.111.7, 12.112.7, 13.113.5
"""
from __future__ import annotations
import math
import pytest
from services.aggregation.projection import V3ProjectionState, 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.112.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.312.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.113.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.111.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)