/** * Position Manager * * Tracks active trades and manages automatic exits */ import { getDriftService } 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' 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 // Targets stopLossPrice: number tp1Price: number tp2Price: number emergencyStopPrice: number // State currentSize: number // Changes after TP1 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 // Monitoring priceCheckCount: number lastPrice: number lastUpdateTime: number } export interface ExitResult { success: boolean reason: 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_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 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, 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 } /** * 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() } } } /** * 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') } /** * Stop price monitoring */ private async stopMonitoring(): Promise { if (!this.isMonitoring) { return } console.log('🛑 Stopping position monitoring...') const priceMonitor = getPythPriceMonitor() await priceMonitor.stop() 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: 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)) { // TP1 fired (should be ~75% reduction) console.log(`🎯 TP1 detected as filled! Reduction: ${reductionPercent.toFixed(1)}%`) trade.tp1Hit = true trade.currentSize = positionSizeUSD // Move SL to breakeven after TP1 trade.stopLossPrice = trade.entryPrice trade.slMovedToBreakeven = true console.log(`🛡️ Stop loss moved to breakeven: $${trade.stopLossPrice.toFixed(4)}`) 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 } } } if (position === null || position.size === 0) { // 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 FIX: Use tp1Hit flag to determine which size to use for P&L calculation // - If tp1Hit=false: First closure, calculate on full position size // - If tp1Hit=true: Runner closure, calculate on tracked remaining size const sizeForPnL = trade.tp1Hit ? trade.currentSize : trade.positionSize // Check if this was a phantom trade by looking at the last known on-chain size // If last on-chain size was <50% of expected, this is a phantom const wasPhantom = 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(` TP1 hit: ${trade.tp1Hit}`) console.log(` Using for P&L calc: $${sizeForPnL.toFixed(2)} (${trade.tp1Hit ? 'runner' : 'full position'})`) if (wasPhantom) { console.log(` ⚠️ PHANTOM TRADE: Setting P&L to 0 (size mismatch >50%)`) } // Determine exit reason based on TP flags and realized P&L // CRITICAL: Use trade state flags, not current price (on-chain orders filled in the past!) let exitReason: 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' = 'SL' // Include any previously realized profit (e.g., from TP1 partial close) const previouslyRealized = trade.realizedPnL let runnerRealized = 0 let runnerProfitPercent = 0 if (!wasPhantom) { runnerProfitPercent = this.calculateProfitPercent( trade.entryPrice, currentPrice, trade.direction ) runnerRealized = (sizeForPnL * runnerProfitPercent) / 100 } const totalRealizedPnL = previouslyRealized + runnerRealized trade.realizedPnL = totalRealizedPnL console.log(` Realized P&L snapshot → Previous: $${previouslyRealized.toFixed(2)} | Runner: $${runnerRealized.toFixed(2)} (Δ${runnerProfitPercent.toFixed(2)}%) | Total: $${totalRealizedPnL.toFixed(2)}`) // Determine exit reason from trade state and P&L if (trade.tp2Hit) { // TP2 was hit, full position closed (runner stopped or hit target) exitReason = 'TP2' } else if (trade.tp1Hit) { // TP1 was hit, position should be 25% size, but now fully closed // This means either TP2 filled or runner got stopped out exitReason = totalRealizedPnL > 0 ? 'TP2' : 'SL' } else { // No TPs hit yet - either SL or TP1 filled just now // Use P&L to determine: positive = TP, negative = SL if (totalRealizedPnL > trade.positionSize * 0.005) { // More than 0.5% profit - must be TP1 exitReason = 'TP1' } else if (totalRealizedPnL < 0) { // Loss - must be SL exitReason = 'SL' } // else: small profit/loss near breakeven, default to SL (could be manual close) } // 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}`) 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)}`) } 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 } // 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 } // Update current size to match reality (already in USD) trade.currentSize = positionSizeUSD trade.tp1Hit = true await this.saveTradeState(trade) } } 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 accountPnL = profitPercent * trade.leverage trade.unrealizedPnL = (trade.currentSize * profitPercent) / 100 // Track peak P&L (MFE - Maximum Favorable Excursion) if (trade.unrealizedPnL > trade.peakPnL) { trade.peakPnL = trade.unrealizedPnL } // Track MAE/MFE (account percentage, not USD) if (accountPnL > trade.maxFavorableExcursion) { trade.maxFavorableExcursion = accountPnL trade.maxFavorablePrice = currentPrice } if (accountPnL < trade.maxAdverseExcursion) { trade.maxAdverseExcursion = accountPnL 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 } } // 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)}% (${accountPnL.toFixed(1)}% acct) | ` + `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 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 } // 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) const newStopLossPrice = this.calculatePrice( trade.entryPrice, this.config.profitLockAfterTP1Percent, // Lock profit on remaining position trade.direction ) trade.stopLossPrice = newStopLossPrice trade.slMovedToBreakeven = true console.log(`🔒 SL moved to lock +${this.config.profitLockAfterTP1Percent}% profit (${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 new SL orders at breakeven/profit level for remaining position console.log(`🛡️ Placing new SL orders at $${newStopLossPrice.toFixed(4)} for remaining position...`) const exitOrdersResult = await placeExitOrders({ symbol: trade.symbol, positionSizeUSD: trade.currentSize, entryPrice: trade.entryPrice, tp1Price: trade.tp2Price, // Only TP2 remains tp2Price: trade.tp2Price, // Dummy, won't be used stopLossPrice: newStopLossPrice, tp1SizePercent: 100, // Close remaining 25% at TP2 tp2SizePercent: 0, 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) } // 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) { // Calculate ATR-based trailing distance let trailingDistancePercent: number if (trade.atrAtEntry && trade.atrAtEntry > 0) { // ATR-based: Use ATR% * multiplier const atrPercent = (trade.atrAtEntry / currentPrice) * 100 const rawDistance = atrPercent * this.config.trailingStopAtrMultiplier // Clamp between min and max trailingDistancePercent = Math.max( this.config.trailingStopMinPercent, Math.min(this.config.trailingStopMaxPercent, rawDistance) ) console.log(`📊 ATR-based trailing: ${trade.atrAtEntry.toFixed(4)} (${atrPercent.toFixed(2)}%) × ${this.config.trailingStopAtrMultiplier}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, '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) */ private async executeExit( trade: ActiveTrade, percentToClose: number, reason: ExitResult['reason'], currentPrice: number ): Promise { 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`) // 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 } // 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') } catch (dbError) { console.error('❌ Failed to save trade exit to database:', dbError) // Don't fail the close if database fails } } await this.removeTrade(trade.id) console.log(`✅ Position closed | P&L: $${trade.realizedPnL.toFixed(2)} | Reason: ${reason}`) } 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) } // TODO: Send notification } 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 } } 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 }