/** * 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 // 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) // 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, 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: 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) if (position === null || position.size === 0) { // Position closed externally (by on-chain TP/SL order) console.log(`⚠️ Position ${trade.symbol} was closed externally (by on-chain order)`) // Save currentSize before it becomes 0 const sizeBeforeClosure = trade.currentSize // Determine exit reason based on price let exitReason: 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' = 'SL' if (trade.direction === 'long') { if (currentPrice >= trade.tp2Price) { exitReason = 'TP2' } else if (currentPrice >= trade.tp1Price) { exitReason = 'TP1' } else if (currentPrice <= trade.stopLossPrice) { exitReason = 'HARD_SL' // Assume hard stop if below SL } } else { // Short if (currentPrice <= trade.tp2Price) { exitReason = 'TP2' } else if (currentPrice <= trade.tp1Price) { exitReason = 'TP1' } else if (currentPrice >= trade.stopLossPrice) { exitReason = 'HARD_SL' // Assume hard stop if above SL } } // Calculate final P&L using size BEFORE closure const profitPercent = this.calculateProfitPercent( trade.entryPrice, currentPrice, trade.direction ) const accountPnL = profitPercent * trade.leverage const realizedPnL = (sizeBeforeClosure * accountPnL) / 100 // Update database const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000) try { await updateTradeExit({ positionId: trade.positionId, exitPrice: currentPrice, exitReason, realizedPnL, exitOrderTx: 'ON_CHAIN_ORDER', holdTimeSeconds, maxDrawdown: 0, maxGain: trade.peakPnL, }) console.log(`💾 External closure recorded: ${exitReason} at $${currentPrice} | P&L: $${realizedPnL.toFixed(2)}`) } catch (dbError) { console.error('❌ Failed to save external closure:', dbError) } // Remove from monitoring await this.removeTrade(trade.id) return } // Position exists but size mismatch (partial close by TP1?) if (position.size < trade.currentSize * 0.95) { // 5% tolerance console.log(`⚠️ Position size mismatch: expected ${trade.currentSize}, got ${position.size}`) // Update current size to match reality trade.currentSize = position.size * (trade.positionSize / trade.currentSize) // Convert to USD 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) } } // Update trade data trade.lastPrice = currentPrice trade.lastUpdateTime = Date.now() trade.priceCheckCount++ // 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 if (trade.unrealizedPnL > trade.peakPnL) { trade.peakPnL = trade.unrealizedPnL } // 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)}` ) } // 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)}%`) await this.executeExit(trade, this.config.takeProfit1SizePercent, 'TP1', currentPrice) // Move SL based on breakEvenTriggerPercent setting trade.tp1Hit = true trade.currentSize = trade.positionSize * ((100 - this.config.takeProfit1SizePercent) / 100) trade.stopLossPrice = this.calculatePrice( trade.entryPrice, this.config.breakEvenTriggerPercent, // Use configured breakeven level trade.direction ) trade.slMovedToBreakeven = true console.log(`🔒 SL moved to +${this.config.breakEvenTriggerPercent}% (${this.config.takeProfit1SizePercent}% closed, ${100 - this.config.takeProfit1SizePercent}% remaining): ${trade.stopLossPrice.toFixed(4)}`) // 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 && this.shouldTakeProfit2(currentPrice, trade)) { console.log(`🎊 TP2 HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}%`) // Calculate how much to close based on TP2 size percent const percentToClose = this.config.takeProfit2SizePercent await this.executeExit(trade, percentToClose, 'TP2', currentPrice) // If some position remains, mark TP2 as hit and activate trailing stop if (percentToClose < 100) { trade.tp2Hit = true 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) { const trailingStopPrice = this.calculatePrice( trade.peakPrice, -this.config.trailingStopPercent, // 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)} (${this.config.trailingStopPercent}% 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) */ 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) { console.error(`❌ Failed to close ${trade.symbol}:`, result.error) 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: 0, // TODO: Track this maxGain: trade.peakPnL, }) 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 trade.currentSize -= result.closedSize || 0 console.log(`✅ 50% closed | Realized: $${result.realizedPnL?.toFixed(2)} | Remaining: ${trade.currentSize}`) } // 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 } } /** * 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 }