Skip to content
markpaper

src/risk/fees.ts

v0.3.0 · 14.4 KB

Download file
// 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)));
}
All files