src/history/edge.test.ts
v0.3.0 · 9.2 KB
// Reviewer edge cases: page boundaries, float artifacts, zeros, negatives, HIP-3 and spot.
import { describe, expect, it } from 'vitest';
import type { InfoRequest, InfoRequester } from '../transport/types.js';
import { DAY_MS, utcDayWindow } from './cache.js';
import { fetchFillsByTime, fetchRecentFills } from './fills.js';
import { groupFillsByOrder } from './group.js';
import { realizedPnlSince, reconstructRoundTrips } from './roundtrips.js';
import { summarizePnl } from './stats.js';
import { fetchTwapSliceFillsByTime, mergeFillsWithTwap, parseTwapSliceFills } from './twap.js';
import type { FillLike, HlFill } from './types.js';
const USER = '0x0000000000000000000000000000000000000000'; // privacy-allow: placeholder address
let nextTid = 1_000;
function hl(p: Partial<HlFill> & { time: number }): HlFill {
nextTid += 1;
return {
coin: 'BTC',
px: '100',
sz: '1',
side: 'B',
startPosition: '0.0',
dir: 'Open Long',
closedPnl: '0.0',
hash: '0x00',
oid: nextTid,
crossed: false,
fee: '0.0',
feeToken: 'USDC',
tid: nextTid,
...p,
};
}
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('fills pagination edges', () => {
it('page cut exactly at the inclusive endTime millisecond keeps its tail', async () => {
const all = [
...Array.from({ length: 1999 }, (_, i) => hl({ time: 1_000 + i })),
...Array.from({ length: 5 }, () => hl({ time: 5_000 })), // endTime ms, cut after the first
hl({ time: 5_001 }), // outside the window
];
const { info, calls } = fillsServer(all, 2000);
const res = await fetchFillsByTime(info, USER, { startTime: 1_000, endTime: 5_000, pageDelayMs: 0 });
expect(res.fills).toHaveLength(2004);
expect(res.fills.every((x) => x.time <= 5_000)).toBe(true);
expect(res.complete).toBe(true);
expect(calls).toHaveLength(2);
expect(calls[1]!.startTime).toBe(5_000);
});
it('an empty window is complete after one request', async () => {
const { info, calls } = fillsServer([]);
const res = await fetchFillsByTime(info, USER, { startTime: 0, endTime: 10, pageDelayMs: 0 });
expect(res).toMatchObject({ fills: [], pages: 1, complete: true, aborted: false, denseMillis: [] });
expect(calls).toHaveLength(1);
});
it('exactly 2000 fills costs one confirming request and is complete', async () => {
const all = Array.from({ length: 2000 }, (_, i) => hl({ time: i }));
const { info, calls } = fillsServer(all);
const res = await fetchFillsByTime(info, USER, { startTime: 0, pageDelayMs: 0 });
expect(res.fills).toHaveLength(2000);
expect(res.complete).toBe(true);
expect(calls).toHaveLength(2);
});
it('keeps HIP-3, perp and spot fills of one response apart by coin, never asks for dex', async () => {
const all = [hl({ time: 1, coin: 'xyz:SPCX' }), hl({ time: 2, coin: 'SPCX' }), hl({ time: 3, coin: '@107' })];
const { info, calls } = fillsServer(all);
const res = await fetchFillsByTime(info, USER, { startTime: 0, pageDelayMs: 0 });
expect(res.fills.map((x) => x.coin)).toEqual(['xyz:SPCX', 'SPCX', '@107']);
expect(calls.every((c) => !('dex' in c))).toBe(true);
});
it('userFills returning null throws (a failed read is not "no fills")', async () => {
const info = (async () => null) as InfoRequester;
await expect(fetchRecentFills(info, USER)).rejects.toThrow(/non-array/);
});
});
describe('exact arithmetic on real-looking strings', () => {
it('group sums 0.1 + 0.2 partials exactly and weights the price', () => {
const g = groupFillsByOrder([
hl({ time: 1, oid: 7, sz: '0.1', px: '0.29', closedPnl: '0.1', fee: '-0.0001' }),
hl({ time: 2, oid: 7, sz: '0.2', px: '0.29', closedPnl: '0.2', fee: '-0.0002' }),
]);
expect(g).toHaveLength(1);
expect(g[0]!.sz).toBe('0.3');
expect(g[0]!.px).toBe('0.29');
expect(g[0]!.closedPnl).toBe('0.3');
expect(g[0]!.fee).toBe('-0.0003'); // maker rebate stays negative
});
it('net PnL with a maker rebate, a spot fee in another token and negative funding', () => {
const s = summarizePnl(
[
hl({ time: 1, closedPnl: '10.1', fee: '-0.2' }),
hl({ time: 2, closedPnl: '-0.3', fee: '0.1' }),
hl({ time: 3, coin: '@107', closedPnl: '0.0', fee: '5', feeToken: 'HYPE' }),
],
[{ delta: { usdc: '-0.7' } }, { delta: { usdc: '0.2' } }],
);
expect(s).toEqual({ closedPnl: '9.8', fees: '-0.1', funding: '-0.5', net: '9.4', nonUsdcFeeFills: 1 });
});
it('empty inputs give zeros, not NaN', () => {
expect(summarizePnl([])).toEqual({ closedPnl: '0', fees: '0', funding: '0', net: '0', nonUsdcFeeFills: 0 });
});
});
describe('round trips edges', () => {
const fl = (p: Partial<FillLike> & { time: number; side: 'B' | 'A'; sz: string; startPosition: string }): FillLike => {
nextTid += 1;
return { coin: 'ETH', px: '100', dir: 'Open Long', closedPnl: '0', fee: '0', tid: nextTid, ...p };
};
it('"-0.0" start and float-drifting partial closes still close flat', () => {
const res = reconstructRoundTrips([
fl({ time: 1, side: 'B', sz: '0.3', startPosition: '-0.0' }),
fl({ time: 2, side: 'A', sz: '0.1', startPosition: '0.3', dir: 'Close Long', closedPnl: '0.1' }),
fl({ time: 3, side: 'A', sz: '0.2', startPosition: '0.2', dir: 'Close Long', closedPnl: '0.2' }),
]);
// 0.3 - 0.1 - 0.2 in floats is 2.7e-17; exact decimals give 0.
expect(res.trips).toHaveLength(1);
expect(res.trips[0]).toMatchObject({ closedPnl: '0.3', closeKind: 'flat', closeNotional: '30' });
expect(res.open).toEqual([]);
});
it('main SPCX and xyz:SPCX are different markets; spot is skipped', () => {
const res = reconstructRoundTrips([
fl({ time: 1, coin: 'SPCX', side: 'B', sz: '1', startPosition: '0' }),
fl({ time: 2, coin: 'xyz:SPCX', side: 'A', sz: '1', startPosition: '0', dir: 'Open Short' }),
fl({ time: 3, coin: 'SPCX', side: 'A', sz: '1', startPosition: '1', dir: 'Close Long', closedPnl: '2' }),
fl({ time: 4, coin: 'PURR/USDC', side: 'B', sz: '5', startPosition: '0', dir: 'Buy' }),
]);
expect(res.trips.map((t) => t.coin)).toEqual(['SPCX']);
expect(res.open.map((t) => `${t.coin}|${t.side}|${t.position}`)).toEqual(['xyz:SPCX|SHORT|-1']);
expect(res.skippedNonTrade).toBe(1);
});
it('size in exponent form parses exactly', () => {
const res = reconstructRoundTrips([
fl({ time: 1, side: 'B', sz: '1e-8', startPosition: '0' }),
fl({ time: 2, side: 'A', sz: '0.00000001', startPosition: '1e-8', dir: 'Close Long' }),
]);
expect(res.trips).toHaveLength(1);
});
it('realizedPnlSince counts a flip fill as the close of its left side', () => {
const fills: FillLike[] = [
fl({ time: 10, side: 'B', sz: '1', startPosition: '0' }),
fl({ time: 20, side: 'A', sz: '0.5', startPosition: '1', dir: 'Close Long', closedPnl: '1' }),
fl({ time: 30, side: 'A', sz: '2', startPosition: '0.5', dir: 'Long > Short', closedPnl: '0.5', px: '102' }),
];
const long = realizedPnlSince(fills, { coin: 'ETH', side: 'LONG', openedAt: 10 });
expect(long.realizedPnl).toBe('1.5');
expect(long.exitPx).toBe('102');
expect(realizedPnlSince(fills, { coin: 'ETH', side: 'SHORT', openedAt: 10 }).realizedPnl).toBeUndefined();
});
it('zero exit price is not reported as exitPx; non-finite closedPnl gives undefined', () => {
const r = realizedPnlSince(
[fl({ time: 5, side: 'A', sz: '1', startPosition: '1', dir: 'Close Long', px: '0', closedPnl: 'x' })],
{ coin: 'ETH', side: 'LONG', openedAt: 0 },
);
expect(r.exitPx).toBeUndefined();
expect(r.realizedPnl).toBeUndefined();
expect(r.closeFills).toHaveLength(1);
});
});
describe('twap edges', () => {
it('wrapper twapId wins; junk rows are skipped; merge keeps the regular copy of a shared tid', () => {
const base = hl({ time: 5, tid: 42, dir: 'Close Short', side: 'B' });
const parsed = parseTwapSliceFills([{ fill: { ...base, twapId: null }, twapId: 77 }, null, 'x', { fill: {} }]);
expect(parsed).toHaveLength(1);
expect(parsed[0]!.twapId).toBe(77);
const merged = mergeFillsWithTwap([base], parsed);
expect(merged).toHaveLength(1);
expect(merged[0]!.source).toBe('fills');
});
it('ByTime rejects an inverted window', async () => {
const info = (async () => []) as InfoRequester;
await expect(fetchTwapSliceFillsByTime(info, USER, { startTime: 10, endTime: 5 })).rejects.toThrow(RangeError);
});
});
describe('utcDayWindow', () => {
it('one UTC day with an inclusive end', () => {
const start = Date.UTC(2026, 8, 5);
expect(utcDayWindow(start + 12 * 3_600_000)).toEqual({ startTime: start, endTime: start + DAY_MS - 1 });
expect(utcDayWindow(start)).toEqual({ startTime: start, endTime: start + DAY_MS - 1 });
expect(() => utcDayWindow(Number.NaN)).toThrow(RangeError);
});
});