replace EV activation gate with cross-sectional 12-1 momentum ranking
The 5-year backtest confirmed the EV gate adds negative value (high threshold = worst expectancy) and that 12-1 month momentum is the one price signal with a plausible, right-signed cross-sectional IC (~0.05). So "qualified" now means: clears the R:R + confidence floors AND the ticker ranks in the top `min_momentum_percentile` of the universe by 12-1 momentum that week. - qualification.py: drop expected_value_r / the EV gate; add a momentum-percentile gate (duck-typed `momentum_percentile`, only enforced when attached + threshold set, else defers to floors). Mirrored in frontend qualification.ts. - activation config/schema: min_expected_value -> min_momentum_percentile (default 80 = top quintile). ActivationSettings, DashboardPage (ranks/【shows】 momentum instead of EV), and the BacktestPanel sweep follow. - backtest: rank each ISO week's universe by 12-1 momentum, assign a percentile, and qualify the top slice; the sweep now sweeps the percentile cutoff. Also offload the backtest's per-ticker compute to a worker thread so the heavy ~5y run no longer blocks the API event loop (the "backend offline" flicker). Production setups don't carry momentum_percentile yet — wiring the scanner to attach it (a universe momentum-rank step) is the next step; until then the live gate defers to floors while the backtest measures the momentum selection. 330 backend tests pass; frontend build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
"""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 gate is
|
||||
expected value (in R): a setup must promise positive, probability-weighted
|
||||
asymmetry, not just a fat-but-improbable target or a likely-but-thin one. R:R
|
||||
and confidence remain as floors, and conviction/conflict/target-probability
|
||||
survive as optional tighteners (off by default).
|
||||
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/target-probability 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
|
||||
@@ -22,37 +25,6 @@ def best_target_probability(setup: Any) -> float:
|
||||
return max(probs, default=0.0)
|
||||
|
||||
|
||||
def primary_target_probability(setup: Any) -> float | None:
|
||||
"""Probability of the starred primary target (the one the headline R:R refers
|
||||
to). Falls back to the best target's probability when none is flagged primary,
|
||||
and None when there are no targets at all (probability unknowable).
|
||||
"""
|
||||
targets = getattr(setup, "targets", None) or []
|
||||
primary = next(
|
||||
(t for t in targets if isinstance(t, dict) and t.get("is_primary")), None
|
||||
)
|
||||
if primary is not None:
|
||||
return float(primary.get("probability", 0.0))
|
||||
probs = [float(t.get("probability", 0.0)) for t in targets if isinstance(t, dict)]
|
||||
return max(probs) if probs else None
|
||||
|
||||
|
||||
def expected_value_r(setup: Any) -> float | None:
|
||||
"""Expected value per unit of risk, in R: ``p·(R:R) − (1 − p)``.
|
||||
|
||||
``p`` is the primary target's hit probability. This single number captures
|
||||
"is this worth taking": it rewards both a good payoff ratio and a likely
|
||||
target, so a fat-but-improbable target can't outrank a solid, probable one —
|
||||
and a high R:R no longer fights a high probability the way the old separate
|
||||
gates did. Returns None when no target probability is known.
|
||||
"""
|
||||
p = primary_target_probability(setup)
|
||||
if p is None:
|
||||
return None
|
||||
p = p / 100.0
|
||||
return p * setup.rr_ratio - (1.0 - p)
|
||||
|
||||
|
||||
def live_risk_reward(setup: Any, current_price: float) -> float | None:
|
||||
"""R:R recomputed from the CURRENT price, not the (possibly stale) entry.
|
||||
|
||||
@@ -77,10 +49,10 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
|
||||
``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 → expected
|
||||
value (the core test) → optional conviction / conflict / target-probability
|
||||
tighteners. ``min_expected_value`` defaults to -inf for callers that pass a
|
||||
legacy config without the key, so they behave exactly as before.
|
||||
Gate order: R:R floor → freshness (live R:R) → confidence floor → momentum
|
||||
percentile (the core selection) → optional conviction / conflict /
|
||||
target-probability 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
|
||||
@@ -94,13 +66,15 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
|
||||
return False
|
||||
if (setup.confidence_score or 0.0) < config["min_confidence"]:
|
||||
return False
|
||||
# Expected value (R): the core gate. Only enforced when computable — setups
|
||||
# without target probabilities (e.g. legacy historical rows) defer to the
|
||||
# R:R + confidence floors above rather than being silently dropped.
|
||||
min_ev = float(config.get("min_expected_value", float("-inf")))
|
||||
ev = expected_value_r(setup)
|
||||
if ev is not None and ev < min_ev:
|
||||
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. Only
|
||||
# enforced when a percentile is attached (live setups / backtest) and a
|
||||
# threshold is set; 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:
|
||||
momentum_percentile = getattr(setup, "momentum_percentile", None)
|
||||
if momentum_percentile is not None and momentum_percentile < min_pct:
|
||||
return False
|
||||
if config.get("require_high_conviction"):
|
||||
if (setup.recommended_action or "") not in HIGH_CONVICTION_ACTIONS:
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user