- Changed from LIMIT to TRIGGER_LIMIT for proper stop loss display - SL now shows correctly on Drift UI with trigger line - Added 0.5% buffer on limit price below trigger for safety - Widened default SL to -2% for safer testing - Tested and verified all 3 exit orders (TP1, TP2, SL) working
467 lines
15 KiB
TypeScript
467 lines
15 KiB
TypeScript
/**
|
|
* Drift Order Execution
|
|
*
|
|
* Handles opening and closing positions with market orders
|
|
*/
|
|
|
|
import { getDriftService } 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
/**
|
|
* 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(`✅ Order placed! Transaction: ${txSig}`)
|
|
|
|
// 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 (optional - may not be immediate in DRY_RUN)
|
|
const position = await driftService.getPosition(marketConfig.driftMarketIndex)
|
|
|
|
if (position && position.side !== 'none') {
|
|
const fillPrice = position.entryPrice
|
|
const slippage = Math.abs((fillPrice - oraclePrice) / oraclePrice) * 100
|
|
|
|
console.log(`💰 Fill details:`)
|
|
console.log(` Fill price: $${fillPrice.toFixed(4)}`)
|
|
console.log(` Slippage: ${slippage.toFixed(3)}%`)
|
|
|
|
return {
|
|
success: true,
|
|
transactionSignature: txSig,
|
|
fillPrice,
|
|
fillSize: baseAssetSize,
|
|
slippage,
|
|
}
|
|
} 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 LIMIT orders) so TP/SL show up in Drift UI.
|
|
* This places reduce-only LIMIT orders for TP1, TP2 and a stop-loss LIMIT order.
|
|
* NOTE: For a safer, more aggressive stop you'd want a trigger-market order; here
|
|
* we use reduce-only LIMIT orders to ensure they are visible in the UI and low-risk.
|
|
*/
|
|
export async function placeExitOrders(options: {
|
|
symbol: string
|
|
positionSizeUSD: number
|
|
tp1Price: number
|
|
tp2Price: number
|
|
stopLossPrice: number
|
|
tp1SizePercent: number // percent of position to close at TP1 (0-100)
|
|
tp2SizePercent: number // percent of position to close at TP2 (0-100)
|
|
direction: 'long' | 'short'
|
|
}): 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
|
|
const usdToBase = (usd: number, price: number) => {
|
|
const base = usd / price
|
|
return Math.floor(base * 1e9) // 9 decimals expected by SDK
|
|
}
|
|
|
|
// Calculate sizes in USD for each TP
|
|
const tp1USD = (options.positionSizeUSD * options.tp1SizePercent) / 100
|
|
const tp2USD = (options.positionSizeUSD * options.tp2SizePercent) / 100
|
|
|
|
// 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)
|
|
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, options.tp2Price)
|
|
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 as TRIGGER_LIMIT order (proper stop loss that shows on Drift UI)
|
|
const slUSD = options.positionSizeUSD // place full-size SL
|
|
const slBaseAmount = usdToBase(slUSD, options.stopLossPrice)
|
|
if (slBaseAmount >= Math.floor(marketConfig.minOrderSize * 1e9)) {
|
|
const orderParams: any = {
|
|
orderType: OrderType.TRIGGER_LIMIT, // Use TRIGGER_LIMIT (not LIMIT with trigger)
|
|
marketIndex: marketConfig.driftMarketIndex,
|
|
direction: orderDirection,
|
|
baseAssetAmount: new BN(slBaseAmount),
|
|
triggerPrice: new BN(Math.floor(options.stopLossPrice * 1e6)), // trigger price
|
|
price: new BN(Math.floor(options.stopLossPrice * 0.995 * 1e6)), // limit price slightly lower (0.5% buffer)
|
|
triggerCondition: options.direction === 'long'
|
|
? OrderTriggerCondition.BELOW // Long: trigger when price drops below
|
|
: OrderTriggerCondition.ABOVE, // Short: trigger when price rises above
|
|
reduceOnly: true,
|
|
}
|
|
|
|
console.log('🚧 Placing SL trigger-limit order (reduce-only)...')
|
|
console.log(` Trigger: ${options.direction === 'long' ? 'BELOW' : 'ABOVE'} $${options.stopLossPrice.toFixed(4)}`)
|
|
console.log(` Limit price: $${(options.stopLossPrice * 0.995).toFixed(4)}`)
|
|
const sig = await (driftClient as any).placePerpOrder(orderParams)
|
|
console.log('✅ SL trigger-limit 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
|
|
const sizeToClose = position.size * (params.percentToClose / 100)
|
|
|
|
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
|
|
const pnlPerUnit = oraclePrice - position.entryPrice
|
|
const realizedPnL = pnlPerUnit * sizeToClose * (position.side === 'long' ? 1 : -1)
|
|
|
|
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}`)
|
|
|
|
// Wait for confirmation (transaction is likely already confirmed by placeAndTakePerpOrder)
|
|
console.log('⏳ Waiting for transaction confirmation...')
|
|
console.log('✅ Transaction confirmed')
|
|
|
|
// Calculate realized P&L
|
|
const pnlPerUnit = oraclePrice - position.entryPrice
|
|
const realizedPnL = pnlPerUnit * sizeToClose * (position.side === 'long' ? 1 : -1)
|
|
|
|
console.log(`💰 Close details:`)
|
|
console.log(` Close price: $${oraclePrice.toFixed(4)}`)
|
|
console.log(` Realized P&L: $${realizedPnL.toFixed(2)}`)
|
|
|
|
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',
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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: [],
|
|
}
|
|
}
|
|
}
|