From 6511a1020b8d85153a596d5dbb15a98fb6b204a3 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Tue, 30 Jun 2026 15:19:07 +0200 Subject: [PATCH] feat: exclude NEUTRAL setups from the activation gate (default on) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A NEUTRAL ("No Clear Setup") recommendation means the engine found no clear directional trade, yet such setups could still qualify and even be crowned the top pick purely on momentum rank (e.g. an extended momentum leader with a far, 5%-probability target). A NEUTRAL signal isn't actionable, so it shouldn't qualify. New `exclude_neutral` activation flag (default on): setup_qualifies drops setups whose recommended_action is NEUTRAL. It lives in the shared gate, so it flows through the dashboard's qualified/top-pick selection, the track record's qualified stats, and the backtest (which computes recommended_action and gates on meets_core). Toggleable in Admin → Settings → Activation; the frontend mirror and activationSummary ("directional") match. Re-run the backtest after enabling to confirm it holds/improves expectancy. Co-Authored-By: Claude Opus 4.8 --- app/schemas/admin.py | 1 + app/services/admin_service.py | 4 ++++ app/services/qualification.py | 7 +++++++ .../components/admin/ActivationSettings.tsx | 19 +++++++++++++++++++ frontend/src/lib/qualification.ts | 3 +++ frontend/src/lib/types.ts | 1 + tests/unit/test_activation_settings.py | 7 +++++++ tests/unit/test_qualification.py | 18 ++++++++++++++++++ 8 files changed, 60 insertions(+) diff --git a/app/schemas/admin.py b/app/schemas/admin.py index a8883b5..9cdf9e9 100644 --- a/app/schemas/admin.py +++ b/app/schemas/admin.py @@ -64,6 +64,7 @@ class ActivationConfigUpdate(BaseModel): min_confidence: float | None = Field(default=None, ge=0, le=100) require_high_conviction: bool | None = None exclude_conflicts: bool | None = None + exclude_neutral: bool | None = None class ScheduleConfigUpdate(BaseModel): diff --git a/app/services/admin_service.py b/app/services/admin_service.py index 79ea48d..49e06b2 100644 --- a/app/services/admin_service.py +++ b/app/services/admin_service.py @@ -51,6 +51,7 @@ _ACTIVATION_FLOAT_KEYS: dict[str, str] = { _ACTIVATION_BOOL_KEYS: dict[str, str] = { "require_high_conviction": "activation_require_high_conviction", "exclude_conflicts": "activation_exclude_conflicts", + "exclude_neutral": "activation_exclude_neutral", } ACTIVATION_DEFAULTS: dict[str, float | bool] = { "min_momentum_percentile": 80.0, @@ -58,6 +59,9 @@ ACTIVATION_DEFAULTS: dict[str, float | bool] = { "min_confidence": 55.0, "require_high_conviction": False, "exclude_conflicts": False, + # On by default: a NEUTRAL ("no clear setup") recommendation isn't an + # actionable signal, so it shouldn't qualify or be crowned a top pick. + "exclude_neutral": True, } diff --git a/app/services/qualification.py b/app/services/qualification.py index d563a02..46412ac 100644 --- a/app/services/qualification.py +++ b/app/services/qualification.py @@ -78,6 +78,13 @@ 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 momentum leaders. + if config.get("exclude_neutral"): + if (setup.recommended_action or "NEUTRAL") == "NEUTRAL": + return False if config.get("require_high_conviction"): if (setup.recommended_action or "") not in HIGH_CONVICTION_ACTIONS: return False diff --git a/frontend/src/components/admin/ActivationSettings.tsx b/frontend/src/components/admin/ActivationSettings.tsx index ea60db1..86468f2 100644 --- a/frontend/src/components/admin/ActivationSettings.tsx +++ b/frontend/src/components/admin/ActivationSettings.tsx @@ -9,6 +9,7 @@ const DEFAULTS: ActivationConfig = { min_confidence: 55, require_high_conviction: false, exclude_conflicts: false, + exclude_neutral: true, }; export function ActivationSettings() { @@ -87,6 +88,24 @@ export function ActivationSettings() { +
+ +
+

Optional tighteners

Off by default — turn on to be more selective on top of the momentum gate.

diff --git a/frontend/src/lib/qualification.ts b/frontend/src/lib/qualification.ts index 7d74da6..9ced85d 100644 --- a/frontend/src/lib/qualification.ts +++ b/frontend/src/lib/qualification.ts @@ -42,6 +42,8 @@ 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; if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) { return false; } @@ -74,6 +76,7 @@ export function activationSummary(config: ActivationConfig): string { const parts = []; if (config.min_momentum_percentile > 0) parts.push(`top ${(100 - config.min_momentum_percentile).toFixed(0)}% momentum`); parts.push(`R:R ≥ ${config.min_rr.toFixed(1)}`, `conf ≥ ${config.min_confidence.toFixed(0)}%`); + if (config.exclude_neutral) parts.push('directional'); if (config.require_high_conviction) parts.push('high-conviction'); if (config.exclude_conflicts) parts.push('clean'); return parts.join(' · '); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 8765341..25eabfb 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -166,6 +166,7 @@ export interface ActivationConfig { min_confidence: number; require_high_conviction: boolean; exclude_conflicts: boolean; + exclude_neutral: boolean; } // Cron schedule for the daily/intraday pipelines + fundamentals diff --git a/tests/unit/test_activation_settings.py b/tests/unit/test_activation_settings.py index 5ad2810..5f7d1da 100644 --- a/tests/unit/test_activation_settings.py +++ b/tests/unit/test_activation_settings.py @@ -30,6 +30,7 @@ class TestActivationConfig: "min_confidence": 55.0, "require_high_conviction": False, "exclude_conflicts": False, + "exclude_neutral": True, } async def test_update_and_read_back(self, session: AsyncSession): @@ -62,6 +63,12 @@ class TestActivationConfig: assert config["require_high_conviction"] is True assert config["exclude_conflicts"] is True + async def test_exclude_neutral_round_trip(self, session: AsyncSession): + # On by default; can be turned off. + assert (await get_activation_config(session))["exclude_neutral"] is True + await update_activation_config(session, {"exclude_neutral": False}) + assert (await get_activation_config(session))["exclude_neutral"] is False + async def test_rejects_negative_rr(self, session: AsyncSession): with pytest.raises(ValidationError): await update_activation_config(session, {"min_rr": -1.0}) diff --git a/tests/unit/test_qualification.py b/tests/unit/test_qualification.py index d57d0bb..110fa6f 100644 --- a/tests/unit/test_qualification.py +++ b/tests/unit/test_qualification.py @@ -111,6 +111,24 @@ class TestStrictTighteners: assert setup_qualifies(s, STRICT_GATE) is False +NEUTRAL_GATE = {**DEFAULT_GATE, "exclude_neutral": True} + + +class TestExcludeNeutral: + def test_neutral_excluded_when_on(self): + assert setup_qualifies(_setup(recommended_action="NEUTRAL"), NEUTRAL_GATE) is False + + def test_missing_action_treated_as_neutral(self): + assert setup_qualifies(_setup(recommended_action=None), NEUTRAL_GATE) is False + + def test_directional_passes_when_on(self): + assert setup_qualifies(_setup(recommended_action="LONG_MODERATE"), NEUTRAL_GATE) is True + + 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 + + class TestBestTargetProbability: def test_returns_max(self): s = _setup(targets=[{"probability": 40.0}, {"probability": 72.0}, {"probability": 55.0}])