Skip to content
markpaper

src/risk/fill-summary.test.ts

v0.3.0 · 8.5 KB

Download file
import { describe, expect, it } from 'vitest';
import { isLiquidationFill, summarizeFills, type SummaryFill } from './fill-summary.js';

describe('summarizeFills', () => {
  it('returns zeros (no NaN) for an empty list', () => {
    const s = summarizeFills([]);
    expect(s).toMatchObject({
      fillCount: 0,
      turnover: 0,
      makerShare: 0,
      fee: 0,
      net: 0,
      feeBps: 0,
      netBps: 0,
      realizedMakerBps: null,
      realizedTakerBps: null,
    });
  });

  it('computes turnover, maker share, fees, closedPnl and net = closedPnl − fee', () => {
    const fills: SummaryFill[] = [
      // maker open, fee rounded by the exchange (12.5 × 0.00015 = 0.001875)
      { px: '50.00', sz: '0.25', fee: '0.0019', feeToken: 'USDC', crossed: false, closedPnl: '0.0' },
      // maker close of a short: closedPnl excludes the fee
      { px: '50.00', sz: '0.5', fee: '0.0038', feeToken: 'USDC', crossed: false, closedPnl: '0.0125' },
      // taker fill
      { px: '100', sz: '0.2', fee: '0.009', feeToken: 'USDC', crossed: true, closedPnl: '-0.05' },
    ];
    const s = summarizeFills(fills);
    expect(s.fillCount).toBe(3);
    expect(s.makerFills).toBe(2);
    expect(s.takerFills).toBe(1);
    expect(s.turnover).toBe(57.5); // 12.5 + 25 + 20
    expect(s.makerNotional).toBe(37.5);
    expect(s.takerNotional).toBe(20);
    expect(s.makerShare).toBeCloseTo(37.5 / 57.5, 12);
    expect(s.fee).toBe(0.0147);
    expect(s.closedPnl).toBe(-0.0375);
    expect(s.net).toBe(-0.0522);
    expect(s.feeBps).toBeCloseTo((1e4 * 0.0147) / 57.5, 12);
    expect(s.netBps).toBeCloseTo((1e4 * -0.0522) / 57.5, 12);
    expect(s.realizedMakerBps).toBeCloseTo((1e4 * 0.0057) / 37.5, 12);
    expect(s.realizedTakerBps).toBeCloseTo(4.5, 12);
  });

  it('treats a negative fee as a rebate that increases net', () => {
    const s = summarizeFills([
      { px: '50', sz: '10', fee: '-0.01', crossed: false, closedPnl: '1' },
      { px: '50', sz: '10', fee: '0.09', crossed: true, closedPnl: '0' },
    ]);
    expect(s.fee).toBe(0.08);
    expect(s.feesPaid).toBe(0.09);
    expect(s.rebates).toBe(0.01);
    expect(s.net).toBe(0.92);
    expect(s.realizedMakerBps).toBeCloseTo(-0.2, 12);
  });

  it('sums thousands of fills without float drift', () => {
    const fills: SummaryFill[] = Array.from({ length: 5000 }, () => ({
      px: '0.1',
      sz: '3',
      fee: '0.00003',
      crossed: false,
      closedPnl: '0.01',
    }));
    const s = summarizeFills(fills);
    expect(s.turnover).toBe(1500);
    expect(s.fee).toBe(0.15);
    expect(s.closedPnl).toBe(50);
    expect(s.net).toBe(49.85);
  });

  it('reports builder fee separately and does not subtract it from net', () => {
    const s = summarizeFills([{ px: '100', sz: '1', fee: '0.045', crossed: true, closedPnl: '1', builderFee: '0.05' }]);
    expect(s.builderFee).toBe(0.05);
    expect(s.net).toBe(0.955);
  });

  it('keeps non-USD fee tokens out of fee/net but lists them per token', () => {
    const s = summarizeFills([
      { px: '10', sz: '1', fee: '0.001', feeToken: 'USDC', crossed: true },
      { px: '10', sz: '1', fee: '0.2', feeToken: 'HYPE', crossed: true },
    ]);
    expect(s.fee).toBe(0.001);
    expect(s.feesByToken).toEqual({ USDC: 0.001, HYPE: 0.2 });
    expect(s.realizedTakerBps).toBeCloseTo(1, 12); // only the USDC fill is in the denominator
    const s2 = summarizeFills([{ px: '10', sz: '1', fee: '0.2', feeToken: 'USDH', crossed: true }], {
      usdFeeTokens: ['USDC', 'USDH'],
    });
    expect(s2.fee).toBe(0.2);
  });

  it('adds funding only to netWithFunding', () => {
    const s = summarizeFills([{ px: '100', sz: '1', fee: '0.045', crossed: true, closedPnl: '2' }], { fundingUsd: '-0.3' });
    expect(s.net).toBe(1.955);
    expect(s.netWithFunding).toBe(1.655);
  });

  it('counts taker fills (a bug for a post-only bot) and liquidation fills', () => {
    const s = summarizeFills([
      { px: '1', sz: '20', fee: '0.009', crossed: true, dir: 'Close Long' },
      { px: '1', sz: '20', fee: '0.009', crossed: true, dir: 'Liquidated Isolated Long' },
    ]);
    expect(s.takerFills).toBe(2);
    expect(s.liquidationFills).toBe(1);
  });

  it('treats missing fee/closedPnl as 0 and throws on malformed numbers', () => {
    expect(summarizeFills([{ px: '10', sz: '2', crossed: false }]).fee).toBe(0);
    expect(() => summarizeFills([{ px: 'oops', sz: '2', crossed: false }])).toThrow(TypeError);
    expect(() => summarizeFills([{ px: '1', sz: '2', fee: 'NaN', crossed: false }])).toThrow(/fill\[0\]\.fee/);
  });
});

describe('summarizeFills: mixed fee tokens, HIP-3 and malformed fills', () => {
  it('does not dilute feeBps with fills whose fee is not counted, and reports them', () => {
    const s = summarizeFills([
      { px: '100', sz: '1', fee: '0.045', feeToken: 'USDC', crossed: true },
      // HIP-3 dex with USDH collateral: fee not in the USD set by default
      { px: '100', sz: '9', fee: '0.405', feeToken: 'USDH', crossed: true },
    ]);
    expect(s.turnover).toBe(1000);
    expect(s.usdFeeTurnover).toBe(100);
    expect(s.fee).toBe(0.045);
    expect(s.feeBps).toBe(4.5); // was 0.45 with the total turnover denominator
    expect(s.unpricedFeeFills).toBe(1);
    expect(s.feesByToken).toEqual({ USDC: 0.045, USDH: 0.405 });
    const all = summarizeFills(
      [
        { px: '100', sz: '1', fee: '0.045', feeToken: 'USDC', crossed: true },
        { px: '100', sz: '9', fee: '0.405', feeToken: 'USDH', crossed: true },
      ],
      { usdFeeTokens: ['USDC', 'USDH'] },
    );
    expect(all.unpricedFeeFills).toBe(0);
    expect(all.fee).toBe(0.45);
    expect(all.feeBps).toBeCloseTo(4.5, 12);
  });

  it('only non-USD fills -> feeBps 0, realized rates null, no NaN', () => {
    const s = summarizeFills([{ px: '80', sz: '1', fee: '0.01', feeToken: 'HYPE', crossed: false }]);
    expect(s.feeBps).toBe(0);
    expect(s.realizedMakerBps).toBeNull();
    expect(Number.isNaN(s.netBps)).toBe(false);
  });

  it('throws on a fill without px or sz instead of counting it as zero turnover', () => {
    expect(() => summarizeFills([{ sz: '1', crossed: true } as unknown as SummaryFill])).toThrow(/fill\[0\]\.px/);
    expect(() => summarizeFills([{ px: '1', sz: '', crossed: true }])).toThrow(/fill\[0\]\.sz/);
  });

  it('is exact on API-shaped px/sz strings where float products drift', () => {
    // Float: 5123.4 × 0.003 = 15.370199999999999
    const s = summarizeFills([
      { px: '64123.0', sz: '0.00731', fee: '0.210933', crossed: true },
      { px: '5123.4', sz: '0.003', fee: '0.006917', crossed: true },
    ]);
    expect(s.turnover).toBe(484.10933);
    expect(s.takerNotional).toBe(484.10933);
  });

  it('a zero fee is neither paid nor a rebate', () => {
    const s = summarizeFills([{ px: '10', sz: '1', fee: '0.0', crossed: false }]);
    expect(s.feesPaid).toBe(0);
    expect(s.rebates).toBe(0);
  });
});

describe('isLiquidationFill', () => {
  it('uses liquidatedUser to tell the victim from the counterparty', () => {
    const own = '0x0000000000000000000000000000000000000001'; // privacy-allow: placeholder address
    const other = '0x0000000000000000000000000000000000000002'; // privacy-allow: placeholder address
    const liq = { markPx: '100.0', method: 'market', liquidatedUser: own };
    expect(isLiquidationFill({ dir: 'Close Long', liquidation: liq }, own.toUpperCase().replace('0X', '0x'))).toBe(true);
    expect(isLiquidationFill({ dir: 'Open Short', liquidation: { ...liq, liquidatedUser: other } }, own)).toBe(false);
    expect(isLiquidationFill({ dir: 'Open Short', liquidation: { ...liq, liquidatedUser: other } })).toBe(true);
    // dir marker always wins
    expect(isLiquidationFill({ dir: 'Liquidated Cross Long', liquidation: { liquidatedUser: other } }, own)).toBe(true);
    const s = summarizeFills(
      [
        { px: '1', sz: '20', crossed: true, liquidation: liq },
        { px: '1', sz: '20', crossed: true, liquidation: { ...liq, liquidatedUser: other } },
      ],
      { user: own },
    );
    expect(s.liquidationFills).toBe(1);
  });

  it('detects Liquidat in dir case-insensitively', () => {
    expect(isLiquidationFill({ dir: 'Liquidated Cross Long' })).toBe(true);
    expect(isLiquidationFill({ dir: 'close short (LIQUIDATION)' })).toBe(true);
    expect(isLiquidationFill({ dir: 'Close Long' })).toBe(false);
    expect(isLiquidationFill({})).toBe(false);
  });

  it('detects a present liquidation field', () => {
    expect(isLiquidationFill({ liquidation: { method: 'market' } })).toBe(true);
    expect(isLiquidationFill({ liquidation: null })).toBe(false);
    expect(isLiquidationFill({ liquidation: false })).toBe(false);
  });
});
All files