From 7712fab7ec92a9ac3a0760643029c8a24454c3a0 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 23 Dec 2025 06:38:35 +0100 Subject: [PATCH 1/4] 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 --- .../exchange/services/exchange.service.ts | 15 +++++ .../actions/base/ccxt-exchange.adapter.ts | 58 ++++++++++++++++--- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/integration/exchange/services/exchange.service.ts b/src/integration/exchange/services/exchange.service.ts index 6dac302a88..4f5c251550 100644 --- a/src/integration/exchange/services/exchange.service.ts +++ b/src/integration/exchange/services/exchange.service.ts @@ -292,6 +292,21 @@ export abstract class ExchangeService extends PricingProvider implements OnModul return Util.roundToValue(price, pricePrecision); } + async getBestBidLiquidity(from: string, to: string): Promise<{ price: number; amount: number }> { + const { pair, direction } = await this.getTradePair(from, to); + const orderBook = await this.callApi((e) => e.fetchOrderBook(pair)); + const { price: pricePrecision } = await this.getPrecision(pair); + + // For selling: we need the best bid (highest buy order) + // For buying: we need the best ask (lowest sell order) + const [price, amount] = direction === OrderSide.SELL ? orderBook.bids[0] : orderBook.asks[0]; + + return { + price: Util.roundToValue(price, pricePrecision), + amount, + }; + } + // orders private 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 e257f109a1..00b98ef2d7 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,7 @@ 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 } = this.parseBuyParams(order.action.paramMap); const targetAssetEntity = asset ? await this.assetService.getAssetByUniqueName(asset) @@ -142,7 +142,30 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { const price = await this.getAndCheckTradePrice(tradeAssetEntity, targetAssetEntity); const minSellAmount = minTradeAmount ?? Util.floor(minAmount * price, 6); - const maxSellAmount = Util.floor(maxAmount * price, 6); + let maxSellAmount = Util.floor(maxAmount * price, 6); + + // For illiquid markets: limit order size to available liquidity at best price + if (liquidityLimited) { + // Get liquidity on the ask side (sellers of targetAsset) + const { amount: liquidityAtBestPrice, price: bestPrice } = await this.exchangeService.getBestBidLiquidity( + tradeAsset, + targetAssetEntity.name, + ); + // Convert liquidity from targetAsset to tradeAsset + const liquidityInTradeAsset = Util.floor(liquidityAtBestPrice * bestPrice, 6); + + this.logger.verbose( + `Liquidity-limited buy: requested ${maxSellAmount} ${tradeAsset}, liquidity at best price ${liquidityAtBestPrice} ${targetAssetEntity.name} (= ${liquidityInTradeAsset} ${tradeAsset}), using min of both`, + ); + + if (liquidityInTradeAsset < minSellAmount) { + throw new OrderNotProcessableException( + `${this.exchangeService.name}: not enough liquidity for ${targetAssetEntity.name} at best price (liquidity: ${liquidityAtBestPrice}, min. requested in ${tradeAsset}: ${minSellAmount})`, + ); + } + + maxSellAmount = Math.min(maxSellAmount, liquidityInTradeAsset); + } const availableBalance = await this.getAvailableTradeBalance(tradeAsset, targetAssetEntity.name); if (minSellAmount > availableBalance) @@ -174,7 +197,7 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { } private async sell(order: LiquidityManagementOrder): Promise { - const { tradeAsset } = this.parseSellParams(order.action.paramMap); + const { tradeAsset, liquidityLimited } = this.parseSellParams(order.action.paramMap); const asset = order.pipeline.rule.targetAsset.dexName; @@ -187,7 +210,25 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { `${this.exchangeService.name}: not enough balance for ${asset} (balance: ${availableBalance}, min. requested: ${order.minAmount}, max. requested: ${order.maxAmount})`, ); - const amount = Math.min(order.maxAmount, availableBalance); + let amount = Math.min(order.maxAmount, availableBalance); + + // For illiquid markets: limit order size to available liquidity at best price + if (liquidityLimited) { + const { amount: liquidityAtBestPrice } = await this.exchangeService.getBestBidLiquidity(asset, tradeAsset); + const limitedAmount = Math.min(amount, liquidityAtBestPrice); + + this.logger.verbose( + `Liquidity-limited sell: requested ${amount}, liquidity at best price ${liquidityAtBestPrice}, using ${limitedAmount}`, + ); + + if (limitedAmount < order.minAmount) { + throw new OrderNotProcessableException( + `${this.exchangeService.name}: not enough liquidity for ${asset} at best price (liquidity: ${liquidityAtBestPrice}, min. requested: ${order.minAmount})`, + ); + } + + amount = limitedAmount; + } order.inputAmount = amount; order.inputAsset = asset; @@ -454,15 +495,17 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { tradeAsset: string; minTradeAmount: number; fullTrade: boolean; + liquidityLimited: boolean; } { 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); if (!tradeAsset) throw new Error(`Params provided to CcxtExchangeAdapter.buy(...) command are invalid.`); - return { asset, tradeAsset, minTradeAmount, fullTrade }; + return { asset, tradeAsset, minTradeAmount, fullTrade, liquidityLimited }; } private validateSellParams(params: Record): boolean { @@ -474,12 +517,13 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { } } - private parseSellParams(params: Record): { tradeAsset: string } { + private parseSellParams(params: Record): { tradeAsset: string; liquidityLimited: boolean } { const tradeAsset = params.tradeAsset as string | undefined; + const liquidityLimited = Boolean(params.liquidityLimited); if (!tradeAsset) throw new Error(`Params provided to CcxtExchangeAdapter.sell(...) command are invalid.`); - return { tradeAsset }; + return { tradeAsset, liquidityLimited }; } private validateTransferParams(params: Record): boolean { From c58e5d7557fd5031237726cc0e60705b41aed41c Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 23 Dec 2025 06:41:34 +0100 Subject: [PATCH 2/4] Add exchange minimum amount check for liquidity-limited trades --- .../exchange/services/exchange.service.ts | 2 +- .../actions/base/ccxt-exchange.adapter.ts | 26 +++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/integration/exchange/services/exchange.service.ts b/src/integration/exchange/services/exchange.service.ts index 4f5c251550..b80e5313f9 100644 --- a/src/integration/exchange/services/exchange.service.ts +++ b/src/integration/exchange/services/exchange.service.ts @@ -231,7 +231,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); } 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 00b98ef2d7..87306a9e17 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 @@ -151,11 +151,22 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { tradeAsset, targetAssetEntity.name, ); + + // Check against exchange minimum trade amount (in base currency = targetAsset) + const pair = await this.exchangeService.getPair(tradeAsset, targetAssetEntity.name); + const exchangeMinAmount = await this.exchangeService.getMinTradeAmount(pair); + + if (liquidityAtBestPrice < exchangeMinAmount) { + throw new OrderNotProcessableException( + `${this.exchangeService.name}: liquidity at best price (${liquidityAtBestPrice}) is below exchange minimum (${exchangeMinAmount}) for ${targetAssetEntity.name}`, + ); + } + // Convert liquidity from targetAsset to tradeAsset const liquidityInTradeAsset = Util.floor(liquidityAtBestPrice * bestPrice, 6); this.logger.verbose( - `Liquidity-limited buy: requested ${maxSellAmount} ${tradeAsset}, liquidity at best price ${liquidityAtBestPrice} ${targetAssetEntity.name} (= ${liquidityInTradeAsset} ${tradeAsset}), using min of both`, + `Liquidity-limited buy: requested ${maxSellAmount} ${tradeAsset}, liquidity at best price ${liquidityAtBestPrice} ${targetAssetEntity.name} (= ${liquidityInTradeAsset} ${tradeAsset}), exchange min ${exchangeMinAmount}, using min of both`, ); if (liquidityInTradeAsset < minSellAmount) { @@ -215,10 +226,21 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { // For illiquid markets: limit order size to available liquidity at best price if (liquidityLimited) { const { amount: liquidityAtBestPrice } = await this.exchangeService.getBestBidLiquidity(asset, tradeAsset); + + // Check against exchange minimum trade amount + const pair = await this.exchangeService.getPair(asset, tradeAsset); + const exchangeMinAmount = await this.exchangeService.getMinTradeAmount(pair); + + if (liquidityAtBestPrice < exchangeMinAmount) { + throw new OrderNotProcessableException( + `${this.exchangeService.name}: liquidity at best price (${liquidityAtBestPrice}) is below exchange minimum (${exchangeMinAmount}) for ${asset}`, + ); + } + const limitedAmount = Math.min(amount, liquidityAtBestPrice); this.logger.verbose( - `Liquidity-limited sell: requested ${amount}, liquidity at best price ${liquidityAtBestPrice}, using ${limitedAmount}`, + `Liquidity-limited sell: requested ${amount}, liquidity at best price ${liquidityAtBestPrice}, exchange min ${exchangeMinAmount}, using ${limitedAmount}`, ); if (limitedAmount < order.minAmount) { From 5ae5a17fe84df3ab14988c022facfc94ef3395b4 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 23 Dec 2025 06:53:41 +0100 Subject: [PATCH 3/4] 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 --- .../exchange/services/exchange.service.ts | 15 ++++++-- .../actions/base/ccxt-exchange.adapter.ts | 35 +++++++++++-------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/src/integration/exchange/services/exchange.service.ts b/src/integration/exchange/services/exchange.service.ts index b80e5313f9..da8f89506a 100644 --- a/src/integration/exchange/services/exchange.service.ts +++ b/src/integration/exchange/services/exchange.service.ts @@ -292,14 +292,25 @@ export abstract class ExchangeService extends PricingProvider implements OnModul return Util.roundToValue(price, pricePrecision); } - async getBestBidLiquidity(from: string, to: string): Promise<{ price: number; amount: number }> { + async getBestBidLiquidity( + from: string, + to: string, + minAmount?: number, + ): Promise<{ price: number; amount: number } | undefined> { const { pair, direction } = await this.getTradePair(from, to); const orderBook = await this.callApi((e) => e.fetchOrderBook(pair)); const { price: pricePrecision } = await this.getPrecision(pair); // For selling: we need the best bid (highest buy order) // For buying: we need the best ask (lowest sell order) - const [price, amount] = direction === OrderSide.SELL ? orderBook.bids[0] : orderBook.asks[0]; + const orders = direction === OrderSide.SELL ? orderBook.bids : orderBook.asks; + + // Find first order that meets minimum amount requirement + const validOrder = minAmount ? orders.find(([, amount]) => amount >= minAmount) : orders[0]; + + if (!validOrder) return undefined; + + const [price, amount] = validOrder; return { price: Util.roundToValue(price, pricePrecision), 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 87306a9e17..04d9b91788 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 @@ -146,27 +146,30 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { // For illiquid markets: limit order size to available liquidity at best price if (liquidityLimited) { - // Get liquidity on the ask side (sellers of targetAsset) - const { amount: liquidityAtBestPrice, price: bestPrice } = await this.exchangeService.getBestBidLiquidity( + // Get exchange minimum trade amount (in base currency = targetAsset) + const pair = await this.exchangeService.getPair(tradeAsset, targetAssetEntity.name); + const exchangeMinAmount = await this.exchangeService.getMinTradeAmount(pair); + + // Get liquidity on the ask side (sellers of targetAsset), skipping orders below minimum + const liquidity = await this.exchangeService.getBestBidLiquidity( tradeAsset, targetAssetEntity.name, + exchangeMinAmount, ); - // Check against exchange minimum trade amount (in base currency = targetAsset) - const pair = await this.exchangeService.getPair(tradeAsset, targetAssetEntity.name); - const exchangeMinAmount = await this.exchangeService.getMinTradeAmount(pair); - - if (liquidityAtBestPrice < exchangeMinAmount) { + if (!liquidity) { throw new OrderNotProcessableException( - `${this.exchangeService.name}: liquidity at best price (${liquidityAtBestPrice}) is below exchange minimum (${exchangeMinAmount}) for ${targetAssetEntity.name}`, + `${this.exchangeService.name}: no order in orderbook meets exchange minimum (${exchangeMinAmount}) for ${targetAssetEntity.name}`, ); } + const { amount: liquidityAtBestPrice, price: bestPrice } = liquidity; + // Convert liquidity from targetAsset to tradeAsset const liquidityInTradeAsset = Util.floor(liquidityAtBestPrice * bestPrice, 6); this.logger.verbose( - `Liquidity-limited buy: requested ${maxSellAmount} ${tradeAsset}, liquidity at best price ${liquidityAtBestPrice} ${targetAssetEntity.name} (= ${liquidityInTradeAsset} ${tradeAsset}), exchange min ${exchangeMinAmount}, using min of both`, + `Liquidity-limited buy: requested ${maxSellAmount} ${tradeAsset}, liquidity at valid price ${liquidityAtBestPrice} ${targetAssetEntity.name} (= ${liquidityInTradeAsset} ${tradeAsset}), exchange min ${exchangeMinAmount}, using min of both`, ); if (liquidityInTradeAsset < minSellAmount) { @@ -225,22 +228,24 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { // For illiquid markets: limit order size to available liquidity at best price if (liquidityLimited) { - const { amount: liquidityAtBestPrice } = await this.exchangeService.getBestBidLiquidity(asset, tradeAsset); - - // Check against exchange minimum trade amount + // Get exchange minimum trade amount const pair = await this.exchangeService.getPair(asset, tradeAsset); const exchangeMinAmount = await this.exchangeService.getMinTradeAmount(pair); - if (liquidityAtBestPrice < exchangeMinAmount) { + // Get liquidity on the bid side, skipping orders below minimum + const liquidity = await this.exchangeService.getBestBidLiquidity(asset, tradeAsset, exchangeMinAmount); + + if (!liquidity) { throw new OrderNotProcessableException( - `${this.exchangeService.name}: liquidity at best price (${liquidityAtBestPrice}) is below exchange minimum (${exchangeMinAmount}) for ${asset}`, + `${this.exchangeService.name}: no order in orderbook meets exchange minimum (${exchangeMinAmount}) for ${asset}`, ); } + const { amount: liquidityAtBestPrice } = liquidity; const limitedAmount = Math.min(amount, liquidityAtBestPrice); this.logger.verbose( - `Liquidity-limited sell: requested ${amount}, liquidity at best price ${liquidityAtBestPrice}, exchange min ${exchangeMinAmount}, using ${limitedAmount}`, + `Liquidity-limited sell: requested ${amount}, liquidity at valid price ${liquidityAtBestPrice}, exchange min ${exchangeMinAmount}, using ${limitedAmount}`, ); if (limitedAmount < order.minAmount) { From 20fdb3572f74be4d16f3843691707103cee640bb Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 29 Dec 2025 13:32:02 +0100 Subject: [PATCH 4/4] 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 --- .../actions/base/ccxt-exchange.adapter.ts | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) 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 04d9b91788..76bff0d389 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, liquidityLimited } = 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,7 +141,7 @@ 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); let maxSellAmount = Util.floor(maxAmount * price, 6); @@ -211,12 +213,12 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { } private async sell(order: LiquidityManagementOrder): Promise { - const { tradeAsset, liquidityLimited } = 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) @@ -276,15 +278,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; @@ -523,16 +525,18 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { 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, liquidityLimited }; + return { asset, tradeAsset, minTradeAmount, fullTrade, liquidityLimited, maxPriceDeviation }; } private validateSellParams(params: Record): boolean { @@ -544,13 +548,18 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { } } - private parseSellParams(params: Record): { tradeAsset: string; liquidityLimited: boolean } { + 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, liquidityLimited }; + return { tradeAsset, liquidityLimited, maxPriceDeviation }; } private validateTransferParams(params: Record): boolean {