Files
signal-platform/frontend/src/lib/qualification.ts
T

151 lines
6.9 KiB
TypeScript

import type { ActivationConfig, TradeSetup, TradeTarget } from './types';
const HIGH_CONVICTION_ACTIONS = new Set(['LONG_HIGH', 'SHORT_HIGH']);
/**
* Floor for the primary target's reach probability — mirrors
* MIN_TARGET_PROBABILITY in app/services/qualification.py. A primary below
* this is a lottery target whose distance inflates R:R past the min_rr gate.
*/
export const MIN_TARGET_PROBABILITY = 20;
function actionDirection(action: TradeSetup['recommended_action']): 'long' | 'short' | 'neutral' {
if (!action || action === 'NEUTRAL') return 'neutral';
if (action.startsWith('LONG')) return 'long';
if (action.startsWith('SHORT')) return 'short';
return 'neutral';
}
/** The starred primary target (the one the headline R:R refers to), falling
* back to the most likely target when no star is stored. */
export function primaryTarget(setup: TradeSetup): TradeTarget | null {
const starred = setup.targets?.find((t) => t.is_primary);
if (starred) return starred;
if (!setup.targets?.length) return null;
return [...setup.targets].sort((a, b) => b.probability - a.probability)[0];
}
/** Probability of the starred primary target (the one the headline R:R refers to). */
export function primaryTargetProbability(setup: TradeSetup): number | null {
return primaryTarget(setup)?.probability ?? null;
}
/** 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;
const risk = setup.direction === 'long' ? currentPrice - setup.stop_loss : setup.stop_loss - currentPrice;
if (reward <= 0 || risk <= 0) return 0;
return reward / risk;
}
/**
* Whether a setup clears the activation gate. Mirrors the backend predicate in
* app/services/qualification.py — keep the two in sync.
*/
export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boolean {
if ((setup.reentry_lockdown_remaining_sessions ?? 0) > 0) return false;
if (setup.rr_ratio < config.min_rr) return false;
// Live R:R from current price — drops setups whose price has already run
// toward target (reward consumed) or through the stop.
if (setup.current_price != null && liveRiskReward(setup, setup.current_price) < config.min_rr) {
return false;
}
const targetProbability = primaryTargetProbability(setup);
if (targetProbability == null || targetProbability < MIN_TARGET_PROBABILITY) return false;
if ((setup.confidence_score ?? 0) < config.min_confidence) return false;
// Residual cross-sectional momentum is the core selection (long-only). While
// the gate is active, shorts never qualify; missing ranks do not qualify
// because the production edge depends on this cross-sectional selection.
if (config.min_momentum_percentile > 0) {
if (setup.direction === 'short') return false;
if (setup.momentum_percentile == null || setup.momentum_percentile < config.min_momentum_percentile) {
return false;
}
}
// NEUTRAL = "no clear setup"; an opposite action means this setup is counter-bias.
if (config.exclude_neutral) {
const actionDir = actionDirection(setup.recommended_action);
if (actionDir === 'neutral' || actionDir !== setup.direction) return false;
}
if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) {
return false;
}
if (config.exclude_conflicts && (setup.risk_level ?? '') !== 'Low') return false;
return true;
}
/**
* Why a setup does NOT clear the gate — the first failing rule, phrased for the
* dashboard's radar list. Returns null when the setup qualifies. Mirrors
* qualifiesSetup rule-for-rule (keep the order in sync).
*/
export function disqualifyReason(setup: TradeSetup, config: ActivationConfig): string | null {
const lockdownRemaining = setup.reentry_lockdown_remaining_sessions ?? 0;
if (lockdownRemaining > 0) {
return `post-stop lockdown · ${lockdownRemaining} session${lockdownRemaining === 1 ? '' : 's'} remaining`;
}
if (setup.rr_ratio < config.min_rr) {
return `R:R ${setup.rr_ratio.toFixed(1)} below gate ${config.min_rr.toFixed(1)}`;
}
if (setup.current_price != null && liveRiskReward(setup, setup.current_price) < config.min_rr) {
return 'price has run — live R:R below gate';
}
const targetProbability = primaryTargetProbability(setup);
if (targetProbability == null || targetProbability <= 0) return 'no target probability';
if (targetProbability < MIN_TARGET_PROBABILITY) {
return `target probability below ${MIN_TARGET_PROBABILITY}%`;
}
if ((setup.confidence_score ?? 0) < config.min_confidence) {
return `confidence below ${config.min_confidence.toFixed(0)}%`;
}
if (config.min_momentum_percentile > 0) {
if (setup.direction === 'short') return 'short — momentum gate is long-only';
if (setup.momentum_percentile == null) return 'no residual momentum rank';
if (setup.momentum_percentile < config.min_momentum_percentile) {
return `momentum below ${config.min_momentum_percentile.toFixed(0)}th %ile`;
}
}
if (config.exclude_neutral) {
const actionDir = actionDirection(setup.recommended_action);
if (actionDir === 'neutral') return 'model action neutral';
if (actionDir !== setup.direction) return 'counter to model bias';
}
if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) {
return 'conviction below High';
}
if (config.exclude_conflicts && (setup.risk_level ?? '') !== 'Low') return 'risk flags not clean';
return null;
}
/**
* Symbol of the current single 'top pick' — the #1 row the dashboard highlights:
* the highest residual 12-1 momentum percentile among qualified setups. Returns
* null when there are no actionable setups. Keep in step with the Top Setups
* ranking in DashboardPage.
*/
export function topPickSymbol(
trades: TradeSetup[] | undefined,
activation: ActivationConfig | undefined,
): string | null {
const all = trades ?? [];
if (all.length === 0) return null;
const qualified = activation ? all.filter((t) => qualifiesSetup(t, activation)) : [];
const top = [...qualified].sort(
(a, b) =>
(b.strategy_rank ?? b.momentum_percentile ?? -Infinity) -
(a.strategy_rank ?? a.momentum_percentile ?? -Infinity),
)[0];
return top?.symbol ?? null;
}
/** Short human summary of the active gate, e.g. for tooltips/labels. */
export function activationSummary(config: ActivationConfig): string {
const parts = [];
if (config.min_momentum_percentile > 0) parts.push(`top ${(100 - config.min_momentum_percentile).toFixed(0)}% residual momentum`);
parts.push(`R:R ≥ ${config.min_rr.toFixed(1)}`, `conf ≥ ${config.min_confidence.toFixed(0)}%`);
if (config.exclude_neutral) parts.push('directional');
if (config.require_high_conviction) parts.push('high-conviction');
if (config.exclude_conflicts) parts.push('clean');
return parts.join(' · ');
}