From 14327ab25ac1a3b8e01c8bf20d1ab4e113a353bf Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Fri, 3 Jul 2026 16:13:27 +0200 Subject: [PATCH] Require aligned action for qualified setups --- app/services/backtest_service.py | 6 +++++- app/services/qualification.py | 22 +++++++++++++++++----- frontend/src/lib/qualification.ts | 23 ++++++++++++++++------- frontend/src/pages/DashboardPage.tsx | 13 +++++-------- tests/unit/test_backtest_service.py | 7 ++++--- tests/unit/test_qualification.py | 10 ++++++++++ 6 files changed, 57 insertions(+), 24 deletions(-) diff --git a/app/services/backtest_service.py b/app/services/backtest_service.py index 40ea099..e5de01e 100644 --- a/app/services/backtest_service.py +++ b/app/services/backtest_service.py @@ -54,6 +54,7 @@ from app.services.outcome_service import ( from app.services.price_service import query_ohlcv from app.services.qualification import ( HIGH_CONVICTION_ACTIONS, + _action_direction, best_target_probability, setup_qualifies, ) @@ -917,7 +918,10 @@ def _gate_ablation(candidates: list[dict], activation: dict, threshold: float) - return (c["confidence"] or 0.0) >= min_conf def neutral_ok(c: dict) -> bool: - return not exclude_neutral or (c.get("action") or "NEUTRAL") != "NEUTRAL" + if not exclude_neutral: + return True + action_direction = _action_direction(c.get("action")) + return action_direction != "neutral" and action_direction == c["direction"] def tighteners_ok(c: dict) -> bool: if require_high and (c.get("action") or "") not in HIGH_CONVICTION_ACTIONS: diff --git a/app/services/qualification.py b/app/services/qualification.py index b9fbff6..818a9a7 100644 --- a/app/services/qualification.py +++ b/app/services/qualification.py @@ -17,6 +17,16 @@ from typing import Any HIGH_CONVICTION_ACTIONS = {"LONG_HIGH", "SHORT_HIGH"} +def _action_direction(action: str | None) -> str: + if not action or action == "NEUTRAL": + return "neutral" + if action.startswith("LONG"): + return "long" + if action.startswith("SHORT"): + return "short" + return "neutral" + + def best_target_probability(setup: Any) -> float: """Highest probability among a setup's targets, 0 if none.""" targets = getattr(setup, "targets", None) or [] @@ -78,12 +88,14 @@ def setup_qualifies(setup: Any, config: dict) -> bool: momentum_percentile = getattr(setup, "momentum_percentile", None) if momentum_percentile is not None and momentum_percentile < min_pct: return False - # A NEUTRAL recommendation means the engine found no clear directional setup — - # not an actionable signal, so by default it doesn't qualify (and can't be a - # top pick). ``exclude_neutral`` defaults on; turn it off to also count - # no-clear-direction residual momentum leaders. + # 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 + # setup is counter-bias. ``exclude_neutral`` defaults on; callers that omit + # it keep legacy floor-only behavior. if config.get("exclude_neutral"): - if (setup.recommended_action or "NEUTRAL") == "NEUTRAL": + action_direction = _action_direction(getattr(setup, "recommended_action", None)) + setup_direction = (getattr(setup, "direction", "long") or "long").lower() + if action_direction == "neutral" or action_direction != setup_direction: return False if config.get("require_high_conviction"): if (setup.recommended_action or "") not in HIGH_CONVICTION_ACTIONS: diff --git a/frontend/src/lib/qualification.ts b/frontend/src/lib/qualification.ts index 5c04c5c..8a53c49 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']); +function actionDirection(action: TradeSetup['recommended_action']): 'long' | 'short' | 'neutral' { + if (!action || action === 'NEUTRAL') return 'neutral'; + if (action.startsWith('LONG')) return 'long'; + if (action.startsWith('SHORT')) return 'short'; + return 'neutral'; +} + export function bestTargetProbability(setup: TradeSetup): number { return setup.targets?.length ? Math.max(...setup.targets.map((t) => t.probability)) : 0; } @@ -42,8 +49,11 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo return false; } } - // NEUTRAL = "no clear setup" — not actionable, so by default it doesn't qualify. - if (config.exclude_neutral && (setup.recommended_action ?? 'NEUTRAL') === 'NEUTRAL') return false; + // NEUTRAL = "no clear setup"; an opposite action means this setup is counter-bias. + if (config.exclude_neutral) { + const actionDir = actionDirection(setup.recommended_action); + if (actionDir === 'neutral' || actionDir !== setup.direction) return false; + } if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) { return false; } @@ -53,9 +63,9 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo /** * Symbol of the current single 'top pick' — the #1 row the dashboard highlights: - * the highest residual 12-1 momentum percentile among qualified setups (or among all - * setups when none qualify). Returns null when there are no setups. Keep in step - * with the Top Setups ranking in DashboardPage. + * the highest residual 12-1 momentum percentile among qualified setups. Returns + * null when there are no actionable setups. Keep in step with the Top Setups + * ranking in DashboardPage. */ export function topPickSymbol( trades: TradeSetup[] | undefined, @@ -64,8 +74,7 @@ export function topPickSymbol( const all = trades ?? []; if (all.length === 0) return null; const qualified = activation ? all.filter((t) => qualifiesSetup(t, activation)) : []; - const pool = qualified.length > 0 ? qualified : all; - const top = [...pool].sort( + const top = [...qualified].sort( (a, b) => (b.momentum_percentile ?? -Infinity) - (a.momentum_percentile ?? -Infinity), )[0]; return top?.symbol ?? null; diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index d3351c2..05f310a 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -76,15 +76,12 @@ export default function DashboardPage() { [trades.data, activation.data], ); - // Show qualified setups first; fall back to the full list when none qualify. - // Rank by residual 12-1 momentum percentile so the strongest names sit at the top. - const showingQualified = qualifiedSetups.length > 0; + // Rank only actionable/qualified setups by residual 12-1 momentum percentile. const topSetups: TradeSetup[] = useMemo(() => { - const pool = showingQualified ? qualifiedSetups : trades.data ?? []; - return [...pool] + return [...qualifiedSetups] .sort((a, b) => (b.momentum_percentile ?? -Infinity) - (a.momentum_percentile ?? -Infinity)) .slice(0, 5); - }, [showingQualified, qualifiedSetups, trades.data]); + }, [qualifiedSetups]); const topWatchlist = useMemo( () => @@ -197,12 +194,12 @@ export default function DashboardPage() {
{trades.isLoading && } {trades.isError && Failed to load setups} {trades.data && topSetups.length === 0 && ( - No active setups. Run the scanner from the Signals page. + No qualified actionable setups right now. )} {topSetups.length > 0 && (
diff --git a/tests/unit/test_backtest_service.py b/tests/unit/test_backtest_service.py index 199b373..67104b0 100644 --- a/tests/unit/test_backtest_service.py +++ b/tests/unit/test_backtest_service.py @@ -313,7 +313,8 @@ def _acand( ) -> dict: """Ablation candidate: meets_core mirrors the default floors (min_rr 1.2, min_confidence 55, exclude_neutral on).""" - meets = rr >= 1.2 and conf >= 55.0 and action != "NEUTRAL" + action_dir = "long" if action.startswith("LONG") else "short" if action.startswith("SHORT") else "neutral" + meets = rr >= 1.2 and conf >= 55.0 and action_dir != "neutral" and action_dir == direction return { "rr": rr, "confidence": conf, @@ -347,7 +348,7 @@ class TestGateAblation: _acand(rr=1.0), # fails R:R floor _acand(action="NEUTRAL"), # fails NEUTRAL exclusion _acand(mp=50.0), # fails the momentum cutoff - _acand(direction="short", mp=95.0), # short — gated out + _acand(direction="short", action="SHORT_MODERATE", mp=95.0), # short — gated out ] rows = {r["variant"]: r for r in bt._gate_ablation(cands, self.ACTIVATION, 80.0)} assert rows["all_floors"]["total"] == 1 @@ -364,7 +365,7 @@ class TestGateAblation: def test_threshold_zero_disables_momentum_gate(self): # Floors only: the short and the low-momentum long both pass all_floors. - cands = [_acand(mp=50.0), _acand(direction="short", mp=None)] + cands = [_acand(mp=50.0), _acand(direction="short", action="SHORT_MODERATE", mp=None)] rows = {r["variant"]: r for r in bt._gate_ablation(cands, self.ACTIVATION, 0.0)} assert rows["all_floors"]["total"] == 2 diff --git a/tests/unit/test_qualification.py b/tests/unit/test_qualification.py index 110fa6f..d1fa9eb 100644 --- a/tests/unit/test_qualification.py +++ b/tests/unit/test_qualification.py @@ -31,6 +31,7 @@ STRICT_GATE = { def _setup(**kwargs): base = dict( + direction="long", rr_ratio=3.0, confidence_score=80.0, recommended_action="LONG_HIGH", @@ -124,6 +125,15 @@ class TestExcludeNeutral: def test_directional_passes_when_on(self): assert setup_qualifies(_setup(recommended_action="LONG_MODERATE"), NEUTRAL_GATE) is True + def test_opposing_short_action_fails_for_long_setup(self): + assert setup_qualifies(_setup(direction="long", recommended_action="SHORT_MODERATE"), NEUTRAL_GATE) is False + + def test_matching_short_action_still_fails_long_only_momentum_gate(self): + assert setup_qualifies( + _setup(direction="short", recommended_action="SHORT_MODERATE", momentum_percentile=95.0), + {**NEUTRAL_GATE, "min_momentum_percentile": 80.0}, + ) is False + 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