Fix: Signal flip race condition - properly coordinate Position Manager during opposite signal closure
- Remove trade from Position Manager BEFORE closing Drift position (prevents race condition) - Explicitly save closure to database with proper P&L calculation - Mark flipped positions as 'manual' exit reason - Increase delay from 1s to 2s for better on-chain confirmation - Preserve MAE/MFE data in closure records Fixes issue where SHORT signal would close LONG but not properly track the new SHORT position. Database now correctly records both old position closure and new position opening.
This commit is contained in:
@@ -11,7 +11,7 @@ import { openPosition, placeExitOrders } from '@/lib/drift/orders'
|
|||||||
import { normalizeTradingViewSymbol } from '@/config/trading'
|
import { normalizeTradingViewSymbol } from '@/config/trading'
|
||||||
import { getMergedConfig } from '@/config/trading'
|
import { getMergedConfig } from '@/config/trading'
|
||||||
import { getInitializedPositionManager, ActiveTrade } from '@/lib/trading/position-manager'
|
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)
|
* Calculate signal quality score (same logic as check-risk endpoint)
|
||||||
@@ -294,7 +294,13 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
|||||||
if (oppositePosition) {
|
if (oppositePosition) {
|
||||||
console.log(`🔄 Signal flip detected! Closing ${oppositePosition.direction} to open ${body.direction}`)
|
console.log(`🔄 Signal flip detected! Closing ${oppositePosition.direction} to open ${body.direction}`)
|
||||||
|
|
||||||
// Close opposite position
|
// CRITICAL: Remove from Position Manager FIRST to prevent race condition
|
||||||
|
// where Position Manager detects "external closure" while we're deliberately closing it
|
||||||
|
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 { closePosition } = await import('@/lib/drift/orders')
|
||||||
const closeResult = await closePosition({
|
const closeResult = await closePosition({
|
||||||
symbol: driftSymbol,
|
symbol: driftSymbol,
|
||||||
@@ -308,12 +314,35 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
|||||||
} else {
|
} else {
|
||||||
console.log(`✅ Closed ${oppositePosition.direction} position at $${closeResult.closePrice?.toFixed(4)} (P&L: $${closeResult.realizedPnL?.toFixed(2)})`)
|
console.log(`✅ Closed ${oppositePosition.direction} position at $${closeResult.closePrice?.toFixed(4)} (P&L: $${closeResult.realizedPnL?.toFixed(2)})`)
|
||||||
|
|
||||||
// Position Manager will handle cleanup (including order cancellation)
|
// Save the closure to database
|
||||||
// The executeExit method already removes the trade and updates 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,
|
||||||
|
})
|
||||||
|
console.log(`💾 Saved opposite position closure to database`)
|
||||||
|
} catch (dbError) {
|
||||||
|
console.error('❌ Failed to save opposite position closure:', dbError)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small delay to ensure position is fully closed
|
// Small delay to ensure position is fully closed on-chain
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate position size with leverage
|
// Calculate position size with leverage
|
||||||
|
|||||||
167
docs/history/SIGNAL_FLIP_RACE_CONDITION_FIX.md
Normal file
167
docs/history/SIGNAL_FLIP_RACE_CONDITION_FIX.md
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user