Files
trading_bot_v4/lib/trading/smart-validation-queue.ts
mindesbunister 2a1badf3ab critical: Fix Smart Validation Queue - restore signals from database on startup
Problem: Queue is in-memory only (Map), container restarts lose all queued signals
Impact: Quality 50-89 signals blocked but never validated, missed +8.56 manual entry opportunity
Root Cause: startSmartValidation() just created empty queue, never loaded from database

Fix:
- Query BlockedSignal table for signals within 30-minute entry window
- Re-queue each signal with original parameters
- Start monitoring if any signals restored
- Use console.log() instead of logger.log() for production visibility

Files Changed:
- lib/trading/smart-validation-queue.ts (Lines 456-500, 137-175, 117-127)

Expected Behavior After Fix:
- Container restart: Loads pending signals from database
- Signals within 30min window: Re-queued and monitored
- Monitoring starts immediately if signals exist
- Logs show: '🔄 Restoring N pending signals from database'

User Quote: 'the smart validation system should have entered the trade as it shot up'

This fix ensures the Smart Validation Queue actually works after container restarts,
catching marginal quality signals that confirm direction via price action.

Deploy Status:  DEPLOYED Dec 9, 2025 17:07 CET
2025-12-09 17:43:02 +01:00

508 lines
18 KiB
TypeScript

/**
* Smart Entry Validation Queue
*
* Purpose: Monitor blocked signals (quality 50-89) and validate them using 1-minute price action.
* Instead of hard-blocking marginal quality signals, we "Block & Watch" - enter if price confirms
* the direction, abandon if price moves against us.
*
* This system bridges the gap between:
* - High quality signals (90+): Immediate execution
* - Marginal quality signals (50-89): Block & Watch → Execute if confirmed
* - Low quality signals (<50): Hard block, no validation
*/
import { getMarketDataCache } from './market-data-cache'
import { logger } from '../utils/logger'
import { getMergedConfig } from '../../config/trading'
import { sendValidationNotification } from '../notifications/telegram'
interface QueuedSignal {
id: string
symbol: string
direction: 'long' | 'short'
originalPrice: number
originalSignalData: {
atr?: number
adx?: number
rsi?: number
volumeRatio?: number
pricePosition?: number
indicatorVersion?: string
timeframe?: string
}
qualityScore: number
blockedAt: number // Unix timestamp
entryWindowMinutes: number // How long to watch (default: 10)
confirmationThreshold: number // % move needed to confirm (default: 0.3%)
maxDrawdown: number // % move against to abandon (default: -0.4%)
highestPrice?: number // Track highest price seen (for longs)
lowestPrice?: number // Track lowest price seen (for shorts)
status: 'pending' | 'confirmed' | 'abandoned' | 'expired' | 'executed'
validatedAt?: number
executedAt?: number
executionPrice?: number
tradeId?: string
}
class SmartValidationQueue {
private queue: Map<string, QueuedSignal> = new Map()
private monitoringInterval?: NodeJS.Timeout
private isMonitoring = false
constructor() {
logger.log('🧠 Smart Validation Queue initialized')
}
/**
* Add a blocked signal to validation queue
*/
async addSignal(params: {
blockReason: string
symbol: string
direction: 'long' | 'short'
originalPrice: number
qualityScore: number
atr?: number
adx?: number
rsi?: number
volumeRatio?: number
pricePosition?: number
indicatorVersion?: string
timeframe?: string
}): Promise<QueuedSignal | null> {
const config = getMergedConfig()
// Only queue signals blocked for quality (not cooldown, rate limits, etc.)
if (params.blockReason !== 'QUALITY_SCORE_TOO_LOW') {
return null
}
// Only queue marginal quality signals (50-89)
// Below 50: Too low quality, don't validate
// 90+: Should have been executed, not blocked
if (params.qualityScore < 50 || params.qualityScore >= 90) {
return null
}
const signalId = `${params.symbol}_${params.direction}_${Date.now()}`
const queuedSignal: QueuedSignal = {
id: signalId,
symbol: params.symbol,
direction: params.direction,
originalPrice: params.originalPrice,
originalSignalData: {
atr: params.atr,
adx: params.adx,
rsi: params.rsi,
volumeRatio: params.volumeRatio,
pricePosition: params.pricePosition,
indicatorVersion: params.indicatorVersion,
timeframe: params.timeframe,
},
qualityScore: params.qualityScore,
blockedAt: Date.now(),
entryWindowMinutes: 30, // Watch for 30 minutes (extended from 10 - Dec 7, 2025)
confirmationThreshold: 0.3, // Need +0.3% move to confirm
maxDrawdown: -0.4, // Abandon if -0.4% against direction
highestPrice: params.originalPrice,
lowestPrice: params.originalPrice,
status: 'pending',
}
this.queue.set(signalId, queuedSignal)
console.log(`⏰ Smart validation queued: ${params.symbol} ${params.direction.toUpperCase()} @ $${params.originalPrice.toFixed(2)} (quality: ${params.qualityScore})`)
console.log(` Watching for ${queuedSignal.entryWindowMinutes}min: +${queuedSignal.confirmationThreshold}% confirms, ${queuedSignal.maxDrawdown}% abandons`)
// Send Telegram notification
await sendValidationNotification({
event: 'queued',
symbol: params.symbol,
direction: params.direction,
originalPrice: params.originalPrice,
qualityScore: params.qualityScore,
})
// Start monitoring if not already running
if (!this.isMonitoring) {
this.startMonitoring()
}
return queuedSignal
}
/**
* Start monitoring queued signals
*/
private startMonitoring(): void {
if (this.isMonitoring) {
return
}
this.isMonitoring = true
console.log('👁️ Smart validation monitoring started (checks every 30s)')
// Check every 30 seconds
this.monitoringInterval = setInterval(async () => {
await this.checkValidations()
}, 30000)
}
/**
* Stop monitoring
*/
stopMonitoring(): void {
if (this.monitoringInterval) {
clearInterval(this.monitoringInterval)
this.monitoringInterval = undefined
}
this.isMonitoring = false
console.log('⏸️ Smart validation monitoring stopped')
}
/**
* Check all pending signals for validation
*/
private async checkValidations(): Promise<void> {
const pending = Array.from(this.queue.values()).filter(s => s.status === 'pending')
if (pending.length === 0) {
// No pending signals, stop monitoring to save resources
this.stopMonitoring()
return
}
console.log(`👁️ Smart validation check: ${pending.length} pending signals`)
for (const signal of pending) {
try {
await this.validateSignal(signal)
} catch (error) {
console.error(`❌ Error validating signal ${signal.id}:`, error)
}
}
// Clean up completed signals older than 1 hour
this.cleanupOldSignals()
}
/**
* Validate a single signal using current price
*/
private async validateSignal(signal: QueuedSignal): Promise<void> {
const now = Date.now()
const ageMinutes = (now - signal.blockedAt) / (1000 * 60)
// Check if expired (beyond entry window)
if (ageMinutes > signal.entryWindowMinutes) {
signal.status = 'expired'
logger.log(`⏰ Signal expired: ${signal.symbol} ${signal.direction} (${ageMinutes.toFixed(1)}min old)`)
// Send Telegram notification
await sendValidationNotification({
event: 'expired',
symbol: signal.symbol,
direction: signal.direction,
originalPrice: signal.originalPrice,
qualityScore: signal.qualityScore,
validationTime: (now - signal.blockedAt) / 1000,
})
return
}
// Get current price from market data cache
const marketDataCache = getMarketDataCache()
const cachedData = marketDataCache.get(signal.symbol)
if (!cachedData || !cachedData.currentPrice) {
logger.log(`⚠️ No price data for ${signal.symbol}, skipping validation`)
return
}
const currentPrice = cachedData.currentPrice
const priceChange = ((currentPrice - signal.originalPrice) / signal.originalPrice) * 100
// Update price extremes
if (!signal.highestPrice || currentPrice > signal.highestPrice) {
signal.highestPrice = currentPrice
}
if (!signal.lowestPrice || currentPrice < signal.lowestPrice) {
signal.lowestPrice = currentPrice
}
// Validation logic based on direction
if (signal.direction === 'long') {
// LONG: Need price to move UP to confirm
if (priceChange >= signal.confirmationThreshold) {
// Price moved up enough - CONFIRMED!
signal.status = 'confirmed'
signal.validatedAt = now
logger.log(`✅ LONG CONFIRMED: ${signal.symbol} moved +${priceChange.toFixed(2)}% ($${signal.originalPrice.toFixed(2)}$${currentPrice.toFixed(2)})`)
logger.log(` Validation time: ${ageMinutes.toFixed(1)} minutes, executing trade...`)
// Send Telegram notification
await sendValidationNotification({
event: 'confirmed',
symbol: signal.symbol,
direction: signal.direction,
originalPrice: signal.originalPrice,
currentPrice: currentPrice,
qualityScore: signal.qualityScore,
validationTime: (now - signal.blockedAt) / 1000,
priceChange: priceChange,
})
// Execute the trade
await this.executeTrade(signal, currentPrice)
} else if (priceChange <= signal.maxDrawdown) {
// Price moved down too much - ABANDON
signal.status = 'abandoned'
logger.log(`❌ LONG ABANDONED: ${signal.symbol} dropped ${priceChange.toFixed(2)}% ($${signal.originalPrice.toFixed(2)}$${currentPrice.toFixed(2)})`)
logger.log(` Saved from potential loser after ${ageMinutes.toFixed(1)} minutes`)
// Send Telegram notification
await sendValidationNotification({
event: 'abandoned',
symbol: signal.symbol,
direction: signal.direction,
originalPrice: signal.originalPrice,
currentPrice: currentPrice,
qualityScore: signal.qualityScore,
validationTime: (now - signal.blockedAt) / 1000,
priceChange: priceChange,
})
} else {
// Still pending, log progress
logger.log(`⏳ LONG watching: ${signal.symbol} at ${priceChange.toFixed(2)}% (need +${signal.confirmationThreshold}%, abandon at ${signal.maxDrawdown}%) - ${ageMinutes.toFixed(1)}min`)
}
} else {
// SHORT: Need price to move DOWN to confirm
if (priceChange <= -signal.confirmationThreshold) {
// Price moved down enough - CONFIRMED!
signal.status = 'confirmed'
signal.validatedAt = now
logger.log(`✅ SHORT CONFIRMED: ${signal.symbol} moved ${priceChange.toFixed(2)}% ($${signal.originalPrice.toFixed(2)}$${currentPrice.toFixed(2)})`)
logger.log(` Validation time: ${ageMinutes.toFixed(1)} minutes, executing trade...`)
// Send Telegram notification
await sendValidationNotification({
event: 'confirmed',
symbol: signal.symbol,
direction: signal.direction,
originalPrice: signal.originalPrice,
currentPrice: currentPrice,
qualityScore: signal.qualityScore,
validationTime: (now - signal.blockedAt) / 1000,
priceChange: priceChange,
})
// Execute the trade
await this.executeTrade(signal, currentPrice)
} else if (priceChange >= -signal.maxDrawdown) {
// Price moved up too much - ABANDON
signal.status = 'abandoned'
logger.log(`❌ SHORT ABANDONED: ${signal.symbol} rose +${priceChange.toFixed(2)}% ($${signal.originalPrice.toFixed(2)}$${currentPrice.toFixed(2)})`)
logger.log(` Saved from potential loser after ${ageMinutes.toFixed(1)} minutes`)
// Send Telegram notification
await sendValidationNotification({
event: 'abandoned',
symbol: signal.symbol,
direction: signal.direction,
originalPrice: signal.originalPrice,
currentPrice: currentPrice,
qualityScore: signal.qualityScore,
validationTime: (now - signal.blockedAt) / 1000,
priceChange: priceChange,
})
} else {
// Still pending, log progress
logger.log(`⏳ SHORT watching: ${signal.symbol} at ${priceChange.toFixed(2)}% (need ${-signal.confirmationThreshold}%, abandon at +${-signal.maxDrawdown}%) - ${ageMinutes.toFixed(1)}min`)
}
}
}
/**
* Execute validated trade via API
*/
private async executeTrade(signal: QueuedSignal, currentPrice: number): Promise<void> {
try {
// Call the execute endpoint with the validated signal
const executeUrl = 'http://localhost:3000/api/trading/execute'
const payload = {
symbol: signal.symbol,
direction: signal.direction,
signalPrice: currentPrice,
currentPrice: currentPrice,
timeframe: signal.originalSignalData.timeframe || '5',
atr: signal.originalSignalData.atr || 0,
adx: signal.originalSignalData.adx || 0,
rsi: signal.originalSignalData.rsi || 0,
volumeRatio: signal.originalSignalData.volumeRatio || 0,
pricePosition: signal.originalSignalData.pricePosition || 0,
indicatorVersion: signal.originalSignalData.indicatorVersion || 'v9',
validatedEntry: true, // Flag to indicate this is a validated entry
originalQualityScore: signal.qualityScore,
validationDelayMinutes: (Date.now() - signal.blockedAt) / (1000 * 60),
}
logger.log(`🚀 Executing validated trade: ${signal.symbol} ${signal.direction.toUpperCase()} @ $${currentPrice.toFixed(2)}`)
logger.log(` Original signal: $${signal.originalPrice.toFixed(2)}, Quality: ${signal.qualityScore}`)
logger.log(` Entry delay: ${payload.validationDelayMinutes.toFixed(1)} minutes`)
const response = await fetch(executeUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.API_SECRET_KEY}`,
},
body: JSON.stringify(payload),
})
const result = await response.json()
if (response.ok && result.success) {
signal.status = 'executed'
signal.executedAt = Date.now()
signal.executionPrice = currentPrice
signal.tradeId = result.trade?.id
logger.log(`✅ Trade executed successfully: ${signal.symbol} ${signal.direction}`)
logger.log(` Trade ID: ${signal.tradeId}`)
logger.log(` Entry: $${currentPrice.toFixed(2)}, Size: $${result.trade?.positionSizeUSD || 'unknown'}`)
// Send execution notification
const slippage = ((currentPrice - signal.originalPrice) / signal.originalPrice) * 100
await sendValidationNotification({
event: 'executed',
symbol: signal.symbol,
direction: signal.direction,
originalPrice: signal.originalPrice,
currentPrice: currentPrice,
qualityScore: signal.qualityScore,
validationTime: (signal.executedAt - signal.blockedAt) / 1000,
priceChange: slippage,
})
} else {
console.error(`❌ Trade execution failed: ${result.error || result.message}`)
signal.status = 'abandoned' // Mark as abandoned if execution fails
}
} catch (error) {
console.error(`❌ Error executing validated trade:`, error)
signal.status = 'abandoned'
}
}
/**
* Clean up old signals to prevent memory leak
*/
private cleanupOldSignals(): void {
const oneHourAgo = Date.now() - (60 * 60 * 1000)
let cleaned = 0
for (const [id, signal] of this.queue) {
if (signal.status !== 'pending' && signal.blockedAt < oneHourAgo) {
this.queue.delete(id)
cleaned++
}
}
if (cleaned > 0) {
logger.log(`🧹 Cleaned up ${cleaned} old validated signals`)
}
}
/**
* Get queue status
*/
getStatus(): {
pending: number
confirmed: number
abandoned: number
expired: number
executed: number
total: number
} {
const signals = Array.from(this.queue.values())
return {
pending: signals.filter(s => s.status === 'pending').length,
confirmed: signals.filter(s => s.status === 'confirmed').length,
abandoned: signals.filter(s => s.status === 'abandoned').length,
expired: signals.filter(s => s.status === 'expired').length,
executed: signals.filter(s => s.status === 'executed').length,
total: signals.length,
}
}
/**
* Get all queued signals (for debugging)
*/
getQueue(): QueuedSignal[] {
return Array.from(this.queue.values())
}
}
// Singleton instance
let queueInstance: SmartValidationQueue | null = null
export function getSmartValidationQueue(): SmartValidationQueue {
if (!queueInstance) {
queueInstance = new SmartValidationQueue()
}
return queueInstance
}
export async function startSmartValidation(): Promise<void> {
const queue = getSmartValidationQueue()
// CRITICAL FIX (Dec 9, 2025): Load pending signals from database on startup
// Bug: Queue is in-memory only, container restart loses all queued signals
// Solution: Query BlockedSignal table for signals within entry window
try {
const { getPrismaClient } = await import('../database/trades')
const prisma = getPrismaClient()
// Find signals blocked within last 30 minutes (entry window)
const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000)
const recentBlocked = await prisma.blockedSignal.findMany({
where: {
blockReason: 'QUALITY_SCORE_TOO_LOW',
signalQualityScore: { gte: 50, lt: 90 }, // Marginal quality range
createdAt: { gte: thirtyMinutesAgo },
},
orderBy: { createdAt: 'desc' },
})
console.log(`🔄 Restoring ${recentBlocked.length} pending signals from database`)
// Re-queue each signal
for (const signal of recentBlocked) {
await queue.addSignal({
blockReason: 'QUALITY_SCORE_TOO_LOW',
symbol: signal.symbol,
direction: signal.direction as 'long' | 'short',
originalPrice: signal.entryPrice,
qualityScore: signal.signalQualityScore || 0,
atr: signal.atr || undefined,
adx: signal.adx || undefined,
rsi: signal.rsi || undefined,
volumeRatio: signal.volumeRatio || undefined,
pricePosition: signal.pricePosition || undefined,
indicatorVersion: signal.indicatorVersion || 'v5',
timeframe: signal.timeframe || '5',
})
}
if (recentBlocked.length > 0) {
console.log(`✅ Smart validation restored ${recentBlocked.length} signals, monitoring started`)
} else {
console.log('🧠 Smart validation system ready (no pending signals)')
}
} catch (error) {
console.error('❌ Error restoring smart validation queue:', error)
console.log('🧠 Smart validation system ready (error loading signals)')
}
}