feat: math core v3 engine upgrade
This commit is contained in:
@@ -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.1–11.7, 12.1–12.7, 13.1–13.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.1–12.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.3–12.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.1–13.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.1–11.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)
|
||||
Reference in New Issue
Block a user