Files
trading_bot_v4/lib/drift/orders.ts
2025-12-10 15:05:44 +01:00

974 lines
38 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Drift Order Execution
*
* Handles opening and closing positions with market orders
*/
import { getDriftService, initializeDriftService } from './client'
import { logger } from '../utils/logger'
import { getMarketConfig } from '../../config/trading'
import BN from 'bn.js'
import {
MarketType,
PositionDirection,
OrderType,
OrderParams,
OrderTriggerCondition,
} from '@drift-labs/sdk'
export interface OpenPositionParams {
symbol: string // e.g., 'SOL-PERP'
direction: 'long' | 'short'
sizeUSD: number // USD notional size
slippageTolerance: number // Percentage (e.g., 1.0 for 1%)
}
export interface OpenPositionResult {
success: boolean
transactionSignature?: string
fillPrice?: number
fillSize?: number
slippage?: number
error?: string
isPhantom?: boolean // Position opened but size mismatch detected
actualSizeUSD?: number // Actual position size if different from requested
}
export interface ClosePositionParams {
symbol: string
percentToClose: number // 0-100
slippageTolerance: number
}
export interface ClosePositionResult {
success: boolean
transactionSignature?: string
closePrice?: number
closedSize?: number
realizedPnL?: number
needsVerification?: boolean
error?: string
}
export interface PlaceExitOrdersResult {
success: boolean
signatures?: string[]
error?: string
}
export interface PlaceExitOrdersOptions {
symbol: string
positionSizeUSD: number
entryPrice: number // CRITICAL: Entry price for calculating position size in base assets
tp1Price: number
tp2Price: number
stopLossPrice: number
tp1SizePercent: number
tp2SizePercent: number
direction: 'long' | 'short'
useStopLimit?: boolean // Optional: use TRIGGER_LIMIT instead of TRIGGER_MARKET for SL
stopLimitBuffer?: number // Optional: buffer percentage for stop-limit (default 0.5%)
// Dual Stop System
useDualStops?: boolean // Enable dual stop system
softStopPrice?: number // Soft stop trigger price (TRIGGER_LIMIT)
softStopBuffer?: number // Buffer for soft stop limit price
hardStopPrice?: number // Hard stop trigger price (TRIGGER_MARKET)
}
/**
* Open a position with a market order
*/
export async function openPosition(
params: OpenPositionParams
): Promise<OpenPositionResult> {
try {
logger.log('📊 Opening position:', params)
const driftService = getDriftService()
const marketConfig = getMarketConfig(params.symbol)
const driftClient = driftService.getClient()
// Get current oracle price
const oraclePrice = await driftService.getOraclePrice(marketConfig.driftMarketIndex)
logger.log(`💰 Current ${params.symbol} price: $${oraclePrice.toFixed(4)}`)
// Calculate position size in base asset
const baseAssetSize = params.sizeUSD / oraclePrice
// Validate minimum order size
if (baseAssetSize < marketConfig.minOrderSize) {
throw new Error(
`Order size ${baseAssetSize.toFixed(4)} is below minimum ${marketConfig.minOrderSize}`
)
}
// Calculate worst acceptable price (with slippage)
const slippageMultiplier = params.direction === 'long'
? 1 + (params.slippageTolerance / 100)
: 1 - (params.slippageTolerance / 100)
const worstPrice = oraclePrice * slippageMultiplier
logger.log(`📝 Order details:`)
logger.log(` Size: ${baseAssetSize.toFixed(4)} ${params.symbol.split('-')[0]}`)
logger.log(` Notional: $${params.sizeUSD.toFixed(2)}`)
logger.log(` Oracle price: $${oraclePrice.toFixed(4)}`)
logger.log(` Worst price (${params.slippageTolerance}% slippage): $${worstPrice.toFixed(4)}`)
// Check DRY_RUN mode
const isDryRun = process.env.DRY_RUN === 'true'
if (isDryRun) {
logger.log('🧪 DRY RUN MODE: Simulating order (not executing on blockchain)')
const mockTxSig = `DRY_RUN_${Date.now()}_${Math.random().toString(36).substring(7)}`
return {
success: true,
transactionSignature: mockTxSig,
fillPrice: oraclePrice,
fillSize: baseAssetSize,
slippage: 0,
}
}
// Prepare order parameters - use simple structure like v3
const orderParams = {
orderType: OrderType.MARKET,
marketIndex: marketConfig.driftMarketIndex,
direction: params.direction === 'long'
? PositionDirection.LONG
: PositionDirection.SHORT,
baseAssetAmount: new BN(Math.floor(baseAssetSize * 1e9)), // 9 decimals
reduceOnly: false,
}
// Place market order using simple placePerpOrder (like v3)
logger.log('🚀 Placing REAL market order...')
const txSig = await driftClient.placePerpOrder(orderParams)
logger.log(`📝 Transaction submitted: ${txSig}`)
// CRITICAL: Confirm transaction actually executed on-chain
logger.log('⏳ Confirming transaction on-chain...')
const connection = driftService.getTradeConnection() // Use Alchemy for trade operations
try {
const confirmation = await connection.confirmTransaction(txSig, 'confirmed')
if (confirmation.value.err) {
console.error(`❌ Transaction failed on-chain:`, confirmation.value.err)
return {
success: false,
error: `Transaction failed: ${JSON.stringify(confirmation.value.err)}`,
}
}
logger.log(`✅ Transaction confirmed on-chain: ${txSig}`)
} catch (confirmError) {
console.error(`❌ Failed to confirm transaction:`, confirmError)
return {
success: false,
error: `Transaction confirmation failed: ${confirmError instanceof Error ? confirmError.message : 'Unknown error'}`,
}
}
// Wait a moment for position to update
logger.log('⏳ Waiting for position to update...')
await new Promise(resolve => setTimeout(resolve, 2000))
// Get actual fill price from position
const position = await driftService.getPosition(marketConfig.driftMarketIndex)
if (position && position.side !== 'none') {
const fillPrice = position.entryPrice
const slippage = Math.abs((fillPrice - oraclePrice) / oraclePrice) * 100
// CRITICAL: Validate actual position size vs expected
// Phantom trade detection: Check if position is significantly smaller than expected
const actualSizeUSD = position.size * fillPrice
const expectedSizeUSD = params.sizeUSD
const sizeRatio = actualSizeUSD / expectedSizeUSD
logger.log(`💰 Fill details:`)
logger.log(` Fill price: $${fillPrice.toFixed(4)}`)
logger.log(` Slippage: ${slippage.toFixed(3)}%`)
logger.log(` Expected size: $${expectedSizeUSD.toFixed(2)}`)
logger.log(` Actual size: $${actualSizeUSD.toFixed(2)}`)
logger.log(` Size ratio: ${(sizeRatio * 100).toFixed(1)}%`)
// Flag as phantom if actual size is less than 50% of expected
const isPhantom = sizeRatio < 0.5
if (isPhantom) {
console.error(`🚨 PHANTOM POSITION DETECTED!`)
console.error(` Expected: $${expectedSizeUSD.toFixed(2)}`)
console.error(` Actual: $${actualSizeUSD.toFixed(2)}`)
console.error(` This indicates the order was rejected or partially filled by Drift`)
}
return {
success: true,
transactionSignature: txSig,
fillPrice,
fillSize: position.size, // Use actual size from Drift, not calculated
slippage,
isPhantom,
actualSizeUSD,
}
} else {
// Position not found yet (may be DRY_RUN mode)
logger.log(`⚠️ Position not immediately visible (may be DRY_RUN mode)`)
logger.log(` Using oracle price as estimate: $${oraclePrice.toFixed(4)}`)
return {
success: true,
transactionSignature: txSig,
fillPrice: oraclePrice,
fillSize: baseAssetSize,
slippage: 0,
}
}
} catch (error) {
console.error('❌ Failed to open position:', error)
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}
/**
* Place on-chain exit orders (reduce-only orders) so TP/SL show up in Drift UI.
*
* Stop Loss Strategy:
* - Default: TRIGGER_MARKET (guaranteed execution, recommended for most traders)
* - Optional: TRIGGER_LIMIT with buffer (protects against extreme wicks in liquid markets)
*
* Take Profit Strategy:
* - Always uses LIMIT orders to lock in desired prices
*/
export async function placeExitOrders(options: PlaceExitOrdersOptions): Promise<PlaceExitOrdersResult> {
try {
logger.log('🛡️ Placing exit orders on-chain:', options.symbol)
const driftService = getDriftService()
const driftClient = driftService.getClient()
const marketConfig = getMarketConfig(options.symbol)
const isDryRun = process.env.DRY_RUN === 'true'
if (isDryRun) {
logger.log('🧪 DRY RUN: Simulating placement of exit orders')
return {
success: true,
signatures: [
`DRY_TP1_${Date.now()}`,
`DRY_TP2_${Date.now()}`,
`DRY_SL_${Date.now()}`,
],
}
}
const signatures: string[] = []
// Helper to compute base asset amount from USD notional and price
// CRITICAL FIX (Dec 10, 2025): Must use SPECIFIC PRICE for each order (TP1 price, TP2 price, SL price)
// Bug discovered: Using entryPrice for all orders causes wrong token quantities = rejected orders
// Original working implementation (commit 4cc294b, Oct 26): "All 3 exit orders placed successfully"
const usdToBase = (usd: number, price: number) => {
const base = usd / price // Use the specific order price (NOT entryPrice)
return Math.floor(base * 1e9) // 9 decimals expected by SDK
}
// Calculate sizes in USD for each TP
// CRITICAL FIX: TP2 must be percentage of REMAINING size after TP1, not original size
const tp1USD = (options.positionSizeUSD * options.tp1SizePercent) / 100
const remainingAfterTP1 = options.positionSizeUSD - tp1USD
const requestedTp2Percent = options.tp2SizePercent ?? 100
const normalizedTp2Percent = requestedTp2Percent > 0 ? requestedTp2Percent : 100
const tp2USD = (remainingAfterTP1 * normalizedTp2Percent) / 100
logger.log(`📊 Exit order sizes:`)
logger.log(` TP1: ${options.tp1SizePercent}% of $${options.positionSizeUSD.toFixed(2)} = $${tp1USD.toFixed(2)}`)
logger.log(` Remaining after TP1: $${remainingAfterTP1.toFixed(2)}`)
logger.log(` TP2: ${normalizedTp2Percent}% of remaining = $${tp2USD.toFixed(2)}`)
logger.log(` Runner (if any): $${(remainingAfterTP1 - tp2USD).toFixed(2)}`)
// For orders that close a long, the order direction should be SHORT (sell)
const orderDirection = options.direction === 'long' ? PositionDirection.SHORT : PositionDirection.LONG
// Place TP1 LIMIT reduce-only
if (tp1USD > 0) {
const baseAmount = usdToBase(tp1USD, options.tp1Price) // Use TP1 price
if (baseAmount >= Math.floor(marketConfig.minOrderSize * 1e9)) {
const orderParams: any = {
orderType: OrderType.LIMIT,
marketIndex: marketConfig.driftMarketIndex,
direction: orderDirection,
baseAssetAmount: new BN(baseAmount),
price: new BN(Math.floor(options.tp1Price * 1e6)), // price in 1e6
reduceOnly: true,
}
logger.log('🚧 Placing TP1 limit order (reduce-only)...')
const sig = await retryWithBackoff(async () =>
await (driftClient as any).placePerpOrder(orderParams)
)
logger.log('✅ TP1 order placed:', sig)
signatures.push(sig)
} else {
logger.log('⚠️ TP1 size below market min, skipping on-chain TP1')
}
}
// Place TP2 LIMIT reduce-only
if (tp2USD > 0) {
const baseAmount = usdToBase(tp2USD, options.tp2Price) // Use TP2 price
if (baseAmount >= Math.floor(marketConfig.minOrderSize * 1e9)) {
const orderParams: any = {
orderType: OrderType.LIMIT,
marketIndex: marketConfig.driftMarketIndex,
direction: orderDirection,
baseAssetAmount: new BN(baseAmount),
price: new BN(Math.floor(options.tp2Price * 1e6)),
reduceOnly: true,
}
logger.log('🚧 Placing TP2 limit order (reduce-only)...')
const sig = await retryWithBackoff(async () =>
await (driftClient as any).placePerpOrder(orderParams)
)
logger.log('✅ TP2 order placed:', sig)
signatures.push(sig)
} else {
logger.log('⚠️ TP2 size below market min, skipping on-chain TP2')
}
}
// Place Stop-Loss order(s)
// Supports three modes:
// 1. Dual Stop System (soft stop-limit + hard stop-market)
// 2. Single TRIGGER_LIMIT (for liquid markets)
// 3. Single TRIGGER_MARKET (default, guaranteed execution)
const slUSD = options.positionSizeUSD
const slBaseAmount = usdToBase(slUSD, options.stopLossPrice) // Use SL price
// Calculate expected number of orders for validation (Bug #76 fix)
const useDualStops = options.useDualStops ?? false
const expectedOrderCount = 2 + (useDualStops ? 2 : 1) // TP1 + TP2 + (soft+hard SL OR single SL)
logger.log(`📊 Expected ${expectedOrderCount} exit orders total (TP1 + TP2 + ${useDualStops ? 'dual stops' : 'single stop'})`)
if (slBaseAmount >= Math.floor(marketConfig.minOrderSize * 1e9)) {
if (useDualStops && options.softStopPrice && options.hardStopPrice) {
// ============== DUAL STOP SYSTEM ==============
logger.log('🛡️🛡️ Placing DUAL STOP SYSTEM...')
try {
// 1. Soft Stop (TRIGGER_LIMIT) - Avoids wicks
const softStopBuffer = options.softStopBuffer ?? 0.4
const softStopMultiplier = options.direction === 'long'
? (1 - softStopBuffer / 100)
: (1 + softStopBuffer / 100)
const softStopParams: any = {
orderType: OrderType.TRIGGER_LIMIT,
marketIndex: marketConfig.driftMarketIndex,
direction: orderDirection,
baseAssetAmount: new BN(slBaseAmount),
triggerPrice: new BN(Math.floor(options.softStopPrice * 1e6)),
price: new BN(Math.floor(options.softStopPrice * softStopMultiplier * 1e6)),
triggerCondition: options.direction === 'long'
? OrderTriggerCondition.BELOW
: OrderTriggerCondition.ABOVE,
reduceOnly: true,
}
logger.log(` 1⃣ Soft Stop (TRIGGER_LIMIT):`)
logger.log(` Trigger: $${options.softStopPrice.toFixed(4)}`)
logger.log(` Limit: $${(options.softStopPrice * softStopMultiplier).toFixed(4)}`)
logger.log(` Purpose: Avoid false breakouts/wicks`)
logger.log(` 🔄 Executing soft stop placement...`)
const softStopSig = await retryWithBackoff(async () =>
await (driftClient as any).placePerpOrder(softStopParams)
)
logger.log(` ✅ Soft stop placed: ${softStopSig}`)
signatures.push(softStopSig)
} catch (softStopError) {
console.error(`❌ CRITICAL: Failed to place soft stop:`, softStopError)
throw new Error(`Soft stop placement failed: ${softStopError instanceof Error ? softStopError.message : 'Unknown error'}`)
}
try {
// 2. Hard Stop (TRIGGER_MARKET) - Guarantees exit
const hardStopParams: any = {
orderType: OrderType.TRIGGER_MARKET,
marketIndex: marketConfig.driftMarketIndex,
direction: orderDirection,
baseAssetAmount: new BN(slBaseAmount),
triggerPrice: new BN(Math.floor(options.hardStopPrice * 1e6)),
triggerCondition: options.direction === 'long'
? OrderTriggerCondition.BELOW
: OrderTriggerCondition.ABOVE,
reduceOnly: true,
}
logger.log(` 2⃣ Hard Stop (TRIGGER_MARKET):`)
logger.log(` Trigger: $${options.hardStopPrice.toFixed(4)}`)
logger.log(` Purpose: Guaranteed exit if soft stop doesn't fill`)
logger.log(` 🔄 Executing hard stop placement...`)
const hardStopSig = await retryWithBackoff(async () =>
await (driftClient as any).placePerpOrder(hardStopParams)
)
logger.log(` ✅ Hard stop placed: ${hardStopSig}`)
signatures.push(hardStopSig)
} catch (hardStopError) {
console.error(`❌ CRITICAL: Failed to place hard stop:`, hardStopError)
throw new Error(`Hard stop placement failed: ${hardStopError instanceof Error ? hardStopError.message : 'Unknown error'}`)
}
logger.log(`🎯 Dual stop system active: Soft @ $${options.softStopPrice.toFixed(2)} | Hard @ $${options.hardStopPrice.toFixed(2)}`)
} else {
// ============== SINGLE STOP SYSTEM ==============
const useStopLimit = options.useStopLimit ?? false
const stopLimitBuffer = options.stopLimitBuffer ?? 0.5
try {
if (useStopLimit) {
// TRIGGER_LIMIT: For liquid markets
const limitPriceMultiplier = options.direction === 'long'
? (1 - stopLimitBuffer / 100)
: (1 + stopLimitBuffer / 100)
const orderParams: any = {
orderType: OrderType.TRIGGER_LIMIT,
marketIndex: marketConfig.driftMarketIndex,
direction: orderDirection,
baseAssetAmount: new BN(slBaseAmount),
triggerPrice: new BN(Math.floor(options.stopLossPrice * 1e6)),
price: new BN(Math.floor(options.stopLossPrice * limitPriceMultiplier * 1e6)),
triggerCondition: options.direction === 'long'
? OrderTriggerCondition.BELOW
: OrderTriggerCondition.ABOVE,
reduceOnly: true,
}
logger.log(`🛡️ Placing SL as TRIGGER_LIMIT (${stopLimitBuffer}% buffer)...`)
logger.log(` Trigger: ${options.direction === 'long' ? 'BELOW' : 'ABOVE'} $${options.stopLossPrice.toFixed(4)}`)
logger.log(` Limit: $${(options.stopLossPrice * limitPriceMultiplier).toFixed(4)}`)
logger.log(` ⚠️ May not fill during fast moves - use for liquid markets only!`)
logger.log(`🔄 Executing SL trigger-limit placement...`)
const sig = await retryWithBackoff(async () =>
await (driftClient as any).placePerpOrder(orderParams)
)
logger.log('✅ SL trigger-limit order placed:', sig)
signatures.push(sig)
} else {
// TRIGGER_MARKET: Default, guaranteed execution
const orderParams: any = {
orderType: OrderType.TRIGGER_MARKET,
marketIndex: marketConfig.driftMarketIndex,
direction: orderDirection,
baseAssetAmount: new BN(slBaseAmount),
triggerPrice: new BN(Math.floor(options.stopLossPrice * 1e6)),
triggerCondition: options.direction === 'long'
? OrderTriggerCondition.BELOW
: OrderTriggerCondition.ABOVE,
reduceOnly: true,
}
logger.log(`🛡️ Placing SL as TRIGGER_MARKET (guaranteed execution - RECOMMENDED)...`)
logger.log(` Trigger: ${options.direction === 'long' ? 'BELOW' : 'ABOVE'} $${options.stopLossPrice.toFixed(4)}`)
logger.log(` ✅ Will execute at market price when triggered (may slip but WILL fill)`)
logger.log(`🔄 Executing SL trigger-market placement...`)
const sig = await retryWithBackoff(async () =>
await (driftClient as any).placePerpOrder(orderParams)
)
logger.log('✅ SL trigger-market order placed:', sig)
signatures.push(sig)
}
} catch (slError) {
console.error(`❌ CRITICAL: Failed to place stop loss:`, slError)
throw new Error(`Stop loss placement failed: ${slError instanceof Error ? slError.message : 'Unknown error'}`)
}
}
} else {
logger.log('⚠️ SL size below market min, skipping on-chain SL')
}
// CRITICAL VALIDATION (Bug #76 fix): Verify all expected orders were placed
if (signatures.length < expectedOrderCount) {
const errorMsg = `MISSING EXIT ORDERS: Expected ${expectedOrderCount}, got ${signatures.length}. Position is UNPROTECTED!`
console.error(`${errorMsg}`)
console.error(` Expected: TP1 + TP2 + ${useDualStops ? 'Soft SL + Hard SL' : 'SL'}`)
console.error(` Got ${signatures.length} signatures:`, signatures)
return {
success: false,
error: errorMsg,
signatures // Return partial signatures for debugging
}
}
logger.log(`✅ All ${expectedOrderCount} exit orders placed successfully`)
return { success: true, signatures }
} catch (error) {
console.error('❌ Failed to place exit orders:', error)
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }
}
}
/**
* Close a position (partially or fully) with a market order
*/
export async function closePosition(
params: ClosePositionParams
): Promise<ClosePositionResult> {
try {
logger.log('📊 Closing position:', params)
const driftService = getDriftService()
const marketConfig = getMarketConfig(params.symbol)
const driftClient = driftService.getClient()
// Get current position
const position = await driftService.getPosition(marketConfig.driftMarketIndex)
if (!position || position.side === 'none') {
throw new Error(`No active position for ${params.symbol}`)
}
console.log(`🔍 CLOSE POSITION DEBUG:`)
console.log(` params.percentToClose: ${params.percentToClose}`)
console.log(` position.size: ${position.size}`)
console.log(` marketConfig.minOrderSize: ${marketConfig.minOrderSize}`)
// Calculate size to close
let sizeToClose = position.size * (params.percentToClose / 100)
console.log(` Calculated sizeToClose: ${sizeToClose}`)
console.log(` Is below minimum? ${sizeToClose < marketConfig.minOrderSize}`)
// CRITICAL FIX: If calculated size is below minimum, close 100% instead
// This prevents "runner" positions from being too small to close
if (sizeToClose < marketConfig.minOrderSize) {
console.log(`⚠️ OVERRIDE: Calculated close size ${sizeToClose.toFixed(4)} is below minimum ${marketConfig.minOrderSize}`)
console.log(`⚠️ OVERRIDE: Forcing 100% close to avoid Drift rejection`)
sizeToClose = position.size // Close entire position
console.log(`⚠️ OVERRIDE: New sizeToClose = ${sizeToClose}`)
}
logger.log(`📝 Close order details:`)
logger.log(` Current position: ${position.size.toFixed(4)} ${position.side}`)
logger.log(` Closing: ${params.percentToClose}% (${sizeToClose.toFixed(4)})`)
logger.log(` Entry price: $${position.entryPrice.toFixed(4)}`)
logger.log(` Unrealized P&L: $${position.unrealizedPnL.toFixed(2)}`)
// Get current oracle price
const oraclePrice = await driftService.getOraclePrice(marketConfig.driftMarketIndex)
logger.log(` Current price: $${oraclePrice.toFixed(4)}`)
// Check DRY_RUN mode
const isDryRun = process.env.DRY_RUN === 'true'
if (isDryRun) {
logger.log('🧪 DRY RUN MODE: Simulating close order (not executing on blockchain)')
// Calculate realized P&L with leverage (default 10x in dry run)
const profitPercent = ((oraclePrice - position.entryPrice) / position.entryPrice) * 100 * (position.side === 'long' ? 1 : -1)
const closedNotional = sizeToClose * oraclePrice
const realizedPnL = (closedNotional * profitPercent) / 100
const accountPnLPercent = profitPercent * 10 // display using default leverage
const mockTxSig = `DRY_RUN_CLOSE_${Date.now()}_${Math.random().toString(36).substring(7)}`
logger.log(`💰 Simulated close:`)
logger.log(` Close price: $${oraclePrice.toFixed(4)}`)
logger.log(` Profit %: ${profitPercent.toFixed(3)}% → Account P&L (10x): ${accountPnLPercent.toFixed(2)}%`)
logger.log(` Realized P&L: $${realizedPnL.toFixed(2)}`)
return {
success: true,
transactionSignature: mockTxSig,
closePrice: oraclePrice,
closedSize: sizeToClose,
realizedPnL,
}
}
// Prepare close order (opposite direction) - use simple structure like v3
const orderParams = {
orderType: OrderType.MARKET,
marketIndex: marketConfig.driftMarketIndex,
direction: position.side === 'long'
? PositionDirection.SHORT
: PositionDirection.LONG,
baseAssetAmount: new BN(Math.floor(sizeToClose * 1e9)), // 9 decimals
reduceOnly: true, // Important: only close existing position
}
// Place market close order using simple placePerpOrder (like v3)
// CRITICAL: Wrap in retry logic for rate limit protection
logger.log('🚀 Placing REAL market close order with retry protection...')
const txSig = await retryWithBackoff(async () => {
return await driftClient.placePerpOrder(orderParams)
}, 3, 8000) // 8s base delay, 3 max retries
logger.log(`✅ Close order placed! Transaction: ${txSig}`)
// CRITICAL: Confirm transaction on-chain to prevent phantom closes
// BUT: Use timeout to prevent API hangs during network congestion
logger.log('⏳ Confirming transaction on-chain (30s timeout)...')
const connection = driftService.getTradeConnection() // Use Alchemy for trade operations
try {
const confirmationPromise = connection.confirmTransaction(txSig, 'confirmed')
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Transaction confirmation timeout')), 30000)
)
const confirmation = await Promise.race([confirmationPromise, timeoutPromise]) as any
if (confirmation.value?.err) {
console.error('❌ Transaction failed on-chain:', confirmation.value.err)
throw new Error(`Transaction failed: ${JSON.stringify(confirmation.value.err)}`)
}
logger.log('✅ Transaction confirmed on-chain')
} catch (timeoutError: any) {
if (timeoutError.message === 'Transaction confirmation timeout') {
console.warn('⚠️ Transaction confirmation timed out after 30s')
console.warn(' Order may still execute - check Drift UI')
console.warn(` Transaction signature: ${txSig}`)
// Continue anyway - order was submitted and will likely execute
} else {
throw timeoutError
}
}
// Calculate realized P&L with leverage
// CRITICAL: P&L must account for leverage and be calculated on USD notional, not base asset size
const profitPercent = ((oraclePrice - position.entryPrice) / position.entryPrice) * 100 * (position.side === 'long' ? 1 : -1)
// Get leverage from user account (defaults to 10x if not found)
let leverage = 10
try {
const userAccount = driftClient.getUserAccount()
if (userAccount && userAccount.maxMarginRatio) {
// maxMarginRatio is in 1e4 scale, leverage = 1 / (margin / 10000)
leverage = 10000 / Number(userAccount.maxMarginRatio)
}
} catch (err) {
logger.log('⚠️ Could not determine leverage from account, using 10x default')
}
// Calculate closed notional value (USD)
const closedNotional = sizeToClose * oraclePrice
const realizedPnL = (closedNotional * profitPercent) / 100
const accountPnLPercent = profitPercent * leverage
logger.log(`💰 Close details:`)
logger.log(` Close price: $${oraclePrice.toFixed(4)}`)
logger.log(` Profit %: ${profitPercent.toFixed(3)}% | Account P&L (${leverage}x): ${accountPnLPercent.toFixed(2)}%`)
logger.log(` Closed notional: $${closedNotional.toFixed(2)}`)
logger.log(` Realized P&L: $${realizedPnL.toFixed(2)}`)
// If closing 100%, verify position actually closed and cancel remaining orders
if (params.percentToClose === 100) {
logger.log('🗑️ Position fully closed, cancelling remaining orders...')
const cancelResult = await cancelAllOrders(params.symbol)
if (cancelResult.success && cancelResult.cancelledCount! > 0) {
logger.log(`✅ Cancelled ${cancelResult.cancelledCount} orders`)
}
// CRITICAL: Verify position actually closed on Drift (Nov 16, 2025)
// Transaction confirmed ≠ Drift state updated immediately
// Wait 5 seconds for Drift internal state to propagate
logger.log('⏳ Waiting 5s for Drift state to propagate...')
await new Promise(resolve => setTimeout(resolve, 5000))
try {
const verifyPosition = await driftService.getPosition(marketConfig.driftMarketIndex)
if (verifyPosition && Math.abs(verifyPosition.size) >= 0.01) {
console.error(`🔴 CRITICAL: Close transaction confirmed BUT position still exists on Drift!`)
console.error(` Transaction: ${txSig}`)
console.error(` Drift size: ${verifyPosition.size}`)
console.error(` This indicates Drift state propagation delay or partial fill`)
console.error(` Position Manager will continue monitoring until Drift confirms closure`)
// Return success but flag that monitoring should continue
return {
success: true,
transactionSignature: txSig,
closePrice: oraclePrice,
closedSize: sizeToClose,
realizedPnL,
needsVerification: true, // Flag for Position Manager
}
} else {
logger.log('✅ Position verified closed on Drift')
}
} catch (verifyError) {
console.warn('⚠️ Could not verify position closure:', verifyError)
// Continue anyway - transaction was confirmed
}
}
return {
success: true,
transactionSignature: txSig,
closePrice: oraclePrice,
closedSize: sizeToClose,
realizedPnL,
}
} catch (error) {
console.error('❌ Failed to close position:', error)
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}
/**
* Cancel all open orders for a specific market
*/
/**
* Retry a function with exponential backoff for rate limit errors
*
* Helius RPC limits (free tier):
* - 100 requests/second burst
* - 10 requests/second sustained
*
* Strategy: Longer delays to avoid overwhelming RPC during rate limit situations
*/
async function retryWithBackoff<T>(
fn: () => Promise<T>,
maxRetries: number = 3,
baseDelay: number = 8000 // Increased from 5s to 8s: 8s → 16s → 32s progression for better RPC recovery
): Promise<T> {
const startTime = Date.now()
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const result = await fn()
// Log successful execution time for rate limit monitoring
if (attempt > 0) {
const totalTime = Date.now() - startTime
logger.log(`✅ Retry successful after ${totalTime}ms (${attempt} retries)`)
// Log to database for analytics
try {
const { logSystemEvent } = await import('../database/trades')
await logSystemEvent('rate_limit_recovered', 'Drift RPC rate limit recovered after retries', {
retriesNeeded: attempt,
totalTimeMs: totalTime,
recoveredAt: new Date().toISOString(),
})
} catch (dbError) {
console.error('Failed to log rate limit recovery:', dbError)
}
}
return result
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
const isRateLimit = errorMessage.includes('429') || errorMessage.includes('rate limit')
if (!isRateLimit || attempt === maxRetries) {
// Log final failure with full context
if (isRateLimit && attempt === maxRetries) {
const totalTime = Date.now() - startTime
console.error(`❌ RATE LIMIT EXHAUSTED: Failed after ${maxRetries} retries and ${totalTime}ms`)
console.error(` Error: ${errorMessage}`)
// Log to database for analytics
try {
const { logSystemEvent } = await import('../database/trades')
await logSystemEvent('rate_limit_exhausted', 'Drift RPC rate limit exceeded max retries', {
maxRetries,
totalTimeMs: totalTime,
errorMessage: errorMessage.substring(0, 500),
failedAt: new Date().toISOString(),
})
} catch (dbError) {
console.error('Failed to log rate limit exhaustion:', dbError)
}
}
throw error
}
const delay = baseDelay * Math.pow(2, attempt)
logger.log(`⏳ Rate limited (429), retrying in ${delay / 1000}s... (attempt ${attempt + 1}/${maxRetries})`)
logger.log(` Error context: ${errorMessage.substring(0, 100)}`)
// Log rate limit hit to database
try {
const { logSystemEvent } = await import('../database/trades')
await logSystemEvent('rate_limit_hit', 'Drift RPC rate limit encountered', {
attempt: attempt + 1,
maxRetries,
delayMs: delay,
errorSnippet: errorMessage.substring(0, 200),
hitAt: new Date().toISOString(),
})
} catch (dbError) {
console.error('Failed to log rate limit hit:', dbError)
}
await new Promise(resolve => setTimeout(resolve, delay))
}
}
throw new Error('Max retries reached')
}
export async function cancelAllOrders(
symbol: string
): Promise<{ success: boolean; cancelledCount?: number; error?: string }> {
try {
logger.log(`🗑️ Cancelling all orders for ${symbol}...`)
// Ensure Drift service is initialized
let driftService = getDriftService()
if (!driftService) {
logger.log('⚠️ Drift service not initialized, initializing now...')
driftService = await initializeDriftService()
}
const driftClient = driftService.getClient()
const marketConfig = getMarketConfig(symbol)
const isDryRun = process.env.DRY_RUN === 'true'
if (isDryRun) {
logger.log('🧪 DRY RUN: Simulating order cancellation')
return { success: true, cancelledCount: 0 }
}
// Get user account to check for orders
const userAccount = driftClient.getUserAccount()
if (!userAccount) {
throw new Error('User account not found')
}
// Filter orders for this market (check for TRULY active orders)
// CRITICAL: Empty slots have orderId=0 OR baseAssetAmount=0
// Only count orders that actually exist and are open
const ordersToCancel = userAccount.orders.filter(
(order: any) => {
// Skip if not our market
if (order.marketIndex !== marketConfig.driftMarketIndex) return false
// Skip if orderId is 0 (empty slot)
if (!order.orderId || order.orderId === 0) return false
// Skip if baseAssetAmount is 0 (no actual order size)
if (!order.baseAssetAmount || order.baseAssetAmount.eq(new BN(0))) return false
// This is a real active order
return true
}
)
if (ordersToCancel.length === 0) {
logger.log('✅ No open orders to cancel')
return { success: true, cancelledCount: 0 }
}
logger.log(`📋 Found ${ordersToCancel.length} open orders to cancel (including trigger orders)`)
logger.log(` (checked ${userAccount.orders.length} total order slots)`)
// Cancel all orders with retry logic for rate limits
const txSig = await retryWithBackoff(async () => {
return await driftClient.cancelOrders(
undefined, // Cancel by market type
marketConfig.driftMarketIndex,
undefined // No specific direction filter
)
})
logger.log(`✅ Orders cancelled! Transaction: ${txSig}`)
return {
success: true,
cancelledCount: ordersToCancel.length,
}
} catch (error) {
console.error('❌ Failed to cancel orders:', error)
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}
/**
* Close entire position for a market
*/
export async function closeEntirePosition(
symbol: string,
slippageTolerance: number = 1.0
): Promise<ClosePositionResult> {
return closePosition({
symbol,
percentToClose: 100,
slippageTolerance,
})
}
/**
* Emergency close all positions
*/
export async function emergencyCloseAll(): Promise<{
success: boolean
results: Array<{
symbol: string
result: ClosePositionResult
}>
}> {
logger.log('🚨 EMERGENCY: Closing all positions')
try {
const driftService = getDriftService()
const positions = await driftService.getAllPositions()
if (positions.length === 0) {
logger.log('✅ No positions to close')
return { success: true, results: [] }
}
const results = []
for (const position of positions) {
logger.log(`🔴 Emergency closing ${position.symbol}...`)
const result = await closeEntirePosition(position.symbol, 2.0) // Allow 2% slippage
results.push({
symbol: position.symbol,
result,
})
}
logger.log('✅ Emergency close complete')
return {
success: true,
results,
}
} catch (error) {
console.error('❌ Emergency close failed:', error)
return {
success: false,
results: [],
}
}
}