src/orders/close.test.ts
v0.3.0 · 13.3 KB
import { describe, expect, it } from 'vitest';
import { AssetRegistryError, type AssetInfo } from '../assets/index.js';
import { hasHiddenRemainder } from '../format/index.js';
import type { InfoCallOptions, InfoRequest, InfoRequester } from '../transport/types.js';
import { closePosition, fetchPosition, marketClose } from './close.js';
import { HlOrderError } from './errors.js';
import type { 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> = {
HYPE: asset('HYPE', 159, 2),
E3: asset('E3', 2, 3),
'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;
},
};
const USER = `0x${'12'.repeat(20)}`;
const named = (name: string, message: string, extra: object = {}) => Object.assign(new Error(message), { name }, extra);
const apiError = (response: unknown) => named('ApiRequestError', 'api', { response });
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 info(state: (dex: string | undefined) => unknown, mids: (dex: string | undefined) => unknown = () => ({})) {
const calls: { body: InfoRequest; opts: InfoCallOptions | undefined }[] = [];
const fn = (async (body: InfoRequest, opts?: InfoCallOptions) => {
calls.push({ body, opts });
const dex = typeof body.dex === 'string' ? body.dex : undefined;
if (body.type === 'clearinghouseState') return state(dex);
if (body.type === 'allMids') return mids(dex);
throw new Error(`unexpected ${body.type}`);
}) as InfoRequester;
return { info: fn, calls };
}
const positions = (...list: Record<string, unknown>[]) => ({ assetPositions: list.map((position) => ({ type: 'oneWay', position })) });
describe('fetchPosition', () => {
it('reads clearinghouseState of the asset dex (weight 2) and returns side and absolute size', async () => {
const { info: i, calls } = info(() => positions({ coin: 'HYPE', szi: '-2.50', positionValue: '-250', entryPx: '100' }));
expect(await fetchPosition(i, USER, ASSETS.HYPE as AssetInfo)).toEqual({
coin: 'HYPE',
side: 'short',
size: '2.5',
szi: '-2.5',
positionValue: '-250',
entryPx: '100',
});
expect(calls[0]).toEqual({ body: { type: 'clearinghouseState', user: USER.toLowerCase() }, opts: { weight: 2 } });
const xyz = info(() => positions());
expect(await fetchPosition(xyz.info, USER, ASSETS['xyz:TSLA'] as AssetInfo)).toBeNull();
expect(xyz.calls[0]?.body).toMatchObject({ dex: 'xyz' });
});
it('flat or absent is null; a HIP-3 coin without prefix still matches', async () => {
const flat = info(() => positions({ coin: 'HYPE', szi: '0.0', positionValue: '0.0' }, { coin: 'BTC', szi: '1' }));
expect(await fetchPosition(flat.info, USER, ASSETS.HYPE as AssetInfo)).toBeNull();
const bare = info(() => positions({ coin: 'TSLA', szi: '3' }));
expect(await fetchPosition(bare.info, USER, ASSETS['xyz:TSLA'] as AssetInfo)).toMatchObject({ coin: 'xyz:TSLA', side: 'long', size: '3' });
});
it('HIP-3 quirk: szi 0 with a signed positionValue gives a position of unknown size', async () => {
const { info: i } = info(() => positions({ coin: 'xyz:TSLA', szi: '0', positionValue: '-250' }));
expect(await fetchPosition(i, USER, ASSETS['xyz:TSLA'] as AssetInfo)).toMatchObject({ side: 'short', size: null });
});
it('a degraded answer is never flat', async () => {
for (const bad of [{}, null, { assetPositions: 'x' }, []]) {
const { info: i } = info(() => bad);
await expect(fetchPosition(i, USER, ASSETS.HYPE as AssetInfo)).rejects.toMatchObject({ code: 'INVALID_RESPONSE' });
}
const dup = info(() => positions({ coin: 'HYPE', szi: '1' }, { coin: 'HYPE', szi: '2' }));
await expect(fetchPosition(dup.info, USER, ASSETS.HYPE as AssetInfo)).rejects.toThrow(/2 times/);
const garbage = info(() => positions({ coin: 'HYPE', szi: 'abc' }));
await expect(fetchPosition(garbage.info, USER, ASSETS.HYPE as AssetInfo)).rejects.toBeInstanceOf(HlOrderError);
});
});
describe('marketClose', () => {
it('full close: reduceOnly IoC, 10% cap, ceil + bump at the order price; recheck after a full fill', async () => {
const { ex, calls } = exchange(() => ok([{ filled: { oid: 9, totalSz: '0.001', avgPx: '2590' } }]));
const r = await marketClose(ex, registry, { coin: 'E3', position: { side: 'long', size: '0.001' }, mid: 2600 });
expect(calls[0]?.orders[0]).toMatchObject({ a: 2, b: false, p: '2340', s: '0.005', r: true, t: { limit: { tif: 'Ioc' } } });
expect(r).toMatchObject({
status: 'filled',
fullClose: true,
idempotent: true,
targetSz: '0.001',
requestedSz: '0.005',
filledSz: '0.001',
avgPx: '2590',
remainingSz: '0',
oid: 9,
recheckPosition: true,
retry: null,
});
// The caller re-reads the position: a stale snapshot may hide a remainder.
expect(hasHiddenRemainder('0.003', r.filledSz, 3)).toBe(true);
expect(hasHiddenRemainder('0', r.filledSz, 3)).toBe(false);
});
it('a partial IoC fill returns the remainder the exchange dropped', async () => {
const { ex } = exchange(() => ok([{ filled: { oid: 1, totalSz: '0.5', avgPx: '99' } }]));
const r = await marketClose(ex, registry, { coin: 'HYPE', position: { side: 'short', size: 2 }, mid: 100 });
expect(r).toMatchObject({ status: 'partial', filledSz: '0.5', remainingSz: '1.5', recheckPosition: false });
});
it('a smaller size is a partial reduce: floored, not idempotent', async () => {
const { ex, calls } = exchange(() => ok([{ filled: { oid: 1, totalSz: '0.5', avgPx: '99' } }]));
const r = await marketClose(ex, registry, { coin: 'HYPE', position: { side: 'long', size: 2 }, size: '0.509', mid: 100 });
expect(calls[0]?.orders[0]).toMatchObject({ s: '0.5', r: true, p: '90' });
expect(r).toMatchObject({ status: 'filled', fullClose: false, idempotent: false, targetSz: '0.509', recheckPosition: false });
const over = await marketClose(ex, registry, { coin: 'HYPE', position: { side: 'long', size: 2 }, size: 5, mid: 100 });
expect(over).toMatchObject({ fullClose: true, targetSz: '2' });
});
it('minimum-value rejection is dust; an IoC without a match is not_filled', async () => {
const dust = exchange(() => {
throw apiError(ok([{ error: 'Order must have minimum value of $10.' }]));
});
expect(await marketClose(dust.ex, registry, { coin: 'HYPE', position: { side: 'long', size: 1 }, mid: 100 })).toMatchObject({
status: 'dust',
filledSz: '0',
remainingSz: '1',
});
const miss = exchange(() => {
throw apiError(ok([{ error: 'Order could not immediately match against any resting orders.' }]));
});
expect(await marketClose(miss.ex, registry, { coin: 'HYPE', position: { side: 'long', size: 1 }, mid: 100 })).toMatchObject({
status: 'not_filled',
reason: 'iocNoMatch',
});
});
it('unknown outcome: a full close may be retried, a partial reduce must be reconciled', async () => {
const down = exchange(() => {
throw named('HttpRequestError', '500', { response: { status: 500 } });
});
const full = await marketClose(down.ex, registry, { coin: 'HYPE', position: { side: 'long', size: 2 }, mid: 100 });
expect(full).toMatchObject({ status: 'unknown', retry: 'retry' });
const partial = await marketClose(down.ex, registry, { coin: 'HYPE', position: { side: 'long', size: 2 }, size: 1, mid: 100 });
expect(partial).toMatchObject({ status: 'unknown', retry: 'reconcile' });
const resting = exchange(() => ok([{ resting: { oid: 3 } }]));
expect(await marketClose(resting.ex, registry, { coin: 'HYPE', position: { side: 'long', size: 2 }, mid: 100 })).toMatchObject({
status: 'unknown',
reason: 'unexpected-resting',
oid: 3,
});
});
it('never attaches a builder fee, even when an untyped caller passes one', async () => {
const { ex, calls } = exchange(() => ok([{ filled: { oid: 1, totalSz: '1', avgPx: '99' } }]));
await marketClose(ex, registry, { coin: 'HYPE', position: { side: 'long', size: 1 }, mid: 100 }, {
builder: { b: `0x${'3'.repeat(40)}`, f: 10 },
} as never);
expect(calls).toHaveLength(1);
expect(calls[0]).not.toHaveProperty('builder');
});
it('validates the position', async () => {
const { ex } = exchange(() => ok([]));
await expect(marketClose(ex, registry, { coin: 'HYPE', position: { side: 'flat' as never, size: 1 }, mid: 1 })).rejects.toThrow(/side/);
await expect(marketClose(ex, registry, { coin: 'HYPE', position: { side: 'long', size: 0 }, mid: 1 })).rejects.toThrow(/non-zero/);
});
});
describe('closePosition', () => {
it('no position: a safe no-op, nothing sent', async () => {
const { ex, calls } = exchange(() => ok([]));
const { info: i } = info(() => positions());
expect(await closePosition(ex, i, registry, { user: USER, coin: 'HYPE' })).toMatchObject({ status: 'no_position', side: null });
expect(calls).toHaveLength(0);
});
it('reads the position and mid, then closes the whole position', async () => {
const { ex, calls } = exchange(() => ok([{ filled: { oid: 5, totalSz: '2.5', avgPx: '101' } }]));
const { info: i, calls: reads } = info(
() => positions({ coin: 'HYPE', szi: '-2.5' }),
() => ({ HYPE: '100' }),
);
const r = await closePosition(ex, i, registry, { user: USER, coin: 'HYPE' });
expect(reads.map((c) => c.body.type)).toEqual(['clearinghouseState', 'allMids']);
expect(calls[0]?.orders[0]).toMatchObject({ b: true, p: '110', s: '2.5', r: true });
expect(r).toMatchObject({ status: 'filled', side: 'short', remainingSz: '0', recheckPosition: true });
});
it('an explicit mid skips allMids; no mid defers without sending', async () => {
const { ex, calls } = exchange(() => ok([{ filled: { oid: 5, totalSz: '1', avgPx: '100' } }]));
const withMid = info(() => positions({ coin: 'HYPE', szi: '1' }));
await closePosition(ex, withMid.info, registry, { user: USER, coin: 'HYPE', mid: 100 });
expect(withMid.calls.map((c) => c.body.type)).toEqual(['clearinghouseState']);
const noMid = info(() => positions({ coin: 'HYPE', szi: '1' }), () => ({}));
expect(await closePosition(ex, noMid.info, registry, { user: USER, coin: 'HYPE' })).toMatchObject({
status: 'deferred',
reason: 'no_mid_price',
remainingSz: '1',
});
expect(calls).toHaveLength(1);
});
it('an unreadable state throws instead of pretending flat', async () => {
const { ex } = exchange(() => ok([]));
const { info: i } = info(() => ({ error: 'degraded' }));
await expect(closePosition(ex, i, registry, { user: USER, coin: 'HYPE' })).rejects.toMatchObject({ code: 'INVALID_RESPONSE' });
await expect(closePosition(ex, i, registry, { user: 'nope', coin: 'HYPE' })).rejects.toThrow(/user/);
});
it('HIP-3 position with unknown size: over-sized reduceOnly close from positionValue', async () => {
const { ex, calls } = exchange(() => ok([{ filled: { oid: 8, totalSz: '1', avgPx: '251' } }]));
const { info: i } = info(
() => positions({ coin: 'xyz:TSLA', szi: '0', positionValue: '-250' }),
() => ({ 'xyz:TSLA': '250' }),
);
const r = await closePosition(ex, i, registry, { user: USER, coin: 'xyz:TSLA' });
expect(calls[0]?.orders[0]).toMatchObject({ a: 110001, b: true, s: '1.1', r: true, p: '275' });
expect(r).toMatchObject({ status: 'filled', sizeEstimated: true, recheckPosition: true, fullClose: true });
});
it('HIP-3 position with unknown size and an explicit partial size: floored partial reduce', async () => {
const { ex, calls } = exchange(() => ok([{ filled: { oid: 8, totalSz: '0.5', avgPx: '249' } }]));
const { info: i } = info(
() => positions({ coin: 'xyz:TSLA', positionValue: '500' }),
() => ({ 'xyz:TSLA': '250' }),
);
const r = await closePosition(ex, i, registry, { user: USER, coin: 'xyz:TSLA', size: '0.509' });
expect(calls[0]?.orders[0]).toMatchObject({ b: false, s: '0.5', r: true });
expect(r).toMatchObject({ status: 'filled', fullClose: false, idempotent: false, sizeEstimated: false });
});
it('partial reduce through closePosition', async () => {
const { ex, calls } = exchange(() => ok([{ filled: { oid: 5, totalSz: '1', avgPx: '100' } }]));
const { info: i } = info(() => positions({ coin: 'HYPE', szi: '3' }), () => ({ HYPE: '100' }));
const r = await closePosition(ex, i, registry, { user: USER, coin: 'HYPE', size: 1 });
expect(calls[0]?.orders[0]).toMatchObject({ s: '1', b: false });
expect(r).toMatchObject({ fullClose: false, status: 'filled' });
});
});