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:
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user