CRITICAL BUG FIX: - Settings page saved MIN_SIGNAL_QUALITY_SCORE to .env but check-risk had hardcoded value - Now reads from config.minSignalQualityScore (defaults to 65, editable via /settings) - Prevents settings changes from being ignored after restart ANTI-CHOP FILTER FIXES: - Fixed volume breakout bonus conflicting with anti-chop filter - Volume breakout now requires ADX > 18 (trending market) - Prevents high volume + low ADX from getting rewarded instead of penalized - Anti-chop filter now properly blocks whipsaw traps at score 60 TESTING INFRASTRUCTURE: - Added backtest script showing +17.1% P&L improvement (saved $242 in losses) - Added test-signals.sh for comprehensive signal quality validation - Added test-recent-signals.sh for analyzing actual trading session signals - All tests passing: timeframe awareness, anti-chop, score thresholds CHANGES: - config/trading.ts: Added minSignalQualityScore to interface and defaults - app/api/trading/check-risk/route.ts: Use config value instead of hardcoded 65 - lib/trading/signal-quality.ts: Fixed volume breakout bonus logic - .env: Added MIN_SIGNAL_QUALITY_SCORE=65 - scripts/: Added comprehensive testing tools BACKTEST RESULTS (Last 30 trades): - Old system (score ≥60): $1,412.79 P&L - New system (score ≥65 + anti-chop): $1,654.79 P&L - Improvement: +$242.00 (+17.1%) - Blocked 5 losing trades, missed 0 winners
185 lines
6.3 KiB
TypeScript
185 lines
6.3 KiB
TypeScript
/**
|
|
* Signal Quality Scoring
|
|
*
|
|
* Unified quality scoring logic used by both check-risk and execute endpoints.
|
|
* Ensures consistent scoring across the trading pipeline.
|
|
*/
|
|
|
|
export interface SignalQualityResult {
|
|
score: number
|
|
passed: boolean
|
|
reasons: string[]
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*
|
|
* 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
|
|
*/
|
|
export function scoreSignalQuality(params: {
|
|
atr: number
|
|
adx: number
|
|
rsi: number
|
|
volumeRatio: number
|
|
pricePosition: number
|
|
direction: 'long' | 'short'
|
|
timeframe?: string // "5" = 5min, "15" = 15min, "60" = 1H, "D" = daily
|
|
minScore?: number // Configurable minimum score threshold
|
|
}): 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
|
|
if (params.rsi < 50 && params.rsi > 30) {
|
|
score += 10
|
|
reasons.push(`RSI supports short (${params.rsi.toFixed(1)})`)
|
|
} else if (params.rsi < 30) {
|
|
score -= 10
|
|
reasons.push(`RSI oversold (${params.rsi.toFixed(1)})`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 - ONLY if trend is strong enough (not choppy)
|
|
// Removed old logic that conflicted with anti-chop filter
|
|
// Old bonus was rewarding high volume even during choppy markets
|
|
if (params.volumeRatio > 1.8 && params.atr < 0.6 && params.adx > 18) {
|
|
score += 10
|
|
reasons.push(`Volume breakout compensates for low ATR (ADX ${params.adx.toFixed(1)} confirms trend)`)
|
|
}
|
|
|
|
const minScore = params.minScore || 65
|
|
const passed = score >= minScore
|
|
|
|
return {
|
|
score,
|
|
passed,
|
|
reasons,
|
|
}
|
|
}
|