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:
@@ -4,7 +4,7 @@ import { useActivationSettings, useUpdateActivationSettings } from '../../hooks/
|
||||
import { SkeletonTable } from '../ui/Skeleton';
|
||||
|
||||
const DEFAULTS: ActivationConfig = {
|
||||
min_expected_value: 0.15,
|
||||
min_momentum_percentile: 80,
|
||||
min_rr: 1.2,
|
||||
min_confidence: 55,
|
||||
min_target_probability: 0,
|
||||
@@ -40,26 +40,27 @@ export function ActivationSettings() {
|
||||
<h3 className="text-sm font-semibold text-gray-200">Activation Gate</h3>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
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
|
||||
<span className="text-gray-300"> expected value</span> — probability-weighted asymmetry —
|
||||
so R:R and target probability no longer fight each other. All setups are still evaluated
|
||||
regardless; tune the EV floor against the Track Record's EV sweep to see what actually wins.
|
||||
Signals "Qualified only" view, and the Track Record's qualified stats. The core selection is
|
||||
<span className="text-gray-300"> cross-sectional momentum</span> — the ticker must rank in the
|
||||
top slice of the universe by 12-1 month momentum, the one signal the backtest showed predicts
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<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
|
||||
type="number"
|
||||
min={-1}
|
||||
max={10}
|
||||
step={0.05}
|
||||
value={form.min_expected_value}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, min_expected_value: Number(e.target.value) }))}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={form.min_momentum_percentile}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, min_momentum_percentile: Number(e.target.value) }))}
|
||||
className="w-full input-glass px-3 py-2 text-sm"
|
||||
/>
|
||||
<span className="text-[11px] text-gray-600">p·R:R − (1−p), 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 className="block space-y-1">
|
||||
<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">
|
||||
<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">
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-gray-400">Min Target Probability (%)</span>
|
||||
|
||||
@@ -184,19 +184,19 @@ export function BacktestPanel() {
|
||||
{report.sweep && report.sweep.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
||||
Min expected-value sweep
|
||||
Momentum-percentile sweep
|
||||
</p>
|
||||
<p className="mb-2 text-[11px] text-gray-500">
|
||||
How many setups qualify — and how they perform — at each expected-value gate (other
|
||||
gate conditions held fixed). EV is in R: 0.15 means +0.15× your risk per trade on
|
||||
average. Lower = more trades, watch that expectancy holds. Your current setting is
|
||||
How many setups qualify — and how they perform — at each momentum-rank cutoff (floors
|
||||
held fixed). 80 = only the top 20% of the universe by 12-1 momentum each week; 0 =
|
||||
floors only. Lower = more trades, watch that expectancy holds. Your current setting is
|
||||
highlighted; set it in Admin → Settings → Activation.
|
||||
</p>
|
||||
<div className="glass overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<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">Wins</th>
|
||||
<th className="px-4 py-2.5 text-right">Losses</th>
|
||||
@@ -207,12 +207,12 @@ export function BacktestPanel() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<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">
|
||||
{current && <span className="mr-1 text-blue-300">★</span>}
|
||||
{row.min_expected_value.toFixed(2)}
|
||||
{row.min_momentum_percentile.toFixed(0)}
|
||||
</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>
|
||||
|
||||
@@ -13,21 +13,6 @@ export function primaryTargetProbability(setup: TradeSetup): number | 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). */
|
||||
export function liveRiskReward(setup: TradeSetup, currentPrice: number): number {
|
||||
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;
|
||||
}
|
||||
if ((setup.confidence_score ?? 0) < config.min_confidence) return false;
|
||||
// Expected value (R) is the core gate. Only enforced when computable — setups
|
||||
// without target probabilities defer to the R:R + confidence floors above.
|
||||
const ev = expectedValueR(setup);
|
||||
if (ev != null && ev < config.min_expected_value) return false;
|
||||
// Cross-sectional momentum is the core selection — only enforced when a
|
||||
// percentile is attached and a threshold is set; otherwise defer to the floors.
|
||||
if (config.min_momentum_percentile > 0 && setup.momentum_percentile != null
|
||||
&& setup.momentum_percentile < config.min_momentum_percentile) {
|
||||
return false;
|
||||
}
|
||||
if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) {
|
||||
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. */
|
||||
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.exclude_conflicts) parts.push('clean');
|
||||
if (config.min_target_probability > 0) parts.push(`target ≥ ${config.min_target_probability.toFixed(0)}%`);
|
||||
|
||||
@@ -134,6 +134,7 @@ export interface TradeSetup {
|
||||
outcome_date: string | null;
|
||||
evaluated_at: string | null;
|
||||
current_price: number | null;
|
||||
momentum_percentile?: number | null;
|
||||
recommendation_summary?: RecommendationSummary;
|
||||
}
|
||||
|
||||
@@ -158,7 +159,7 @@ export interface PerformanceStats {
|
||||
|
||||
// Activation gate: what counts as an actionable signal
|
||||
export interface ActivationConfig {
|
||||
min_expected_value: number;
|
||||
min_momentum_percentile: number;
|
||||
min_rr: number;
|
||||
min_confidence: number;
|
||||
min_target_probability: number;
|
||||
@@ -221,7 +222,7 @@ export interface BacktestCalibrationRow {
|
||||
}
|
||||
|
||||
export interface BacktestSweepRow extends BacktestBucket {
|
||||
min_expected_value: number;
|
||||
min_momentum_percentile: number;
|
||||
}
|
||||
|
||||
export interface BacktestSignalEvalRow {
|
||||
@@ -244,7 +245,7 @@ export interface BacktestReport {
|
||||
overall_qualified: BacktestBucket;
|
||||
overall_all: BacktestBucket;
|
||||
by_direction: Record<string, BacktestBucket>;
|
||||
min_expected_value: number;
|
||||
min_momentum_percentile: number;
|
||||
sweep: BacktestSweepRow[];
|
||||
calibration: BacktestCalibrationRow[];
|
||||
signal_eval?: BacktestSignalEvalRow[];
|
||||
|
||||
@@ -12,7 +12,7 @@ import { OpenTradesPanel } from '../components/dashboard/OpenTradesPanel';
|
||||
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
|
||||
import { formatPrice } from '../lib/format';
|
||||
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';
|
||||
|
||||
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.
|
||||
// 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 topSetups: TradeSetup[] = useMemo(() => {
|
||||
const pool = showingQualified ? qualifiedSetups : trades.data ?? [];
|
||||
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);
|
||||
}, [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">R:R</th>
|
||||
<th className="px-4 py-3 text-right">Target Prob</th>
|
||||
<th className="px-4 py-3 text-right">Exp. Value</th>
|
||||
<th className="px-4 py-3 text-right">Momentum</th>
|
||||
<th className="hidden px-4 py-3 md:table-cell">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{topSetups.map((setup, i) => {
|
||||
const ev = expectedValueR(setup);
|
||||
const isTopPick = i === 0;
|
||||
return (
|
||||
<tr
|
||||
@@ -212,8 +211,8 @@ export default function DashboardPage() {
|
||||
return p != null ? `${Math.round(p)}%` : '—';
|
||||
})()}
|
||||
</td>
|
||||
<td className={`num px-4 py-3 text-right font-semibold ${rColor(ev)}`}>
|
||||
{fmtR(ev)}
|
||||
<td className="num px-4 py-3 text-right font-semibold text-gray-200">
|
||||
{setup.momentum_percentile != null ? `${Math.round(setup.momentum_percentile)}%ile` : '—'}
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 text-xs text-gray-400 md:table-cell">
|
||||
{recommendationActionLabel(setup.recommended_action)}
|
||||
@@ -225,7 +224,7 @@ export default function DashboardPage() {
|
||||
</table>
|
||||
<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">
|
||||
Exp. Value = probability-weighted payoff per unit of risk
|
||||
Momentum = ticker's 12-1 month rank across the universe (higher = stronger)
|
||||
</span>
|
||||
<Link to="/signals" className="text-xs font-medium text-blue-300 hover:text-blue-200 transition-colors">
|
||||
All setups →
|
||||
|
||||
Reference in New Issue
Block a user