Skip to content
markpaper

src/orders/place.test.ts

v0.3.0 · 16.5 KB

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