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:
mindesbunister
2026-01-07 09:59:36 +01:00
parent efbe4d0c04
commit 361f3ba183
7 changed files with 151 additions and 32 deletions

View File

@@ -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'}`)

View File

@@ -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,

View File

@@ -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
})

View File

@@ -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)

View File

@@ -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,

View File

@@ -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,