fix(critical): Unify quality score calculation across check-risk and execute

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
This commit is contained in:
mindesbunister
2025-11-04 11:40:25 +01:00
parent fdbb474e68
commit 02193b7dce
3 changed files with 166 additions and 226 deletions

View File

@@ -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<NextResponse<RiskCheck
return NextResponse.json(
{
allowed: false,
reason: 'Risk check failed',
reason: 'Server error',
details: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
interface SignalQualityResult {
passed: boolean
score: number
reasons: string[]
}
/**
* Score signal quality based on context metrics from TradingView
* Returns score 0-100 and array of reasons
*/
function scoreSignalQuality(params: {
atr: number
adx: number
rsi: number
volumeRatio: number
pricePosition: number
direction: 'long' | 'short'
minScore?: number // Configurable minimum score threshold
}): SignalQualityResult {
let score = 50 // Base score
const reasons: string[] = []
// 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 (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
}
}

View File

@@ -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<NextResponse<ExecuteTr
// Save phantom trade to database for analysis
try {
const qualityScore = calculateQualityScore({
atr: body.atr,
adx: body.adx,
rsi: body.rsi,
volumeRatio: body.volumeRatio,
pricePosition: body.pricePosition,
const qualityResult = scoreSignalQuality({
atr: body.atr || 0,
adx: body.adx || 0,
rsi: body.rsi || 0,
volumeRatio: body.volumeRatio || 0,
pricePosition: body.pricePosition || 0,
direction: body.direction,
})
@@ -411,7 +334,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
rsiAtEntry: body.rsi,
volumeAtEntry: body.volumeRatio,
pricePositionAtEntry: body.pricePosition,
signalQualityScore: qualityScore,
signalQualityScore: qualityResult.score,
// Phantom-specific fields
status: 'phantom',
isPhantom: true,
@@ -591,12 +514,12 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
// Save trade to database
try {
// Calculate quality score if metrics available
const qualityScore = calculateQualityScore({
atr: body.atr,
adx: body.adx,
rsi: body.rsi,
volumeRatio: body.volumeRatio,
pricePosition: body.pricePosition,
const qualityResult = scoreSignalQuality({
atr: body.atr || 0,
adx: body.adx || 0,
rsi: body.rsi || 0,
volumeRatio: body.volumeRatio || 0,
pricePosition: body.pricePosition || 0,
direction: body.direction,
})
@@ -629,14 +552,11 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
rsiAtEntry: body.rsi,
volumeAtEntry: body.volumeRatio,
pricePositionAtEntry: body.pricePosition,
signalQualityScore: qualityScore,
signalQualityScore: qualityResult.score,
})
if (qualityScore !== undefined) {
console.log(`💾 Trade saved with quality score: ${qualityScore}/100`)
} else {
console.log('💾 Trade saved to database')
}
console.log(`💾 Trade saved with quality score: ${qualityResult.score}/100`)
console.log(`📊 Quality reasons: ${qualityResult.reasons.join(', ')}`)
} catch (dbError) {
console.error('❌ Failed to save trade to database:', dbError)
// Don't fail the trade if database save fails