From 02193b7dce884fa2eab6bbdaab9db1613dceb648 Mon Sep 17 00:00:00 2001 From: mindesbunister Date: Tue, 4 Nov 2025 11:40:25 +0100 Subject: [PATCH] fix(critical): Unify quality score calculation across check-risk and execute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROBLEM: - check-risk calculated quality score: 60, 70 (PASSED) - execute calculated quality score: 35, 45 (should have BLOCKED) - Two different functions with different logic caused trades to bypass validation ROOT CAUSE: Two separate scoring functions existed: 1. scoreSignalQuality() in check-risk (detailed, 95% price threshold) 2. calculateQualityScore() in execute (simpler, 90% price threshold) Example with pricePosition=96.4%, volumeRatio=0.9: - check-risk: Checks >95, volumeRatio>1.4 failed → -15 + bonuses = 60 ✅ PASSED - execute: Checks >90 → -15 + bonuses = 35 ❌ Should block but already opened SOLUTION: 1. Created lib/trading/signal-quality.ts with unified scoreSignalQuality() 2. Both endpoints now import and use SAME function 3. Consistent scoring logic: 95% price threshold, volume breakout bonus 4. Returns detailed reasons for debugging IMPACT: - Quality scores now MATCH between check-risk and execute - No more trades bypassing validation due to calculation differences - Better debugging with quality reasons logged Files changed: - NEW: lib/trading/signal-quality.ts (unified scoring function) - MODIFIED: app/api/trading/check-risk/route.ts (import shared function) - MODIFIED: app/api/trading/execute/route.ts (import shared function) - REMOVED: Duplicate calculateQualityScore() from execute - REMOVED: Duplicate scoreSignalQuality() from check-risk --- app/api/trading/check-risk/route.ts | 131 +------------------------ app/api/trading/execute/route.ts | 114 ++++----------------- lib/trading/signal-quality.ts | 147 ++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 226 deletions(-) create mode 100644 lib/trading/signal-quality.ts diff --git a/app/api/trading/check-risk/route.ts b/app/api/trading/check-risk/route.ts index 666fb52..879d38a 100644 --- a/app/api/trading/check-risk/route.ts +++ b/app/api/trading/check-risk/route.ts @@ -10,6 +10,7 @@ import { getMergedConfig, TradingConfig } from '@/config/trading' import { getInitializedPositionManager, ActiveTrade } from '@/lib/trading/position-manager' import { getLastTradeTime, getLastTradeTimeForSymbol, getTradesInLastHour, getTodayPnL } from '@/lib/database/trades' import { getPythPriceMonitor } from '@/lib/pyth/price-monitor' +import { scoreSignalQuality, SignalQualityResult } from '@/lib/trading/signal-quality' export interface RiskCheckRequest { symbol: string @@ -320,138 +321,10 @@ export async function POST(request: NextRequest): Promise 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 (trend strength: want >18) - if (params.adx > 0) { - 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) - 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 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`) - } - - const minScore = params.minScore ?? 60 // Use config value or default to 60 - return { - passed: score >= minScore, - score, - reasons - } -} diff --git a/app/api/trading/execute/route.ts b/app/api/trading/execute/route.ts index 79c8786..ecb6f30 100644 --- a/app/api/trading/execute/route.ts +++ b/app/api/trading/execute/route.ts @@ -12,84 +12,7 @@ import { normalizeTradingViewSymbol } from '@/config/trading' import { getMergedConfig } from '@/config/trading' import { getInitializedPositionManager, ActiveTrade } from '@/lib/trading/position-manager' import { createTrade, updateTradeExit } from '@/lib/database/trades' - -/** - * Calculate signal quality score (same logic as check-risk endpoint) - */ -function calculateQualityScore(params: { - atr?: number - adx?: number - rsi?: number - volumeRatio?: number - pricePosition?: number - direction: 'long' | 'short' -}): number | undefined { - // If no metrics provided, return undefined - if (!params.atr || params.atr === 0) { - return undefined - } - - let score = 50 // Base score - - // ATR check - if (params.atr < 0.6) { - score -= 15 - } else if (params.atr > 2.5) { - score -= 20 - } else { - score += 10 - } - - // ADX check - if (params.adx && params.adx > 0) { - if (params.adx > 25) { - score += 15 - } else if (params.adx < 18) { - score -= 15 - } else { - score += 5 - } - } - - // RSI check - if (params.rsi && params.rsi > 0) { - if (params.direction === 'long') { - if (params.rsi > 50 && params.rsi < 70) { - score += 10 - } else if (params.rsi > 70) { - score -= 10 - } - } else { - if (params.rsi < 50 && params.rsi > 30) { - score += 10 - } else if (params.rsi < 30) { - score -= 10 - } - } - } - - // Volume check - if (params.volumeRatio && params.volumeRatio > 0) { - if (params.volumeRatio > 1.2) { - score += 10 - } else if (params.volumeRatio < 0.8) { - score -= 10 - } - } - - // Price position check - if (params.pricePosition && params.pricePosition > 0) { - if (params.direction === 'long' && params.pricePosition > 90) { - score -= 15 - } else if (params.direction === 'short' && params.pricePosition < 10) { - score -= 15 - } else { - score += 5 - } - } - - return score -} +import { scoreSignalQuality } from '@/lib/trading/signal-quality' export interface ExecuteTradeRequest { symbol: string // TradingView symbol (e.g., 'SOLUSDT') @@ -381,12 +304,12 @@ export async function POST(request: NextRequest): Promise 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 (trend strength: want >18) + if (params.adx > 0) { + 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) + 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 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`) + } + + const minScore = params.minScore || 60 + const passed = score >= minScore + + return { + score, + passed, + reasons, + } +}