6511a1020b
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>
95 lines
4.3 KiB
Python
95 lines
4.3 KiB
Python
"""Shared definition of a 'qualified' (actionable) trade setup.
|
|
|
|
A single predicate, driven by the admin activation config, used by the
|
|
performance stats (server) and mirrored on the frontend. The core selection is
|
|
cross-sectional momentum: a setup's ticker must rank in the top
|
|
``min_momentum_percentile`` of the universe by 12-1 month momentum — the one
|
|
signal the backtest showed actually sorts forward returns. R:R and confidence
|
|
remain as floors, and conviction/conflict survive as optional tighteners (off by
|
|
default). The momentum percentile is computed across the universe and attached to
|
|
each setup upstream; when it's absent the gate falls back to the floors.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
HIGH_CONVICTION_ACTIONS = {"LONG_HIGH", "SHORT_HIGH"}
|
|
|
|
|
|
def best_target_probability(setup: Any) -> float:
|
|
"""Highest probability among a setup's targets, 0 if none."""
|
|
targets = getattr(setup, "targets", None) or []
|
|
probs = [float(t.get("probability", 0.0)) for t in targets if isinstance(t, dict)]
|
|
return max(probs, default=0.0)
|
|
|
|
|
|
def live_risk_reward(setup: Any, current_price: float) -> float | None:
|
|
"""R:R recomputed from the CURRENT price, not the (possibly stale) entry.
|
|
|
|
Returns None / a low value when the setup is no longer actionable: price
|
|
already at/past the target (no reward left) or through the stop. This is how
|
|
over-progressed setups get filtered without a separate 'max progress' knob.
|
|
"""
|
|
if setup.direction == "long":
|
|
reward = setup.target - current_price
|
|
risk = current_price - setup.stop_loss
|
|
else:
|
|
reward = current_price - setup.target
|
|
risk = setup.stop_loss - current_price
|
|
if reward <= 0 or risk <= 0:
|
|
return 0.0
|
|
return reward / risk
|
|
|
|
|
|
def setup_qualifies(setup: Any, config: dict) -> bool:
|
|
"""Whether a setup clears the activation gate.
|
|
|
|
``setup`` is duck-typed: any object exposing rr_ratio, confidence_score,
|
|
recommended_action, risk_level and a ``targets`` list of dicts.
|
|
|
|
Gate order: R:R floor → freshness (live R:R) → confidence floor → momentum
|
|
percentile (the core selection) → optional conviction / conflict tighteners.
|
|
``min_momentum_percentile`` defaults to 0 (off) for callers that pass a legacy
|
|
config without the key.
|
|
"""
|
|
if setup.rr_ratio < config["min_rr"]:
|
|
return False
|
|
# Live R:R from the current price: drops setups whose price has already run
|
|
# toward the target (reward consumed) or through the stop. Only applied when
|
|
# a current price is attached (live list); skipped for historical setups.
|
|
current_price = getattr(setup, "current_price", None)
|
|
if current_price is not None:
|
|
live_rr = live_risk_reward(setup, float(current_price))
|
|
if live_rr is not None and live_rr < config["min_rr"]:
|
|
return False
|
|
if (setup.confidence_score or 0.0) < config["min_confidence"]:
|
|
return False
|
|
# Cross-sectional momentum: the core selection. A setup's ticker must rank in
|
|
# the top ``min_momentum_percentile`` of the universe by 12-1 momentum. The
|
|
# validated edge is long-only, so while the gate is active shorts (which fight
|
|
# the trend) never qualify. The percentile floor is only enforced when a
|
|
# percentile is attached (live setups / backtest); callers that don't attach
|
|
# it defer to the floors above.
|
|
min_pct = float(config.get("min_momentum_percentile", 0.0))
|
|
if min_pct > 0:
|
|
if (getattr(setup, "direction", "long") or "long") == "short":
|
|
return False
|
|
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
|
|
if config.get("exclude_conflicts"):
|
|
if (setup.risk_level or "") != "Low":
|
|
return False
|
|
return True
|