Files
trading_bot_v4/lib/analysis/blocked-signal-tracker.ts
mindesbunister 0310b14f24 fix: Enable BlockedSignalTracker for SMART_VALIDATION_QUEUED signals
- Added 'SMART_VALIDATION_QUEUED' to blockReason filter (line 115)
- Enables price tracking and outcome analysis for validation queue
- Will track MFE/MAE, TP1/TP2/SL hits for queued signals
- Purpose: Collect data to justify adaptive threshold calculations
- Affects 10 existing + all future validation queue signals
2025-12-17 14:00:11 +01:00

631 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<void> {
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<void> {
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<any[]> {
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<any> {
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<void> {
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()
}
}