605f95098c
Part 1 — long-only. The momentum edge is long top-momentum; the gate was
qualifying shorts on high-momentum names (fighting the trend), which showed as
the -0.13R Short(qual.) drag. While the gate is active, shorts no longer qualify
(backend qualification, backtest _momentum_qualifies, and the frontend mirror).
Part 2 — production wiring. Live setups now carry a real momentum rank, so the
dashboard, the Track Record's qualified stats, and outcome evaluation all gate on
the same value instead of deferring to floors:
- new momentum_service.compute_momentum_percentiles: 12-1 momentum per ticker,
ranked across the universe into a {symbol: percentile} map.
- the daily R:R scan ranks the universe up front and stores each setup's
percentile (new trade_setups.momentum_percentile column, migration 010).
- enhance_trade_setup mutates the same row, so the percentile is preserved;
_trade_setup_to_dict + TradeSetupResponse expose it to the API.
Until a fresh scan runs, pre-existing setups have a null percentile and the gate
falls back to floors for them (longs) / excludes them (shorts) — they fill in on
the next scan. 341 backend tests pass; frontend build clean.
Needs the alembic upgrade (migration 010) on deploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
126 lines
4.9 KiB
Python
126 lines
4.9 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
|
|
|
|
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
|
|
|
|
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
|