Files
signal-platform/tests/unit/test_qualification.py
T
dennisthiessen ef523474ad
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 41s
Deploy / deploy (push) Successful in 26s
replace EV activation gate with cross-sectional 12-1 momentum ranking
The 5-year backtest confirmed the EV gate adds negative value (high threshold =
worst expectancy) and that 12-1 month momentum is the one price signal with a
plausible, right-signed cross-sectional IC (~0.05). So "qualified" now means:
clears the R:R + confidence floors AND the ticker ranks in the top
`min_momentum_percentile` of the universe by 12-1 momentum that week.

- qualification.py: drop expected_value_r / the EV gate; add a momentum-percentile
  gate (duck-typed `momentum_percentile`, only enforced when attached + threshold
  set, else defers to floors). Mirrored in frontend qualification.ts.
- activation config/schema: min_expected_value -> min_momentum_percentile
  (default 80 = top quintile). ActivationSettings, DashboardPage (ranks/【shows】
  momentum instead of EV), and the BacktestPanel sweep follow.
- backtest: rank each ISO week's universe by 12-1 momentum, assign a percentile,
  and qualify the top slice; the sweep now sweeps the percentile cutoff.

Also offload the backtest's per-ticker compute to a worker thread so the heavy
~5y run no longer blocks the API event loop (the "backend offline" flicker).

Production setups don't carry momentum_percentile yet — wiring the scanner to
attach it (a universe momentum-rank step) is the next step; until then the live
gate defers to floors while the backtest measures the momentum selection. 330
backend tests pass; frontend build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 22:42:24 +02:00

117 lines
4.4 KiB
Python

"""Unit tests for the activation qualification predicate (momentum-based gate)."""
from __future__ import annotations
from types import SimpleNamespace
from app.services.qualification import best_target_probability, setup_qualifies
# Default gate: floors only; the momentum selection is off (0). Conviction /
# conflict / target-probability are optional tighteners, off here.
DEFAULT_GATE = {
"min_momentum_percentile": 0.0,
"min_rr": 1.2,
"min_confidence": 55.0,
"min_target_probability": 0.0,
"require_high_conviction": False,
"exclude_conflicts": False,
}
# Gate with the cross-sectional momentum selection on (top quintile).
MOMENTUM_GATE = {**DEFAULT_GATE, "min_momentum_percentile": 80.0}
# Strict gate: every optional tightener turned on.
STRICT_GATE = {
"min_momentum_percentile": 0.0,
"min_rr": 2.0,
"min_confidence": 70.0,
"min_target_probability": 60.0,
"require_high_conviction": True,
"exclude_conflicts": True,
}
def _setup(**kwargs):
base = dict(
rr_ratio=3.0,
confidence_score=80.0,
recommended_action="LONG_HIGH",
risk_level="Low",
targets=[{"probability": 50.0, "is_primary": True}],
)
base.update(kwargs)
return SimpleNamespace(**base)
class TestFloors:
def test_passes_floors(self):
assert setup_qualifies(_setup(), DEFAULT_GATE) is True
def test_low_rr_floor_fails(self):
assert setup_qualifies(_setup(rr_ratio=1.0), DEFAULT_GATE) is False
def test_low_confidence_fails(self):
assert setup_qualifies(_setup(confidence_score=40.0), DEFAULT_GATE) is False
def test_conviction_and_conflict_ignored_by_default(self):
s = _setup(recommended_action="LONG_MODERATE", risk_level="Medium")
assert setup_qualifies(s, DEFAULT_GATE) is True
def test_over_progressed_setup_fails_on_live_rr(self):
s = _setup(direction="long", target=120.0, stop_loss=95.0, current_price=117.0)
assert setup_qualifies(s, DEFAULT_GATE) is False
def test_fresh_setup_passes_live_rr(self):
s = _setup(direction="long", target=120.0, stop_loss=95.0, current_price=101.0)
assert setup_qualifies(s, DEFAULT_GATE) is True
def test_past_stop_fails_live_rr(self):
s = _setup(direction="long", target=120.0, stop_loss=95.0, current_price=94.0)
assert setup_qualifies(s, DEFAULT_GATE) is False
class TestMomentumGate:
def test_top_momentum_passes(self):
assert setup_qualifies(_setup(momentum_percentile=92.0), MOMENTUM_GATE) is True
def test_below_threshold_fails(self):
assert setup_qualifies(_setup(momentum_percentile=50.0), MOMENTUM_GATE) is False
def test_missing_percentile_defers_to_floors(self):
# No percentile attached (e.g. production not yet wired) → the momentum
# gate is skipped and the setup still clears on the floors.
assert setup_qualifies(_setup(), MOMENTUM_GATE) is True
def test_threshold_zero_disables_gate(self):
# min_momentum_percentile 0 → a low-momentum name still passes.
assert setup_qualifies(_setup(momentum_percentile=10.0), DEFAULT_GATE) is True
def test_missing_key_defaults_off(self):
legacy = {k: v for k, v in DEFAULT_GATE.items() if k != "min_momentum_percentile"}
assert setup_qualifies(_setup(momentum_percentile=10.0), legacy) is True
class TestStrictTighteners:
def test_clean_high_conviction_passes(self):
assert setup_qualifies(_setup(targets=[{"probability": 65.0, "is_primary": True}]), STRICT_GATE) is True
def test_moderate_action_fails(self):
s = _setup(recommended_action="LONG_MODERATE", targets=[{"probability": 65.0, "is_primary": True}])
assert setup_qualifies(s, STRICT_GATE) is False
def test_non_low_risk_fails(self):
s = _setup(risk_level="Medium", targets=[{"probability": 65.0, "is_primary": True}])
assert setup_qualifies(s, STRICT_GATE) is False
def test_low_target_probability_fails(self):
assert setup_qualifies(_setup(targets=[{"probability": 40.0, "is_primary": True}]), STRICT_GATE) is False
class TestBestTargetProbability:
def test_returns_max(self):
s = _setup(targets=[{"probability": 40.0}, {"probability": 72.0}, {"probability": 55.0}])
assert best_target_probability(s) == 72.0
def test_empty_is_zero(self):
assert best_target_probability(_setup(targets=[])) == 0.0