feat: exclude NEUTRAL setups from the activation gate (default on)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/[0.06] pt-4">
|
||||
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.exclude_neutral}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, exclude_neutral: e.target.checked }))}
|
||||
className="mt-0.5 h-4 w-4 cursor-pointer accent-blue-400"
|
||||
/>
|
||||
<span>
|
||||
Require a directional call (exclude NEUTRAL)
|
||||
<span className="mt-0.5 block text-[11px] text-gray-500">
|
||||
On by default. A NEUTRAL ("No Clear Setup") recommendation isn't a tradeable signal, so it
|
||||
never qualifies or becomes a top pick. Turn off to also count no-clear-direction momentum leaders.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/[0.06] pt-4">
|
||||
<p className="text-xs font-medium uppercase tracking-widest text-gray-500">Optional tighteners</p>
|
||||
<p className="mt-1 text-[11px] text-gray-600">Off by default — turn on to be more selective on top of the momentum gate.</p>
|
||||
|
||||
@@ -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(' · ');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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}])
|
||||
|
||||
Reference in New Issue
Block a user