- Part 1: Position Manager fractional remnant detection after close attempts * Check if position < 1.5× minOrderSize after close transaction * Log to persistent logger with FRACTIONAL_REMNANT_DETECTED * Track closeAttempts, limit to 3 maximum * Mark exitReason='FRACTIONAL_REMNANT' in database * Remove from monitoring after 3 failed attempts - Part 2: Pre-close validation in closePosition() * Check if position viable before attempting close * Reject positions < 1.5× minOrderSize with specific error * Prevent wasted transaction attempts on too-small positions * Return POSITION_TOO_SMALL_TO_CLOSE error with manual instructions - Part 3: Health monitor detection for fractional remnants * Query Trade table for FRACTIONAL_REMNANT exits in last 24h * Alert operators with position details and manual cleanup instructions * Provide trade IDs, symbols, and Drift UI link - Database schema: Added closeAttempts Int? field to Track attempts Root cause: Drift protocol exchange constraints can leave fractional positions Evidence: 3 close transactions confirmed but 0.15 SOL remnant persisted Financial impact: ,000+ risk from unprotected fractional positions Status: Fix implemented, awaiting deployment verification See: docs/COMMON_PITFALLS.md Bug #89 for complete incident details
1018 lines
40 KiB
TypeScript
1018 lines
40 KiB
TypeScript
/**
|
||
* 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
|
||
expectedOrders?: number
|
||
placedOrders?: number
|
||
}
|
||
|
||
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[] = []
|
||
let expectedOrders = 0
|
||
|
||
// 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
|
||
// Allow explicit 0% to mean "no TP2 order" without forcing it back to 100%
|
||
const normalizedTp2Percent = requestedTp2Percent === undefined
|
||
? 100
|
||
: Math.max(0, requestedTp2Percent)
|
||
const tp2USD = (remainingAfterTP1 * normalizedTp2Percent) / 100
|
||
|
||
if (normalizedTp2Percent === 0) {
|
||
logger.log('ℹ️ TP2 on-chain order skipped (trigger-only; software handles trailing)')
|
||
}
|
||
|
||
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)) {
|
||
expectedOrders += 1
|
||
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)) {
|
||
expectedOrders += 1
|
||
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
|
||
|
||
const useDualStops = options.useDualStops ?? false
|
||
logger.log(`📊 Expected exit orders: TP1/TP2 that meet min size + ${useDualStops ? 'soft+hard SL' : 'single SL'}`)
|
||
|
||
const minOrderLamports = Math.floor(marketConfig.minOrderSize * 1e9)
|
||
if (slBaseAmount < minOrderLamports) {
|
||
const errorMsg = `Stop loss size below market minimum (base ${slBaseAmount} < min ${minOrderLamports})`
|
||
console.error(`❌ ${errorMsg}`)
|
||
return { success: false, error: errorMsg, signatures, expectedOrders, placedOrders: signatures.length }
|
||
}
|
||
|
||
if (slBaseAmount >= minOrderLamports) {
|
||
|
||
if (useDualStops && options.softStopPrice && options.hardStopPrice) {
|
||
// ============== DUAL STOP SYSTEM ==============
|
||
logger.log('🛡️🛡️ Placing DUAL STOP SYSTEM...')
|
||
expectedOrders += 2
|
||
|
||
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
|
||
expectedOrders += 1
|
||
|
||
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'}`)
|
||
}
|
||
}
|
||
}
|
||
|
||
const placedOrders = signatures.length
|
||
if (placedOrders < expectedOrders) {
|
||
const errorMsg = `MISSING EXIT ORDERS: Expected ${expectedOrders}, got ${placedOrders}. Position is UNPROTECTED!`
|
||
console.error(`❌ ${errorMsg}`)
|
||
console.error(` Expected: TP1/TP2 that met min + ${useDualStops ? 'Soft SL + Hard SL' : 'SL'}`)
|
||
console.error(` Got ${placedOrders} signatures:`, signatures)
|
||
|
||
return {
|
||
success: false,
|
||
error: errorMsg,
|
||
signatures, // Return partial signatures for debugging
|
||
expectedOrders,
|
||
placedOrders,
|
||
}
|
||
}
|
||
|
||
logger.log(`✅ All ${expectedOrders} exit orders placed successfully`)
|
||
return { success: true, signatures, expectedOrders, placedOrders }
|
||
} 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 {
|
||
const marketConfig = getMarketConfig(params.symbol)
|
||
const driftService = await getDriftService()
|
||
const driftClient = driftService.getClient()
|
||
const position = await driftService.getPosition(marketConfig.driftMarketIndex)
|
||
|
||
if (!position || position.side === 'none') {
|
||
console.warn(`⚠️ No open position found for ${params.symbol}, skipping close request`)
|
||
return {
|
||
success: false,
|
||
error: 'No open position to close',
|
||
}
|
||
}
|
||
|
||
// BUG #89 FIX PART 2 (Dec 16, 2025): Pre-close validation for fractional positions
|
||
// Before attempting close, check if position is too small to close reliably
|
||
// Drift protocol has exchange-level constraints that prevent closing very small positions
|
||
// If position below minimum viable size, return error with manual resolution instructions
|
||
const oraclePriceForCheck = await driftService.getOraclePrice(marketConfig.driftMarketIndex)
|
||
const positionSizeUSD = Math.abs(position.size) * oraclePriceForCheck
|
||
const minViableSize = marketConfig.minOrderSize * 1.5
|
||
|
||
if (positionSizeUSD < minViableSize && params.percentToClose >= 100) {
|
||
console.log(`🛑 POSITION TOO SMALL TO CLOSE`)
|
||
console.log(` Position: ${Math.abs(position.size)} tokens ($${positionSizeUSD.toFixed(2)})`)
|
||
console.log(` Minimum viable size: $${minViableSize.toFixed(2)}`)
|
||
console.log(` This position is below Drift protocol's minimum viable close size`)
|
||
console.log(` Close transactions may confirm but leave fractional remnants`)
|
||
console.log(` Manual intervention required via Drift UI`)
|
||
|
||
return {
|
||
success: false,
|
||
error: 'POSITION_TOO_SMALL_TO_CLOSE',
|
||
closedSize: Math.abs(position.size),
|
||
} as any
|
||
}
|
||
|
||
logger.log('📊 Closing position:', params)
|
||
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
|
||
let confirmationTimedOut = false
|
||
|
||
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') {
|
||
confirmationTimedOut = true
|
||
console.warn('⚠️ Transaction confirmation timed out after 30s')
|
||
console.warn(' Order may still execute - check Drift UI')
|
||
console.warn(` Transaction signature: ${txSig}`)
|
||
// Continue but flag for Position Manager verification so we do not drop tracking
|
||
} 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,
|
||
needsVerification: confirmationTimedOut, // Keep monitoring if confirmation never arrived
|
||
}
|
||
|
||
} 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: [],
|
||
}
|
||
}
|
||
}
|