Enhancement #4: Failed Revenge Tracking - Added 3 database fields: revengeOutcome, revengePnL, revengeFailedReason - Added updateRevengeOutcome() method in stop-hunt-tracker.ts - Position Manager hooks revenge trade closes, records outcome - Enables data-driven analysis of revenge success rate Enhancement #10: Metadata Persistence - Added 4 database fields: firstCrossTime, lowestInZone, highestInZone, zoneResetCount - Migrated 90-second zone tracking from in-memory to database - Rewrote shouldExecuteRevenge() with database persistence - Container restarts now preserve exact zone tracking state Technical Details: - Prisma schema updated with 7 new StopHunt fields - Added signalSource field to ActiveTrade interface - All zone metadata persisted in real-time to database - Build verified successful (no TypeScript errors) Files Changed: - prisma/schema.prisma (StopHunt model + index) - lib/trading/stop-hunt-tracker.ts (DB persistence + outcome tracking) - lib/trading/position-manager.ts (revenge hook + interface) - docs/REVENGE_ENHANCEMENTS_EXPLAINED.md (comprehensive guide) Pending User Decision: - Enhancement #1: ADX confirmation (3 options explained in docs) - Enhancement #6: SL distance validation (2× ATR recommended) Status: Ready for deployment after Prisma migration Date: Nov 27, 2025
565 lines
19 KiB
TypeScript
565 lines
19 KiB
TypeScript
/**
|
|
* 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 { 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) {
|
|
console.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,
|
|
}
|
|
})
|
|
|
|
console.log(`🎯 STOP HUNT RECORDED: ${params.symbol} ${params.direction.toUpperCase()}`)
|
|
console.log(` Quality: ${params.originalQualityScore}, Loss: $${params.stopLossAmount.toFixed(2)}`)
|
|
console.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
|
|
console.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
|
|
console.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
|
|
}
|
|
|
|
console.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) {
|
|
console.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,
|
|
}
|
|
})
|
|
console.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
|
|
console.log(` ✅ LONG revenge: Price held below entry for ${(timeInZone/60000).toFixed(1)}min, confirmed!`)
|
|
console.log(` Entry ${originalEntryPrice.toFixed(2)} → Current ${currentPrice.toFixed(2)}`)
|
|
return true
|
|
} else {
|
|
console.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) {
|
|
console.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,
|
|
}
|
|
})
|
|
console.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
|
|
console.log(` ✅ SHORT revenge: Price held above entry for ${(timeInZone/60000).toFixed(1)}min, confirmed!`)
|
|
console.log(` Entry ${originalEntryPrice.toFixed(2)} → Current ${currentPrice.toFixed(2)}`)
|
|
return true
|
|
} else {
|
|
console.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) {
|
|
console.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 {
|
|
console.log(`🔥 EXECUTING REVENGE TRADE: ${stopHunt.symbol} ${stopHunt.direction.toUpperCase()}`)
|
|
console.log(` Original loss: $${stopHunt.stopLossAmount.toFixed(2)}`)
|
|
console.log(` Revenge size: 1.2x (getting our money back!)`)
|
|
|
|
// 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) {
|
|
// 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(),
|
|
}
|
|
})
|
|
|
|
console.log(`✅ REVENGE TRADE EXECUTED: ${result.trade?.id}`)
|
|
console.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) {
|
|
console.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') ? '✅' : '❌'
|
|
console.log(`${emoji} REVENGE OUTCOME: ${params.outcome} (${params.pnl >= 0 ? '+' : ''}$${params.pnl.toFixed(2)})`)
|
|
|
|
if (params.failedReason) {
|
|
console.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) {
|
|
console.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)
|
|
}
|
|
}
|