diff --git a/lib/trading/position-manager.ts b/lib/trading/position-manager.ts index 13da6b0..3885a97 100644 --- a/lib/trading/position-manager.ts +++ b/lib/trading/position-manager.ts @@ -1517,6 +1517,17 @@ export class PositionManager { * * 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, @@ -1524,6 +1535,22 @@ export class PositionManager { 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}%)`) @@ -1646,7 +1673,12 @@ export class PositionManager { } } - await this.removeTrade(trade.id) + // 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