/** * Blocked Signal Price Tracker * * Automatically tracks price movements for blocked signals to determine * if they would have been profitable trades. This enables data-driven * multi-timeframe analysis. * * Features: * - Price tracking at 1min, 5min, 15min, 30min, 1hr, 2hr, 4hr, 8hr intervals * - TP1/TP2/SL hit detection using ATR-based targets * - Max favorable/adverse excursion tracking * - Automatic analysis completion after 8 hours or TP/SL hit * - Background job runs every 5 minutes * * EXTENDED TRACKING (Dec 2, 2025): * - Previously tracked for 30 minutes only (missed slow developers) * - Now tracks for 8 hours to capture low ADX signals that take 4+ hours * - User directive: "30 minutes...simply not long enough to know whats going to happen" * - Purpose: Accurate win rate data for quality 80-89 signals */ import { getPrismaClient } from '../database/trades' import { logger } from '../utils/logger' import { initializeDriftService } from '../drift/client' import { getMergedConfig, SUPPORTED_MARKETS } from '../../config/trading' interface BlockedSignalWithTracking { id: string symbol: string direction: 'long' | 'short' entryPrice: number atr: number adx: number createdAt: Date priceAfter1Min: number | null priceAfter5Min: number | null priceAfter15Min: number | null priceAfter30Min: number | null priceAfter1Hr: number | null priceAfter2Hr: number | null priceAfter4Hr: number | null priceAfter8Hr: number | null wouldHitTP1: boolean | null wouldHitTP2: boolean | null wouldHitSL: boolean | null maxFavorablePrice: number | null maxAdversePrice: number | null maxFavorableExcursion: number | null maxAdverseExcursion: number | null analysisComplete: boolean } export class BlockedSignalTracker { private prisma = getPrismaClient() private intervalId: NodeJS.Timeout | null = null private isRunning = false /** * Start the background tracking job * Runs every 5 minutes to update price data for blocked signals */ public start(): void { if (this.isRunning) { logger.log('âš ī¸ Blocked signal tracker already running') return } logger.log('đŸ”Ŧ Starting blocked signal price tracker...') this.isRunning = true // Run immediately on start this.trackPrices().catch(error => { console.error('❌ Error in initial price tracking:', error) }) // Then run every 5 minutes this.intervalId = setInterval(() => { this.trackPrices().catch(error => { console.error('❌ Error in price tracking:', error) }) }, 5 * 60 * 1000) // 5 minutes logger.log('✅ Blocked signal tracker started (runs every 5 minutes)') } /** * Stop the background tracking job */ public stop(): void { if (this.intervalId) { clearInterval(this.intervalId) this.intervalId = null } this.isRunning = false logger.log('âšī¸ Blocked signal tracker stopped') } /** * Main tracking logic - processes all incomplete blocked signals */ private async trackPrices(): Promise { try { // Initialize Drift service if needed const driftService = await initializeDriftService() if (!driftService) { logger.log('âš ī¸ Drift service not available, skipping price tracking') return } // Get all incomplete signals from last 48 hours (extended for 8hr tracking) // Track quality-blocked, data collection, AND smart validation queue signals const signals = await this.prisma.blockedSignal.findMany({ where: { blockReason: { in: ['DATA_COLLECTION_ONLY', 'QUALITY_SCORE_TOO_LOW', 'SMART_VALIDATION_QUEUED'] }, analysisComplete: false, createdAt: { gte: new Date(Date.now() - 48 * 60 * 60 * 1000) // Last 48 hours (8hr tracking + buffer) } }, orderBy: { createdAt: 'asc' } }) if (signals.length === 0) { logger.log('📊 No blocked signals to track') return } logger.log(`📊 Tracking ${signals.length} blocked signals...`) for (const signal of signals) { await this.trackSignal(signal as any) } logger.log(`✅ Price tracking complete for ${signals.length} signals`) } catch (error) { console.error('❌ Error in trackPrices:', error) } } /** * Track a single blocked signal */ private async trackSignal(signal: BlockedSignalWithTracking): Promise { try { const now = Date.now() const signalTime = signal.createdAt.getTime() const elapsedMinutes = (now - signalTime) / (60 * 1000) // Get current price from Drift oracle const driftService = await initializeDriftService() const marketConfig = SUPPORTED_MARKETS[signal.symbol] if (!marketConfig) { logger.log(`âš ī¸ No market config for ${signal.symbol}, skipping`) return } const currentPrice = await driftService.getOraclePrice(marketConfig.driftMarketIndex) const entryPrice = Number(signal.entryPrice) if (entryPrice === 0) { logger.log(`âš ī¸ Entry price is 0 for ${signal.symbol}, skipping`) return } // Calculate profit percentage const profitPercent = this.calculateProfitPercent( entryPrice, currentPrice, signal.direction ) // Calculate TP/SL levels using ATR const config = getMergedConfig() const { tp1Percent, tp2Percent, slPercent } = this.calculateTargets( Number(signal.atr), entryPrice, config ) // Update prices at intervals const updates: any = {} if (elapsedMinutes >= 1 && !signal.priceAfter1Min) { updates.priceAfter1Min = currentPrice logger.log(` 📍 ${signal.symbol} ${signal.direction} @ 1min: $${currentPrice.toFixed(2)} (${profitPercent.toFixed(2)}%)`) } if (elapsedMinutes >= 5 && !signal.priceAfter5Min) { updates.priceAfter5Min = currentPrice logger.log(` 📍 ${signal.symbol} ${signal.direction} @ 5min: $${currentPrice.toFixed(2)} (${profitPercent.toFixed(2)}%)`) } if (elapsedMinutes >= 15 && !signal.priceAfter15Min) { updates.priceAfter15Min = currentPrice logger.log(` 📍 ${signal.symbol} ${signal.direction} @ 15min: $${currentPrice.toFixed(2)} (${profitPercent.toFixed(2)}%)`) } if (elapsedMinutes >= 30 && !signal.priceAfter30Min) { updates.priceAfter30Min = currentPrice logger.log(` 📍 ${signal.symbol} ${signal.direction} @ 30min: $${currentPrice.toFixed(2)} (${profitPercent.toFixed(2)}%)`) } // EXTENDED TRACKING (Dec 2, 2025): Track up to 8 hours for slow developers if (elapsedMinutes >= 60 && !signal.priceAfter1Hr) { updates.priceAfter1Hr = currentPrice logger.log(` 📍 ${signal.symbol} ${signal.direction} @ 1hr: $${currentPrice.toFixed(2)} (${profitPercent.toFixed(2)}%)`) } if (elapsedMinutes >= 120 && !signal.priceAfter2Hr) { updates.priceAfter2Hr = currentPrice logger.log(` 📍 ${signal.symbol} ${signal.direction} @ 2hr: $${currentPrice.toFixed(2)} (${profitPercent.toFixed(2)}%)`) } if (elapsedMinutes >= 240 && !signal.priceAfter4Hr) { updates.priceAfter4Hr = currentPrice logger.log(` 📍 ${signal.symbol} ${signal.direction} @ 4hr: $${currentPrice.toFixed(2)} (${profitPercent.toFixed(2)}%)`) } if (elapsedMinutes >= 480 && !signal.priceAfter8Hr) { updates.priceAfter8Hr = currentPrice logger.log(` 📍 ${signal.symbol} ${signal.direction} @ 8hr: $${currentPrice.toFixed(2)} (${profitPercent.toFixed(2)}%)`) } // Mark complete after 8 hours OR if TP/SL already hit if (elapsedMinutes >= 480 && !signal.analysisComplete) { updates.analysisComplete = true logger.log(` ✅ ${signal.symbol} ${signal.direction} @ 8hr: TRACKING COMPLETE`) } // Early completion if TP1/TP2/SL hit (no need to wait full 8 hours) if (!signal.analysisComplete && (signal.wouldHitTP1 || signal.wouldHitTP2 || signal.wouldHitSL)) { updates.analysisComplete = true const hitReason = signal.wouldHitTP1 ? 'TP1' : signal.wouldHitTP2 ? 'TP2' : 'SL' logger.log(` ✅ ${signal.symbol} ${signal.direction}: ${hitReason} hit at ${profitPercent.toFixed(2)}% - TRACKING COMPLETE`) } // Update max favorable/adverse excursion const currentMFE = signal.maxFavorableExcursion || 0 const currentMAE = signal.maxAdverseExcursion || 0 if (profitPercent > currentMFE) { updates.maxFavorableExcursion = profitPercent updates.maxFavorablePrice = currentPrice } if (profitPercent < currentMAE) { updates.maxAdverseExcursion = profitPercent updates.maxAdversePrice = currentPrice } // Check if TP1/TP2/SL would have been hit if (signal.wouldHitTP1 === null && Math.abs(profitPercent) >= tp1Percent) { updates.wouldHitTP1 = profitPercent > 0 logger.log(` đŸŽ¯ ${signal.symbol} ${signal.direction} hit ${profitPercent > 0 ? 'TP1' : 'SL'} (${profitPercent.toFixed(2)}%)`) } if (signal.wouldHitTP2 === null && Math.abs(profitPercent) >= tp2Percent) { updates.wouldHitTP2 = profitPercent > 0 logger.log(` đŸŽ¯ ${signal.symbol} ${signal.direction} hit TP2 (${profitPercent.toFixed(2)}%)`) } if (signal.wouldHitSL === null && profitPercent <= -slPercent) { updates.wouldHitSL = true logger.log(` 🛑 ${signal.symbol} ${signal.direction} hit SL (${profitPercent.toFixed(2)}%)`) } // Update database if we have changes if (Object.keys(updates).length > 0) { await this.prisma.blockedSignal.update({ where: { id: signal.id }, data: updates }) } } catch (error) { console.error(`❌ Error tracking signal ${signal.id}:`, error) } } /** * Calculate profit percentage based on direction */ private calculateProfitPercent( entryPrice: number, currentPrice: number, direction: 'long' | 'short' ): number { if (direction === 'long') { return ((currentPrice - entryPrice) / entryPrice) * 100 } else { return ((entryPrice - currentPrice) / entryPrice) * 100 } } /** * Calculate TP/SL targets using ATR */ private calculateTargets( atr: number, entryPrice: number, config: any ): { tp1Percent: number; tp2Percent: number; slPercent: number } { // ATR as percentage of price const atrPercent = (atr / entryPrice) * 100 // TP1: ATR × 2.0 multiplier let tp1Percent = atrPercent * config.atrMultiplierTp1 tp1Percent = Math.max(config.minTp1Percent, Math.min(config.maxTp1Percent, tp1Percent)) // TP2: ATR × 4.0 multiplier let tp2Percent = atrPercent * config.atrMultiplierTp2 tp2Percent = Math.max(config.minTp2Percent, Math.min(config.maxTp2Percent, tp2Percent)) // SL: ATR × 3.0 multiplier let slPercent = atrPercent * config.atrMultiplierSl slPercent = Math.max(config.minSlPercent, Math.min(config.maxSlPercent, slPercent)) return { tp1Percent, tp2Percent, slPercent } } /** * Query all 1-minute price data for a signal's tracking window * Purpose: Get minute-by-minute granular data instead of 8 polling checkpoints * Returns: Array of MarketData objects with price, timestamp, ATR, ADX, etc. */ private async getHistoricalPrices( symbol: string, startTime: Date, endTime: Date ): Promise { try { const marketData = await this.prisma.marketData.findMany({ where: { symbol, timeframe: '1', // 1-minute data timestamp: { gte: startTime, lte: endTime } }, orderBy: { timestamp: 'asc' // Chronological order } }) logger.log(`📊 Retrieved ${marketData.length} 1-minute data points for ${symbol}`) return marketData } catch (error) { console.error('❌ Error querying historical prices:', error) return [] } } /** * Analyze minute-by-minute data to find EXACT timing of TP/SL hits * Purpose: Replace 8 polling checkpoints with 480 data point analysis * Algorithm: * 1. Calculate TP1/TP2/SL target prices * 2. Loop through all 1-minute data points: * - Calculate profit % for each minute * - Check if TP1/TP2/SL hit (first time only) * - Record exact timestamp when hit * - Track max favorable/adverse prices * 3. Return updates object with all findings */ private async analyzeHistoricalData( signal: BlockedSignalWithTracking, historicalPrices: any[], config: any ): Promise { const updates: any = {} const entryPrice = Number(signal.entryPrice) const direction = signal.direction // Calculate TP/SL targets using ATR const targets = this.calculateTargets(signal.atr || 0, entryPrice, config) // Calculate actual target prices based on direction let tp1Price: number, tp2Price: number, slPrice: number if (direction === 'long') { tp1Price = entryPrice * (1 + targets.tp1Percent / 100) tp2Price = entryPrice * (1 + targets.tp2Percent / 100) slPrice = entryPrice * (1 - targets.slPercent / 100) } else { tp1Price = entryPrice * (1 - targets.tp1Percent / 100) tp2Price = entryPrice * (1 - targets.tp2Percent / 100) slPrice = entryPrice * (1 + targets.slPercent / 100) } logger.log(`đŸŽ¯ Analyzing ${signal.symbol} ${direction}: Entry $${entryPrice.toFixed(2)}, TP1 $${tp1Price.toFixed(2)}, TP2 $${tp2Price.toFixed(2)}, SL $${slPrice.toFixed(2)}`) // Track hits (only record first occurrence) let tp1HitTime: Date | null = null let tp2HitTime: Date | null = null let slHitTime: Date | null = null // Track max favorable/adverse let maxFavorablePrice = entryPrice let maxAdversePrice = entryPrice let maxFavorableExcursion = 0 let maxAdverseExcursion = 0 // Checkpoint tracking (for comparison with old system) const checkpoints = { '1min': null as number | null, '5min': null as number | null, '15min': null as number | null, '30min': null as number | null, '1hr': null as number | null, '2hr': null as number | null, '4hr': null as number | null, '8hr': null as number | null } // Process each 1-minute data point for (const dataPoint of historicalPrices) { const currentPrice = Number(dataPoint.price) const timestamp = new Date(dataPoint.timestamp) const minutesElapsed = Math.floor((timestamp.getTime() - signal.createdAt.getTime()) / 60000) // Calculate profit percentage const profitPercent = this.calculateProfitPercent(entryPrice, currentPrice, direction) // Track max favorable/adverse if (profitPercent > maxFavorableExcursion) { maxFavorableExcursion = profitPercent maxFavorablePrice = currentPrice } if (profitPercent < maxAdverseExcursion) { maxAdverseExcursion = profitPercent maxAdversePrice = currentPrice } // Check for TP1 hit (first time only) if (!tp1HitTime) { const tp1Hit = direction === 'long' ? currentPrice >= tp1Price : currentPrice <= tp1Price if (tp1Hit) { tp1HitTime = timestamp logger.log(`✅ TP1 hit at ${timestamp.toISOString()} (${minutesElapsed}min) - Price: $${currentPrice.toFixed(2)}`) } } // Check for TP2 hit (first time only) if (!tp2HitTime) { const tp2Hit = direction === 'long' ? currentPrice >= tp2Price : currentPrice <= tp2Price if (tp2Hit) { tp2HitTime = timestamp logger.log(`✅ TP2 hit at ${timestamp.toISOString()} (${minutesElapsed}min) - Price: $${currentPrice.toFixed(2)}`) } } // Check for SL hit (first time only) if (!slHitTime) { const slHit = direction === 'long' ? currentPrice <= slPrice : currentPrice >= slPrice if (slHit) { slHitTime = timestamp logger.log(`❌ SL hit at ${timestamp.toISOString()} (${minutesElapsed}min) - Price: $${currentPrice.toFixed(2)}`) } } // Record checkpoint prices (for comparison) if (minutesElapsed >= 1 && !checkpoints['1min']) checkpoints['1min'] = currentPrice if (minutesElapsed >= 5 && !checkpoints['5min']) checkpoints['5min'] = currentPrice if (minutesElapsed >= 15 && !checkpoints['15min']) checkpoints['15min'] = currentPrice if (minutesElapsed >= 30 && !checkpoints['30min']) checkpoints['30min'] = currentPrice if (minutesElapsed >= 60 && !checkpoints['1hr']) checkpoints['1hr'] = currentPrice if (minutesElapsed >= 120 && !checkpoints['2hr']) checkpoints['2hr'] = currentPrice if (minutesElapsed >= 240 && !checkpoints['4hr']) checkpoints['4hr'] = currentPrice if (minutesElapsed >= 480 && !checkpoints['8hr']) checkpoints['8hr'] = currentPrice } // Build updates object with findings updates.wouldHitTP1 = tp1HitTime !== null updates.wouldHitTP2 = tp2HitTime !== null updates.wouldHitSL = slHitTime !== null // CRITICAL: Store exact timestamps (minute precision) if (tp1HitTime) updates.tp1HitTime = tp1HitTime if (tp2HitTime) updates.tp2HitTime = tp2HitTime if (slHitTime) updates.slHitTime = slHitTime // Store max favorable/adverse updates.maxFavorablePrice = maxFavorablePrice updates.maxAdversePrice = maxAdversePrice updates.maxFavorableExcursion = maxFavorableExcursion updates.maxAdverseExcursion = maxAdverseExcursion // Store checkpoint prices (for comparison with old system) if (checkpoints['1min']) updates.priceAfter1Min = checkpoints['1min'] if (checkpoints['5min']) updates.priceAfter5Min = checkpoints['5min'] if (checkpoints['15min']) updates.priceAfter15Min = checkpoints['15min'] if (checkpoints['30min']) updates.priceAfter30Min = checkpoints['30min'] if (checkpoints['1hr']) updates.priceAfter1Hr = checkpoints['1hr'] if (checkpoints['2hr']) updates.priceAfter2Hr = checkpoints['2hr'] if (checkpoints['4hr']) updates.priceAfter4Hr = checkpoints['4hr'] if (checkpoints['8hr']) updates.priceAfter8Hr = checkpoints['8hr'] logger.log(`📊 Analysis complete: TP1=${updates.wouldHitTP1}, TP2=${updates.wouldHitTP2}, SL=${updates.wouldHitSL}, MFE=${maxFavorableExcursion.toFixed(2)}%, MAE=${maxAdverseExcursion.toFixed(2)}%`) return updates } /** * ONE-TIME BATCH PROCESSING: Process all signals with historical data * Purpose: Analyze completed tracking windows using collected MarketData * Algorithm: * 1. Find signals NOT yet analyzed (analysisComplete = false) * 2. Check if enough historical data exists (8 hours or TP/SL hit) * 3. Query MarketData for signal's time window * 4. Run minute-precision analysis * 5. Update database with exact TP/SL timing * * This replaces the polling approach with batch historical analysis */ async processCompletedSignals(): Promise { try { const config = getMergedConfig() // Find signals ready for batch processing // CRITICAL FIX (Dec 2, 2025): Changed from 30min to 1min // Rationale: We collect 1-minute data, so use it! No reason to wait longer. const oneMinuteAgo = new Date(Date.now() - 1 * 60 * 1000) const signalsToProcess = await this.prisma.blockedSignal.findMany({ where: { analysisComplete: false, createdAt: { lte: oneMinuteAgo // At least 1 minute old (we have 1-min data!) }, blockReason: { in: ['DATA_COLLECTION_ONLY', 'QUALITY_SCORE_TOO_LOW'] } }, orderBy: { createdAt: 'asc' } }) if (signalsToProcess.length === 0) { logger.log('📊 No signals ready for batch processing') return } logger.log(`🔄 Processing ${signalsToProcess.length} signals with historical data...`) let processed = 0 let skipped = 0 for (const signal of signalsToProcess) { try { // Define 8-hour tracking window const startTime = signal.createdAt const endTime = new Date(startTime.getTime() + 8 * 60 * 60 * 1000) // Query historical 1-minute data const historicalPrices = await this.getHistoricalPrices( signal.symbol, startTime, endTime ) if (historicalPrices.length === 0) { logger.log(`â­ī¸ Skipping ${signal.symbol} ${signal.direction} - no historical data`) skipped++ continue } logger.log(`📊 Processing ${signal.symbol} ${signal.direction} with ${historicalPrices.length} data points...`) // Analyze minute-by-minute const updates = await this.analyzeHistoricalData( signal as any, // Database model has more fields than interface historicalPrices, config ) // Mark as complete updates.analysisComplete = true // Update database with findings await this.prisma.blockedSignal.update({ where: { id: signal.id }, data: updates }) processed++ logger.log(`✅ ${signal.symbol} ${signal.direction} analyzed successfully`) } catch (error) { console.error(`❌ Error processing signal ${signal.id}:`, error) skipped++ } } logger.log(`🎉 Batch processing complete: ${processed} analyzed, ${skipped} skipped`) } catch (error) { console.error('❌ Error in batch processing:', error) } } } // Singleton instance let trackerInstance: BlockedSignalTracker | null = null export function getBlockedSignalTracker(): BlockedSignalTracker { if (!trackerInstance) { trackerInstance = new BlockedSignalTracker() } return trackerInstance } export function startBlockedSignalTracking(): void { const tracker = getBlockedSignalTracker() tracker.start() } export function stopBlockedSignalTracking(): void { if (trackerInstance) { trackerInstance.stop() } }