From 65335cf1f3c5650878a9e416ae3988aa852b0971 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Sat, 4 Jul 2026 13:51:44 +0200 Subject: [PATCH] Tighten qualified signal gate --- app/services/qualification.py | 36 ++++++++++++++++++--------- frontend/src/lib/qualification.ts | 8 +++--- tests/unit/test_outcome_service.py | 11 +++++++++ tests/unit/test_qualification.py | 39 +++++++++++++++++++++++++----- 4 files changed, 74 insertions(+), 20 deletions(-) diff --git a/app/services/qualification.py b/app/services/qualification.py index 818a9a7..e7ad678 100644 --- a/app/services/qualification.py +++ b/app/services/qualification.py @@ -5,9 +5,9 @@ performance stats (server) and mirrored on the frontend. The core selection is residual cross-sectional momentum: a setup's ticker must rank in the top ``min_momentum_percentile`` of the universe by beta-adjusted 12-1 month momentum. R:R and confidence remain as floors, and conviction/conflict survive as optional -tighteners (off by default). The activation percentile is computed across the -universe and attached to each setup upstream; when it's absent the gate falls -back to the floors. +tighteners (off by default). Qualified setups must also have a probability-backed +target; otherwise a mathematically high R:R can be driven by a fragile target +with no independent validation. """ from __future__ import annotations @@ -34,6 +34,18 @@ def best_target_probability(setup: Any) -> float: return max(probs, default=0.0) +def primary_target_probability(setup: Any) -> float | None: + """Probability of the primary/headline target, falling back to best target.""" + targets = getattr(setup, "targets", None) or [] + for target in targets: + if not isinstance(target, dict) or not target.get("is_primary"): + continue + probability = target.get("probability") + return float(probability) if probability is not None else None + best = best_target_probability(setup) + return best if best > 0 else None + + def live_risk_reward(setup: Any, current_price: float) -> float | None: """R:R recomputed from the CURRENT price, not the (possibly stale) entry. @@ -58,10 +70,10 @@ def setup_qualifies(setup: Any, config: dict) -> bool: ``setup`` is duck-typed: any object exposing rr_ratio, confidence_score, recommended_action, risk_level and a ``targets`` list of dicts. - Gate order: R:R floor → freshness (live R:R) → confidence floor → momentum - percentile (the core selection) → optional conviction / conflict tighteners. - ``min_momentum_percentile`` defaults to 0 (off) for callers that pass a legacy - config without the key. + Gate order: R:R floor, freshness (live R:R), target probability, confidence + floor, momentum percentile (the core selection), then optional conviction / + conflict tighteners. ``min_momentum_percentile`` defaults to 0 (off) for + callers that pass a legacy config without the key. """ if setup.rr_ratio < config["min_rr"]: return False @@ -73,20 +85,22 @@ def setup_qualifies(setup: Any, config: dict) -> bool: live_rr = live_risk_reward(setup, float(current_price)) if live_rr is not None and live_rr < config["min_rr"]: return False + if primary_target_probability(setup) is None: + return False if (setup.confidence_score or 0.0) < config["min_confidence"]: return False # Residual cross-sectional momentum: the core selection. A setup's ticker # must rank in the top ``min_momentum_percentile`` of the universe by # beta-adjusted 12-1 momentum. The validated edge is long-only, so while the - # gate is active shorts (which fight the trend) never qualify. The percentile - # floor is only enforced when a percentile is attached (live setups / - # backtest); callers that don't attach it defer to the floors above. + # gate is active shorts (which fight the trend) never qualify. Missing ranks + # do not qualify because the production edge depends on this cross-sectional + # selection. min_pct = float(config.get("min_momentum_percentile", 0.0)) if min_pct > 0: if (getattr(setup, "direction", "long") or "long") == "short": return False momentum_percentile = getattr(setup, "momentum_percentile", None) - if momentum_percentile is not None and momentum_percentile < min_pct: + if momentum_percentile is None or momentum_percentile < min_pct: return False # A setup is actionable only when the live ticker action points in the same # direction. NEUTRAL means no clear signal; an opposite action means the diff --git a/frontend/src/lib/qualification.ts b/frontend/src/lib/qualification.ts index 289118d..195f3a6 100644 --- a/frontend/src/lib/qualification.ts +++ b/frontend/src/lib/qualification.ts @@ -39,13 +39,15 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo if (setup.current_price != null && liveRiskReward(setup, setup.current_price) < config.min_rr) { return false; } + const targetProbability = primaryTargetProbability(setup); + if (targetProbability == null || targetProbability <= 0) return false; if ((setup.confidence_score ?? 0) < config.min_confidence) return false; // Residual cross-sectional momentum is the core selection (long-only). While - // the gate is active, shorts never qualify; the percentile floor is enforced - // only when a percentile is attached, otherwise defer to the floors. + // the gate is active, shorts never qualify; missing ranks do not qualify + // because the production edge depends on this cross-sectional selection. if (config.min_momentum_percentile > 0) { if (setup.direction === 'short') return false; - if (setup.momentum_percentile != null && setup.momentum_percentile < config.min_momentum_percentile) { + if (setup.momentum_percentile == null || setup.momentum_percentile < config.min_momentum_percentile) { return false; } } diff --git a/tests/unit/test_outcome_service.py b/tests/unit/test_outcome_service.py index 408dfb4..b9442c0 100644 --- a/tests/unit/test_outcome_service.py +++ b/tests/unit/test_outcome_service.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from datetime import date, datetime, timedelta, timezone import pytest @@ -127,6 +128,15 @@ def _make_setup( detected: datetime | None = None, **kwargs, ) -> TradeSetup: + targets_json = kwargs.pop( + "targets_json", + json.dumps([{ + "price": target, + "rr_ratio": rr, + "probability": 50.0, + "is_primary": True, + }]), + ) return TradeSetup( ticker_id=ticker.id, direction=direction, @@ -136,6 +146,7 @@ def _make_setup( rr_ratio=rr, composite_score=50.0, detected_at=detected or datetime(2026, 1, 2, 21, 0, tzinfo=timezone.utc), + targets_json=targets_json, **kwargs, ) diff --git a/tests/unit/test_qualification.py b/tests/unit/test_qualification.py index d1fa9eb..f711243 100644 --- a/tests/unit/test_qualification.py +++ b/tests/unit/test_qualification.py @@ -4,10 +4,15 @@ from __future__ import annotations from types import SimpleNamespace -from app.services.qualification import best_target_probability, setup_qualifies +from app.services.qualification import ( + best_target_probability, + primary_target_probability, + setup_qualifies, +) # Default gate: floors only; the momentum selection is off (0). Conviction / -# conflict / target-probability are optional tighteners, off here. +# conflict are optional tighteners, off here. Target probability is always +# required because qualified means the headline target is evidence-backed. DEFAULT_GATE = { "min_momentum_percentile": 0.0, "min_rr": 1.2, @@ -68,6 +73,15 @@ class TestFloors: 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_target_probability_fails(self): + assert setup_qualifies(_setup(targets=[]), DEFAULT_GATE) is False + + def test_non_primary_target_probability_still_passes(self): + assert setup_qualifies( + _setup(targets=[{"probability": 42.0}]), + DEFAULT_GATE, + ) is True + class TestMomentumGate: def test_top_momentum_passes(self): @@ -76,15 +90,17 @@ class TestMomentumGate: 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_missing_percentile_fails_when_gate_active(self): + # No residual rank means the production momentum edge was not measured. + assert setup_qualifies(_setup(), MOMENTUM_GATE) is False 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_threshold_zero_allows_missing_percentile(self): + assert setup_qualifies(_setup(), 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 @@ -146,3 +162,14 @@ class TestBestTargetProbability: def test_empty_is_zero(self): assert best_target_probability(_setup(targets=[])) == 0.0 + + def test_primary_probability_prefers_starred_target(self): + s = _setup(targets=[ + {"probability": 70.0}, + {"probability": 45.0, "is_primary": True}, + ]) + assert primary_target_probability(s) == 45.0 + + def test_primary_probability_falls_back_to_best(self): + s = _setup(targets=[{"probability": 40.0}, {"probability": 72.0}]) + assert primary_target_probability(s) == 72.0