/** * Signal Quality Scoring * * Unified quality scoring logic used by both check-risk and execute endpoints. * Ensures consistent scoring across the trading pipeline. */ import { getRecentSignals } from '../database/trades' import { logger } from '../utils/logger' export interface SignalQualityResult { score: number passed: boolean reasons: string[] frequencyPenalties?: { overtrading: number flipFlop: number alternating: number } } /** * Calculate signal quality score based on technical indicators * * Scoring breakdown: * - Base: 50 points * - ATR (volatility): -20 to +10 points * - ADX (trend strength): -15 to +15 points * - RSI (momentum): -10 to +10 points * - Volume: -10 to +15 points * - Price position: -15 to +5 points * - Volume breakout bonus: +10 points * - Signal frequency penalties: -20 to -30 points * * Total range: ~15-115 points (realistically 30-100) * Threshold: 60 points minimum for execution * * TIMEFRAME-AWARE SCORING: * - 5min charts have lower ADX/ATR thresholds (trends develop slower) * - Higher timeframes require stronger confirmation * * SIGNAL FREQUENCY PENALTIES (NEW): * - 3+ signals in 30 min: -20 points (overtrading zone) * - Opposite direction in last 15 min: -25 points (flip-flop) * - Last 3 trades alternating: -30 points (chop pattern) */ export async function scoreSignalQuality(params: { atr: number adx: number rsi: number volumeRatio: number pricePosition: number direction: 'long' | 'short' symbol: string // Required for frequency check currentPrice?: number // Required for flip-flop price context check timeframe?: string // "5" = 5min, "15" = 15min, "60" = 1H, "D" = daily minScore?: number // Configurable minimum score threshold skipFrequencyCheck?: boolean // For testing or when frequency check not needed maGap?: number // V9: MA gap percentage (MA50-MA200)/MA200*100 }): Promise { let score = 50 // Base score const reasons: string[] = [] // Determine if this is a short timeframe (5min, 15min) const is5minChart = params.timeframe === '5' const is15minChart = params.timeframe === '15' const isShortTimeframe = is5minChart || is15minChart // ATR check (volatility gate: 0.15% - 2.5%) if (params.atr > 0) { if (params.atr < 0.15) { score -= 15 reasons.push(`ATR too low (${params.atr.toFixed(2)}% - dead market)`) } else if (params.atr > 2.5) { score -= 20 reasons.push(`ATR too high (${params.atr.toFixed(2)}% - too volatile)`) } else if (params.atr >= 0.15 && params.atr < 0.4) { score += 5 reasons.push(`ATR moderate (${params.atr.toFixed(2)}%)`) } else { score += 10 reasons.push(`ATR healthy (${params.atr.toFixed(2)}%)`) } } // ADX check - TIMEFRAME AWARE (trend strength) // 5min/15min: 12-22 is healthy (trends develop slower) // 1H+: 18+ is healthy (stronger trends expected) if (params.adx > 0) { if (isShortTimeframe) { // 5min/15min thresholds if (params.adx > 22) { score += 15 reasons.push(`Strong trend for ${params.timeframe}min (ADX ${params.adx.toFixed(1)})`) } else if (params.adx < 12) { score -= 15 reasons.push(`Weak trend for ${params.timeframe}min (ADX ${params.adx.toFixed(1)})`) } else { score += 5 reasons.push(`Moderate trend for ${params.timeframe}min (ADX ${params.adx.toFixed(1)})`) } } else { // Higher timeframe thresholds (1H, 4H, D) if (params.adx > 25) { score += 15 reasons.push(`Strong trend (ADX ${params.adx.toFixed(1)})`) } else if (params.adx < 18) { score -= 15 reasons.push(`Weak trend (ADX ${params.adx.toFixed(1)})`) } else { score += 5 reasons.push(`Moderate trend (ADX ${params.adx.toFixed(1)})`) } } } // RSI check (momentum confirmation) if (params.rsi > 0) { if (params.direction === 'long') { if (params.rsi > 50 && params.rsi < 70) { score += 10 reasons.push(`RSI supports long (${params.rsi.toFixed(1)})`) } else if (params.rsi > 70) { score -= 10 reasons.push(`RSI overbought (${params.rsi.toFixed(1)})`) } } else { // short // v10 (Nov 26, 2025): RSI is NOT a good SHORT filter! // Data analysis of 95 SHORTs showed: // - Winning SHORTs: avg RSI 45.8 (HIGHER than losers!) // - Losing SHORTs: avg RSI 42.9 // - RSI 50+: 68.2% WR, +$29.88 profit (BEST performance) // RSI alone is counterintuitive for shorts - focus on ADX + price position instead // Give small bonus for typical short RSI range, but don't penalize high RSI if (params.rsi < 50 && params.rsi > 30) { score += 5 reasons.push(`RSI supports short (${params.rsi.toFixed(1)})`) } else if (params.rsi < 30) { score -= 10 reasons.push(`RSI oversold - bounce risk (${params.rsi.toFixed(1)})`) } // No penalty for RSI 50+ - data shows these are actually the best shorts! } } // Volume check (want > 1.0 = above average) // ANTI-CHOP FILTER: High volume + low ADX = whipsaw trap if (params.volumeRatio > 0) { const isChoppy = params.adx > 0 && params.adx < 16 const hasHighVolume = params.volumeRatio > 1.5 if (isChoppy && hasHighVolume) { // High volume during choppy conditions (ADX < 16) is often a trap score -= 15 reasons.push(`⚠️ Whipsaw trap: High volume (${params.volumeRatio.toFixed(2)}x) + choppy market (ADX ${params.adx.toFixed(1)})`) } else if (params.volumeRatio > 1.5) { score += 15 reasons.push(`Very strong volume (${params.volumeRatio.toFixed(2)}x avg)`) } else if (params.volumeRatio > 1.2) { score += 10 reasons.push(`Strong volume (${params.volumeRatio.toFixed(2)}x avg)`) } else if (params.volumeRatio < 0.8) { score -= 10 reasons.push(`Weak volume (${params.volumeRatio.toFixed(2)}x avg)`) } } // Price position check (avoid chasing vs breakout detection) if (params.pricePosition > 0) { if (params.direction === 'long' && params.pricePosition > 95) { // High volume breakout at range top can be good if (params.volumeRatio > 1.4) { score += 5 reasons.push(`Volume breakout at range top (${params.pricePosition.toFixed(0)}%, vol ${params.volumeRatio.toFixed(2)}x)`) } else { score -= 15 reasons.push(`Price near top of range (${params.pricePosition.toFixed(0)}%) - risky long`) } } else if (params.direction === 'short' && params.pricePosition < 5) { // High volume breakdown at range bottom can be good if (params.volumeRatio > 1.4) { score += 5 reasons.push(`Volume breakdown at range bottom (${params.pricePosition.toFixed(0)}%, vol ${params.volumeRatio.toFixed(2)}x)`) } else { score -= 15 reasons.push(`Price near bottom of range (${params.pricePosition.toFixed(0)}%) - risky short`) } } else { score += 5 reasons.push(`Price position OK (${params.pricePosition.toFixed(0)}%)`) } } // Volume breakout bonus (high volume can override other weaknesses) if (params.volumeRatio > 1.8 && params.atr < 0.6) { score += 10 reasons.push(`Volume breakout compensates for low ATR`) } // Signal frequency penalties (check database for recent signals) const frequencyPenalties = { overtrading: 0, flipFlop: 0, alternating: 0, } if (!params.skipFrequencyCheck) { try { const recentSignals = await getRecentSignals({ symbol: params.symbol, direction: params.direction, timeWindowMinutes: 30, timeframe: params.timeframe, }) // Penalty 1: Overtrading (3+ signals in 30 minutes) if (recentSignals.totalSignals >= 3) { frequencyPenalties.overtrading = -20 score -= 20 reasons.push(`⚠️ Overtrading zone: ${recentSignals.totalSignals} signals in 30min (-20 pts)`) } // Penalty 2: Flip-flop (opposite direction in last 15 minutes) // BUT: Only penalize if price hasn't moved significantly (< 2% from opposite signal) // This distinguishes chop (bad) from legitimate reversals (good) if (recentSignals.oppositeDirectionInWindow && recentSignals.oppositeDirectionPrice) { if (!params.currentPrice || params.currentPrice === 0) { // No current price available - apply penalty (conservative) console.warn(`⚠️ Flip-flop check: No currentPrice available, applying penalty`) frequencyPenalties.flipFlop = -25 score -= 25 reasons.push(`⚠️ Flip-flop detected: opposite direction ${recentSignals.oppositeDirectionMinutesAgo}min ago, no price data (-25 pts)`) } else { const priceChangePercent = Math.abs( (params.currentPrice - recentSignals.oppositeDirectionPrice) / recentSignals.oppositeDirectionPrice * 100 ) logger.log(`🔍 Flip-flop price check: $${recentSignals.oppositeDirectionPrice.toFixed(2)} → $${params.currentPrice.toFixed(2)} = ${priceChangePercent.toFixed(2)}%`) if (priceChangePercent < 2.0) { // Small price move = consolidation/chop = BAD frequencyPenalties.flipFlop = -25 score -= 25 reasons.push( `⚠️ Flip-flop in tight range: ${recentSignals.oppositeDirectionMinutesAgo}min ago, ` + `only ${priceChangePercent.toFixed(2)}% move ($${recentSignals.oppositeDirectionPrice.toFixed(2)} → $${params.currentPrice.toFixed(2)}) (-25 pts)` ) } else { // Large price move = potential reversal = ALLOW reasons.push( `✅ Direction change after ${priceChangePercent.toFixed(1)}% move ` + `($${recentSignals.oppositeDirectionPrice.toFixed(2)} → $${params.currentPrice.toFixed(2)}, ${recentSignals.oppositeDirectionMinutesAgo}min ago) - reversal allowed` ) } } } else if (recentSignals.oppositeDirectionInWindow && !recentSignals.oppositeDirectionPrice) { // Fallback: If we don't have opposite price data, apply penalty (conservative) frequencyPenalties.flipFlop = -25 score -= 25 reasons.push(`⚠️ Flip-flop detected: opposite direction ${recentSignals.oppositeDirectionMinutesAgo}min ago, no historical price (-25 pts)`) } // Penalty 3: Alternating pattern (last 3 trades flip directions) if (recentSignals.isAlternatingPattern) { frequencyPenalties.alternating = -30 score -= 30 const pattern = recentSignals.last3Trades.map(t => t.direction).join(' → ') reasons.push(`⚠️ Chop pattern: last 3 trades alternating (${pattern}) (-30 pts)`) } } catch (error) { // Don't fail the whole score if frequency check fails console.error('❌ Signal frequency check failed:', error) reasons.push('⚠️ Frequency check unavailable (no penalty applied)') } } // V9: MA Gap Analysis (Nov 26, 2025) // MA convergence/divergence indicates momentum building or fading // Helps catch early trend signals when MAs align with direction if (params.maGap !== undefined && params.maGap !== null) { if (params.direction === 'long') { if (params.maGap >= 0 && params.maGap < 2.0) { // Tight bullish convergence (MA50 above MA200, close together) score += 15 reasons.push(`✅ Tight bullish MA convergence (${params.maGap.toFixed(2)}% gap) (+15 pts)`) } else if (params.maGap < 0 && params.maGap > -2.0) { // MAs converging from below (MA50 approaching MA200) score += 12 reasons.push(`✅ MAs converging bullish (${params.maGap.toFixed(2)}% gap) (+12 pts)`) } else if (params.maGap < -2.0 && params.maGap > -5.0) { // Early momentum building score += 8 reasons.push(`✅ Early bullish momentum (${params.maGap.toFixed(2)}% gap) (+8 pts)`) } else if (params.maGap >= 2.0) { // Wide gap = momentum already extended score += 5 reasons.push(`⚠️ Extended bullish gap (${params.maGap.toFixed(2)}%) (+5 pts)`) } else if (params.maGap <= -5.0) { // Very bearish MA structure for long score -= 5 reasons.push(`⚠️ Bearish MA structure for long (${params.maGap.toFixed(2)}%) (-5 pts)`) } } else if (params.direction === 'short') { if (params.maGap <= 0 && params.maGap > -2.0) { // Tight bearish convergence (MA50 below MA200, close together) score += 15 reasons.push(`✅ Tight bearish MA convergence (${params.maGap.toFixed(2)}% gap) (+15 pts)`) } else if (params.maGap > 0 && params.maGap < 2.0) { // MAs converging from above (MA50 approaching MA200) score += 12 reasons.push(`✅ MAs converging bearish (${params.maGap.toFixed(2)}% gap) (+12 pts)`) } else if (params.maGap > 2.0 && params.maGap < 5.0) { // Early momentum building score += 8 reasons.push(`✅ Early bearish momentum (${params.maGap.toFixed(2)}% gap) (+8 pts)`) } else if (params.maGap <= -2.0) { // Wide gap = momentum already extended score += 5 reasons.push(`⚠️ Extended bearish gap (${params.maGap.toFixed(2)}%) (+5 pts)`) } else if (params.maGap >= 5.0) { // Very bullish MA structure for short score -= 5 reasons.push(`⚠️ Bullish MA structure for short (${params.maGap.toFixed(2)}%) (-5 pts)`) } } } // Direction-specific threshold support (Nov 23, 2025) // Use provided minScore, or fall back to 60 if not specified const minScore = params.minScore || 60 const passed = score >= minScore return { score, passed, reasons, frequencyPenalties, } }