/** * Position Manager * * Tracks active trades and manages automatic exits */ import { getDriftService, initializeDriftService } from '../drift/client' 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 = new Map() private config: TradingConfig private isMonitoring: boolean = false private initialized: boolean = false private validationInterval: NodeJS.Timeout | null = null constructor(config?: Partial) { this.config = getMergedConfig(config) console.log('โœ… Position manager created') } /** * Initialize and restore active trades from database */ async initialize(): Promise { if (this.initialized) { return } console.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) console.log(`โœ… Restored trade: ${activeTrade.symbol} ${activeTrade.direction} at $${activeTrade.entryPrice}`) } if (this.activeTrades.size > 0) { console.log(`๐ŸŽฏ Restored ${this.activeTrades.size} active trades`) await this.startMonitoring() } else { console.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 { console.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' console.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' console.log(`๐Ÿƒ Manual closure was Trailing SL (price at trailing stop target)`) } else { exitReason = 'SL' console.log(`๐Ÿ›‘ Manual closure was SL (price at target)`) } } else { console.log(`๐Ÿ‘ค Manual closure confirmed (price not at any target)`) console.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 console.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, }) console.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 { console.log(`๐Ÿ“Š Adding trade to monitor: ${trade.symbol} ${trade.direction}`) this.activeTrades.set(trade.id, trade) // 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) console.log(`โœ… Trade added. Active trades: ${this.activeTrades.size}`) // Start monitoring if not already running if (!this.isMonitoring && this.activeTrades.size > 0) { await this.startMonitoring() } } /** * Remove a trade from monitoring */ async removeTrade(tradeId: string): Promise { const trade = this.activeTrades.get(tradeId) if (trade) { console.log(`๐Ÿ—‘๏ธ Removing trade: ${trade.symbol}`) // Cancel all orders for this symbol (cleanup orphaned orders) try { const { cancelAllOrders } = await import('../drift/orders') const cancelResult = await cancelAllOrders(trade.symbol) if (cancelResult.success && cancelResult.cancelledCount! > 0) { console.log(`โœ… Cancelled ${cancelResult.cancelledCount} orphaned orders`) } } catch (error) { console.error('โŒ Failed to cancel orders during trade removal:', error) // Continue with removal even if cancel fails } 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) console.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 { if (this.activeTrades.size === 0) { return // Nothing to validate } console.log('๐Ÿ” Validating positions against Drift...') try { const driftService = getDriftService() // If Drift service not ready, skip this validation cycle if (!driftService || !(driftService as any).isInitialized) { console.log('โš ๏ธ Drift service not ready - skipping validation this cycle') console.log(` Positions in memory: ${this.activeTrades.size}`) console.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) { console.log(`๐Ÿ”ด Ghost position detected: ${trade.symbol} (${tradeId})`) console.log(` Database: exitReason IS NULL (thinks it's open)`) console.log(` Drift: Position ${position ? 'closed (size=' + position.size + ')' : 'missing'}`) console.log(` Cause: Likely failed DB update during external closure`) // Auto-cleanup: Handle as external closure await this.handleExternalClosure(trade, 'Ghost position cleanup') console.log(`โœ… Ghost position cleaned up: ${trade.symbol}`) } } catch (posError) { console.error(`โš ๏ธ Could not check ${trade.symbol} on Drift:`, posError) // Continue checking other positions } } console.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 { console.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) { console.log(`โš ๏ธ DUPLICATE PREVENTED: Trade ${tradeId} already processed, skipping`) console.log(` This prevents duplicate Telegram notifications with compounding P&L`) return } console.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 console.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, }) console.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 { if (this.isMonitoring) { return } // Get unique symbols from active trades const symbols = [...new Set( Array.from(this.activeTrades.values()).map(trade => trade.symbol) )] if (symbols.length === 0) { return } console.log('๐Ÿš€ Starting price monitoring for:', symbols) const priceMonitor = getPythPriceMonitor() await priceMonitor.start({ symbols, onPriceUpdate: async (update: PriceUpdate) => { await this.handlePriceUpdate(update) }, onError: (error: Error) => { console.error('โŒ Price monitor error:', error) }, }) this.isMonitoring = true console.log('โœ… Position monitoring active') // Schedule periodic validation to detect and cleanup ghost positions this.scheduleValidation() } /** * Stop price monitoring */ private async stopMonitoring(): Promise { if (!this.isMonitoring) { return } console.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 console.log('โœ… Position monitoring stopped') } /** * Handle price update for all relevant trades */ private async handlePriceUpdate(update: PriceUpdate): Promise { // Find all trades for this symbol const tradesForSymbol = Array.from(this.activeTrades.values()) .filter(trade => trade.symbol === update.symbol) 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 { // 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)) { console.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 { const driftService = getDriftService() // Skip position verification if Drift service isn't initialized yet // (happens briefly after restart while service initializes) if (!driftService || !(driftService as any).isInitialized) { // Service still initializing, skip this check cycle return } 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) { console.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 } // Position closed externally (by on-chain TP/SL order or manual closure) console.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 console.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) { console.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) { console.log(`โš ๏ธ Size reduction detected (${reductionPercent.toFixed(1)}%) but price NOT at TP1`) console.log(` Current: $${currentPrice.toFixed(4)}, TP1: $${trade.takeProfitPrice1?.toFixed(4) || 'N/A'}`) console.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) console.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 console.log(`๐Ÿ”’ ADX-based runner SL: ${adx.toFixed(1)} โ†’ 0% (breakeven - weak trend)`) } else if (adx < 25) { runnerSlPercent = -0.3 // Moderate trend: some room console.log(`๐Ÿ”’ ADX-based runner SL: ${adx.toFixed(1)} โ†’ -0.3% (moderate trend)`) } else { runnerSlPercent = -0.55 // Strong trend: full retracement room console.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 ) console.log(`๐Ÿ“Š Runner SL calculation: Entry $${trade.entryPrice.toFixed(4)} ${runnerSlPercent >= 0 ? '+' : ''}${runnerSlPercent}% = $${newStopLossPrice.toFixed(4)}`) console.log(` (${this.config.takeProfit1SizePercent}% closed, ${100 - this.config.takeProfit1SizePercent}% remaining)`) // Move SL to ADX-based position after TP1 trade.stopLossPrice = newStopLossPrice trade.slMovedToBreakeven = true console.log(`๐Ÿ›ก๏ธ Stop loss moved to: $${trade.stopLossPrice.toFixed(4)}`) // CRITICAL: Update on-chain orders to reflect new SL at breakeven try { const { cancelAllOrders, placeExitOrders } = await import('../drift/orders') console.log(`๐Ÿ”„ Cancelling old exit orders...`) const cancelResult = await cancelAllOrders(trade.symbol) if (cancelResult.success) { console.log(`โœ… Cancelled ${cancelResult.cancelledCount} old orders`) } console.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) { console.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) console.log(`๐ŸŽฏ TP2 detected as filled! Reduction: ${reductionPercent.toFixed(1)}%`) trade.tp2Hit = true trade.currentSize = positionSizeUSD trade.trailingStopActive = true console.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 console.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 console.log(`โš ๏ธ Position ${trade.symbol} entry mismatch: tracking $${trade.entryPrice.toFixed(4)} but found $${position.entryPrice.toFixed(4)}`) console.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 console.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, }) console.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!) console.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 if (trade.closingInProgress) { // Check if close has been stuck for >60 seconds (abnormal) const timeInClosing = Date.now() - (trade.closeConfirmedAt || Date.now()) if (timeInClosing > 60000) { console.log(`โš ๏ธ Close stuck in progress for ${(timeInClosing / 1000).toFixed(0)}s - allowing external closure check`) trade.closingInProgress = false // Reset flag to allow cleanup } else { // Normal case: Close confirmed recently, waiting for Drift propagation (5-10s) // Skip external closure detection entirely to prevent duplicate P&L updates console.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 ) console.log(`๐ŸŽŠ TP2 PRICE REACHED: ${trade.symbol} at ${profitPercent.toFixed(2)}%`) console.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() console.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) { console.log(`โš ๏ธ RUNNER CLOSED EXTERNALLY: ${trade.symbol}`) console.log(` TP1 hit: true, TP2 hit: false`) console.log(` This runner should have had trailing stop protection!`) console.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) { console.log(` โš ๏ธ Price reached TP2 ($${trade.tp2Price?.toFixed(4)}) but tp2Hit was false!`) console.log(` Trailing stop should have been active but wasn't`) } else { console.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 console.log(`๐Ÿ“Š External closure detected - Position size tracking:`) console.log(` Original size: $${trade.positionSize.toFixed(2)}`) console.log(` Tracked current size: $${trade.currentSize.toFixed(2)}`) console.log(` Using for P&L calc: $${sizeForPnL.toFixed(2)} (full position - exit reason will determine TP1 vs SL)`) if (wasPhantom) { console.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 console.log(` ๐Ÿ’ฐ P&L calculation:`) console.log(` Entry: $${trade.entryPrice.toFixed(4)} โ†’ Exit: $${currentPrice.toFixed(4)}`) console.log(` Profit %: ${runnerProfitPercent.toFixed(3)}%`) console.log(` Position size: $${sizeForPnL.toFixed(2)}`) console.log(` Realized P&L: $${totalRealizedPnL.toFixed(2)}`) } else { console.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) { console.log(` ๐Ÿƒ Runner closed with TRAILING STOP active`) console.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) console.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 console.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)) { console.log(`โš ๏ธ DUPLICATE PROCESSING PREVENTED: Trade ${tradeId} already removed from monitoring`) console.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) console.log(`๐Ÿ—‘๏ธ Removed trade ${tradeId} from monitoring (BEFORE DB update to prevent duplicates)`) console.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 console.log(`๐Ÿ—‘๏ธ Cancelling remaining orders for ${trade.symbol}...`) try { const { cancelAllOrders } = await import('../drift/orders') const cancelResult = await cancelAllOrders(trade.symbol) if (cancelResult.success) { console.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, }) console.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) } // Stop monitoring if no more trades if (this.activeTrades.size === 0 && this.isMonitoring) { this.stopMonitoring() } 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 console.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) { console.log(`๐Ÿ”„ DIRECTION CHANGE DETECTED: ${trade.direction} โ†’ ${positionDirection}`) console.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, }) console.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) { console.log(`๐Ÿšจ EXTREME SIZE MISMATCH (${(sizeRatio * 100).toFixed(1)}%) - Closing phantom trade`) console.log(` Expected: $${trade.currentSize.toFixed(2)}`) console.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, }) console.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) { console.log(`โœ… TP1 VERIFIED: Size mismatch + price target reached`) console.log(` Size: $${trade.currentSize.toFixed(2)} โ†’ $${positionSizeUSD.toFixed(2)} (${((positionSizeUSD / trade.currentSize) * 100).toFixed(1)}%)`) console.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) console.log(`๐ŸŽ‰ TP1 HIT: ${trade.symbol} via on-chain order (detected by size reduction)`) } else { console.log(`โš ๏ธ Size reduced but TP1 price NOT reached yet - NOT triggering TP1 logic`) console.log(` Current: ${currentPrice.toFixed(4)}, TP1 target: ${trade.tp1Price.toFixed(4)} (${trade.direction === 'long' ? 'need higher' : 'need lower'})`) console.log(` Size: $${trade.currentSize.toFixed(2)} โ†’ $${positionSizeUSD.toFixed(2)} (${((positionSizeUSD / trade.currentSize) * 100).toFixed(1)}%)`) console.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) { console.log(`๐Ÿ”ด GHOST DETECTED in monitoring loop: ${trade.symbol}`) console.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) { console.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)) { console.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)) { console.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)) { console.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)) { console.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 console.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) { console.log(`โš ๏ธ Multiple trades on ${trade.symbol} detected (${otherTradesOnSymbol.length + 1} total)`) console.log(`โš ๏ธ Skipping order cancellation to avoid wiping other positions' orders`) console.log(`โš ๏ธ Relying on Position Manager software monitoring for remaining ${100 - this.config.takeProfit1SizePercent}%`) } else { console.log('๐Ÿ—‘๏ธ Cancelling old stop loss orders...') const { cancelAllOrders, placeExitOrders } = await import('../drift/orders') const cancelResult = await cancelAllOrders(trade.symbol) if (cancelResult.success) { console.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) console.log(`๐Ÿ›ก๏ธ Placing only SL orders at $${newStopLossPrice.toFixed(4)} for remaining position...`) console.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) { console.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 ) { console.log(`๐Ÿ” Profit lock trigger: ${trade.symbol}`) trade.stopLossPrice = this.calculatePrice( trade.entryPrice, this.config.profitLockPercent, trade.direction ) trade.slMovedToProfit = true console.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)) { console.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)) { console.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 console.log(`๐Ÿƒ TP2-as-Runner activated: ${((trade.currentSize / trade.positionSize) * 100).toFixed(1)}% remaining with trailing stop`) console.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) console.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 console.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 console.log(`๐Ÿ“Š 1-min ADX update: Entry ${(trade.adxAtEntry || 0).toFixed(1)} โ†’ Current ${currentADX.toFixed(1)} (${adxChange >= 0 ? '+' : ''}${adxChange.toFixed(1)} change)`) } } catch (error) { console.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 console.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 console.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 console.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 console.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 console.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) ) console.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) ) console.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 console.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)) { console.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 { // 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) { console.log(`โš ๏ธ DUPLICATE EXIT PREVENTED: ${tradeId} already processing ${reason}`) console.log(` This prevents duplicate Telegram notifications with compounding P&L`) return } console.log(`๐Ÿ—‘๏ธ Removed ${trade.symbol} from monitoring (${reason}) - atomic deduplication applied`) } try { console.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) { console.log(`๐Ÿ”ด LAYER 2: Ghost detected after ${trade.priceCheckCount} failures`) console.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 { console.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) { console.log(`โš ๏ธ Close transaction confirmed but position still exists on Drift`) console.log(` Keeping ${trade.symbol} in monitoring until Drift confirms closure`) console.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() console.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, }) 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 } } // 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() } console.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) console.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 { console.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) } console.log('โœ… All positions closed') } /** * Save trade state to database (for persistence across restarts) */ private async saveTradeState(trade: ActiveTrade): Promise { 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) { console.error('โŒ Failed to save trade state:', error) // Don't throw - state save is non-critical } } /** * Reload configuration from merged sources (used after settings updates) */ refreshConfig(partial?: Partial): void { this.config = getMergedConfig(partial) console.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 | 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 { const manager = getPositionManager() if (initPromise) { await initPromise } return manager }