Skip to content
markpaper

src/risk/fees.test.ts

v0.3.0 · 11 KB

Download file
import { describe, expect, it } from 'vitest';
import type { InfoCallOptions, InfoRequest, InfoRequester } from '../transport/types.js';
import {
  MAX_STAKING_DISCOUNT,
  PERP_FEE_TIERS,
  PERP_MM_REBATE,
  backtestTradePnlUsd,
  bpsToRate,
  feeRatesForVolume,
  feeTierForVolume,
  fillFee,
  fillNotional,
  fundingPaymentUsd,
  parseFeeScheduleTiers,
  parseUserFees,
  rateToBps,
  readUserFees,
  roundTripFeeUsd,
} from './fees.js';

describe('PERP_FEE_TIERS', () => {
  it('matches the built-in table (T0 base 1.5 / 4.5 bps)', () => {
    expect(PERP_FEE_TIERS.map((t) => [t.id, t.minVolume14dUsd, t.makerBps, t.takerBps])).toEqual([
      ['T0', 0, 1.5, 4.5],
      ['T1', 5e6, 1.2, 4.0],
      ['T2', 25e6, 0.8, 3.5],
      ['T3', 100e6, 0.4, 3.0],
      ['T4', 500e6, 0, 2.8],
      ['T5', 2e9, 0, 2.6],
    ]);
  });

  it('is frozen', () => {
    expect(Object.isFrozen(PERP_FEE_TIERS)).toBe(true);
    expect(Object.isFrozen(PERP_FEE_TIERS[0])).toBe(true);
  });

  it('keeps the MM rebate separate from the tiers', () => {
    expect(PERP_MM_REBATE.minMakerVolumeShare).toBe(0.005);
    expect(PERP_MM_REBATE.makerBpsRange).toEqual([-0.3, -0.1]);
    expect(PERP_FEE_TIERS.every((t) => t.makerBps >= 0)).toBe(true);
    expect(MAX_STAKING_DISCOUNT).toBe(0.4);
  });
});

describe('bps <-> rate', () => {
  it('converts exactly', () => {
    expect(bpsToRate(1.5)).toBe(0.00015);
    expect(bpsToRate(1.2)).toBe(0.00012);
    expect(rateToBps('0.00045')).toBe(4.5);
    expect(rateToBps(0.00015)).toBe(1.5);
  });
});

describe('feeTierForVolume', () => {
  it('picks the highest reached threshold, inclusive', () => {
    expect(feeTierForVolume(0).id).toBe('T0');
    expect(feeTierForVolume(4_999_999.99).id).toBe('T0');
    expect(feeTierForVolume(5_000_000).id).toBe('T1');
    expect(feeTierForVolume(99_999_999).id).toBe('T2');
    expect(feeTierForVolume(100_000_000).id).toBe('T3');
    expect(feeTierForVolume(500_000_000).id).toBe('T4');
    expect(feeTierForVolume(7e9).id).toBe('T5'); // unverified >$2B step is not modelled
  });

  it('rejects negative / non-finite volume and empty tables', () => {
    expect(() => feeTierForVolume(-1)).toThrow(RangeError);
    expect(() => feeTierForVolume(Number.NaN)).toThrow(RangeError);
    expect(() => feeTierForVolume(1, [])).toThrow(RangeError);
  });

  it('accepts an unsorted custom table', () => {
    const tiers = [
      { id: 'B', minVolume14dUsd: 10, makerBps: 1, takerBps: 2 },
      { id: 'A', minVolume14dUsd: 0, makerBps: 3, takerBps: 4 },
    ];
    expect(feeTierForVolume(5, tiers).id).toBe('A');
    expect(feeTierForVolume(10, tiers).id).toBe('B');
  });
});

describe('feeRatesForVolume', () => {
  it('returns bps and fractions for the tier', () => {
    const r = feeRatesForVolume(0);
    expect(r).toMatchObject({ makerBps: 1.5, takerBps: 4.5, makerRate: 0.00015, takerRate: 0.00045 });
    expect(r.tier.id).toBe('T0');
  });

  it('applies the max staking discount (40%) exactly', () => {
    const r = feeRatesForVolume(0, { stakingDiscount: 0.4 });
    expect(r.makerBps).toBe(0.9);
    expect(r.takerBps).toBe(2.7);
    expect(r.makerRate).toBe(0.00009);
  });

  it('stacks staking and referral multiplicatively (experimental)', () => {
    const r = feeRatesForVolume(0, { stakingDiscount: 0.1, referralDiscount: 0.04 });
    // 4.5 × 0.9 × 0.96 = 3.888
    expect(r.takerBps).toBe(3.888);
  });

  it('never discounts a zero maker rate', () => {
    const r = feeRatesForVolume(600e6, { stakingDiscount: 0.4 });
    expect(r.makerBps).toBe(0);
    expect(r.takerBps).toBe(1.68);
  });

  it('never scales a rebate', () => {
    const tiers = [{ id: 'R', minVolume14dUsd: 0, makerBps: -0.2, takerBps: 2.5 }];
    const r = feeRatesForVolume(1, { stakingDiscount: 0.4, tiers });
    expect(r.makerBps).toBe(-0.2);
    expect(r.takerBps).toBe(1.5);
  });

  it('validates discount ranges', () => {
    expect(() => feeRatesForVolume(0, { stakingDiscount: 0.5 })).toThrow(RangeError);
    expect(() => feeRatesForVolume(0, { stakingDiscount: -0.1 })).toThrow(RangeError);
    expect(() => feeRatesForVolume(0, { referralDiscount: 1 })).toThrow(RangeError);
    expect(() => feeRatesForVolume(0, { referralDiscount: Number.NaN })).toThrow(RangeError);
  });
});

describe('parseUserFees / readUserFees', () => {
  it('maps userAddRate to maker and userCrossRate to taker', () => {
    const r = parseUserFees({ userAddRate: '0.00015', userCrossRate: '0.00045', activeReferralDiscount: '0.04' });
    expect(r).toEqual({
      makerRate: 0.00015,
      takerRate: 0.00045,
      makerBps: 1.5,
      takerBps: 4.5,
      referralDiscount: 0.04,
      stakingDiscount: 0,
      spotMakerRate: null,
      spotTakerRate: null,
      spotMakerBps: null,
      spotTakerBps: null,
    });
  });

  it('parses spot rates and the active staking discount of a full response (SDK shape)', () => {
    const r = parseUserFees({
      dailyUserVlm: [],
      userCrossRate: '0.00045',
      userAddRate: '0.00015',
      userSpotCrossRate: '0.0007',
      userSpotAddRate: '0.0004',
      activeReferralDiscount: '0.0',
      trial: null,
      feeTrialEscrow: '0.0',
      nextTrialAvailableTimestamp: null,
      stakingLink: null,
      activeStakingDiscount: { bpsOfMaxSupply: '0.0', discount: '0.05' },
    });
    expect(r.spotTakerBps).toBe(7);
    expect(r.spotMakerBps).toBe(4);
    expect(r.spotMakerRate).toBe(0.0004);
    expect(r.stakingDiscount).toBe(0.05);
    expect(r.referralDiscount).toBe(0);
  });

  it('keeps float-hostile rates exact (0.000028 × 1e4 = 0.28, not 0.27999999999999997)', () => {
    expect(0.000028 * 1e4).not.toBe(0.28);
    const r = parseUserFees({ userAddRate: '0.000028', userCrossRate: '0.000385' });
    expect(r.makerBps).toBe(0.28);
    expect(r.takerBps).toBe(3.85);
  });

  it('defaults a missing referral discount to 0 and accepts numbers', () => {
    const r = parseUserFees({ userAddRate: -0.00002, userCrossRate: 0.00025 });
    expect(r.makerBps).toBe(-0.2);
    expect(r.takerBps).toBe(2.5);
    expect(r.referralDiscount).toBe(0);
  });

  it('throws on a response without rates', () => {
    expect(() => parseUserFees(null)).toThrow(TypeError);
    expect(() => parseUserFees({ userCrossRate: '0.00045' })).toThrow(TypeError);
    expect(() => parseUserFees({ userAddRate: 'n/a', userCrossRate: '0.00045' })).toThrow(TypeError);
  });

  it('reads through the InfoRequester with weight 20', async () => {
    const calls: Array<{ body: InfoRequest; opts: InfoCallOptions | undefined }> = [];
    const info = (async (body: InfoRequest, opts?: InfoCallOptions) => {
      calls.push({ body, opts });
      return { userAddRate: '0.00012', userCrossRate: '0.0004', activeReferralDiscount: '0' };
    }) as InfoRequester;
    const r = await readUserFees(info, '0xYOUR_ADDRESS', { timeoutMs: 5000 });
    expect(r.makerBps).toBe(1.2);
    expect(r.takerBps).toBe(4);
    expect(calls).toEqual([{ body: { type: 'userFees', user: '0xYOUR_ADDRESS' }, opts: { weight: 20, timeoutMs: 5000 } }]);
  });

  it('propagates transport errors', async () => {
    const info = (async () => {
      throw new Error('429');
    }) as InfoRequester;
    await expect(readUserFees(info, '0xYOUR_ADDRESS')).rejects.toThrow('429');
  });
});

describe('parseFeeScheduleTiers (experimental)', () => {
  const schedule = {
    feeSchedule: {
      cross: '0.00045',
      add: '0.00015',
      spotCross: '0.0007',
      spotAdd: '0.0004',
      tiers: {
        vip: [
          { ntlCutoff: '25000000.0', cross: '0.00035', add: '0.00008', spotCross: '0.0005', spotAdd: '0.0002' },
          { ntlCutoff: '5000000.0', cross: '0.0004', add: '0.00012', spotCross: '0.0006', spotAdd: '0.0003' },
        ],
        mm: [{ makerFractionCutoff: '0.005', add: '-0.00001' }],
      },
      referralDiscount: '0.04',
      stakingDiscountTiers: [],
    },
  };

  it('builds a sorted tier table in bps usable by feeTierForVolume', () => {
    const tiers = parseFeeScheduleTiers(schedule);
    expect(tiers.map((t) => [t.minVolume14dUsd, t.makerBps, t.takerBps])).toEqual([
      [0, 1.5, 4.5],
      [5e6, 1.2, 4],
      [25e6, 0.8, 3.5],
    ]);
    expect(feeTierForVolume(6e6, tiers).takerBps).toBe(4);
    expect(feeRatesForVolume(30e6, { tiers }).makerRate).toBe(0.00008);
  });

  it('works without vip tiers and throws on a missing schedule or bad rate', () => {
    expect(parseFeeScheduleTiers({ feeSchedule: { cross: '0.00045', add: '0.00015' } })).toHaveLength(1);
    expect(() => parseFeeScheduleTiers({})).toThrow(TypeError);
    expect(() => parseFeeScheduleTiers(null)).toThrow(TypeError);
    expect(() =>
      parseFeeScheduleTiers({ feeSchedule: { cross: '0.00045', add: '0.00015', tiers: { vip: [{ ntlCutoff: '1', cross: 'x', add: '0' }] } } }),
    ).toThrow(/vip\[0\]\.cross/);
    expect(() =>
      parseFeeScheduleTiers({ feeSchedule: { cross: '0.00045', add: '0.00015', tiers: { vip: [{ ntlCutoff: '-1', cross: '0', add: '0' }] } } }),
    ).toThrow(TypeError);
  });
});

describe('fillFee', () => {
  it('uses the maker rate for crossed=false (0.25 × 50 × 0.00015 = 0.001875)', () => {
    const rates = { makerRate: 0.00015, takerRate: 0.00045 };
    expect(fillFee({ px: '50.00', sz: '0.25', crossed: false }, rates)).toBe(0.001875);
    expect(fillNotional({ px: '50.00', sz: '0.25' })).toBe(12.5);
  });

  it('uses the taker rate for crossed=true', () => {
    const rates = { makerRate: 0.00015, takerRate: 0.00045 };
    expect(fillFee({ px: 100, sz: 2, crossed: true }, rates)).toBe(0.09);
  });

  it('returns a negative fee for a rebate', () => {
    expect(fillFee({ px: '50', sz: '10', crossed: false }, { makerRate: -0.00002, takerRate: 0.00025 })).toBe(-0.01);
  });
});

describe('backtest fees', () => {
  it('charges both sides on notional', () => {
    expect(roundTripFeeUsd(1000, 4.5)).toBe(0.9);
    expect(roundTripFeeUsd('12500', '1.5')).toBe(3.75);
  });

  it('computes trade PnL as margin × roe minus round-trip taker fees', () => {
    // $100 margin, 10x -> $1000 notional; +12% ROE = $12; fees 1000 × 4.5bp × 2 = $0.90
    expect(backtestTradePnlUsd({ marginUsd: 100, leverage: 10, roe: 0.12, feeBpsPerSide: 4.5 })).toBe(11.1);
  });

  it('treats leverage below 1 as 1 for the notional', () => {
    expect(backtestTradePnlUsd({ marginUsd: 100, leverage: 0.5, roe: 0, feeBpsPerSide: 4.5 })).toBe(-0.09);
  });
});

describe('fundingPaymentUsd', () => {
  it('longs pay on a positive rate', () => {
    expect(fundingPaymentUsd('2', '100', '0.0001')).toBe(-0.02);
  });

  it('shorts receive on a positive rate and pay on a negative one', () => {
    expect(fundingPaymentUsd('-2', '100', '0.0001')).toBe(0.02);
    expect(fundingPaymentUsd(-2, 100, -0.0001)).toBe(-0.02);
  });

  it('is zero when flat', () => {
    expect(fundingPaymentUsd(0, 100, 0.0001)).toBe(0);
  });

  it('is exact on API-shaped strings where floats drift (funding rate 0.0000175312)', () => {
    // Float: -1.5 × 241.18 × 0.0000175312 = -0.0063422622239999995
    expect(fundingPaymentUsd('1.5', '241.18', '0.0000175312')).toBe(-0.006342262224);
  });

  it('throws on malformed input instead of returning NaN', () => {
    expect(() => fundingPaymentUsd('1', 'n/a', '0.0001')).toThrow(RangeError);
  });
});
All files