/** * Stop Hunt Revenge Tracker (Nov 20, 2025) * * Tracks high-quality stop-outs (score 85+) and automatically re-enters * when the stop hunt reverses and the real move begins. * * How it works: * 1. When quality 85+ trade gets stopped out → Create StopHunt record * 2. Monitor price for 4 hours * 3. If price crosses back through original entry + ADX rebuilds → Auto re-enter with 1.2x size * 4. Send "🔥 REVENGE TRADE" notification * * Purpose: Catch the real move after getting swept by stop hunters */ import { getPrismaClient } from '../database/trades' import { logger } from '../utils/logger' import { initializeDriftService } from '../drift/client' import { getPythPriceMonitor } from '../pyth/price-monitor' interface StopHuntRecord { id: string originalTradeId: string symbol: string direction: 'long' | 'short' stopHuntPrice: number originalEntryPrice: number originalQualityScore: number originalADX: number | null originalATR: number | null stopLossAmount: number stopHuntTime: Date revengeExecuted: boolean revengeWindowExpired: boolean revengeExpiresAt: Date highestPriceAfterStop: number | null lowestPriceAfterStop: number | null // Zone tracking persistence (Enhancement #10) firstCrossTime: Date | null lowestInZone: number | null highestInZone: number | null zoneResetCount: number // Revenge outcome tracking (Enhancement #4) revengeOutcome: string | null revengePnL: number | null revengeFailedReason: string | null } let trackerInstance: StopHuntTracker | null = null let monitoringInterval: NodeJS.Timeout | null = null export class StopHuntTracker { private prisma = getPrismaClient() private isMonitoring = false /** * Create stop hunt record when quality 85+ trade gets stopped out */ async recordStopHunt(params: { originalTradeId: string symbol: string direction: 'long' | 'short' stopHuntPrice: number originalEntryPrice: number originalQualityScore: number originalADX?: number originalATR?: number stopLossAmount: number }): Promise { // Only track quality 85+ stop-outs (high-confidence trades) if (params.originalQualityScore < 85) { logger.log(`⚠️ Stop hunt not tracked: Quality ${params.originalQualityScore} < 85 threshold`) return } const revengeExpiresAt = new Date(Date.now() + 4 * 60 * 60 * 1000) // 4 hours try { await this.prisma.stopHunt.create({ data: { originalTradeId: params.originalTradeId, symbol: params.symbol, direction: params.direction, stopHuntPrice: params.stopHuntPrice, originalEntryPrice: params.originalEntryPrice, originalQualityScore: params.originalQualityScore, originalADX: params.originalADX || null, originalATR: params.originalATR || null, stopLossAmount: params.stopLossAmount, stopHuntTime: new Date(), revengeExpiresAt, } }) logger.log(`🎯 STOP HUNT RECORDED: ${params.symbol} ${params.direction.toUpperCase()}`) logger.log(` Quality: ${params.originalQualityScore}, Loss: $${params.stopLossAmount.toFixed(2)}`) logger.log(` Revenge window: 4 hours (expires ${revengeExpiresAt.toLocaleTimeString()})`) // Start monitoring if not already running if (!this.isMonitoring) { this.startMonitoring() } } catch (error) { console.error('❌ Failed to record stop hunt:', error) } } /** * Start monitoring active stop hunts for revenge opportunities */ startMonitoring(): void { if (this.isMonitoring) return this.isMonitoring = true logger.log('🔍 Stop Hunt Revenge Tracker: Monitoring started') // Check every 30 seconds monitoringInterval = setInterval(async () => { await this.checkRevengeOpportunities() }, 30 * 1000) } /** * Stop monitoring (cleanup on shutdown) */ stopMonitoring(): void { if (monitoringInterval) { clearInterval(monitoringInterval) monitoringInterval = null } this.isMonitoring = false logger.log('🛑 Stop Hunt Revenge Tracker: Monitoring stopped') } /** * Check all active stop hunts for revenge entry conditions */ private async checkRevengeOpportunities(): Promise { try { // Get active stop hunts (not executed, not expired) const activeStopHunts = await this.prisma.stopHunt.findMany({ where: { revengeExecuted: false, revengeWindowExpired: false, revengeExpiresAt: { gt: new Date() // Not expired yet } } }) if (activeStopHunts.length === 0) { // No active stop hunts, stop monitoring to save resources if (this.isMonitoring) { console.log('📊 No active stop hunts - pausing monitoring') this.stopMonitoring() } return } logger.log(`🔍 Checking ${activeStopHunts.length} active stop hunt(s)...`) for (const stopHunt of activeStopHunts) { await this.checkStopHunt(stopHunt as StopHuntRecord) } // Expire old stop hunts await this.expireOldStopHunts() } catch (error) { console.error('❌ Error checking revenge opportunities:', error) } } /** * Check individual stop hunt for revenge entry * * ENHANCED (Nov 26, 2025): "Wait for next candle" approach * - Don't enter immediately when price crosses entry * - Wait for confirmation: candle CLOSE below/above entry * - This avoids entering on wicks that get retested * - Example: Entry $136.32, price wicks to $136.20 then bounces to $137.50 * Old system: Enters $136.32, stops at $137.96, loses again * New system: Waits for CLOSE below $136.32, enters more safely */ private async checkStopHunt(stopHunt: StopHuntRecord): Promise { try { // Get current price const priceMonitor = getPythPriceMonitor() const latestPrice = priceMonitor.getCachedPrice(stopHunt.symbol) if (!latestPrice || !latestPrice.price) { return // Price not available, skip } const currentPrice = latestPrice.price // Update high/low tracking const highestPrice = Math.max(currentPrice, stopHunt.highestPriceAfterStop || currentPrice) const lowestPrice = Math.min(currentPrice, stopHunt.lowestPriceAfterStop || currentPrice) await this.prisma.stopHunt.update({ where: { id: stopHunt.id }, data: { highestPriceAfterStop: highestPrice, lowestPriceAfterStop: lowestPrice, } }) // Check revenge conditions (now requires sustained move, not just wick) const shouldRevenge = await this.shouldExecuteRevenge(stopHunt, currentPrice) if (shouldRevenge) { logger.log(`🔥 REVENGE CONDITIONS MET: ${stopHunt.symbol} ${stopHunt.direction.toUpperCase()}`) await this.executeRevengeTrade(stopHunt, currentPrice) } } catch (error) { console.error(`❌ Error checking stop hunt ${stopHunt.id}:`, error) } } /** * Determine if revenge entry conditions are met * * ENHANCED (Nov 27, 2025): Database-persisted zone tracking * - OLD: In-memory metadata lost on container restart * - NEW: Persists firstCrossTime to database, survives restarts * - Tracks zone entry/exit behavior for analysis * * ENHANCED (Nov 26, 2025): Candle close confirmation * - OLD: Enters immediately when price crosses entry (gets stopped by retest) * - NEW: Requires price to STAY below/above entry for 90+ seconds * - This simulates "candle close" confirmation without needing TradingView data * - Prevents entering on wicks that bounce back * * Real-world validation (Nov 26): * - Original SHORT entry: $136.32, stopped at $138.00 * - Price wicked to $136.20 then bounced to $137.50 * - OLD system: Would enter $136.32, stop at $137.96, LOSE AGAIN * - NEW system: Requires price below $136.32 for 90s before entry * - Result: Enters safely after confirmation, rides to $144.50 (+$530!) */ private async shouldExecuteRevenge(stopHunt: StopHuntRecord, currentPrice: number): Promise { const { direction, stopHuntPrice, originalEntryPrice } = stopHunt const now = Date.now() if (direction === 'long') { // Long stopped out above entry → Revenge when price drops back below entry const crossedBackDown = currentPrice < originalEntryPrice * 0.995 // 0.5% buffer if (crossedBackDown) { // Price is in revenge zone - persist to database if (!stopHunt.firstCrossTime) { await this.prisma.stopHunt.update({ where: { id: stopHunt.id }, data: { firstCrossTime: new Date(), lowestInZone: currentPrice, } }) logger.log(` ⏱️ LONG revenge zone entered at ${currentPrice.toFixed(2)}, waiting for 90s confirmation...`) return false } // Update lowest price in zone const currentLowest = Math.min(stopHunt.lowestInZone || currentPrice, currentPrice) await this.prisma.stopHunt.update({ where: { id: stopHunt.id }, data: { lowestInZone: currentLowest } }) // Check if we've been in zone for 90+ seconds (1.5 minutes) const timeInZone = now - stopHunt.firstCrossTime.getTime() if (timeInZone >= 90000) { // 90 seconds = 1.5 minutes logger.log(` ✅ LONG revenge: Price held below entry for ${(timeInZone/60000).toFixed(1)}min, confirmed!`) logger.log(` Entry ${originalEntryPrice.toFixed(2)} → Current ${currentPrice.toFixed(2)}`) return true } else { logger.log(` ⏱️ LONG revenge: ${(timeInZone/60000).toFixed(1)}min in zone (need 1.5min)`) return false } } else { // Price left revenge zone - reset timer and increment counter if (stopHunt.firstCrossTime) { logger.log(` ❌ LONG revenge: Price bounced back up to ${currentPrice.toFixed(2)}, resetting timer`) await this.prisma.stopHunt.update({ where: { id: stopHunt.id }, data: { firstCrossTime: null, lowestInZone: null, zoneResetCount: { increment: 1 } } }) } return false } } else { // Short stopped out below entry → Revenge when price rises back above entry const crossedBackUp = currentPrice > originalEntryPrice * 1.005 // 0.5% buffer if (crossedBackUp) { // Price is in revenge zone - persist to database if (!stopHunt.firstCrossTime) { await this.prisma.stopHunt.update({ where: { id: stopHunt.id }, data: { firstCrossTime: new Date(), highestInZone: currentPrice, } }) logger.log(` ⏱️ SHORT revenge zone entered at ${currentPrice.toFixed(2)}, waiting for 90s confirmation...`) return false } // Update highest price in zone const currentHighest = Math.max(stopHunt.highestInZone || currentPrice, currentPrice) await this.prisma.stopHunt.update({ where: { id: stopHunt.id }, data: { highestInZone: currentHighest } }) // Check if we've been in zone for 90+ seconds (1.5 minutes) const timeInZone = now - stopHunt.firstCrossTime.getTime() if (timeInZone >= 90000) { // 90 seconds = 1.5 minutes logger.log(` ✅ SHORT revenge: Price held above entry for ${(timeInZone/60000).toFixed(1)}min, confirmed!`) logger.log(` Entry ${originalEntryPrice.toFixed(2)} → Current ${currentPrice.toFixed(2)}`) return true } else { logger.log(` ⏱️ SHORT revenge: ${(timeInZone/60000).toFixed(1)}min in zone (need 1.5min)`) return false } } else { // Price left revenge zone - reset timer and increment counter if (stopHunt.firstCrossTime) { logger.log(` ❌ SHORT revenge: Price dropped back to ${currentPrice.toFixed(2)}, resetting timer`) await this.prisma.stopHunt.update({ where: { id: stopHunt.id }, data: { firstCrossTime: null, highestInZone: null, zoneResetCount: { increment: 1 } } }) } return false } } } /** * Execute revenge trade automatically */ private async executeRevengeTrade(stopHunt: StopHuntRecord, currentPrice: number): Promise { try { logger.log(`🔥 EXECUTING REVENGE TRADE: ${stopHunt.symbol} ${stopHunt.direction.toUpperCase()}`) logger.log(` Original loss: $${stopHunt.stopLossAmount.toFixed(2)}`) logger.log(` Revenge size: 1.2x (getting our money back!)`) // CRITICAL: Validate current ADX from 1-minute data cache // Block revenge if trend has weakened (ADX < 20) const { getMarketDataCache } = await import('../trading/market-data-cache') const cache = getMarketDataCache() const cachedData = cache.get(stopHunt.symbol) if (cachedData && cachedData.adx !== undefined) { const currentADX = cachedData.adx const dataAge = Date.now() - cachedData.timestamp logger.log(` 📊 Fresh ADX check: ${currentADX.toFixed(1)} (${(dataAge/1000).toFixed(0)}s old)`) if (currentADX < 20) { logger.log(` ❌ REVENGE BLOCKED: ADX ${currentADX.toFixed(1)} < 20 (weak trend, not worth re-entry)`) // Update database with failed reason await this.prisma.stopHunt.update({ where: { id: stopHunt.id }, data: { revengeExecuted: true, revengeFailedReason: `ADX_TOO_LOW_${currentADX.toFixed(1)}` } }) // Send Telegram notification about blocked revenge const { sendTelegramMessage } = await import('../notifications/telegram') await sendTelegramMessage( `🚫 REVENGE BLOCKED - Weak Trend\n\n` + `${stopHunt.symbol} ${stopHunt.direction.toUpperCase()}\n` + `Original Quality: ${stopHunt.originalQualityScore}\n` + `Entry would be: $${currentPrice.toFixed(2)}\n\n` + `❌ Current ADX: ${currentADX.toFixed(1)} < 20\n` + `Trend too weak for revenge re-entry\n` + `Protecting capital ✓` ) return } logger.log(` ✅ ADX validation passed: ${currentADX.toFixed(1)} ≥ 20 (strong trend)`) } else { logger.log(` ⚠️ No fresh ADX data (cache age: ${cachedData ? (Date.now() - cachedData.timestamp)/1000 : 'N/A'}s)`) logger.log(` ⚠️ Proceeding with revenge but using original ADX ${stopHunt.originalADX}`) } // Call execute endpoint with revenge parameters const response = await fetch('http://localhost:3000/api/trading/execute', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.API_SECRET_KEY}` }, body: JSON.stringify({ symbol: stopHunt.symbol, direction: stopHunt.direction, currentPrice, timeframe: 'revenge', // Special timeframe for revenge trades signalSource: 'stop_hunt_revenge', // Use original quality metrics atr: stopHunt.originalATR || 0.45, adx: stopHunt.originalADX || 32, rsi: stopHunt.direction === 'long' ? 58 : 42, volumeRatio: 1.2, pricePosition: 50, // Metadata revengeMetadata: { originalTradeId: stopHunt.originalTradeId, stopHuntId: stopHunt.id, originalLoss: stopHunt.stopLossAmount, sizingMultiplier: 1.0 // Same size as original (user at 100% allocation) } }) }) const result = await response.json() if (result.success) { // Calculate SL distance at entry (for Enhancement #6 analysis) const slDistance = stopHunt.direction === 'long' ? currentPrice - stopHunt.stopHuntPrice // LONG: Room below entry : stopHunt.stopHuntPrice - currentPrice // SHORT: Room above entry // Mark revenge as executed await this.prisma.stopHunt.update({ where: { id: stopHunt.id }, data: { revengeExecuted: true, revengeTradeId: result.trade?.id, revengeEntryPrice: currentPrice, revengeTime: new Date(), slDistanceAtEntry: Math.abs(slDistance), // Store absolute distance } }) logger.log(`✅ REVENGE TRADE EXECUTED: ${result.trade?.id}`) logger.log(`📊 SL Distance: $${Math.abs(slDistance).toFixed(2)} (${stopHunt.originalATR ? `${(Math.abs(slDistance) / stopHunt.originalATR).toFixed(2)}× ATR` : 'no ATR'})`) logger.log(`🔥 LET'S GET OUR MONEY BACK!`) // Send special Telegram notification await this.sendRevengeNotification(stopHunt, result.trade) } else { console.error(`❌ Revenge trade failed:`, result.error) } } catch (error) { console.error(`❌ Error executing revenge trade:`, error) } } /** * Send special Telegram notification for revenge trades */ private async sendRevengeNotification(stopHunt: StopHuntRecord, trade: any): Promise { try { const message = ` 🔥 REVENGE TRADE ACTIVATED 🔥 ${stopHunt.symbol} ${stopHunt.direction.toUpperCase()} 💀 Original Stop Hunt: -$${stopHunt.stopLossAmount.toFixed(2)} 🎯 Revenge Entry: $${trade.entryPrice.toFixed(2)} 💪 Position Size: $${trade.positionSizeUSD.toFixed(2)} (same as original) ⚔️ TIME FOR PAYBACK! Original Quality: ${stopHunt.originalQualityScore}/100 Stop Hunt Price: $${stopHunt.stopHuntPrice.toFixed(4)} Reversal Confirmed: Price crossed back through entry Let's get our money back! 💰 `.trim() await fetch(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id: process.env.TELEGRAM_CHAT_ID, text: message, parse_mode: 'HTML' }) }) } catch (error) { console.error('❌ Failed to send revenge notification:', error) } } /** * Update revenge trade outcome when it closes * Called by Position Manager when revenge trade exits */ async updateRevengeOutcome(params: { revengeTradeId: string outcome: string // "TP1", "TP2", "SL", "TRAILING_SL" pnl: number failedReason?: string }): Promise { try { // Find stop hunt by revenge trade ID const stopHunt = await this.prisma.stopHunt.findFirst({ where: { revengeTradeId: params.revengeTradeId } }) if (!stopHunt) { logger.log(`⚠️ No stop hunt found for revenge trade ${params.revengeTradeId}`) return } await this.prisma.stopHunt.update({ where: { id: stopHunt.id }, data: { revengeOutcome: params.outcome, revengePnL: params.pnl, revengeFailedReason: params.failedReason || null, } }) const emoji = params.outcome.includes('TP') ? '✅' : '❌' logger.log(`${emoji} REVENGE OUTCOME: ${params.outcome} (${params.pnl >= 0 ? '+' : ''}$${params.pnl.toFixed(2)})`) if (params.failedReason) { logger.log(` Reason: ${params.failedReason}`) } } catch (error) { console.error('❌ Error updating revenge outcome:', error) } } /** * Expire stop hunts that are past their 4-hour window */ private async expireOldStopHunts(): Promise { try { const expired = await this.prisma.stopHunt.updateMany({ where: { revengeExecuted: false, revengeWindowExpired: false, revengeExpiresAt: { lte: new Date() } }, data: { revengeWindowExpired: true } }) if (expired.count > 0) { logger.log(`⏰ Expired ${expired.count} stop hunt revenge window(s)`) } } catch (error) { console.error('❌ Error expiring stop hunts:', error) } } } /** * Get singleton instance */ export function getStopHuntTracker(): StopHuntTracker { if (!trackerInstance) { trackerInstance = new StopHuntTracker() } return trackerInstance } /** * Start tracking (called on server startup) */ export async function startStopHuntTracking(): Promise { try { const tracker = getStopHuntTracker() const prisma = getPrismaClient() const activeCount = await prisma.stopHunt.count({ where: { revengeExecuted: false, revengeWindowExpired: false, revengeExpiresAt: { gt: new Date() } } }) if (activeCount > 0) { console.log(`🎯 Found ${activeCount} active stop hunt(s) - starting revenge tracker`) tracker.startMonitoring() } else { console.log('📊 No active stop hunts - tracker will start when needed') } } catch (error) { console.error('❌ Error starting stop hunt tracker:', error) } }