- Changed from getPythPriceMonitor() to initializeDriftService() - Uses getOraclePrice() with Drift market index - Skips signals with entryPrice = 0 - Initialize Drift service in trackPrices() before processing - Price tracking now working: priceAfter1Min/5Min/15Min/30Min fields populate - analysisComplete transitions to true after 30 minutes - wouldHitTP1/TP2/SL detection working (based on ATR targets) Bug: Pyth price cache didn't have SOL-PERP prices, tracker skipped all signals Fix: Drift oracle prices always available, tracker now functional Impact: Multi-timeframe data collection now operational for Phase 1 analysis
297 lines
9.1 KiB
TypeScript
297 lines
9.1 KiB
TypeScript
/**
|
||
* 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 intervals
|
||
* - TP1/TP2/SL hit detection using ATR-based targets
|
||
* - Max favorable/adverse excursion tracking
|
||
* - Automatic analysis completion after 30 minutes
|
||
* - Background job runs every 5 minutes
|
||
*/
|
||
|
||
import { getPrismaClient } from '../database/trades'
|
||
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
|
||
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) {
|
||
console.log('⚠️ Blocked signal tracker already running')
|
||
return
|
||
}
|
||
|
||
console.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
|
||
|
||
console.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
|
||
console.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) {
|
||
console.log('⚠️ Drift service not available, skipping price tracking')
|
||
return
|
||
}
|
||
|
||
// Get all incomplete signals from last 24 hours
|
||
const signals = await this.prisma.blockedSignal.findMany({
|
||
where: {
|
||
blockReason: 'DATA_COLLECTION_ONLY',
|
||
analysisComplete: false,
|
||
createdAt: {
|
||
gte: new Date(Date.now() - 24 * 60 * 60 * 1000) // Last 24 hours
|
||
}
|
||
},
|
||
orderBy: { createdAt: 'asc' }
|
||
})
|
||
|
||
if (signals.length === 0) {
|
||
console.log('📊 No blocked signals to track')
|
||
return
|
||
}
|
||
|
||
console.log(`📊 Tracking ${signals.length} blocked signals...`)
|
||
|
||
for (const signal of signals) {
|
||
await this.trackSignal(signal as any)
|
||
}
|
||
|
||
console.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) {
|
||
console.log(`⚠️ No market config for ${signal.symbol}, skipping`)
|
||
return
|
||
}
|
||
|
||
const currentPrice = await driftService.getOraclePrice(marketConfig.driftMarketIndex)
|
||
const entryPrice = Number(signal.entryPrice)
|
||
|
||
if (entryPrice === 0) {
|
||
console.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
|
||
console.log(` 📍 ${signal.symbol} ${signal.direction} @ 1min: $${currentPrice.toFixed(2)} (${profitPercent.toFixed(2)}%)`)
|
||
}
|
||
|
||
if (elapsedMinutes >= 5 && !signal.priceAfter5Min) {
|
||
updates.priceAfter5Min = currentPrice
|
||
console.log(` 📍 ${signal.symbol} ${signal.direction} @ 5min: $${currentPrice.toFixed(2)} (${profitPercent.toFixed(2)}%)`)
|
||
}
|
||
|
||
if (elapsedMinutes >= 15 && !signal.priceAfter15Min) {
|
||
updates.priceAfter15Min = currentPrice
|
||
console.log(` 📍 ${signal.symbol} ${signal.direction} @ 15min: $${currentPrice.toFixed(2)} (${profitPercent.toFixed(2)}%)`)
|
||
}
|
||
|
||
if (elapsedMinutes >= 30 && !signal.priceAfter30Min) {
|
||
updates.priceAfter30Min = currentPrice
|
||
updates.analysisComplete = true
|
||
console.log(` ✅ ${signal.symbol} ${signal.direction} @ 30min: $${currentPrice.toFixed(2)} (${profitPercent.toFixed(2)}%) - 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
|
||
console.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
|
||
console.log(` 🎯 ${signal.symbol} ${signal.direction} hit TP2 (${profitPercent.toFixed(2)}%)`)
|
||
}
|
||
|
||
if (signal.wouldHitSL === null && profitPercent <= -slPercent) {
|
||
updates.wouldHitSL = true
|
||
console.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 }
|
||
}
|
||
}
|
||
|
||
// 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()
|
||
}
|
||
}
|