Skip to content
markpaper

src/history/fills.test.ts

v0.3.0 · 7.4 KB

Download file
import { describe, expect, it } from 'vitest';
import type { InfoRequest, InfoRequester } from '../transport/types.js';
import {
  coinDex,
  createFillDeduper,
  dedupFills,
  fetchFillsByTime,
  fetchRecentFills,
  fillKey,
  isSpotCoin,
  sortFills,
} from './fills.js';
import type { HlFill } from './types.js';

const USER = '0x0000000000000000000000000000000000000000'; // privacy-allow: placeholder address

function mkFill(p: Partial<HlFill> & { time: number; tid: number }): HlFill {
  return {
    coin: 'BTC',
    px: '100',
    sz: '1',
    side: 'B',
    startPosition: '0.0',
    dir: 'Open Long',
    closedPnl: '0.0',
    hash: '0x00',
    oid: p.tid,
    crossed: true,
    fee: '0.1',
    feeToken: 'USDC',
    ...p,
  };
}

/** Simulates userFillsByTime: oldest `cap` fills from startTime, inclusive endTime. */
function fillsServer(all: HlFill[], cap = 2000) {
  const calls: InfoRequest[] = [];
  const info = (async (body: InfoRequest) => {
    calls.push(body);
    const start = body.startTime as number;
    const end = body.endTime as number | undefined;
    return all
      .filter((f) => f.time >= start && (end === undefined || f.time <= end))
      .sort((a, b) => a.time - b.time || a.tid - b.tid)
      .slice(0, cap);
  }) as InfoRequester;
  return { info, calls };
}

describe('fetchFillsByTime', () => {
  it('pages forward past the 2000 cap and keeps fills cut inside one millisecond', async () => {
    // 3 fills per ms: the first page (2000) ends in the middle of a millisecond,
    // where a `newest + 1` cursor would drop fills.
    const all = Array.from({ length: 4500 }, (_, i) => mkFill({ tid: i + 1, time: 1_000 + Math.floor(i / 3) }));
    const { info, calls } = fillsServer(all);
    const res = await fetchFillsByTime(info, USER, { startTime: 1_000, endTime: 10_000, pageDelayMs: 0 });
    expect(res.fills).toHaveLength(4500);
    expect(new Set(res.fills.map((f) => f.tid)).size).toBe(4500);
    expect(res.complete).toBe(true);
    expect(res.pages).toBe(3);
    expect(res.fills[0]!.time).toBeLessThanOrEqual(res.fills[4499]!.time);
  });

  it('never sends dex and defaults aggregateByTime to false', async () => {
    const { info, calls } = fillsServer([mkFill({ tid: 1, time: 5 })]);
    await fetchFillsByTime(info, USER, { startTime: 0, endTime: 10, pageDelayMs: 0 });
    expect(calls).toHaveLength(1);
    expect(calls[0]).toEqual({ type: 'userFillsByTime', user: USER, startTime: 0, endTime: 10, aggregateByTime: false });
    expect('dex' in calls[0]!).toBe(false);
  });

  it('omits endTime when not given and forwards aggregateByTime', async () => {
    const { info, calls } = fillsServer([]);
    const res = await fetchFillsByTime(info, USER, { startTime: 0, aggregateByTime: true, pageDelayMs: 0 });
    expect('endTime' in calls[0]!).toBe(false);
    expect(calls[0]!.aggregateByTime).toBe(true);
    expect(res.complete).toBe(true);
    expect(res.fills).toEqual([]);
  });

  it('flags a millisecond with more than 2000 fills (unreachable tail)', async () => {
    const dense = Array.from({ length: 2500 }, (_, i) => mkFill({ tid: i + 1, time: 5_000 }));
    const later = Array.from({ length: 10 }, (_, i) => mkFill({ tid: 10_000 + i, time: 5_001 + i }));
    const { info } = fillsServer([...dense, ...later]);
    const res = await fetchFillsByTime(info, USER, { startTime: 0, pageDelayMs: 0 });
    expect(res.denseMillis).toEqual([5_000]);
    expect(res.fills).toHaveLength(2010);
    expect(res.fills.filter((f) => f.time > 5_000)).toHaveLength(10);
  });

  it('aborts at maxFills (guard for very active accounts)', async () => {
    const all = Array.from({ length: 6000 }, (_, i) => mkFill({ tid: i + 1, time: i }));
    const { info } = fillsServer(all);
    const res = await fetchFillsByTime(info, USER, { startTime: 0, maxFills: 3000, pageDelayMs: 0 });
    expect(res.aborted).toBe(true);
    expect(res.complete).toBe(false);
    expect(res.pages).toBe(2);
  });

  it('propagates errors instead of returning an empty list', async () => {
    const info = (async () => {
      throw new Error('HTTP 500');
    }) as InfoRequester;
    await expect(fetchFillsByTime(info, USER, { startTime: 0, pageDelayMs: 0 })).rejects.toThrow('HTTP 500');
    const bad = (async () => null) as InfoRequester;
    await expect(fetchFillsByTime(bad, USER, { startTime: 0, pageDelayMs: 0 })).rejects.toThrow(/non-array/);
  });

  it('validates the window', async () => {
    const { info } = fillsServer([]);
    await expect(fetchFillsByTime(info, USER, { startTime: 10, endTime: 5 })).rejects.toThrow(RangeError);
    await expect(fetchFillsByTime(info, USER, { startTime: -1 })).rejects.toThrow(RangeError);
  });

  it('forwards the abort signal to the requester', async () => {
    const ac = new AbortController();
    let seen: AbortSignal | undefined;
    const info = (async (_b: InfoRequest, o?: { signal?: AbortSignal }) => {
      seen = o?.signal;
      return [];
    }) as InfoRequester;
    await fetchFillsByTime(info, USER, { startTime: 0, signal: ac.signal });
    expect(seen).toBe(ac.signal);
  });
});

describe('fetchRecentFills', () => {
  it('makes one userFills request without dex, sorts, dedups and flags the cap', async () => {
    const calls: InfoRequest[] = [];
    const raw = Array.from({ length: 2000 }, (_, i) => mkFill({ tid: 2000 - i, time: 2000 - i }));
    const info = (async (b: InfoRequest) => {
      calls.push(b);
      return [...raw, raw[0]];
    }) as InfoRequester;
    const res = await fetchRecentFills(info, USER);
    expect(calls).toEqual([{ type: 'userFills', user: USER }]);
    expect(res.capped).toBe(true);
    expect(res.fills).toHaveLength(2000);
    expect(res.fills[0]!.time).toBe(1);
  });

  it('is not capped below 2000', async () => {
    const info = (async () => [mkFill({ tid: 1, time: 1 })]) as InfoRequester;
    expect((await fetchRecentFills(info, USER, { aggregateByTime: true })).capped).toBe(false);
  });
});

describe('dedup helpers', () => {
  it('dedups by tid (main + ignored-dex duplicate would double PnL)', () => {
    const a = mkFill({ tid: 1, time: 1, coin: 'xyz:TSLA', closedPnl: '5' });
    const res = dedupFills([a, { ...a }, mkFill({ tid: 2, time: 1 })]);
    expect(res).toHaveLength(2);
  });

  it('falls back to a composite key when tid is missing', () => {
    const f = { coin: 'BTC', px: '1', sz: '2', side: 'B' as const, time: 3, dir: 'Open Long', closedPnl: '0' };
    expect(fillKey(f)).toBe('__3_BTC_1_2_B');
    expect(dedupFills([f, { ...f }, { ...f, sz: '3' }])).toHaveLength(2);
  });

  it('sortFills orders by time then tid without mutating input', () => {
    const input = [mkFill({ tid: 3, time: 2 }), mkFill({ tid: 2, time: 1 }), mkFill({ tid: 1, time: 2 })];
    expect(sortFills(input).map((f) => f.tid)).toEqual([2, 1, 3]);
    expect(input[0]!.tid).toBe(3);
  });

  it('bounded deduper evicts the oldest keys', () => {
    const dd = createFillDeduper({ maxSize: 4, evictCount: 2 });
    for (let i = 1; i <= 5; i += 1) expect(dd.add(mkFill({ tid: i, time: i }))).toBe(true);
    expect(dd.size).toBe(3);
    expect(dd.has(mkFill({ tid: 1, time: 1 }))).toBe(false);
    expect(dd.has(mkFill({ tid: 5, time: 5 }))).toBe(true);
    expect(dd.add(mkFill({ tid: 5, time: 5 }))).toBe(false);
  });

  it('classifies spot and HIP-3 coins', () => {
    expect(isSpotCoin('PURR/USDC')).toBe(true);
    expect(isSpotCoin('@107')).toBe(true);
    expect(isSpotCoin('xyz:TSLA')).toBe(false);
    expect(coinDex('xyz:TSLA')).toBe('xyz');
    expect(coinDex('BTC')).toBe('');
  });
});
All files