replace EV activation gate with cross-sectional 12-1 momentum ranking
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 41s
Deploy / deploy (push) Successful in 26s

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:
2026-06-23 22:42:24 +02:00
parent 099846513b
commit ef523474ad
12 changed files with 202 additions and 196 deletions
+1 -1
View File
@@ -59,7 +59,7 @@ class TickerUniverseUpdate(BaseModel):
class ActivationConfigUpdate(BaseModel): class ActivationConfigUpdate(BaseModel):
"""Activation gate: what counts as an actionable signal.""" """Activation gate: what counts as an actionable signal."""
min_expected_value: float | None = Field(default=None, ge=-1, le=10) min_momentum_percentile: float | None = Field(default=None, ge=0, le=100)
min_rr: float | None = Field(default=None, ge=0) min_rr: float | None = Field(default=None, ge=0)
min_confidence: float | None = Field(default=None, ge=0, le=100) min_confidence: float | None = Field(default=None, ge=0, le=100)
min_target_probability: float | None = Field(default=None, ge=0, le=100) min_target_probability: float | None = Field(default=None, ge=0, le=100)
+4 -4
View File
@@ -43,7 +43,7 @@ SUPPORTED_TICKER_UNIVERSES = {"sp500", "nasdaq100", "nasdaq_all"}
# confidence are floors; high-conviction / clean-read / target-probability are # confidence are floors; high-conviction / clean-read / target-probability are
# optional tighteners (off by default — turn on to be more selective). # optional tighteners (off by default — turn on to be more selective).
_ACTIVATION_FLOAT_KEYS: dict[str, str] = { _ACTIVATION_FLOAT_KEYS: dict[str, str] = {
"min_expected_value": "activation_min_expected_value", "min_momentum_percentile": "activation_min_momentum_percentile",
"min_rr": "activation_min_rr", "min_rr": "activation_min_rr",
"min_confidence": "activation_min_confidence", "min_confidence": "activation_min_confidence",
"min_target_probability": "activation_min_target_probability", "min_target_probability": "activation_min_target_probability",
@@ -53,7 +53,7 @@ _ACTIVATION_BOOL_KEYS: dict[str, str] = {
"exclude_conflicts": "activation_exclude_conflicts", "exclude_conflicts": "activation_exclude_conflicts",
} }
ACTIVATION_DEFAULTS: dict[str, float | bool] = { ACTIVATION_DEFAULTS: dict[str, float | bool] = {
"min_expected_value": 0.15, "min_momentum_percentile": 80.0,
"min_rr": 1.2, "min_rr": 1.2,
"min_confidence": 55.0, "min_confidence": 55.0,
"min_target_probability": 0.0, "min_target_probability": 0.0,
@@ -201,8 +201,8 @@ async def update_activation_config(
db: AsyncSession, updates: dict[str, float | bool] db: AsyncSession, updates: dict[str, float | bool]
) -> dict[str, float | bool]: ) -> dict[str, float | bool]:
"""Update the activation gate. Accepts public keys; only supplied keys change.""" """Update the activation gate. Accepts public keys; only supplied keys change."""
if "min_expected_value" in updates and not -1.0 <= updates["min_expected_value"] <= 10.0: if "min_momentum_percentile" in updates and not 0 <= updates["min_momentum_percentile"] <= 100:
raise ValidationError("min_expected_value must be between -1 and 10 (R units)") raise ValidationError("min_momentum_percentile must be between 0 and 100")
if "min_rr" in updates and updates["min_rr"] < 0: if "min_rr" in updates and updates["min_rr"] < 0:
raise ValidationError("min_rr must be >= 0") raise ValidationError("min_rr must be >= 0")
if "min_confidence" in updates and not 0 <= updates["min_confidence"] <= 100: if "min_confidence" in updates and not 0 <= updates["min_confidence"] <= 100:
+94 -23
View File
@@ -14,6 +14,7 @@ held neutral here — this calibrates the price/S-R/probability machinery only.
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
import math import math
@@ -40,7 +41,6 @@ from app.services.outcome_service import (
from app.services.price_service import query_ohlcv from app.services.price_service import query_ohlcv
from app.services.qualification import ( from app.services.qualification import (
best_target_probability, best_target_probability,
expected_value_r,
setup_qualifies, setup_qualifies,
) )
from app.services.recommendation_service import ( from app.services.recommendation_service import (
@@ -110,6 +110,14 @@ def _window_setups(
if entry <= 0: if entry <= 0:
return [] return []
# 12-1 month momentum (skip the last month) — the universe ranks on this.
# None until a year of history exists; such setups can't qualify on momentum.
mom_12_1 = (
closes[-22] / closes[-253] - 1.0
if len(closes) >= 253 and closes[-253] > 0
else None
)
try: try:
atr = compute_atr(highs, lows, closes)["atr"] atr = compute_atr(highs, lows, closes)["atr"]
except Exception: except Exception:
@@ -180,13 +188,12 @@ def _window_setups(
stop_loss=stop, stop_loss=stop,
entry_price=entry, entry_price=entry,
) )
# meets_core = clears every gate EXCEPT the expected-value floor, so the # meets_core = clears every gate EXCEPT the cross-sectional momentum
# report can sweep the min_expected_value threshold without re-replaying. # percentile, which can only be assigned once all tickers' setups for a
core_config = {**activation, "min_expected_value": float("-inf")} # week are known. run_backtest ranks momentum and finalizes `qualified`.
core_config = {**activation, "min_momentum_percentile": 0.0}
meets_core = setup_qualifies(setup_ns, core_config) meets_core = setup_qualifies(setup_ns, core_config)
ev = expected_value_r(setup_ns)
best_prob = best_target_probability(setup_ns) best_prob = best_target_probability(setup_ns)
min_ev = float(activation.get("min_expected_value", 0.0))
out.append({ out.append({
"direction": direction, "direction": direction,
"entry": entry, "entry": entry,
@@ -196,11 +203,10 @@ def _window_setups(
"confidence": confidences[direction], "confidence": confidences[direction],
"primary_prob": float(primary["probability"]), "primary_prob": float(primary["probability"]),
"best_prob": best_prob, "best_prob": best_prob,
"ev": ev, "momentum": mom_12_1,
"meets_core": meets_core, "meets_core": meets_core,
"action": action, "action": action,
"risk_level": risk_level, "risk_level": risk_level,
"qualified": meets_core and ev is not None and ev >= min_ev,
}) })
return out return out
@@ -230,17 +236,18 @@ def _replay_ticker(symbol: str, records: list, config: dict, activation: dict) -
realized_r = -1.0 realized_r = -1.0
else: # expired else: # expired
realized_r = 0.0 realized_r = 0.0
iso = records[i].date.isocalendar()
candidates.append({ candidates.append({
"symbol": symbol, "symbol": symbol,
"date": records[i].date.isoformat(), "date": records[i].date.isoformat(),
"iso_week": (iso[0], iso[1]),
"direction": s["direction"], "direction": s["direction"],
"rr": s["rr"], "rr": s["rr"],
"confidence": s["confidence"], "confidence": s["confidence"],
"primary_prob": s["primary_prob"], "primary_prob": s["primary_prob"],
"best_prob": s["best_prob"], "best_prob": s["best_prob"],
"ev": s["ev"], "momentum": s["momentum"],
"meets_core": s["meets_core"], "meets_core": s["meets_core"],
"qualified": s["qualified"],
"outcome": outcome, "outcome": outcome,
"target_hit": target_hit, "target_hit": target_hit,
"realized_r": realized_r, "realized_r": realized_r,
@@ -484,6 +491,49 @@ def _signal_evaluation(collected: dict) -> list[dict]:
return rows return rows
def _process_ticker(
symbol: str,
records: list,
config: dict,
activation: dict,
collected: dict,
) -> list[dict]:
"""The CPU-bound per-ticker work — replay + signal accumulation — bundled so
run_backtest can hand it to a worker thread. Mutates ``collected``."""
cands = _replay_ticker(symbol, records, config, activation)
_accumulate_signal_series(records, collected)
return cands
def _assign_momentum_percentiles(candidates: list[dict]) -> None:
"""Per ISO week, rank candidates by their ticker's 12-1 momentum and attach a
0100 ``momentum_percentile`` (100 = highest momentum in the universe that
week). Candidates whose momentum is unknown (insufficient lookback) get None
and therefore can't clear a momentum gate. Mutates ``candidates``."""
by_week: dict = defaultdict(list)
for c in candidates:
if c.get("momentum") is not None:
by_week[c["iso_week"]].append(c)
for group in by_week.values():
ordered = sorted(group, key=lambda c: c["momentum"])
n = len(ordered)
for rank, c in enumerate(ordered):
c["momentum_percentile"] = (rank / (n - 1) * 100.0) if n > 1 else 100.0
for c in candidates:
c.setdefault("momentum_percentile", None)
def _momentum_qualifies(cand: dict, threshold: float) -> bool:
"""Whether a candidate clears the floors (meets_core) and the momentum gate.
Threshold 0 disables the momentum gate (floors only)."""
if not cand["meets_core"]:
return False
if threshold <= 0:
return True
mp = cand.get("momentum_percentile")
return mp is not None and mp >= threshold
async def run_backtest( async def run_backtest(
db: AsyncSession, db: AsyncSession,
progress_cb: Callable[[int, int, str], None] | None = None, progress_cb: Callable[[int, int, str], None] | None = None,
@@ -504,29 +554,50 @@ async def run_backtest(
progress_cb(index, total, ticker.symbol) progress_cb(index, total, ticker.symbol)
try: try:
records = await query_ohlcv(db, ticker.symbol) records = await query_ohlcv(db, ticker.symbol)
candidates.extend(_replay_ticker(ticker.symbol, records, config, activation)) # Detach the ORM rows to plain objects in the event loop (safe to read
_accumulate_signal_series(records, collected) # here), then run the heavy replay in a worker thread. The compute is
# CPU-bound and used to block the event loop — and the API server with
# it — for the whole run; offloading lets CPython hand the GIL back to
# the loop every few ms so health checks / page loads stay responsive.
bars = [
SimpleNamespace(
date=r.date,
open=float(r.open),
high=float(r.high),
low=float(r.low),
close=float(r.close),
volume=int(r.volume),
)
for r in records
]
cands = await asyncio.to_thread(
_process_ticker, ticker.symbol, bars, config, activation, collected
)
candidates.extend(cands)
except Exception: except Exception:
logger.exception("Backtest replay failed for %s", ticker.symbol) logger.exception("Backtest replay failed for %s", ticker.symbol)
if progress_cb is not None and total: if progress_cb is not None and total:
progress_cb(total, total, "") progress_cb(total, total, "")
# Cross-sectional momentum: rank every week's universe, then "qualified" means
# floors + top ``min_momentum_percentile`` by 12-1 momentum.
_assign_momentum_percentiles(candidates)
current_min_pct = float(activation.get("min_momentum_percentile", 80.0))
for c in candidates:
c["qualified"] = _momentum_qualifies(c, current_min_pct)
qualified = [c for c in candidates if c["qualified"]] qualified = [c for c in candidates if c["qualified"]]
longs = [c for c in qualified if c["direction"] == "long"] longs = [c for c in qualified if c["direction"] == "long"]
shorts = [c for c in qualified if c["direction"] == "short"] shorts = [c for c in qualified if c["direction"] == "short"]
# Threshold sweep: re-apply the gate at several min_expected_value values # Threshold sweep: re-apply the momentum gate at several percentile cutoffs
# (holding the other conditions fixed) so the trade-off between how many # (floors held fixed) so the trade-off between how many setups qualify and
# setups qualify and their expectancy is visible without re-replaying. # their expectancy is visible without re-replaying. 0 = floors only.
current_min_ev = float(activation.get("min_expected_value", 0.15))
sweep = [] sweep = []
for threshold in (0.4, 0.3, 0.25, 0.2, 0.15, 0.1, 0.05, 0.0): for threshold in (90.0, 80.0, 70.0, 60.0, 50.0, 0.0):
cands = [ cands = [c for c in candidates if _momentum_qualifies(c, threshold)]
c for c in candidates sweep.append({"min_momentum_percentile": threshold, **_bucket_stats(cands)})
if c["meets_core"] and c["ev"] is not None and c["ev"] >= threshold
]
sweep.append({"min_expected_value": threshold, **_bucket_stats(cands)})
return { return {
"generated_at": datetime.now(timezone.utc).isoformat(), "generated_at": datetime.now(timezone.utc).isoformat(),
@@ -541,7 +612,7 @@ async def run_backtest(
"long": _bucket_stats(longs), "long": _bucket_stats(longs),
"short": _bucket_stats(shorts), "short": _bucket_stats(shorts),
}, },
"min_expected_value": current_min_ev, "min_momentum_percentile": current_min_pct,
"sweep": sweep, "sweep": sweep,
"calibration": _calibration(candidates), "calibration": _calibration(candidates),
"signal_eval": _signal_evaluation(collected), "signal_eval": _signal_evaluation(collected),
+21 -47
View File
@@ -1,11 +1,14 @@
"""Shared definition of a 'qualified' (actionable) trade setup. """Shared definition of a 'qualified' (actionable) trade setup.
A single predicate, driven by the admin activation config, used by the A single predicate, driven by the admin activation config, used by the
performance stats (server) and mirrored on the frontend. The core gate is performance stats (server) and mirrored on the frontend. The core selection is
expected value (in R): a setup must promise positive, probability-weighted cross-sectional momentum: a setup's ticker must rank in the top
asymmetry, not just a fat-but-improbable target or a likely-but-thin one. R:R ``min_momentum_percentile`` of the universe by 12-1 month momentum — the one
and confidence remain as floors, and conviction/conflict/target-probability signal the backtest showed actually sorts forward returns. R:R and confidence
survive as optional tighteners (off by default). 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 from __future__ import annotations
@@ -22,37 +25,6 @@ def best_target_probability(setup: Any) -> float:
return max(probs, default=0.0) 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: def live_risk_reward(setup: Any, current_price: float) -> float | None:
"""R:R recomputed from the CURRENT price, not the (possibly stale) entry. """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, ``setup`` is duck-typed: any object exposing rr_ratio, confidence_score,
recommended_action, risk_level and a ``targets`` list of dicts. recommended_action, risk_level and a ``targets`` list of dicts.
Gate order: R:R floor → freshness (live R:R) → confidence floor → expected Gate order: R:R floor → freshness (live R:R) → confidence floor → momentum
value (the core test) → optional conviction / conflict / target-probability percentile (the core selection) → optional conviction / conflict /
tighteners. ``min_expected_value`` defaults to -inf for callers that pass a target-probability tighteners. ``min_momentum_percentile`` defaults to 0 (off)
legacy config without the key, so they behave exactly as before. for callers that pass a legacy config without the key.
""" """
if setup.rr_ratio < config["min_rr"]: if setup.rr_ratio < config["min_rr"]:
return False return False
@@ -94,13 +66,15 @@ def setup_qualifies(setup: Any, config: dict) -> bool:
return False return False
if (setup.confidence_score or 0.0) < config["min_confidence"]: if (setup.confidence_score or 0.0) < config["min_confidence"]:
return False return False
# Expected value (R): the core gate. Only enforced when computable — setups # Cross-sectional momentum: the core selection. A setup's ticker must rank in
# without target probabilities (e.g. legacy historical rows) defer to the # the top ``min_momentum_percentile`` of the universe by 12-1 momentum. Only
# R:R + confidence floors above rather than being silently dropped. # enforced when a percentile is attached (live setups / backtest) and a
min_ev = float(config.get("min_expected_value", float("-inf"))) # threshold is set; callers that don't attach it defer to the floors above.
ev = expected_value_r(setup) min_pct = float(config.get("min_momentum_percentile", 0.0))
if ev is not None and ev < min_ev: if min_pct > 0:
return False 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 config.get("require_high_conviction"):
if (setup.recommended_action or "") not in HIGH_CONVICTION_ACTIONS: if (setup.recommended_action or "") not in HIGH_CONVICTION_ACTIONS:
return False return False
@@ -4,7 +4,7 @@ import { useActivationSettings, useUpdateActivationSettings } from '../../hooks/
import { SkeletonTable } from '../ui/Skeleton'; import { SkeletonTable } from '../ui/Skeleton';
const DEFAULTS: ActivationConfig = { const DEFAULTS: ActivationConfig = {
min_expected_value: 0.15, min_momentum_percentile: 80,
min_rr: 1.2, min_rr: 1.2,
min_confidence: 55, min_confidence: 55,
min_target_probability: 0, min_target_probability: 0,
@@ -40,26 +40,27 @@ export function ActivationSettings() {
<h3 className="text-sm font-semibold text-gray-200">Activation Gate</h3> <h3 className="text-sm font-semibold text-gray-200">Activation Gate</h3>
<p className="mt-1 text-xs text-gray-500"> <p className="mt-1 text-xs text-gray-500">
What counts as a signal worth acting on. Drives the Dashboard's "Qualified" metric, the What counts as a signal worth acting on. Drives the Dashboard's "Qualified" metric, the
Signals "Qualified only" view, and the Track Record's qualified stats. The core test is Signals "Qualified only" view, and the Track Record's qualified stats. The core selection is
<span className="text-gray-300"> expected value</span> probability-weighted asymmetry <span className="text-gray-300"> cross-sectional momentum</span> the ticker must rank in the
so R:R and target probability no longer fight each other. All setups are still evaluated top slice of the universe by 12-1 month momentum, the one signal the backtest showed predicts
regardless; tune the EV floor against the Track Record's EV sweep to see what actually wins. forward returns. R:R and confidence stay as floors. Tune the cutoff against the Track Record's
momentum sweep to see what actually wins.
</p> </p>
</div> </div>
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<label className="block space-y-1"> <label className="block space-y-1">
<span className="text-xs text-gray-400">Min Expected Value (R)</span> <span className="text-xs text-gray-400">Min Momentum Percentile</span>
<input <input
type="number" type="number"
min={-1} min={0}
max={10} max={100}
step={0.05} step={5}
value={form.min_expected_value} value={form.min_momentum_percentile}
onChange={(e) => setForm((prev) => ({ ...prev, min_expected_value: Number(e.target.value) }))} onChange={(e) => setForm((prev) => ({ ...prev, min_momentum_percentile: Number(e.target.value) }))}
className="w-full input-glass px-3 py-2 text-sm" className="w-full input-glass px-3 py-2 text-sm"
/> />
<span className="text-[11px] text-gray-600">p·R:R (1p), in R. 0.15 ≈ +0.15× risk/trade. The core gate.</span> <span className="text-[11px] text-gray-600">Ticker's 12-1 momentum rank. 80 = top 20% of the universe. 0 disables. The core gate.</span>
</label> </label>
<label className="block space-y-1"> <label className="block space-y-1">
<span className="text-xs text-gray-400">Min Risk:Reward (1 : x)</span> <span className="text-xs text-gray-400">Min Risk:Reward (1 : x)</span>
@@ -89,7 +90,7 @@ export function ActivationSettings() {
<div className="border-t border-white/[0.06] pt-4"> <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="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 EV gate.</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>
<div className="mt-3 grid gap-3 md:grid-cols-3"> <div className="mt-3 grid gap-3 md:grid-cols-3">
<label className="block space-y-1"> <label className="block space-y-1">
<span className="text-xs text-gray-400">Min Target Probability (%)</span> <span className="text-xs text-gray-400">Min Target Probability (%)</span>
@@ -184,19 +184,19 @@ export function BacktestPanel() {
{report.sweep && report.sweep.length > 0 && ( {report.sweep && report.sweep.length > 0 && (
<div> <div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500"> <p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Min expected-value sweep Momentum-percentile sweep
</p> </p>
<p className="mb-2 text-[11px] text-gray-500"> <p className="mb-2 text-[11px] text-gray-500">
How many setups qualify and how they perform at each expected-value gate (other How many setups qualify and how they perform at each momentum-rank cutoff (floors
gate conditions held fixed). EV is in R: 0.15 means +0.15× your risk per trade on held fixed). 80 = only the top 20% of the universe by 12-1 momentum each week; 0 =
average. Lower = more trades, watch that expectancy holds. Your current setting is floors only. Lower = more trades, watch that expectancy holds. Your current setting is
highlighted; set it in Admin Settings Activation. highlighted; set it in Admin Settings Activation.
</p> </p>
<div className="glass overflow-x-auto"> <div className="glass overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500"> <tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-2.5">Min EV (R)</th> <th className="px-4 py-2.5">Min momentum %ile</th>
<th className="px-4 py-2.5 text-right">Qualified</th> <th className="px-4 py-2.5 text-right">Qualified</th>
<th className="px-4 py-2.5 text-right">Wins</th> <th className="px-4 py-2.5 text-right">Wins</th>
<th className="px-4 py-2.5 text-right">Losses</th> <th className="px-4 py-2.5 text-right">Losses</th>
@@ -207,12 +207,12 @@ export function BacktestPanel() {
</thead> </thead>
<tbody> <tbody>
{report.sweep.map((row) => { {report.sweep.map((row) => {
const current = Math.abs(row.min_expected_value - report.min_expected_value) < 0.001; const current = Math.abs(row.min_momentum_percentile - report.min_momentum_percentile) < 0.001;
return ( return (
<tr key={row.min_expected_value} className={`border-b border-white/[0.04] ${current ? 'bg-blue-400/10' : ''}`}> <tr key={row.min_momentum_percentile} className={`border-b border-white/[0.04] ${current ? 'bg-blue-400/10' : ''}`}>
<td className="num px-4 py-2.5 text-gray-200"> <td className="num px-4 py-2.5 text-gray-200">
{current && <span className="mr-1 text-blue-300"></span>} {current && <span className="mr-1 text-blue-300"></span>}
{row.min_expected_value.toFixed(2)} {row.min_momentum_percentile.toFixed(0)}
</td> </td>
<td className="num px-4 py-2.5 text-right text-gray-200">{row.total}</td> <td className="num px-4 py-2.5 text-right text-gray-200">{row.total}</td>
<td className="num px-4 py-2.5 text-right text-emerald-400">{row.wins}</td> <td className="num px-4 py-2.5 text-right text-emerald-400">{row.wins}</td>
+9 -20
View File
@@ -13,21 +13,6 @@ export function primaryTargetProbability(setup: TradeSetup): number | null {
return setup.targets?.length ? bestTargetProbability(setup) : null; return setup.targets?.length ? bestTargetProbability(setup) : null;
} }
/**
* Expected value per unit of risk, in R. Probability-weighted payoff:
* EV = p·(R:R) (1 p)
* where p is the primary target's hit probability. This is the single "is this
* worth taking" number — it rewards both a good payoff ratio and a likely
* target, so a fat-but-improbable target can't outrank a solid, probable one.
* Returns null when no target probability is known.
*/
export function expectedValueR(setup: TradeSetup): number | null {
const prob = primaryTargetProbability(setup);
if (prob == null) return null;
const p = prob / 100;
return p * setup.rr_ratio - (1 - p);
}
/** R:R recomputed from the current price (0 if no reward/risk left). */ /** R:R recomputed from the current price (0 if no reward/risk left). */
export function liveRiskReward(setup: TradeSetup, currentPrice: number): number { export function liveRiskReward(setup: TradeSetup, currentPrice: number): number {
const reward = setup.direction === 'long' ? setup.target - currentPrice : currentPrice - setup.target; const reward = setup.direction === 'long' ? setup.target - currentPrice : currentPrice - setup.target;
@@ -48,10 +33,12 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
return false; return false;
} }
if ((setup.confidence_score ?? 0) < config.min_confidence) return false; if ((setup.confidence_score ?? 0) < config.min_confidence) return false;
// Expected value (R) is the core gate. Only enforced when computable — setups // Cross-sectional momentum is the core selection — only enforced when a
// without target probabilities defer to the R:R + confidence floors above. // percentile is attached and a threshold is set; otherwise defer to the floors.
const ev = expectedValueR(setup); if (config.min_momentum_percentile > 0 && setup.momentum_percentile != null
if (ev != null && ev < config.min_expected_value) return false; && setup.momentum_percentile < config.min_momentum_percentile) {
return false;
}
if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) { if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) {
return false; return false;
} }
@@ -64,7 +51,9 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
/** Short human summary of the active gate, e.g. for tooltips/labels. */ /** Short human summary of the active gate, e.g. for tooltips/labels. */
export function activationSummary(config: ActivationConfig): string { export function activationSummary(config: ActivationConfig): string {
const parts = [`EV ≥ ${config.min_expected_value.toFixed(2)}R`, `R:R ≥ ${config.min_rr.toFixed(1)}`, `conf ≥ ${config.min_confidence.toFixed(0)}%`]; 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.require_high_conviction) parts.push('high-conviction'); if (config.require_high_conviction) parts.push('high-conviction');
if (config.exclude_conflicts) parts.push('clean'); if (config.exclude_conflicts) parts.push('clean');
if (config.min_target_probability > 0) parts.push(`target ≥ ${config.min_target_probability.toFixed(0)}%`); if (config.min_target_probability > 0) parts.push(`target ≥ ${config.min_target_probability.toFixed(0)}%`);
+4 -3
View File
@@ -134,6 +134,7 @@ export interface TradeSetup {
outcome_date: string | null; outcome_date: string | null;
evaluated_at: string | null; evaluated_at: string | null;
current_price: number | null; current_price: number | null;
momentum_percentile?: number | null;
recommendation_summary?: RecommendationSummary; recommendation_summary?: RecommendationSummary;
} }
@@ -158,7 +159,7 @@ export interface PerformanceStats {
// Activation gate: what counts as an actionable signal // Activation gate: what counts as an actionable signal
export interface ActivationConfig { export interface ActivationConfig {
min_expected_value: number; min_momentum_percentile: number;
min_rr: number; min_rr: number;
min_confidence: number; min_confidence: number;
min_target_probability: number; min_target_probability: number;
@@ -221,7 +222,7 @@ export interface BacktestCalibrationRow {
} }
export interface BacktestSweepRow extends BacktestBucket { export interface BacktestSweepRow extends BacktestBucket {
min_expected_value: number; min_momentum_percentile: number;
} }
export interface BacktestSignalEvalRow { export interface BacktestSignalEvalRow {
@@ -244,7 +245,7 @@ export interface BacktestReport {
overall_qualified: BacktestBucket; overall_qualified: BacktestBucket;
overall_all: BacktestBucket; overall_all: BacktestBucket;
by_direction: Record<string, BacktestBucket>; by_direction: Record<string, BacktestBucket>;
min_expected_value: number; min_momentum_percentile: number;
sweep: BacktestSweepRow[]; sweep: BacktestSweepRow[];
calibration: BacktestCalibrationRow[]; calibration: BacktestCalibrationRow[];
signal_eval?: BacktestSignalEvalRow[]; signal_eval?: BacktestSignalEvalRow[];
+7 -8
View File
@@ -12,7 +12,7 @@ import { OpenTradesPanel } from '../components/dashboard/OpenTradesPanel';
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton'; import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
import { formatPrice } from '../lib/format'; import { formatPrice } from '../lib/format';
import { recommendationActionLabel } from '../lib/recommendation'; import { recommendationActionLabel } from '../lib/recommendation';
import { qualifiesSetup, activationSummary, primaryTargetProbability, expectedValueR } from '../lib/qualification'; import { qualifiesSetup, activationSummary, primaryTargetProbability } from '../lib/qualification';
import type { TradeSetup } from '../lib/types'; import type { TradeSetup } from '../lib/types';
function fmtR(value: number | null): string { function fmtR(value: number | null): string {
@@ -69,12 +69,12 @@ export default function DashboardPage() {
); );
// Show qualified setups first; fall back to the full list when none qualify. // Show qualified setups first; fall back to the full list when none qualify.
// Rank by expected value (R) so the best opportunity sits at the top. // Rank by 12-1 momentum percentile so the strongest names sit at the top.
const showingQualified = qualifiedSetups.length > 0; const showingQualified = qualifiedSetups.length > 0;
const topSetups: TradeSetup[] = useMemo(() => { const topSetups: TradeSetup[] = useMemo(() => {
const pool = showingQualified ? qualifiedSetups : trades.data ?? []; const pool = showingQualified ? qualifiedSetups : trades.data ?? [];
return [...pool] return [...pool]
.sort((a, b) => (expectedValueR(b) ?? -Infinity) - (expectedValueR(a) ?? -Infinity)) .sort((a, b) => (b.momentum_percentile ?? -Infinity) - (a.momentum_percentile ?? -Infinity))
.slice(0, 5); .slice(0, 5);
}, [showingQualified, qualifiedSetups, trades.data]); }, [showingQualified, qualifiedSetups, trades.data]);
@@ -176,13 +176,12 @@ export default function DashboardPage() {
<th className="px-4 py-3 text-right">Entry</th> <th className="px-4 py-3 text-right">Entry</th>
<th className="px-4 py-3 text-right">R:R</th> <th className="px-4 py-3 text-right">R:R</th>
<th className="px-4 py-3 text-right">Target&nbsp;Prob</th> <th className="px-4 py-3 text-right">Target&nbsp;Prob</th>
<th className="px-4 py-3 text-right">Exp.&nbsp;Value</th> <th className="px-4 py-3 text-right">Momentum</th>
<th className="hidden px-4 py-3 md:table-cell">Action</th> <th className="hidden px-4 py-3 md:table-cell">Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{topSetups.map((setup, i) => { {topSetups.map((setup, i) => {
const ev = expectedValueR(setup);
const isTopPick = i === 0; const isTopPick = i === 0;
return ( return (
<tr <tr
@@ -212,8 +211,8 @@ export default function DashboardPage() {
return p != null ? `${Math.round(p)}%` : '—'; return p != null ? `${Math.round(p)}%` : '—';
})()} })()}
</td> </td>
<td className={`num px-4 py-3 text-right font-semibold ${rColor(ev)}`}> <td className="num px-4 py-3 text-right font-semibold text-gray-200">
{fmtR(ev)} {setup.momentum_percentile != null ? `${Math.round(setup.momentum_percentile)}%ile` : '—'}
</td> </td>
<td className="hidden px-4 py-3 text-xs text-gray-400 md:table-cell"> <td className="hidden px-4 py-3 text-xs text-gray-400 md:table-cell">
{recommendationActionLabel(setup.recommended_action)} {recommendationActionLabel(setup.recommended_action)}
@@ -225,7 +224,7 @@ export default function DashboardPage() {
</table> </table>
<div className="flex items-center justify-between border-t border-white/[0.04] px-4 py-2.5"> <div className="flex items-center justify-between border-t border-white/[0.04] px-4 py-2.5">
<span className="text-[11px] text-gray-500"> <span className="text-[11px] text-gray-500">
Exp. Value = probability-weighted payoff per unit of risk Momentum = ticker's 12-1 month rank across the universe (higher = stronger)
</span> </span>
<Link to="/signals" className="text-xs font-medium text-blue-300 hover:text-blue-200 transition-colors"> <Link to="/signals" className="text-xs font-medium text-blue-300 hover:text-blue-200 transition-colors">
All setups → All setups →
+6 -6
View File
@@ -25,7 +25,7 @@ class TestActivationConfig:
async def test_defaults_when_unset(self, session: AsyncSession): async def test_defaults_when_unset(self, session: AsyncSession):
config = await get_activation_config(session) config = await get_activation_config(session)
assert config == { assert config == {
"min_expected_value": 0.15, "min_momentum_percentile": 80.0,
"min_rr": 1.2, "min_rr": 1.2,
"min_confidence": 55.0, "min_confidence": 55.0,
"min_target_probability": 0.0, "min_target_probability": 0.0,
@@ -35,13 +35,13 @@ class TestActivationConfig:
async def test_update_and_read_back(self, session: AsyncSession): async def test_update_and_read_back(self, session: AsyncSession):
updated = await update_activation_config( updated = await update_activation_config(
session, {"min_expected_value": 0.25, "min_confidence": 60.0} session, {"min_momentum_percentile": 70.0, "min_confidence": 60.0}
) )
assert updated["min_expected_value"] == 0.25 assert updated["min_momentum_percentile"] == 70.0
assert updated["min_confidence"] == 60.0 assert updated["min_confidence"] == 60.0
config = await get_activation_config(session) config = await get_activation_config(session)
assert config["min_expected_value"] == 0.25 assert config["min_momentum_percentile"] == 70.0
assert config["min_confidence"] == 60.0 assert config["min_confidence"] == 60.0
async def test_partial_update_keeps_other_value(self, session: AsyncSession): async def test_partial_update_keeps_other_value(self, session: AsyncSession):
@@ -50,9 +50,9 @@ class TestActivationConfig:
assert config["min_rr"] == 1.2 # default untouched assert config["min_rr"] == 1.2 # default untouched
assert config["min_confidence"] == 80.0 assert config["min_confidence"] == 80.0
async def test_rejects_out_of_range_expected_value(self, session: AsyncSession): async def test_rejects_out_of_range_momentum_percentile(self, session: AsyncSession):
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
await update_activation_config(session, {"min_expected_value": 50.0}) await update_activation_config(session, {"min_momentum_percentile": 150.0})
async def test_conviction_flags_round_trip(self, session: AsyncSession): async def test_conviction_flags_round_trip(self, session: AsyncSession):
await update_activation_config( await update_activation_config(
+2 -2
View File
@@ -113,8 +113,8 @@ async def test_run_backtest_smoke(session):
# the oscillating series should yield at least some resolved setups # the oscillating series should yield at least some resolved setups
assert report["candidates"] >= 1 assert report["candidates"] >= 1
# sweep: lowering the EV threshold can only add qualifiers, never remove them # sweep: lowering the momentum-percentile cutoff can only add qualifiers
sweep = sorted(report["sweep"], key=lambda r: r["min_expected_value"], reverse=True) sweep = sorted(report["sweep"], key=lambda r: r["min_momentum_percentile"], reverse=True)
counts = [r["total"] for r in sweep] counts = [r["total"] for r in sweep]
assert counts == sorted(counts) # ascending as threshold descends assert counts == sorted(counts) # ascending as threshold descends
# every calibration row is internally consistent # every calibration row is internally consistent
+32 -61
View File
@@ -1,20 +1,15 @@
"""Unit tests for the activation qualification predicate (EV-based gate).""" """Unit tests for the activation qualification predicate (momentum-based gate)."""
from __future__ import annotations from __future__ import annotations
from types import SimpleNamespace from types import SimpleNamespace
from app.services.qualification import ( from app.services.qualification import best_target_probability, setup_qualifies
best_target_probability,
expected_value_r,
primary_target_probability,
setup_qualifies,
)
# Default gate: expected value is the core test; conviction/conflict/target-prob # Default gate: floors only; the momentum selection is off (0). Conviction /
# are optional tighteners, off here. # conflict / target-probability are optional tighteners, off here.
DEFAULT_GATE = { DEFAULT_GATE = {
"min_expected_value": 0.15, "min_momentum_percentile": 0.0,
"min_rr": 1.2, "min_rr": 1.2,
"min_confidence": 55.0, "min_confidence": 55.0,
"min_target_probability": 0.0, "min_target_probability": 0.0,
@@ -22,9 +17,12 @@ DEFAULT_GATE = {
"exclude_conflicts": False, "exclude_conflicts": False,
} }
# Strict gate: every optional tightener turned on (the old shipped defaults). # Gate with the cross-sectional momentum selection on (top quintile).
MOMENTUM_GATE = {**DEFAULT_GATE, "min_momentum_percentile": 80.0}
# Strict gate: every optional tightener turned on.
STRICT_GATE = { STRICT_GATE = {
"min_expected_value": 0.0, "min_momentum_percentile": 0.0,
"min_rr": 2.0, "min_rr": 2.0,
"min_confidence": 70.0, "min_confidence": 70.0,
"min_target_probability": 60.0, "min_target_probability": 60.0,
@@ -45,59 +43,17 @@ def _setup(**kwargs):
return SimpleNamespace(**base) return SimpleNamespace(**base)
class TestExpectedValue: class TestFloors:
def test_uses_primary_target_not_best(self): def test_passes_floors(self):
s = _setup(
rr_ratio=1.5,
targets=[
{"probability": 80.0},
{"probability": 30.0, "is_primary": True},
],
)
# EV from the primary (30%): 0.3*1.5 - 0.7 = -0.25
assert expected_value_r(s) == -0.25
assert primary_target_probability(s) == 30.0
def test_falls_back_to_best_when_no_primary_flag(self):
s = _setup(rr_ratio=2.0, targets=[{"probability": 40.0}, {"probability": 60.0}])
assert primary_target_probability(s) == 60.0
# 0.6*2.0 - 0.4 = 0.8
assert abs(expected_value_r(s) - 0.8) < 1e-9
def test_none_when_no_targets(self):
assert expected_value_r(_setup(targets=[])) is None
assert primary_target_probability(_setup(targets=[])) is None
class TestSetupQualifies:
def test_positive_ev_setup_passes(self):
# primary 50% @ rr 3.0 → EV = 1.0
assert setup_qualifies(_setup(), DEFAULT_GATE) is True assert setup_qualifies(_setup(), DEFAULT_GATE) is True
def test_negative_ev_fails(self):
# primary 30% @ rr 1.3 → EV = -0.31, below the 0.15 floor
s = _setup(rr_ratio=1.3, targets=[{"probability": 30.0, "is_primary": True}])
assert setup_qualifies(s, DEFAULT_GATE) is False
def test_thin_positive_ev_below_floor_fails(self):
# Positive but thin: 0.45*1.3 - 0.55 = 0.035, under the 0.15 floor.
s = _setup(rr_ratio=1.3, targets=[{"probability": 45.0, "is_primary": True}])
assert setup_qualifies(s, DEFAULT_GATE) is False
def test_low_rr_floor_fails(self): def test_low_rr_floor_fails(self):
assert setup_qualifies(_setup(rr_ratio=1.0), DEFAULT_GATE) is False assert setup_qualifies(_setup(rr_ratio=1.0), DEFAULT_GATE) is False
def test_low_confidence_fails(self): def test_low_confidence_fails(self):
assert setup_qualifies(_setup(confidence_score=40.0), DEFAULT_GATE) is False assert setup_qualifies(_setup(confidence_score=40.0), DEFAULT_GATE) is False
def test_no_targets_defers_to_rr_and_confidence(self):
# No probability → EV uncomputable → not blocked on EV; passes on floors.
assert setup_qualifies(_setup(targets=[]), DEFAULT_GATE) is True
# ...but still subject to the rr/confidence floors.
assert setup_qualifies(_setup(targets=[], rr_ratio=1.0), DEFAULT_GATE) is False
def test_conviction_and_conflict_ignored_by_default(self): def test_conviction_and_conflict_ignored_by_default(self):
# Moderate action + medium risk still pass when tighteners are off.
s = _setup(recommended_action="LONG_MODERATE", risk_level="Medium") s = _setup(recommended_action="LONG_MODERATE", risk_level="Medium")
assert setup_qualifies(s, DEFAULT_GATE) is True assert setup_qualifies(s, DEFAULT_GATE) is True
@@ -113,11 +69,26 @@ class TestSetupQualifies:
s = _setup(direction="long", target=120.0, stop_loss=95.0, current_price=94.0) s = _setup(direction="long", target=120.0, stop_loss=95.0, current_price=94.0)
assert setup_qualifies(s, DEFAULT_GATE) is False assert setup_qualifies(s, DEFAULT_GATE) is False
def test_missing_min_ev_key_skips_ev(self):
# Legacy callers without min_expected_value: EV defaults to -inf (no floor). class TestMomentumGate:
legacy = {k: v for k, v in DEFAULT_GATE.items() if k != "min_expected_value"} def test_top_momentum_passes(self):
s = _setup(rr_ratio=1.3, targets=[{"probability": 30.0, "is_primary": True}]) assert setup_qualifies(_setup(momentum_percentile=92.0), MOMENTUM_GATE) is True
assert setup_qualifies(s, legacy) is True
def test_below_threshold_fails(self):
assert setup_qualifies(_setup(momentum_percentile=50.0), MOMENTUM_GATE) is False
def test_missing_percentile_defers_to_floors(self):
# No percentile attached (e.g. production not yet wired) → the momentum
# gate is skipped and the setup still clears on the floors.
assert setup_qualifies(_setup(), MOMENTUM_GATE) is True
def test_threshold_zero_disables_gate(self):
# min_momentum_percentile 0 → a low-momentum name still passes.
assert setup_qualifies(_setup(momentum_percentile=10.0), DEFAULT_GATE) is True
def test_missing_key_defaults_off(self):
legacy = {k: v for k, v in DEFAULT_GATE.items() if k != "min_momentum_percentile"}
assert setup_qualifies(_setup(momentum_percentile=10.0), legacy) is True
class TestStrictTighteners: class TestStrictTighteners: