Skip to content
markpaper

src/orders/close.test.ts

v0.3.0 · 13.3 KB

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