critical: Bug #89 - Detect and handle Drift fractional position remnants (3-part fix)
- Part 1: Position Manager fractional remnant detection after close attempts * Check if position < 1.5× minOrderSize after close transaction * Log to persistent logger with FRACTIONAL_REMNANT_DETECTED * Track closeAttempts, limit to 3 maximum * Mark exitReason='FRACTIONAL_REMNANT' in database * Remove from monitoring after 3 failed attempts - Part 2: Pre-close validation in closePosition() * Check if position viable before attempting close * Reject positions < 1.5× minOrderSize with specific error * Prevent wasted transaction attempts on too-small positions * Return POSITION_TOO_SMALL_TO_CLOSE error with manual instructions - Part 3: Health monitor detection for fractional remnants * Query Trade table for FRACTIONAL_REMNANT exits in last 24h * Alert operators with position details and manual cleanup instructions * Provide trade IDs, symbols, and Drift UI link - Database schema: Added closeAttempts Int? field to Track attempts Root cause: Drift protocol exchange constraints can leave fractional positions Evidence: 3 close transactions confirmed but 0.15 SOL remnant persisted Financial impact: ,000+ risk from unprotected fractional positions Status: Fix implemented, awaiting deployment verification See: docs/COMMON_PITFALLS.md Bug #89 for complete incident details
This commit is contained in:
@@ -563,6 +563,29 @@ export async function closePosition(
|
||||
error: 'No open position to close',
|
||||
}
|
||||
}
|
||||
|
||||
// BUG #89 FIX PART 2 (Dec 16, 2025): Pre-close validation for fractional positions
|
||||
// Before attempting close, check if position is too small to close reliably
|
||||
// Drift protocol has exchange-level constraints that prevent closing very small positions
|
||||
// If position below minimum viable size, return error with manual resolution instructions
|
||||
const oraclePriceForCheck = await driftService.getOraclePrice(marketConfig.driftMarketIndex)
|
||||
const positionSizeUSD = Math.abs(position.size) * oraclePriceForCheck
|
||||
const minViableSize = marketConfig.minOrderSize * 1.5
|
||||
|
||||
if (positionSizeUSD < minViableSize && params.percentToClose >= 100) {
|
||||
console.log(`🛑 POSITION TOO SMALL TO CLOSE`)
|
||||
console.log(` Position: ${Math.abs(position.size)} tokens ($${positionSizeUSD.toFixed(2)})`)
|
||||
console.log(` Minimum viable size: $${minViableSize.toFixed(2)}`)
|
||||
console.log(` This position is below Drift protocol's minimum viable close size`)
|
||||
console.log(` Close transactions may confirm but leave fractional remnants`)
|
||||
console.log(` Manual intervention required via Drift UI`)
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'POSITION_TOO_SMALL_TO_CLOSE',
|
||||
closedSize: Math.abs(position.size),
|
||||
} as any
|
||||
}
|
||||
|
||||
logger.log('📊 Closing position:', params)
|
||||
console.log(` params.percentToClose: ${params.percentToClose}`)
|
||||
|
||||
@@ -193,6 +193,37 @@ export async function checkPositionManagerHealth(): Promise<HealthCheckResult> {
|
||||
}
|
||||
}
|
||||
|
||||
// BUG #89 FIX PART 3 (Dec 16, 2025): Check for fractional position remnants
|
||||
// Query database for positions marked as FRACTIONAL_REMNANT in last 24 hours
|
||||
// These require manual intervention via Drift UI
|
||||
const prisma = getPrismaClient()
|
||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||
|
||||
const fractionalRemnants = await prisma.trade.findMany({
|
||||
where: {
|
||||
exitReason: 'FRACTIONAL_REMNANT' as any,
|
||||
exitTime: { gte: twentyFourHoursAgo }
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
symbol: true,
|
||||
positionSizeUSD: true,
|
||||
exitTime: true
|
||||
}
|
||||
})
|
||||
|
||||
if (fractionalRemnants.length > 0) {
|
||||
issues.push(`🛑 CRITICAL: ${fractionalRemnants.length} fractional position remnant(s) detected!`)
|
||||
issues.push(` These are positions that confirmed close but left small remnants on Drift`)
|
||||
issues.push(` Close attempts exhausted (3 max) - MANUAL INTERVENTION REQUIRED`)
|
||||
|
||||
for (const remnant of fractionalRemnants) {
|
||||
issues.push(` • ${remnant.symbol}: Size $${remnant.positionSizeUSD.toFixed(2)} (Trade ID: ${remnant.id})`)
|
||||
issues.push(` Detected at: ${remnant.exitTime?.toISOString()}`)
|
||||
issues.push(` Action: Close manually via Drift UI at https://app.drift.trade`)
|
||||
}
|
||||
}
|
||||
|
||||
const isHealthy = issues.length === 0
|
||||
|
||||
return {
|
||||
|
||||
@@ -1931,6 +1931,86 @@ export class PositionManager {
|
||||
trade.realizedPnL += result.realizedPnL || 0
|
||||
logger.log(`💰 P&L accumulated: +$${(result.realizedPnL || 0).toFixed(2)} | Total: $${trade.realizedPnL.toFixed(2)}`)
|
||||
|
||||
// BUG #89 FIX PART 1 (Dec 16, 2025): Detect fractional position remnants
|
||||
// After close transaction confirms, verify Drift actually closed position
|
||||
// If fractional remnant remains, log to persistent logger and mark for manual intervention
|
||||
if (percentToClose >= 100) {
|
||||
try {
|
||||
const driftService = getDriftService()
|
||||
const marketConfig = getMarketConfig(trade.symbol)
|
||||
const position = await driftService.getPosition(marketConfig.driftMarketIndex)
|
||||
|
||||
if (position && Math.abs(position.size) > 0.01) {
|
||||
// Position still exists after close - check if it's a fractional remnant
|
||||
const positionSizeUSD = Math.abs(position.size) * currentPrice
|
||||
const minViableSize = marketConfig.minOrderSize * 1.5
|
||||
|
||||
if (positionSizeUSD < minViableSize) {
|
||||
console.log(`⚠️ FRACTIONAL REMNANT DETECTED: ${trade.symbol}`)
|
||||
console.log(` Position: ${Math.abs(position.size)} tokens ($${positionSizeUSD.toFixed(2)})`)
|
||||
console.log(` Below minimum viable size: $${minViableSize.toFixed(2)}`)
|
||||
console.log(` Close transaction confirmed but remnant persisted`)
|
||||
|
||||
// Log to persistent logger for manual review
|
||||
const { logCriticalError } = await import('../utils/persistent-logger')
|
||||
await logCriticalError('FRACTIONAL_REMNANT_DETECTED', {
|
||||
tradeId: trade.id,
|
||||
symbol: trade.symbol,
|
||||
remnantSize: Math.abs(position.size),
|
||||
remnantUSD: positionSizeUSD,
|
||||
minViableSize,
|
||||
transactionSignature: result.transactionSignature,
|
||||
closeAttempts: (trade as any).closeAttempts || 1,
|
||||
})
|
||||
|
||||
// Track close attempts (initialize if undefined)
|
||||
const closeAttempts = ((trade as any).closeAttempts || 0) + 1
|
||||
;(trade as any).closeAttempts = closeAttempts
|
||||
|
||||
console.log(` Close attempts: ${closeAttempts}/3`)
|
||||
|
||||
if (closeAttempts >= 3) {
|
||||
console.log(`🛑 FRACTIONAL REMNANT: Max attempts reached (3)`)
|
||||
console.log(` Marking for manual intervention and removing from monitoring`)
|
||||
console.log(` Manual cleanup required via Drift UI`)
|
||||
|
||||
// Mark trade with special exit reason for detection
|
||||
try {
|
||||
const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000)
|
||||
await updateTradeExit({
|
||||
positionId: trade.positionId,
|
||||
exitPrice: result.closePrice || currentPrice,
|
||||
exitReason: 'FRACTIONAL_REMNANT' as any,
|
||||
realizedPnL: trade.realizedPnL,
|
||||
exitOrderTx: result.transactionSignature || 'MANUAL_CLOSE',
|
||||
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,
|
||||
})
|
||||
logger.log('💾 Fractional remnant marked in database')
|
||||
} catch (dbError) {
|
||||
console.error('❌ Failed to mark fractional remnant:', dbError)
|
||||
}
|
||||
|
||||
// Remove from monitoring - manual intervention required
|
||||
return
|
||||
} else {
|
||||
console.log(`⚠️ Will retry on next close attempt`)
|
||||
// Keep monitoring - will retry if SL/TP triggers again
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (checkError) {
|
||||
console.error('⚠️ Could not verify position after close:', checkError)
|
||||
// Continue with normal flow - verification failure shouldn't block exit
|
||||
}
|
||||
}
|
||||
|
||||
// Update trade state
|
||||
if (percentToClose >= 100) {
|
||||
// Full close - remove from monitoring
|
||||
|
||||
Reference in New Issue
Block a user