From 0fdade0120925ec7b9b48a8784f7515d5a9797b7 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 12 Jan 2026 16:33:03 +0100 Subject: [PATCH] Add liquidity-limited trading for illiquid markets (#2690) * Add liquidity-limited trading for illiquid markets - Add getBestBidLiquidity() method to ExchangeService - Add liquidityLimited parameter to buy/sell actions - Limit order size to available liquidity at best price - Prevents slippage on low-liquidity pairs like ZCHF/USDT * Add exchange minimum amount check for liquidity-limited trades * Skip orderbook entries below exchange minimum - Add minAmount parameter to getBestBidLiquidity() - Iterate through orderbook to find first order meeting minimum - Pass exchange minimum to getBestBidLiquidity() in buy/sell methods * Add configurable maxPriceDeviation parameter (#2691) - Add maxPriceDeviation to parseBuyParams and parseSellParams - Pass maxPriceDeviation to getAndCheckTradePrice (default: 5%) - Improve error message to show configured max deviation * feat: refactoring * fix: eslint fix --------- Co-authored-by: David May --- .../exchange/services/exchange.service.ts | 24 +++++- .../actions/base/ccxt-exchange.adapter.ts | 75 +++++++++++++++---- 2 files changed, 82 insertions(+), 17 deletions(-) diff --git a/src/integration/exchange/services/exchange.service.ts b/src/integration/exchange/services/exchange.service.ts index c2229c711b..c009cba01b 100644 --- a/src/integration/exchange/services/exchange.service.ts +++ b/src/integration/exchange/services/exchange.service.ts @@ -233,7 +233,7 @@ export abstract class ExchangeService extends PricingProvider implements OnModul return this.markets; } - private async getMinTradeAmount(pair: string): Promise { + async getMinTradeAmount(pair: string): Promise { return this.getMarket(pair).then((m) => m.limits.amount.min); } @@ -294,6 +294,28 @@ export abstract class ExchangeService extends PricingProvider implements OnModul return Util.roundToValue(price, pricePrecision); } + async getBestBidLiquidity(from: string, to: string): Promise<{ price: number; amount: number } | undefined> { + const { pair, direction } = await this.getTradePair(from, to); + + const minAmount = await this.getMinTradeAmount(pair); + const orderBook = await this.callApi((e) => e.fetchOrderBook(pair)); + const { price: pricePrecision } = await this.getPrecision(pair); + + const orders = direction === OrderSide.SELL ? orderBook.bids : orderBook.asks; + + // Find first order that meets minimum amount requirement + const validOrder = orders.find(([, amount]) => amount >= minAmount); + + if (!validOrder) return undefined; + + const [price, amount] = validOrder; + + return { + price: Util.roundToValue(price, pricePrecision), + amount, + }; + } + // orders protected async trade(from: string, to: string, amount: number): Promise { diff --git a/src/subdomains/core/liquidity-management/adapters/actions/base/ccxt-exchange.adapter.ts b/src/subdomains/core/liquidity-management/adapters/actions/base/ccxt-exchange.adapter.ts index 8c9894b866..a79767f718 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/base/ccxt-exchange.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/base/ccxt-exchange.adapter.ts @@ -122,7 +122,9 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { } private async buy(order: LiquidityManagementOrder): Promise { - const { asset, tradeAsset, minTradeAmount, fullTrade } = this.parseBuyParams(order.action.paramMap); + const { asset, tradeAsset, minTradeAmount, fullTrade, liquidityLimited, maxPriceDeviation } = this.parseBuyParams( + order.action.paramMap, + ); const targetAssetEntity = asset ? await this.assetService.getAssetByUniqueName(asset) @@ -139,18 +141,29 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { ); } - const price = await this.getAndCheckTradePrice(tradeAssetEntity, targetAssetEntity); + const price = await this.getAndCheckTradePrice(tradeAssetEntity, targetAssetEntity, maxPriceDeviation); const minSellAmount = minTradeAmount ?? Util.floor(minAmount * price, 6); const maxSellAmount = Util.floor(maxAmount * price, 6); const availableBalance = await this.getAvailableTradeBalance(tradeAsset, targetAssetEntity.name); - if (minSellAmount > availableBalance) + let effectiveMax = Math.min(maxSellAmount, availableBalance); + + if (liquidityLimited) { + const { amount: liquidity, price: liquidityPrice } = await this.getBestPriceLiquidity( + tradeAsset, + targetAssetEntity.name, + ); + effectiveMax = Math.min(effectiveMax, Util.floor(liquidity * liquidityPrice, 6)); + } + + if (effectiveMax < minSellAmount) { throw new OrderNotProcessableException( - `${this.exchangeService.name}: not enough balance for ${tradeAsset} (balance: ${availableBalance}, min. requested: ${minSellAmount}, max. requested: ${maxSellAmount})`, + `${this.exchangeService.name}: not enough balance/liquidity for ${tradeAsset} (balance: ${effectiveMax}, min. requested: ${minSellAmount}, max. requested: ${maxSellAmount})`, ); + } - const amount = Math.min(maxSellAmount, availableBalance); + const amount = effectiveMax; order.inputAmount = amount; order.inputAsset = tradeAsset; @@ -174,20 +187,28 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { } private async sell(order: LiquidityManagementOrder): Promise { - const { tradeAsset } = this.parseSellParams(order.action.paramMap); + const { tradeAsset, liquidityLimited, maxPriceDeviation } = this.parseSellParams(order.action.paramMap); const asset = order.pipeline.rule.targetAsset.dexName; const tradeAssetEntity = await this.assetService.getAssetByUniqueName(`${this.exchangeService.name}/${tradeAsset}`); - await this.getAndCheckTradePrice(order.pipeline.rule.targetAsset, tradeAssetEntity); + await this.getAndCheckTradePrice(order.pipeline.rule.targetAsset, tradeAssetEntity, maxPriceDeviation); const availableBalance = await this.getAvailableTradeBalance(asset, tradeAsset); - if (order.minAmount > availableBalance) + let effectiveMax = Math.min(order.maxAmount, availableBalance); + + if (liquidityLimited) { + const { amount: liquidity } = await this.getBestPriceLiquidity(asset, tradeAsset); + effectiveMax = Math.min(effectiveMax, liquidity); + } + + if (effectiveMax < order.minAmount) { throw new OrderNotProcessableException( - `${this.exchangeService.name}: not enough balance for ${asset} (balance: ${availableBalance}, min. requested: ${order.minAmount}, max. requested: ${order.maxAmount})`, + `${this.exchangeService.name}: not enough balance/liquidity for ${asset} (balance: ${effectiveMax}, min. requested: ${order.minAmount}, max. requested: ${order.maxAmount})`, ); + } - const amount = Math.min(order.maxAmount, availableBalance); + const amount = effectiveMax; order.inputAmount = amount; order.inputAsset = asset; @@ -208,15 +229,15 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { } } - private async getAndCheckTradePrice(from: Asset, to: Asset): Promise { + private async getAndCheckTradePrice(from: Asset, to: Asset, maxPriceDeviation = 0.05): Promise { const price = await this.exchangeService.getCurrentPrice(from.name, to.name); // price fetch should already throw error if out of range const checkPrice = await this.pricingService.getPrice(from, to, PriceValidity.VALID_ONLY); - if (Math.abs((price - checkPrice.price) / checkPrice.price) > 0.05) + if (Math.abs((price - checkPrice.price) / checkPrice.price) > maxPriceDeviation) throw new OrderFailedException( - `Trade price out of range: exchange price ${price}, check price ${checkPrice.price}`, + `Trade price out of range: exchange price ${price}, check price ${checkPrice.price}, max deviation ${maxPriceDeviation}`, ); return price; @@ -454,15 +475,19 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { tradeAsset: string; minTradeAmount: number; fullTrade: boolean; + liquidityLimited: boolean; + maxPriceDeviation?: number; } { const asset = params.asset as string | undefined; const tradeAsset = params.tradeAsset as string | undefined; const minTradeAmount = params.minTradeAmount as number | undefined; const fullTrade = Boolean(params.fullTrade); // use full trade for directly triggered actions + const liquidityLimited = Boolean(params.liquidityLimited); + const maxPriceDeviation = params.maxPriceDeviation as number | undefined; if (!tradeAsset) throw new Error(`Params provided to CcxtExchangeAdapter.buy(...) command are invalid.`); - return { asset, tradeAsset, minTradeAmount, fullTrade }; + return { asset, tradeAsset, minTradeAmount, fullTrade, liquidityLimited, maxPriceDeviation }; } private validateSellParams(params: Record): boolean { @@ -474,12 +499,18 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { } } - private parseSellParams(params: Record): { tradeAsset: string } { + private parseSellParams(params: Record): { + tradeAsset: string; + liquidityLimited: boolean; + maxPriceDeviation?: number; + } { const tradeAsset = params.tradeAsset as string | undefined; + const liquidityLimited = Boolean(params.liquidityLimited); + const maxPriceDeviation = params.maxPriceDeviation as number | undefined; if (!tradeAsset) throw new Error(`Params provided to CcxtExchangeAdapter.sell(...) command are invalid.`); - return { tradeAsset }; + return { tradeAsset, liquidityLimited, maxPriceDeviation }; } private validateTransferParams(params: Record): boolean { @@ -516,4 +547,16 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { e.message?.includes(m), ); } + + private async getBestPriceLiquidity(from: string, to: string): Promise<{ amount: number; price: number }> { + const liquidity = await this.exchangeService.getBestBidLiquidity(from, to); + + if (!liquidity) { + throw new OrderNotProcessableException( + `${this.exchangeService.name}: not enough liquidity for ${from} (no order in orderbook meets exchange minimum)`, + ); + } + + return liquidity; + } }