diff --git a/.env b/.env index 6442f1e..e890ea3 100644 --- a/.env +++ b/.env @@ -375,8 +375,8 @@ TRAILING_STOP_PERCENT=0.3 TRAILING_STOP_ACTIVATION=0.4 MIN_QUALITY_SCORE=60 SOLANA_ENABLED=true -SOLANA_POSITION_SIZE=50 -SOLANA_LEVERAGE=1 +SOLANA_POSITION_SIZE=100 +SOLANA_LEVERAGE=15 SOLANA_USE_PERCENTAGE_SIZE=true ETHEREUM_ENABLED=false ETHEREUM_POSITION_SIZE=50 @@ -393,3 +393,5 @@ TRAILING_STOP_ATR_MULTIPLIER=1.5 TRAILING_STOP_MIN_PERCENT=0.25 TRAILING_STOP_MAX_PERCENT=0.9 USE_PERCENTAGE_SIZE=false + +BREAKEVEN_TRIGGER_PERCENT=0.4 \ No newline at end of file diff --git a/lib/trading/position-manager.ts b/lib/trading/position-manager.ts index a4077b4..df21784 100644 --- a/lib/trading/position-manager.ts +++ b/lib/trading/position-manager.ts @@ -227,6 +227,8 @@ export class PositionManager { * - 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) { @@ -235,13 +237,18 @@ export class PositionManager { console.log('🔍 Validating positions against Drift...') + // LAYER 1: Database-based age check (doesn't require RPC - always works) + await this.cleanupStalePositions() + try { const driftService = getDriftService() - // Skip if Drift service not initialized + // If Drift service not ready, use database-only validation if (!driftService || !(driftService as any).isInitialized) { - console.log('⏳ Drift service not ready, skipping validation') - return + console.log('⚠️ Drift service not ready - using database-only validation') + console.log(` Positions in memory: ${this.activeTrades.size}`) + console.log(` These will be checked against database on next monitoring cycle`) + return // Database cleanup already ran above } // Check each tracked trade individually @@ -276,6 +283,30 @@ export class PositionManager { } } + /** + * LAYER 1: Database-based stale position cleanup + * + * Removes positions from memory that are older than 6 hours + * Doesn't require RPC calls - always works even during rate limiting + * + * CRITICAL: This prevents ghost accumulation during rate limit death spirals + */ + private async cleanupStalePositions(): Promise { + const sixHoursAgo = Date.now() - (6 * 60 * 60 * 1000) + + for (const [tradeId, trade] of this.activeTrades) { + // If position is >6 hours old, it's likely a ghost (max trade duration should be ~2-3 hours) + if (trade.entryTime < sixHoursAgo) { + console.log(`🔴 STALE GHOST DETECTED: ${trade.symbol} (age: ${Math.floor((Date.now() - trade.entryTime) / 3600000)}h)`) + console.log(` Entry time: ${new Date(trade.entryTime).toISOString()}`) + console.log(` Removing from memory - likely closed externally hours ago`) + + await this.handleExternalClosure(trade, 'Stale position cleanup (>6h old)') + console.log(`✅ Stale ghost cleaned up: ${trade.symbol}`) + } + } + } + /** * Handle external closure for ghost position cleanup * @@ -846,6 +877,29 @@ export class PositionManager { 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) { @@ -1122,6 +1176,17 @@ export class PositionManager { // 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 to close this position 20+ times (40+ seconds of retries), + // force remove from monitoring to prevent infinite rate limit storms + if (trade.priceCheckCount > 20) { + console.log(`🔴 DEATH SPIRAL DETECTED: ${trade.symbol} failed 20+ close attempts`) + console.log(` Forcing removal from monitoring to prevent rate limit exhaustion`) + await this.handleExternalClosure(trade, 'Death spiral prevention (20+ failed close attempts)') + return + } + // 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