Skip to content
markpaper

src/orders/build.test.ts

v0.3.0 · 17.2 KB

Download file
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' });
  });
});
All files