import type { ActivationConfig, TradeSetup } from './types'; const HIGH_CONVICTION_ACTIONS = new Set(['LONG_HIGH', 'SHORT_HIGH']); 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'; } export function bestTargetProbability(setup: TradeSetup): number { return setup.targets?.length ? Math.max(...setup.targets.map((t) => t.probability)) : 0; } /** Probability of the starred primary target (the one the headline R:R refers to). */ export function primaryTargetProbability(setup: TradeSetup): number | null { const primary = setup.targets?.find((t) => t.is_primary); if (primary) return primary.probability; return setup.targets?.length ? bestTargetProbability(setup) : 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.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; } 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; the percentile floor is enforced // only when a percentile is attached, otherwise defer to the floors. 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; } /** * 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(' · '); }