diff --git a/lib/trading/position-manager.ts b/lib/trading/position-manager.ts index 9edebe8..c6947a4 100644 --- a/lib/trading/position-manager.ts +++ b/lib/trading/position-manager.ts @@ -75,6 +75,7 @@ export class PositionManager { private config: TradingConfig private isMonitoring: boolean = false private initialized: boolean = false + private validationInterval: NodeJS.Timeout | null = null constructor(config?: Partial) { this.config = getMergedConfig(config) @@ -197,6 +198,142 @@ export class PositionManager { } } + /** + * 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 + */ + private async validatePositions(): Promise { + if (this.activeTrades.size === 0) { + return // Nothing to validate + } + + console.log('๐Ÿ” Validating positions against Drift...') + + try { + const driftService = getDriftService() + + // Skip if Drift service not initialized + if (!driftService || !(driftService as any).isInitialized) { + console.log('โณ Drift service not ready, skipping validation') + 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})`) + + // Calculate approximate P&L using last known price + const profitPercent = this.calculateProfitPercent( + trade.entryPrice, + trade.lastPrice, + trade.direction + ) + const estimatedPnL = (trade.currentSize * profitPercent) / 100 + + console.log(`๐Ÿ’ฐ Estimated P&L: ${profitPercent.toFixed(2)}% โ†’ $${estimatedPnL.toFixed(2)}`) + + // Remove from monitoring FIRST to prevent race conditions + const tradeId = trade.id + if (!this.activeTrades.has(tradeId)) { + console.log(`โš ๏ธ Trade ${tradeId} already removed, skipping cleanup`) + return + } + + this.activeTrades.delete(tradeId) + console.log(`๐Ÿ—‘๏ธ Removed ${trade.symbol} from monitoring`) + + // 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`) + } 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 */ @@ -244,6 +381,9 @@ export class PositionManager { this.isMonitoring = true console.log('โœ… Position monitoring active') + + // Schedule periodic validation to detect and cleanup ghost positions + this.scheduleValidation() } /** @@ -258,6 +398,12 @@ export class PositionManager { 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')