src/orders/place.test.ts
v0.3.0 · 16.5 KB
import { describe, expect, it } from 'vitest';
import { AssetRegistryError, type AssetInfo } from '../assets/index.js';
import type { InfoRequest, InfoRequester } from '../transport/types.js';
import type { OrderIntentInput } from './build.js';
import { HlOrderError } from './errors.js';
import { mayBeLive, placeOrders, type OrderOutcome, type PlaceOrdersResult } from './place.js';
import type { ExchangeCallOptions, OrderActionParams, OrderExchange } from './types.js';
function asset(coin: string, assetId: number, szDecimals: number): 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',
};
}
const ASSETS: Record<string, AssetInfo> = {
BTC: asset('BTC', 0, 5),
HYPE: asset('HYPE', 159, 2),
XRP: asset('XRP', 25, 0),
'xyz:TSLA': asset('xyz:TSLA', 110001, 2),
};
const registry = {
async resolve(coin: string): Promise<AssetInfo> {
const a = ASSETS[coin];
if (!a) throw new AssetRegistryError(coin, 'unlisted', 'not_in_universe');
return a;
},
};
type Call = { params: OrderActionParams; opts: ExchangeCallOptions | undefined };
/** Exchange stub: `handler` returns the body or throws (asynchronously, like the SDK after validation). */
function exchange(handler: (params: OrderActionParams, n: number) => unknown) {
const calls: Call[] = [];
const ex: OrderExchange = {
order(params, opts) {
calls.push({ params, opts });
const n = calls.length;
return Promise.resolve().then(() => handler(params, n));
},
};
return { ex, calls };
}
const named = (name: string, message: string, extra: object = {}) => Object.assign(new Error(message), { name }, extra);
const apiError = (response: unknown, message = 'api') => named('ApiRequestError', message, { response });
const httpError = (status: number, message: string) => named('HttpRequestError', message, { response: { status } });
const ok = (statuses: unknown[]) => ({ status: 'ok', response: { type: 'order', data: { statuses } } });
const LIMIT_BUY: OrderIntentInput = { coin: 'HYPE', side: 'buy', size: 1, price: 50 };
const IOC_BUY: OrderIntentInput = { coin: 'HYPE', side: 'buy', size: 2, market: { slippage: 0.01, mid: 100 } };
const RO_SELL: OrderIntentInput = { coin: 'HYPE', side: 'sell', size: 1, price: 60, reduceOnly: true };
const FULL_CLOSE: OrderIntentInput = { coin: 'HYPE', side: 'sell', size: 1, market: { slippage: 0.1, mid: 100 }, sizeIntent: 'fullClose' };
function outcome(r: PlaceOrdersResult, i: number): OrderOutcome {
const o = r.orders[i];
if (!o) throw new Error(`no order ${i}`);
return o.outcome;
}
describe('placeOrders: confirmed batch', () => {
it('maps resting and a partial IoC fill; the IoC remainder is returned', async () => {
const { ex, calls } = exchange(() => ok([{ resting: { oid: 11 } }, { filled: { oid: 12, totalSz: '0.5', avgPx: '100.5' } }]));
const r = await placeOrders(ex, registry, [LIMIT_BUY, IOC_BUY]);
expect(calls).toHaveLength(1);
expect(calls[0]?.params.grouping).toBe('na');
expect(calls[0]?.params.orders.map((o) => [o.p, o.s, o.t])).toEqual([
['50', '1', { limit: { tif: 'Gtc' } }],
['101', '2', { limit: { tif: 'Ioc' } }],
]);
expect(calls[0]?.params).not.toHaveProperty('builder');
expect(calls[0]?.opts).toBeUndefined();
expect(outcome(r, 0)).toEqual({ status: 'resting', oid: 11, cloid: r.orders[0]?.cloid });
expect(outcome(r, 1)).toMatchObject({ status: 'filled', oid: 12, totalSz: '0.5', fullyFilled: false, remainingSz: '1.5' });
expect(r).toMatchObject({ sent: true, needsReconcile: false, reconcileCloids: [], placedOids: [11, 12] });
expect(r.batches[0]).toMatchObject({ outcome: 'ok', retry: null, needsReconcile: false, indices: [0, 1] });
});
it('a fill within one lot counts as complete', async () => {
const { ex } = exchange(() => ok([{ filled: { oid: 1, totalSz: '1.99', avgPx: '100' } }]));
const r = await placeOrders(ex, registry, [IOC_BUY]);
expect(outcome(r, 0)).toMatchObject({ fullyFilled: true, remainingSz: '0.01' });
});
it('string statuses without oid are accepted and may be live', async () => {
const { ex } = exchange(() => ok(['waitingForTrigger']));
const r = await placeOrders(ex, registry, [
{ coin: 'HYPE', side: 'sell', size: 1, reduceOnly: true, price: 90, trigger: { triggerPx: 95, tpsl: 'sl' } },
]);
expect(outcome(r, 0)).toEqual({ status: 'accepted', detail: 'waitingForTrigger' });
expect(mayBeLive(outcome(r, 0))).toBe(true);
expect(r.needsReconcile).toBe(false);
});
it('an overfill is not trusted', async () => {
const { ex } = exchange(() => ok([{ filled: { oid: 1, totalSz: '5', avgPx: '100' } }]));
const r = await placeOrders(ex, registry, [LIMIT_BUY]);
expect(outcome(r, 0)).toEqual({ status: 'unconfirmed', reason: 'overfill' });
expect(r.needsReconcile).toBe(true);
expect(r.reconcileCloids).toEqual([r.orders[0]?.cloid]);
});
});
describe('placeOrders: exchange errors', () => {
it('partial batch: the SDK threw, but the placed neighbour is kept', async () => {
const { ex } = exchange(() => {
throw apiError(ok([{ resting: { oid: 21 } }, { error: 'Order must have minimum value of $10.' }]), 'order 1: Order must have minimum value of $10.');
});
const r = await placeOrders(ex, registry, [LIMIT_BUY, IOC_BUY]);
expect(outcome(r, 0)).toMatchObject({ status: 'resting', oid: 21 });
expect(outcome(r, 1)).toMatchObject({ status: 'rejected', kind: 'minNotional', disposition: 'permanent' });
expect(r.placedOids).toEqual([21]);
expect(r.batches[0]).toMatchObject({ outcome: 'partial', retry: 'reconcile', needsReconcile: false });
expect(r.needsReconcile).toBe(false);
});
it('whole-action rejection: every order rejected, nothing to reconcile', async () => {
const { ex } = exchange(() => {
throw apiError({ status: 'err', response: 'Insufficient margin to place order.' });
});
const r = await placeOrders(ex, registry, [LIMIT_BUY, IOC_BUY]);
expect(r.orders.map((o) => o.outcome)).toEqual([
expect.objectContaining({ status: 'rejected', kind: 'insufficientMargin' }),
expect.objectContaining({ status: 'rejected', kind: 'insufficientMargin' }),
]);
expect(r.batches[0]).toMatchObject({ outcome: 'rejected', retry: 'give-up' });
expect(r.needsReconcile).toBe(false);
});
it('HTTP 5xx: outcome unknown, cloids returned for reconciliation, no blind retry of opens', async () => {
const { ex } = exchange(() => {
throw httpError(502, '502 Bad Gateway');
});
const r = await placeOrders(ex, registry, [LIMIT_BUY, IOC_BUY]);
expect(r.orders.every((o) => o.outcome.status === 'unknown')).toBe(true);
expect(r.reconcileCloids).toEqual(r.orders.map((o) => o.cloid));
expect(r.reconcileCloids).toHaveLength(2);
expect(r).toMatchObject({ needsReconcile: true, sent: true });
expect(r.batches[0]).toMatchObject({ outcome: 'unknown', retry: 'reconcile' });
});
it('HTTP 5xx on a batch of full closes: idempotent, retry is allowed', async () => {
const { ex } = exchange(() => {
throw httpError(503, '503');
});
const r = await placeOrders(ex, registry, [FULL_CLOSE]);
expect(r.batches[0]?.retry).toBe('retry');
expect(outcome(r, 0).status).toBe('unknown');
});
it('HTTP 429: provably not applied, safe to retry even for opens', async () => {
const { ex } = exchange(() => {
throw httpError(429, '429 Too Many Requests');
});
const r = await placeOrders(ex, registry, [LIMIT_BUY]);
expect(outcome(r, 0)).toMatchObject({ status: 'not_sent', reason: 'not-applied', retryable: true });
expect(r.batches[0]).toMatchObject({ outcome: 'not-applied', retry: 'retry' });
expect(r.needsReconcile).toBe(false);
});
it('HTTP 4xx other than 408/429: not applied, and re-sending the same request is pointless', async () => {
const { ex } = exchange(() => {
throw httpError(422, '422 Failed to deserialize the JSON body');
});
const r = await placeOrders(ex, registry, [LIMIT_BUY]);
expect(outcome(r, 0)).toMatchObject({ status: 'not_sent', reason: 'not-applied', retryable: false });
expect(r.batches[0]?.retry).toBe('give-up');
});
it('a synchronous SDK ValidationError is caught: not sent, not retryable', async () => {
const ex: OrderExchange = {
order() {
throw named('ValidationError', 'Invalid length: Expected 34 but received 6');
},
};
const r = await placeOrders(ex, registry, [LIMIT_BUY]);
expect(outcome(r, 0)).toMatchObject({ status: 'not_sent', reason: 'validation', retryable: false });
expect(r.batches[0]?.retry).toBe('give-up');
});
it('empty statuses confirm nothing', async () => {
const { ex } = exchange(() => ok([]));
const r = await placeOrders(ex, registry, [LIMIT_BUY]);
expect(outcome(r, 0)).toEqual({ status: 'unconfirmed', reason: 'empty statuses' });
expect(r.batches[0]).toMatchObject({ outcome: 'malformed', needsReconcile: true, retry: 'reconcile' });
expect(r.reconcileCloids).toHaveLength(1);
});
it('fewer statuses than orders: the missing ones are unconfirmed', async () => {
const { ex } = exchange(() => ok([{ resting: { oid: 5 } }]));
const r = await placeOrders(ex, registry, [LIMIT_BUY, IOC_BUY]);
expect(outcome(r, 0)).toMatchObject({ status: 'resting', oid: 5 });
expect(outcome(r, 1)).toEqual({ status: 'unconfirmed', reason: 'missing' });
expect(r.reconcileCloids).toEqual([r.orders[1]?.cloid]);
});
it('a response that blows up while being read is unknown, never lost', async () => {
const hostile = new Proxy(
{},
{
get() {
throw new Error('boom');
},
},
);
const { ex } = exchange(() => hostile);
const r = await placeOrders(ex, registry, [LIMIT_BUY]);
expect(outcome(r, 0)).toEqual({ status: 'unknown', message: 'boom' });
expect(r).toMatchObject({ needsReconcile: true, reconcileCloids: [r.orders[0]?.cloid] });
});
it('a returned (not thrown) status err body is a rejection', async () => {
const { ex } = exchange(() => ({ status: 'err', response: 'User or API Wallet 0x0000000000000000000000000000000000000000 does not exist.' })); // privacy-allow: placeholder address
const r = await placeOrders(ex, registry, [LIMIT_BUY]);
expect(outcome(r, 0)).toMatchObject({ status: 'rejected', kind: 'agentNotAuthorized', disposition: 'fatal' });
expect(r.batches[0]?.retry).toBe('give-up');
});
});
describe('placeOrders: building', () => {
it('a failing intent does not block the others', async () => {
const { ex, calls } = exchange(() => ok([{ resting: { oid: 1 } }]));
const r = await placeOrders(ex, registry, [{ ...LIMIT_BUY, coin: 'NOPE' }, LIMIT_BUY]);
expect(outcome(r, 0)).toMatchObject({ status: 'build_failed' });
expect(outcome(r, 1)).toMatchObject({ status: 'resting', oid: 1 });
expect(calls[0]?.params.orders).toHaveLength(1);
});
it('skipped and deferred intents are not sent; nothing to send means no call', async () => {
const info = (async () => ({})) as InfoRequester;
const { ex, calls } = exchange(() => ok([]));
const r = await placeOrders(
ex,
registry,
[
{ coin: 'XRP', side: 'buy', size: 0.2, price: 1000 },
{ coin: 'HYPE', side: 'sell', size: 1, reduceOnly: true, market: { slippage: 0.01 } },
],
{ info },
);
expect(calls).toHaveLength(0);
expect(r.orders.map((o) => o.outcome)).toEqual([
{ status: 'skipped', reason: 'size_rounds_to_zero' },
{ status: 'deferred', reason: 'no_mid_price' },
]);
expect(r).toMatchObject({ sent: false, batches: [], needsReconcile: false });
});
it('duplicate cloids in one call: the second one is not sent', async () => {
const cloid = `0x${'7'.repeat(32)}`;
const { ex, calls } = exchange(() => ok([{ resting: { oid: 1 } }]));
const r = await placeOrders(ex, registry, [{ ...LIMIT_BUY, cloid }, { ...LIMIT_BUY, cloid }]);
expect(outcome(r, 1)).toMatchObject({ status: 'build_failed', message: expect.stringMatching(/Duplicate cloid/) });
expect(calls[0]?.params.orders).toHaveLength(1);
});
it('reads allMids once per dex for the whole batch', async () => {
const bodies: InfoRequest[] = [];
const info = (async (body: InfoRequest) => {
bodies.push(body);
return body.dex === 'xyz' ? { 'xyz:TSLA': '250' } : { HYPE: '100', BTC: '100000' };
}) as InfoRequester;
const { ex } = exchange((p) => ok(p.orders.map((_, i) => ({ resting: { oid: i + 1 } }))));
const market = { slippage: 0.01 };
await placeOrders(
ex,
registry,
[
{ coin: 'HYPE', side: 'buy', size: 1, market },
{ coin: 'BTC', side: 'buy', size: 0.001, market },
{ coin: 'xyz:TSLA', side: 'buy', size: 1, market },
{ coin: 'xyz:TSLA', side: 'sell', size: 1, market },
],
{ info },
);
expect(bodies).toEqual([{ type: 'allMids' }, { type: 'allMids', dex: 'xyz' }]);
});
it('passes vaultAddress (lowercase), expiresAfter and signal to the exchange call', async () => {
const { ex, calls } = exchange(() => ok([{ resting: { oid: 1 } }]));
const signal = new AbortController().signal;
await placeOrders(ex, registry, [LIMIT_BUY], { vaultAddress: `0x${'AB'.repeat(20)}`, expiresAfter: 1_900_000_000_000, signal });
expect(calls[0]?.opts).toEqual({ vaultAddress: `0x${'ab'.repeat(20)}`, expiresAfter: 1_900_000_000_000, signal });
await expect(placeOrders(ex, registry, [LIMIT_BUY], { vaultAddress: '0x12' })).rejects.toThrow(HlOrderError);
await expect(placeOrders(ex, registry, [LIMIT_BUY], { expiresAfter: -1 })).rejects.toThrow(/expiresAfter/);
await expect(placeOrders(ex, registry, [LIMIT_BUY], { grouping: 'positionTpsl' as never })).rejects.toThrow(/grouping/);
});
});
describe('placeOrders: builder fee', () => {
const builder = { b: `0x${'Ab'.repeat(20)}`, f: 50 };
it('never attaches the builder fee to reduceOnly orders: they go first, in their own batch', async () => {
const { ex, calls } = exchange((_p, n) => ok([{ resting: { oid: n } }]));
const r = await placeOrders(ex, registry, [LIMIT_BUY, RO_SELL], { builder });
expect(calls).toHaveLength(2);
expect(calls[0]?.params.orders[0]?.r).toBe(true);
expect(calls[0]?.params).not.toHaveProperty('builder');
expect(calls[1]?.params.builder).toEqual({ b: `0x${'ab'.repeat(20)}`, f: 50 });
expect(r.orders.map((o) => o.outcome)).toEqual([
expect.objectContaining({ status: 'resting', oid: 2 }),
expect.objectContaining({ status: 'resting', oid: 1 }),
]);
expect(r.batches.map((b) => [b.indices, b.withBuilder])).toEqual([
[[1], false],
[[0], true],
]);
});
it('holds the builder batch when the first batch has an unknown outcome', async () => {
const { ex, calls } = exchange(() => {
throw httpError(504, '504 Gateway Timeout');
});
const r = await placeOrders(ex, registry, [LIMIT_BUY, RO_SELL], { builder });
expect(calls).toHaveLength(1);
expect(outcome(r, 1).status).toBe('unknown');
expect(outcome(r, 0)).toMatchObject({ status: 'not_sent', reason: 'held', retryable: true });
expect(r.batches[1]).toMatchObject({ outcome: 'held', retry: 'retry' });
});
it('validates the builder', async () => {
const { ex } = exchange(() => ok([{ resting: { oid: 1 } }]));
await expect(placeOrders(ex, registry, [LIMIT_BUY], { builder: { ...builder, f: 101 } })).rejects.toThrow(/perp cap/);
await expect(placeOrders(ex, registry, [LIMIT_BUY], { builder: { ...builder, f: 1.5 } })).rejects.toThrow(/builder.f/);
await expect(placeOrders(ex, registry, [LIMIT_BUY], { builder: { b: '0xnope', f: 1 } })).rejects.toThrow(/builder.b/);
});
});
describe('mayBeLive', () => {
it('only resting, accepted, unconfirmed and unknown may be live', () => {
const cases: [OrderOutcome, boolean][] = [
[{ status: 'resting', oid: 1 }, true],
[{ status: 'accepted', detail: 'waitingForFill' }, true],
[{ status: 'unconfirmed', reason: 'x' }, true],
[{ status: 'unknown', message: 'x' }, true],
[{ status: 'filled', oid: 1, totalSz: '1', avgPx: '1', fullyFilled: true, remainingSz: '0' }, false],
[{ status: 'rejected', message: 'x', kind: 'unknown', disposition: 'unknown' }, false],
[{ status: 'not_sent', reason: 'held', retryable: true, message: '' }, false],
[{ status: 'skipped', reason: 'no_mid_price' }, false],
];
for (const [o, live] of cases) expect(mayBeLive(o)).toBe(live);
});
});