src/risk/fill-summary.test.ts
v0.3.0 · 8.5 KB
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);
});
});