src/history/fills.test.ts
v0.3.0 · 7.4 KB
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('');
});
});