272 lines
11 KiB
TypeScript
272 lines
11 KiB
TypeScript
/**
|
|
* Position Sync Helper
|
|
*
|
|
* Extracted from sync-positions endpoint to allow internal reuse
|
|
* by health monitor for automatic position synchronization.
|
|
*
|
|
* Created: Dec 12, 2025
|
|
* Enhanced: Dec 12, 2025 - Added order signature discovery for synced positions
|
|
*/
|
|
|
|
import { getDriftService } from '../drift/client'
|
|
import { getPrismaClient } from '../database/trades'
|
|
import { getMergedConfig } from '@/config/trading'
|
|
import { OrderType, OrderTriggerCondition } from '@drift-labs/sdk'
|
|
|
|
/**
|
|
* Query Drift for existing orders on a position and extract signatures
|
|
* CRITICAL FIX (Dec 12, 2025): Auto-synced positions need order signatures
|
|
* so Position Manager can update orders after TP1/TP2 events
|
|
*/
|
|
async function discoverExistingOrders(symbol: string, marketIndex: number): Promise<{
|
|
tp1OrderTx?: string
|
|
tp2OrderTx?: string
|
|
slOrderTx?: string
|
|
softStopOrderTx?: string
|
|
hardStopOrderTx?: string
|
|
}> {
|
|
try {
|
|
const driftService = getDriftService()
|
|
const driftClient = driftService.getClient()
|
|
|
|
// Get user account and extract orders
|
|
const userAccount = driftClient.getUserAccount()
|
|
if (!userAccount) {
|
|
console.log('⚠️ No user account found, cannot discover orders')
|
|
return {}
|
|
}
|
|
|
|
// Filter for orders on this market that are reduce-only
|
|
const marketOrders = userAccount.orders.filter((order: any) =>
|
|
order.marketIndex === marketIndex &&
|
|
order.reduceOnly === true &&
|
|
order.orderId && order.orderId !== 0
|
|
)
|
|
|
|
const signatures: any = {}
|
|
|
|
for (const order of marketOrders) {
|
|
// Try to extract transaction signature from order reference
|
|
// Note: Drift SDK may not expose TX signatures directly on orders
|
|
// This is a best-effort attempt to recover order references
|
|
const orderRefStr = order.orderId?.toString() || ''
|
|
|
|
if (order.orderType === OrderType.LIMIT) {
|
|
// TP1 or TP2 (LIMIT orders)
|
|
// Higher trigger = TP for long, lower trigger = TP for short
|
|
if (!signatures.tp1OrderTx) {
|
|
signatures.tp1OrderTx = orderRefStr
|
|
console.log(`🔍 Found potential TP1 order: ${orderRefStr.slice(0, 8)}...`)
|
|
} else if (!signatures.tp2OrderTx) {
|
|
signatures.tp2OrderTx = orderRefStr
|
|
console.log(`🔍 Found potential TP2 order: ${orderRefStr.slice(0, 8)}...`)
|
|
}
|
|
} else if (order.orderType === OrderType.TRIGGER_MARKET) {
|
|
// Hard stop loss (TRIGGER_MARKET)
|
|
if (!signatures.slOrderTx && !signatures.hardStopOrderTx) {
|
|
signatures.hardStopOrderTx = orderRefStr
|
|
console.log(`🔍 Found hard SL order: ${orderRefStr.slice(0, 8)}...`)
|
|
}
|
|
} else if (order.orderType === OrderType.TRIGGER_LIMIT) {
|
|
// Soft stop loss (TRIGGER_LIMIT)
|
|
if (!signatures.softStopOrderTx) {
|
|
signatures.softStopOrderTx = orderRefStr
|
|
console.log(`🔍 Found soft SL order: ${orderRefStr.slice(0, 8)}...`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Log what we found
|
|
const foundCount = Object.keys(signatures).filter(k => signatures[k]).length
|
|
if (foundCount > 0) {
|
|
console.log(`✅ Discovered ${foundCount} existing order(s) for ${symbol}`)
|
|
} else {
|
|
console.warn(`⚠️ No existing orders found for ${symbol} - PM won't be able to update orders after events`)
|
|
}
|
|
|
|
return signatures
|
|
} catch (error) {
|
|
console.error(`❌ Error discovering orders for ${symbol}:`, error)
|
|
return {} // Return empty object on error (fail gracefully)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sync a single Drift position to Position Manager
|
|
*/
|
|
export async function syncSinglePosition(driftPos: any, positionManager: any): Promise<void> {
|
|
const driftService = getDriftService()
|
|
const prisma = getPrismaClient()
|
|
const config = getMergedConfig()
|
|
|
|
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 (Drift size is tokens)
|
|
const positionSizeUSD = Math.abs(driftPos.size) * currentPrice
|
|
|
|
// Try to find an existing open trade in the database for this symbol
|
|
const existingTrade = await prisma.trade.findFirst({
|
|
where: {
|
|
symbol: driftPos.symbol,
|
|
status: 'open',
|
|
},
|
|
orderBy: { entryTime: 'desc' },
|
|
})
|
|
|
|
const normalizeDirection = (dir: string): 'long' | 'short' =>
|
|
dir === 'long' ? 'long' : 'short'
|
|
|
|
const buildActiveTradeFromDb = (dbTrade: any): any => {
|
|
const pmState = (dbTrade.configSnapshot as any)?.positionManagerState
|
|
|
|
return {
|
|
id: dbTrade.id,
|
|
positionId: dbTrade.positionId,
|
|
symbol: dbTrade.symbol,
|
|
direction: normalizeDirection(dbTrade.direction),
|
|
entryPrice: dbTrade.entryPrice,
|
|
entryTime: dbTrade.entryTime.getTime(),
|
|
positionSize: dbTrade.positionSizeUSD,
|
|
leverage: dbTrade.leverage,
|
|
stopLossPrice: pmState?.stopLossPrice ?? dbTrade.stopLossPrice,
|
|
tp1Price: dbTrade.takeProfit1Price,
|
|
tp2Price: dbTrade.takeProfit2Price,
|
|
emergencyStopPrice: dbTrade.stopLossPrice * (dbTrade.direction === 'long' ? 0.98 : 1.02),
|
|
currentSize: pmState?.currentSize ?? dbTrade.positionSizeUSD,
|
|
originalPositionSize: dbTrade.positionSizeUSD,
|
|
takeProfitPrice1: dbTrade.takeProfit1Price,
|
|
takeProfitPrice2: dbTrade.takeProfit2Price,
|
|
tp1Hit: pmState?.tp1Hit ?? false,
|
|
tp2Hit: pmState?.tp2Hit ?? false,
|
|
slMovedToBreakeven: pmState?.slMovedToBreakeven ?? false,
|
|
slMovedToProfit: pmState?.slMovedToProfit ?? false,
|
|
trailingStopActive: pmState?.trailingStopActive ?? false,
|
|
trailingStopDistance: pmState?.trailingStopDistance,
|
|
realizedPnL: pmState?.realizedPnL ?? 0,
|
|
unrealizedPnL: pmState?.unrealizedPnL ?? 0,
|
|
peakPnL: pmState?.peakPnL ?? 0,
|
|
peakPrice: pmState?.peakPrice ?? dbTrade.entryPrice,
|
|
maxFavorableExcursion: pmState?.maxFavorableExcursion ?? 0,
|
|
maxAdverseExcursion: pmState?.maxAdverseExcursion ?? 0,
|
|
maxFavorablePrice: pmState?.maxFavorablePrice ?? dbTrade.entryPrice,
|
|
maxAdversePrice: pmState?.maxAdversePrice ?? dbTrade.entryPrice,
|
|
originalAdx: dbTrade.adxAtEntry,
|
|
timesScaled: pmState?.timesScaled ?? 0,
|
|
totalScaleAdded: pmState?.totalScaleAdded ?? 0,
|
|
atrAtEntry: dbTrade.atrAtEntry,
|
|
// TP2 runner configuration (CRITICAL FIX - Dec 12, 2025)
|
|
tp1SizePercent: pmState?.tp1SizePercent ?? dbTrade.tp1SizePercent ?? config.takeProfit1SizePercent,
|
|
tp2SizePercent: pmState?.tp2SizePercent ?? dbTrade.tp2SizePercent ?? config.takeProfit2SizePercent,
|
|
runnerTrailingPercent: pmState?.runnerTrailingPercent,
|
|
priceCheckCount: 0,
|
|
lastPrice: pmState?.lastPrice ?? dbTrade.entryPrice,
|
|
lastUpdateTime: Date.now(),
|
|
}
|
|
}
|
|
|
|
let activeTrade
|
|
|
|
if (existingTrade) {
|
|
console.log(`🔗 Found existing open trade in DB for ${driftPos.symbol}, attaching to Position Manager`)
|
|
activeTrade = buildActiveTradeFromDb(existingTrade)
|
|
} else {
|
|
console.warn(`⚠️ No open DB trade found for ${driftPos.symbol}. Creating synced placeholder to restore protection.`)
|
|
const now = new Date()
|
|
const syntheticPositionId = `autosync-${now.getTime()}-${driftPos.marketIndex}`
|
|
|
|
// CRITICAL FIX (Dec 12, 2025): Query Drift for existing orders
|
|
// Auto-synced positions need order signatures so PM can update orders after TP1/TP2 events
|
|
console.log(`🔍 Querying Drift for existing orders on ${driftPos.symbol}...`)
|
|
const existingOrders = await discoverExistingOrders(driftPos.symbol, driftPos.marketIndex)
|
|
|
|
const placeholderTrade = await prisma.trade.create({
|
|
data: {
|
|
positionId: syntheticPositionId,
|
|
symbol: driftPos.symbol,
|
|
direction,
|
|
entryPrice,
|
|
entryTime: now,
|
|
positionSizeUSD,
|
|
collateralUSD: positionSizeUSD / config.leverage,
|
|
leverage: config.leverage,
|
|
stopLossPrice,
|
|
takeProfit1Price: tp1Price,
|
|
takeProfit2Price: tp2Price,
|
|
tp1SizePercent: config.takeProfit1SizePercent,
|
|
tp2SizePercent:
|
|
config.useTp2AsTriggerOnly && (config.takeProfit2SizePercent ?? 0) <= 0
|
|
? 0
|
|
: (config.takeProfit2SizePercent ?? 0),
|
|
status: 'open',
|
|
signalSource: 'autosync',
|
|
timeframe: 'sync',
|
|
// CRITICAL FIX (Dec 12, 2025): Record discovered order signatures
|
|
tp1OrderTx: existingOrders.tp1OrderTx || null,
|
|
tp2OrderTx: existingOrders.tp2OrderTx || null,
|
|
slOrderTx: existingOrders.slOrderTx || null,
|
|
softStopOrderTx: existingOrders.softStopOrderTx || null,
|
|
hardStopOrderTx: existingOrders.hardStopOrderTx || null,
|
|
configSnapshot: {
|
|
source: 'health-monitor-autosync',
|
|
syncedAt: now.toISOString(),
|
|
discoveredOrders: existingOrders, // Store for debugging
|
|
positionManagerState: {
|
|
currentSize: positionSizeUSD,
|
|
tp1Hit: false,
|
|
tp2Hit: false,
|
|
slMovedToBreakeven: false,
|
|
slMovedToProfit: false,
|
|
trailingStopActive: false,
|
|
stopLossPrice,
|
|
realizedPnL: 0,
|
|
unrealizedPnL: driftPos.unrealizedPnL ?? 0,
|
|
peakPnL: driftPos.unrealizedPnL ?? 0,
|
|
peakPrice: entryPrice,
|
|
lastPrice: currentPrice,
|
|
maxFavorableExcursion: 0,
|
|
maxAdverseExcursion: 0,
|
|
maxFavorablePrice: entryPrice,
|
|
maxAdversePrice: entryPrice,
|
|
lastUpdate: now.toISOString(),
|
|
// TP2 runner configuration (CRITICAL FIX - Dec 12, 2025)
|
|
tp1SizePercent: config.takeProfit1SizePercent,
|
|
tp2SizePercent: config.takeProfit2SizePercent,
|
|
trailingStopDistance: undefined,
|
|
runnerTrailingPercent: undefined,
|
|
},
|
|
},
|
|
entryOrderTx: syntheticPositionId,
|
|
},
|
|
})
|
|
|
|
activeTrade = buildActiveTradeFromDb(placeholderTrade)
|
|
}
|
|
|
|
await positionManager.addTrade(activeTrade)
|
|
console.log(`✅ Synced ${driftPos.symbol} to Position Manager`)
|
|
} catch (error) {
|
|
console.error(`❌ Failed to sync ${driftPos.symbol}:`, error)
|
|
throw error
|
|
}
|
|
}
|