You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

220 lines
7.4 KiB
Python

"""Unit tests for scenario robustness.py — RRS computation logic."""
import pytest
from libs.backtest.domain import MetricsBundle
from libs.backtest.scenarios.robustness import (
RegimeRobustnessReport,
ScenarioResult,
compute_rrs,
)
def _make_result(
name: str,
sharpe: float = 1.0,
total_return: float = 10.0,
max_dd: float = 10.0,
win_rate: float = 0.55,
profit_factor: float = 1.5,
trade_count: int = 20,
) -> ScenarioResult:
"""Helper: construct a minimal ScenarioResult."""
m = MetricsBundle(
trade_count=trade_count,
win_rate=win_rate,
avg_win_pct=2.0,
avg_loss_pct=-1.0,
profit_factor=profit_factor,
total_return_pct=total_return,
max_drawdown_pct=max_dd,
sharpe_ratio=sharpe,
)
return ScenarioResult(
scenario_name=name,
metrics=m,
trade_count=trade_count,
sharpe_ratio=sharpe,
total_return_pct=total_return,
max_drawdown_pct=max_dd,
win_rate=win_rate,
profit_factor=profit_factor,
)
@pytest.mark.unit
class TestComputeRRS:
def test_empty_results_returns_zeros(self):
rrs, si, br, dr, rt, st = compute_rrs({})
assert rrs == 0.0
assert si == 0.0
assert br == 0.0
def test_signal_integrity_perfect_when_no_signal_sharpe_zero(self):
results = {
"no_signal": _make_result("no_signal", sharpe=0.0),
"strong_signal": _make_result("strong_signal", sharpe=1.5),
}
_, si, _, _, _, _ = compute_rrs(results)
assert si == 100.0
def test_signal_integrity_perfect_when_no_signal_sharpe_negative(self):
results = {
"no_signal": _make_result("no_signal", sharpe=-0.5),
"strong_signal": _make_result("strong_signal", sharpe=1.5),
}
_, si, _, _, _, _ = compute_rrs(results)
assert si == 100.0
def test_signal_integrity_zero_when_no_signal_equals_strong_signal(self):
results = {
"no_signal": _make_result("no_signal", sharpe=1.5),
"strong_signal": _make_result("strong_signal", sharpe=1.5),
}
_, si, _, _, _, _ = compute_rrs(results)
assert si == pytest.approx(0.0, abs=1.0)
def test_signal_integrity_neutral_when_no_signal_scenario_missing(self):
results = {"steady_bull": _make_result("steady_bull", sharpe=1.0)}
_, si, _, _, _, _ = compute_rrs(results)
assert si == 50.0
def test_breadth_all_positive(self):
results = {
"a": _make_result("a", sharpe=0.5),
"b": _make_result("b", sharpe=1.0),
"c": _make_result("c", sharpe=0.1),
}
_, _, br, _, _, _ = compute_rrs(results)
assert br == pytest.approx(100.0)
def test_breadth_all_negative(self):
results = {
"a": _make_result("a", sharpe=-0.5),
"b": _make_result("b", sharpe=-1.0),
}
_, _, br, _, _, _ = compute_rrs(results)
assert br == pytest.approx(0.0)
def test_breadth_half_positive(self):
results = {
"a": _make_result("a", sharpe=1.0),
"b": _make_result("b", sharpe=-1.0),
}
_, _, br, _, _, _ = compute_rrs(results)
assert br == pytest.approx(50.0)
def test_drawdown_resilience_zero_dd_gives_100(self):
results = {"a": _make_result("a", max_dd=0.0)}
_, _, _, dr, _, _ = compute_rrs(results)
assert dr == pytest.approx(100.0)
def test_drawdown_resilience_50pct_dd_gives_0(self):
results = {"a": _make_result("a", max_dd=50.0)}
_, _, _, dr, _, _ = compute_rrs(results)
assert dr == pytest.approx(0.0)
def test_drawdown_resilience_clamped_not_negative(self):
results = {"a": _make_result("a", max_dd=80.0)}
_, _, _, dr, _, _ = compute_rrs(results)
assert dr >= 0.0
def test_regime_transition_neutral_when_missing(self):
results = {"steady_bull": _make_result("steady_bull", sharpe=1.0)}
_, _, _, _, rt, _ = compute_rrs(results)
assert rt == 50.0
def test_regime_transition_good_when_above_median(self):
results = {
"a": _make_result("a", sharpe=0.5),
"b": _make_result("b", sharpe=0.5),
"regime_switch": _make_result("regime_switch", sharpe=2.0),
}
_, _, _, _, rt, _ = compute_rrs(results)
# regime_switch > median → should be > 50
assert rt > 50.0
def test_stability_perfect_when_all_same_sharpe(self):
results = {
"a": _make_result("a", sharpe=1.0),
"b": _make_result("b", sharpe=1.0),
"c": _make_result("c", sharpe=1.0),
}
_, _, _, _, _, st = compute_rrs(results)
assert st == pytest.approx(100.0)
def test_stability_lower_when_high_variance(self):
low_var = {
"a": _make_result("a", sharpe=1.0),
"b": _make_result("b", sharpe=1.1),
"c": _make_result("c", sharpe=0.9),
}
high_var = {
"a": _make_result("a", sharpe=3.0),
"b": _make_result("b", sharpe=-0.5),
"c": _make_result("c", sharpe=0.1),
}
_, _, _, _, _, st_low = compute_rrs(low_var)
_, _, _, _, _, st_high = compute_rrs(high_var)
assert st_low > st_high
def test_rrs_weighted_sum(self):
"""RRS = 0.25*si + 0.25*br + 0.20*dr + 0.15*rt + 0.15*st."""
results = {
"no_signal": _make_result("no_signal", sharpe=-0.1),
"strong_signal": _make_result("strong_signal", sharpe=1.5),
"steady_bull": _make_result("steady_bull", sharpe=1.2),
"steady_bear": _make_result("steady_bear", sharpe=0.3),
"regime_switch": _make_result("regime_switch", sharpe=0.8),
}
rrs, si, br, dr, rt, st = compute_rrs(results)
expected = 0.25 * si + 0.25 * br + 0.20 * dr + 0.15 * rt + 0.15 * st
assert rrs == pytest.approx(expected, rel=1e-6)
def test_rrs_bounded_0_to_100(self):
results = {
"no_signal": _make_result("no_signal", sharpe=-0.5),
"strong_signal": _make_result("strong_signal", sharpe=2.0),
"a": _make_result("a", sharpe=1.0),
"b": _make_result("b", sharpe=1.5),
}
rrs, _, _, _, _, _ = compute_rrs(results)
assert 0.0 <= rrs <= 100.0
@pytest.mark.unit
class TestVerdicts:
def _make_report(self, rrs: float) -> RegimeRobustnessReport:
from libs.backtest.scenarios.robustness import _verdict
results = {"a": _make_result("a", sharpe=1.0)}
return RegimeRobustnessReport(
experiment_name="test",
scenario_results=results,
signal_integrity=50.0,
breadth=50.0,
drawdown_resilience=50.0,
regime_transition=50.0,
stability=50.0,
rrs=rrs,
verdict=_verdict(rrs),
)
def test_robust_verdict(self):
r = self._make_report(75.0)
assert r.verdict == "ROBUST"
def test_fragile_verdict_lower_bound(self):
r = self._make_report(40.0)
assert r.verdict == "FRAGILE"
def test_fragile_verdict_upper_bound(self):
r = self._make_report(69.9)
assert r.verdict == "FRAGILE"
def test_overfit_verdict(self):
r = self._make_report(39.9)
assert r.verdict == "OVERFIT"
def test_boundary_70_is_robust(self):
r = self._make_report(70.0)
assert r.verdict == "ROBUST"