630 lines
22 KiB
Python
630 lines
22 KiB
Python
"""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)
|