Files
stonks-oracle/tests/test_v3_eligibility.py
T

629 lines
22 KiB
Python
Raw 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 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 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)