From 8f66f3c961f1ecd1ed805a91093ad7e8348f4265 Mon Sep 17 00:00:00 2001 From: moo-onthelawn <70078372+moo-onthelawn@users.noreply.github.com> Date: Thu, 19 Sep 2024 17:19:23 -0400 Subject: [PATCH] fix: fix isolated pnl transfer calc (#664) --- build.gradle.kts | 2 +- .../calculator/TradeInputCalculator.kt | 68 +++++++++++++----- .../TradeInputMarketOrderCalculator.kt | 69 ++++++++++++++----- .../exchange.dydx.abacus/utils/Constants.kt | 3 +- v4_abacus.podspec | 2 +- 5 files changed, 104 insertions(+), 40 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index df558d49b..fa309dcf3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -52,7 +52,7 @@ allprojects { } group = "exchange.dydx.abacus" -version = "1.11.11" +version = "1.11.12" repositories { google() diff --git a/src/commonMain/kotlin/exchange.dydx.abacus/calculator/TradeInputCalculator.kt b/src/commonMain/kotlin/exchange.dydx.abacus/calculator/TradeInputCalculator.kt index 59e9ee971..5812d50a3 100644 --- a/src/commonMain/kotlin/exchange.dydx.abacus/calculator/TradeInputCalculator.kt +++ b/src/commonMain/kotlin/exchange.dydx.abacus/calculator/TradeInputCalculator.kt @@ -13,8 +13,7 @@ import exchange.dydx.abacus.calculator.SlippageConstants.TAKE_PROFIT_MARKET_ORDE import exchange.dydx.abacus.output.input.MarginMode import exchange.dydx.abacus.output.input.OrderSide import exchange.dydx.abacus.protocols.ParserProtocol -import exchange.dydx.abacus.utils.MAX_FREE_CROSS_COLLATERAL_BUFFER_PERCENT -import exchange.dydx.abacus.utils.MAX_FREE_ISOLATED_COLLATERAL_BUFFER_PERCENT +import exchange.dydx.abacus.utils.MAX_FREE_COLLATERAL_BUFFER_PERCENT import exchange.dydx.abacus.utils.NUM_PARENT_SUBACCOUNTS import exchange.dydx.abacus.utils.Numeric import exchange.dydx.abacus.utils.QUANTUM_MULTIPLIER @@ -25,6 +24,7 @@ import exchange.dydx.abacus.utils.safeSet import kollections.JsExport import kotlinx.serialization.Serializable import kotlin.math.abs +import kotlin.math.max import kotlin.math.min import kotlin.math.pow @@ -478,8 +478,9 @@ internal class TradeInputCalculator( ): Map? { val marketId = parser.asString(market?.get("id")) val tradeSize = parser.asNativeMap(trade["size"]) + val tradeSide = OrderSide.invoke(parser.asString(trade["side"])) - if (tradeSize != null && marketId != null) { + if (tradeSize != null && marketId != null && tradeSide != null) { val maxMarketLeverage = maxMarketLeverage(market) val targetLeverage = parser.asDouble(trade["targetLeverage"]) val marginMode = MarginMode.invoke(parser.asString(trade["marginMode"])) ?: MarginMode.Cross @@ -490,7 +491,6 @@ internal class TradeInputCalculator( } val freeCollateral = parser.asDouble(parser.value(subaccount, "freeCollateral.current")) ?: Numeric.double.ZERO - val tradeSide = OrderSide.invoke(parser.asString(trade["side"])) val position = parser.asNativeMap(parser.value(subaccount, "openPositions.$marketId")) val positionNotionalSize = if (position != null) { parser.asDouble( @@ -512,7 +512,7 @@ internal class TradeInputCalculator( } else { Numeric.double.ZERO } - val isTradeSameSide = tradeSide != null && + val isTradeSameSide = ((tradeSide == OrderSide.Buy && positionSize >= Numeric.double.ZERO) || (tradeSide == OrderSide.Sell && positionSize <= Numeric.double.ZERO)) return when (input) { @@ -582,6 +582,7 @@ internal class TradeInputCalculator( stepSize, oraclePrice, isReduceOnly, + tradeSide, ) } @@ -591,13 +592,43 @@ internal class TradeInputCalculator( return null } - private fun isolatedPnlImpact(marginMode: MarginMode, size: Double, entryPrice: Double, oraclePrice: Double?, isReduceOnly: Boolean): Double { - // Calculate the difference between the oracle price and the ask/bid price in order to determine immediate PnL impact that would affect collateral checks - // Should only apply to orders that are increasing in position size (not reduceOnly) - // In a cleaner world, this would call MarginCalculator.getShouldTransferInCollateralDeprecated and MarginCalculator.getTransferAmountFromTargetLeverage but because it will be deprecated soon anyways, just passing in the necessary variables + private fun isolatedPnlImpactForBalance( + marginMode: MarginMode, + tradeSide: OrderSide, + balance: Double, + tradeLeverage: Double, + entryPrice: Double, + oraclePrice: Double?, + isReduceOnly: Boolean + ): Double { + // Calculates the pnl impact for an isolated order trade, given: + // - the difference between the oracle price and the ask/bid price + // - a total balance to be used for the trade, note this balance should also be used for the pnl impact + // + // This should only apply to orders that are increasing in position size (not reduceOnly). + // In a cleaner world, this would call MarginCalculator.getShouldTransferInCollateralDeprecated and MarginCalculator.getTransferAmountFromTargetLeverage but + // because it will be deprecated soon anyways, just passing in the necessary variables. + + // Formula Derivation: + // pnlImpact = diff * size + // size = balance * tradeLeverage / entryPrice + // pnlImpact = diff * (balance - pnlImpact) * tradeLeverage / entryPrice + // pnlImpact = (diff * balance - diff * pnlImpact) * tradeLeverage / entryPrice + // pnlImpact * (entryPrice + diff * tradeLeverage) = diff * balance * tradeLeverage + // pnlImpact = (diff * balance * tradeLeverage) / (entryPrice + diff * tradeLeverage) + return when (marginMode) { MarginMode.Cross -> Numeric.double.ZERO - MarginMode.Isolated -> if (isReduceOnly) Numeric.double.ZERO else (entryPrice - (oraclePrice ?: entryPrice)).abs() * size + MarginMode.Isolated -> if (isReduceOnly) { + Numeric.double.ZERO + } else { + val diff = when (tradeSide) { + OrderSide.Buy -> entryPrice - (oraclePrice ?: entryPrice) + OrderSide.Sell -> (oraclePrice ?: entryPrice) - entryPrice + } + val pnlImpact = if ((entryPrice + diff * tradeLeverage) > Numeric.double.ZERO) (diff * balance * tradeLeverage) / (entryPrice + diff * tradeLeverage) else Numeric.double.ZERO + max(pnlImpact, Numeric.double.ZERO) + } } } @@ -613,6 +644,7 @@ internal class TradeInputCalculator( stepSize: Double, oraclePrice: Double?, isReduceOnly: Boolean, + tradeSide: OrderSide, ): Map? { if (marginMode == MarginMode.Isolated && !isTradeSameSide) { // For isolated margin orders where the user is trading on the opposite side of their currentPosition, the balancePercent represents a percentage of their current position rather than freeCollateral @@ -624,11 +656,7 @@ internal class TradeInputCalculator( return null } - val maxPercent = when (marginMode) { - MarginMode.Cross -> MAX_FREE_CROSS_COLLATERAL_BUFFER_PERCENT - MarginMode.Isolated -> MAX_FREE_ISOLATED_COLLATERAL_BUFFER_PERCENT - } - val cappedPercent = min(balancePercent, maxPercent) + val cappedPercent = min(balancePercent, MAX_FREE_COLLATERAL_BUFFER_PERCENT) val existingBalance = existingPositionNotionalSize.abs() / tradeLeverage val desiredBalance = when (marginMode) { @@ -657,23 +685,25 @@ internal class TradeInputCalculator( if (entryPrice != null && entryPrice > Numeric.double.ZERO && entrySize != null) { val entryUsdcSize = entrySize * entryPrice val entryBalanceSize = entryUsdcSize / tradeLeverage - filled = (balanceTotal + entryBalanceSize + isolatedPnlImpact(marginMode, entrySize, entryPrice, oraclePrice, isReduceOnly)) >= desiredBalance + val pnlImpact = isolatedPnlImpactForBalance(marginMode, tradeSide, desiredBalance, tradeLeverage, entryPrice, oraclePrice, isReduceOnly) + filled = (balanceTotal + entryBalanceSize + pnlImpact) >= desiredBalance var matchedSize = entrySize var matchedUsdcSize = entryUsdcSize - var matchedBalance = matchedUsdcSize / tradeLeverage + isolatedPnlImpact(marginMode, matchedSize, entryPrice, oraclePrice, isReduceOnly) + var matchedBalance = matchedUsdcSize / tradeLeverage if (filled) { - matchedBalance = desiredBalance - balanceTotal - isolatedPnlImpact(marginMode, (desiredBalance - balanceTotal) * tradeLeverage / entryPrice, entryPrice, oraclePrice, isReduceOnly) + matchedBalance = desiredBalance - balanceTotal - pnlImpact matchedUsdcSize = matchedBalance * tradeLeverage matchedSize = matchedUsdcSize / entryPrice + // Round the size to appropriate step size for market and recalculate matchedSize = Rounder.quickRound( matchedSize, stepSize, ) matchedUsdcSize = matchedSize * entryPrice - matchedBalance = matchedUsdcSize / tradeLeverage + isolatedPnlImpact(marginMode, matchedSize, entryPrice, oraclePrice, isReduceOnly) + matchedBalance = matchedUsdcSize / tradeLeverage + pnlImpact } sizeTotal += matchedSize usdcSizeTotal += matchedUsdcSize diff --git a/src/commonMain/kotlin/exchange.dydx.abacus/calculator/V2/TradeInput/TradeInputMarketOrderCalculator.kt b/src/commonMain/kotlin/exchange.dydx.abacus/calculator/V2/TradeInput/TradeInputMarketOrderCalculator.kt index 465e2c5a5..60a96f81b 100644 --- a/src/commonMain/kotlin/exchange.dydx.abacus/calculator/V2/TradeInput/TradeInputMarketOrderCalculator.kt +++ b/src/commonMain/kotlin/exchange.dydx.abacus/calculator/V2/TradeInput/TradeInputMarketOrderCalculator.kt @@ -16,11 +16,11 @@ import exchange.dydx.abacus.state.internalstate.InternalSubaccountState import exchange.dydx.abacus.state.internalstate.InternalTradeInputState import exchange.dydx.abacus.state.internalstate.InternalUserState import exchange.dydx.abacus.state.internalstate.safeCreate -import exchange.dydx.abacus.utils.MAX_FREE_CROSS_COLLATERAL_BUFFER_PERCENT -import exchange.dydx.abacus.utils.MAX_FREE_ISOLATED_COLLATERAL_BUFFER_PERCENT +import exchange.dydx.abacus.utils.MAX_FREE_COLLATERAL_BUFFER_PERCENT import exchange.dydx.abacus.utils.Numeric import exchange.dydx.abacus.utils.Rounder import kollections.toIList +import kotlin.math.max import kotlin.math.min internal class TradeInputMarketOrderCalculator() { @@ -109,7 +109,7 @@ internal class TradeInputMarketOrderCalculator() { val tradeSize = trade.size val freeCollateral = subaccount?.calculated?.get(CalculationPeriod.current)?.freeCollateral - if (tradeSize != null && freeCollateral != null && freeCollateral > Numeric.double.ZERO) { + if (tradeSize != null && tradeSide != null && freeCollateral != null && freeCollateral > Numeric.double.ZERO) { val maxMarketLeverage = market?.perpetualMarket?.configs?.maxMarketLeverage ?: Numeric.double.ONE val targetLeverage = trade.targetLeverage val marginMode = trade.marginMode ?: MarginMode.Cross @@ -131,8 +131,7 @@ internal class TradeInputMarketOrderCalculator() { } else { Numeric.double.ZERO } - val isTradeSameSide = tradeSide != null && - ((tradeSide == OrderSide.Buy && positionSize >= Numeric.double.ZERO) || (tradeSide == OrderSide.Sell && positionSize <= Numeric.double.ZERO)) + val isTradeSameSide = ((tradeSide == OrderSide.Buy && positionSize >= Numeric.double.ZERO) || (tradeSide == OrderSide.Sell && positionSize <= Numeric.double.ZERO)) return when (input) { "size.size", "size.percent" -> { @@ -195,6 +194,7 @@ internal class TradeInputMarketOrderCalculator() { stepSize = stepSize, oraclePrice = oraclePrice, isReduceOnly = isReduceOnly, + tradeSide = tradeSide, ) } @@ -215,12 +215,40 @@ internal class TradeInputMarketOrderCalculator() { } } - private fun isolatedPnlImpact(marginMode: MarginMode, size: Double, entryPrice: Double, oraclePrice: Double?, isReduceOnly: Boolean): Double { - // Calculate the difference between the oracle price and the ask/bid price in order to determine immediate PnL impact that would affect collateral checks + private fun isolatedPnlImpactForBalance( + marginMode: MarginMode, + tradeSide: OrderSide, + desiredBalance: Double, + tradeLeverage: Double, + entryPrice: Double, + oraclePrice: Double?, + isReduceOnly: Boolean + ): Double { + // Calculates the pnl impact for an isolated order trade, given: + // - the difference between the oracle price and the ask/bid price + // - a total balance to be used for the trade, note this balance should also be used for the pnl impact // TODO CT-1192: refactor to call into MarginCalculator.getShouldTransferInCollateralDeprecated and MarginCalculator.getTransferAmountFromTargetLeverage + + // Formula Derivation: + // pnlImpact = diff * size + // size = balance * tradeLeverage / entryPrice + // pnlImpact = diff * (balance - pnlImpact) * tradeLeverage / entryPrice + // pnlImpact = (diff * balance - diff * pnlImpact) * tradeLeverage / entryPrice + // pnlImpact * (entryPrice + diff * tradeLeverage) = diff * balance * tradeLeverage + // pnlImpact = (diff * balance * tradeLeverage) / (entryPrice + diff * tradeLeverage) + return when (marginMode) { MarginMode.Cross -> Numeric.double.ZERO - MarginMode.Isolated -> if (isReduceOnly) Numeric.double.ZERO else (entryPrice - (oraclePrice ?: entryPrice)).abs() * size + MarginMode.Isolated -> if (isReduceOnly) { + Numeric.double.ZERO + } else { + val diff = when (tradeSide) { + OrderSide.Buy -> entryPrice - (oraclePrice ?: entryPrice) + OrderSide.Sell -> (oraclePrice ?: entryPrice) - entryPrice + } + val pnlImpact = if ((entryPrice + diff * tradeLeverage) > Numeric.double.ZERO) (diff * desiredBalance * tradeLeverage) / (entryPrice + diff * tradeLeverage) else Numeric.double.ZERO + max(pnlImpact, Numeric.double.ZERO) + } } } @@ -236,6 +264,7 @@ internal class TradeInputMarketOrderCalculator() { stepSize: Double, oraclePrice: Double?, isReduceOnly: Boolean, + tradeSide: OrderSide, ): TradeInputMarketOrder? { if (marginMode == MarginMode.Isolated && !isTradeSameSide) { // For isolated margin orders where the user is trading on the opposite side of their currentPosition, the balancePercent represents a percentage of their current position rather than freeCollateral @@ -243,11 +272,7 @@ internal class TradeInputMarketOrderCalculator() { return createMarketOrderFromSize(size = desiredSize, existingPositionNotionalSize = existingPositionNotionalSize, isTradeSameSide = isTradeSameSide, freeCollateral = freeCollateral, tradeLeverage = tradeLeverage, orderbook = orderbook) } - val maxPercent = when (marginMode) { - MarginMode.Cross -> MAX_FREE_CROSS_COLLATERAL_BUFFER_PERCENT - MarginMode.Isolated -> MAX_FREE_ISOLATED_COLLATERAL_BUFFER_PERCENT - } - val cappedPercent = min(balancePercent, maxPercent) + val cappedPercent = min(balancePercent, MAX_FREE_COLLATERAL_BUFFER_PERCENT) val existingBalance = existingPositionNotionalSize.abs() / tradeLeverage @@ -276,23 +301,33 @@ internal class TradeInputMarketOrderCalculator() { if (entryPrice > Numeric.double.ZERO) { val entryUsdcSize = entrySize * entryPrice val entryBalanceSize = entryUsdcSize / tradeLeverage - filled = (balanceTotal + entryBalanceSize + isolatedPnlImpact(marginMode = marginMode, size = entrySize, entryPrice = entryPrice, oraclePrice = oraclePrice, isReduceOnly = isReduceOnly)) >= desiredBalance + val pnlImpact = isolatedPnlImpactForBalance( + marginMode = marginMode, + tradeSide = tradeSide, + desiredBalance = desiredBalance, + tradeLeverage = tradeLeverage, + entryPrice = entryPrice, + oraclePrice = oraclePrice, + isReduceOnly = isReduceOnly, + ) + filled = (balanceTotal + entryBalanceSize + pnlImpact) >= desiredBalance var matchedSize = entrySize var matchedUsdcSize = entryUsdcSize - var matchedBalance = matchedUsdcSize / tradeLeverage + isolatedPnlImpact(marginMode = marginMode, size = matchedSize, entryPrice = entryPrice, oraclePrice = oraclePrice, isReduceOnly = isReduceOnly) + var matchedBalance = matchedUsdcSize / tradeLeverage if (filled) { - matchedBalance = desiredBalance - balanceTotal + isolatedPnlImpact(marginMode = marginMode, size = (desiredBalance - balanceTotal) * tradeLeverage / entryPrice, entryPrice = entryPrice, oraclePrice = oraclePrice, isReduceOnly = isReduceOnly) + matchedBalance = desiredBalance - balanceTotal - pnlImpact matchedUsdcSize = matchedBalance * tradeLeverage matchedSize = matchedUsdcSize / entryPrice + // Round the size to appropriate step size for market and recalculate matchedSize = Rounder.quickRound( matchedSize, stepSize, ) matchedUsdcSize = matchedSize * entryPrice - matchedBalance = matchedUsdcSize / tradeLeverage + isolatedPnlImpact(marginMode = marginMode, size = matchedSize, entryPrice = entryPrice, oraclePrice = oraclePrice, isReduceOnly = isReduceOnly) + matchedBalance = matchedUsdcSize / tradeLeverage + pnlImpact } sizeTotal += matchedSize usdcSizeTotal += matchedUsdcSize diff --git a/src/commonMain/kotlin/exchange.dydx.abacus/utils/Constants.kt b/src/commonMain/kotlin/exchange.dydx.abacus/utils/Constants.kt index 38ff7e918..ae820bb41 100644 --- a/src/commonMain/kotlin/exchange.dydx.abacus/utils/Constants.kt +++ b/src/commonMain/kotlin/exchange.dydx.abacus/utils/Constants.kt @@ -12,10 +12,9 @@ internal const val QUANTUM_MULTIPLIER = 1_000_000 internal const val SLIPPAGE_PERCENT = "1" // Trade Constants -internal const val MAX_FREE_CROSS_COLLATERAL_BUFFER_PERCENT = 0.95 +internal const val MAX_FREE_COLLATERAL_BUFFER_PERCENT = 0.95 // Isolated Margin Constants -internal const val MAX_FREE_ISOLATED_COLLATERAL_BUFFER_PERCENT = 0.93 internal const val MAX_LEVERAGE_BUFFER_PERCENT = 0.98 internal const val MARGIN_COLLATERALIZATION_CHECK_BUFFER = 0.01; diff --git a/v4_abacus.podspec b/v4_abacus.podspec index 6b628d8c5..eb6fb10d1 100644 --- a/v4_abacus.podspec +++ b/v4_abacus.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |spec| spec.name = 'v4_abacus' - spec.version = '1.11.11' + spec.version = '1.11.12' spec.homepage = 'https://github.com/dydxprotocol/v4-abacus' spec.source = { :http=> ''} spec.authors = ''