- Detect position size mismatches (>50% variance) after opening - Save phantom trades to database with expectedSizeUSD, actualSizeUSD, phantomReason - Return error from execute endpoint to prevent Position Manager tracking - Add comprehensive documentation of phantom trade issue and solution - Enable data collection for pattern analysis and future optimization Fixes oracle price lag issue during volatile markets where transactions confirm but positions don't actually open at expected size.
755 lines
28 KiB
TypeScript
755 lines
28 KiB
TypeScript
/**
|
||
* Drift Order Execution
|
||
*
|
||
* Handles opening and closing positions with market orders
|
||
*/
|
||
|
||
import { getDriftService, initializeDriftService } from './client'
|
||
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
|
||
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 {
|
||
console.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)
|
||
console.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
|
||
|
||
console.log(`📝 Order details:`)
|
||
console.log(` Size: ${baseAssetSize.toFixed(4)} ${params.symbol.split('-')[0]}`)
|
||
console.log(` Notional: $${params.sizeUSD.toFixed(2)}`)
|
||
console.log(` Oracle price: $${oraclePrice.toFixed(4)}`)
|
||
console.log(` Worst price (${params.slippageTolerance}% slippage): $${worstPrice.toFixed(4)}`)
|
||
|
||
// Check DRY_RUN mode
|
||
const isDryRun = process.env.DRY_RUN === 'true'
|
||
|
||
if (isDryRun) {
|
||
console.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)
|
||
console.log('🚀 Placing REAL market order...')
|
||
const txSig = await driftClient.placePerpOrder(orderParams)
|
||
|
||
console.log(`📝 Transaction submitted: ${txSig}`)
|
||
|
||
// CRITICAL: Confirm transaction actually executed on-chain
|
||
console.log('⏳ Confirming transaction on-chain...')
|
||
const connection = driftService.getConnection()
|
||
|
||
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)}`,
|
||
}
|
||
}
|
||
|
||
console.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
|
||
console.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
|
||
|
||
console.log(`💰 Fill details:`)
|
||
console.log(` Fill price: $${fillPrice.toFixed(4)}`)
|
||
console.log(` Slippage: ${slippage.toFixed(3)}%`)
|
||
console.log(` Expected size: $${expectedSizeUSD.toFixed(2)}`)
|
||
console.log(` Actual size: $${actualSizeUSD.toFixed(2)}`)
|
||
console.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)
|
||
console.log(`⚠️ Position not immediately visible (may be DRY_RUN mode)`)
|
||
console.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 {
|
||
console.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) {
|
||
console.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: Use ENTRY price to calculate position size, not TP price!
|
||
// This ensures we close the correct percentage of the actual position
|
||
const usdToBase = (usd: number) => {
|
||
const base = usd / options.entryPrice // Use entry price for size calculation
|
||
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 tp2USD = (remainingAfterTP1 * options.tp2SizePercent) / 100
|
||
|
||
console.log(`📊 Exit order sizes:`)
|
||
console.log(` TP1: ${options.tp1SizePercent}% of $${options.positionSizeUSD.toFixed(2)} = $${tp1USD.toFixed(2)}`)
|
||
console.log(` Remaining after TP1: $${remainingAfterTP1.toFixed(2)}`)
|
||
console.log(` TP2: ${options.tp2SizePercent}% of remaining = $${tp2USD.toFixed(2)}`)
|
||
console.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)
|
||
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,
|
||
}
|
||
|
||
console.log('🚧 Placing TP1 limit order (reduce-only)...')
|
||
const sig = await (driftClient as any).placePerpOrder(orderParams)
|
||
console.log('✅ TP1 order placed:', sig)
|
||
signatures.push(sig)
|
||
} else {
|
||
console.log('⚠️ TP1 size below market min, skipping on-chain TP1')
|
||
}
|
||
}
|
||
|
||
// Place TP2 LIMIT reduce-only
|
||
if (tp2USD > 0) {
|
||
const baseAmount = usdToBase(tp2USD)
|
||
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,
|
||
}
|
||
|
||
console.log('🚧 Placing TP2 limit order (reduce-only)...')
|
||
const sig = await (driftClient as any).placePerpOrder(orderParams)
|
||
console.log('✅ TP2 order placed:', sig)
|
||
signatures.push(sig)
|
||
} else {
|
||
console.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)
|
||
|
||
if (slBaseAmount >= Math.floor(marketConfig.minOrderSize * 1e9)) {
|
||
const useDualStops = options.useDualStops ?? false
|
||
|
||
if (useDualStops && options.softStopPrice && options.hardStopPrice) {
|
||
// ============== DUAL STOP SYSTEM ==============
|
||
console.log('🛡️🛡️ Placing DUAL STOP SYSTEM...')
|
||
|
||
// 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,
|
||
}
|
||
|
||
console.log(` 1️⃣ Soft Stop (TRIGGER_LIMIT):`)
|
||
console.log(` Trigger: $${options.softStopPrice.toFixed(4)}`)
|
||
console.log(` Limit: $${(options.softStopPrice * softStopMultiplier).toFixed(4)}`)
|
||
console.log(` Purpose: Avoid false breakouts/wicks`)
|
||
|
||
const softStopSig = await (driftClient as any).placePerpOrder(softStopParams)
|
||
console.log(` ✅ Soft stop placed: ${softStopSig}`)
|
||
signatures.push(softStopSig)
|
||
|
||
// 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,
|
||
}
|
||
|
||
console.log(` 2️⃣ Hard Stop (TRIGGER_MARKET):`)
|
||
console.log(` Trigger: $${options.hardStopPrice.toFixed(4)}`)
|
||
console.log(` Purpose: Guaranteed exit if soft stop doesn't fill`)
|
||
|
||
const hardStopSig = await (driftClient as any).placePerpOrder(hardStopParams)
|
||
console.log(` ✅ Hard stop placed: ${hardStopSig}`)
|
||
signatures.push(hardStopSig)
|
||
|
||
console.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
|
||
|
||
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,
|
||
}
|
||
|
||
console.log(`🛡️ Placing SL as TRIGGER_LIMIT (${stopLimitBuffer}% buffer)...`)
|
||
console.log(` Trigger: ${options.direction === 'long' ? 'BELOW' : 'ABOVE'} $${options.stopLossPrice.toFixed(4)}`)
|
||
console.log(` Limit: $${(options.stopLossPrice * limitPriceMultiplier).toFixed(4)}`)
|
||
console.log(` ⚠️ May not fill during fast moves - use for liquid markets only!`)
|
||
|
||
const sig = await (driftClient as any).placePerpOrder(orderParams)
|
||
console.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,
|
||
}
|
||
|
||
console.log(`🛡️ Placing SL as TRIGGER_MARKET (guaranteed execution - RECOMMENDED)...`)
|
||
console.log(` Trigger: ${options.direction === 'long' ? 'BELOW' : 'ABOVE'} $${options.stopLossPrice.toFixed(4)}`)
|
||
console.log(` ✅ Will execute at market price when triggered (may slip but WILL fill)`)
|
||
|
||
const sig = await (driftClient as any).placePerpOrder(orderParams)
|
||
console.log('✅ SL trigger-market order placed:', sig)
|
||
signatures.push(sig)
|
||
}
|
||
}
|
||
} else {
|
||
console.log('⚠️ SL size below market min, skipping on-chain SL')
|
||
}
|
||
|
||
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 {
|
||
console.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}`)
|
||
}
|
||
|
||
// Calculate size to close
|
||
let sizeToClose = position.size * (params.percentToClose / 100)
|
||
|
||
// 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(`⚠️ Calculated close size ${sizeToClose.toFixed(4)} is below minimum ${marketConfig.minOrderSize}`)
|
||
console.log(` Forcing 100% close to avoid Drift rejection`)
|
||
sizeToClose = position.size // Close entire position
|
||
}
|
||
|
||
console.log(`📝 Close order details:`)
|
||
console.log(` Current position: ${position.size.toFixed(4)} ${position.side}`)
|
||
console.log(` Closing: ${params.percentToClose}% (${sizeToClose.toFixed(4)})`)
|
||
console.log(` Entry price: $${position.entryPrice.toFixed(4)}`)
|
||
console.log(` Unrealized P&L: $${position.unrealizedPnL.toFixed(2)}`)
|
||
|
||
// Get current oracle price
|
||
const oraclePrice = await driftService.getOraclePrice(marketConfig.driftMarketIndex)
|
||
console.log(` Current price: $${oraclePrice.toFixed(4)}`)
|
||
|
||
// Check DRY_RUN mode
|
||
const isDryRun = process.env.DRY_RUN === 'true'
|
||
|
||
if (isDryRun) {
|
||
console.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 leverage = 10 // Use 10x for dry run
|
||
const accountPnLPercent = profitPercent * leverage
|
||
const closedNotional = sizeToClose * oraclePrice
|
||
const realizedPnL = (closedNotional * accountPnLPercent) / 100
|
||
|
||
const mockTxSig = `DRY_RUN_CLOSE_${Date.now()}_${Math.random().toString(36).substring(7)}`
|
||
|
||
console.log(`💰 Simulated close:`)
|
||
console.log(` Close price: $${oraclePrice.toFixed(4)}`)
|
||
console.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)
|
||
console.log('🚀 Placing REAL market close order...')
|
||
const txSig = await driftClient.placePerpOrder(orderParams)
|
||
|
||
console.log(`✅ Close order placed! Transaction: ${txSig}`)
|
||
|
||
// CRITICAL: Confirm transaction on-chain to prevent phantom closes
|
||
console.log('⏳ Confirming transaction on-chain...')
|
||
const connection = driftService.getConnection()
|
||
const confirmation = await connection.confirmTransaction(txSig, 'confirmed')
|
||
|
||
if (confirmation.value.err) {
|
||
console.error('❌ Transaction failed on-chain:', confirmation.value.err)
|
||
throw new Error(`Transaction failed: ${JSON.stringify(confirmation.value.err)}`)
|
||
}
|
||
|
||
console.log('✅ Transaction confirmed on-chain')
|
||
|
||
// 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) {
|
||
console.log('⚠️ Could not determine leverage from account, using 10x default')
|
||
}
|
||
|
||
const accountPnLPercent = profitPercent * leverage
|
||
|
||
// Calculate closed notional value (USD)
|
||
const closedNotional = sizeToClose * oraclePrice
|
||
const realizedPnL = (closedNotional * accountPnLPercent) / 100
|
||
|
||
console.log(`💰 Close details:`)
|
||
console.log(` Close price: $${oraclePrice.toFixed(4)}`)
|
||
console.log(` Profit %: ${profitPercent.toFixed(3)}% | Account P&L (${leverage}x): ${accountPnLPercent.toFixed(2)}%`)
|
||
console.log(` Closed notional: $${closedNotional.toFixed(2)}`)
|
||
console.log(` Realized P&L: $${realizedPnL.toFixed(2)}`)
|
||
|
||
// If closing 100%, cancel all remaining orders for this market
|
||
if (params.percentToClose === 100) {
|
||
console.log('🗑️ Position fully closed, cancelling remaining orders...')
|
||
const cancelResult = await cancelAllOrders(params.symbol)
|
||
if (cancelResult.success && cancelResult.cancelledCount! > 0) {
|
||
console.log(`✅ Cancelled ${cancelResult.cancelledCount} orders`)
|
||
}
|
||
}
|
||
|
||
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
|
||
*/
|
||
export async function cancelAllOrders(
|
||
symbol: string
|
||
): Promise<{ success: boolean; cancelledCount?: number; error?: string }> {
|
||
try {
|
||
console.log(`🗑️ Cancelling all orders for ${symbol}...`)
|
||
|
||
// Ensure Drift service is initialized
|
||
let driftService = getDriftService()
|
||
if (!driftService) {
|
||
console.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) {
|
||
console.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 active orders, not just status)
|
||
// Note: Trigger orders may have different status values, so we check for non-zero orderId
|
||
const ordersToCancel = userAccount.orders.filter(
|
||
(order: any) =>
|
||
order.marketIndex === marketConfig.driftMarketIndex &&
|
||
order.orderId > 0 // Active orders have orderId > 0
|
||
)
|
||
|
||
if (ordersToCancel.length === 0) {
|
||
console.log('✅ No open orders to cancel')
|
||
return { success: true, cancelledCount: 0 }
|
||
}
|
||
|
||
console.log(`📋 Found ${ordersToCancel.length} open orders to cancel (including trigger orders)`)
|
||
|
||
// Cancel all orders for this market (cancels all types: LIMIT, TRIGGER_MARKET, TRIGGER_LIMIT)
|
||
const txSig = await driftClient.cancelOrders(
|
||
undefined, // Cancel by market type
|
||
marketConfig.driftMarketIndex,
|
||
undefined // No specific direction filter
|
||
)
|
||
|
||
console.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
|
||
}>
|
||
}> {
|
||
console.log('🚨 EMERGENCY: Closing all positions')
|
||
|
||
try {
|
||
const driftService = getDriftService()
|
||
const positions = await driftService.getAllPositions()
|
||
|
||
if (positions.length === 0) {
|
||
console.log('✅ No positions to close')
|
||
return { success: true, results: [] }
|
||
}
|
||
|
||
const results = []
|
||
|
||
for (const position of positions) {
|
||
console.log(`🔴 Emergency closing ${position.symbol}...`)
|
||
const result = await closeEntirePosition(position.symbol, 2.0) // Allow 2% slippage
|
||
results.push({
|
||
symbol: position.symbol,
|
||
result,
|
||
})
|
||
}
|
||
|
||
console.log('✅ Emergency close complete')
|
||
|
||
return {
|
||
success: true,
|
||
results,
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('❌ Emergency close failed:', error)
|
||
return {
|
||
success: false,
|
||
results: [],
|
||
}
|
||
}
|
||
}
|