Ship greenfield min_rr=2.0 and conf=0, read-only Structural S/R, indicator cache invalidation, and UI/gate language that treats GTL as screening not exit. Align strategy_rank missing-vol fallback live vs backtest, single-source PRIMARY_TARGET_MIN_RR, expand prod parity tests, and drop dead FE clients.
150 lines
6.8 KiB
TypeScript
150 lines
6.8 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_gate_reset_required) 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 {
|
|
if (setup.reentry_gate_reset_required) {
|
|
return 'post-stop gate reset required';
|
|
}
|
|
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:
|
|
* highest production strategy_rank (80/20 mom/vol) among qualified setups,
|
|
* falling back to residual momentum percentile. 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(' · ');
|
|
}
|