src/account/equity.ts
v0.3.0 · 12 KB
import * as D from './decimal.js';
import type { Dec } from './decimal.js';
import { HlAccountError } from './errors.js';
import { parsePerpStates, parseSpotStables } from './parse.js';
import type { ParsedSpotStables } from './parse.js';
import type { AccountSnapshot } from './types.js';
/**
* Stablecoins counted as free spot collateral. Explicit symbols, matched exactly — never
* `startsWith('USD')`. Keep it configurable: new stables appear. Spot alts (HYPE, PURR, ...) are
* excluded on purpose: a −40% alt move would look like a blown account in drawdown metrics.
*/
export const DEFAULT_STABLES: readonly string[] = Object.freeze(['USDC', 'USDT', 'USDT0', 'USDH', 'USDE']);
/**
* Version of the capital formula implemented by {@link computeEquity}. Bump it in the same commit as
* any change to the spot parser, the capital formula or the dex handling.
*/
export const EQUITY_FORMULA_VERSION = 1;
export interface ComputeEquityOptions {
/** Stable symbols counted as free collateral. Default {@link DEFAULT_STABLES}. */
stables?: readonly string[];
/**
* Last-known-good free stables to use when the spot leg of this snapshot is unreadable.
* Substituting 0 is not always conservative: when capital scales order sizes, a 0 shrinks the
* target and can trigger an oversized reduction of an open position.
* The result is then marked `spotFresh: false` and `trusted: false`.
*/
fallbackSpotFreeStables?: number | string;
}
export interface DexEquity {
/** `""` = main perp dex. */
dex: string;
/**
* `marginSummary.accountValue` (cross + isolated). `crossMarginSummary` understates isolated positions.
* On a HIP-3 dex with non-USDC collateral (USDH, USDE, USDT0, ...) it is in that token's units and is
* summed as if the stablecoin were at $1.
*/
accountValue: number;
totalMarginUsed: number;
totalNtlPos: number;
/** Per-dex `withdrawable`, `null` when absent or malformed. Not a measure of dex trading capacity. */
withdrawable: number | null;
/** `crossMarginSummary.accountValue`, informational only. */
crossAccountValue: number | null;
openPositions: number;
/** Rows with `szi == 0` or `positionValue == 0`: not open, but "no data" rather than a confirmed close. */
zeroSizeCoins: string[];
}
export interface StableBalance {
coin: string;
total: number;
hold: number;
spotHold: number | null;
/** `spotHold` when present, otherwise `hold`. */
reserve: number;
reserveSource: 'spotHold' | 'hold';
/** `max(0, total − reserve)`. */
free: number;
borrowed: number | null;
}
export interface EquityBreakdown {
user: string;
/** Dex ids included, main dex (`""`) first. */
dexes: string[];
perDex: DexEquity[];
/** `marginSummary.accountValue` by dex id (`""` = main). */
perpAccountValue: Record<string, number>;
/**
* Σ perp `accountValue` over the included dexes. Reflexive on a Unified Account (spot↔perp moves change
* it without trading), so do not use it as capital; it IS the right input for a "did the account blow
* up" guard, which must not see spot (Earn and spot bids swing by large amounts unrelated to trading).
*/
perpEquity: number;
/** Spot leg of this snapshot parsed successfully. */
spotOk: boolean;
/** Free stables come from this snapshot (not from `fallbackSpotFreeStables`). */
spotFresh: boolean;
/** Why the spot leg was rejected, `null` when `spotOk`. */
spotError: string | null;
/** Σ `max(0, total − reserve)` over stables; the fallback when spot is unreadable; `null` if unknown. */
spotFreeStables: number | null;
/**
* Σ stable `total` (free + reserve). Display-only: on a Unified Account without borrowing it matches
* the "Total Equity" shown by some frontends, but it is ~0 on classic accounts (USDC sits in the perp
* leg) and inflated by resting spot bids. Never use it as sizing capital.
*/
spotStableTotal: number | null;
/** Σ stable reserve. On a Unified Account it mirrors Σ perp accountValue plus the spot-bid reserve. */
spotStableReserve: number | null;
stableBalances: StableBalance[];
/** `portfolioMarginEnabled === true` in the spot answer: `hold` is net of borrow capacity, `spotHold` is the reserve. */
portfolioMarginEnabled: boolean;
/**
* Some stable row reports `borrowed > 0`.
* @experimental The knowledge base only verified `capital = spot total` without borrowing; how
* borrowing should affect capital is an open question. Treat capital as less certain when true.
*/
hasBorrowedStables: boolean;
/**
* Account capital = Σ perp accountValue (all included dexes) + free stables, or `null` when the spot
* leg is unreadable and no fallback was given. Invariant across account modes (classic, Unified,
* portfolio margin) and across HL's convention flips. Excludes spot alts, staking, vault/HLP equity
* (not withdrawable into margin instantly) and does not add uPnL again (already inside accountValue).
*/
total: number | null;
/** Σ `marginSummary.totalMarginUsed` over dexes. */
marginUsed: number;
/**
* Single-pool margin ratio: `marginUsed / (perpEquity + freeStables)`; `+Infinity` when the denominator
* is <= 0 but margin is used (fail-closed: near-liquidation blocks entries; the naive "ratio 0" let
* them through); 0 when nothing is used. When spot is unknown and no fallback is given the denominator
* uses 0 free stables — a larger ratio, which is the safe side for a risk cap.
* Per-dex ratios are deliberately not offered: on a Unified Account collateral is fungible across dexes,
* and a per-dex cap gives false skips that block trading on a dex while the collateral sits elsewhere.
*/
marginRatio: number;
/**
* Σ per-dex `withdrawable`, `null` if any dex lacks it. Collateral is shared on a Unified Account, so
* check shortage against the SUM. Semantics on unified/portfolio-margin accounts were not researched.
*/
withdrawable: number | null;
/** Spot leg fresh and parsed; perp legs are always validated or the call throws. */
trusted: boolean;
/** Formula fingerprint to store next to any remembered value (peak, baseline). See {@link isBasisComparable}. */
basis: string;
/** Exact decimal strings of the headline values. */
exact: {
perpEquity: string;
spotFreeStables: string | null;
total: string | null;
marginUsed: string;
withdrawable: string | null;
};
}
/**
* Builds the formula fingerprint: `v<version>|perp+free|dexes=[main,xyz]|stables=[...]`.
* Dex and stable lists are sorted (main first) so equal sets give equal strings.
*/
export function equityBasis(dexes: readonly string[], stables: readonly string[] = DEFAULT_STABLES): string {
const dexList = [...new Set(dexes)].sort((a, b) => (a === '' ? -1 : b === '' ? 1 : a < b ? -1 : a > b ? 1 : 0));
const stableList = [...new Set(stables)].sort();
return `v${EQUITY_FORMULA_VERSION}|perp+free|dexes=[${dexList.map((d) => (d === '' ? 'main' : d)).join(',')}]|stables=[${stableList.join(',')}]`;
}
/**
* Whether a remembered value (peak, baseline, starting balance) can be compared with today's reading.
*
* Only when both were produced by the same formula: a peak recorded under another formula reads as a large
* false drawdown, and a guard acting on it closes healthy positions. Different basis → not comparable;
* missing basis (legacy value) → not comparable.
*/
export function isBasisComparable(stored: string | null | undefined, current: string): boolean {
return typeof stored === 'string' && stored !== '' && stored === current;
}
function toNum(d: Dec | null): number | null {
return d === null ? null : D.toNumber(d);
}
/**
* Computes account capital and margin figures from one snapshot, strictly by the knowledge-base formulas.
*
* - Perp equity = Σ `marginSummary.accountValue` over every dex in the snapshot (never `crossMarginSummary`).
* - Free stables = Σ `max(0, total − (spotHold ?? hold))` over an explicit stable list.
* - Capital = perp equity + free stables. Adding the whole spot `total` double counts on a Unified
* Account, where the USDC `hold` mirrors perp margin (capital comes out up to ×2).
* - Portfolio margin: negative `hold` is legal only with `spotHold`; the real reserve is `spotHold`.
*
* All sums are exact decimals; numbers are produced at the end.
*
* @throws {HlAccountError} `DEGRADED_PERP` for invalid perp answers, `MISSING_MAIN_DEX`, `INVALID_ARGUMENT`.
*/
export function computeEquity(snapshot: AccountSnapshot, opts: ComputeEquityOptions = {}): EquityBreakdown {
const stables = opts.stables ?? DEFAULT_STABLES;
if (!Array.isArray(stables) || stables.some((s) => typeof s !== 'string' || s === '')) {
throw new HlAccountError('INVALID_ARGUMENT', 'stables must be an array of non-empty symbols');
}
const dexes = parsePerpStates(snapshot.perp);
if (!dexes.some((d) => d.dex === '')) {
throw new HlAccountError('MISSING_MAIN_DEX', 'snapshot has no main perp dex ("") — equity would be incomplete');
}
const perpEquity = D.sum(dexes.map((d) => d.accountValue));
const marginUsed = D.sum(dexes.map((d) => d.totalMarginUsed));
const withdrawable = dexes.every((d) => d.withdrawable !== null)
? D.sum(dexes.map((d) => d.withdrawable as Dec))
: null;
const spot: ParsedSpotStables = snapshot.spot.ok
? parseSpotStables(snapshot.spot.state, stables)
: { ok: false, portfolioMarginEnabled: false, reason: snapshot.spot.error };
let free: Dec | null = spot.ok ? spot.free : null;
if (!spot.ok && opts.fallbackSpotFreeStables !== undefined) {
free = parseFallback(opts.fallbackSpotFreeStables);
}
const total = free === null ? null : D.add(perpEquity, free);
const denominator = D.add(perpEquity, free ?? D.ZERO);
const marginRatio =
D.sign(denominator) > 0
? D.toNumber(marginUsed) / D.toNumber(denominator)
: D.sign(marginUsed) > 0
? Number.POSITIVE_INFINITY
: 0;
const perpAccountValue: Record<string, number> = {};
for (const d of dexes) perpAccountValue[d.dex] = D.toNumber(d.accountValue);
const dexIds = dexes.map((d) => d.dex);
return {
user: snapshot.user,
dexes: dexIds,
perDex: dexes.map((d) => ({
dex: d.dex,
accountValue: D.toNumber(d.accountValue),
totalMarginUsed: D.toNumber(d.totalMarginUsed),
totalNtlPos: D.toNumber(d.totalNtlPos),
withdrawable: toNum(d.withdrawable),
crossAccountValue: toNum(d.crossAccountValue),
openPositions: d.positions.length,
zeroSizeCoins: [...d.zeroSizeCoins],
})),
perpAccountValue,
perpEquity: D.toNumber(perpEquity),
spotOk: spot.ok,
spotFresh: spot.ok,
spotError: spot.ok ? null : spot.reason,
spotFreeStables: toNum(free),
spotStableTotal: spot.ok ? D.toNumber(spot.total) : null,
spotStableReserve: spot.ok ? D.toNumber(spot.reserve) : null,
stableBalances: spot.ok
? spot.rows.map((r) => ({
coin: r.coin,
total: D.toNumber(r.total),
hold: D.toNumber(r.hold),
spotHold: toNum(r.spotHold),
reserve: D.toNumber(r.reserve),
reserveSource: r.reserveSource,
free: D.toNumber(r.free),
borrowed: toNum(r.borrowed),
}))
: [],
portfolioMarginEnabled: spot.portfolioMarginEnabled,
hasBorrowedStables: spot.ok ? spot.hasBorrowed : false,
total: toNum(total),
marginUsed: D.toNumber(marginUsed),
marginRatio,
withdrawable: toNum(withdrawable),
trusted: spot.ok,
basis: equityBasis(dexIds, stables),
exact: {
perpEquity: D.toDecString(perpEquity),
spotFreeStables: free === null ? null : D.toDecString(free),
total: total === null ? null : D.toDecString(total),
marginUsed: D.toDecString(marginUsed),
withdrawable: withdrawable === null ? null : D.toDecString(withdrawable),
},
};
}
function parseFallback(value: number | string): Dec {
const d = typeof value === 'number' ? D.decFromNumber(value) : D.parseDec(value);
if (!d || D.sign(d) < 0) {
throw new HlAccountError('INVALID_ARGUMENT', 'fallbackSpotFreeStables must be a finite non-negative amount');
}
return d;
}