Files
trading_bot_v4/lib/trading/signal-quality.ts
mindesbunister 2017cba452 feat: v9 SHORT quality improvements - momentum-based filtering
PROBLEM IDENTIFIED (Nov 26, 2025):
- Two v9 SHORT losses today: -$133.31 and -$153.98 (total -$287.29)
- Analysis of 95 historical SHORTs revealed counterintuitive patterns
- RSI filter was blocking the WRONG trades

DATA FINDINGS:
- RSI <35 SHORTs: 37.5% WR, -$655.23 (4 biggest disasters)
- Winning SHORTs: avg ADX 26.9, Price Position 19-64%
- Losing SHORTs: avg ADX 20.7, Price Position 13.6-65%
- Today's disaster: ADX 20.7, Price Pos 13.6%, RSI 46.2

ROOT CAUSE:
- v8 failed: Shorting oversold (RSI 25-35), caught falling knives
- v9 RSI filter: Blocked RSI <33 but allowed weak chop trades
- Real issue: Trend strength (ADX) + Entry timing (Price Position)

SOLUTION IMPLEMENTED:
1. REMOVED RSI filter for SHORTs entirely (was blocking wrong trades)
2. ADDED momentum-based filter:
   - Requires ADX >= 23 (trending environment, not chop)
   - Requires Price Position >= 60% (short at top of range) OR
   - Price Position <= 40% with Volume >= 2.0x (capitulation breakdown)
   - Penalty: -30 points if criteria not met
   - Bonus: +10 points if momentum criteria met

EXPECTED IMPACT:
- Blocks today's disaster: ADX 20.7 <23, Price Pos 13.6% <60%
- Blocks 4 of top 6 worst losses (all weak trend or bottom-fishing)
- Enables catching massive downtrends at top of range
- Total potential savings: ~$776 from historical disasters

FILES CHANGED:
- lib/trading/signal-quality.ts (lines 118-156, 197-238)

Built and deployed: Nov 26, 2025 19:45 CET
Container restarted: trading-bot-v4
2025-11-26 19:51:47 +01:00

366 lines
15 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'
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`)
}
// v10 (Nov 26, 2025): SHORT-SPECIFIC MOMENTUM FILTER
// Data analysis of 95 SHORTs revealed the REAL pattern:
// - Winners: ADX 26.9, Price Position 37.8-64.2% (shorting at top!)
// - Losers: ADX 20-25, Price Position 13.6-38.4% (shorting in chop at bottom!)
// - Today's disaster: ADX 20.7, Price Pos 13.6% = both failed filters
//
// The winning formula: Catch shorts at the TOP with strong trend confirmation
if (params.direction === 'short') {
const hasStrongTrend = params.adx >= 23
const atTopOfRange = params.pricePosition >= 60
const atBottomWithVolume = params.pricePosition <= 40 && params.volumeRatio >= 2.0
if (!hasStrongTrend) {
score -= 30
reasons.push(`🚨 v10: SHORT in weak trend (ADX ${params.adx.toFixed(1)} < 23) → HIGH CHOP RISK (-30 pts)`)
} else if (!atTopOfRange && !atBottomWithVolume) {
score -= 25
reasons.push(`🚨 v10: SHORT in mid-range (${params.pricePosition.toFixed(0)}%) without setup → TRAP ZONE (-25 pts)`)
} else if (atTopOfRange) {
score += 15
reasons.push(`✅ v10: SHORT at top of range (${params.pricePosition.toFixed(0)}%) with ADX ${params.adx.toFixed(1)} → IDEAL SETUP (+15 pts)`)
} else if (atBottomWithVolume) {
score += 10
reasons.push(`✅ v10: SHORT capitulation bounce (${params.pricePosition.toFixed(0)}%, vol ${params.volumeRatio.toFixed(2)}x) → VALID (+10 pts)`)
}
}
// 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,
})
// 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
)
console.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,
}
}