critical: Fix exit order token sizing - TP/SL now use exact position size
BUG #92: Exit orders (TP1, TP2, SL) had different token sizes than position - Position: 142.91 SOL but TP1=140.87 SOL, SL=147.03 SOL (WRONG) - Root cause: usdToBase() calculated tokens as USD/price per order - Each exit order price produced different token amounts FIX: Pass actual token count via positionSizeTokens parameter - Added positionSizeTokens to PlaceExitOrdersOptions interface - Added tokensToBase() helper (tokens * 1e9 directly) - All exit sections now use token-based calculation when available Files updated to pass positionSizeTokens: - app/api/trading/execute/route.ts: openResult.fillSize - lib/trading/smart-entry-timer.ts: openResult.fillSize - lib/trading/sync-helper.ts: Math.abs(driftPos.size) - lib/trading/position-manager.ts: Math.abs(position.size) + fetch patterns - lib/startup/init-position-manager.ts: Math.abs(position.size) - lib/health/position-manager-health.ts: Drift position fetch + token size Result: When position = X tokens, ALL exit orders close portions of X tokens - TP1: X * tp1SizePercent / 100 tokens - TP2: remaining * tp2SizePercent / 100 tokens - SL: X tokens (full position) Backward compatible: Falls back to USD calculation if positionSizeTokens not provided
This commit is contained in:
@@ -61,6 +61,7 @@ export interface PlaceExitOrdersResult {
|
||||
export interface PlaceExitOrdersOptions {
|
||||
symbol: string
|
||||
positionSizeUSD: number
|
||||
positionSizeTokens?: number // CRITICAL FIX (Jan 6, 2026): Use actual token count for exit orders
|
||||
entryPrice: number // CRITICAL: Entry price for calculating position size in base assets
|
||||
tp1Price: number
|
||||
tp2Price: number
|
||||
@@ -275,42 +276,74 @@ export async function placeExitOrders(options: PlaceExitOrdersOptions): Promise<
|
||||
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)')
|
||||
// CRITICAL FIX (Jan 6, 2026): Use actual token count for exit orders
|
||||
// BUG: When position = 142.91 SOL, exit orders should close 142.91 SOL (not recalculated from USD)
|
||||
// OLD (WRONG): TP1 = USD/TP1_price = different token amount per order price
|
||||
// NEW (CORRECT): TP1 = positionSizeTokens * tp1Percent = SAME token amount as position
|
||||
|
||||
// Helper to convert tokens to base asset lamports (9 decimals)
|
||||
const tokensToBase = (tokens: number) => {
|
||||
return Math.floor(tokens * 1e9) // 9 decimals expected by SDK
|
||||
}
|
||||
|
||||
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)}`)
|
||||
// Legacy helper for backward compatibility (when positionSizeTokens not provided)
|
||||
const usdToBase = (usd: number, price: number) => {
|
||||
const base = usd / price
|
||||
return Math.floor(base * 1e9)
|
||||
}
|
||||
|
||||
// Calculate sizes in TOKENS for each TP (correct approach)
|
||||
// If positionSizeTokens provided, use it directly; otherwise fall back to USD calculation
|
||||
const hasTokenSize = options.positionSizeTokens !== undefined && options.positionSizeTokens > 0
|
||||
|
||||
let tp1Tokens: number
|
||||
let tp2Tokens: number
|
||||
let slTokens: number
|
||||
|
||||
if (hasTokenSize) {
|
||||
// CORRECT: Use actual position token count
|
||||
tp1Tokens = (options.positionSizeTokens! * options.tp1SizePercent) / 100
|
||||
const remainingAfterTP1 = options.positionSizeTokens! - tp1Tokens
|
||||
const normalizedTp2Percent = options.tp2SizePercent === undefined ? 100 : Math.max(0, options.tp2SizePercent)
|
||||
tp2Tokens = (remainingAfterTP1 * normalizedTp2Percent) / 100
|
||||
slTokens = options.positionSizeTokens! // SL closes full position
|
||||
|
||||
logger.log(`📊 Exit order sizes (TOKEN-BASED - CORRECT):`)
|
||||
logger.log(` Position: ${options.positionSizeTokens!.toFixed(4)} tokens`)
|
||||
logger.log(` TP1: ${options.tp1SizePercent}% = ${tp1Tokens.toFixed(4)} tokens`)
|
||||
logger.log(` Remaining after TP1: ${remainingAfterTP1.toFixed(4)} tokens`)
|
||||
logger.log(` TP2: ${normalizedTp2Percent}% of remaining = ${tp2Tokens.toFixed(4)} tokens`)
|
||||
logger.log(` SL: ${slTokens.toFixed(4)} tokens (full position)`)
|
||||
} else {
|
||||
// LEGACY FALLBACK: Calculate from USD (less accurate but backward compatible)
|
||||
console.warn('⚠️ positionSizeTokens not provided, falling back to USD calculation (less accurate)')
|
||||
const tp1USD = (options.positionSizeUSD * options.tp1SizePercent) / 100
|
||||
const remainingAfterTP1USD = options.positionSizeUSD - tp1USD
|
||||
const normalizedTp2Percent = options.tp2SizePercent === undefined ? 100 : Math.max(0, options.tp2SizePercent)
|
||||
const tp2USD = (remainingAfterTP1USD * normalizedTp2Percent) / 100
|
||||
|
||||
// Calculate tokens from USD using respective prices
|
||||
tp1Tokens = tp1USD / options.tp1Price
|
||||
tp2Tokens = tp2USD / options.tp2Price
|
||||
slTokens = options.positionSizeUSD / options.stopLossPrice
|
||||
|
||||
logger.log(`📊 Exit order sizes (USD-BASED - LEGACY):`)
|
||||
logger.log(` TP1: ${options.tp1SizePercent}% of $${options.positionSizeUSD.toFixed(2)} = $${tp1USD.toFixed(2)} = ${tp1Tokens.toFixed(4)} tokens`)
|
||||
logger.log(` Remaining after TP1: $${remainingAfterTP1USD.toFixed(2)}`)
|
||||
logger.log(` TP2: ${normalizedTp2Percent}% of remaining = $${tp2USD.toFixed(2)} = ${tp2Tokens.toFixed(4)} tokens`)
|
||||
logger.log(` SL: $${options.positionSizeUSD.toFixed(2)} = ${slTokens.toFixed(4)} tokens`)
|
||||
}
|
||||
|
||||
if (options.tp2SizePercent === 0) {
|
||||
logger.log('ℹ️ TP2 on-chain order skipped (trigger-only; software handles trailing)')
|
||||
}
|
||||
|
||||
// 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 (tp1Tokens > 0) {
|
||||
const baseAmount = tokensToBase(tp1Tokens)
|
||||
if (baseAmount >= Math.floor(marketConfig.minOrderSize * 1e9)) {
|
||||
expectedOrders += 1
|
||||
const orderParams: any = {
|
||||
@@ -334,8 +367,10 @@ export async function placeExitOrders(options: PlaceExitOrdersOptions): Promise<
|
||||
}
|
||||
|
||||
// Place TP2 LIMIT reduce-only
|
||||
if (tp2USD > 0) {
|
||||
const baseAmount = usdToBase(tp2USD, options.tp2Price) // Use TP2 price
|
||||
if (tp2Tokens > 0) {
|
||||
const baseAmount = tokensToBase(tp2Tokens)
|
||||
logger.log(`📊 TP2 size calculation: TOKEN-BASED`)
|
||||
logger.log(` TP2 tokens: ${tp2Tokens.toFixed(4)}, baseAmount: ${baseAmount}`)
|
||||
if (baseAmount >= Math.floor(marketConfig.minOrderSize * 1e9)) {
|
||||
expectedOrders += 1
|
||||
const orderParams: any = {
|
||||
@@ -365,7 +400,9 @@ export async function placeExitOrders(options: PlaceExitOrdersOptions): Promise<
|
||||
// 3. Single TRIGGER_MARKET (default, guaranteed execution)
|
||||
|
||||
const slUSD = options.positionSizeUSD
|
||||
const slBaseAmount = usdToBase(slUSD, options.stopLossPrice) // Use SL price
|
||||
const slBaseAmount = hasTokenSize ? tokensToBase(slTokens) : usdToBase(slUSD, options.stopLossPrice)
|
||||
logger.log(`📊 SL size calculation: ${hasTokenSize ? 'TOKEN-BASED - CORRECT' : 'USD-BASED - LEGACY'}`)
|
||||
logger.log(` SL tokens: ${slTokens.toFixed(4)}, slBaseAmount: ${slBaseAmount}`)
|
||||
|
||||
const useDualStops = options.useDualStops ?? false
|
||||
logger.log(`📊 Expected exit orders: TP1/TP2 that meet min size + ${useDualStops ? 'soft+hard SL' : 'single SL'}`)
|
||||
|
||||
@@ -128,9 +128,28 @@ async function ensureExitOrdersForTrade(
|
||||
|
||||
const { placeExitOrders } = await import('../drift/orders')
|
||||
|
||||
// Fetch current position size in tokens for accurate order sizing
|
||||
let positionSizeTokens: number | undefined
|
||||
try {
|
||||
const { getDriftService } = await import('../drift/client')
|
||||
const { getMarketConfig } = await import('../../config/trading')
|
||||
const driftService = getDriftService()
|
||||
if (driftService && (driftService as any).isInitialized) {
|
||||
const marketConfig = getMarketConfig(trade.symbol)
|
||||
const position = await driftService.getPosition(marketConfig.driftMarketIndex)
|
||||
if (position && Math.abs(position.size) > 0.01) {
|
||||
positionSizeTokens = Math.abs(position.size)
|
||||
console.log(`📊 Position size for health monitor orders: ${positionSizeTokens.toFixed(4)} tokens`)
|
||||
}
|
||||
}
|
||||
} catch (posError) {
|
||||
console.warn('⚠️ Could not fetch position for health monitor token size, using USD fallback')
|
||||
}
|
||||
|
||||
const placeResult = await placeExitOrders({
|
||||
symbol: trade.symbol,
|
||||
positionSizeUSD,
|
||||
positionSizeTokens,
|
||||
entryPrice: trade.entryPrice,
|
||||
tp1Price,
|
||||
tp2Price,
|
||||
|
||||
@@ -280,6 +280,12 @@ async function restoreOrdersIfMissing(
|
||||
// Import order placement function
|
||||
const { placeExitOrders } = await import('../drift/orders')
|
||||
|
||||
// Get position size in tokens for accurate order sizing
|
||||
const positionSizeTokens = position && position.size ? Math.abs(position.size) : undefined
|
||||
if (positionSizeTokens) {
|
||||
logger.log(`📊 Position size for restored orders: ${positionSizeTokens.toFixed(4)} tokens`)
|
||||
}
|
||||
|
||||
// Place exit orders using trade's TP/SL prices
|
||||
const result = await placeExitOrders({
|
||||
symbol: trade.symbol,
|
||||
@@ -289,6 +295,7 @@ async function restoreOrdersIfMissing(
|
||||
tp2Price: trade.takeProfit2Price,
|
||||
stopLossPrice: trade.stopLossPrice,
|
||||
positionSizeUSD: trade.positionSizeUSD,
|
||||
positionSizeTokens: positionSizeTokens,
|
||||
tp1SizePercent: 75,
|
||||
tp2SizePercent: 0, // TP2-as-runner
|
||||
})
|
||||
|
||||
@@ -848,6 +848,7 @@ export class PositionManager {
|
||||
const placeResult = await placeExitOrders({
|
||||
symbol: trade.symbol,
|
||||
positionSizeUSD: trade.currentSize,
|
||||
positionSizeTokens: Math.abs(position.size), // CRITICAL FIX (Jan 6, 2026): Use actual token count
|
||||
entryPrice: trade.entryPrice,
|
||||
tp1Price: trade.tp2Price || trade.entryPrice * (trade.direction === 'long' ? 1.02 : 0.98),
|
||||
tp2Price: trade.tp2Price || trade.entryPrice * (trade.direction === 'long' ? 1.04 : 0.96),
|
||||
@@ -911,6 +912,7 @@ export class PositionManager {
|
||||
direction: trade.direction,
|
||||
entryPrice: trade.entryPrice,
|
||||
positionSizeUSD: trade.currentSize, // Runner size
|
||||
positionSizeTokens: Math.abs(position.size), // CRITICAL FIX (Jan 6, 2026): Use actual token count
|
||||
stopLossPrice: trade.stopLossPrice, // At breakeven now
|
||||
tp1Price: trade.tp2Price, // TP2 becomes new TP1 for runner
|
||||
tp2Price: 0, // No TP2 for runner
|
||||
@@ -1580,6 +1582,22 @@ export class PositionManager {
|
||||
if (cancelResult.success) {
|
||||
logger.log(`✅ Cancelled ${cancelResult.cancelledCount || 0} old orders`)
|
||||
|
||||
// CRITICAL FIX (Jan 6, 2026): Get current position tokens for accurate SL sizing
|
||||
let currentPositionTokens: number | undefined
|
||||
try {
|
||||
const driftService = getDriftService()
|
||||
if (driftService && (driftService as any).isInitialized) {
|
||||
const marketConfig = getMarketConfig(trade.symbol)
|
||||
const position = await driftService.getPosition(marketConfig.driftMarketIndex)
|
||||
if (position && Math.abs(position.size) > 0.01) {
|
||||
currentPositionTokens = Math.abs(position.size)
|
||||
logger.log(`📊 Current position size for SL: ${currentPositionTokens.toFixed(4)} tokens`)
|
||||
}
|
||||
}
|
||||
} catch (posError) {
|
||||
console.warn('⚠️ Could not fetch position for token size, using USD fallback')
|
||||
}
|
||||
|
||||
// Place ONLY new SL orders at breakeven/profit level for remaining position
|
||||
// DO NOT place TP2 order - trailing stop is software-only (Position Manager monitors)
|
||||
logger.log(`🛡️ Placing only SL orders at $${newStopLossPrice.toFixed(4)} for remaining position...`)
|
||||
@@ -1587,6 +1605,7 @@ export class PositionManager {
|
||||
const exitOrdersResult = await placeExitOrders({
|
||||
symbol: trade.symbol,
|
||||
positionSizeUSD: trade.currentSize,
|
||||
positionSizeTokens: currentPositionTokens, // CRITICAL FIX (Jan 6, 2026): Use actual token count
|
||||
entryPrice: trade.entryPrice,
|
||||
tp1Price: trade.tp2Price, // Dummy value, won't be used (tp1SizePercent=0)
|
||||
tp2Price: trade.tp2Price, // Dummy value, won't be used (tp2SizePercent=0)
|
||||
@@ -1683,6 +1702,22 @@ export class PositionManager {
|
||||
if (cancelResult.success) {
|
||||
logger.log(`✅ Old SL orders cancelled`)
|
||||
|
||||
// CRITICAL FIX (Jan 6, 2026): Get current position tokens for accurate SL sizing
|
||||
let currentPositionTokens: number | undefined
|
||||
try {
|
||||
const driftService = getDriftService()
|
||||
if (driftService && (driftService as any).isInitialized) {
|
||||
const marketConfig = getMarketConfig(trade.symbol)
|
||||
const position = await driftService.getPosition(marketConfig.driftMarketIndex)
|
||||
if (position && Math.abs(position.size) > 0.01) {
|
||||
currentPositionTokens = Math.abs(position.size)
|
||||
logger.log(`📊 Current position size for trailing SL: ${currentPositionTokens.toFixed(4)} tokens`)
|
||||
}
|
||||
}
|
||||
} catch (posError) {
|
||||
console.warn('⚠️ Could not fetch position for token size, using USD fallback')
|
||||
}
|
||||
|
||||
// Calculate initial trailing SL price
|
||||
const trailingDistancePercent = this.config.trailingStopPercent || 0.5
|
||||
const initialTrailingSL = this.calculatePrice(
|
||||
@@ -1697,6 +1732,7 @@ export class PositionManager {
|
||||
const exitOrdersResult = await placeExitOrders({
|
||||
symbol: trade.symbol,
|
||||
positionSizeUSD: trade.currentSize,
|
||||
positionSizeTokens: currentPositionTokens, // CRITICAL FIX (Jan 6, 2026): Use actual token count
|
||||
entryPrice: trade.entryPrice,
|
||||
tp1Price: trade.tp2Price, // No TP1 (already hit)
|
||||
tp2Price: trade.tp2Price, // No TP2 (trigger only)
|
||||
@@ -1894,10 +1930,27 @@ export class PositionManager {
|
||||
if (cancelResult.success) {
|
||||
logger.log(`✅ Old SL orders cancelled`)
|
||||
|
||||
// Fetch current position size in tokens for accurate SL sizing
|
||||
let currentPositionTokens: number | undefined
|
||||
try {
|
||||
const driftService = getDriftService()
|
||||
if (driftService && (driftService as any).isInitialized) {
|
||||
const marketConfig = getMarketConfig(trade.symbol)
|
||||
const position = await driftService.getPosition(marketConfig.driftMarketIndex)
|
||||
if (position && Math.abs(position.size) > 0.01) {
|
||||
currentPositionTokens = Math.abs(position.size)
|
||||
logger.log(`📊 Current position size for trailing SL: ${currentPositionTokens.toFixed(4)} tokens`)
|
||||
}
|
||||
}
|
||||
} catch (posError) {
|
||||
console.warn('⚠️ Could not fetch position for trailing SL token size, using USD fallback')
|
||||
}
|
||||
|
||||
// Place new SL orders at trailing stop price
|
||||
const exitOrdersResult = await placeExitOrders({
|
||||
symbol: trade.symbol,
|
||||
positionSizeUSD: trade.currentSize,
|
||||
positionSizeTokens: currentPositionTokens,
|
||||
entryPrice: trade.entryPrice,
|
||||
tp1Price: trade.tp2Price, // No TP1 (already hit)
|
||||
tp2Price: trade.tp2Price, // No TP2 (already hit)
|
||||
|
||||
@@ -515,6 +515,7 @@ export class SmartEntryTimer {
|
||||
const exitRes = await placeExitOrders({
|
||||
symbol: signal.symbol,
|
||||
positionSizeUSD,
|
||||
positionSizeTokens: openResult.fillSize, // CRITICAL FIX (Jan 6, 2026): Use actual token count
|
||||
entryPrice: fillPrice,
|
||||
tp1Price,
|
||||
tp2Price,
|
||||
|
||||
@@ -227,6 +227,7 @@ export async function syncSinglePosition(driftPos: any, positionManager: any): P
|
||||
const placeResult = await placeExitOrders({
|
||||
symbol: driftPos.symbol,
|
||||
positionSizeUSD,
|
||||
positionSizeTokens: Math.abs(driftPos.size), // CRITICAL FIX (Jan 6, 2026): Use actual token count
|
||||
entryPrice,
|
||||
tp1Price,
|
||||
tp2Price,
|
||||
|
||||
Reference in New Issue
Block a user