- Allow shorts at range bottom (<5%) with volume >1.2x OR RSI <40 - Allow longs at range bottom with volume >1.2x OR RSI >60 - Reduce ADX penalty from -15 to -5 when strong volume (>1.2x) present - Reduce price position penalties from -15 to -10 (less harsh) - Volume compensation recognizes breakdowns start before ADX strengthens Test case (blocked signal that would have profited): - OLD: ATR 0.32, ADX 17.3, RSI 32.5, Vol 1.27x, Price 0.9% → Score 45 (blocked) - NEW: Same metrics → Score 85 (executes) Rationale: Breakdowns continue lower, volume confirms conviction, ADX lags price action
222 lines
7.8 KiB
TypeScript
222 lines
7.8 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
|
|
*
|
|
* TIMEFRAME-AWARE SCORING:
|
|
* 5min charts naturally have lower ADX/ATR than higher timeframes
|
|
*
|
|
* Scoring breakdown:
|
|
* - Base: 50 points
|
|
* - ATR (volatility): -20 to +10 points (5min: 0.25-0.7% is healthy)
|
|
* - ADX (trend strength): -15 to +15 points (5min: 15+ is trending)
|
|
* - RSI (momentum): -10 to +10 points
|
|
* - Volume: -10 to +15 points
|
|
* - Price position: -15 to +5 points
|
|
* - Volume breakout bonus: +10 points
|
|
* - Anti-chop filter: -20 points (5min only, extreme chop)
|
|
*
|
|
* Total range: ~15-115 points (realistically 30-100)
|
|
* Threshold: 60 points minimum for execution
|
|
*/
|
|
export function scoreSignalQuality(params: {
|
|
atr: number
|
|
adx: number
|
|
rsi: number
|
|
volumeRatio: number
|
|
pricePosition: number
|
|
direction: 'long' | 'short'
|
|
minScore?: number // Configurable minimum score threshold
|
|
timeframe?: string // e.g., '5', '15', '60', '1D'
|
|
}): SignalQualityResult {
|
|
let score = 50 // Base score
|
|
const reasons: string[] = []
|
|
|
|
// Detect 5-minute timeframe
|
|
const is5min = params.timeframe === '5' || params.timeframe === 'manual'
|
|
|
|
// ATR check - TIMEFRAME AWARE
|
|
if (params.atr > 0) {
|
|
if (is5min) {
|
|
// 5min: lower thresholds, more lenient
|
|
if (params.atr < 0.2) {
|
|
score -= 15
|
|
reasons.push(`ATR too low (${params.atr.toFixed(2)}% - dead market)`)
|
|
} else if (params.atr > 1.5) {
|
|
score -= 20
|
|
reasons.push(`ATR too high (${params.atr.toFixed(2)}% - too volatile)`)
|
|
} else if (params.atr >= 0.2 && params.atr < 0.35) {
|
|
score += 5
|
|
reasons.push(`ATR acceptable (${params.atr.toFixed(2)}%)`)
|
|
} else {
|
|
score += 10
|
|
reasons.push(`ATR healthy (${params.atr.toFixed(2)}%)`)
|
|
}
|
|
} else {
|
|
// Higher timeframes: stricter requirements
|
|
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
|
|
if (params.adx > 0) {
|
|
if (is5min) {
|
|
// 5min: ADX 15+ is actually trending, 20+ is strong
|
|
// High volume can compensate for lower ADX in breakouts/breakdowns
|
|
const hasStrongVolume = params.volumeRatio > 1.2
|
|
|
|
if (params.adx > 22) {
|
|
score += 15
|
|
reasons.push(`Strong 5min trend (ADX ${params.adx.toFixed(1)})`)
|
|
} else if (params.adx < 12) {
|
|
// Reduce penalty if strong volume present (breakdown/breakout in progress)
|
|
if (hasStrongVolume) {
|
|
score -= 5
|
|
reasons.push(`Lower 5min ADX (${params.adx.toFixed(1)}) but strong volume compensates`)
|
|
} else {
|
|
score -= 15
|
|
reasons.push(`Weak 5min trend (ADX ${params.adx.toFixed(1)})`)
|
|
}
|
|
} else {
|
|
score += 5
|
|
reasons.push(`Moderate 5min trend (ADX ${params.adx.toFixed(1)})`)
|
|
}
|
|
} else {
|
|
// Higher timeframes: stricter ADX requirements
|
|
const hasStrongVolume = params.volumeRatio > 1.2
|
|
|
|
if (params.adx > 25) {
|
|
score += 15
|
|
reasons.push(`Strong trend (ADX ${params.adx.toFixed(1)})`)
|
|
} else if (params.adx < 18) {
|
|
// Reduce penalty if strong volume present
|
|
if (hasStrongVolume) {
|
|
score -= 5
|
|
reasons.push(`Lower ADX (${params.adx.toFixed(1)}) but strong volume compensates`)
|
|
} else {
|
|
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)
|
|
if (params.volumeRatio > 0) {
|
|
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/breakdown 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
|
|
// Also allow if RSI is bearish (< 40) - breakdowns continue lower
|
|
if (params.volumeRatio > 1.2 || (params.rsi > 0 && params.rsi < 40)) {
|
|
score += 5
|
|
reasons.push(`Valid breakdown at range bottom (${params.pricePosition.toFixed(0)}%, vol ${params.volumeRatio.toFixed(2)}x, RSI ${params.rsi.toFixed(1)})`)
|
|
} else {
|
|
score -= 10
|
|
reasons.push(`Price near bottom of range (${params.pricePosition.toFixed(0)}%) - reduced penalty for short`)
|
|
}
|
|
} else if (params.direction === 'long' && params.pricePosition < 5) {
|
|
// Longs at bottom with good volume = potential reversal
|
|
if (params.volumeRatio > 1.2 || (params.rsi > 0 && params.rsi > 60)) {
|
|
score += 5
|
|
reasons.push(`Potential reversal at bottom (${params.pricePosition.toFixed(0)}%, vol ${params.volumeRatio.toFixed(2)}x, RSI ${params.rsi.toFixed(1)})`)
|
|
} else {
|
|
score -= 10
|
|
reasons.push(`Price near bottom (${params.pricePosition.toFixed(0)}%) - reduced penalty for reversal long`)
|
|
}
|
|
} 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`)
|
|
}
|
|
|
|
// ANTI-CHOP FILTER for 5min (extreme penalty for sideways chop)
|
|
if (is5min && params.adx < 10 && params.atr < 0.25 && params.volumeRatio < 0.9) {
|
|
score -= 20
|
|
reasons.push(`⛔ Extreme chop detected (ADX ${params.adx.toFixed(1)}, ATR ${params.atr.toFixed(2)}%, Vol ${params.volumeRatio.toFixed(2)}x)`)
|
|
}
|
|
|
|
const minScore = params.minScore || 60
|
|
const passed = score >= minScore
|
|
|
|
return {
|
|
score,
|
|
passed,
|
|
reasons,
|
|
}
|
|
}
|