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
|
let driftPositions
|
||||||
try {
|
try {
|
||||||
driftPositions = await driftService.getAllPositions()
|
driftPositions = await driftService.getAllPositions()
|
||||||
console.log(`📊 Found ${driftPositions.length} positions on Drift`)
|
console.log(`📊 Found ${driftPositions.length} actual positions on Drift`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMsg = `Failed to fetch Drift positions: ${error}`
|
const errorMsg = `Failed to fetch Drift positions: ${error}`
|
||||||
console.error(`❌ ${errorMsg}`)
|
console.error(`❌ ${errorMsg}`)
|
||||||
@@ -121,12 +121,20 @@ export async function validateAllOpenTrades(): Promise<ValidationResult> {
|
|||||||
return result
|
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) {
|
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 {
|
try {
|
||||||
await validateSingleTrade(trade, driftPositions, result)
|
await validateSymbolTrades(symbol, trades, driftPositions, result)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMsg = `Error validating ${trade.symbol}: ${error}`
|
const errorMsg = `Error validating ${symbol}: ${error}`
|
||||||
console.error(`❌ ${errorMsg}`)
|
console.error(`❌ ${errorMsg}`)
|
||||||
result.errors.push(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(
|
async function validateSymbolTrades(
|
||||||
trade: any,
|
symbol: string,
|
||||||
|
trades: any[],
|
||||||
driftPositions: any[],
|
driftPositions: any[],
|
||||||
result: ValidationResult
|
result: ValidationResult
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const prisma = getPrismaClient()
|
const prisma = getPrismaClient()
|
||||||
|
|
||||||
// Find matching Drift position
|
// 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) {
|
if (!driftPosition || Math.abs(driftPosition.size) < 0.01) {
|
||||||
// GHOST DETECTED: Database says open, but Drift says closed/missing
|
// NO POSITION ON DRIFT - all DB trades for this symbol are ghosts
|
||||||
console.log(`👻 GHOST DETECTED: ${trade.symbol} ${trade.direction}`)
|
console.log(`👻 GHOST DETECTED: ${symbol} - ${trades.length} DB trades but no Drift position`)
|
||||||
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'}`)
|
|
||||||
|
|
||||||
// Check if this is a closed position with P&L but missing exitReason
|
for (const trade of trades) {
|
||||||
const hasRealizedPnL = trade.realizedPnL !== null && trade.realizedPnL !== undefined
|
const hasRealizedPnL = trade.realizedPnL !== null && trade.realizedPnL !== undefined
|
||||||
const exitReason = hasRealizedPnL ? 'manual' : 'GHOST_CLEANUP'
|
const exitReason = hasRealizedPnL ? 'manual' : 'GHOST_CLEANUP'
|
||||||
const exitPrice = trade.exitPrice || trade.entryPrice
|
const exitPrice = trade.exitPrice || trade.entryPrice
|
||||||
const realizedPnL = trade.realizedPnL || 0
|
const realizedPnL = trade.realizedPnL || 0
|
||||||
|
|
||||||
// Mark as closed in database
|
await prisma.trade.update({
|
||||||
await prisma.trade.update({
|
where: { id: trade.id },
|
||||||
where: { id: trade.id },
|
data: {
|
||||||
data: {
|
exitReason: exitReason,
|
||||||
exitReason: exitReason,
|
exitTime: new Date(),
|
||||||
exitTime: new Date(),
|
exitPrice: exitPrice,
|
||||||
exitPrice: exitPrice,
|
realizedPnL: realizedPnL,
|
||||||
realizedPnL: realizedPnL,
|
status: 'closed'
|
||||||
status: 'closed'
|
}
|
||||||
}
|
})
|
||||||
})
|
|
||||||
|
|
||||||
console.log(` ✅ Marked as closed (reason: ${exitReason})`)
|
console.log(` ✅ Closed ghost trade ${trade.id} (${exitReason})`)
|
||||||
result.ghosts++
|
result.ghosts++
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position exists on Drift - validate it matches
|
// POSITION EXISTS ON DRIFT
|
||||||
const driftDirection = driftPosition.side.toLowerCase()
|
const driftDirection = driftPosition.side.toLowerCase()
|
||||||
|
|
||||||
if (driftDirection !== trade.direction) {
|
// Check if any trade has wrong direction
|
||||||
console.log(`⚠️ DIRECTION MISMATCH: ${trade.symbol}`)
|
const wrongDirection = trades.find(t => t.direction !== driftDirection)
|
||||||
console.log(` DB: ${trade.direction} | Drift: ${driftDirection}`)
|
if (wrongDirection) {
|
||||||
result.errors.push(`${trade.symbol}: Direction mismatch`)
|
console.log(`⚠️ DIRECTION MISMATCH: ${symbol}`)
|
||||||
|
console.log(` DB: ${wrongDirection.direction} | Drift: ${driftDirection}`)
|
||||||
|
result.errors.push(`${symbol}: Direction mismatch`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valid: DB and Drift both show position open with matching direction
|
// If multiple trades exist for same symbol, keep only the MOST RECENT
|
||||||
result.valid++
|
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