Implemented comprehensive price tracking for multi-timeframe signal analysis. **Components Added:** - lib/analysis/blocked-signal-tracker.ts - Background job tracking prices - app/api/analytics/signal-tracking/route.ts - Status/metrics endpoint **Features:** - Automatic price tracking at 1min, 5min, 15min, 30min intervals - TP1/TP2/SL hit detection using ATR-based targets - Max favorable/adverse excursion tracking (MFE/MAE) - Analysis completion after 30 minutes - Background job runs every 5 minutes - Entry price captured from signal time **Database Changes:** - Added entryPrice field to BlockedSignal (for price tracking baseline) - Added maxFavorablePrice, maxAdversePrice fields - Added maxFavorableExcursion, maxAdverseExcursion fields **Integration:** - Auto-starts on container startup - Tracks all DATA_COLLECTION_ONLY signals - Uses same TP/SL calculation as live trades (ATR-based) - Calculates profit % based on direction (long vs short) **API Endpoints:** - GET /api/analytics/signal-tracking - View tracking status and metrics - POST /api/analytics/signal-tracking - Manually trigger update (auth required) **Purpose:** Enables data-driven multi-timeframe comparison. After 50+ signals per timeframe, can analyze which timeframe (5min vs 15min vs 1H vs 4H vs Daily) has best win rate, profit potential, and signal quality. **What It Tracks:** - Price at 1min, 5min, 15min, 30min after signal - Would TP1/TP2/SL have been hit? - Maximum profit/loss during 30min window - Complete analysis of signal profitability **How It Works:** 1. Signal comes in (15min, 1H, 4H, Daily) → saved to BlockedSignal 2. Background job runs every 5min 3. Queries current price from Pyth 4. Calculates profit % from entry 5. Checks if TP/SL thresholds crossed 6. Updates MFE/MAE if new highs/lows 7. After 30min, marks analysisComplete=true **Future Analysis:** After 50+ signals per timeframe: - Compare TP1 hit rates across timeframes - Identify which timeframe has highest win rate - Determine optimal signal frequency vs quality trade-off - Switch production to best-performing timeframe User requested: "i want all the bells and whistles. lets make the powerhouse more powerfull. i cant see any reason why we shouldnt"
285 lines
8.7 KiB
TypeScript
285 lines
8.7 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 { getPythPriceMonitor } from '../pyth/price-monitor'
|
||
import { getMergedConfig } 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 {
|
||
// 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
|
||
const priceMonitor = getPythPriceMonitor()
|
||
const latestPrice = priceMonitor.getCachedPrice(signal.symbol)
|
||
|
||
if (!latestPrice || !latestPrice.price) {
|
||
console.log(`⚠️ No price available for ${signal.symbol}, skipping`)
|
||
return
|
||
}
|
||
|
||
const currentPrice = latestPrice.price
|
||
const entryPrice = Number(signal.entryPrice)
|
||
|
||
// 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()
|
||
}
|
||
}
|