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