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
This commit is contained in:
@@ -26,6 +26,7 @@ export interface ActiveTrade {
|
||||
atrAtEntry?: number // ATR value at entry for ATR-based trailing stop
|
||||
adxAtEntry?: number // ADX value at entry for trend strength multiplier
|
||||
signalQualityScore?: number // Quality score for stop hunt tracking
|
||||
signalSource?: string // Trade source: 'tradingview', 'manual', 'stop_hunt_revenge'
|
||||
|
||||
// Targets
|
||||
stopLossPrice: number
|
||||
@@ -1551,6 +1552,26 @@ export class PositionManager {
|
||||
maxAdversePrice: trade.maxAdversePrice,
|
||||
})
|
||||
console.log('💾 Trade saved to database')
|
||||
|
||||
// 🔥 REVENGE OUTCOME TRACKING (Enhancement #4 - Nov 27, 2025)
|
||||
// If this was a revenge trade, record the outcome in StopHunt table
|
||||
if (trade.signalSource === 'stop_hunt_revenge') {
|
||||
try {
|
||||
const { getStopHuntTracker } = await import('./stop-hunt-tracker')
|
||||
const tracker = getStopHuntTracker()
|
||||
|
||||
await tracker.updateRevengeOutcome({
|
||||
revengeTradeId: trade.id,
|
||||
outcome: reason as string,
|
||||
pnl: trade.realizedPnL,
|
||||
failedReason: reason === 'SL' ? 'stopped_again' : undefined
|
||||
})
|
||||
console.log(`🔥 Revenge outcome recorded: ${reason} (P&L: $${trade.realizedPnL.toFixed(2)})`)
|
||||
} catch (revengeError) {
|
||||
console.error('❌ Failed to record revenge outcome:', revengeError)
|
||||
// Don't fail trade closure if revenge tracking fails
|
||||
}
|
||||
}
|
||||
} catch (dbError) {
|
||||
console.error('❌ Failed to save trade exit to database:', dbError)
|
||||
// Don't fail the close if database fails
|
||||
|
||||
@@ -34,6 +34,17 @@ interface StopHuntRecord {
|
||||
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
|
||||
@@ -197,7 +208,7 @@ export class StopHuntTracker {
|
||||
})
|
||||
|
||||
// Check revenge conditions (now requires sustained move, not just wick)
|
||||
const shouldRevenge = this.shouldExecuteRevenge(stopHunt, currentPrice)
|
||||
const shouldRevenge = await this.shouldExecuteRevenge(stopHunt, currentPrice)
|
||||
|
||||
if (shouldRevenge) {
|
||||
console.log(`🔥 REVENGE CONDITIONS MET: ${stopHunt.symbol} ${stopHunt.direction.toUpperCase()}`)
|
||||
@@ -212,9 +223,14 @@ export class StopHuntTracker {
|
||||
/**
|
||||
* 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 60+ seconds
|
||||
* - 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
|
||||
*
|
||||
@@ -222,37 +238,41 @@ export class StopHuntTracker {
|
||||
* - 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 60s before entry
|
||||
* - NEW system: Requires price below $136.32 for 90s before entry
|
||||
* - Result: Enters safely after confirmation, rides to $144.50 (+$530!)
|
||||
*/
|
||||
private shouldExecuteRevenge(stopHunt: StopHuntRecord, currentPrice: number): boolean {
|
||||
private async shouldExecuteRevenge(stopHunt: StopHuntRecord, currentPrice: number): Promise<boolean> {
|
||||
const { direction, stopHuntPrice, originalEntryPrice } = stopHunt
|
||||
|
||||
// Track how long price has been in revenge zone
|
||||
const now = Date.now()
|
||||
const metadata = (stopHunt as any).revengeMetadata || {}
|
||||
|
||||
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 - track duration
|
||||
if (!metadata.firstCrossTime) {
|
||||
metadata.firstCrossTime = now
|
||||
metadata.lowestInZone = currentPrice
|
||||
// Update metadata in memory (not persisting to DB to avoid spam)
|
||||
;(stopHunt as any).revengeMetadata = metadata
|
||||
console.log(` ⏱️ LONG revenge zone entered at ${currentPrice.toFixed(2)}, waiting for 60s confirmation...`)
|
||||
// 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
|
||||
metadata.lowestInZone = Math.min(metadata.lowestInZone, currentPrice)
|
||||
;(stopHunt as any).revengeMetadata = metadata
|
||||
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 - partial candle confirmation)
|
||||
const timeInZone = now - metadata.firstCrossTime
|
||||
// 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)}`)
|
||||
@@ -262,10 +282,17 @@ export class StopHuntTracker {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
// Price left revenge zone - reset timer
|
||||
if (metadata.firstCrossTime) {
|
||||
// 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`)
|
||||
;(stopHunt as any).revengeMetadata = {}
|
||||
await this.prisma.stopHunt.update({
|
||||
where: { id: stopHunt.id },
|
||||
data: {
|
||||
firstCrossTime: null,
|
||||
lowestInZone: null,
|
||||
zoneResetCount: { increment: 1 }
|
||||
}
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -274,21 +301,28 @@ export class StopHuntTracker {
|
||||
const crossedBackUp = currentPrice > originalEntryPrice * 1.005 // 0.5% buffer
|
||||
|
||||
if (crossedBackUp) {
|
||||
// Price is in revenge zone - track duration
|
||||
if (!metadata.firstCrossTime) {
|
||||
metadata.firstCrossTime = now
|
||||
metadata.highestInZone = currentPrice
|
||||
;(stopHunt as any).revengeMetadata = metadata
|
||||
console.log(` ⏱️ SHORT revenge zone entered at ${currentPrice.toFixed(2)}, waiting for 60s confirmation...`)
|
||||
// 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
|
||||
metadata.highestInZone = Math.max(metadata.highestInZone, currentPrice)
|
||||
;(stopHunt as any).revengeMetadata = metadata
|
||||
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 - fast but filters wicks)
|
||||
const timeInZone = now - metadata.firstCrossTime
|
||||
// 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)}`)
|
||||
@@ -298,10 +332,17 @@ export class StopHuntTracker {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
// Price left revenge zone - reset timer
|
||||
if (metadata.firstCrossTime) {
|
||||
// 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`)
|
||||
;(stopHunt as any).revengeMetadata = {}
|
||||
await this.prisma.stopHunt.update({
|
||||
where: { id: stopHunt.id },
|
||||
data: {
|
||||
firstCrossTime: null,
|
||||
highestInZone: null,
|
||||
zoneResetCount: { increment: 1 }
|
||||
}
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -414,6 +455,48 @@ Reversal Confirmed: Price crossed back through entry
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user