Skip to content
markpaper

src/account/equity.ts

v0.3.0 · 12 KB

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