- Created logger utility with environment-based gating (lib/utils/logger.ts) - Replaced 517 console.log statements with logger.log (71% reduction) - Fixed import paths in 15 files (resolved comment-trapped imports) - Added DEBUG_LOGS=false to .env - Achieves 71% immediate log reduction (517/731 statements) - Expected 90% reduction in production when deployed Impact: Reduced I/O blocking, lower log volume in production Risk: LOW (easy rollback, non-invasive) Phase: Phase 1, Task 1.1 (Quick Wins - Console.log Production Gating) Files changed: - NEW: lib/utils/logger.ts (production-safe logging) - NEW: scripts/replace-console-logs.js (automation tool) - Modified: 15 lib/*.ts files (console.log → logger.log) - Modified: .env (DEBUG_LOGS=false) Next: Task 1.2 (Image Size Optimization)
341 lines
14 KiB
TypeScript
341 lines
14 KiB
TypeScript
/**
|
|
* 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<SignalQualityResult> {
|
|
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,
|
|
}
|
|
}
|