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(' · '); }