src/risk/fill-summary.ts
v0.3.0 · 9.6 KB
// 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),
};
}