Files
trading_bot_v4/lib/trading/stop-hunt-tracker.ts

618 lines
21 KiB
TypeScript
Raw Permalink 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.
/**
* 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<void> {
// 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<void> {
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<void> {
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<boolean> {
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<void> {
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<void> {
try {
const message = `
🔥 <b>REVENGE TRADE ACTIVATED</b> 🔥
<b>${stopHunt.symbol} ${stopHunt.direction.toUpperCase()}</b>
💀 Original Stop Hunt: -$${stopHunt.stopLossAmount.toFixed(2)}
🎯 Revenge Entry: $${trade.entryPrice.toFixed(2)}
💪 Position Size: $${trade.positionSizeUSD.toFixed(2)} (same as original)
⚔️ <b>TIME FOR PAYBACK!</b>
Original Quality: ${stopHunt.originalQualityScore}/100
Stop Hunt Price: $${stopHunt.stopHuntPrice.toFixed(4)}
Reversal Confirmed: Price crossed back through entry
<i>Let's get our money back! 💰</i>
`.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<void> {
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<void> {
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<void> {
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)
}
}