src/orders/twap.test.ts
v0.3.0 · 6.6 KB
import { describe, expect, it } from 'vitest';
import type { AssetInfo } from '../assets/index.js';
import { HlOrderError } from './errors.js';
import { assertTwapMinutes, cancelTwap, MAX_TWAP_MINUTES, MIN_TWAP_MINUTES, placeTwap } from './twap.js';
import type { TwapExchange } from './types.js';
const resolveCalls: { coin: string; opts: unknown }[] = [];
const registry = {
async resolve(coin: string, opts?: { rejectDelisted?: boolean }): Promise<AssetInfo> {
resolveCalls.push({ coin, opts });
return { coin, assetId: 159, index: 159, szDecimals: 2, maxPxDecimals: 4, maxLeverage: 10, onlyIsolated: false, isDelisted: false, dex: '', dexIndex: 0, market: 'perp' };
},
};
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 twapBody = (status: unknown, type = 'twapOrder') => ({ status: 'ok', response: { type, data: { status } } });
function stub(order: () => unknown, cancel: () => unknown = () => twapBody('success', 'twapCancel')) {
const calls: { kind: string; params: unknown; opts: unknown }[] = [];
const ex: TwapExchange = {
twapOrder(params, opts) {
calls.push({ kind: 'twapOrder', params, opts });
return Promise.resolve().then(order);
},
twapCancel(params, opts) {
calls.push({ kind: 'twapCancel', params, opts });
return Promise.resolve().then(cancel);
},
};
return { ex, calls };
}
describe('TWAP duration', () => {
it('accepts integers 5..1440 minutes only', () => {
expect(MIN_TWAP_MINUTES).toBe(5);
expect(MAX_TWAP_MINUTES).toBe(1440);
for (const ok of [5, 30, 1440]) expect(() => assertTwapMinutes(ok)).not.toThrow();
for (const bad of [1, 4, 1441, 30.5, Number.NaN, '30' as never]) expect(() => assertTwapMinutes(bad)).toThrow(HlOrderError);
});
});
describe('placeTwap (experimental)', () => {
it('sends the floored size and returns the twapId', async () => {
const { ex, calls } = stub(() => twapBody({ running: { twapId: 77738308 } }));
const r = await placeTwap(ex, registry, { coin: 'HYPE', side: 'buy', size: '1.239', minutes: 30 });
expect(calls[0]).toEqual({ kind: 'twapOrder', params: { twap: { a: 159, b: true, s: '1.23', r: false, m: 30, t: false } }, opts: undefined });
expect(r).toEqual({ status: 'running', twapId: 77738308, sz: '1.23', assetId: 159 });
expect(resolveCalls.at(-1)).toEqual({ coin: 'HYPE', opts: { rejectDelisted: true } });
});
it('reduceOnly and randomize flags; exits may use delisted markets', async () => {
const { ex, calls } = stub(() => twapBody({ running: { twapId: 1 } }));
await placeTwap(ex, registry, { coin: 'HYPE', side: 'A', size: 2, minutes: 5, reduceOnly: true, randomize: true }, { vaultAddress: `0x${'5'.repeat(40)}` });
expect(calls[0]).toMatchObject({ params: { twap: { b: false, r: true, t: true, m: 5 } }, opts: { vaultAddress: `0x${'5'.repeat(40)}` } });
expect(resolveCalls.at(-1)?.opts).toEqual({ rejectDelisted: false });
});
it('refuses bad input before sending', async () => {
const { ex, calls } = stub(() => twapBody({ running: { twapId: 1 } }));
await expect(placeTwap(ex, registry, { coin: 'HYPE', side: 'buy', size: '0.001', minutes: 30 })).rejects.toThrow(/below one lot/);
await expect(placeTwap(ex, registry, { coin: 'HYPE', side: 'buy', size: 1, minutes: 1 })).rejects.toThrow(/duration/);
await expect(placeTwap(ex, registry, { coin: 'HYPE', side: 'up' as never, size: 1, minutes: 5 })).rejects.toThrow(/side/);
expect(calls).toHaveLength(0);
});
it('a TWAP error inside data.status is a rejection, thrown or returned', async () => {
const thrown = stub(() => {
throw apiError(twapBody({ error: 'Invalid TWAP duration: 1 min(s)' }));
});
expect(await placeTwap(thrown.ex, registry, { coin: 'HYPE', side: 'buy', size: 1, minutes: 5 })).toMatchObject({
status: 'rejected',
kind: 'invalidTwapDuration',
});
const returned = stub(() => twapBody({ error: 'Invalid TWAP duration: 1 min(s)' }));
expect(await placeTwap(returned.ex, registry, { coin: 'HYPE', side: 'buy', size: 1, minutes: 5 })).toMatchObject({ status: 'rejected' });
});
it('twapOrder is not idempotent: unknown outcome means reconcile, 429 may be retried', async () => {
const down = stub(() => {
throw httpError(502);
});
expect(await placeTwap(down.ex, registry, { coin: 'HYPE', side: 'buy', size: 1, minutes: 5 })).toMatchObject({ status: 'unknown', retry: 'reconcile' });
const limited = stub(() => {
throw httpError(429);
});
expect(await placeTwap(limited.ex, registry, { coin: 'HYPE', side: 'buy', size: 1, minutes: 5 })).toMatchObject({ status: 'not_sent', retry: 'retry' });
const weird = stub(() => twapBody({ running: { twapId: -1 } }));
expect(await placeTwap(weird.ex, registry, { coin: 'HYPE', side: 'buy', size: 1, minutes: 5 })).toMatchObject({ status: 'unknown' });
const outerErr = stub(() => {
throw apiError({ status: 'err', response: 'Insufficient margin to place order.' });
});
expect(await placeTwap(outerErr.ex, registry, { coin: 'HYPE', side: 'buy', size: 1, minutes: 5 })).toMatchObject({
status: 'rejected',
kind: 'insufficientMargin',
});
});
});
describe('cancelTwap (experimental)', () => {
it('success, already gone, error, unconfirmed', async () => {
const good = stub(() => null);
expect(await cancelTwap(good.ex, registry, { coin: 'HYPE', twapId: 7 })).toEqual({ status: 'success' });
expect(good.calls[0]).toMatchObject({ kind: 'twapCancel', params: { a: 159, t: 7 } });
const gone = stub(
() => null,
() => {
throw apiError(twapBody({ error: 'TWAP was never placed, already canceled, or filled.' }, 'twapCancel'));
},
);
expect(await cancelTwap(gone.ex, registry, { coin: 'HYPE', twapId: 7 })).toMatchObject({ status: 'alreadyGone' });
const other = stub(
() => null,
() => twapBody({ error: 'Something odd' }, 'twapCancel'),
);
expect(await cancelTwap(other.ex, registry, { coin: 'HYPE', twapId: 7 })).toMatchObject({ status: 'error', message: 'Something odd' });
const down = stub(
() => null,
() => {
throw httpError(500);
},
);
expect(await cancelTwap(down.ex, registry, { coin: 'HYPE', twapId: 7 })).toMatchObject({ status: 'unconfirmed', retry: 'retry' });
await expect(cancelTwap(good.ex, registry, { coin: 'HYPE', twapId: 1.5 })).rejects.toThrow(/twapId/);
});
});