Skip to content
markpaper

src/risk/fill-summary.ts

v0.3.0 · 9.6 KB

Download file
// Fee / PnL accounting over a list of fills.

import { dec, decAdd, decCmp, decDiv, decMul, decShift, decSub, decToNumber, tryDec, type Dec } from './decimal.js';
import type { Numeric } from './fees.js';

/** Fill fields relevant to fees and PnL (a subset of the `userFills` item). */
export interface SummaryFill {
  px: Numeric;
  sz: Numeric;
  /** Exchange fee of this fill: > 0 paid, < 0 rebate received. */
  fee?: Numeric;
  /** Fee currency (`USDC` for main-dex perps). Missing is treated as USD. */
  feeToken?: string;
  /** `true` = taker, `false` = maker. */
  crossed: boolean;
  /** Realized PnL of this fill, EXCLUDING fees (0 for opening fills). */
  closedPnl?: Numeric;
  /** Builder fee paid to ANY builder (recipient not included). */
  builderFee?: Numeric;
  /** Direction label (`Open Long`, `Close Short`, ... liquidations contain `Liquidat`). */
  dir?: string;
  /**
   * Liquidation details. The SDK types it as `{ liquidatedUser?, markPx, method }`;
   * the knowledge base treats the structure as undocumented.
   */
  liquidation?: unknown;
}

export interface SummarizeFillsOptions {
  /**
   * Fee tokens that count as USD in `fee` / `net`. Fees in any other token are
   * only reported in `feesByToken` and counted in `unpricedFeeFills`.
   * Default `['USDC']`. HIP-3 dexes may use other collateral (seen live: USDH,
   * USDE, USDT0); add those tokens explicitly to treat them as USD.
   */
  usdFeeTokens?: readonly string[];
  /** Funding received (+) / paid (−) over the same period, e.g. Σ `userFunding` `delta.usdc`. */
  fundingUsd?: Numeric;
  /**
   * Address of the account the fills belong to. When given, a fill whose
   * `liquidation.liquidatedUser` is ANOTHER address (this account was the
   * counterparty) is not counted as a liquidation of this account.
   */
  user?: string;
}

export interface FillsSummary {
  fillCount: number;
  makerFills: number;
  /** Taker fill count. For a post-only bot every taker fill is a bug (the order crossed the spread). */
  takerFills: number;
  /** Fills recognised as liquidations (see {@link isLiquidationFill}). */
  liquidationFills: number;
  /**
   * Fills whose fee token is not in `usdFeeTokens`: their fee is NOT in `fee`,
   * `net`, `feeBps` or `netBps`, so `net` is overstated by those fees. Non-zero
   * on spot or non-USDC HIP-3 dexes: check it before trusting `net`.
   */
  unpricedFeeFills: number;
  /** Σ px·sz over all fills. */
  turnover: number;
  /** Σ px·sz over fills whose fee is in a USD token (denominator of `feeBps`). */
  usdFeeTurnover: number;
  makerNotional: number;
  takerNotional: number;
  /** Maker share of turnover (by notional), 0 when turnover is 0. */
  makerShare: number;
  /** Σ fee in USD fee tokens; negative = net rebate. */
  fee: number;
  /** Σ of positive USD fees (paid). */
  feesPaid: number;
  /** Σ of |negative| USD fees (rebates received), as a positive number. */
  rebates: number;
  /** Σ fee grouped by fee token (all tokens). */
  feesByToken: Record<string, number>;
  /** Σ closedPnl (excludes fees and funding). */
  closedPnl: number;
  /** `closedPnl − fee`. */
  net: number;
  /** `net + fundingUsd` (equals `net` when no funding was given). */
  netWithFunding: number;
  /**
   * Σ builderFee, reported separately and NOT subtracted from `net`: whether
   * the exchange `fee` already includes the builder fee is unverified. It is the
   * fee paid to ANY builder; attribute your own builder revenue by `oid`.
   */
  builderFee: number;
  /**
   * `1e4 × fee / usdFeeTurnover`, 0 without USD-fee turnover. The denominator
   * excludes fills whose fee is not counted, so mixing in spot/HIP-3 fills with
   * other fee tokens does not dilute the rate.
   */
  feeBps: number;
  /** `1e4 × net / turnover`, 0 when turnover is 0. */
  netBps: number;
  /**
   * Realized maker fee rate in bps (`Σ maker fee / Σ maker notional`), null
   * without maker volume. Use it instead of a constant on HIP-3 dexes and spot,
   * where the rate differs from the main perp dex (HIP-3 metas carry their own
   * `deployerFeeScale`).
   */
  realizedMakerBps: number | null;
  /** Realized taker fee rate in bps, null without taker volume. */
  realizedTakerBps: number | null;
}

/**
 * Whether a fill is a forced liquidation: `dir` contains `Liquidat` (case
 * insensitive) or a truthy `liquidation` field is present. Check this first when
 * a user reports "the stop/bot closed everything".
 *
 * @param user Optional own address: a `liquidation.liquidatedUser` that differs
 * from it marks a fill where this account was the counterparty, not the victim.
 */
export function isLiquidationFill(fill: { dir?: string; liquidation?: unknown }, user?: string): boolean {
  if (typeof fill.dir === 'string' && /liquidat/i.test(fill.dir)) return true;
  const liq = fill.liquidation;
  if (liq === undefined || liq === null || liq === false) return false;
  if (user && typeof liq === 'object') {
    const victim = (liq as { liquidatedUser?: unknown }).liquidatedUser;
    if (typeof victim === 'string' && victim.toLowerCase() !== user.toLowerCase()) return false;
  }
  return true;
}

function num(d: Dec): number {
  return decToNumber(d);
}

function required(value: Numeric | undefined, name: string, index: number): Dec {
  const d = value === undefined || value === '' ? null : tryDec(value);
  if (d === null) throw new TypeError(`fill[${index}].${name} is missing or not a decimal: ${JSON.stringify(value)}`);
  return d;
}

function optional(value: Numeric | undefined, name: string, index: number): Dec {
  if (value === undefined || value === '') return dec(0);
  const d = tryDec(value);
  if (d === null) throw new TypeError(`fill[${index}].${name} is not a decimal: ${JSON.stringify(value)}`);
  return d;
}

function bps(numerator: Dec, denominator: Dec): number {
  if (decCmp(denominator, 0) === 0) return 0;
  return num(decDiv(decShift(numerator, 4), denominator));
}

/**
 * Aggregates fees and PnL over fills with exact decimal sums.
 *
 * - notional = px × sz; turnover = Σ notional;
 * - fee > 0 paid, fee < 0 rebate; fee bps = 1e4 × Σfee / turnover;
 * - `closedPnl` excludes fees, so net = Σ closedPnl − Σ fee (funding separate);
 * - builder fee is reported separately (see {@link FillsSummary.builderFee}).
 *
 * The account value alone hides all of this: how much went to fees, which share
 * was executed as taker and what the net result per bp of turnover is.
 *
 * @throws TypeError when `px`/`sz` is missing or any numeric field is malformed
 * (silently counting a fill as zero would under-report turnover and fees).
 */
export function summarizeFills(fills: readonly SummaryFill[], opts: SummarizeFillsOptions = {}): FillsSummary {
  const usdTokens = new Set(opts.usdFeeTokens ?? ['USDC']);
  let turnover = dec(0);
  let usdFeeTurnover = dec(0);
  let makerNotional = dec(0);
  let takerNotional = dec(0);
  let fee = dec(0);
  let feesPaid = dec(0);
  let rebates = dec(0);
  let makerFee = dec(0);
  let takerFee = dec(0);
  // Notional of fills whose fee is in a USD token: denominator of the realized rates.
  let makerUsdNotional = dec(0);
  let takerUsdNotional = dec(0);
  let closedPnl = dec(0);
  let builderFee = dec(0);
  const byToken = new Map<string, Dec>();
  let makerFills = 0;
  let takerFills = 0;
  let liquidationFills = 0;
  let unpricedFeeFills = 0;

  fills.forEach((f, i) => {
    const notional = decMul(required(f.px, 'px', i), required(f.sz, 'sz', i));
    const fillFee = optional(f.fee, 'fee', i);
    const token = f.feeToken ?? 'USDC';
    turnover = decAdd(turnover, notional);
    byToken.set(token, decAdd(byToken.get(token) ?? dec(0), fillFee));
    const isUsd = f.feeToken === undefined || usdTokens.has(token);
    if (isUsd) {
      usdFeeTurnover = decAdd(usdFeeTurnover, notional);
      fee = decAdd(fee, fillFee);
      if (decCmp(fillFee, 0) > 0) feesPaid = decAdd(feesPaid, fillFee);
      else rebates = decSub(rebates, fillFee);
    } else {
      unpricedFeeFills++;
    }
    if (f.crossed) {
      takerFills++;
      takerNotional = decAdd(takerNotional, notional);
      if (isUsd) {
        takerFee = decAdd(takerFee, fillFee);
        takerUsdNotional = decAdd(takerUsdNotional, notional);
      }
    } else {
      makerFills++;
      makerNotional = decAdd(makerNotional, notional);
      if (isUsd) {
        makerFee = decAdd(makerFee, fillFee);
        makerUsdNotional = decAdd(makerUsdNotional, notional);
      }
    }
    closedPnl = decAdd(closedPnl, optional(f.closedPnl, 'closedPnl', i));
    builderFee = decAdd(builderFee, optional(f.builderFee, 'builderFee', i));
    if (isLiquidationFill(f, opts.user)) liquidationFills++;
  });

  const net = decSub(closedPnl, fee);
  const funding = opts.fundingUsd === undefined ? dec(0) : dec(opts.fundingUsd);
  const feesByToken: Record<string, number> = {};
  for (const [token, value] of byToken) feesByToken[token] = num(value);
  const turnoverZero = decCmp(turnover, 0) === 0;

  return {
    fillCount: fills.length,
    makerFills,
    takerFills,
    liquidationFills,
    unpricedFeeFills,
    turnover: num(turnover),
    usdFeeTurnover: num(usdFeeTurnover),
    makerNotional: num(makerNotional),
    takerNotional: num(takerNotional),
    makerShare: turnoverZero ? 0 : num(decDiv(makerNotional, turnover)),
    fee: num(fee),
    feesPaid: num(feesPaid),
    rebates: num(rebates),
    feesByToken,
    closedPnl: num(closedPnl),
    net: num(net),
    netWithFunding: num(decAdd(net, funding)),
    builderFee: num(builderFee),
    feeBps: bps(fee, usdFeeTurnover),
    netBps: bps(net, turnover),
    realizedMakerBps: decCmp(makerUsdNotional, 0) === 0 ? null : bps(makerFee, makerUsdNotional),
    realizedTakerBps: decCmp(takerUsdNotional, 0) === 0 ? null : bps(takerFee, takerUsdNotional),
  };
}
All files