Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
36 changes: 28 additions & 8 deletions src/integration/exchange/services/scrypt.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
async sell(from: string, to: string, amount: number, priceCap?: number): Promise<string> {
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
Expand All @@ -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 || !Number.isFinite(priceCap) || priceCap <= 0) return orderbookPrice;
return side === ScryptOrderSide.BUY ? Math.min(orderbookPrice, priceCap) : Math.max(orderbookPrice, priceCap);
}

async buy(from: string, to: string, amount: number): Promise<string> {
const { symbol, side } = await this.getTradePair(from, to);
const price = await this.getOrderBookPrice(symbol, side);
Expand Down Expand Up @@ -323,7 +334,13 @@ export class ScryptService extends PricingProvider {
};
}

async checkTrade(clOrdId: string, from: string, to: string, orderCreated?: Date): Promise<boolean> {
async checkTrade(
clOrdId: string,
from: string,
to: string,
orderCreated?: Date,
priceCap?: number,
): Promise<boolean> {
const orderInfo = await this.getOrderStatus(clOrdId);
if (!orderInfo) {
// If the order is older than 1 hour and still not found, it's lost
Expand All @@ -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) {
Expand All @@ -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;
}
Expand All @@ -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)`);

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading