From 51c7cbf3ca5edd8a68e92280cd455f7ebbd4674f Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 21 May 2026 16:23:15 +0200 Subject: [PATCH 1/4] feat(scrypt): tighten price deviation cap to 0.3% + notify on overshoot Scrypt embeds its commission into the quoted price ("price you see is what you get"), so the spread between Scrypt's executable price and our pricing reference is the only pre-trade signal that the implicit cost is too high. The default maxPriceDeviation of 5% in ScryptAdapter.getAndCheckTradePrice was too loose: on 2026-05-21 a 570'000 EUR BTC/EUR buy on Scrypt cleared at a 0.65% spread vs. our pricing reference, costing ~2'870 EUR more than Kraken would have. The check passed silently because 0.65% << 5%. Changes: - Add Config.scrypt.maxPriceDeviation = 0.003 (0.3%) - ScryptAdapter.getAndCheckTradePrice defaults to that cap - On overshoot: send ErrorMonitoring mail (isLiqMail) before throwing OrderFailedException, debounced 1h per asset pair to avoid spam --- src/config/config.ts | 5 ++ .../adapters/actions/scrypt.adapter.ts | 60 +++++++++++++++++-- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/config/config.ts b/src/config/config.ts index 73bf43e7b6..a81305d2b4 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1265,6 +1265,11 @@ export class Configuration { wsUrl: process.env.SCRYPT_WS_URL, apiKey: process.env.SCRYPT_API_KEY, apiSecret: process.env.SCRYPT_API_SECRET, + // Hard cap on the spread between Scrypt's executable price and our pricing reference. + // Scrypt embeds its fee in the quote ("price you see is what you get"), so a wide + // spread is the only pre-trade signal that the implicit cost is too high. Abort + alert + // when exceeded to avoid silent overpayment like the 2026-05-21 BTC/EUR incident. + maxPriceDeviation: 0.003, }; get evmWallets(): Map { diff --git a/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts b/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts index 207160051c..8bab8b0420 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts @@ -1,4 +1,5 @@ import { Inject, Injectable, forwardRef } from '@nestjs/common'; +import { Config } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { ScryptOrderInfo, ScryptOrderSide, ScryptTransactionStatus } from 'src/integration/exchange/dto/scrypt.dto'; import { TradeChangedException } from 'src/integration/exchange/exceptions/trade-changed.exception'; @@ -10,6 +11,9 @@ import { DfxLogger } from 'src/shared/services/dfx-logger'; import { Util } from 'src/shared/utils/util'; import { BuyCryptoService } from 'src/subdomains/core/buy-crypto/process/services/buy-crypto.service'; import { DexService } from 'src/subdomains/supporting/dex/services/dex.service'; +import { MailContext, MailType } from 'src/subdomains/supporting/notification/enums'; +import { MailRequest } from 'src/subdomains/supporting/notification/interfaces'; +import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; import { PriceCurrency, PriceValidity, @@ -46,6 +50,7 @@ export class ScryptAdapter extends LiquidityActionAdapter { private readonly assetService: AssetService, private readonly ruleRepo: LiquidityManagementRuleRepository, private readonly balanceRepo: LiquidityBalanceRepository, + private readonly notificationService: NotificationService, @Inject(forwardRef(() => BuyCryptoService)) private readonly buyCryptoService: BuyCryptoService, ) { super(LiquidityManagementSystem.SCRYPT); @@ -449,17 +454,62 @@ export class ScryptAdapter extends LiquidityActionAdapter { return side === ScryptOrderSide.BUY ? availableBalance * 0.99 : availableBalance; } - private async getAndCheckTradePrice(from: Asset, to: Asset, maxPriceDeviation = 0.05): Promise { + private async getAndCheckTradePrice( + from: Asset, + to: Asset, + maxPriceDeviation = Config.scrypt.maxPriceDeviation, + ): Promise { const price = await this.scryptService.getCurrentPrice(from.name, to.name); const checkPrice = await this.pricingService.getPrice(from, to, PriceValidity.VALID_ONLY); - if (Math.abs((price - checkPrice.price) / checkPrice.price) > maxPriceDeviation) { - throw new OrderFailedException( - `Trade price out of range: exchange price ${price}, check price ${checkPrice.price}, max deviation ${maxPriceDeviation}`, - ); + const deviation = Math.abs((price - checkPrice.price) / checkPrice.price); + + if (deviation > maxPriceDeviation) { + const message = + `Scrypt ${from.name}/${to.name} price deviation ${(deviation * 100).toFixed(4)}% ` + + `exceeds max ${(maxPriceDeviation * 100).toFixed(4)}% ` + + `(exchange price ${price}, reference ${checkPrice.price}). Trade aborted.`; + + this.logger.error(message); + await this.notifyPriceDeviation(message, from, to, price, checkPrice.price, deviation, maxPriceDeviation); + + throw new OrderFailedException(message); } return price; } + + private async notifyPriceDeviation( + summary: string, + from: Asset, + to: Asset, + price: number, + referencePrice: number, + deviation: number, + maxPriceDeviation: number, + ): Promise { + const mailRequest: MailRequest = { + type: MailType.ERROR_MONITORING, + context: MailContext.LIQUIDITY_MANAGEMENT, + input: { + subject: `Scrypt ${from.name}/${to.name} price deviation too high`, + errors: [ + summary, + `Exchange price (Scrypt): ${price}`, + `Reference price (pricing service): ${referencePrice}`, + `Deviation: ${(deviation * 100).toFixed(4)} % (cap: ${(maxPriceDeviation * 100).toFixed(4)} %)`, + ], + isLiqMail: true, + }, + correlationId: `scrypt-price-deviation-${from.name}-${to.name}`, + options: { debounce: 60 * 60 * 1000, suppressRecurring: true }, + }; + + try { + await this.notificationService.sendMail(mailRequest); + } catch (e) { + this.logger.error('Failed to send Scrypt price deviation notification', e); + } + } } From a7bed369526fe3616c49a855ed9b05ac3568d67f Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 21 May 2026 18:12:17 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(scrypt):=20cap=20LIMIT=20execution=20p?= =?UTF-8?q?rice=20at=20reference=20=C2=B1=20maxPriceDeviation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 (#3736) added a pre-trade reference deviation check. That fires only at order-placement time; if Scrypt's quote drifts during fill, or the periodic edit loop re-prices a resting order, the 0.3% cap is no longer enforced. This change makes the cap hard. The LIMIT price submitted to Scrypt is clamped to a Reference × (1 ± maxPriceDeviation) bound for the lifetime of the order: - ScryptService.sell(from, to, amount, priceCap?) — initial order placement uses min(orderbookPrice, cap) for BUY, max(orderbookPrice, cap) for SELL. - ScryptService.checkTrade(..., priceCap?) — the edit loop applies the same clamp before issuing an OrderCancelReplaceRequest, so resting orders never get re-priced past the cap. - ScryptService.applyPriceCap() — single source of truth for the clamp, static for ease of unit testing. In the adapter: - getAndCheckTradePrice now returns { scryptPrice, priceCap, side } - check{Sell,Buy}Completion recompute priceCap on each tick from the current reference, so the protection adapts to market moves without needing extra DB columns. - computePriceCap helper handles the "to per from" vs "quote per base" conversion (only differs for side=BUY). Trade-off: an order can now legitimately sit unfilled if the venue is priced beyond the cap for an extended period. That is the intended behaviour — better a stalled order than silent overpayment. --- .../exchange/services/scrypt.service.ts | 36 ++++++-- .../adapters/actions/scrypt.adapter.ts | 90 +++++++++++++------ 2 files changed, 93 insertions(+), 33 deletions(-) diff --git a/src/integration/exchange/services/scrypt.service.ts b/src/integration/exchange/services/scrypt.service.ts index 26fbac2c85..1a4f09defc 100644 --- a/src/integration/exchange/services/scrypt.service.ts +++ b/src/integration/exchange/services/scrypt.service.ts @@ -242,11 +242,17 @@ export class ScryptService extends PricingProvider { return side === ScryptOrderSide.BUY ? price : 1 / price; } - async sell(from: string, to: string, amount: number): Promise { + async sell(from: string, to: string, amount: number, priceCap?: number): Promise { const { symbol, side } = await this.getTradePair(from, to); - const price = await this.getOrderBookPrice(symbol, side); + const orderbookPrice = await this.getOrderBookPrice(symbol, side); const sizeIncrement = await this.getSizeIncrement(symbol); + // Hard execution-price cap (Scrypt embeds its commission into the quote, so the + // only way to bound implicit cost is the LIMIT price itself). + // - BUY (we pay quote per base): cap is upper bound, LIMIT = min(orderbook, cap) + // - SELL (we receive quote per base): cap is lower bound, LIMIT = max(orderbook, cap) + const price = ScryptService.applyPriceCap(orderbookPrice, side, priceCap); + // OrderQty must be in base currency // SELL (from=base): orderQty = amount // BUY (from=quote): orderQty = amount / price @@ -256,6 +262,11 @@ export class ScryptService extends PricingProvider { return this.placeAndReturnId(symbol, side, orderQty, price); } + static applyPriceCap(orderbookPrice: number, side: ScryptOrderSide, priceCap?: number): number { + if (priceCap == null) return orderbookPrice; + return side === ScryptOrderSide.BUY ? Math.min(orderbookPrice, priceCap) : Math.max(orderbookPrice, priceCap); + } + async buy(from: string, to: string, amount: number): Promise { const { symbol, side } = await this.getTradePair(from, to); const price = await this.getOrderBookPrice(symbol, side); @@ -323,7 +334,13 @@ export class ScryptService extends PricingProvider { }; } - async checkTrade(clOrdId: string, from: string, to: string, orderCreated?: Date): Promise { + async checkTrade( + clOrdId: string, + from: string, + to: string, + orderCreated?: Date, + priceCap?: number, + ): Promise { const orderInfo = await this.getOrderStatus(clOrdId); if (!orderInfo) { // If the order is older than 1 hour and still not found, it's lost @@ -341,15 +358,17 @@ export class ScryptService extends PricingProvider { switch (orderInfo.status) { case ScryptOrderStatus.NEW: case ScryptOrderStatus.PARTIALLY_FILLED: { + const { side } = await this.getTradePair(from, to); const currentPrice = await this.getTradePrice(from, to); + const cappedPrice = ScryptService.applyPriceCap(currentPrice, side, priceCap); // Use tolerance for float comparison to avoid unnecessary updates due to rounding - const priceChanged = orderInfo.price && Math.abs(currentPrice - orderInfo.price) > 0.000001; + const priceChanged = orderInfo.price && Math.abs(cappedPrice - orderInfo.price) > 0.000001; if (priceChanged) { - this.logger.verbose(`Order ${clOrdId}: price changed ${orderInfo.price} -> ${currentPrice}, updating order`); + this.logger.verbose(`Order ${clOrdId}: price changed ${orderInfo.price} -> ${cappedPrice}, updating order`); try { - const newId = await this.editOrder(clOrdId, from, to, orderInfo.remainingQuantity, currentPrice); + const newId = await this.editOrder(clOrdId, from, to, orderInfo.remainingQuantity, cappedPrice); this.logger.verbose(`Order ${clOrdId} changed to ${newId}`); throw new TradeChangedException(newId); } catch (e) { @@ -364,7 +383,7 @@ export class ScryptService extends PricingProvider { } } } else { - this.logger.verbose(`Order ${clOrdId} open, price is still ${currentPrice}`); + this.logger.verbose(`Order ${clOrdId} open, price is still ${cappedPrice}`); } return false; } @@ -383,7 +402,8 @@ export class ScryptService extends PricingProvider { // Restart order with remaining amount (already in base currency) const { symbol, side } = await this.getTradePair(from, to); - const price = await this.getOrderBookPrice(symbol, side); + const orderbookPrice = await this.getOrderBookPrice(symbol, side); + const price = ScryptService.applyPriceCap(orderbookPrice, side, priceCap); this.logger.verbose(`Order ${clOrdId} cancelled, restarting with remaining ${remaining} (base currency)`); diff --git a/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts b/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts index 8bab8b0420..55d62f703c 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts @@ -136,7 +136,7 @@ export class ScryptAdapter extends LiquidityActionAdapter { const targetAsset = order.pipeline.rule.targetAsset; const tradeAssetEntity = await this.assetService.getAssetByUniqueName(`Scrypt/${tradeAsset}`); - await this.getAndCheckTradePrice(targetAsset, tradeAssetEntity, maxPriceDeviation); + const { priceCap } = await this.getAndCheckTradePrice(targetAsset, tradeAssetEntity, maxPriceDeviation); const availableBalance = await this.scryptService.getAvailableBalance(targetAsset.dexName); const effectiveMax = Math.min(order.maxAmount, availableBalance); @@ -149,7 +149,7 @@ export class ScryptAdapter extends LiquidityActionAdapter { const amount = Util.floor(effectiveMax, 6); - return this.executeSell(order, amount, targetAsset.dexName, tradeAsset); + return this.executeSell(order, amount, targetAsset.dexName, tradeAsset, priceCap); } private async buy(order: LiquidityManagementOrder): Promise { @@ -158,9 +158,13 @@ export class ScryptAdapter extends LiquidityActionAdapter { const targetAssetEntity = order.pipeline.rule.targetAsset; const tradeAssetEntity = await this.assetService.getAssetByUniqueName(`Scrypt/${tradeAsset}`); - const price = await this.getAndCheckTradePrice(tradeAssetEntity, targetAssetEntity, maxPriceDeviation); - const minSellAmount = Util.floor(order.minAmount * price, 6); - const maxSellAmount = Util.floor(order.maxAmount * price, 6); + const { scryptPrice, priceCap } = await this.getAndCheckTradePrice( + tradeAssetEntity, + targetAssetEntity, + maxPriceDeviation, + ); + const minSellAmount = Util.floor(order.minAmount * scryptPrice, 6); + const maxSellAmount = Util.floor(order.maxAmount * scryptPrice, 6); const availableBalance = await this.getAvailableTradeBalance(tradeAsset, targetAssetEntity.dexName); const fiatOrderCap = ['CHF', 'EUR'].includes(tradeAsset) ? 200_000 : Infinity; @@ -179,7 +183,7 @@ export class ScryptAdapter extends LiquidityActionAdapter { order.outputAsset = targetAssetEntity.dexName; try { - return await this.scryptService.sell(tradeAsset, targetAssetEntity.dexName, amount); + return await this.scryptService.sell(tradeAsset, targetAssetEntity.dexName, amount, priceCap); } catch (e) { if (this.isBalanceTooLowError(e)) { throw new OrderNotProcessableException(e.message); @@ -224,11 +228,15 @@ export class ScryptAdapter extends LiquidityActionAdapter { const targetAsset = order.pipeline.rule.targetAsset; const tradeAssetEntity = await this.assetService.getAssetByUniqueName(`Scrypt/${tradeAsset}`); - const price = await this.getAndCheckTradePrice(targetAsset, tradeAssetEntity, maxPriceDeviation); + const { scryptPrice, priceCap } = await this.getAndCheckTradePrice( + targetAsset, + tradeAssetEntity, + maxPriceDeviation, + ); const availableBalance = await this.scryptService.getAvailableBalance(targetAsset.dexName); - // price = targetAsset per tradeAsset (e.g., EUR per BTC) - const sellAmount = Util.floor(deficitAmount * price, 6); + // scryptPrice = targetAsset per tradeAsset (e.g., EUR per BTC) + const sellAmount = Util.floor(deficitAmount * scryptPrice, 6); const amount = Util.floor(Math.min(sellAmount, order.maxAmount, availableBalance), 6); if (amount <= 0) { @@ -237,7 +245,7 @@ export class ScryptAdapter extends LiquidityActionAdapter { ); } - return this.executeSell(order, amount, targetAsset.dexName, tradeAsset); + return this.executeSell(order, amount, targetAsset.dexName, tradeAsset, priceCap); } // --- COMPLETION CHECKS --- // @@ -265,22 +273,33 @@ export class ScryptAdapter extends LiquidityActionAdapter { } private async checkSellCompletion(order: LiquidityManagementOrder): Promise { - const { tradeAsset } = this.parseTradeParams(order.action.paramMap); - const asset = order.pipeline.rule.targetAsset.dexName; + const { tradeAsset, maxPriceDeviation } = this.parseTradeParams(order.action.paramMap); + const targetAsset = order.pipeline.rule.targetAsset; + const tradeAssetEntity = await this.assetService.getAssetByUniqueName(`Scrypt/${tradeAsset}`); + + const priceCap = await this.computePriceCap(targetAsset, tradeAssetEntity, maxPriceDeviation); - return this.checkTradeCompletion(order, asset, tradeAsset); + return this.checkTradeCompletion(order, targetAsset.dexName, tradeAsset, priceCap); } private async checkBuyCompletion(order: LiquidityManagementOrder): Promise { - const { tradeAsset } = this.parseTradeParams(order.action.paramMap); - const asset = order.pipeline.rule.targetAsset.dexName; + const { tradeAsset, maxPriceDeviation } = this.parseTradeParams(order.action.paramMap); + const targetAsset = order.pipeline.rule.targetAsset; + const tradeAssetEntity = await this.assetService.getAssetByUniqueName(`Scrypt/${tradeAsset}`); - return this.checkTradeCompletion(order, tradeAsset, asset); + const priceCap = await this.computePriceCap(tradeAssetEntity, targetAsset, maxPriceDeviation); + + return this.checkTradeCompletion(order, tradeAsset, targetAsset.dexName, priceCap); } - private async checkTradeCompletion(order: LiquidityManagementOrder, from: string, to: string): Promise { + private async checkTradeCompletion( + order: LiquidityManagementOrder, + from: string, + to: string, + priceCap?: number, + ): Promise { try { - const isComplete = await this.scryptService.checkTrade(order.correlationId, from, to, order.created); + const isComplete = await this.scryptService.checkTrade(order.correlationId, from, to, order.created, priceCap); if (isComplete) { order.outputAmount = await this.aggregateTradeOutput(order); @@ -425,13 +444,14 @@ export class ScryptAdapter extends LiquidityActionAdapter { amount: number, fromAsset: string, toAsset: string, + priceCap?: number, ): Promise { order.inputAmount = amount; order.inputAsset = fromAsset; order.outputAsset = toAsset; try { - return await this.scryptService.sell(fromAsset, toAsset, amount); + return await this.scryptService.sell(fromAsset, toAsset, amount, priceCap); } catch (e) { if (this.isBalanceTooLowError(e)) { throw new OrderNotProcessableException(e.message); @@ -458,26 +478,46 @@ export class ScryptAdapter extends LiquidityActionAdapter { from: Asset, to: Asset, maxPriceDeviation = Config.scrypt.maxPriceDeviation, - ): Promise { - const price = await this.scryptService.getCurrentPrice(from.name, to.name); + ): Promise<{ scryptPrice: number; priceCap: number; side: ScryptOrderSide }> { + const { side } = await this.scryptService.getTradePair(from.name, to.name); + const scryptPrice = await this.scryptService.getCurrentPrice(from.name, to.name); const checkPrice = await this.pricingService.getPrice(from, to, PriceValidity.VALID_ONLY); - const deviation = Math.abs((price - checkPrice.price) / checkPrice.price); + const deviation = Math.abs((scryptPrice - checkPrice.price) / checkPrice.price); if (deviation > maxPriceDeviation) { const message = `Scrypt ${from.name}/${to.name} price deviation ${(deviation * 100).toFixed(4)}% ` + `exceeds max ${(maxPriceDeviation * 100).toFixed(4)}% ` + - `(exchange price ${price}, reference ${checkPrice.price}). Trade aborted.`; + `(exchange price ${scryptPrice}, reference ${checkPrice.price}). Trade aborted.`; this.logger.error(message); - await this.notifyPriceDeviation(message, from, to, price, checkPrice.price, deviation, maxPriceDeviation); + await this.notifyPriceDeviation(message, from, to, scryptPrice, checkPrice.price, deviation, maxPriceDeviation); throw new OrderFailedException(message); } - return price; + return { scryptPrice, priceCap: this.toPriceCap(checkPrice.price, side, maxPriceDeviation), side }; + } + + private async computePriceCap( + from: Asset, + to: Asset, + maxPriceDeviation = Config.scrypt.maxPriceDeviation, + ): Promise { + const { side } = await this.scryptService.getTradePair(from.name, to.name); + const checkPrice = await this.pricingService.getPrice(from, to, PriceValidity.VALID_ONLY); + return this.toPriceCap(checkPrice.price, side, maxPriceDeviation); + } + + private toPriceCap(referencePrice: number, side: ScryptOrderSide, maxPriceDeviation: number): number { + // pricingService returns "to per from"; Scrypt LIMIT orders use quote per base. + // For side=BUY (from=quote, to=base) we need the inverse; for side=SELL it matches. + const refQuotePerBase = side === ScryptOrderSide.BUY ? 1 / referencePrice : referencePrice; + return side === ScryptOrderSide.BUY + ? refQuotePerBase * (1 + maxPriceDeviation) + : refQuotePerBase * (1 - maxPriceDeviation); } private async notifyPriceDeviation( From 50f7a9a927f28f91f75935dcb371954e029aeeb9 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 21 May 2026 18:40:35 +0200 Subject: [PATCH 3/4] ci: retrigger workflows after base retarget to develop From d33eb28112fbb40397a7fa4ed18735c325ee5800 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 21 May 2026 18:55:32 +0200 Subject: [PATCH 4/4] fix(scrypt): correct toPriceCap inversion + notification debounce + cap validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review caught three correctness bugs in the price-cap implementation: 1. **toPriceCap inversion direction was reversed.** pricingService returns Price.price = source/target (= from-per-to), verified via Price.convert(amount) = amount/price. Scrypt LIMIT orders are quote-per-base. The two coincide for side=BUY (from=quote, to=base) and require inversion for side=SELL (from=base, to=quote). The original code inverted on BUY, producing a microscopic cap that would have rejected every Scrypt BUY trade once the cap was hit (or filled catastrophically against any standing book). Symmetry test added. 2. **Notification debounce was a permanent-mute.** `suppressRecurring: true` combined with `debounce: 1h` made Notification.isSuppressed return true forever for the same correlationId+context (the two conditions are OR'd in notification.entity.ts:43-47). Drop suppressRecurring so the 1h debounce works as documented in the PR. 3. **applyPriceCap accepted invalid caps.** Only `priceCap == null` short-circuited; 0, negative, NaN, ±Infinity would pass through and produce nonsensical LIMIT prices. Add defensive Number.isFinite + positivity check. Plus toPriceCap promoted to static-public to make it directly unit-testable. Tests added: applyPriceCap (BUY/SELL × cap variants, including all invalid forms) and toPriceCap (BUY/SELL with realistic refs, plus a symmetry sanity check that locks the inversion direction in). --- .../services/__tests__/scrypt.service.spec.ts | 49 +++++++++++++++++ .../exchange/services/scrypt.service.ts | 2 +- .../actions/__tests__/scrypt.adapter.spec.ts | 53 +++++++++++++++++++ .../adapters/actions/scrypt.adapter.ts | 18 ++++--- 4 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 src/integration/exchange/services/__tests__/scrypt.service.spec.ts create mode 100644 src/subdomains/core/liquidity-management/adapters/actions/__tests__/scrypt.adapter.spec.ts diff --git a/src/integration/exchange/services/__tests__/scrypt.service.spec.ts b/src/integration/exchange/services/__tests__/scrypt.service.spec.ts new file mode 100644 index 0000000000..b00dcbcb4a --- /dev/null +++ b/src/integration/exchange/services/__tests__/scrypt.service.spec.ts @@ -0,0 +1,49 @@ +import { ScryptOrderSide } from '../../dto/scrypt.dto'; +import { ScryptService } from '../scrypt.service'; + +describe('ScryptService.applyPriceCap', () => { + const orderbook = 66_500; + + describe('priceCap unset / invalid', () => { + it.each([ + ['undefined', undefined], + ['null', null], + ['zero', 0], + ['negative', -100], + ['NaN', Number.NaN], + ['Infinity', Number.POSITIVE_INFINITY], + ['-Infinity', Number.NEGATIVE_INFINITY], + ])('returns orderbookPrice when priceCap is %s', (_label, cap) => { + expect(ScryptService.applyPriceCap(orderbook, ScryptOrderSide.BUY, cap as number | undefined)).toBe(orderbook); + expect(ScryptService.applyPriceCap(orderbook, ScryptOrderSide.SELL, cap as number | undefined)).toBe(orderbook); + }); + }); + + describe('side=BUY (cap = upper bound)', () => { + it('uses orderbook price when below cap (cheaper market)', () => { + expect(ScryptService.applyPriceCap(66_500, ScryptOrderSide.BUY, 66_700)).toBe(66_500); + }); + + it('uses cap when orderbook is above cap (clamp)', () => { + expect(ScryptService.applyPriceCap(66_900, ScryptOrderSide.BUY, 66_700)).toBe(66_700); + }); + + it('returns either when equal', () => { + expect(ScryptService.applyPriceCap(66_700, ScryptOrderSide.BUY, 66_700)).toBe(66_700); + }); + }); + + describe('side=SELL (cap = lower bound)', () => { + it('uses orderbook price when above cap (better market)', () => { + expect(ScryptService.applyPriceCap(66_500, ScryptOrderSide.SELL, 66_300)).toBe(66_500); + }); + + it('uses cap when orderbook is below cap (clamp)', () => { + expect(ScryptService.applyPriceCap(66_100, ScryptOrderSide.SELL, 66_300)).toBe(66_300); + }); + + it('returns either when equal', () => { + expect(ScryptService.applyPriceCap(66_300, ScryptOrderSide.SELL, 66_300)).toBe(66_300); + }); + }); +}); diff --git a/src/integration/exchange/services/scrypt.service.ts b/src/integration/exchange/services/scrypt.service.ts index 1a4f09defc..280c85bf12 100644 --- a/src/integration/exchange/services/scrypt.service.ts +++ b/src/integration/exchange/services/scrypt.service.ts @@ -263,7 +263,7 @@ export class ScryptService extends PricingProvider { } static applyPriceCap(orderbookPrice: number, side: ScryptOrderSide, priceCap?: number): number { - if (priceCap == null) return orderbookPrice; + if (priceCap == null || !Number.isFinite(priceCap) || priceCap <= 0) return orderbookPrice; return side === ScryptOrderSide.BUY ? Math.min(orderbookPrice, priceCap) : Math.max(orderbookPrice, priceCap); } diff --git a/src/subdomains/core/liquidity-management/adapters/actions/__tests__/scrypt.adapter.spec.ts b/src/subdomains/core/liquidity-management/adapters/actions/__tests__/scrypt.adapter.spec.ts new file mode 100644 index 0000000000..76b1648439 --- /dev/null +++ b/src/subdomains/core/liquidity-management/adapters/actions/__tests__/scrypt.adapter.spec.ts @@ -0,0 +1,53 @@ +import { ScryptOrderSide } from 'src/integration/exchange/dto/scrypt.dto'; +import { ScryptAdapter } from '../scrypt.adapter'; + +describe('ScryptAdapter.toPriceCap', () => { + const maxDev = 0.003; + + // Anchor: at 2026-05-21 ~13:44 UTC, Kraken VWAP BTC/EUR was ~66'218 EUR/BTC. + // The pricingService convention is Price.price = source/target (= from/to), + // verified via Price.convert(amount) = amount/price. + // Scrypt LIMIT orders express price as quote-per-base, so: + // pricingService.getPrice(EUR, BTC).price = EUR-per-BTC (high number, e.g. 66_500) + // pricingService.getPrice(BTC, EUR).price = BTC-per-EUR (small number, e.g. 1.5e-5) + + describe('side=BUY (e.g. sellIfDeficit BTC: from=EUR=quote, to=BTC=base)', () => { + it('treats from-per-to reference directly as quote-per-base and returns upper bound', () => { + // ref 66'500 EUR/BTC → cap = 66'500 × 1.003 = 66'699.5 + expect(ScryptAdapter.toPriceCap(66_500, ScryptOrderSide.BUY, maxDev)).toBeCloseTo(66_699.5, 4); + }); + + it('cap is strictly greater than reference for BUY', () => { + const cap = ScryptAdapter.toPriceCap(66_500, ScryptOrderSide.BUY, maxDev); + expect(cap).toBeGreaterThan(66_500); + }); + }); + + describe('side=SELL (e.g. selling BTC for EUR: from=BTC=base, to=EUR=quote)', () => { + it('inverts from-per-to reference into quote-per-base and returns lower bound', () => { + // ref 1.5e-5 BTC/EUR → refQuotePerBase = 1 / 1.5e-5 = 66_666.67 → cap = ×0.997 = 66'466.67 + expect(ScryptAdapter.toPriceCap(1.5e-5, ScryptOrderSide.SELL, maxDev)).toBeCloseTo(66_466.67, 1); + }); + + it('cap is strictly less than the inverted reference for SELL', () => { + const ref = 1.5e-5; + const cap = ScryptAdapter.toPriceCap(ref, ScryptOrderSide.SELL, maxDev); + expect(cap).toBeLessThan(1 / ref); + }); + }); + + describe('symmetry sanity', () => { + it('BUY cap on (EUR, BTC) ≈ inverse of SELL cap on (BTC, EUR) within 1.6× the deviation window', () => { + // Refs are exact inverses of each other (66_500 ↔ 1/66_500) + const buyRef = 66_500; + const sellRef = 1 / buyRef; + + const buyCap = ScryptAdapter.toPriceCap(buyRef, ScryptOrderSide.BUY, maxDev); // 66_500 × 1.003 + const sellCap = ScryptAdapter.toPriceCap(sellRef, ScryptOrderSide.SELL, maxDev); // 66_500 × 0.997 + + // Both caps are in EUR/BTC (quote-per-base). They differ by 2×maxDev around the mid. + const ratio = buyCap / sellCap; + expect(ratio).toBeCloseTo((1 + maxDev) / (1 - maxDev), 5); + }); + }); +}); diff --git a/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts b/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts index 55d62f703c..cd0702fd70 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts @@ -498,7 +498,7 @@ export class ScryptAdapter extends LiquidityActionAdapter { throw new OrderFailedException(message); } - return { scryptPrice, priceCap: this.toPriceCap(checkPrice.price, side, maxPriceDeviation), side }; + return { scryptPrice, priceCap: ScryptAdapter.toPriceCap(checkPrice.price, side, maxPriceDeviation), side }; } private async computePriceCap( @@ -508,13 +508,17 @@ export class ScryptAdapter extends LiquidityActionAdapter { ): Promise { const { side } = await this.scryptService.getTradePair(from.name, to.name); const checkPrice = await this.pricingService.getPrice(from, to, PriceValidity.VALID_ONLY); - return this.toPriceCap(checkPrice.price, side, maxPriceDeviation); + return ScryptAdapter.toPriceCap(checkPrice.price, side, maxPriceDeviation); } - private toPriceCap(referencePrice: number, side: ScryptOrderSide, maxPriceDeviation: number): number { - // pricingService returns "to per from"; Scrypt LIMIT orders use quote per base. - // For side=BUY (from=quote, to=base) we need the inverse; for side=SELL it matches. - const refQuotePerBase = side === ScryptOrderSide.BUY ? 1 / referencePrice : referencePrice; + static toPriceCap(referencePrice: number, side: ScryptOrderSide, maxPriceDeviation: number): number { + // pricingService.getPrice(from, to) returns Price.price = source/target = from-per-to + // (verified via Price.convert(amount) = amount / price, see Price entity). + // Scrypt LIMIT orders use quote-per-base. + // + // side=BUY (from=quote, to=base): from-per-to = quote-per-base → no inversion + // side=SELL (from=base, to=quote): from-per-to = base-per-quote → invert + const refQuotePerBase = side === ScryptOrderSide.SELL ? 1 / referencePrice : referencePrice; return side === ScryptOrderSide.BUY ? refQuotePerBase * (1 + maxPriceDeviation) : refQuotePerBase * (1 - maxPriceDeviation); @@ -543,7 +547,7 @@ export class ScryptAdapter extends LiquidityActionAdapter { isLiqMail: true, }, correlationId: `scrypt-price-deviation-${from.name}-${to.name}`, - options: { debounce: 60 * 60 * 1000, suppressRecurring: true }, + options: { debounce: 60 * 60 * 1000 }, }; try {