src/orders/review.test.ts
v0.3.0 · 14.5 KB
// Money-safety invariants and regression tests found in the independent review of the orders module.
import { describe, expect, it } from 'vitest';
import { AssetRegistryError, type AssetInfo } from '../assets/index.js';
import { isValidPrice, isValidSize } from '../format/index.js';
import type { InfoRequester } from '../transport/types.js';
import { buildOrder, type BuildOrderResult, type OrderIntentInput, type PlaceableOrder } from './build.js';
import { cancelOrders } from './cancel.js';
import { closePosition, marketClose } from './close.js';
import { HlOrderError } from './errors.js';
import { placeOrders } from './place.js';
import type { CancelExchange, OrderActionParams, OrderExchange } from './types.js';
function asset(coin: string, assetId: number, szDecimals: number, extra: Partial<AssetInfo> = {}): AssetInfo {
const dex = coin.includes(':') ? (coin.split(':')[0] as string) : '';
return {
coin,
assetId,
index: assetId % 10000,
szDecimals,
maxPxDecimals: Math.max(0, 6 - szDecimals),
maxLeverage: 10,
onlyIsolated: false,
isDelisted: false,
dex,
dexIndex: dex ? 1 : 0,
market: 'perp',
...extra,
};
}
const ASSETS: Record<string, AssetInfo> = {
BTC: asset('BTC', 0, 5),
HYPE: asset('HYPE', 159, 2),
XRP: asset('XRP', 25, 0),
SZ1: asset('SZ1', 7, 1),
SZ3: asset('SZ3', 8, 3),
SZ4: asset('SZ4', 9, 4),
'xyz:TSLA': asset('xyz:TSLA', 110001, 2),
'PURR/USDC': asset('PURR/USDC', 10000, 0, { market: 'spot', maxPxDecimals: 8, dexIndex: null }),
};
const registry = {
async resolve(coin: string): Promise<AssetInfo> {
const a = ASSETS[coin];
if (!a) throw new AssetRegistryError(coin, 'unlisted', 'not_in_universe');
return a;
},
};
const USER = `0x${'0'.repeat(40)}`;
const named = (name: string, message: string, extra: object = {}) => Object.assign(new Error(message), { name }, extra);
const apiError = (response: unknown) => named('ApiRequestError', 'api', { response });
const httpError = (status: number) => named('HttpRequestError', `${status}`, { response: { status } });
const ok = (statuses: unknown[]) => ({ status: 'ok', response: { type: 'order', data: { statuses } } });
function exchange(handler: (params: OrderActionParams) => unknown) {
const calls: OrderActionParams[] = [];
const ex: OrderExchange = {
order(params) {
calls.push(params);
return Promise.resolve().then(() => handler(params));
},
};
return { ex, calls };
}
function placed(r: BuildOrderResult): PlaceableOrder {
if (r.action !== 'place') throw new Error(`expected place, got ${r.action} ${r.reason}`);
return r;
}
/** Deterministic pseudo-random generator (no flaky tests). */
function lcg(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 2 ** 32;
};
}
describe('money invariants over many prices and lot sizes', () => {
const coins = ['BTC', 'HYPE', 'XRP', 'SZ1', 'SZ3', 'SZ4', 'xyz:TSLA'];
it('market orders: correct asset id, side and r; price on the grid and never narrower than the slippage', async () => {
const rnd = lcg(42);
let checked = 0;
for (let n = 0; n < 400; n++) {
const coin = coins[n % coins.length] as string;
const a = ASSETS[coin] as AssetInfo;
// Mids from 0.0001 to ~200000 with float noise.
const mid = Number((10 ** (rnd() * 9 - 4)).toPrecision(8));
const side = rnd() < 0.5 ? 'buy' : 'sell';
const reduceOnly = rnd() < 0.5;
const slippage = 0.005 + rnd() * 0.05;
const size = (50 + rnd() * 500) / mid;
let r: BuildOrderResult;
try {
r = await buildOrder(registry, { coin, side, size, reduceOnly, market: { slippage, mid } });
} catch {
continue; // a price that rounds to 0 on this grid throws; it is never sent
}
if (r.action !== 'place') continue;
checked++;
const w = r.wire;
expect(w.a).toBe(a.assetId);
expect(w.b).toBe(side === 'buy');
expect(w.r).toBe(reduceOnly);
expect(w.t).toEqual({ limit: { tif: 'Ioc' } });
expect(isValidPrice(w.p, a)).toBe(true);
expect(isValidSize(w.s, a.szDecimals)).toBe(true);
const applied = reduceOnly ? Math.max(slippage, 0.1) : slippage;
const px = Number(w.p);
if (side === 'buy') expect(px).toBeGreaterThanOrEqual(mid * (1 + applied) * (1 - 1e-12));
else expect(px).toBeLessThanOrEqual(mid * (1 - applied) * (1 + 1e-12));
expect(px * Number(w.s)).toBeGreaterThanOrEqual(10 - 1e-9);
}
expect(checked).toBeGreaterThan(300);
});
it('passive limit prices never get worse than requested and stay on the grid', async () => {
const rnd = lcg(7);
for (let n = 0; n < 300; n++) {
const coin = coins[n % coins.length] as string;
const a = ASSETS[coin] as AssetInfo;
const price = Number((10 ** (rnd() * 6 - 1)).toPrecision(9));
const side = rnd() < 0.5 ? 'B' : 'A';
const r = await buildOrder(registry, { coin, side, size: Math.max(1, 1000 / price), price });
const o = placed(r);
expect(isValidPrice(o.px, a)).toBe(true);
if (side === 'B') expect(Number(o.px)).toBeLessThanOrEqual(price);
else expect(Number(o.px)).toBeGreaterThanOrEqual(price);
expect(o.wire.b).toBe(side === 'B');
}
});
});
describe('review fixes: buildOrder', () => {
it("'exact' also refuses an off-grid Alo price clamped against the book and an off-grid trigger price", async () => {
await expect(
buildOrder(registry, { coin: 'HYPE', side: 'buy', size: 1, price: 83.20512, tif: 'Alo', book: { bestAsk: 90 } }, { priceRounding: 'exact' }),
).rejects.toThrow(/not on the price grid/);
await expect(
buildOrder(
registry,
{ coin: 'HYPE', side: 'sell', size: 1, price: 90, reduceOnly: true, trigger: { triggerPx: 95.12345, tpsl: 'sl' } },
{ priceRounding: 'exact' },
),
).rejects.toThrow(/Trigger price/);
// On-grid values still pass unchanged.
const o = placed(
await buildOrder(registry, { coin: 'HYPE', side: 'buy', size: 1, price: '83.2', tif: 'Alo', book: { bestAsk: 90 } }, { priceRounding: 'exact' }),
);
expect(o.px).toBe('83.2');
});
it('a trigger order must state reduceOnly explicitly (a protective trigger without r:true opens a position)', async () => {
const err = await buildOrder(registry, { coin: 'HYPE', side: 'sell', size: 1, price: 90, trigger: { triggerPx: 95, tpsl: 'sl' } }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(HlOrderError);
expect((err as HlOrderError).code).toBe('INVALID_ARGUMENT');
const exit = placed(await buildOrder(registry, { coin: 'HYPE', side: 'sell', size: 1, price: 90, reduceOnly: true, trigger: { triggerPx: 95, tpsl: 'sl' } }));
expect(exit.wire.r).toBe(true);
const entry = placed(await buildOrder(registry, { coin: 'HYPE', side: 'buy', size: 1, price: 110, reduceOnly: false, trigger: { triggerPx: 105, tpsl: 'tp' } }));
expect(entry.wire.r).toBe(false);
const viaIntent = placed(
await buildOrder(registry, { coin: 'HYPE', side: 'sell', size: 1, market: { slippage: 0.02 }, sizeIntent: 'fullClose', trigger: { triggerPx: 95, tpsl: 'sl' } }),
);
expect(viaIntent.wire.r).toBe(true);
});
it('a partial reduce is never rounded up, a full close never gated (KB decision table at the order price)', async () => {
// partial RO 0.2 @ szDecimals 0 -> skip; full close 0.2 -> 1 lot.
const partial = await buildOrder(registry, { coin: 'XRP', side: 'sell', size: 0.2, reduceOnly: true, market: { slippage: 0.1, mid: 50 } });
expect(partial).toMatchObject({ action: 'skip', reason: 'size_rounds_to_zero' });
const full = placed(await buildOrder(registry, { coin: 'XRP', side: 'sell', size: 0.2, sizeIntent: 'fullClose', market: { slippage: 0.1, mid: 50 } }));
expect(full.sz).toBe('1');
expect(full.wire.r).toBe(true);
});
});
describe('review fixes: placeOrders retry advice', () => {
it('a one-order batch the SDK threw on with a permanent rejection: outcome rejected, give-up (not reconcile)', async () => {
const { ex } = exchange(() => {
throw apiError(ok([{ error: 'Order must have minimum value of $10.' }]));
});
const r = await placeOrders(ex, registry, [{ coin: 'HYPE', side: 'buy', size: 1, price: 50 }]);
expect(r.orders[0]?.outcome).toMatchObject({ status: 'rejected', kind: 'minNotional' });
expect(r.batches[0]).toMatchObject({ outcome: 'rejected', retry: 'give-up', needsReconcile: false });
expect(r.needsReconcile).toBe(false);
});
it('a rejected batch with a retryable kind advises retry; a mixed batch keeps reconcile', async () => {
const limited = exchange(() => {
throw apiError(ok([{ error: 'Too many requests, please wait and retry' }]));
});
const r1 = await placeOrders(limited.ex, registry, [{ coin: 'HYPE', side: 'buy', size: 1, price: 50 }]);
expect(r1.batches[0]?.outcome).toBe('rejected');
expect(r1.batches[0]?.retry).toBe('retry');
const mixed = exchange(() => {
throw apiError(ok([{ filled: { oid: 5, totalSz: '1', avgPx: '50' } }, { error: 'Order has invalid price.' }, { future: 1 }]));
});
const r2 = await placeOrders(mixed.ex, registry, [
{ coin: 'HYPE', side: 'buy', size: 1, price: 50 },
{ coin: 'HYPE', side: 'buy', size: 1, price: 51 },
{ coin: 'HYPE', side: 'buy', size: 1, price: 52 },
]);
expect(r2.placedOids).toEqual([5]);
expect(r2.batches[0]).toMatchObject({ outcome: 'partial', retry: 'reconcile', needsReconcile: true });
expect(r2.reconcileCloids).toEqual([r2.orders[2]?.cloid]);
});
it('5xx on a batch mixing a full close and an open is not idempotent: reconcile, never blind retry', async () => {
const { ex } = exchange(() => {
throw httpError(502);
});
const r = await placeOrders(ex, registry, [
{ coin: 'HYPE', side: 'sell', size: 1, sizeIntent: 'fullClose', market: { slippage: 0.1, mid: 100 } },
{ coin: 'HYPE', side: 'buy', size: 1, price: 50 },
]);
expect(r.batches[0]).toMatchObject({ outcome: 'unknown', retry: 'reconcile', needsReconcile: true });
expect(r.reconcileCloids).toHaveLength(2);
});
});
describe('review fixes: closes', () => {
it('a dust rejection thrown by the SDK stops the loop: status dust, retry give-up', async () => {
const { ex } = exchange(() => {
throw apiError(ok([{ error: 'Order must have minimum value of $10.' }]));
});
const r = await marketClose(ex, registry, { coin: 'HYPE', position: { side: 'long', size: '0.05' }, mid: 100 });
expect(r).toMatchObject({ status: 'dust', retry: 'give-up', filledSz: '0', remainingSz: '0.05' });
});
it('closes a short with a buy and a long with a sell, always reduceOnly, never with a builder fee', async () => {
const { ex, calls } = exchange(() => ok([{ filled: { oid: 1, totalSz: '1', avgPx: '100' } }]));
await marketClose(ex, registry, { coin: 'HYPE', position: { side: 'short', size: 1 }, mid: 100 }, { builder: { b: USER, f: 10 } } as never);
await marketClose(ex, registry, { coin: 'HYPE', position: { side: 'long', size: 1 }, mid: 100 });
expect(calls.map((c) => [c.orders[0]?.b, c.orders[0]?.r, c.builder])).toEqual([
[true, true, undefined],
[false, true, undefined],
]);
});
it('refuses a negative (signed szi) size that contradicts side long', async () => {
const { ex, calls } = exchange(() => ok([]));
await expect(marketClose(ex, registry, { coin: 'HYPE', position: { side: 'long', size: '-2' }, mid: 100 })).rejects.toThrow(/contradicts/);
expect(calls).toHaveLength(0);
// A signed short size with side short is fine.
const good = exchange(() => ok([{ filled: { oid: 1, totalSz: '2', avgPx: '100' } }]));
const r = await marketClose(good.ex, registry, { coin: 'HYPE', position: { side: 'short', size: '-2' }, mid: 100 });
expect(r.status).toBe('filled');
expect(good.calls[0]?.orders[0]?.b).toBe(true);
});
it('closePosition refuses spot instead of reporting a safe no_position', async () => {
const { ex, calls } = exchange(() => ok([]));
const info = (async () => ({ assetPositions: [] })) as InfoRequester;
await expect(closePosition(ex, info, registry, { user: USER, coin: 'PURR/USDC' })).rejects.toThrow(/perp markets only/);
expect(calls).toHaveLength(0);
});
});
describe('review fixes: cancelOrders', () => {
function stub(): { ex: CancelExchange; calls: unknown[] } {
const calls: unknown[] = [];
const ex: CancelExchange = {
cancel(params) {
calls.push(params);
return Promise.resolve({ status: 'ok', response: { type: 'cancel', data: { statuses: params.cancels.map(() => 'success') } } });
},
cancelByCloid(params) {
calls.push(params);
return Promise.resolve({ status: 'ok', response: { type: 'cancel', data: { statuses: params.cancels.map(() => 'success') } } });
},
};
return { ex, calls };
}
it('an unresolvable coin (registry unavailable) is not sent and keeps needsReconcile: the order may still rest', async () => {
const flaky = {
async resolve(coin: string): Promise<AssetInfo> {
if (coin === 'HYPE') return ASSETS.HYPE as AssetInfo;
throw new AssetRegistryError(coin, 'unknown', 'load_failed');
},
};
const { ex, calls } = stub();
const r = await cancelOrders(ex, flaky, [
{ coin: 'xyz:TSLA', oid: 9 },
{ coin: 'HYPE', oid: 10 },
]);
expect(r.items[0]).toMatchObject({ oid: 9, confirmed: false, outcome: { status: 'not_sent', retryable: true } });
expect(r.items[1]).toMatchObject({ oid: 10, confirmed: true });
expect(r).toMatchObject({ allConfirmed: false, needsReconcile: true });
expect(calls).toHaveLength(1);
});
it('a plain transport error from the registry is treated the same way', async () => {
const broken = {
async resolve(): Promise<AssetInfo> {
throw new Error('fetch failed');
},
};
const { ex, calls } = stub();
const r = await cancelOrders(ex, broken, [{ coin: 'HYPE', cloid: `0x${'ab'.repeat(16)}` }]);
expect(r.items[0]).toMatchObject({ cloid: `0x${'ab'.repeat(16)}`, outcome: { status: 'not_sent' } });
expect(r.needsReconcile).toBe(true);
expect(calls).toHaveLength(0);
});
it('a coin the registry proves unlisted stays invalid_target without reconcile', async () => {
const { ex } = stub();
const r = await cancelOrders(ex, registry, [{ coin: 'NOPE', oid: 1 }]);
expect(r.items[0]?.outcome.status).toBe('invalid_target');
expect(r.needsReconcile).toBe(false);
});
});
// Keep the intent type referenced for editors that strip unused imports.
export type _Intent = OrderIntentInput;