src/orders/build.test.ts
v0.3.0 · 17.2 KB
import { describe, expect, it } from 'vitest';
import { AssetRegistryError, createAssetRegistry, type AssetInfo } from '../assets/index.js';
import { HlFormatError } from '../format/index.js';
import type { InfoCallOptions, InfoRequest, InfoRequester } from '../transport/types.js';
import { buildOrder, type BuildOrderResult, type OrderIntentInput, type PlaceableOrder } from './build.js';
import { HlOrderError } from './errors.js';
import { createMidCache, fetchMid } from './mids.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),
E3: asset('E3', 2, 3),
LOT1: asset('LOT1', 3, 1),
OLD: asset('OLD', 4, 1, { isDelisted: true }),
'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;
},
};
function infoStub(answer: (body: InfoRequest) => unknown) {
const calls: { body: InfoRequest; opts: InfoCallOptions | undefined }[] = [];
const info = (async (body: InfoRequest, opts?: InfoCallOptions) => {
calls.push({ body, opts });
return answer(body);
}) as InfoRequester;
return { info, calls };
}
function placed(r: BuildOrderResult): PlaceableOrder {
if (r.action !== 'place') throw new Error(`expected place, got ${r.action} ${r.reason}`);
return r;
}
const build = (intent: OrderIntentInput, opts = {}) => buildOrder(registry, intent, opts);
describe('buildOrder: limit prices', () => {
it('rounds a limit price passively and the size down, fills asset id and cloid', async () => {
const buy = placed(await build({ coin: 'HYPE', side: 'buy', size: '1.234', price: 83.20512 }));
expect(buy.wire).toEqual({ a: 159, b: true, p: '83.205', s: '1.23', r: false, t: { limit: { tif: 'Gtc' } }, c: buy.cloid });
expect(buy.cloid).toMatch(/^0x[0-9a-f]{32}$/);
expect(buy.warnings).toEqual(['price_rounded']);
expect(buy.notional).toBe('102.34215');
const sell = placed(await build({ coin: 'HYPE', side: 'A', size: 1, price: 83.20512 }));
expect(sell.px).toBe('83.206');
expect(sell.side).toBe('A');
});
it("'exact' refuses off-grid prices, 'round' goes to the nearest tick", async () => {
await expect(build({ coin: 'HYPE', side: 'buy', size: 1, price: 83.20512 }, { priceRounding: 'exact' })).rejects.toThrow(HlOrderError);
const exact = placed(await build({ coin: 'HYPE', side: 'buy', size: 1, price: '83.2050' }, { priceRounding: 'exact' }));
expect(exact.px).toBe('83.205');
expect(exact.warnings).toEqual([]);
expect(placed(await build({ coin: 'HYPE', side: 'buy', size: 1, price: 83.2057 }, { priceRounding: 'round' })).px).toBe('83.206');
expect(placed(await build({ coin: 'HYPE', side: 'buy', size: 1, price: 83.2057 })).px).toBe('83.205');
});
it('Alo with a book is clamped one tick inside the opposite side', async () => {
const bid = placed(await build({ coin: 'HYPE', side: 'buy', size: 1, price: 100.5, tif: 'Alo', book: { bestBid: 100.1, bestAsk: 100.2 } }));
expect(bid.px).toBe('100.19');
expect(bid.wire.t).toEqual({ limit: { tif: 'Alo' } });
const ask = placed(await build({ coin: 'HYPE', side: 'sell', size: 1, price: 99, tif: 'Alo', book: { bestBid: 99.5 } }));
// Below 100 the tick is 0.001 (5 significant figures), not 0.01.
expect(ask.px).toBe('99.501');
});
it('uses the HIP-3 asset id', async () => {
expect(placed(await build({ coin: 'xyz:TSLA', side: 'buy', size: 1, price: 250 })).wire.a).toBe(110001);
});
});
describe('buildOrder: market orders', () => {
it('IoC at mid * (1 + slippage), rounded toward execution', async () => {
const o = placed(await build({ coin: 'BTC', side: 'buy', size: 0.001, market: { slippage: 0.01, mid: '79555.55' } }));
expect(o.px).toBe('80352');
expect(o.tif).toBe('Ioc');
expect(o.wire.t).toEqual({ limit: { tif: 'Ioc' } });
expect(o.mid).toBe('79555.55');
expect(o.slippage).toBe(0.01);
});
it('a market order must be Ioc', async () => {
await expect(build({ coin: 'BTC', side: 'buy', size: 1, market: { slippage: 0.01, mid: 1 }, tif: 'Gtc' })).rejects.toThrow(/must be Ioc/);
});
it('reads allMids of the asset dex (weight 2), with the bare-key fallback', async () => {
const { info, calls } = infoStub(() => ({ 'xyz:TSLA': '250.5' }));
const o = placed(await build({ coin: 'xyz:TSLA', side: 'buy', size: 1, market: { slippage: 0.01 } }, { info }));
expect(o.px).toBe('253.01');
expect(calls).toEqual([{ body: { type: 'allMids', dex: 'xyz' }, opts: { weight: 2 } }]);
const bare = infoStub(() => ({ TSLA: '250.5' }));
expect(placed(await build({ coin: 'xyz:TSLA', side: 'buy', size: 1, market: { slippage: 0.01 } }, { info: bare.info })).px).toBe('253.01');
const main = infoStub(() => ({ BTC: '100000' }));
await build({ coin: 'BTC', side: 'buy', size: 0.001, market: { slippage: 0.01 } }, { info: main.info });
expect(main.calls[0]?.body).toEqual({ type: 'allMids' });
});
it('without mid and info it throws', async () => {
await expect(build({ coin: 'BTC', side: 'buy', size: 1, market: { slippage: 0.01 } })).rejects.toThrow(/market.mid or options.info/);
});
it('no mid: skip an entry, defer an exit', async () => {
const { info } = infoStub(() => ({}));
const entry = await build({ coin: 'HYPE', side: 'buy', size: 1, market: { slippage: 0.01 } }, { info });
expect(entry).toMatchObject({ action: 'skip', reason: 'no_mid_price' });
const exit = await build({ coin: 'HYPE', side: 'sell', size: 1, reduceOnly: true, market: { slippage: 0.01 } }, { info });
expect(exit).toMatchObject({ action: 'defer', reason: 'no_mid_price' });
});
it('invalid mids (Infinity, 0, negative, absurd, garbage) count as no mid', async () => {
for (const bad of ['Infinity', '0', '-5', 1e300, 'abc', '']) {
const r = await build({ coin: 'HYPE', side: 'buy', size: 1, market: { slippage: 0.01, mid: bad } });
expect(r).toMatchObject({ action: 'skip', reason: 'no_mid_price' });
}
const { info } = infoStub(() => ['not', 'a', 'map']);
expect(await build({ coin: 'HYPE', side: 'buy', size: 1, market: { slippage: 0.01 } }, { info })).toMatchObject({ reason: 'no_mid_price' });
});
it('reduceOnly exits get the 10% slippage floor; opt out with exitSlippageFloor 0', async () => {
const sell = placed(await build({ coin: 'HYPE', side: 'sell', size: 1, reduceOnly: true, market: { slippage: 0.01, mid: 100 } }));
expect(sell.slippage).toBe(0.1);
expect(sell.px).toBe('90');
const buy = placed(await build({ coin: 'HYPE', side: 'buy', size: 1, reduceOnly: true, market: { slippage: 0.01, mid: 100 } }));
expect(buy.px).toBe('110');
const narrow = placed(
await build({ coin: 'HYPE', side: 'sell', size: 1, reduceOnly: true, market: { slippage: 0.01, mid: 100 } }, { exitSlippageFloor: 0 }),
);
expect(narrow.px).toBe('99');
const entry = placed(await build({ coin: 'HYPE', side: 'buy', size: 1, market: { slippage: 0.01, mid: 100 } }));
expect(entry.px).toBe('101');
});
it('warns when the slippage is below 0.5% (an IoC at mid does not cross)', async () => {
const o = placed(await build({ coin: 'HYPE', side: 'buy', size: 1, market: { slippage: 0.001, mid: 100 } }));
expect(o.warnings).toContain('slippage_below_recommended');
expect(o.px).toBe('100.1');
});
});
describe('buildOrder: size policy and the $10 minimum (knowledge-base cases)', () => {
it('open 0.0019 @ 5500 (szDecimals 3) floors to $5.5 and is skipped', async () => {
expect(await build({ coin: 'E3', side: 'buy', size: 0.0019, price: 5500 })).toMatchObject({
action: 'skip',
reason: 'order_below_min_after_rounding',
sz: '0.001',
notional: '5.5',
});
});
it('open 0.2 @ szDecimals 0 is skipped, never bumped to one lot', async () => {
expect(await build({ coin: 'XRP', side: 'buy', size: 0.2, price: 1000 })).toMatchObject({ action: 'skip', reason: 'size_rounds_to_zero' });
});
it('open 5 @ 1000 (szDecimals 2) is unchanged', async () => {
expect(placed(await build({ coin: 'HYPE', side: 'buy', size: 5, price: 1000 })).sz).toBe('5');
});
it('partial reduce 0.2 @ szDecimals 0 is skipped; 0.001 @ $5000 is skipped', async () => {
expect(await build({ coin: 'XRP', side: 'sell', size: 0.2, price: 20, reduceOnly: true })).toMatchObject({ reason: 'size_rounds_to_zero' });
expect(await build({ coin: 'E3', side: 'sell', size: 0.001, price: 5000, reduceOnly: true })).toMatchObject({
reason: 'order_below_min_after_rounding',
});
});
it('partial reduce 0.29 @ $35 (szDecimals 2) survives float noise and is sent', async () => {
const o = placed(await build({ coin: 'HYPE', side: 'sell', size: 0.29, price: 35, reduceOnly: true }));
expect(o.sz).toBe('0.29');
expect(o.notional).toBe('10.15');
expect(o.wire.r).toBe(true);
expect(o.sizeIntent).toBe('partialReduce');
});
it('full close ceils and bumps in lots: 0.001 @ $3000 -> 0.004; 0.2 @ szDecimals 0 -> 1 lot', async () => {
const eth = placed(await build({ coin: 'E3', side: 'sell', size: 0.001, price: 3000, sizeIntent: 'fullClose' }));
expect(eth).toMatchObject({ sz: '0.004', notional: '12', bumped: true, reduceOnly: true });
const xrp = placed(await build({ coin: 'XRP', side: 'sell', size: 0.2, price: 20, sizeIntent: 'fullClose' }));
expect(xrp).toMatchObject({ sz: '1', bumped: false });
});
it('a full close is bumped at the ORDER price, not at mid', async () => {
const o = placed(
await build({ coin: 'E3', side: 'sell', size: 0.001, sizeIntent: 'fullClose', market: { slippage: 0.1, mid: 2600 } }),
);
expect(o.px).toBe('2340');
// At mid 4 lots would do (10.4), but at the order price they are worth 9.36 and would be rejected.
expect(o.sz).toBe('0.005');
expect(Number(o.notional)).toBeGreaterThanOrEqual(10);
});
it('rejects contradictory reduceOnly / sizeIntent combinations', async () => {
await expect(build({ coin: 'E3', side: 'sell', size: 1, price: 3000, sizeIntent: 'fullClose', reduceOnly: false })).rejects.toThrow(/requires reduceOnly/);
await expect(build({ coin: 'E3', side: 'buy', size: 1, price: 3000, sizeIntent: 'open', reduceOnly: true })).rejects.toThrow(/contradicts/);
await expect(build({ coin: 'E3', side: 'buy', size: 1, price: 3000, sizeIntent: 'bogus' as never })).rejects.toThrow(/sizeIntent/);
});
it('delisted markets: no new exposure, exits still allowed', async () => {
expect(await build({ coin: 'OLD', side: 'buy', size: 5, price: 10 })).toMatchObject({ action: 'skip', reason: 'market_delisted' });
expect(placed(await build({ coin: 'OLD', side: 'sell', size: 5, price: 10, reduceOnly: true })).sz).toBe('5');
});
});
describe('buildOrder: size flooring', () => {
it('floors the size to szDecimals for Gtc and IoC alike', async () => {
expect(placed(await build({ coin: 'LOT1', side: 'buy', size: 0.16, price: 100 })).sz).toBe('0.1');
expect(placed(await build({ coin: 'LOT1', side: 'buy', size: 0.16, market: { slippage: 0.01, mid: 100 } })).sz).toBe('0.1');
});
});
describe('buildOrder: triggers', () => {
it('market stop-loss: trigger price on the grid, worst price from triggerPx with the exit floor', async () => {
const o = placed(
await build({ coin: 'HYPE', side: 'sell', size: 1, reduceOnly: true, trigger: { triggerPx: 95.1234, tpsl: 'sl' }, market: { slippage: 0.02 } }),
);
expect(o.wire.t).toEqual({ trigger: { isMarket: true, triggerPx: '95.123', tpsl: 'sl' } });
expect(o.px).toBe('85.61');
expect(o.tif).toBeNull();
expect(o.triggerPx).toBe('95.123');
expect(o.warnings).toEqual(['price_rounded']);
});
it('limit take-profit with an explicit price', async () => {
const o = placed(
await build({ coin: 'HYPE', side: 'sell', size: 1, reduceOnly: true, price: 120, trigger: { triggerPx: 110, tpsl: 'tp', isMarket: false } }),
);
expect(o.wire).toMatchObject({ p: '120', t: { trigger: { isMarket: false, triggerPx: '110', tpsl: 'tp' } } });
expect(o.warnings).toEqual([]);
});
it('refuses tif or market.mid on a trigger', async () => {
await expect(build({ coin: 'HYPE', side: 'sell', size: 1, price: 1, tif: 'Gtc', trigger: { triggerPx: 1, tpsl: 'tp' } })).rejects.toThrow(/tif/);
await expect(
build({ coin: 'HYPE', side: 'sell', size: 1, market: { slippage: 0.1, mid: 5 }, trigger: { triggerPx: 1, tpsl: 'sl' } }),
).rejects.toThrow(/market.mid/);
await expect(build({ coin: 'HYPE', side: 'sell', size: 1, price: 1, trigger: { triggerPx: 1, tpsl: 'stop' as never } })).rejects.toThrow(/tpsl/);
});
});
describe('buildOrder: cloid and input checks', () => {
it('explicit cloids are lowercased; false or autoCloid:false sends none', async () => {
const upper = `0x${'AB'.repeat(16)}`;
expect(placed(await build({ coin: 'HYPE', side: 'buy', size: 1, price: 50, cloid: upper })).wire.c).toBe(upper.toLowerCase());
expect(placed(await build({ coin: 'HYPE', side: 'buy', size: 1, price: 50, cloid: false })).wire.c).toBeUndefined();
expect(placed(await build({ coin: 'HYPE', side: 'buy', size: 1, price: 50 }, { autoCloid: false })).wire).not.toHaveProperty('c');
await expect(build({ coin: 'HYPE', side: 'buy', size: 1, price: 50, cloid: '0x1234' })).rejects.toThrow(HlOrderError);
});
it('cloidFactory and cloidPrefix', async () => {
const fixed = `0x${'1'.repeat(32)}`;
expect(placed(await build({ coin: 'HYPE', side: 'buy', size: 1, price: 50 }, { cloidFactory: () => fixed })).cloid).toBe(fixed);
expect(placed(await build({ coin: 'HYPE', side: 'buy', size: 1, price: 50 }, { cloidPrefix: 'beef' })).cloid).toMatch(/^0xbeef/);
await expect(build({ coin: 'HYPE', side: 'buy', size: 1, price: 50 }, { cloidFactory: () => 'nope' })).rejects.toThrow(HlOrderError);
});
it('needs exactly one of price / market, a valid side and tif', async () => {
await expect(build({ coin: 'HYPE', side: 'buy', size: 1 })).rejects.toThrow(/exactly one/);
await expect(build({ coin: 'HYPE', side: 'buy', size: 1, price: 1, market: { slippage: 0.01, mid: 1 } })).rejects.toThrow(/exactly one/);
await expect(build({ coin: 'HYPE', side: 'long' as never, size: 1, price: 1 })).rejects.toThrow(/side/);
await expect(build({ coin: 'HYPE', side: 'buy', size: 1, price: 1, tif: 'Fok' as never })).rejects.toThrow(/tif/);
await expect(build({ coin: '', side: 'buy', size: 1, price: 1 })).rejects.toThrow(/coin/);
await expect(build({ coin: 'HYPE', side: 'buy', size: -1, price: 50 })).rejects.toThrow(HlFormatError);
await expect(build({ coin: 'HYPE', side: 'buy', size: 1, price: 50 }, { priceRounding: 'up' as never })).rejects.toThrow(/priceRounding/);
});
it('an unlisted coin propagates the registry error', async () => {
await expect(build({ coin: 'NOPE', side: 'buy', size: 1, price: 1 })).rejects.toBeInstanceOf(AssetRegistryError);
});
});
describe('fetchMid and the per-call mid cache', () => {
it('returns the normalized mid, null when missing, and rethrows transport errors', async () => {
const { info } = infoStub(() => ({ HYPE: '100.50', BAD: 'NaN' }));
expect(await fetchMid(info, { coin: 'HYPE', dex: '' })).toBe('100.5');
expect(await fetchMid(info, { coin: 'BAD', dex: '' })).toBeNull();
expect(await fetchMid(info, { coin: 'NONE', dex: '' })).toBeNull();
expect(await fetchMid(info, { coin: 'constructor', dex: '' })).toBeNull();
const failing = (async () => {
throw new Error('HL info HTTP 503');
}) as InfoRequester;
await expect(fetchMid(failing, { coin: 'HYPE', dex: '' })).rejects.toThrow(/503/);
});
it('shares one read per dex, but does not cache a failed read', async () => {
let n = 0;
const info = (async () => {
n += 1;
if (n === 1) throw new Error('transient');
return { HYPE: '10', BTC: '20' };
}) as InfoRequester;
const mid = createMidCache(info, {});
if (!mid) throw new Error('expected a cache');
await expect(mid({ coin: 'HYPE', dex: '' })).rejects.toThrow('transient');
expect(await mid({ coin: 'HYPE', dex: '' })).toBe('10');
expect(await mid({ coin: 'BTC', dex: '' })).toBe('20');
expect(n).toBe(2);
expect(createMidCache(undefined, {})).toBeUndefined();
});
});
describe('buildOrder with a real asset registry', () => {
it('resolves szDecimals and asset id from meta', async () => {
const { info } = infoStub((body) => {
if (body.type === 'meta') return { universe: [{ name: 'BTC', szDecimals: 5, maxLeverage: 40 }, { name: 'ETH', szDecimals: 4, maxLeverage: 25 }] };
if (body.type === 'perpDexs') return [null];
throw new Error(`unexpected ${body.type}`);
});
const reg = createAssetRegistry(info, { crossCheck: false, dexes: [] });
const o = placed(await buildOrder(reg, { coin: 'ETH', side: 'buy', size: '0.12345', price: 3000.123 }));
expect(o.wire).toMatchObject({ a: 1, p: '3000.1', s: '0.1234' });
});
});