src/account/fees.ts
v0.3.0 · 4.3 KB
import type { InfoRequester } from '../transport/types.js';
import { normalizeAddress } from './address.js';
import * as D from './decimal.js';
import { HlAccountError } from './errors.js';
import { describe, isObject } from './parse.js';
import { callOptions } from './call.js';
import type { AccountCallOptions } from './call.js';
export interface UserFees {
/** Perp maker rate as a fraction (`userAddRate`, "add liquidity"). 0.00015 = 1.5 bps. Negative = rebate. */
makerRate: number;
/** Perp taker rate as a fraction (`userCrossRate`, "cross the spread"). */
takerRate: number;
/** `makerRate × 1e4`, computed exactly. */
makerBps: number;
/** `takerRate × 1e4`, computed exactly. */
takerBps: number;
/**
* `activeReferralDiscount` as a fraction; 0 when absent.
* Its size and conditions were not verified live.
*/
referralDiscount: number;
/**
* Spot maker rate (`userSpotAddRate`) when present.
* @experimental Spot fees were not verified live; check against a spot-trading account.
*/
spotMakerRate: number | null;
/**
* Spot taker rate (`userSpotCrossRate`) when present.
* @experimental See {@link UserFees.spotMakerRate}.
*/
spotTakerRate: number | null;
/** Exact decimal strings. */
exact: { makerRate: string; takerRate: string; makerBps: string; takerBps: string; referralDiscount: string };
/** The full answer, for fields not modelled here (tiers, staking discount, daily volume). */
raw: Record<string, unknown>;
}
/**
* Parses a `userFees` answer.
*
* Field names are easy to swap: `userAddRate` is MAKER, `userCrossRate` is TAKER. Rates are fraction
* strings; basis points are `rate × 1e4` (shifted exactly, no float multiply). The base perp tier of the
* public HL fee schedule is maker 1.5 bps / taker 4.5 bps.
*
* @throws {HlAccountError} `INVALID_RESPONSE` when the answer is not an object or a rate is malformed.
*/
export function parseUserFees(raw: unknown): UserFees {
if (!isObject(raw)) throw new HlAccountError('INVALID_RESPONSE', `userFees answer is not an object (got ${describe(raw)})`);
const maker = D.parseDec(raw.userAddRate);
const taker = D.parseDec(raw.userCrossRate);
if (!maker) throw new HlAccountError('INVALID_RESPONSE', `userFees.userAddRate is not a decimal (got ${describe(raw.userAddRate)})`);
if (!taker) throw new HlAccountError('INVALID_RESPONSE', `userFees.userCrossRate is not a decimal (got ${describe(raw.userCrossRate)})`);
const referral =
raw.activeReferralDiscount === undefined || raw.activeReferralDiscount === null
? D.ZERO
: D.parseDec(raw.activeReferralDiscount);
if (!referral) {
throw new HlAccountError('INVALID_RESPONSE', `userFees.activeReferralDiscount is not a decimal (got ${describe(raw.activeReferralDiscount)})`);
}
const makerBps = D.shiftLeft(maker, 4);
const takerBps = D.shiftLeft(taker, 4);
const spotMaker = D.parseDec(raw.userSpotAddRate);
const spotTaker = D.parseDec(raw.userSpotCrossRate);
return {
makerRate: D.toNumber(maker),
takerRate: D.toNumber(taker),
makerBps: D.toNumber(makerBps),
takerBps: D.toNumber(takerBps),
referralDiscount: D.toNumber(referral),
spotMakerRate: spotMaker ? D.toNumber(spotMaker) : null,
spotTakerRate: spotTaker ? D.toNumber(spotTaker) : null,
exact: {
makerRate: D.toDecString(maker),
takerRate: D.toDecString(taker),
makerBps: D.toDecString(makerBps),
takerBps: D.toDecString(takerBps),
referralDiscount: D.toDecString(referral),
},
raw,
};
}
/**
* Reads the account's actual fee rates via `{type:"userFees", user}` (weight 20 — read it once at
* startup/preflight and log it, do not poll per order).
*
* Practice: if a strategy's pre-fee edge is below `makerBps`, warn about negative expectancy: the maker fee
* is exactly the price of a resting order. Actual charged fees must still come from fills (`fee`), not from
* these constants.
*
* @throws {HlAccountError} `INVALID_ARGUMENT`, `INVALID_RESPONSE`; transport errors are rethrown.
*/
export async function fetchUserFees(info: InfoRequester, user: string, opts?: AccountCallOptions): Promise<UserFees> {
const address = normalizeAddress(user, 'user');
const raw = await info<unknown>({ type: 'userFees', user: address }, callOptions(opts));
return parseUserFees(raw);
}