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}])