Files
trading_bot_v4/lib/trading/position-manager.ts
mindesbunister 01bd730b19 critical: FIX Bug #77 - Position Manager monitoring stopped by Drift init check
CRITICAL FIX (Dec 13, 2025) - $1,000 LOSS BUG ROOT CAUSE

The $1,000 loss bug is FIXED! Telegram-opened positions are now properly monitored.

ROOT CAUSE:
- handlePriceUpdate() had early return if Drift service not initialized
- Drift initializes lazily (only when first API call needs it)
- Position Manager starts monitoring immediately after addTrade()
- Pyth price monitor calls handlePriceUpdate() every 2 seconds
- But handlePriceUpdate() returned early because Drift wasn't ready
- Result: Monitoring loop ran but did NOTHING (silent failure)

THE FIX:
- Removed early return for Drift initialization check (line 692-696)
- Price checking loop now runs even if Drift temporarily unavailable
- External closure detection fails gracefully if Drift unavailable (separate concern)
- Added logging: '🔍 Price check: SOL-PERP @ $132.29 (2 trades)'

VERIFICATION (Dec 13, 2025 21:47 UTC):
- Test position opened via /api/trading/test
- Monitoring started: 'Position monitoring active, isMonitoring: true'
- Price checks running every 2 seconds: '🔍 Price check' logs visible
- Diagnostic endpoint confirms: isMonitoring=true, activeTradesCount=2

IMPACT:
- Prevents $1,000+ losses from unmonitored positions
- Telegram trades now get full TP/SL/trailing stop protection
- Position Manager monitoring loop actually runs now
- No more 'added but not monitored' situations

FILES CHANGED:
- lib/trading/position-manager.ts (lines 685-695, 650-658)

This was the root cause of Bug #77. User's SOL-PERP SHORT (Nov 13, 2025 20:47)
was never monitored because handlePriceUpdate() returned early for 29 minutes.
Container restart at 21:20 lost all failure logs. Now fixed permanently.
2025-12-13 22:47:59 +01:00

2212 lines
98 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/**
* Position Manager
*
* Tracks active trades and manages automatic exits
*/
import { getDriftService, initializeDriftService } from '../drift/client'
import { logger } from '../utils/logger'
import { closePosition } from '../drift/orders'
import { getPythPriceMonitor, PriceUpdate } from '../pyth/price-monitor'
import { getMergedConfig, TradingConfig, getMarketConfig } from '../../config/trading'
import { updateTradeExit, updateTradeState, getOpenTrades } from '../database/trades'
import { sendPositionClosedNotification } from '../notifications/telegram'
import { getStopHuntTracker } from './stop-hunt-tracker'
import { getMarketDataCache } from './market-data-cache'
export interface ActiveTrade {
id: string
positionId: string // Transaction signature
symbol: string
direction: 'long' | 'short'
// Entry details
entryPrice: number
entryTime: number
positionSize: number
leverage: number
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
tp1Price: number
tp2Price: number
emergencyStopPrice: number
// State
currentSize: number // Changes after TP1
originalPositionSize: number // Original entry size for accurate P&L on manual closes
takeProfitPrice1?: number // TP1 price for validation
takeProfitPrice2?: number // TP2 price for validation
tp1Hit: boolean
tp2Hit: boolean
slMovedToBreakeven: boolean
slMovedToProfit: boolean
trailingStopActive: boolean
// P&L tracking
realizedPnL: number
unrealizedPnL: number
peakPnL: number
peakPrice: number // Track highest price reached (for trailing)
// MAE/MFE tracking
maxFavorableExcursion: number // Best profit % reached
maxAdverseExcursion: number // Worst loss % reached
maxFavorablePrice: number // Price at best profit
maxAdversePrice: number // Price at worst loss
// Position scaling tracking
originalAdx?: number // ADX at initial entry (for scaling validation)
timesScaled?: number // How many times position has been scaled
totalScaleAdded?: number // Total USD added through scaling
// Close verification tracking (Nov 16, 2025)
closingInProgress?: boolean // True when close tx confirmed but Drift not yet propagated
closeConfirmedAt?: number // Timestamp when close was confirmed (for timeout)
// Monitoring
priceCheckCount: number
lastPrice: number
lastUpdateTime: number
}
export interface ExitResult {
success: boolean
reason: 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' | 'TRAILING_SL' | 'emergency' | 'manual' | 'error'
closePrice?: number
closedSize?: number
realizedPnL?: number
transactionSignature?: string
error?: string
}
export class PositionManager {
private activeTrades: Map<string, ActiveTrade> = new Map()
private config: TradingConfig
private isMonitoring: boolean = false
private initialized: boolean = false
private validationInterval: NodeJS.Timeout | null = null
constructor(config?: Partial<TradingConfig>) {
this.config = getMergedConfig(config)
logger.log('✅ Position manager created')
}
/**
* Initialize and restore active trades from database
*/
async initialize(forceReload: boolean = false): Promise<void> {
if (this.initialized && !forceReload) {
return
}
if (forceReload) {
logger.log('🔄 Force reloading Position Manager state from database')
this.activeTrades.clear()
this.isMonitoring = false
}
logger.log('🔄 Restoring active trades from database...')
try {
const openTrades = await getOpenTrades()
for (const dbTrade of openTrades) {
// Extract Position Manager state from configSnapshot
const pmState = (dbTrade.configSnapshot as any)?.positionManagerState
// Reconstruct ActiveTrade object
const activeTrade: ActiveTrade = {
id: dbTrade.id,
positionId: dbTrade.positionId,
symbol: dbTrade.symbol,
direction: dbTrade.direction as 'long' | 'short',
entryPrice: dbTrade.entryPrice,
entryTime: dbTrade.entryTime.getTime(),
positionSize: dbTrade.positionSizeUSD,
leverage: dbTrade.leverage,
stopLossPrice: pmState?.stopLossPrice ?? dbTrade.stopLossPrice,
tp1Price: dbTrade.takeProfit1Price,
tp2Price: dbTrade.takeProfit2Price,
emergencyStopPrice: dbTrade.stopLossPrice * (dbTrade.direction === 'long' ? 0.98 : 1.02),
currentSize: pmState?.currentSize ?? dbTrade.positionSizeUSD,
originalPositionSize: dbTrade.positionSizeUSD, // Store original size for P&L
takeProfitPrice1: dbTrade.takeProfit1Price,
takeProfitPrice2: dbTrade.takeProfit2Price,
tp1Hit: pmState?.tp1Hit ?? false,
tp2Hit: pmState?.tp2Hit ?? false,
slMovedToBreakeven: pmState?.slMovedToBreakeven ?? false,
slMovedToProfit: pmState?.slMovedToProfit ?? false,
trailingStopActive: pmState?.trailingStopActive ?? false,
realizedPnL: pmState?.realizedPnL ?? 0,
unrealizedPnL: pmState?.unrealizedPnL ?? 0,
peakPnL: pmState?.peakPnL ?? 0,
peakPrice: pmState?.peakPrice ?? dbTrade.entryPrice,
maxFavorableExcursion: pmState?.maxFavorableExcursion ?? 0,
maxAdverseExcursion: pmState?.maxAdverseExcursion ?? 0,
maxFavorablePrice: pmState?.maxFavorablePrice ?? dbTrade.entryPrice,
maxAdversePrice: pmState?.maxAdversePrice ?? dbTrade.entryPrice,
priceCheckCount: 0,
lastPrice: pmState?.lastPrice ?? dbTrade.entryPrice,
lastUpdateTime: Date.now(),
}
this.activeTrades.set(activeTrade.id, activeTrade)
logger.log(`✅ Restored trade: ${activeTrade.symbol} ${activeTrade.direction} at $${activeTrade.entryPrice}`)
}
if (this.activeTrades.size > 0) {
logger.log(`🎯 Restored ${this.activeTrades.size} active trades`)
await this.startMonitoring()
} else {
logger.log('✅ No active trades to restore')
}
} catch (error) {
console.error('❌ Failed to restore active trades:', error)
}
this.initialized = true
}
/**
* Handle manual closures with proper exit reason detection
* Called when size reduction detected but price NOT at TP1 level
*/
private async handleManualClosure(
trade: ActiveTrade,
currentPrice: number,
remainingSize: number
): Promise<void> {
logger.log(`👤 Processing manual closure for ${trade.symbol}`)
// Determine exit reason based on price levels
let exitReason: 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' | 'TRAILING_SL' | 'manual' | 'emergency' = 'manual'
const profitPercent = this.calculateProfitPercent(trade.entryPrice, currentPrice, trade.direction)
// Check if price is at TP2 or SL levels
const isAtTP2 = this.isPriceAtTarget(currentPrice, trade.takeProfitPrice2 || 0)
const isAtSL = this.isPriceAtTarget(currentPrice, trade.stopLossPrice || 0)
if (isAtTP2 && trade.tp1Hit) {
exitReason = 'TP2'
logger.log(`✅ Manual closure was TP2 (price at target)`)
} else if (isAtSL) {
// Check if trailing stop was active
if (trade.trailingStopActive && trade.tp2Hit) {
exitReason = 'TRAILING_SL'
logger.log(`🏃 Manual closure was Trailing SL (price at trailing stop target)`)
} else {
exitReason = 'SL'
logger.log(`🛑 Manual closure was SL (price at target)`)
}
} else {
logger.log(`👤 Manual closure confirmed (price not at any target)`)
logger.log(` Current: $${currentPrice.toFixed(4)}, TP1: $${trade.takeProfitPrice1?.toFixed(4)}, TP2: $${trade.takeProfitPrice2?.toFixed(4)}, SL: $${trade.stopLossPrice?.toFixed(4)}`)
}
// CRITICAL: Calculate P&L using originalPositionSize for accuracy
const realizedPnL = (trade.originalPositionSize * profitPercent) / 100
logger.log(`💰 Manual close P&L: ${profitPercent.toFixed(2)}% on $${trade.originalPositionSize.toFixed(2)} = $${realizedPnL.toFixed(2)}`)
// Remove from monitoring FIRST (prevent race conditions)
this.activeTrades.delete(trade.id)
// Update database
try {
await updateTradeExit({
positionId: trade.positionId,
exitPrice: currentPrice,
exitReason,
realizedPnL,
exitOrderTx: 'Manual closure detected',
holdTimeSeconds: Math.floor((Date.now() - trade.entryTime) / 1000),
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
maxGain: Math.max(0, trade.maxFavorableExcursion),
maxFavorableExcursion: trade.maxFavorableExcursion,
maxAdverseExcursion: trade.maxAdverseExcursion,
maxFavorablePrice: trade.maxFavorablePrice,
maxAdversePrice: trade.maxAdversePrice,
})
logger.log(`✅ Manual closure recorded: ${trade.symbol} ${exitReason} P&L: $${realizedPnL.toFixed(2)}`)
// Send Telegram notification
await sendPositionClosedNotification({
symbol: trade.symbol,
direction: trade.direction,
entryPrice: trade.entryPrice,
exitPrice: currentPrice,
positionSize: trade.originalPositionSize,
realizedPnL,
exitReason,
holdTimeSeconds: Math.floor((Date.now() - trade.entryTime) / 1000),
maxGain: Math.max(0, trade.maxFavorableExcursion),
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
})
} catch (error) {
console.error('❌ Failed to save manual closure:', error)
}
if (this.activeTrades.size === 0) {
this.stopMonitoring()
}
}
/**
* Add a new trade to monitor
*/
async addTrade(trade: ActiveTrade): Promise<void> {
console.log(`📊 ADDTRADE: Adding trade to monitor: ${trade.symbol} ${trade.direction}`)
console.log(`📊 ADDTRADE: Trade ID: ${trade.id}`)
console.log(`📊 ADDTRADE: Before add - activeTrades.size: ${this.activeTrades.size}`)
this.activeTrades.set(trade.id, trade)
console.log(`📊 ADDTRADE: After add - activeTrades.size: ${this.activeTrades.size}`)
console.log(`📊 ADDTRADE: isMonitoring: ${this.isMonitoring}`)
// Note: Initial state is saved by the API endpoint that creates the trade
// We don't save here to avoid race condition (trade may not be in DB yet)
logger.log(`✅ Trade added. Active trades: ${this.activeTrades.size}`)
// Start monitoring if not already running
if (!this.isMonitoring && this.activeTrades.size > 0) {
console.log(`📊 ADDTRADE: Calling startMonitoring() - conditions met`)
await this.startMonitoring()
// BUG #77 FIX: Verify monitoring actually started
if (this.activeTrades.size > 0 && !this.isMonitoring) {
const errorMsg = `CRITICAL: Failed to start monitoring! activeTrades=${this.activeTrades.size}, isMonitoring=${this.isMonitoring}`
console.error(`${errorMsg}`)
// Log to persistent file
const { logCriticalError } = await import('../utils/persistent-logger')
await logCriticalError('MONITORING_START_FAILED', {
activeTradesCount: this.activeTrades.size,
isMonitoring: this.isMonitoring,
symbols: Array.from(this.activeTrades.values()).map(t => t.symbol),
tradeIds: Array.from(this.activeTrades.keys())
})
throw new Error(errorMsg)
}
logger.log(`✅ Monitoring verification passed: isMonitoring=${this.isMonitoring}`)
}
}
/**
* Remove a trade from monitoring
* BUG #78 FIX: Safely handle order cancellation to avoid removing active position orders
*/
async removeTrade(tradeId: string): Promise<void> {
const trade = this.activeTrades.get(tradeId)
if (trade) {
logger.log(`🗑️ Removing trade: ${trade.symbol}`)
// BUG #78 FIX: Check Drift position size before canceling orders
// If Drift shows an open position, DON'T cancel orders (may belong to active position)
try {
const driftService = getDriftService()
const marketConfig = getMarketConfig(trade.symbol)
// Query Drift for current position
const driftPosition = await driftService.getPosition(marketConfig.driftMarketIndex)
if (driftPosition && Math.abs(driftPosition.size) >= 0.01) {
// Position still open on Drift - DO NOT cancel orders
console.warn(`⚠️ SAFETY CHECK: ${trade.symbol} position still open on Drift (size: ${driftPosition.size})`)
console.warn(` Skipping order cancellation to avoid removing active position protection`)
console.warn(` Removing from tracking only`)
// Just remove from map, don't cancel orders
this.activeTrades.delete(tradeId)
// Log for monitoring
const { logCriticalError } = await import('../utils/persistent-logger')
await logCriticalError('ORPHAN_REMOVAL_SKIPPED_ACTIVE_POSITION', {
tradeId,
symbol: trade.symbol,
driftSize: driftPosition.size,
reason: 'Drift position still open - preserved orders for safety'
})
} else {
// Position confirmed closed on Drift - safe to cancel orders
logger.log(`✅ Drift position confirmed closed (size: ${driftPosition?.size || 0})`)
logger.log(` Safe to cancel remaining orders`)
const { cancelAllOrders } = await import('../drift/orders')
const cancelResult = await cancelAllOrders(trade.symbol)
if (cancelResult.success && cancelResult.cancelledCount! > 0) {
logger.log(`✅ Cancelled ${cancelResult.cancelledCount} orphaned orders`)
} else if (!cancelResult.success) {
console.error(`❌ Failed to cancel orders: ${cancelResult.error}`)
} else {
logger.log(` No orders to cancel`)
}
this.activeTrades.delete(tradeId)
}
} catch (error) {
const errorMessage = `❌ Error checking Drift position during trade removal: ${error instanceof Error ? error.message : String(error)}`
console.error(errorMessage)
console.warn('⚠️ Removing from tracking without canceling orders (safety first)')
// On error, err on side of caution - don't cancel orders
this.activeTrades.delete(tradeId)
}
// Stop monitoring if no more trades
if (this.activeTrades.size === 0 && this.isMonitoring) {
this.stopMonitoring()
}
}
}
/**
* Schedule periodic validation to detect ghost positions
*/
private scheduleValidation(): void {
// Clear any existing interval
if (this.validationInterval) {
clearInterval(this.validationInterval)
}
// Run validation every 5 minutes
const validationIntervalMs = 5 * 60 * 1000
this.validationInterval = setInterval(async () => {
await this.validatePositions()
}, validationIntervalMs)
logger.log('🔍 Scheduled position validation every 5 minutes')
}
/**
* Validate tracked positions against Drift to detect ghosts
*
* Ghost positions occur when:
* - Database has exitReason IS NULL (we think it's open)
* - But Drift shows position closed or missing
*
* This happens due to:
* - Failed database updates during external closures
* - Container restarts before cleanup completed
* - On-chain orders filled without Position Manager knowing
*
* CRITICAL (Nov 15, 2025): This MUST run even during rate limiting to prevent ghost accumulation
*/
private async validatePositions(): Promise<void> {
if (this.activeTrades.size === 0) {
return // Nothing to validate
}
logger.log('🔍 Validating positions against Drift...')
try {
const driftService = getDriftService()
// If Drift service not ready, skip this validation cycle
if (!driftService || !(driftService as any).isInitialized) {
logger.log('⚠️ Drift service not ready - skipping validation this cycle')
logger.log(` Positions in memory: ${this.activeTrades.size}`)
logger.log(` Will retry on next cycle (5 minutes) or during monitoring (40 seconds)`)
return
}
// Check each tracked trade individually
for (const [tradeId, trade] of this.activeTrades) {
const marketConfig = getMarketConfig(trade.symbol)
try {
const position = await driftService.getPosition(marketConfig.driftMarketIndex)
// Ghost detected: we're tracking it but Drift shows closed/missing
if (!position || Math.abs(position.size) < 0.01) {
logger.log(`🔴 Ghost position detected: ${trade.symbol} (${tradeId})`)
logger.log(` Database: exitReason IS NULL (thinks it's open)`)
logger.log(` Drift: Position ${position ? 'closed (size=' + position.size + ')' : 'missing'}`)
logger.log(` Cause: Likely failed DB update during external closure`)
// Auto-cleanup: Handle as external closure
await this.handleExternalClosure(trade, 'Ghost position cleanup')
logger.log(`✅ Ghost position cleaned up: ${trade.symbol}`)
}
} catch (posError) {
console.error(`⚠️ Could not check ${trade.symbol} on Drift:`, posError)
// Continue checking other positions
}
}
logger.log(`✅ Validation complete: ${this.activeTrades.size} positions healthy`)
} catch (error) {
console.error('❌ Position validation failed:', error)
// Don't throw - validation errors shouldn't break monitoring
}
}
/**
* Handle external closure for ghost position cleanup
*
* Called when:
* - Periodic validation detects position closed on Drift but tracked in DB
* - Manual cleanup needed after failed database updates
*/
private async handleExternalClosure(trade: ActiveTrade, reason: string): Promise<void> {
logger.log(`🧹 Handling external closure: ${trade.symbol} (${reason})`)
// CRITICAL FIX (Dec 2, 2025): Remove from activeTrades FIRST, then check if already removed
// Bug: Multiple monitoring loops detect ghost simultaneously
// - Loop 1 checks has(tradeId) → true → proceeds
// - Loop 2 checks has(tradeId) → true → also proceeds (RACE CONDITION)
// - Both send Telegram notifications with compounding P&L
// Fix: Delete BEFORE check, so only first loop proceeds
const tradeId = trade.id
const wasInMap = this.activeTrades.delete(tradeId)
if (!wasInMap) {
logger.log(`⚠️ DUPLICATE PREVENTED: Trade ${tradeId} already processed, skipping`)
logger.log(` This prevents duplicate Telegram notifications with compounding P&L`)
return
}
logger.log(`🗑️ Removed ${trade.symbol} from monitoring (will not process duplicates)`)
// CRITICAL: Calculate P&L using originalPositionSize for accuracy
// currentSize may be stale if Drift propagation was interrupted
const profitPercent = this.calculateProfitPercent(
trade.entryPrice,
trade.lastPrice,
trade.direction
)
const sizeForPnL = trade.originalPositionSize // Use original, not currentSize
const estimatedPnL = (sizeForPnL * profitPercent) / 100
logger.log(`💰 Estimated P&L: ${profitPercent.toFixed(2)}% on $${sizeForPnL.toFixed(2)}$${estimatedPnL.toFixed(2)}`)
// Update database
try {
const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000)
await updateTradeExit({
positionId: trade.positionId,
exitPrice: trade.lastPrice,
exitReason: 'manual', // Ghost closures treated as manual
realizedPnL: estimatedPnL,
exitOrderTx: reason, // Store cleanup reason
holdTimeSeconds,
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
maxGain: Math.max(0, trade.maxFavorableExcursion),
maxFavorableExcursion: trade.maxFavorableExcursion,
maxAdverseExcursion: trade.maxAdverseExcursion,
maxFavorablePrice: trade.maxFavorablePrice,
maxAdversePrice: trade.maxAdversePrice,
})
logger.log(`💾 Ghost closure saved to database`)
// Send Telegram notification for ghost closure
await sendPositionClosedNotification({
symbol: trade.symbol,
direction: trade.direction,
entryPrice: trade.entryPrice,
exitPrice: trade.lastPrice,
positionSize: trade.currentSize,
realizedPnL: estimatedPnL,
exitReason: reason, // e.g., "Ghost position cleanup", "Layer 2: Ghost detected via Drift API"
holdTimeSeconds: Math.floor((Date.now() - trade.entryTime) / 1000),
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
maxGain: Math.max(0, trade.maxFavorableExcursion),
})
} catch (dbError) {
console.error('❌ Failed to save ghost closure:', dbError)
}
// Stop monitoring if no more trades
if (this.activeTrades.size === 0 && this.isMonitoring) {
this.stopMonitoring()
}
}
/**
* Get all active trades
*/
getActiveTrades(): ActiveTrade[] {
return Array.from(this.activeTrades.values())
}
/**
* Get specific trade
*/
getTrade(tradeId: string): ActiveTrade | null {
return this.activeTrades.get(tradeId) || null
}
/**
* Start price monitoring for all active trades
*/
private async startMonitoring(): Promise<void> {
console.log('🚀 STARTMON: Entered startMonitoring()')
console.log(`🚀 STARTMON: Current isMonitoring: ${this.isMonitoring}`)
if (this.isMonitoring) {
console.log('⚠️ STARTMON: Monitoring already active, skipping duplicate start')
logger.log('⚠️ Monitoring already active, skipping duplicate start')
return
}
// Get unique symbols from active trades
const symbols = [...new Set(
Array.from(this.activeTrades.values()).map(trade => trade.symbol)
)]
console.log(`🚀 STARTMON: Symbols to monitor: ${symbols.join(', ')} (count: ${symbols.length})`)
if (symbols.length === 0) {
console.log('⚠️ STARTMON: No symbols to monitor, skipping start')
logger.log('⚠️ No symbols to monitor, skipping start')
return
}
console.log('🚀 STARTMON: Starting price monitoring...')
logger.log('🚀 Starting price monitoring...')
logger.log(` Active trades: ${this.activeTrades.size}`)
logger.log(` Symbols: ${symbols.join(', ')}`)
logger.log(` Current isMonitoring: ${this.isMonitoring}`)
const priceMonitor = getPythPriceMonitor()
console.log('🚀 STARTMON: Got price monitor instance')
try {
console.log('📡 STARTMON: Calling priceMonitor.start()...')
logger.log('📡 Calling priceMonitor.start()...')
await priceMonitor.start({
symbols,
onPriceUpdate: async (update: PriceUpdate) => {
await this.handlePriceUpdate(update)
},
onError: (error: Error) => {
console.error('❌ Price monitor error:', error)
},
})
console.log('📡 STARTMON: priceMonitor.start() completed')
this.isMonitoring = true
console.log(`✅ STARTMON: Set isMonitoring = true`)
console.log(`✅ STARTMON: Position monitoring active, isMonitoring: ${this.isMonitoring}`)
logger.log('✅ Position monitoring active')
logger.log(` isMonitoring flag set to: ${this.isMonitoring}`)
// Schedule periodic validation to detect and cleanup ghost positions
this.scheduleValidation()
console.log('✅ STARTMON: Scheduled validation')
} catch (error) {
console.error('❌ STARTMON CRITICAL: Failed to start price monitoring:', error)
console.error('❌ STARTMON: Error details:', error instanceof Error ? error.stack : String(error))
// Log error to persistent file
const { logCriticalError } = await import('../utils/persistent-logger')
await logCriticalError('PRICE_MONITOR_START_FAILED', {
symbols,
activeTradesCount: this.activeTrades.size,
error: error instanceof Error ? error.message : String(error)
})
throw error // Re-throw so caller knows monitoring failed
}
}
/**
* Stop price monitoring
*/
private async stopMonitoring(): Promise<void> {
if (!this.isMonitoring) {
return
}
logger.log('🛑 Stopping position monitoring...')
const priceMonitor = getPythPriceMonitor()
await priceMonitor.stop()
// Clear validation interval
if (this.validationInterval) {
clearInterval(this.validationInterval)
this.validationInterval = null
}
this.isMonitoring = false
logger.log('✅ Position monitoring stopped')
}
/**
* Handle price update for all relevant trades
*/
private async handlePriceUpdate(update: PriceUpdate): Promise<void> {
// Find all trades for this symbol
const tradesForSymbol = Array.from(this.activeTrades.values())
.filter(trade => trade.symbol === update.symbol)
// BUG #77 FIX: Log price updates so we can verify monitoring loop is running
if (tradesForSymbol.length > 0) {
console.log(`🔍 Price check: ${update.symbol} @ $${update.price.toFixed(2)} (${tradesForSymbol.length} trades)`)
}
for (const trade of tradesForSymbol) {
try {
await this.checkTradeConditions(trade, update.price)
} catch (error) {
console.error(`❌ Error checking trade ${trade.id}:`, error)
}
}
}
/**
* Check if any exit conditions are met for a trade
*/
private async checkTradeConditions(
trade: ActiveTrade,
currentPrice: number
): Promise<void> {
// CRITICAL FIX (Nov 23, 2025): Check if trade still in monitoring
// Prevents duplicate processing when async operations remove trade during loop
if (!this.activeTrades.has(trade.id)) {
logger.log(`⏭️ Skipping ${trade.symbol} - already removed from monitoring`)
return
}
// CRITICAL: Update lastPrice FIRST so /status always shows current price
// (even if function returns early due to position checks)
trade.lastPrice = currentPrice
trade.lastUpdateTime = Date.now()
trade.priceCheckCount++
// CRITICAL: First check if on-chain position still exists
// (may have been closed by TP/SL orders without us knowing)
try {
// BUG #77 FIX (Dec 13, 2025): Don't skip price checks if Drift not initialized
// This early return was causing monitoring to never run!
// Position Manager price checking loop must run even if external closure detection is temporarily unavailable
// Let the external closure check fail gracefully later if Drift unavailable
const driftService = getDriftService()
const marketConfig = getMarketConfig(trade.symbol)
const position = await driftService.getPosition(marketConfig.driftMarketIndex)
// Calculate trade age in seconds
const tradeAgeSeconds = (Date.now() - trade.entryTime) / 1000
if (position === null || position.size === 0) {
// IMPORTANT: Skip "external closure" detection for NEW trades (<30 seconds old)
// Drift positions may not be immediately visible after opening due to blockchain delays
if (tradeAgeSeconds < 30) {
logger.log(`⏳ Trade ${trade.symbol} is new (${tradeAgeSeconds.toFixed(1)}s old) - skipping external closure check`)
return // Skip this check cycle, position might still be propagating
}
// CRITICAL FIX (Dec 7, 2025): DOUBLE-CHECK before processing external closure
// Root cause of 90-min monitoring gap: Drift state propagation delays cause false positives
// Position appears closed when it's actually still closing (state lag)
// Solution: Wait 10 seconds and re-query to confirm position truly closed
logger.log(`⚠️ Position ${trade.symbol} APPEARS closed - DOUBLE-CHECKING in 10 seconds...`)
logger.log(` First check: position=${position ? 'exists' : 'null'}, size=${position?.size || 0}`)
// Wait 10 seconds for Drift state to propagate
await new Promise(resolve => setTimeout(resolve, 10000))
// Re-query Drift to confirm position truly closed
logger.log(`🔍 Re-querying Drift after 10s delay...`)
const recheckPosition = await driftService.getPosition(marketConfig.driftMarketIndex)
if (recheckPosition && recheckPosition.size !== 0) {
// FALSE POSITIVE! Position still open after recheck
logger.log(`🚨 FALSE POSITIVE DETECTED: Position still open after double-check!`)
logger.log(` Recheck: position size = ${recheckPosition.size} tokens (NOT ZERO!)`)
logger.log(` This was Drift state lag, not an actual closure`)
logger.log(` Continuing monitoring - NOT removing from active trades`)
// Reset closingInProgress flag if it was set (allows normal monitoring)
if (trade.closingInProgress) {
logger.log(` Resetting closingInProgress flag (false alarm)`)
trade.closingInProgress = false
}
return // DON'T process as external closure, DON'T remove from monitoring
}
// Position confirmed closed after double-check
logger.log(`✅ Position confirmed CLOSED after double-check (size still 0)`)
logger.log(` Safe to proceed with external closure handling`)
// Position closed externally (by on-chain TP/SL order or manual closure)
logger.log(`⚠️ Position ${trade.symbol} was closed externally (by on-chain order)`)
} else {
// Position exists - check if size changed (TP1/TP2 filled)
// CRITICAL FIX: position.size from Drift SDK is base asset tokens, must convert to USD
const positionSizeUSD = Math.abs(position.size) * currentPrice // Convert tokens to USD
const trackedSizeUSD = trade.currentSize
const sizeDiffPercent = Math.abs(positionSizeUSD - trackedSizeUSD) / trackedSizeUSD * 100
logger.log(`📊 Position check: Drift=$${positionSizeUSD.toFixed(2)} Tracked=$${trackedSizeUSD.toFixed(2)} Diff=${sizeDiffPercent.toFixed(1)}%`)
// If position size reduced significantly, TP orders likely filled
if (positionSizeUSD < trackedSizeUSD * 0.9 && sizeDiffPercent > 10) {
logger.log(`✅ Position size reduced: tracking $${trackedSizeUSD.toFixed(2)} → found $${positionSizeUSD.toFixed(2)}`)
// Detect which TP filled based on size reduction
const reductionPercent = ((trackedSizeUSD - positionSizeUSD) / trade.positionSize) * 100
if (!trade.tp1Hit && reductionPercent >= (this.config.takeProfit1SizePercent * 0.8)) {
// CRITICAL: Validate price is actually at TP1 before marking as TP1 hit
const isPriceAtTP1 = this.isPriceAtTarget(currentPrice, trade.takeProfitPrice1 || 0, 0.002)
if (!isPriceAtTP1) {
logger.log(`⚠️ Size reduction detected (${reductionPercent.toFixed(1)}%) but price NOT at TP1`)
logger.log(` Current: $${currentPrice.toFixed(4)}, TP1: $${trade.takeProfitPrice1?.toFixed(4) || 'N/A'}`)
logger.log(` This is likely a MANUAL CLOSE or external order, not TP1`)
// Handle as external closure with proper exit reason detection
await this.handleManualClosure(trade, currentPrice, positionSizeUSD)
return
}
// TP1 fired (price validated at target)
logger.log(`🎯 TP1 detected as filled! Reduction: ${reductionPercent.toFixed(1)}%, price at TP1 target`)
trade.tp1Hit = true
trade.currentSize = positionSizeUSD
// ADX-based runner SL positioning (Nov 19, 2025)
// Strong trends get more room, weak trends protect capital
let runnerSlPercent: number
const adx = trade.adxAtEntry || 0
if (adx < 20) {
runnerSlPercent = 0 // Weak trend: breakeven, preserve capital
logger.log(`🔒 ADX-based runner SL: ${adx.toFixed(1)} → 0% (breakeven - weak trend)`)
} else if (adx < 25) {
runnerSlPercent = -0.3 // Moderate trend: some room
logger.log(`🔒 ADX-based runner SL: ${adx.toFixed(1)} → -0.3% (moderate trend)`)
} else {
runnerSlPercent = -0.55 // Strong trend: full retracement room
logger.log(`🔒 ADX-based runner SL: ${adx.toFixed(1)} → -0.55% (strong trend)`)
}
// CRITICAL: Use DATABASE entry price (Drift recalculates after partial closes)
const newStopLossPrice = this.calculatePrice(
trade.entryPrice,
runnerSlPercent,
trade.direction
)
logger.log(`📊 Runner SL calculation: Entry $${trade.entryPrice.toFixed(4)} ${runnerSlPercent >= 0 ? '+' : ''}${runnerSlPercent}% = $${newStopLossPrice.toFixed(4)}`)
logger.log(` (${this.config.takeProfit1SizePercent}% closed, ${100 - this.config.takeProfit1SizePercent}% remaining)`)
// Move SL to ADX-based position after TP1
trade.stopLossPrice = newStopLossPrice
trade.slMovedToBreakeven = true
logger.log(`🛡️ Stop loss moved to: $${trade.stopLossPrice.toFixed(4)}`)
// CRITICAL FIX (Dec 12, 2025): Check if we have order signatures
// Auto-synced positions may have NULL signatures, need fallback
const { updateTradeState, getPrismaClient } = await import('../database/trades')
const prisma = getPrismaClient()
const dbTrade = await prisma.trade.findUnique({
where: { id: trade.id },
select: { slOrderTx: true, softStopOrderTx: true, hardStopOrderTx: true }
})
const hasOrderSignatures = dbTrade && (
dbTrade.slOrderTx || dbTrade.softStopOrderTx || dbTrade.hardStopOrderTx
)
if (!hasOrderSignatures) {
logger.log(`⚠️ No order signatures found - auto-synced position detected`)
logger.log(`🔧 FALLBACK: Placing fresh SL order at breakeven $${trade.stopLossPrice.toFixed(4)}`)
// Place fresh SL order without trying to cancel (no signatures to cancel)
const { placeExitOrders } = await import('../drift/orders')
try {
const placeResult = await placeExitOrders({
symbol: trade.symbol,
positionSizeUSD: trade.currentSize,
entryPrice: trade.entryPrice,
tp1Price: trade.tp2Price || trade.entryPrice * (trade.direction === 'long' ? 1.02 : 0.98),
tp2Price: trade.tp2Price || trade.entryPrice * (trade.direction === 'long' ? 1.04 : 0.96),
stopLossPrice: trade.stopLossPrice,
tp1SizePercent: 0, // Already hit, don't place TP1
tp2SizePercent: 100, // Place TP2 on remaining position (runner)
direction: trade.direction,
useDualStops: this.config.useDualStops,
softStopPrice: this.config.useDualStops ? trade.stopLossPrice : undefined,
hardStopPrice: this.config.useDualStops
? (trade.direction === 'long' ? trade.stopLossPrice * 0.99 : trade.stopLossPrice * 1.01)
: undefined,
})
if (placeResult.success && placeResult.signatures) {
logger.log(`✅ Fresh SL order placed successfully`)
// Update database with new order signatures
const updateData: any = {
stopLossPrice: trade.stopLossPrice,
}
if (this.config.useDualStops && placeResult.signatures.length >= 2) {
updateData.softStopOrderTx = placeResult.signatures[0]
updateData.hardStopOrderTx = placeResult.signatures[1]
logger.log(`💾 Recorded dual SL signatures: soft=${placeResult.signatures[0].slice(0,8)}... hard=${placeResult.signatures[1].slice(0,8)}...`)
} else if (placeResult.signatures.length >= 1) {
updateData.slOrderTx = placeResult.signatures[0]
logger.log(`💾 Recorded SL signature: ${placeResult.signatures[0].slice(0,8)}...`)
}
await updateTradeState({
id: trade.id,
...updateData
})
logger.log(`✅ Database updated with new SL order signatures`)
} else {
console.error(`❌ Failed to place fresh SL order:`, placeResult.error)
logger.log(`⚠️ CRITICAL: Runner has NO STOP LOSS PROTECTION - manual intervention needed`)
}
} catch (placeError) {
console.error(`❌ Error placing fresh SL order:`, placeError)
logger.log(`⚠️ CRITICAL: Runner has NO STOP LOSS PROTECTION - manual intervention needed`)
}
} else {
// Normal flow: Has order signatures, can cancel and replace
logger.log(`✅ Order signatures found - normal order update flow`)
try {
const { cancelAllOrders, placeExitOrders } = await import('../drift/orders')
logger.log(`🔄 Cancelling old exit orders...`)
const cancelResult = await cancelAllOrders(trade.symbol)
if (cancelResult.success) {
logger.log(`✅ Cancelled ${cancelResult.cancelledCount} old orders`)
}
logger.log(`🛡️ Placing new exit orders with SL at breakeven...`)
const orderResult = await placeExitOrders({
symbol: trade.symbol,
direction: trade.direction,
entryPrice: trade.entryPrice,
positionSizeUSD: trade.currentSize, // Runner size
stopLossPrice: trade.stopLossPrice, // At breakeven now
tp1Price: trade.tp2Price, // TP2 becomes new TP1 for runner
tp2Price: 0, // No TP2 for runner
tp1SizePercent: 0, // Close 0% at TP2 (activates trailing)
tp2SizePercent: 0, // No TP2
softStopPrice: 0,
hardStopPrice: 0,
})
if (orderResult.success) {
logger.log(`✅ Exit orders updated with SL at breakeven`)
} else {
console.error(`❌ Failed to update exit orders:`, orderResult.error)
}
} catch (error) {
console.error(`❌ Failed to update on-chain orders after TP1:`, error)
}
}
await this.saveTradeState(trade)
} else if (trade.tp1Hit && !trade.tp2Hit && reductionPercent >= 85) {
// TP2 fired (total should be ~95% closed, 5% runner left)
logger.log(`🎯 TP2 detected as filled! Reduction: ${reductionPercent.toFixed(1)}%`)
trade.tp2Hit = true
trade.currentSize = positionSizeUSD
trade.trailingStopActive = true
logger.log(`🏃 Runner active: $${positionSizeUSD.toFixed(2)} with ${this.config.trailingStopPercent}% trailing stop`)
await this.saveTradeState(trade)
// CRITICAL: Don't return early! Continue monitoring the runner position
// The trailing stop logic at line 732 needs to run
} else {
// Partial fill detected but unclear which TP - just update size
logger.log(`⚠️ Unknown partial fill detected - updating tracked size to $${positionSizeUSD.toFixed(2)}`)
trade.currentSize = positionSizeUSD
await this.saveTradeState(trade)
}
}
// CRITICAL: Check for entry price mismatch (NEW position opened)
// This can happen if user manually closed and opened a new position
// Only check if we haven't detected TP fills (entry price changes after partial closes on Drift)
if (!trade.tp1Hit && !trade.tp2Hit) {
const entryPriceDiff = Math.abs(position.entryPrice - trade.entryPrice)
const entryPriceDiffPercent = (entryPriceDiff / trade.entryPrice) * 100
if (entryPriceDiffPercent > 0.5) {
// Entry prices differ by >0.5% - this is a DIFFERENT position
logger.log(`⚠️ Position ${trade.symbol} entry mismatch: tracking $${trade.entryPrice.toFixed(4)} but found $${position.entryPrice.toFixed(4)}`)
logger.log(`🗑️ This is a different/newer position - removing old trade from monitoring`)
// Mark the old trade as closed (we lost track of it)
// Calculate approximate P&L using last known price
const profitPercent = this.calculateProfitPercent(
trade.entryPrice,
trade.lastPrice,
trade.direction
)
const accountPnLPercent = profitPercent * trade.leverage
const estimatedPnL = (trade.currentSize * profitPercent) / 100
logger.log(`💰 Estimated P&L for lost trade: ${profitPercent.toFixed(2)}% price → ${accountPnLPercent.toFixed(2)}% account → $${estimatedPnL.toFixed(2)} realized`)
try {
await updateTradeExit({
positionId: trade.positionId,
exitPrice: trade.lastPrice,
exitReason: 'SOFT_SL', // Unknown - just mark as closed
realizedPnL: estimatedPnL,
exitOrderTx: 'UNKNOWN_CLOSURE',
holdTimeSeconds: Math.floor((Date.now() - trade.entryTime) / 1000),
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
maxGain: Math.max(0, trade.maxFavorableExcursion),
maxFavorableExcursion: trade.maxFavorableExcursion,
maxAdverseExcursion: trade.maxAdverseExcursion,
maxFavorablePrice: trade.maxFavorablePrice,
maxAdversePrice: trade.maxAdversePrice,
})
logger.log(`💾 Old trade marked as closed (lost tracking) with estimated P&L: $${estimatedPnL.toFixed(2)}`)
} catch (dbError) {
console.error('❌ Failed to save lost trade closure:', dbError)
}
// Remove from monitoring WITHOUT cancelling orders (they belong to the new position!)
logger.log(`🗑️ Removing old trade WITHOUT cancelling orders`)
this.activeTrades.delete(trade.id)
if (this.activeTrades.size === 0 && this.isMonitoring) {
this.stopMonitoring()
}
return
}
}
}
// CRITICAL: Skip external closure detection if close is already in progress (Nov 16, 2025)
// This prevents duplicate P&L compounding when close tx confirmed but Drift not yet propagated
// CRITICAL FIX (Dec 7, 2025): Extended timeout from 60s to 5 minutes
// Root cause: Drift state propagation can take MUCH longer than 60 seconds
// 60s timeout caused false "external closure" detection while position actually still closing
// Result: Position removed from monitoring prematurely, left unprotected for 90+ minutes
if (trade.closingInProgress) {
// Check if close has been stuck for >5 minutes (abnormal - Drift should propagate by then)
const timeInClosing = Date.now() - (trade.closeConfirmedAt || Date.now())
if (timeInClosing > 300000) { // 5 minutes instead of 60 seconds
logger.log(`⚠️ Close stuck in progress for ${(timeInClosing / 1000).toFixed(0)}s (5+ min) - allowing external closure check`)
logger.log(` This is ABNORMAL - Drift state should have propagated within 5 minutes`)
trade.closingInProgress = false // Reset flag to allow cleanup
} else {
// Normal case: Close confirmed recently, waiting for Drift propagation (can take up to 5 min)
// Skip external closure detection entirely to prevent duplicate P&L updates
logger.log(`🔒 Close in progress (${(timeInClosing / 1000).toFixed(0)}s) - skipping external closure check`)
// Continue to price calculations below (monitoring continues normally)
}
}
// CRITICAL FIX (Nov 20, 2025): Check if price hit TP2 BEFORE external closure detection
// This activates trailing stop even if position fully closes before we detect TP2
if (trade.tp1Hit && !trade.tp2Hit && !trade.closingInProgress) {
const reachedTP2 = this.shouldTakeProfit2(currentPrice, trade)
if (reachedTP2) {
// Calculate profit percent for logging
const profitPercent = this.calculateProfitPercent(
trade.entryPrice,
currentPrice,
trade.direction
)
logger.log(`🎊 TP2 PRICE REACHED: ${trade.symbol} at ${profitPercent.toFixed(2)}%`)
logger.log(` Activating trailing stop for runner protection`)
trade.tp2Hit = true
trade.trailingStopActive = true
// Initialize peak price for trailing if not set
if (trade.peakPrice === 0 ||
(trade.direction === 'long' && currentPrice > trade.peakPrice) ||
(trade.direction === 'short' && currentPrice < trade.peakPrice)) {
trade.peakPrice = currentPrice
}
// Save state
await this.saveTradeState(trade)
}
}
if ((position === null || position.size === 0) && !trade.closingInProgress) {
// CRITICAL FIX (Nov 24, 2025): IMMEDIATELY mark as closingInProgress
// This prevents ANY duplicate processing before DB update completes
trade.closingInProgress = true
trade.closeConfirmedAt = Date.now()
logger.log(`🔒 Marked ${trade.symbol} as closingInProgress to prevent duplicate external closure processing`)
// CRITICAL FIX (Nov 20, 2025): If TP1 already hit, this is RUNNER closure
// We should have been monitoring with trailing stop active
// Check if we should have had trailing stop protection
if (trade.tp1Hit && !trade.tp2Hit) {
logger.log(`⚠️ RUNNER CLOSED EXTERNALLY: ${trade.symbol}`)
logger.log(` TP1 hit: true, TP2 hit: false`)
logger.log(` This runner should have had trailing stop protection!`)
logger.log(` Likely cause: Monitoring detected full closure before TP2 price check`)
// Check if price reached TP2 - if so, trailing should have been active
const reachedTP2 = trade.direction === 'long'
? currentPrice >= (trade.tp2Price || 0)
: currentPrice <= (trade.tp2Price || 0)
if (reachedTP2) {
logger.log(` ⚠️ Price reached TP2 ($${trade.tp2Price?.toFixed(4)}) but tp2Hit was false!`)
logger.log(` Trailing stop should have been active but wasn't`)
} else {
logger.log(` Runner hit SL before reaching TP2 ($${trade.tp2Price?.toFixed(4)})`)
}
}
// CRITICAL: Use original position size for P&L calculation on external closures
// trade.currentSize may already be 0 if on-chain orders closed the position before
// Position Manager detected it, causing zero P&L bug
// HOWEVER: If this was a phantom trade (extreme size mismatch), set P&L to 0
// CRITICAL: Determine size for P&L calculation based on TP1 status
// If TP1 already hit, we're closing the RUNNER only (currentSize)
// If TP1 not hit, we're closing the FULL position (originalPositionSize)
const sizeForPnL = trade.tp1Hit ? trade.currentSize : trade.originalPositionSize
// Check if this was a phantom trade by looking at ORIGINAL size mismatch
// Phantom = position opened but size was <50% of expected FROM THE START
// DO NOT flag runners after TP1 as phantom!
const wasPhantom = !trade.tp1Hit && trade.currentSize > 0 && (trade.currentSize / trade.positionSize) < 0.5
logger.log(`📊 External closure detected - Position size tracking:`)
logger.log(` Original size: $${trade.positionSize.toFixed(2)}`)
logger.log(` Tracked current size: $${trade.currentSize.toFixed(2)}`)
logger.log(` Using for P&L calc: $${sizeForPnL.toFixed(2)} (full position - exit reason will determine TP1 vs SL)`)
if (wasPhantom) {
logger.log(` ⚠️ PHANTOM TRADE: Setting P&L to 0 (size mismatch >50%)`)
}
// CRITICAL FIX (Nov 26, 2025): Calculate P&L from actual entry/exit prices
// ALWAYS use entry price vs current price with the ACTUAL position size in USD
// DO NOT rely on Drift settledPnL - it's zero for closed positions
// DO NOT use token size - use the USD notional size from when position opened
let totalRealizedPnL = 0
let runnerProfitPercent = 0
if (!wasPhantom) {
// Calculate profit percentage from entry to current price
runnerProfitPercent = this.calculateProfitPercent(
trade.entryPrice,
currentPrice,
trade.direction
)
// CRITICAL: Use USD notional size, NOT token size
// sizeForPnL is already in USD from above calculation
totalRealizedPnL = (sizeForPnL * runnerProfitPercent) / 100
logger.log(` 💰 P&L calculation:`)
logger.log(` Entry: $${trade.entryPrice.toFixed(4)} → Exit: $${currentPrice.toFixed(4)}`)
logger.log(` Profit %: ${runnerProfitPercent.toFixed(3)}%`)
logger.log(` Position size: $${sizeForPnL.toFixed(2)}`)
logger.log(` Realized P&L: $${totalRealizedPnL.toFixed(2)}`)
} else {
logger.log(` Phantom trade P&L: $0.00`)
}
// Determine exit reason from P&L percentage and trade state
// Use actual profit percent to determine what order filled
let exitReason: 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' | 'TRAILING_SL' = 'SL'
// CRITICAL (Nov 20, 2025): Check if trailing stop was active
// If so, this is a trailing stop exit, not regular SL
if (trade.tp2Hit && trade.trailingStopActive) {
logger.log(` 🏃 Runner closed with TRAILING STOP active`)
logger.log(` Peak price: $${trade.peakPrice.toFixed(4)}, Current: $${currentPrice.toFixed(4)}`)
// Check if price dropped from peak (trailing stop hit)
const isPullback = trade.direction === 'long'
? currentPrice < trade.peakPrice * 0.99 // More than 1% below peak
: currentPrice > trade.peakPrice * 1.01 // More than 1% above peak
if (isPullback) {
exitReason = 'TRAILING_SL' // Distinguish from regular SL (Nov 24, 2025)
logger.log(` ✅ Confirmed: Trailing stop hit (pulled back from peak)`)
} else {
// Very close to peak - might be emergency close or manual
exitReason = 'TP2' // Give credit for reaching runner profit target
logger.log(` ✅ Closed near peak - counting as TP2`)
}
} else if (runnerProfitPercent > 0.3) {
// Positive profit - was a TP order
if (runnerProfitPercent >= 1.2) {
// Large profit (>1.2%) - TP2 range
exitReason = 'TP2'
} else {
// Moderate profit (0.3-1.2%) - TP1 range
exitReason = 'TP1'
}
} else {
// Negative or tiny profit - was SL
exitReason = 'SL'
}
// Update database - CRITICAL: Only update once per trade!
const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000)
// CRITICAL BUG FIX: Mark trade as processed IMMEDIATELY to prevent duplicate updates
// Remove from monitoring BEFORE database update to prevent race condition
const tradeId = trade.id
// VERIFICATION: Check if already removed (would indicate duplicate processing attempt)
if (!this.activeTrades.has(tradeId)) {
logger.log(`⚠️ DUPLICATE PROCESSING PREVENTED: Trade ${tradeId} already removed from monitoring`)
logger.log(` This is the bug fix working - without it, we'd update DB again with compounded P&L`)
return // Already processed, don't update DB again
}
this.activeTrades.delete(tradeId)
logger.log(`🗑️ Removed trade ${tradeId} from monitoring (BEFORE DB update to prevent duplicates)`)
logger.log(` Active trades remaining: ${this.activeTrades.size}`)
// CRITICAL: Cancel all remaining orders for this position (ghost order cleanup)
// When position closes externally (on-chain SL/TP), TP/SL orders may remain active
// These ghost orders can trigger unintended positions if price moves to those levels
logger.log(`🗑️ Cancelling remaining orders for ${trade.symbol}...`)
try {
const { cancelAllOrders } = await import('../drift/orders')
const cancelResult = await cancelAllOrders(trade.symbol)
if (cancelResult.success) {
logger.log(`✅ Cancelled ${cancelResult.cancelledCount || 0} ghost orders`)
} else {
console.error(`⚠️ Failed to cancel orders: ${cancelResult.error}`)
}
} catch (cancelError) {
console.error('❌ Error cancelling ghost orders:', cancelError)
// Don't fail the trade closure if order cancellation fails
}
try {
await updateTradeExit({
positionId: trade.positionId,
exitPrice: currentPrice,
exitReason,
realizedPnL: totalRealizedPnL,
exitOrderTx: 'ON_CHAIN_ORDER',
holdTimeSeconds,
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
maxGain: Math.max(0, trade.maxFavorableExcursion),
maxFavorableExcursion: trade.maxFavorableExcursion,
maxAdverseExcursion: trade.maxAdverseExcursion,
maxFavorablePrice: trade.maxFavorablePrice,
maxAdversePrice: trade.maxAdversePrice,
})
logger.log(`💾 External closure recorded: ${exitReason} at $${currentPrice} | P&L: $${totalRealizedPnL.toFixed(2)}`)
// CRITICAL FIX (Dec 3, 2025): Check revenge eligibility for external closures
// Bug Fix: External closures (on-chain SL orders) weren't checking if quality 85+ for revenge
// Solution: After DB save, check if this was a quality 85+ SL stop-out and record for revenge
const qualityScore = trade.signalQualityScore || 0
if (exitReason === 'SL' && qualityScore >= 85) {
console.log(`🔍 Quality ${qualityScore} SL stop-out (external) - checking revenge eligibility...`)
try {
const { getStopHuntTracker } = await import('./stop-hunt-tracker')
const stopHuntTracker = getStopHuntTracker()
await stopHuntTracker.recordStopHunt({
originalTradeId: trade.id,
symbol: trade.symbol,
direction: trade.direction,
stopHuntPrice: currentPrice,
originalEntryPrice: trade.entryPrice,
originalQualityScore: qualityScore,
originalADX: trade.adxAtEntry || 0,
originalATR: trade.atrAtEntry || 0,
stopLossAmount: Math.abs(totalRealizedPnL)
})
console.log(`🎯 Stop hunt recorded (external closure) - revenge window active for 4 hours`)
} catch (revengeError) {
console.error('⚠️ Failed to record stop hunt for revenge:', revengeError)
// Don't fail external closure if revenge recording fails
}
}
} catch (dbError) {
console.error('❌ Failed to save external closure:', dbError)
}
// CRITICAL FIX (Dec 7, 2025): Stop monitoring ONLY if Drift confirms no open positions
// Root cause: activeTrades.size === 0 doesn't guarantee Drift has no positions
// Scenario: PM processes false "external closure", removes trade, tries to stop monitoring
// But position actually still open on Drift (state lag)!
// Solution: Query Drift to confirm no positions before stopping monitoring
if (this.activeTrades.size === 0 && this.isMonitoring) {
logger.log(`🔍 No active trades in Position Manager - verifying Drift has no open positions...`)
try {
const driftService = getDriftService()
const allPositions = await driftService.getAllPositions()
const openPositions = allPositions.filter(p => p.size !== 0)
if (openPositions.length > 0) {
logger.log(`🚨 CRITICAL SAFETY CHECK TRIGGERED!`)
logger.log(` Position Manager: 0 active trades`)
logger.log(` Drift Protocol: ${openPositions.length} open positions!`)
logger.log(` MISMATCH DETECTED - keeping monitoring ACTIVE for safety`)
// Log details of orphaned positions
for (const pos of openPositions) {
const marketConfig = Object.values(await import('../../config/trading').then(m => ({
'SOL-PERP': m.getMarketConfig('SOL-PERP'),
'BTC-PERP': m.getMarketConfig('BTC-PERP'),
'ETH-PERP': m.getMarketConfig('ETH-PERP')
}))).find(cfg => cfg.driftMarketIndex === pos.marketIndex)
logger.log(` - ${marketConfig?.symbol || `Market ${pos.marketIndex}`}: ${pos.size} tokens`)
}
logger.log(` Recommendation: Check /api/trading/positions and manually close if needed`)
logger.log(` DriftStateVerifier will attempt auto-recovery on next check`)
// DON'T stop monitoring - let DriftStateVerifier handle recovery
return
}
logger.log(`✅ Confirmed: Drift has no open positions, safe to stop monitoring`)
this.stopMonitoring()
} catch (error) {
console.error('❌ Error checking Drift positions before stop:', error)
logger.log(`⚠️ Could not verify Drift state - keeping monitoring ACTIVE for safety`)
// If we can't verify, DON'T stop monitoring (fail-safe)
}
}
return
}
// Position still exists on Drift - check for size mismatches
if (position && position.size !== 0 && !trade.closingInProgress) {
// CRITICAL: Convert position.size (base asset tokens) to USD for comparison
const positionSizeUSD = Math.abs(position.size) * currentPrice
// Position exists but size mismatch (partial close by TP1?)
if (positionSizeUSD < trade.currentSize * 0.95) { // 5% tolerance
logger.log(`⚠️ Position size mismatch: expected $${trade.currentSize.toFixed(2)}, got $${positionSizeUSD.toFixed(2)}`)
// CRITICAL: Check if position direction changed (signal flip, not TP1!)
const positionDirection = position.side === 'long' ? 'long' : 'short'
if (positionDirection !== trade.direction) {
logger.log(`🔄 DIRECTION CHANGE DETECTED: ${trade.direction}${positionDirection}`)
logger.log(` This is a signal flip, not TP1! Closing old position as manual.`)
// Calculate actual P&L on full position
const profitPercent = this.calculateProfitPercent(trade.entryPrice, currentPrice, trade.direction)
const actualPnL = (trade.positionSize * profitPercent) / 100
try {
const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000)
await updateTradeExit({
positionId: trade.positionId,
exitPrice: currentPrice,
exitReason: 'manual',
realizedPnL: actualPnL,
exitOrderTx: 'SIGNAL_FLIP',
holdTimeSeconds,
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
maxGain: Math.max(0, trade.maxFavorableExcursion),
maxFavorableExcursion: trade.maxFavorableExcursion,
maxAdverseExcursion: trade.maxAdverseExcursion,
maxFavorablePrice: trade.maxFavorablePrice,
maxAdversePrice: trade.maxAdversePrice,
})
logger.log(`💾 Signal flip closure recorded: P&L $${actualPnL.toFixed(2)}`)
} catch (dbError) {
console.error('❌ Failed to save signal flip closure:', dbError)
}
await this.removeTrade(trade.id)
return
}
// CRITICAL: If mismatch is extreme (>50%), this is a phantom trade
const sizeRatio = positionSizeUSD / trade.currentSize
if (sizeRatio < 0.5) {
logger.log(`🚨 EXTREME SIZE MISMATCH (${(sizeRatio * 100).toFixed(1)}%) - Closing phantom trade`)
logger.log(` Expected: $${trade.currentSize.toFixed(2)}`)
logger.log(` Actual: $${positionSizeUSD.toFixed(2)}`)
// Close as phantom trade
try {
const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000)
await updateTradeExit({
positionId: trade.positionId,
exitPrice: currentPrice,
exitReason: 'manual',
realizedPnL: 0,
exitOrderTx: 'AUTO_CLEANUP',
holdTimeSeconds,
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
maxGain: Math.max(0, trade.maxFavorableExcursion),
maxFavorableExcursion: trade.maxFavorableExcursion,
maxAdverseExcursion: trade.maxAdverseExcursion,
maxFavorablePrice: trade.maxFavorablePrice,
maxAdversePrice: trade.maxAdversePrice,
})
logger.log(`💾 Phantom trade closed`)
} catch (dbError) {
console.error('❌ Failed to close phantom trade:', dbError)
}
await this.removeTrade(trade.id)
return
}
// CRITICAL FIX (Nov 30, 2025): MUST verify price reached TP1 before setting flag
// BUG: Setting tp1Hit=true based ONLY on size mismatch caused premature order cancellation
// Size reduction could be: partial fill, slippage, external action, RPC staleness
// ONLY set tp1Hit when BOTH conditions met: size reduced AND price target reached
const tp1PriceReached = this.shouldTakeProfit1(currentPrice, trade)
if (tp1PriceReached) {
logger.log(`✅ TP1 VERIFIED: Size mismatch + price target reached`)
logger.log(` Size: $${trade.currentSize.toFixed(2)}$${positionSizeUSD.toFixed(2)} (${((positionSizeUSD / trade.currentSize) * 100).toFixed(1)}%)`)
logger.log(` Price: ${currentPrice.toFixed(4)} crossed TP1 target ${trade.tp1Price.toFixed(4)}`)
// Update current size to match reality (already in USD)
trade.currentSize = positionSizeUSD
trade.tp1Hit = true
await this.saveTradeState(trade)
logger.log(`🎉 TP1 HIT: ${trade.symbol} via on-chain order (detected by size reduction)`)
} else {
logger.log(`⚠️ Size reduced but TP1 price NOT reached yet - NOT triggering TP1 logic`)
logger.log(` Current: ${currentPrice.toFixed(4)}, TP1 target: ${trade.tp1Price.toFixed(4)} (${trade.direction === 'long' ? 'need higher' : 'need lower'})`)
logger.log(` Size: $${trade.currentSize.toFixed(2)}$${positionSizeUSD.toFixed(2)} (${((positionSizeUSD / trade.currentSize) * 100).toFixed(1)}%)`)
logger.log(` Likely: Partial fill, slippage, or external action`)
// Update tracked size but DON'T trigger TP1 logic
trade.currentSize = positionSizeUSD
await this.saveTradeState(trade)
// Continue monitoring - TP1 logic will trigger when price actually crosses target
}
}
} // End of: if (position && position.size !== 0 && !trade.closingInProgress)
} catch (error) {
// If we can't check position, continue with monitoring (don't want to false-positive)
// This can happen briefly during startup while Drift service initializes
if ((error as Error).message?.includes('not initialized')) {
// Silent - expected during initialization
} else {
console.error(`⚠️ Could not verify on-chain position for ${trade.symbol}:`, error)
}
}
// Calculate P&L
const profitPercent = this.calculateProfitPercent(
trade.entryPrice,
currentPrice,
trade.direction
)
const currentPnLDollars = (trade.currentSize * profitPercent) / 100
trade.unrealizedPnL = currentPnLDollars
// Track peak P&L (MFE - Maximum Favorable Excursion)
if (trade.unrealizedPnL > trade.peakPnL) {
trade.peakPnL = trade.unrealizedPnL
}
// Track MAE/MFE in PERCENTAGE (not dollars!)
// CRITICAL FIX (Nov 23, 2025): Schema expects % (0.48 = 0.48%), not dollar amounts
// Bug was storing $64.08 when actual was 0.48%, causing 100× inflation in analysis
if (profitPercent > trade.maxFavorableExcursion) {
trade.maxFavorableExcursion = profitPercent
trade.maxFavorablePrice = currentPrice
}
if (profitPercent < trade.maxAdverseExcursion) {
trade.maxAdverseExcursion = profitPercent
trade.maxAdversePrice = currentPrice
}
// Track peak price for trailing stop
if (trade.direction === 'long') {
if (currentPrice > trade.peakPrice) {
trade.peakPrice = currentPrice
}
} else {
if (currentPrice < trade.peakPrice || trade.peakPrice === 0) {
trade.peakPrice = currentPrice
}
}
// LAYER 3: Ghost detection during normal monitoring (Nov 15, 2025)
// Every 20 price checks (~40 seconds), verify position still exists on Drift
// This catches ghosts quickly without requiring 5-minute validation timer
if (trade.priceCheckCount % 20 === 0) {
try {
const driftService = getDriftService()
if (driftService && (driftService as any).isInitialized) {
const marketConfig = getMarketConfig(trade.symbol)
const position = await driftService.getPosition(marketConfig.driftMarketIndex)
// Position missing on Drift but we're still tracking it = ghost
if (!position || Math.abs(position.size) < 0.01) {
logger.log(`🔴 GHOST DETECTED in monitoring loop: ${trade.symbol}`)
logger.log(` Position Manager thinks it's open, but Drift shows closed`)
await this.handleExternalClosure(trade, 'Ghost detected during monitoring')
return // Exit monitoring for this position
}
}
} catch (checkError) {
// Silently skip this check on RPC errors - don't spam logs
}
}
// Log status every 10 checks (~20 seconds)
if (trade.priceCheckCount % 10 === 0) {
logger.log(
`📊 ${trade.symbol} | ` +
`Price: ${currentPrice.toFixed(4)} | ` +
`P&L: ${profitPercent.toFixed(2)}% | ` +
`Unrealized: $${trade.unrealizedPnL.toFixed(2)} | ` +
`Peak: $${trade.peakPnL.toFixed(2)} | ` +
`MFE: $${trade.maxFavorableExcursion.toFixed(2)} | ` +
`MAE: $${trade.maxAdverseExcursion.toFixed(2)}`
)
}
// Check exit conditions (in order of priority)
// 1. Emergency stop (-2%)
if (this.shouldEmergencyStop(currentPrice, trade)) {
logger.log(`🚨 EMERGENCY STOP: ${trade.symbol}`)
await this.executeExit(trade, 100, 'emergency', currentPrice)
return
}
// 2. Stop loss (BEFORE TP1)
if (!trade.tp1Hit && this.shouldStopLoss(currentPrice, trade)) {
logger.log(`🔴 STOP LOSS: ${trade.symbol} at ${profitPercent.toFixed(2)}%`)
await this.executeExit(trade, 100, 'SL', currentPrice)
return
}
// 2b. CRITICAL: Runner stop loss (AFTER TP1, BEFORE TP2)
// This protects the runner position after TP1 closes main position
if (trade.tp1Hit && !trade.tp2Hit && this.shouldStopLoss(currentPrice, trade)) {
logger.log(`🔴 RUNNER STOP LOSS: ${trade.symbol} at ${profitPercent.toFixed(2)}% (profit lock triggered)`)
await this.executeExit(trade, 100, 'SL', currentPrice)
return
}
// 3. Take profit 1 (closes configured %)
if (!trade.tp1Hit && this.shouldTakeProfit1(currentPrice, trade)) {
logger.log(`🎉 TP1 HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}%`)
// CRITICAL: Set flag BEFORE async executeExit to prevent race condition
// Multiple monitoring loops can trigger TP1 simultaneously if we wait until after
trade.tp1Hit = true
await this.executeExit(trade, this.config.takeProfit1SizePercent, 'TP1', currentPrice)
trade.currentSize = trade.positionSize * ((100 - this.config.takeProfit1SizePercent) / 100)
// ADX-based runner SL positioning (Nov 19, 2025)
// Strong trends get more room, weak trends protect capital
let runnerSlPercent: number
const adx = trade.adxAtEntry || 0
if (adx < 20) {
runnerSlPercent = 0 // Weak trend: breakeven, preserve capital
} else if (adx < 25) {
runnerSlPercent = -0.3 // Moderate trend: some room
} else {
runnerSlPercent = -0.55 // Strong trend: full retracement room
}
const newStopLossPrice = this.calculatePrice(
trade.entryPrice,
runnerSlPercent,
trade.direction
)
trade.stopLossPrice = newStopLossPrice
trade.slMovedToBreakeven = true
logger.log(`🔒 ADX-based runner SL: ${adx.toFixed(1)}${runnerSlPercent}% (${this.config.takeProfit1SizePercent}% closed, ${100 - this.config.takeProfit1SizePercent}% remaining): ${newStopLossPrice.toFixed(4)}`)
// CRITICAL: Cancel old on-chain SL orders and place new ones at updated price
// BUT: Only if this is the ONLY active trade on this symbol
// Multiple positions on same symbol = can't distinguish which orders belong to which trade
try {
const otherTradesOnSymbol = Array.from(this.activeTrades.values()).filter(
t => t.symbol === trade.symbol && t.id !== trade.id
)
if (otherTradesOnSymbol.length > 0) {
logger.log(`⚠️ Multiple trades on ${trade.symbol} detected (${otherTradesOnSymbol.length + 1} total)`)
logger.log(`⚠️ Skipping order cancellation to avoid wiping other positions' orders`)
logger.log(`⚠️ Relying on Position Manager software monitoring for remaining ${100 - this.config.takeProfit1SizePercent}%`)
} else {
logger.log('🗑️ Cancelling old stop loss orders...')
const { cancelAllOrders, placeExitOrders } = await import('../drift/orders')
const cancelResult = await cancelAllOrders(trade.symbol)
if (cancelResult.success) {
logger.log(`✅ Cancelled ${cancelResult.cancelledCount || 0} old orders`)
// Place ONLY new SL orders at breakeven/profit level for remaining position
// DO NOT place TP2 order - trailing stop is software-only (Position Manager monitors)
logger.log(`🛡️ Placing only SL orders at $${newStopLossPrice.toFixed(4)} for remaining position...`)
logger.log(` TP2 at $${trade.tp2Price.toFixed(4)} is software-monitored only (activates trailing stop)`)
const exitOrdersResult = await placeExitOrders({
symbol: trade.symbol,
positionSizeUSD: trade.currentSize,
entryPrice: trade.entryPrice,
tp1Price: trade.tp2Price, // Dummy value, won't be used (tp1SizePercent=0)
tp2Price: trade.tp2Price, // Dummy value, won't be used (tp2SizePercent=0)
stopLossPrice: newStopLossPrice,
tp1SizePercent: 0, // No TP1 order
tp2SizePercent: 0, // No TP2 order - trailing stop is software-only
direction: trade.direction,
useDualStops: this.config.useDualStops,
softStopPrice: trade.direction === 'long'
? newStopLossPrice * 1.005 // 0.5% above for long
: newStopLossPrice * 0.995, // 0.5% below for short
hardStopPrice: newStopLossPrice,
})
if (exitOrdersResult.success) {
logger.log('✅ New SL orders placed on-chain at updated price')
} else {
console.error('❌ Failed to place new SL orders:', exitOrdersResult.error)
}
}
}
} catch (error) {
console.error('❌ Failed to update on-chain SL orders:', error)
// Don't fail the TP1 exit if SL update fails - software monitoring will handle it
}
// Save state after TP1
await this.saveTradeState(trade)
return
}
// 4. Profit lock trigger
if (
trade.tp1Hit &&
!trade.slMovedToProfit &&
profitPercent >= this.config.profitLockTriggerPercent
) {
logger.log(`🔐 Profit lock trigger: ${trade.symbol}`)
trade.stopLossPrice = this.calculatePrice(
trade.entryPrice,
this.config.profitLockPercent,
trade.direction
)
trade.slMovedToProfit = true
logger.log(`🎯 SL moved to +${this.config.profitLockPercent}%: ${trade.stopLossPrice.toFixed(4)}`)
// Save state after profit lock
await this.saveTradeState(trade)
}
// CRITICAL: Check stop loss for runner (after TP1, before TP2)
if (trade.tp1Hit && !trade.tp2Hit && this.shouldStopLoss(currentPrice, trade)) {
logger.log(`🔴 RUNNER STOP LOSS: ${trade.symbol} at ${profitPercent.toFixed(2)}% (profit lock triggered)`)
await this.executeExit(trade, 100, 'SL', currentPrice)
return
}
// 5. Take profit 2 (remaining position)
if (trade.tp1Hit && !trade.tp2Hit && this.shouldTakeProfit2(currentPrice, trade)) {
logger.log(`🎊 TP2 HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}%`)
// CRITICAL: Set flag BEFORE any async operations to prevent race condition
trade.tp2Hit = true
// Calculate how much to close based on TP2 size percent
const percentToClose = this.config.takeProfit2SizePercent
// CRITICAL FIX: If percentToClose is 0, don't call executeExit (would close 100% due to minOrderSize)
// Instead, just mark TP2 as hit and activate trailing stop on full remaining position
if (percentToClose === 0) {
trade.trailingStopActive = true // Activate trailing stop immediately
logger.log(`🏃 TP2-as-Runner activated: ${((trade.currentSize / trade.positionSize) * 100).toFixed(1)}% remaining with trailing stop`)
logger.log(`📊 No position closed at TP2 - full ${trade.currentSize.toFixed(2)} USD remains as runner`)
// Save state after TP2
await this.saveTradeState(trade)
return
}
// If percentToClose > 0, execute partial close
await this.executeExit(trade, percentToClose, 'TP2', currentPrice)
// If some position remains, update size and activate trailing stop
if (percentToClose < 100) {
trade.currentSize = trade.currentSize * ((100 - percentToClose) / 100)
logger.log(`🏃 Runner activated: ${((trade.currentSize / trade.positionSize) * 100).toFixed(1)}% remaining with trailing stop`)
// Save state after TP2
await this.saveTradeState(trade)
}
return
}
// 6. Trailing stop for runner (after TP2)
if (trade.tp2Hit && this.config.useTrailingStop) {
// Check if trailing stop should be activated
if (!trade.trailingStopActive && profitPercent >= this.config.trailingStopActivation) {
trade.trailingStopActive = true
logger.log(`🎯 Trailing stop activated at +${profitPercent.toFixed(2)}%`)
}
// If trailing stop is active, adjust SL dynamically
if (trade.trailingStopActive) {
// PHASE 7.3: 1-Minute Adaptive TP/SL (Nov 27, 2025)
// Query fresh 1-minute ADX data and adjust trailing stop based on trend strength changes
let currentADX = trade.adxAtEntry || 0
let adxChange = 0
let usingFreshData = false
try {
const marketCache = getMarketDataCache()
const freshData = marketCache.get(trade.symbol)
if (freshData && freshData.adx) {
currentADX = freshData.adx
adxChange = currentADX - (trade.adxAtEntry || 0)
usingFreshData = true
logger.log(`📊 1-min ADX update: Entry ${(trade.adxAtEntry || 0).toFixed(1)} → Current ${currentADX.toFixed(1)} (${adxChange >= 0 ? '+' : ''}${adxChange.toFixed(1)} change)`)
}
} catch (error) {
logger.log(`⚠️ Could not fetch fresh ADX data, using entry ADX: ${error}`)
}
// Calculate ATR-based trailing distance with ADAPTIVE ADX multiplier
let trailingDistancePercent: number
if (trade.atrAtEntry && trade.atrAtEntry > 0) {
// Start with base ATR multiplier
let trailMultiplier = this.config.trailingStopAtrMultiplier
// ADAPTIVE ADX-based trend strength adjustment (Nov 27, 2025)
// Uses CURRENT 1-minute ADX if available, falls back to entry ADX
if (currentADX > 0) {
if (currentADX > 30) {
// Very strong trend (ADX > 30): 50% wider trail
trailMultiplier *= 1.5
logger.log(`📈 ${usingFreshData ? '1-min' : 'Entry'} ADX very strong (${currentADX.toFixed(1)}): Trail multiplier ${this.config.trailingStopAtrMultiplier}x → ${trailMultiplier.toFixed(2)}x`)
} else if (currentADX > 25) {
// Strong trend (ADX 25-30): 25% wider trail
trailMultiplier *= 1.25
logger.log(`📈 ${usingFreshData ? '1-min' : 'Entry'} ADX strong (${currentADX.toFixed(1)}): Trail multiplier ${this.config.trailingStopAtrMultiplier}x → ${trailMultiplier.toFixed(2)}x`)
}
// Else: weak/moderate trend, use base multiplier
// ACCELERATION BONUS: If ADX increased significantly, widen trail even more
if (usingFreshData && adxChange > 5) {
const oldMultiplier = trailMultiplier
trailMultiplier *= 1.3
logger.log(`🚀 ADX acceleration (+${adxChange.toFixed(1)} points): Trail multiplier ${oldMultiplier.toFixed(2)}x → ${trailMultiplier.toFixed(2)}x`)
}
// DECELERATION PENALTY: If ADX decreased significantly, tighten trail
if (usingFreshData && adxChange < -3) {
const oldMultiplier = trailMultiplier
trailMultiplier *= 0.7
logger.log(`⚠️ ADX deceleration (${adxChange.toFixed(1)} points): Trail multiplier ${oldMultiplier.toFixed(2)}x → ${trailMultiplier.toFixed(2)}x (tighter to protect)`)
}
}
// Profit acceleration: bigger profit = wider trail
if (profitPercent > 2.0) {
const oldMultiplier = trailMultiplier
trailMultiplier *= 1.3
logger.log(`💰 Large profit (${profitPercent.toFixed(2)}%): Trail multiplier ${oldMultiplier.toFixed(2)}x → ${trailMultiplier.toFixed(2)}x`)
}
// ATR-based: Use ATR% * adjusted multiplier
const atrPercent = (trade.atrAtEntry / currentPrice) * 100
const rawDistance = atrPercent * trailMultiplier
// Clamp between min and max
trailingDistancePercent = Math.max(
this.config.trailingStopMinPercent,
Math.min(this.config.trailingStopMaxPercent, rawDistance)
)
logger.log(`📊 Adaptive trailing: ATR ${trade.atrAtEntry.toFixed(4)} (${atrPercent.toFixed(2)}%) × ${trailMultiplier.toFixed(2)}x = ${trailingDistancePercent.toFixed(2)}%`)
} else {
// Fallback to configured legacy percent with min/max clamping
trailingDistancePercent = Math.max(
this.config.trailingStopMinPercent,
Math.min(this.config.trailingStopMaxPercent, this.config.trailingStopPercent)
)
logger.log(`⚠️ No ATR data, using fallback: ${trailingDistancePercent.toFixed(2)}%`)
}
const trailingStopPrice = this.calculatePrice(
trade.peakPrice,
-trailingDistancePercent, // Trail below peak
trade.direction
)
// Only move SL up (for long) or down (for short), never backwards
const shouldUpdate = trade.direction === 'long'
? trailingStopPrice > trade.stopLossPrice
: trailingStopPrice < trade.stopLossPrice
if (shouldUpdate) {
const oldSL = trade.stopLossPrice
trade.stopLossPrice = trailingStopPrice
logger.log(`📈 Trailing SL updated: ${oldSL.toFixed(4)}${trailingStopPrice.toFixed(4)} (${trailingDistancePercent.toFixed(2)}% below peak $${trade.peakPrice.toFixed(4)})`)
// Save state after trailing SL update (every 10 updates to avoid spam)
if (trade.priceCheckCount % 10 === 0) {
await this.saveTradeState(trade)
}
}
// Check if trailing stop hit
if (this.shouldStopLoss(currentPrice, trade)) {
logger.log(`🔴 TRAILING STOP HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}%`)
await this.executeExit(trade, 100, 'TRAILING_SL', currentPrice)
return
}
}
}
}
/**
* Execute exit (close position)
*
* Rate limit handling: If 429 error occurs, marks trade for retry
* instead of removing it from monitoring (prevents orphaned positions)
*
* CRITICAL FIX (Dec 2, 2025): Atomic deduplication at function entry
* Bug: Multiple monitoring loops detect SL/TP condition simultaneously
* - All call executeExit() before any can mark position as closing
* - Race condition in later removeTrade() call
* - Each execution sends Telegram notification
* - P&L values compound across notifications (16 duplicates, 796x inflation)
* Fix: Delete from activeTrades FIRST using atomic Map.delete()
* - Only first caller gets wasInMap=true, others get false and return
* - Prevents duplicate database updates, notifications, P&L compounding
* - Same pattern as ghost detection fix (handleExternalClosure)
*/
private async executeExit(
trade: ActiveTrade,
percentToClose: number,
reason: ExitResult['reason'],
currentPrice: number
): Promise<void> {
// CRITICAL FIX (Dec 2, 2025): Atomic deduplication for full closes
// For partial closes (TP1), we DON'T delete yet (position still monitored for TP2)
// For full closes (100%), delete FIRST to prevent duplicate execution
if (percentToClose >= 100) {
const tradeId = trade.id
const wasInMap = this.activeTrades.delete(tradeId)
if (!wasInMap) {
logger.log(`⚠️ DUPLICATE EXIT PREVENTED: ${tradeId} already processing ${reason}`)
logger.log(` This prevents duplicate Telegram notifications with compounding P&L`)
return
}
logger.log(`🗑️ Removed ${trade.symbol} from monitoring (${reason}) - atomic deduplication applied`)
}
try {
logger.log(`🔴 Executing ${reason} for ${trade.symbol} (${percentToClose}%)`)
const result = await closePosition({
symbol: trade.symbol,
percentToClose,
slippageTolerance: this.config.slippageTolerance,
})
if (!result.success) {
const errorMsg = result.error || 'Unknown error'
// Check if it's a rate limit error
if (errorMsg.includes('429') || errorMsg.toLowerCase().includes('rate limit')) {
console.error(`⚠️ Rate limited while closing ${trade.symbol} - will retry on next price update`)
// LAYER 2: Death spiral detector (Nov 15, 2025)
// If we've failed 20+ times, check Drift API to see if it's a ghost position
if (trade.priceCheckCount > 20 && !trade.closingInProgress) {
try {
const driftService = getDriftService()
const marketConfig = getMarketConfig(trade.symbol)
const position = await driftService.getPosition(marketConfig.driftMarketIndex)
// If position doesn't exist on Drift, it's a ghost - remove immediately
if (!position || Math.abs(position.size) < 0.01) {
logger.log(`🔴 LAYER 2: Ghost detected after ${trade.priceCheckCount} failures`)
logger.log(` Drift shows position closed/missing - removing from monitoring`)
// CRITICAL: Mark as closing to prevent duplicate processing
trade.closingInProgress = true
trade.closeConfirmedAt = Date.now()
await this.handleExternalClosure(trade, 'Layer 2: Ghost detected via Drift API')
return
} else {
logger.log(` Position verified on Drift (size: ${position.size}) - will keep retrying`)
}
} catch (checkError) {
console.error(` Could not verify position on Drift:`, checkError)
}
}
// DON'T remove trade from monitoring - let it retry naturally
// The retry logic in closePosition() already handled 3 attempts
// Next price update will trigger another exit attempt
return
}
console.error(`❌ Failed to close ${trade.symbol}:`, errorMsg)
return
}
// CRITICAL: Check if position needs verification (Nov 16, 2025)
// If close transaction confirmed but Drift still shows position open,
// DON'T mark as closed yet - keep monitoring until Drift confirms
if ((result as any).needsVerification) {
logger.log(`⚠️ Close transaction confirmed but position still exists on Drift`)
logger.log(` Keeping ${trade.symbol} in monitoring until Drift confirms closure`)
logger.log(` Ghost detection will handle final cleanup once Drift updates`)
// CRITICAL: Mark as "closing in progress" to prevent duplicate external closure detection
// Without this flag, the monitoring loop detects position as "externally closed"
// every 2 seconds and adds P&L repeatedly, causing 20x compounding bug
trade.closingInProgress = true
trade.closeConfirmedAt = Date.now()
logger.log(`🔒 Marked as closing in progress - external closure detection disabled`)
// Keep monitoring - ghost detection will eventually see it's closed
return
}
// Update trade state
if (percentToClose >= 100) {
// Full close - remove from monitoring
trade.realizedPnL += result.realizedPnL || 0
// Save to database (only for valid exit reasons)
if (reason !== 'error') {
try {
const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000)
await updateTradeExit({
positionId: trade.positionId,
exitPrice: result.closePrice || currentPrice,
exitReason: reason as 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' | 'manual' | 'emergency',
realizedPnL: trade.realizedPnL,
exitOrderTx: result.transactionSignature || 'MANUAL_CLOSE',
holdTimeSeconds,
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
maxGain: Math.max(0, trade.maxFavorableExcursion),
maxFavorableExcursion: trade.maxFavorableExcursion,
maxAdverseExcursion: trade.maxAdverseExcursion,
maxFavorablePrice: trade.maxFavorablePrice,
maxAdversePrice: trade.maxAdversePrice,
})
logger.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
})
logger.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
}
}
// CRITICAL: Trade already removed from activeTrades at function start (atomic delete)
// No need to call removeTrade() again - just stop monitoring if empty
if (this.activeTrades.size === 0 && this.isMonitoring) {
this.stopMonitoring()
}
logger.log(`✅ Position closed | P&L: $${trade.realizedPnL.toFixed(2)} | Reason: ${reason}`)
// Send Telegram notification
await sendPositionClosedNotification({
symbol: trade.symbol,
direction: trade.direction,
entryPrice: trade.entryPrice,
exitPrice: result.closePrice || currentPrice,
positionSize: trade.positionSize,
realizedPnL: trade.realizedPnL,
exitReason: reason,
holdTimeSeconds: Math.floor((Date.now() - trade.entryTime) / 1000),
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
maxGain: Math.max(0, trade.maxFavorableExcursion),
})
// 🎯 STOP HUNT REVENGE SYSTEM (Nov 20, 2025)
// Record high-quality stop-outs for automatic revenge re-entry
if (reason === 'SL' && trade.signalQualityScore && trade.signalQualityScore >= 85) {
try {
const stopHuntTracker = getStopHuntTracker()
await stopHuntTracker.recordStopHunt({
originalTradeId: trade.id,
symbol: trade.symbol,
direction: trade.direction,
stopHuntPrice: result.closePrice || currentPrice,
originalEntryPrice: trade.entryPrice,
originalQualityScore: trade.signalQualityScore,
originalADX: trade.adxAtEntry,
originalATR: trade.atrAtEntry,
stopLossAmount: Math.abs(trade.realizedPnL), // Loss amount (positive)
})
console.log(`🎯 Stop hunt recorded - revenge window activated`)
} catch (stopHuntError) {
console.error('❌ Failed to record stop hunt:', stopHuntError)
}
}
} else {
// Partial close (TP1)
trade.realizedPnL += result.realizedPnL || 0
// result.closedSize is returned in base asset units (e.g., SOL), convert to USD using closePrice
const closePriceForCalc = result.closePrice || currentPrice
const closedSizeBase = result.closedSize || 0
const closedUSD = closedSizeBase * closePriceForCalc
trade.currentSize = Math.max(0, trade.currentSize - closedUSD)
logger.log(`✅ Partial close executed | Realized: $${(result.realizedPnL || 0).toFixed(2)} | Closed (base): ${closedSizeBase.toFixed(6)} | Closed (USD): $${closedUSD.toFixed(2)} | Remaining USD: $${trade.currentSize.toFixed(2)}`)
// Persist updated trade state so analytics reflect partial profits immediately
await this.saveTradeState(trade)
// Send Telegram notification for TP1 partial close
await sendPositionClosedNotification({
symbol: trade.symbol,
direction: trade.direction,
entryPrice: trade.entryPrice,
exitPrice: result.closePrice || currentPrice,
positionSize: closedUSD, // Show only the closed portion
realizedPnL: result.realizedPnL || 0,
exitReason: `${reason} (${percentToClose}% closed, ${(100 - percentToClose).toFixed(0)}% runner remaining)`,
holdTimeSeconds: Math.floor((Date.now() - trade.entryTime) / 1000),
maxDrawdown: Math.abs(Math.min(0, trade.maxAdverseExcursion)),
maxGain: Math.max(0, trade.maxFavorableExcursion),
})
}
} catch (error) {
console.error(`❌ Error executing exit for ${trade.symbol}:`, error)
}
}
/**
* Decision helpers
*/
private shouldEmergencyStop(price: number, trade: ActiveTrade): boolean {
if (trade.direction === 'long') {
return price <= trade.emergencyStopPrice
} else {
return price >= trade.emergencyStopPrice
}
}
/**
* Check if current price is at a target price within tolerance
* Used to validate TP/SL hits vs manual closes
*/
private isPriceAtTarget(currentPrice: number, targetPrice: number, tolerance: number = 0.002): boolean {
if (!targetPrice || targetPrice === 0) return false
const diff = Math.abs(currentPrice - targetPrice) / targetPrice
return diff <= tolerance
}
private shouldStopLoss(price: number, trade: ActiveTrade): boolean {
if (trade.direction === 'long') {
return price <= trade.stopLossPrice
} else {
return price >= trade.stopLossPrice
}
}
private shouldTakeProfit1(price: number, trade: ActiveTrade): boolean {
if (trade.direction === 'long') {
return price >= trade.tp1Price
} else {
return price <= trade.tp1Price
}
}
private shouldTakeProfit2(price: number, trade: ActiveTrade): boolean {
if (trade.direction === 'long') {
return price >= trade.tp2Price
} else {
return price <= trade.tp2Price
}
}
/**
* Calculate profit percentage
*/
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 price based on percentage
*/
private calculatePrice(
entryPrice: number,
percent: number,
direction: 'long' | 'short'
): number {
if (direction === 'long') {
return entryPrice * (1 + percent / 100)
} else {
return entryPrice * (1 - percent / 100)
}
}
/**
* Emergency close all positions
*/
async closeAll(): Promise<void> {
logger.log('🚨 EMERGENCY: Closing all positions')
const trades = Array.from(this.activeTrades.values())
for (const trade of trades) {
await this.executeExit(trade, 100, 'emergency', trade.lastPrice)
}
logger.log('✅ All positions closed')
}
/**
* Save trade state to database (for persistence across restarts)
*/
private async saveTradeState(trade: ActiveTrade): Promise<void> {
try {
await updateTradeState({
positionId: trade.positionId,
currentSize: trade.currentSize,
tp1Hit: trade.tp1Hit,
slMovedToBreakeven: trade.slMovedToBreakeven,
slMovedToProfit: trade.slMovedToProfit,
stopLossPrice: trade.stopLossPrice,
realizedPnL: trade.realizedPnL,
unrealizedPnL: trade.unrealizedPnL,
peakPnL: trade.peakPnL,
lastPrice: trade.lastPrice,
})
} catch (error) {
const tradeId = (trade as any).id ?? 'unknown'
const positionId = trade.positionId ?? 'unknown'
console.error(`❌ Failed to save trade state (tradeId=${tradeId}, positionId=${positionId}, symbol=${trade.symbol}):`, error)
// Don't throw - state save is non-critical
}
}
/**
* Reload configuration from merged sources (used after settings updates)
*/
refreshConfig(partial?: Partial<TradingConfig>): void {
this.config = getMergedConfig(partial)
logger.log('🔄 Position Manager config refreshed')
}
/**
* Get monitoring status
*/
getStatus(): {
isMonitoring: boolean
activeTradesCount: number
symbols: string[]
} {
const symbols = [...new Set(
Array.from(this.activeTrades.values()).map(t => t.symbol)
)]
return {
isMonitoring: this.isMonitoring,
activeTradesCount: this.activeTrades.size,
symbols,
}
}
}
// Singleton instance
let positionManagerInstance: PositionManager | null = null
let initPromise: Promise<void> | null = null
export function getPositionManager(): PositionManager {
if (!positionManagerInstance) {
positionManagerInstance = new PositionManager()
// Initialize asynchronously (restore trades from database)
if (!initPromise) {
initPromise = positionManagerInstance.initialize().catch(error => {
console.error('❌ Failed to initialize Position Manager:', error)
})
}
}
return positionManagerInstance
}
export async function getInitializedPositionManager(): Promise<PositionManager> {
const manager = getPositionManager()
if (initPromise) {
await initPromise
}
return manager
}