fix: Handle multiple DB trades for single Drift position in validator
PROBLEM (User identified): - Analytics showed 3 open trades when Drift UI showed only 1 position - Database had 3 separate trade records all marked as 'open' - Root cause: Drift has 1 POSITION + 3 ORDERS (TP/SL exit orders) - Validator was incorrectly treating each as separate position SOLUTION: - Group database trades by symbol before validation - If multiple DB trades exist for one Drift position, keep only MOST RECENT - Close older duplicate trades with exitReason='DUPLICATE_CLEANUP' - Properly handles: 1 Drift position → 1 DB trade (correct state) RESULT: - Database: 1 open trade (matches Drift reality) - Analytics: Shows accurate position count - Runs automatically every 10 minutes + manual trigger available
This commit is contained in:
@@ -112,7 +112,7 @@ export async function validateAllOpenTrades(): Promise<ValidationResult> {
|
||||
let driftPositions
|
||||
try {
|
||||
driftPositions = await driftService.getAllPositions()
|
||||
console.log(`📊 Found ${driftPositions.length} positions on Drift`)
|
||||
console.log(`📊 Found ${driftPositions.length} actual positions on Drift`)
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to fetch Drift positions: ${error}`
|
||||
console.error(`❌ ${errorMsg}`)
|
||||
@@ -121,12 +121,20 @@ export async function validateAllOpenTrades(): Promise<ValidationResult> {
|
||||
return result
|
||||
}
|
||||
|
||||
// Validate each database trade
|
||||
// Group database trades by symbol to handle multiple DB entries for same position
|
||||
const tradesBySymbol = new Map<string, any[]>()
|
||||
for (const trade of openTrades) {
|
||||
const existing = tradesBySymbol.get(trade.symbol) || []
|
||||
existing.push(trade)
|
||||
tradesBySymbol.set(trade.symbol, existing)
|
||||
}
|
||||
|
||||
// Validate each symbol's trades against Drift position
|
||||
for (const [symbol, trades] of tradesBySymbol) {
|
||||
try {
|
||||
await validateSingleTrade(trade, driftPositions, result)
|
||||
await validateSymbolTrades(symbol, trades, driftPositions, result)
|
||||
} catch (error) {
|
||||
const errorMsg = `Error validating ${trade.symbol}: ${error}`
|
||||
const errorMsg = `Error validating ${symbol}: ${error}`
|
||||
console.error(`❌ ${errorMsg}`)
|
||||
result.errors.push(errorMsg)
|
||||
}
|
||||
@@ -155,60 +163,97 @@ export async function validateAllOpenTrades(): Promise<ValidationResult> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single trade against Drift positions
|
||||
* Validate all trades for a symbol against the Drift position
|
||||
*
|
||||
* CRITICAL: Multiple DB trades can exist for one Drift position
|
||||
* This happens because old trades get re-opened by startup validator
|
||||
* Only the MOST RECENT trade should be kept open
|
||||
*/
|
||||
async function validateSingleTrade(
|
||||
trade: any,
|
||||
async function validateSymbolTrades(
|
||||
symbol: string,
|
||||
trades: any[],
|
||||
driftPositions: any[],
|
||||
result: ValidationResult
|
||||
): Promise<void> {
|
||||
const prisma = getPrismaClient()
|
||||
|
||||
// Find matching Drift position
|
||||
const driftPosition = driftPositions.find(p => p.symbol === trade.symbol)
|
||||
const driftPosition = driftPositions.find(p => p.symbol === symbol)
|
||||
|
||||
if (!driftPosition || Math.abs(driftPosition.size) < 0.01) {
|
||||
// GHOST DETECTED: Database says open, but Drift says closed/missing
|
||||
console.log(`👻 GHOST DETECTED: ${trade.symbol} ${trade.direction}`)
|
||||
console.log(` DB: Open since ${trade.createdAt.toISOString()}`)
|
||||
console.log(` Drift: Position not found or size = 0`)
|
||||
console.log(` P&L: ${trade.realizedPnL ? `$${trade.realizedPnL.toFixed(2)}` : 'null'}`)
|
||||
// NO POSITION ON DRIFT - all DB trades for this symbol are ghosts
|
||||
console.log(`👻 GHOST DETECTED: ${symbol} - ${trades.length} DB trades but no Drift position`)
|
||||
|
||||
// Check if this is a closed position with P&L but missing exitReason
|
||||
const hasRealizedPnL = trade.realizedPnL !== null && trade.realizedPnL !== undefined
|
||||
const exitReason = hasRealizedPnL ? 'manual' : 'GHOST_CLEANUP'
|
||||
const exitPrice = trade.exitPrice || trade.entryPrice
|
||||
const realizedPnL = trade.realizedPnL || 0
|
||||
for (const trade of trades) {
|
||||
const hasRealizedPnL = trade.realizedPnL !== null && trade.realizedPnL !== undefined
|
||||
const exitReason = hasRealizedPnL ? 'manual' : 'GHOST_CLEANUP'
|
||||
const exitPrice = trade.exitPrice || trade.entryPrice
|
||||
const realizedPnL = trade.realizedPnL || 0
|
||||
|
||||
// Mark as closed in database
|
||||
await prisma.trade.update({
|
||||
where: { id: trade.id },
|
||||
data: {
|
||||
exitReason: exitReason,
|
||||
exitTime: new Date(),
|
||||
exitPrice: exitPrice,
|
||||
realizedPnL: realizedPnL,
|
||||
status: 'closed'
|
||||
}
|
||||
})
|
||||
|
||||
console.log(` ✅ Marked as closed (reason: ${exitReason})`)
|
||||
result.ghosts++
|
||||
await prisma.trade.update({
|
||||
where: { id: trade.id },
|
||||
data: {
|
||||
exitReason: exitReason,
|
||||
exitTime: new Date(),
|
||||
exitPrice: exitPrice,
|
||||
realizedPnL: realizedPnL,
|
||||
status: 'closed'
|
||||
}
|
||||
})
|
||||
|
||||
console.log(` ✅ Closed ghost trade ${trade.id} (${exitReason})`)
|
||||
result.ghosts++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Position exists on Drift - validate it matches
|
||||
// POSITION EXISTS ON DRIFT
|
||||
const driftDirection = driftPosition.side.toLowerCase()
|
||||
|
||||
if (driftDirection !== trade.direction) {
|
||||
console.log(`⚠️ DIRECTION MISMATCH: ${trade.symbol}`)
|
||||
console.log(` DB: ${trade.direction} | Drift: ${driftDirection}`)
|
||||
result.errors.push(`${trade.symbol}: Direction mismatch`)
|
||||
// Check if any trade has wrong direction
|
||||
const wrongDirection = trades.find(t => t.direction !== driftDirection)
|
||||
if (wrongDirection) {
|
||||
console.log(`⚠️ DIRECTION MISMATCH: ${symbol}`)
|
||||
console.log(` DB: ${wrongDirection.direction} | Drift: ${driftDirection}`)
|
||||
result.errors.push(`${symbol}: Direction mismatch`)
|
||||
return
|
||||
}
|
||||
|
||||
// Valid: DB and Drift both show position open with matching direction
|
||||
result.valid++
|
||||
// If multiple trades exist for same symbol, keep only the MOST RECENT
|
||||
if (trades.length > 1) {
|
||||
console.log(`🔄 DUPLICATE TRADES: ${symbol} has ${trades.length} open DB entries for 1 Drift position`)
|
||||
|
||||
// Sort by creation time, keep newest
|
||||
trades.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
const keepTrade = trades[0]
|
||||
const closeTrades = trades.slice(1)
|
||||
|
||||
console.log(` ✅ Keeping: ${keepTrade.id} (${keepTrade.createdAt.toISOString()})`)
|
||||
|
||||
for (const trade of closeTrades) {
|
||||
const hasRealizedPnL = trade.realizedPnL !== null && trade.realizedPnL !== undefined
|
||||
const exitReason = hasRealizedPnL ? 'manual' : 'DUPLICATE_CLEANUP'
|
||||
|
||||
await prisma.trade.update({
|
||||
where: { id: trade.id },
|
||||
data: {
|
||||
exitReason: exitReason,
|
||||
exitTime: new Date(),
|
||||
exitPrice: trade.exitPrice || trade.entryPrice,
|
||||
realizedPnL: trade.realizedPnL || 0,
|
||||
status: 'closed'
|
||||
}
|
||||
})
|
||||
|
||||
console.log(` 🗑️ Closed duplicate: ${trade.id} (${trade.createdAt.toISOString()})`)
|
||||
result.ghosts++
|
||||
}
|
||||
|
||||
result.valid++ // Count the one we kept
|
||||
} else {
|
||||
// Single trade matches single Drift position - all good
|
||||
result.valid++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user