Files
signal-platform/tests/unit/test_qualification.py
T
dennisthiessen 6511a1020b
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 56s
Deploy / deploy (push) Successful in 34s
feat: exclude NEUTRAL setups from the activation gate (default on)
A NEUTRAL ("No Clear Setup") recommendation means the engine found no clear
directional trade, yet such setups could still qualify and even be crowned the
top pick purely on momentum rank (e.g. an extended momentum leader with a far,
5%-probability target). A NEUTRAL signal isn't actionable, so it shouldn't
qualify.

New `exclude_neutral` activation flag (default on): setup_qualifies drops setups
whose recommended_action is NEUTRAL. It lives in the shared gate, so it flows
through the dashboard's qualified/top-pick selection, the track record's
qualified stats, and the backtest (which computes recommended_action and gates on
meets_core). Toggleable in Admin → Settings → Activation; the frontend mirror and
activationSummary ("directional") match.

Re-run the backtest after enabling to confirm it holds/improves expectancy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:19:07 +02:00

139 lines
5.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,
"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,
"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
def test_short_excluded_when_gate_active(self):
# The momentum edge is long-only — a short never qualifies while the gate
# is on, even on a top-momentum name.
assert setup_qualifies(_setup(direction="short", momentum_percentile=95.0), MOMENTUM_GATE) is False
def test_short_allowed_when_gate_off(self):
# With the momentum gate disabled, shorts pass on the floors as before.
assert setup_qualifies(_setup(direction="short", momentum_percentile=10.0), DEFAULT_GATE) 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
NEUTRAL_GATE = {**DEFAULT_GATE, "exclude_neutral": True}
class TestExcludeNeutral:
def test_neutral_excluded_when_on(self):
assert setup_qualifies(_setup(recommended_action="NEUTRAL"), NEUTRAL_GATE) is False
def test_missing_action_treated_as_neutral(self):
assert setup_qualifies(_setup(recommended_action=None), NEUTRAL_GATE) is False
def test_directional_passes_when_on(self):
assert setup_qualifies(_setup(recommended_action="LONG_MODERATE"), NEUTRAL_GATE) is True
def test_neutral_allowed_when_off(self):
# Flag absent from the config → NEUTRAL still qualifies (backward compatible).
assert setup_qualifies(_setup(recommended_action="NEUTRAL"), DEFAULT_GATE) is True
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