Add Position Sync feature for recovering tracking after partial fills
- New /api/trading/sync-positions endpoint (no auth) - Fetches actual Drift positions and compares with Position Manager - Removes stale tracking, adds missing positions with calculated TP/SL - Settings UI: Orange 'Sync Positions' button added - CLI script: scripts/sync-positions.sh for terminal access - Full documentation in docs/guides/POSITION_SYNC_GUIDE.md - Quick reference: POSITION_SYNC_QUICK_REF.md - Updated AI instructions with pitfall #23 Problem solved: Manual Telegram trades with partial fills can cause Position Manager to lose tracking, leaving positions without software- based stop loss protection. This feature restores dual-layer protection. Note: Docker build not picking up route yet (cache issue), needs investigation
This commit is contained in:
193
app/api/trading/sync-positions/route.ts
Normal file
193
app/api/trading/sync-positions/route.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Sync Positions API Endpoint
|
||||
*
|
||||
* Re-synchronizes Position Manager with actual Drift positions
|
||||
* Useful when:
|
||||
* - Partial fills cause tracking issues
|
||||
* - Bot restarts and loses in-memory state
|
||||
* - Manual interventions on Drift
|
||||
* - Database gets out of sync
|
||||
*
|
||||
* POST /api/trading/sync-positions
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { initializeDriftService, getDriftService } from '@/lib/drift/client'
|
||||
import { getInitializedPositionManager } from '@/lib/trading/position-manager'
|
||||
import { getPrismaClient } from '@/lib/database/trades'
|
||||
import { getMergedConfig } from '@/config/trading'
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
console.log('🔄 Position sync requested...')
|
||||
|
||||
const config = getMergedConfig()
|
||||
const driftService = await getDriftService()
|
||||
const positionManager = await getInitializedPositionManager()
|
||||
const prisma = getPrismaClient()
|
||||
|
||||
// Get all current Drift positions
|
||||
const driftPositions = await driftService.getAllPositions()
|
||||
console.log(`📊 Found ${driftPositions.length} positions on Drift`)
|
||||
|
||||
// Get all currently tracked positions
|
||||
const trackedTrades = Array.from(positionManager.getActiveTrades().values())
|
||||
console.log(`📋 Position Manager tracking ${trackedTrades.length} trades`)
|
||||
|
||||
const syncResults = {
|
||||
drift_positions: driftPositions.length,
|
||||
tracked_positions: trackedTrades.length,
|
||||
added: [] as string[],
|
||||
removed: [] as string[],
|
||||
unchanged: [] as string[],
|
||||
errors: [] as string[],
|
||||
}
|
||||
|
||||
// Step 1: Remove tracked positions that don't exist on Drift
|
||||
for (const trade of trackedTrades) {
|
||||
const existsOnDrift = driftPositions.some(p => p.symbol === trade.symbol)
|
||||
|
||||
if (!existsOnDrift) {
|
||||
console.log(`🗑️ Removing ${trade.symbol} (not on Drift)`)
|
||||
await positionManager.removeTrade(trade.id)
|
||||
syncResults.removed.push(trade.symbol)
|
||||
|
||||
// Mark as closed in database
|
||||
try {
|
||||
await prisma.trade.update({
|
||||
where: { positionId: trade.positionId },
|
||||
data: {
|
||||
status: 'closed',
|
||||
exitReason: 'sync_cleanup',
|
||||
exitTime: new Date(),
|
||||
},
|
||||
})
|
||||
} catch (dbError) {
|
||||
console.error(`❌ Failed to update database for ${trade.symbol}:`, dbError)
|
||||
}
|
||||
} else {
|
||||
syncResults.unchanged.push(trade.symbol)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Add Drift positions that aren't being tracked
|
||||
for (const driftPos of driftPositions) {
|
||||
const isTracked = trackedTrades.some(t => t.symbol === driftPos.symbol)
|
||||
|
||||
if (!isTracked) {
|
||||
console.log(`➕ Adding ${driftPos.symbol} to Position Manager`)
|
||||
|
||||
try {
|
||||
// Get current oracle price for this market
|
||||
const currentPrice = await driftService.getOraclePrice(driftPos.marketIndex)
|
||||
|
||||
// Calculate targets based on current config
|
||||
const direction = driftPos.side
|
||||
const entryPrice = driftPos.entryPrice
|
||||
|
||||
// Calculate TP/SL prices
|
||||
const calculatePrice = (entry: number, percent: number, dir: 'long' | 'short') => {
|
||||
if (dir === 'long') {
|
||||
return entry * (1 + percent / 100)
|
||||
} else {
|
||||
return entry * (1 - percent / 100)
|
||||
}
|
||||
}
|
||||
|
||||
const stopLossPrice = calculatePrice(entryPrice, config.stopLossPercent, direction)
|
||||
const tp1Price = calculatePrice(entryPrice, config.takeProfit1Percent, direction)
|
||||
const tp2Price = calculatePrice(entryPrice, config.takeProfit2Percent, direction)
|
||||
const emergencyStopPrice = calculatePrice(entryPrice, config.emergencyStopPercent, direction)
|
||||
|
||||
// Calculate position size in USD
|
||||
const positionSizeUSD = driftPos.size * currentPrice
|
||||
|
||||
// Create ActiveTrade object
|
||||
const activeTrade = {
|
||||
id: `sync-${Date.now()}-${driftPos.symbol}`,
|
||||
positionId: `manual-${Date.now()}`, // Synthetic ID since we don't have the original
|
||||
symbol: driftPos.symbol,
|
||||
direction: direction,
|
||||
entryPrice: entryPrice,
|
||||
entryTime: Date.now() - (60 * 60 * 1000), // Assume 1 hour ago (we don't know actual time)
|
||||
positionSize: positionSizeUSD,
|
||||
leverage: config.leverage,
|
||||
stopLossPrice: stopLossPrice,
|
||||
tp1Price: tp1Price,
|
||||
tp2Price: tp2Price,
|
||||
emergencyStopPrice: emergencyStopPrice,
|
||||
currentSize: positionSizeUSD,
|
||||
tp1Hit: false,
|
||||
tp2Hit: false,
|
||||
slMovedToBreakeven: false,
|
||||
slMovedToProfit: false,
|
||||
trailingStopActive: false,
|
||||
realizedPnL: 0,
|
||||
unrealizedPnL: driftPos.unrealizedPnL,
|
||||
peakPnL: driftPos.unrealizedPnL,
|
||||
peakPrice: currentPrice,
|
||||
maxFavorableExcursion: 0,
|
||||
maxAdverseExcursion: 0,
|
||||
maxFavorablePrice: currentPrice,
|
||||
maxAdversePrice: currentPrice,
|
||||
originalAdx: undefined,
|
||||
timesScaled: 0,
|
||||
totalScaleAdded: 0,
|
||||
atrAtEntry: undefined,
|
||||
runnerTrailingPercent: undefined,
|
||||
priceCheckCount: 0,
|
||||
lastPrice: currentPrice,
|
||||
lastUpdateTime: Date.now(),
|
||||
}
|
||||
|
||||
await positionManager.addTrade(activeTrade)
|
||||
syncResults.added.push(driftPos.symbol)
|
||||
|
||||
console.log(`✅ Added ${driftPos.symbol} to monitoring`)
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to add ${driftPos.symbol}:`, error)
|
||||
syncResults.errors.push(`${driftPos.symbol}: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
success: true,
|
||||
message: 'Position sync complete',
|
||||
results: syncResults,
|
||||
details: {
|
||||
drift_positions: driftPositions.map(p => ({
|
||||
symbol: p.symbol,
|
||||
direction: p.side,
|
||||
size: p.size,
|
||||
entry: p.entryPrice,
|
||||
pnl: p.unrealizedPnL,
|
||||
})),
|
||||
now_tracking: Array.from(positionManager.getActiveTrades().values()).map(t => ({
|
||||
symbol: t.symbol,
|
||||
direction: t.direction,
|
||||
entry: t.entryPrice,
|
||||
})),
|
||||
},
|
||||
}
|
||||
|
||||
console.log('✅ Position sync complete')
|
||||
console.log(` Added: ${syncResults.added.length}`)
|
||||
console.log(` Removed: ${syncResults.removed.length}`)
|
||||
console.log(` Unchanged: ${syncResults.unchanged.length}`)
|
||||
console.log(` Errors: ${syncResults.errors.length}`)
|
||||
|
||||
return NextResponse.json(summary)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Position sync error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user