diff --git a/app/services/qualification.py b/app/services/qualification.py index e7ad678..349c506 100644 --- a/app/services/qualification.py +++ b/app/services/qualification.py @@ -5,9 +5,11 @@ 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). 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. +tighteners (off by default). Qualified setups must also have a primary target +with at least ``MIN_TARGET_PROBABILITY`` reach probability: a primary below the +floor is a lottery target whose distance inflates R:R, so it would otherwise +game the min_rr gate (the model clamps probabilities at 3%, and far targets pin +there while their live R:R stays high forever). """ from __future__ import annotations @@ -16,6 +18,13 @@ from typing import Any HIGH_CONVICTION_ACTIONS = {"LONG_HIGH", "SHORT_HIGH"} +# Floor for the primary target's reach probability, shared with the primary +# target selection in recommendation_service and mirrored in the frontend +# (qualification.ts). Under the two-barrier model a fair-race 1.5:1 target sits +# near ~34% before drift adjustments, so 20% only excludes targets the model +# itself considers long shots. +MIN_TARGET_PROBABILITY = 20.0 + def _action_direction(action: str | None) -> str: if not action or action == "NEUTRAL": @@ -85,7 +94,8 @@ 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: + target_probability = primary_target_probability(setup) + if target_probability is None or target_probability < MIN_TARGET_PROBABILITY: return False if (setup.confidence_score or 0.0) < config["min_confidence"]: return False diff --git a/app/services/recommendation_service.py b/app/services/recommendation_service.py index 45817a2..d8dcd75 100644 --- a/app/services/recommendation_service.py +++ b/app/services/recommendation_service.py @@ -13,6 +13,7 @@ from app.models.settings import SystemSetting from app.models.sr_level import SRLevel from app.models.ticker import Ticker from app.models.trade_setup import TradeSetup +from app.services.qualification import MIN_TARGET_PROBABILITY from app.services.sr_service import cluster_sr_zones logger = logging.getLogger(__name__) @@ -575,10 +576,10 @@ def build_recommendation_snapshot( PRIMARY_TARGET_MIN_RR = 1.5 -# Below this the target is a lottery ticket: under the two-barrier model a -# fair-race 1.5:1 target sits near ~34% before drift adjustments, so 20% only -# excludes targets the model itself considers long shots. -PRIMARY_TARGET_MIN_PROBABILITY = 20.0 +# Below this the target is a lottery ticket. Shared with the activation gate +# (qualification.MIN_TARGET_PROBABILITY) so the primary selection and the gate +# agree on what counts as a probability-backed target. +PRIMARY_TARGET_MIN_PROBABILITY = MIN_TARGET_PROBABILITY def _select_primary_target( diff --git a/frontend/src/lib/qualification.ts b/frontend/src/lib/qualification.ts index f63687e..d0ccf36 100644 --- a/frontend/src/lib/qualification.ts +++ b/frontend/src/lib/qualification.ts @@ -2,6 +2,13 @@ import type { ActivationConfig, TradeSetup } from './types'; const HIGH_CONVICTION_ACTIONS = new Set(['LONG_HIGH', 'SHORT_HIGH']); +/** + * Floor for the primary target's reach probability — mirrors + * MIN_TARGET_PROBABILITY in app/services/qualification.py. A primary below + * this is a lottery target whose distance inflates R:R past the min_rr gate. + */ +export const MIN_TARGET_PROBABILITY = 20; + function actionDirection(action: TradeSetup['recommended_action']): 'long' | 'short' | 'neutral' { if (!action || action === 'NEUTRAL') return 'neutral'; if (action.startsWith('LONG')) return 'long'; @@ -40,7 +47,7 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo return false; } const targetProbability = primaryTargetProbability(setup); - if (targetProbability == null || targetProbability <= 0) return false; + if (targetProbability == null || targetProbability < MIN_TARGET_PROBABILITY) 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; missing ranks do not qualify @@ -77,6 +84,9 @@ export function disqualifyReason(setup: TradeSetup, config: ActivationConfig): s } const targetProbability = primaryTargetProbability(setup); if (targetProbability == null || targetProbability <= 0) return 'no target probability'; + if (targetProbability < MIN_TARGET_PROBABILITY) { + return `target probability below ${MIN_TARGET_PROBABILITY}%`; + } if ((setup.confidence_score ?? 0) < config.min_confidence) { return `confidence below ${config.min_confidence.toFixed(0)}%`; } diff --git a/tests/unit/test_qualification.py b/tests/unit/test_qualification.py index f711243..34051d3 100644 --- a/tests/unit/test_qualification.py +++ b/tests/unit/test_qualification.py @@ -82,6 +82,31 @@ class TestFloors: DEFAULT_GATE, ) is True + def test_lottery_primary_target_fails(self): + # A far target pinned at the model's 3% clamp floor: its distance keeps + # both stored and live R:R above the gate, so only the probability floor + # can reject it (the stale pre-fix lottery-headline case). + s = _setup(rr_ratio=3.09, targets=[{"probability": 3.0, "is_primary": True}]) + assert setup_qualifies(s, DEFAULT_GATE) is False + + def test_probability_at_floor_passes(self): + assert setup_qualifies( + _setup(targets=[{"probability": 20.0, "is_primary": True}]), + DEFAULT_GATE, + ) is True + + def test_probability_just_below_floor_fails(self): + assert setup_qualifies( + _setup(targets=[{"probability": 19.9, "is_primary": True}]), + DEFAULT_GATE, + ) is False + + def test_best_target_fallback_below_floor_fails(self): + # No starred primary: the fallback takes the best target, which must + # still clear the probability floor. + s = _setup(targets=[{"probability": 12.0}, {"probability": 8.0}]) + assert setup_qualifies(s, DEFAULT_GATE) is False + class TestMomentumGate: def test_top_momentum_passes(self):