Skip to content
markpaper

src/history/roundtrips.test.ts

v0.3.0 · 8 KB

Download file
import { describe, expect, it } from 'vitest';
import {
  isClosingDir,
  isPositionTradeDir,
  realizedPnlSince,
  reconstructRoundTrips,
} from './roundtrips.js';
import type { FillLike } from './types.js';

let tid = 0;
function f(p: Partial<FillLike> & { time: number; side: 'B' | 'A'; sz: string; startPosition: string }): FillLike {
  tid += 1;
  return { coin: 'BTC', px: '100', dir: 'Open Long', closedPnl: '0', fee: '0.1', tid, ...p };
}

describe('reconstructRoundTrips', () => {
  it('builds a flat -> flat trip and sums closedPnl of all partial closes', () => {
    const res = reconstructRoundTrips([
      f({ time: 1, side: 'B', sz: '1', startPosition: '0.0', dir: 'Open Long' }),
      f({ time: 2, side: 'B', sz: '1', startPosition: '1', dir: 'Open Long' }),
      f({ time: 3, side: 'A', sz: '0.5', startPosition: '2', dir: 'Close Long', closedPnl: '5', px: '110' }),
      f({ time: 4, side: 'A', sz: '1.5', startPosition: '1.5', dir: 'Close Long', closedPnl: '15', px: '110' }),
    ]);
    expect(res.trips).toHaveLength(1);
    const t = res.trips[0]!;
    expect(t.side).toBe('LONG');
    expect(t.closedPnl).toBe('20');
    expect(t.fee).toBe('0.4');
    expect(t.closeNotional).toBe('220');
    expect(t.maxSize).toBe('2');
    expect(t.fillCount).toBe(4);
    expect(t.durationMs).toBe(3);
    expect(t.closeKind).toBe('flat');
    expect(res.open).toEqual([]);
  });

  it('does not build a phantom cycle from a position opened before the window', () => {
    const res = reconstructRoundTrips([
      f({ time: 1, side: 'A', sz: '1', startPosition: '3', dir: 'Close Long', closedPnl: '9' }),
      f({ time: 2, side: 'A', sz: '2', startPosition: '2', dir: 'Close Long', closedPnl: '9' }),
      f({ time: 3, side: 'A', sz: '1', startPosition: '0', dir: 'Open Short' }),
      f({ time: 4, side: 'B', sz: '1', startPosition: '-1', dir: 'Close Short', closedPnl: '-2' }),
    ]);
    expect(res.skippedPreWindow).toBe(2);
    expect(res.trips).toHaveLength(1);
    expect(res.trips[0]!.side).toBe('SHORT');
    expect(res.trips[0]!.closedPnl).toBe('-2');
  });

  it('reports a trip still open at the end of the window', () => {
    const res = reconstructRoundTrips([
      f({ time: 1, side: 'B', sz: '1', startPosition: '0', coin: 'xyz:TSLA' }),
      f({ time: 2, side: 'B', sz: '0.5', startPosition: '1', coin: 'xyz:TSLA' }),
    ]);
    expect(res.trips).toEqual([]);
    expect(res.open).toHaveLength(1);
    expect(res.open[0]).toMatchObject({ coin: 'xyz:TSLA', side: 'LONG', position: '1.5', fillCount: 2 });
  });

  it('splits a flipping fill into a closed trip and a new opposite trip', () => {
    const res = reconstructRoundTrips([
      f({ time: 1, side: 'B', sz: '1', startPosition: '0' }),
      f({ time: 2, side: 'A', sz: '3', startPosition: '1', dir: 'Long > Short', closedPnl: '4', px: '104' }),
      f({ time: 3, side: 'B', sz: '2', startPosition: '-2', dir: 'Close Short', closedPnl: '1' }),
    ]);
    expect(res.trips.map((t) => [t.side, t.closeKind, t.closedPnl, t.closeNotional])).toEqual([
      ['LONG', 'flip', '4', '104'],
      ['SHORT', 'flat', '1', '200'],
    ]);
    expect(res.trips[1]!.openTime).toBe(2);
  });

  it('closes a trip as inferred when the exchange reports flat but no close fill was seen', () => {
    const res = reconstructRoundTrips([
      f({ time: 1, side: 'B', sz: '1', startPosition: '0' }),
      // closing fill (e.g. a TWAP slice) missing
      f({ time: 5, side: 'A', sz: '1', startPosition: '0', dir: 'Open Short' }),
    ]);
    expect(res.trips).toHaveLength(1);
    expect(res.trips[0]).toMatchObject({ side: 'LONG', closeKind: 'inferred', closeTime: 1 });
    expect(res.open[0]).toMatchObject({ side: 'SHORT' });
  });

  it('closes as inferred when the position side changed without a seen flip', () => {
    const res = reconstructRoundTrips([
      f({ time: 1, side: 'B', sz: '1', startPosition: '0' }),
      f({ time: 2, side: 'B', sz: '1', startPosition: '-3', dir: 'Close Short' }),
    ]);
    expect(res.trips[0]!.closeKind).toBe('inferred');
    expect(res.skippedPreWindow).toBe(1);
    expect(res.open).toEqual([]);
  });

  it('skips Settlement, spot and unknown dirs', () => {
    const res = reconstructRoundTrips([
      f({ time: 1, side: 'B', sz: '1', startPosition: '0', dir: 'Settlement' }),
      f({ time: 2, side: 'B', sz: '1', startPosition: '0', dir: 'Buy', coin: 'PURR/USDC' }),
      f({ time: 3, side: 'B', sz: '1', startPosition: '0', dir: 'Buy', coin: '@107' }),
      f({ time: 4, side: 'B', sz: '1', startPosition: '0', dir: 'Something' }),
    ]);
    expect(res.skippedNonTrade).toBe(4);
    expect(res.open).toEqual([]);
  });

  it('handles liquidation dirs as closing', () => {
    const res = reconstructRoundTrips([
      f({ time: 1, side: 'B', sz: '2', startPosition: '0' }),
      f({ time: 2, side: 'A', sz: '2', startPosition: '2', dir: 'Liquidated Isolated Long', closedPnl: '-50' }),
    ]);
    expect(res.trips[0]!.closedPnl).toBe('-50');
  });

  it('uses exact decimals and a relative flat epsilon', () => {
    // 0.1 + 0.2 - 0.3 is not 0 in floats; it is flat here.
    const exact = reconstructRoundTrips([
      f({ time: 1, side: 'B', sz: '0.1', startPosition: '0' }),
      f({ time: 2, side: 'B', sz: '0.2', startPosition: '0.1' }),
      f({ time: 3, side: 'A', sz: '0.3', startPosition: '0.3', dir: 'Close Long' }),
    ]);
    expect(exact.trips).toHaveLength(1);
    // residue below max(sz*1e-6, 1e-9) counts as flat
    const eps = reconstructRoundTrips([
      f({ time: 1, side: 'B', sz: '1000', startPosition: '0' }),
      f({ time: 2, side: 'A', sz: '1000', startPosition: '1000.0005', dir: 'Close Long' }),
    ]);
    expect(eps.trips).toHaveLength(1);
    // residue above the epsilon is not flat
    const notFlat = reconstructRoundTrips([
      f({ time: 1, side: 'B', sz: '1', startPosition: '0' }),
      f({ time: 2, side: 'A', sz: '1', startPosition: '1.01', dir: 'Close Long' }),
    ]);
    expect(notFlat.trips).toHaveLength(0);
    expect(notFlat.open).toHaveLength(1);
  });

  it('tracks coins independently and sorts unsorted input', () => {
    const res = reconstructRoundTrips([
      f({ time: 4, side: 'A', sz: '1', startPosition: '1', coin: 'ETH', dir: 'Close Long' }),
      f({ time: 3, side: 'A', sz: '1', startPosition: '1', dir: 'Close Long' }),
      f({ time: 1, side: 'B', sz: '1', startPosition: '0' }),
      f({ time: 2, side: 'B', sz: '1', startPosition: '0', coin: 'ETH' }),
    ]);
    expect(res.trips.map((t) => t.coin)).toEqual(['BTC', 'ETH']);
  });
});

describe('dir classifiers', () => {
  it('classifies trade and closing dirs', () => {
    expect(isPositionTradeDir('Open Long')).toBe(true);
    expect(isPositionTradeDir('Settlement')).toBe(false);
    expect(isPositionTradeDir('Buy')).toBe(false);
    expect(isClosingDir('Close Short')).toBe(true);
    expect(isClosingDir('Liquidated Cross Long')).toBe(true);
    expect(isClosingDir('Open Short')).toBe(false);
  });
});

describe('realizedPnlSince', () => {
  const fills: FillLike[] = [
    { coin: 'SOL', px: '90', sz: '1', side: 'A', time: 100, dir: 'Close Long', closedPnl: '-7' }, // older trade
    { coin: 'SOL', px: '101', sz: '1', side: 'A', time: 1_100, dir: 'Close Long', closedPnl: '1.5' },
    { coin: 'SOL', px: '103', sz: '1', side: 'A', time: 1_200, dir: 'Close Long', closedPnl: '3.25' },
    { coin: 'SOL', px: '99', sz: '1', side: 'B', time: 1_300, dir: 'Close Short', closedPnl: '100' }, // other side
    { coin: 'SOL', px: '100', sz: '1', side: 'B', time: 1_050, dir: 'Open Long', closedPnl: '0' },
  ];

  it('sums all close fills of that side since openedAt; exit is the latest close', () => {
    const r = realizedPnlSince(fills, { coin: 'SOL', side: 'LONG', openedAt: 1_000 });
    expect(r.realizedPnl).toBe('4.75');
    expect(r.exitPx).toBe('103');
    expect(r.exitTime).toBe(1_200);
    expect(r.closeFills).toHaveLength(2);
  });

  it('returns undefined when there is no close fill yet (indexing lag)', () => {
    const r = realizedPnlSince(fills, { coin: 'SOL', side: 'LONG', openedAt: 5_000 });
    expect(r.realizedPnl).toBeUndefined();
    expect(r.exitPx).toBeUndefined();
  });

});
All files