diff --git a/app/api/trading/execute/route.ts b/app/api/trading/execute/route.ts index 7879bd6..4dee290 100644 --- a/app/api/trading/execute/route.ts +++ b/app/api/trading/execute/route.ts @@ -11,7 +11,7 @@ import { openPosition, placeExitOrders } from '@/lib/drift/orders' import { normalizeTradingViewSymbol } from '@/config/trading' import { getMergedConfig } from '@/config/trading' import { getInitializedPositionManager, ActiveTrade } from '@/lib/trading/position-manager' -import { createTrade } from '@/lib/database/trades' +import { createTrade, updateTradeExit } from '@/lib/database/trades' /** * Calculate signal quality score (same logic as check-risk endpoint) @@ -294,7 +294,13 @@ export async function POST(request: NextRequest): Promise setTimeout(resolve, 1000)) + // Small delay to ensure position is fully closed on-chain + await new Promise(resolve => setTimeout(resolve, 2000)) } // Calculate position size with leverage diff --git a/docs/history/SIGNAL_FLIP_RACE_CONDITION_FIX.md b/docs/history/SIGNAL_FLIP_RACE_CONDITION_FIX.md new file mode 100644 index 0000000..5f64155 --- /dev/null +++ b/docs/history/SIGNAL_FLIP_RACE_CONDITION_FIX.md @@ -0,0 +1,167 @@ +# Signal Flip Race Condition Fix + +**Date:** November 3, 2025 +**Issue:** ETH LONG position was closed when SHORT signal arrived, but SHORT position was never properly tracked and closed immediately + +## Problem Description + +When a signal arrives in the opposite direction of an existing position (e.g., SHORT signal while LONG is open), the bot is supposed to: +1. Close the existing position +2. Open a new position in the opposite direction + +However, a race condition was occurring where: +1. The execute endpoint would close the Drift position directly +2. Position Manager would detect position disappeared → "external closure" +3. Position Manager would try to save the closure while new position was being opened +4. New position would get added to Position Manager while old position cleanup was in progress +5. **Result:** Confusion about which position was which, leading to incorrect exit reasons and premature closures + +## Specific Example (November 3, 2025) + +**Timeline:** +- 18:30:17 - SHORT signal arrives (LONG position at $3659.48 has been open for 50 minutes) +- 18:30:17 - Execute endpoint detects opposite position, calls `closePosition()` on Drift +- 18:30:17 - SHORT position opens at $3658.58 +- 18:30:21 - Position Manager detects LONG disappeared, saves as "external closure (SL)" +- 18:30:41 - SHORT position closes (exit reason incorrectly marked as "TP2" even though it exited at $3653.04, between entry and TP1) + +**Database Evidence:** +```sql +-- LONG (should have been closed for flip) +positionId: 5BwZ7n... | direction: long | entry: $3659.48 | exit: $3655.23 | reason: SL | P&L: -$0.04 + +-- SHORT (closed prematurely) +positionId: 2vDMTU... | direction: short | entry: $3658.58 | exit: $3653.04 | reason: TP2 | P&L: +$0.05 +``` + +**Log Evidence:** +``` +🔄 Signal flip detected! Closing long to open short +✅ Closed long position at $3652.7025 (P&L: $-0.06) +💰 Opening short position: + Symbol: ETH-PERP +⚠️ TP1 size below market min, skipping on-chain TP1 +⚠️ TP2 size below market min, skipping on-chain TP2 +🛡️🛡️ Placing DUAL STOP SYSTEM... +📊 Adding trade to monitor: ETH-PERP short +⚠️ Position ETH-PERP was closed externally (by on-chain order) +``` + +**Additional Issue Discovered:** +ETH position size ($4) is too small, causing TP orders to fail minimum size requirements: +- TP1: 75% of $4 = $3 = ~0.00082 ETH (below 0.01 ETH minimum) +- TP2: 75% of $1 = $0.75 = ~0.0002 ETH (below 0.01 ETH minimum) + +## Root Cause + +The execute endpoint was: +1. Closing the Drift position directly via `closePosition()` +2. **NOT** removing the trade from Position Manager first +3. Expecting Position Manager to "figure it out" via external closure detection +4. Creating race condition where Position Manager processes old position while new position is being added + +## Solution + +Modified `/app/api/trading/execute/route.ts` to: + +1. **Remove opposite position from Position Manager FIRST** + - Prevents "external closure" detection race condition + - Cancels all orders for old position cleanly + +2. **Then close Drift position** + - Clean state: Position Manager no longer tracking it + +3. **Save closure to database explicitly** + - Calculate proper P&L using tracked position data + - Mark as 'manual' exit reason (closed for flip) + - Include MAE/MFE data + +4. **Increase delay from 1s to 2s** + - More time for on-chain confirmation before opening new position + +## Code Changes + +```typescript +if (oppositePosition) { + console.log(`🔄 Signal flip detected! Closing ${oppositePosition.direction} to open ${body.direction}`) + + // CRITICAL: Remove from Position Manager FIRST to prevent race condition + console.log(`🗑️ Removing ${oppositePosition.direction} position from Position Manager before flip...`) + await positionManager.removeTrade(oppositePosition.id) + console.log(`✅ Removed from Position Manager`) + + // Close opposite position on Drift + const { closePosition } = await import('@/lib/drift/orders') + const closeResult = await closePosition({ + symbol: driftSymbol, + percentToClose: 100, + slippageTolerance: config.slippageTolerance, + }) + + // ... error handling ... + + // Save the closure to database + try { + const holdTimeSeconds = Math.floor((Date.now() - oppositePosition.entryTime) / 1000) + const profitPercent = ((closeResult.closePrice! - oppositePosition.entryPrice) / oppositePosition.entryPrice) * 100 + const accountPnL = profitPercent * oppositePosition.leverage * (oppositePosition.direction === 'long' ? 1 : -1) + const realizedPnL = (oppositePosition.currentSize * accountPnL) / 100 + + await updateTradeExit({ + positionId: oppositePosition.positionId, + exitPrice: closeResult.closePrice!, + exitReason: 'manual', // Manually closed for flip + realizedPnL: realizedPnL, + exitOrderTx: closeResult.transactionSignature || 'FLIP_CLOSE', + holdTimeSeconds, + maxDrawdown: Math.abs(Math.min(0, oppositePosition.maxAdverseExcursion)), + maxGain: Math.max(0, oppositePosition.maxFavorableExcursion), + maxFavorableExcursion: oppositePosition.maxFavorableExcursion, + maxAdverseExcursion: oppositePosition.maxAdverseExcursion, + maxFavorablePrice: oppositePosition.maxFavorablePrice, + maxAdversePrice: oppositePosition.maxAdversePrice, + }) + } catch (dbError) { + console.error('❌ Failed to save opposite position closure:', dbError) + } + + // Small delay to ensure position is fully closed on-chain + await new Promise(resolve => setTimeout(resolve, 2000)) +} +``` + +## Testing Required + +1. **Signal flip scenario:** + - Open LONG position + - Send SHORT signal + - Verify: LONG closes cleanly, SHORT opens successfully + - Verify: Database shows LONG closed with 'manual' reason + - Verify: No "external closure" race condition logs + +2. **Verify no regression:** + - Normal LONG → close naturally + - Normal SHORT → close naturally + - Scaling still works + - Duplicate blocking still works + +## Related Issues + +- **Minimum position size for ETH:** $4 position results in TP orders below exchange minimums + - Consider increasing ETH position size to $40 to ensure TP orders can be placed + - Or implement tiered exit system that uses software monitoring for small positions + - Current setup only places stop loss orders, which is risky + +## Files Modified + +- `/app/api/trading/execute/route.ts` - Signal flip logic with proper Position Manager coordination +- Added import: `updateTradeExit` from `@/lib/database/trades` + +## Prevention + +Going forward, any code that closes positions should: +1. Check if Position Manager is tracking it +2. Remove from Position Manager FIRST +3. Then close on Drift +4. Explicitly save to database with proper exit reason +5. Never rely on "external closure detection" for intentional closes