src/risk/fees.ts
v0.3.0 · 14.4 KB
// Fee tiers, account fee rates, per-fill fee math, backtest fee helpers and funding.
import type { InfoCallOptions, InfoRequester } from '../transport/types.js';
import { dec, decMul, decShift, decSub, decToNumber, tryDec, type DecInput } from './decimal.js';
/** Price, size or amount as returned by the API (decimal string) or as a number. */
export type Numeric = string | number;
export type PerpFeeTierId = 'T0' | 'T1' | 'T2' | 'T3' | 'T4' | 'T5';
export interface PerpFeeTier {
readonly id: string;
/** Rolling 14-day volume (USD) from which the tier applies. */
readonly minVolume14dUsd: number;
/** Maker (add liquidity) rate in basis points. */
readonly makerBps: number;
/** Taker (cross the spread) rate in basis points. */
readonly takerBps: number;
}
/**
* Main perp dex fee tiers by rolling 14-day volume.
*
* @remarks
* A built-in modelling table, not read from the API. Only T0 (maker 1.5 bps /
* taker 4.5 bps) is confirmed by a live `userFees` read; T1–T5 are medium
* confidence. A tier above $2B mentioned in
* the docs (~$7B, taker ~2.4 bps) is intentionally NOT included because it was
* never verified. Spot and HIP-3 dexes have different rates: treat the fee rate
* as a per-market parameter. The real rates of an account must be read with
* {@link readUserFees}; use this table for modelling only.
*
* The window is rolling: volume does not accumulate beyond the window and a
* tier is kept only while the volume is sustained. Zero maker fee and rebates
* cannot be bought, only traded into.
*/
export const PERP_FEE_TIERS: readonly PerpFeeTier[] = Object.freeze([
Object.freeze({ id: 'T0', minVolume14dUsd: 0, makerBps: 1.5, takerBps: 4.5 }),
Object.freeze({ id: 'T1', minVolume14dUsd: 5_000_000, makerBps: 1.2, takerBps: 4.0 }),
Object.freeze({ id: 'T2', minVolume14dUsd: 25_000_000, makerBps: 0.8, takerBps: 3.5 }),
Object.freeze({ id: 'T3', minVolume14dUsd: 100_000_000, makerBps: 0.4, takerBps: 3.0 }),
Object.freeze({ id: 'T4', minVolume14dUsd: 500_000_000, makerBps: 0.0, takerBps: 2.8 }),
Object.freeze({ id: 'T5', minVolume14dUsd: 2_000_000_000, makerBps: 0.0, takerBps: 2.6 }),
]);
/**
* Market-maker rebate program, kept separate from the volume tiers.
*
* @remarks
* A rebate is paid only when the account's maker volume exceeds 0.5% of the
* whole exchange maker volume. The mapping of rebate steps to volume shares was
* not verified; only the range −0.1 … −0.3 bps is known. Read an account's
* actual rates with {@link readUserFees}.
*/
export const PERP_MM_REBATE = Object.freeze({
/** Minimum share of total exchange maker volume. */
minMakerVolumeShare: 0.005,
/** Known rebate range in bps (negative = received), most generous first. */
makerBpsRange: Object.freeze([-0.3, -0.1] as const),
});
/** Maximum HYPE staking fee discount (40%), reached at 500k staked HYPE. */
export const MAX_STAKING_DISCOUNT = 0.4;
/** Staked HYPE required for {@link MAX_STAKING_DISCOUNT}. */
export const MAX_STAKING_DISCOUNT_HYPE = 500_000;
/** Converts basis points to a fraction exactly: 1.5 bps -> 0.00015. */
export function bpsToRate(bps: Numeric): number {
return decToNumber(decShift(dec(bps), -4));
}
/** Converts a fraction to basis points exactly (`rate × 1e4`): 0.00045 -> 4.5. */
export function rateToBps(rate: Numeric): number {
return decToNumber(decShift(dec(rate), 4));
}
function assertVolume(volume14dUsd: number): void {
if (!Number.isFinite(volume14dUsd) || volume14dUsd < 0) {
throw new RangeError(`volume14dUsd must be a finite non-negative number, got ${volume14dUsd}`);
}
}
/** Tier table sorted by threshold (copy). @internal */
export function sortTiers(tiers: readonly PerpFeeTier[]): PerpFeeTier[] {
if (tiers.length === 0) throw new RangeError('fee tier table is empty');
return [...tiers].sort((a, b) => a.minVolume14dUsd - b.minVolume14dUsd);
}
/** Highest tier whose threshold is <= the 14-day volume (T0 for volume 0). */
export function feeTierForVolume(volume14dUsd: number, tiers: readonly PerpFeeTier[] = PERP_FEE_TIERS): PerpFeeTier {
assertVolume(volume14dUsd);
const sorted = sortTiers(tiers);
let found = sorted[0] as PerpFeeTier;
for (const t of sorted) if (t.minVolume14dUsd <= volume14dUsd) found = t;
return found;
}
export interface FeeRates {
/** Maker rate in bps (negative = rebate). */
makerBps: number;
/** Taker rate in bps. */
takerBps: number;
/** Maker rate as a fraction of notional (`makerBps / 1e4`). */
makerRate: number;
/** Taker rate as a fraction of notional (`takerBps / 1e4`). */
takerRate: number;
}
export interface TierFeeRates extends FeeRates {
tier: PerpFeeTier;
}
export interface FeeDiscountOptions {
/**
* HYPE staking discount as a fraction, 0 … {@link MAX_STAKING_DISCOUNT}.
* @experimental How the staking discount stacks with tiers and referral
* discounts was never verified; it is applied multiplicatively to positive
* rates only. Prefer the account's real rates from {@link readUserFees}.
*/
stakingDiscount?: number;
/**
* Referral discount as a fraction in [0, 1) (`activeReferralDiscount`).
* @experimental Size, conditions and stacking of the referral discount were
* never verified; applied multiplicatively to positive rates only.
*/
referralDiscount?: number;
/** Custom tier table (defaults to {@link PERP_FEE_TIERS}). */
tiers?: readonly PerpFeeTier[];
}
function discounted(bps: number, factor: DecInput): number {
// A discount reduces what you pay; it never scales a rebate.
if (!(bps > 0)) return bps;
return decToNumber(decMul(bps, factor));
}
/**
* Modelled perp fee rates for a 14-day volume, optionally with discounts.
*
* @remarks Tier numbers come from the modelling table {@link PERP_FEE_TIERS}; read the
* account's real rates with {@link readUserFees}. Discount stacking is
* `rate × (1 − staking) × (1 − referral)` and is unverified (experimental).
*/
export function feeRatesForVolume(volume14dUsd: number, opts: FeeDiscountOptions = {}): TierFeeRates {
const tier = feeTierForVolume(volume14dUsd, opts.tiers ?? PERP_FEE_TIERS);
const staking = opts.stakingDiscount ?? 0;
const referral = opts.referralDiscount ?? 0;
if (!Number.isFinite(staking) || staking < 0 || staking > MAX_STAKING_DISCOUNT) {
throw new RangeError(`stakingDiscount must be within [0, ${MAX_STAKING_DISCOUNT}], got ${staking}`);
}
if (!Number.isFinite(referral) || referral < 0 || referral >= 1) {
throw new RangeError(`referralDiscount must be within [0, 1), got ${referral}`);
}
const factor = decMul(decSub(1, staking), decSub(1, referral));
const makerBps = discounted(tier.makerBps, factor);
const takerBps = discounted(tier.takerBps, factor);
return { tier, makerBps, takerBps, makerRate: bpsToRate(makerBps), takerRate: bpsToRate(takerBps) };
}
export interface AccountFeeRates extends FeeRates {
/** `activeReferralDiscount` as a fraction (0 when absent). */
referralDiscount: number;
/** `activeStakingDiscount.discount` as a fraction (0 when absent). */
stakingDiscount: number;
/** Spot maker rate (`userSpotAddRate`) as a fraction, null when absent. */
spotMakerRate: number | null;
/** Spot taker rate (`userSpotCrossRate`) as a fraction, null when absent. */
spotTakerRate: number | null;
/** Spot maker rate in bps, null when absent. */
spotMakerBps: number | null;
/** Spot taker rate in bps, null when absent. */
spotTakerBps: number | null;
}
function requiredRate(raw: Record<string, unknown>, key: string): DecInput {
const d = tryDec(raw[key]);
if (d === null) throw new TypeError(`userFees.${key} is missing or not a decimal: ${JSON.stringify(raw[key])}`);
return d;
}
/**
* Parses a `{type:'userFees'}` response.
* Field names are easy to swap: `userAddRate` is MAKER (add liquidity),
* `userCrossRate` is TAKER (cross the spread). Rates arrive as fraction strings
* and convert to bps as `rate × 1e4`.
* @throws TypeError when the response does not carry both rates.
*/
export function parseUserFees(raw: unknown): AccountFeeRates {
if (typeof raw !== 'object' || raw === null) throw new TypeError('userFees response is not an object');
const r = raw as Record<string, unknown>;
const maker = requiredRate(r, 'userAddRate');
const taker = requiredRate(r, 'userCrossRate');
const ref = tryDec(r['activeReferralDiscount']);
// Spot rates differ from perp rates (the fee rate is a per-market parameter);
// they are optional so that older / partial responses still parse.
const spotMaker = tryDec(r['userSpotAddRate']);
const spotTaker = tryDec(r['userSpotCrossRate']);
const stakingRaw = r['activeStakingDiscount'];
const staking =
typeof stakingRaw === 'object' && stakingRaw !== null ? tryDec((stakingRaw as Record<string, unknown>)['discount']) : null;
return {
makerRate: decToNumber(maker),
takerRate: decToNumber(taker),
makerBps: decToNumber(decShift(maker, 4)),
takerBps: decToNumber(decShift(taker, 4)),
referralDiscount: ref === null ? 0 : decToNumber(ref),
stakingDiscount: staking === null ? 0 : decToNumber(staking),
spotMakerRate: spotMaker === null ? null : decToNumber(spotMaker),
spotTakerRate: spotTaker === null ? null : decToNumber(spotTaker),
spotMakerBps: spotMaker === null ? null : decToNumber(decShift(spotMaker, 4)),
spotTakerBps: spotTaker === null ? null : decToNumber(decShift(spotTaker, 4)),
};
}
/**
* Official perp tier table from the `feeSchedule` object of a `userFees`
* response: the base `add`/`cross` rates as `T0` plus `tiers.vip[]`
* (`ntlCutoff`, `add`, `cross`) as `T1…`, sorted by threshold, in bps. Pass the
* result as `tiers` to {@link feeTierForVolume} / {@link feeRatesForVolume}
* instead of the built-in table.
*
* @experimental The field shape comes from the SDK types; it was not checked
* against a live response, and how `ntlCutoff` weights spot volume
* is unverified. MM rebate tiers (`tiers.mm`) are not included.
* @throws TypeError when `feeSchedule` or a rate is missing or malformed.
*/
export function parseFeeScheduleTiers(raw: unknown): PerpFeeTier[] {
const r = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : null;
const schedule = r?.['feeSchedule'];
if (typeof schedule !== 'object' || schedule === null) throw new TypeError('userFees.feeSchedule is missing');
const s = schedule as Record<string, unknown>;
const toBps = (obj: Record<string, unknown>, key: string, where: string): number => {
const d = tryDec(obj[key]);
if (d === null) throw new TypeError(`${where}.${key} is missing or not a decimal: ${JSON.stringify(obj[key])}`);
return decToNumber(decShift(d, 4));
};
const tiers: PerpFeeTier[] = [
{ id: 'T0', minVolume14dUsd: 0, makerBps: toBps(s, 'add', 'feeSchedule'), takerBps: toBps(s, 'cross', 'feeSchedule') },
];
const vip = (s['tiers'] as Record<string, unknown> | undefined)?.['vip'];
if (vip !== undefined && !Array.isArray(vip)) throw new TypeError('feeSchedule.tiers.vip is not an array');
(vip ?? []).forEach((row: unknown, i: number) => {
if (typeof row !== 'object' || row === null) throw new TypeError(`feeSchedule.tiers.vip[${i}] is not an object`);
const o = row as Record<string, unknown>;
const where = `feeSchedule.tiers.vip[${i}]`;
const cutoff = tryDec(o['ntlCutoff']);
if (cutoff === null || cutoff.c < 0n) throw new TypeError(`${where}.ntlCutoff is missing or invalid`);
tiers.push({
id: `T${i + 1}`,
minVolume14dUsd: decToNumber(cutoff),
makerBps: toBps(o, 'add', where),
takerBps: toBps(o, 'cross', where),
});
});
return sortTiers(tiers);
}
/** Request weight of `userFees` (heavy). */
export const USER_FEES_WEIGHT = 20;
/** Reads and parses the account's fee rates via `{type:'userFees', user}` (weight 20). */
export async function readUserFees(
info: InfoRequester,
user: string,
opts: InfoCallOptions = {},
): Promise<AccountFeeRates> {
const raw = await info<unknown>({ type: 'userFees', user }, { weight: USER_FEES_WEIGHT, ...opts });
return parseUserFees(raw);
}
export interface FeeFillInput {
px: Numeric;
sz: Numeric;
/** `true` = taker, `false` = maker. */
crossed: boolean;
}
/** Exact notional `px × sz` of a fill. */
export function fillNotional(fill: { px: Numeric; sz: Numeric }): number {
return decToNumber(decMul(fill.px, fill.sz));
}
/**
* Expected exchange fee of a fill: `px × sz × (crossed ? takerRate : makerRate)`.
*
* @remarks The `fee` field of a fill is rounded by the exchange (a computed
* 0.25 × 50 × 0.00015 = 0.001875 may arrive as "0.0019"), so compare with a
* tolerance. In live accounting take the `fee` from fills, not this estimate.
*/
export function fillFee(fill: FeeFillInput, rates: Pick<FeeRates, 'makerRate' | 'takerRate'>): number {
const rate = fill.crossed ? rates.takerRate : rates.makerRate;
return decToNumber(decMul(decMul(fill.px, fill.sz), rate));
}
/**
* Round-trip fee for a backtest: fees are charged on notional on BOTH sides
* (`notional × bps / 1e4 × 2`). A one-sided fee is a classic reason a backtest
* promises profit while live trading loses.
*/
export function roundTripFeeUsd(notionalUsd: Numeric, feeBpsPerSide: Numeric): number {
return decToNumber(decMul(decShift(decMul(notionalUsd, feeBpsPerSide), -4), 2));
}
export interface BacktestTradeInput {
/** Margin committed to the trade, USD. */
marginUsd: number;
/** Leverage (values below 1 are treated as 1 for notional). */
leverage: number;
/** Trade ROE as a fraction (0.12 = +12%). */
roe: number;
/**
* Fee per side in bps. Use the TAKER rate for IOC/market entries and exits
* (maker rates and rebates do not apply to them).
*/
feeBpsPerSide: number;
}
/**
* Trade PnL for a backtest: `margin × roe − notional × bps / 1e4 × 2`,
* `notional = margin × max(leverage, 1)`. Funding is a separate line item
* (see {@link fundingPaymentUsd}).
*/
export function backtestTradePnlUsd(t: BacktestTradeInput): number {
const gross = decMul(t.marginUsd, t.roe);
const notional = decMul(t.marginUsd, Math.max(t.leverage, 1));
const fees = decMul(decShift(decMul(notional, t.feeBpsPerSide), -4), 2);
return decToNumber(decSub(gross, fees));
}
/**
* Hourly funding payment of a position: `−szi × oraclePx × fundingRate`
* (`szi` signed; negative result = paid). Positive rate: longs pay shorts.
* `closedPnl` excludes funding, so total PnL = Σ closedPnl − Σ fee + Σ funding.
*/
export function fundingPaymentUsd(szi: Numeric, oraclePx: Numeric, fundingRate: Numeric): number {
return decToNumber(decMul(decMul(szi, oraclePx), decSub(0, fundingRate)));
}