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:
mindesbunister
2025-12-16 22:05:12 +01:00
parent 7c8f1688aa
commit b11da009eb
6 changed files with 321 additions and 1 deletions

View File

@@ -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}`)

View File

@@ -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 {

View File

@@ -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