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>
This commit is contained in:
@@ -25,7 +25,7 @@ class TestActivationConfig:
|
||||
async def test_defaults_when_unset(self, session: AsyncSession):
|
||||
config = await get_activation_config(session)
|
||||
assert config == {
|
||||
"min_expected_value": 0.15,
|
||||
"min_momentum_percentile": 80.0,
|
||||
"min_rr": 1.2,
|
||||
"min_confidence": 55.0,
|
||||
"min_target_probability": 0.0,
|
||||
@@ -35,13 +35,13 @@ class TestActivationConfig:
|
||||
|
||||
async def test_update_and_read_back(self, session: AsyncSession):
|
||||
updated = await update_activation_config(
|
||||
session, {"min_expected_value": 0.25, "min_confidence": 60.0}
|
||||
session, {"min_momentum_percentile": 70.0, "min_confidence": 60.0}
|
||||
)
|
||||
assert updated["min_expected_value"] == 0.25
|
||||
assert updated["min_momentum_percentile"] == 70.0
|
||||
assert updated["min_confidence"] == 60.0
|
||||
|
||||
config = await get_activation_config(session)
|
||||
assert config["min_expected_value"] == 0.25
|
||||
assert config["min_momentum_percentile"] == 70.0
|
||||
assert config["min_confidence"] == 60.0
|
||||
|
||||
async def test_partial_update_keeps_other_value(self, session: AsyncSession):
|
||||
@@ -50,9 +50,9 @@ class TestActivationConfig:
|
||||
assert config["min_rr"] == 1.2 # default untouched
|
||||
assert config["min_confidence"] == 80.0
|
||||
|
||||
async def test_rejects_out_of_range_expected_value(self, session: AsyncSession):
|
||||
async def test_rejects_out_of_range_momentum_percentile(self, session: AsyncSession):
|
||||
with pytest.raises(ValidationError):
|
||||
await update_activation_config(session, {"min_expected_value": 50.0})
|
||||
await update_activation_config(session, {"min_momentum_percentile": 150.0})
|
||||
|
||||
async def test_conviction_flags_round_trip(self, session: AsyncSession):
|
||||
await update_activation_config(
|
||||
|
||||
@@ -113,8 +113,8 @@ async def test_run_backtest_smoke(session):
|
||||
# the oscillating series should yield at least some resolved setups
|
||||
assert report["candidates"] >= 1
|
||||
|
||||
# sweep: lowering the EV threshold can only add qualifiers, never remove them
|
||||
sweep = sorted(report["sweep"], key=lambda r: r["min_expected_value"], reverse=True)
|
||||
# sweep: lowering the momentum-percentile cutoff can only add qualifiers
|
||||
sweep = sorted(report["sweep"], key=lambda r: r["min_momentum_percentile"], reverse=True)
|
||||
counts = [r["total"] for r in sweep]
|
||||
assert counts == sorted(counts) # ascending as threshold descends
|
||||
# every calibration row is internally consistent
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
"""Unit tests for the activation qualification predicate (EV-based gate)."""
|
||||
"""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,
|
||||
expected_value_r,
|
||||
primary_target_probability,
|
||||
setup_qualifies,
|
||||
)
|
||||
from app.services.qualification import best_target_probability, setup_qualifies
|
||||
|
||||
# Default gate: expected value is the core test; conviction/conflict/target-prob
|
||||
# are optional tighteners, off here.
|
||||
# Default gate: floors only; the momentum selection is off (0). Conviction /
|
||||
# conflict / target-probability are optional tighteners, off here.
|
||||
DEFAULT_GATE = {
|
||||
"min_expected_value": 0.15,
|
||||
"min_momentum_percentile": 0.0,
|
||||
"min_rr": 1.2,
|
||||
"min_confidence": 55.0,
|
||||
"min_target_probability": 0.0,
|
||||
@@ -22,9 +17,12 @@ DEFAULT_GATE = {
|
||||
"exclude_conflicts": False,
|
||||
}
|
||||
|
||||
# Strict gate: every optional tightener turned on (the old shipped defaults).
|
||||
# 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_expected_value": 0.0,
|
||||
"min_momentum_percentile": 0.0,
|
||||
"min_rr": 2.0,
|
||||
"min_confidence": 70.0,
|
||||
"min_target_probability": 60.0,
|
||||
@@ -45,59 +43,17 @@ def _setup(**kwargs):
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
class TestExpectedValue:
|
||||
def test_uses_primary_target_not_best(self):
|
||||
s = _setup(
|
||||
rr_ratio=1.5,
|
||||
targets=[
|
||||
{"probability": 80.0},
|
||||
{"probability": 30.0, "is_primary": True},
|
||||
],
|
||||
)
|
||||
# EV from the primary (30%): 0.3*1.5 - 0.7 = -0.25
|
||||
assert expected_value_r(s) == -0.25
|
||||
assert primary_target_probability(s) == 30.0
|
||||
|
||||
def test_falls_back_to_best_when_no_primary_flag(self):
|
||||
s = _setup(rr_ratio=2.0, targets=[{"probability": 40.0}, {"probability": 60.0}])
|
||||
assert primary_target_probability(s) == 60.0
|
||||
# 0.6*2.0 - 0.4 = 0.8
|
||||
assert abs(expected_value_r(s) - 0.8) < 1e-9
|
||||
|
||||
def test_none_when_no_targets(self):
|
||||
assert expected_value_r(_setup(targets=[])) is None
|
||||
assert primary_target_probability(_setup(targets=[])) is None
|
||||
|
||||
|
||||
class TestSetupQualifies:
|
||||
def test_positive_ev_setup_passes(self):
|
||||
# primary 50% @ rr 3.0 → EV = 1.0
|
||||
class TestFloors:
|
||||
def test_passes_floors(self):
|
||||
assert setup_qualifies(_setup(), DEFAULT_GATE) is True
|
||||
|
||||
def test_negative_ev_fails(self):
|
||||
# primary 30% @ rr 1.3 → EV = -0.31, below the 0.15 floor
|
||||
s = _setup(rr_ratio=1.3, targets=[{"probability": 30.0, "is_primary": True}])
|
||||
assert setup_qualifies(s, DEFAULT_GATE) is False
|
||||
|
||||
def test_thin_positive_ev_below_floor_fails(self):
|
||||
# Positive but thin: 0.45*1.3 - 0.55 = 0.035, under the 0.15 floor.
|
||||
s = _setup(rr_ratio=1.3, targets=[{"probability": 45.0, "is_primary": True}])
|
||||
assert setup_qualifies(s, DEFAULT_GATE) is False
|
||||
|
||||
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_no_targets_defers_to_rr_and_confidence(self):
|
||||
# No probability → EV uncomputable → not blocked on EV; passes on floors.
|
||||
assert setup_qualifies(_setup(targets=[]), DEFAULT_GATE) is True
|
||||
# ...but still subject to the rr/confidence floors.
|
||||
assert setup_qualifies(_setup(targets=[], rr_ratio=1.0), DEFAULT_GATE) is False
|
||||
|
||||
def test_conviction_and_conflict_ignored_by_default(self):
|
||||
# Moderate action + medium risk still pass when tighteners are off.
|
||||
s = _setup(recommended_action="LONG_MODERATE", risk_level="Medium")
|
||||
assert setup_qualifies(s, DEFAULT_GATE) is True
|
||||
|
||||
@@ -113,11 +69,26 @@ class TestSetupQualifies:
|
||||
s = _setup(direction="long", target=120.0, stop_loss=95.0, current_price=94.0)
|
||||
assert setup_qualifies(s, DEFAULT_GATE) is False
|
||||
|
||||
def test_missing_min_ev_key_skips_ev(self):
|
||||
# Legacy callers without min_expected_value: EV defaults to -inf (no floor).
|
||||
legacy = {k: v for k, v in DEFAULT_GATE.items() if k != "min_expected_value"}
|
||||
s = _setup(rr_ratio=1.3, targets=[{"probability": 30.0, "is_primary": True}])
|
||||
assert setup_qualifies(s, legacy) is True
|
||||
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user