Skip to content
markpaper

src/history/edge.test.ts

v0.3.0 · 9.2 KB

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