Skip to content
markpaper

src/risk/margin.ts

v0.3.0 · 13.2 KB

Download file
// Leverage, ROE, liquidation approximation and account-level margin metrics.
//
// Convention: ROE, moves and margin fractions are FRACTIONS (−0.5 = −50%),
// not percents. Multiply by 100 for display.

import { tryDec, decToNumber } from './decimal.js';
import type { Numeric } from './fees.js';

export type PositionSide = 'long' | 'short';

/** `+1` for long, `−1` for short. */
export function sideSign(side: PositionSide): 1 | -1 {
  return side === 'long' ? 1 : -1;
}

/** Side of a signed size (`szi > 0` long, `< 0` short), `null` when flat. */
export function sideOfSzi(szi: Numeric): PositionSide | null {
  const d = tryDec(szi);
  if (d === null || d.c === 0n) return null;
  return d.c > 0n ? 'long' : 'short';
}

function toNum(x: Numeric): number {
  const d = tryDec(x);
  return d === null ? Number.NaN : decToNumber(d);
}

// ---------------------------------------------------------------------------
// Leverage
// ---------------------------------------------------------------------------

/**
 * Leverage accepted by `updateLeverage`: `max(1, min(floor(x), maxLeverage))`.
 *
 * HL rejects leverage above the asset's `maxLeverage` (it differs a lot per
 * market, e.g. 10x vs 50x on HIP-3) and leverage must be a whole number >= 1.
 * If `maxLeverage` is missing/0/NaN it is treated as unknown and NOT used as a
 * cap: `Number(u.maxLeverage ?? 0)` turns a missing field into a 0 cap, which
 * zeroes the notional and silently skips every trade.
 *
 * @throws RangeError when `leverage` is not finite.
 */
export function clampLeverage(leverage: number, maxLeverage: number | null | undefined): number {
  if (!Number.isFinite(leverage)) throw new RangeError(`leverage must be finite, got ${leverage}`);
  let lev = Math.max(1, Math.floor(leverage));
  if (typeof maxLeverage === 'number' && Number.isFinite(maxLeverage) && maxLeverage > 0) {
    lev = Math.min(lev, Math.max(1, Math.floor(maxLeverage)));
  }
  return lev;
}

export interface LeverageAssetMeta {
  maxLeverage: number;
  /** Isolated-only flag of `meta.universe[]` (deprecated upstream in favour of `marginMode`). */
  onlyIsolated?: boolean;
  /**
   * Margin mode constraint of `meta.universe[]`: `'strictIsolated'` and
   * `'noCross'` both forbid cross margin. Absent = no constraint.
   */
  marginMode?: string;
}

/** `marginMode` values that forbid cross margin. */
const NO_CROSS_MARGIN_MODES: ReadonlySet<string> = new Set(['strictIsolated', 'noCross']);

/**
 * Whether an asset accepts cross margin: not `onlyIsolated` and no cross-forbidding
 * `marginMode`. The API currently sends both fields together (checked live on
 * the main dex and HIP-3 `xyz`), but the SDK marks `onlyIsolated` as
 * deprecated; reading only the old flag would send `isCross: true` to
 * isolated-only markets once it disappears, and HL rejects that.
 */
export function assetAllowsCross(meta: Pick<LeverageAssetMeta, 'onlyIsolated' | 'marginMode'>): boolean {
  if (meta.onlyIsolated === true) return false;
  return !(typeof meta.marginMode === 'string' && NO_CROSS_MARGIN_MODES.has(meta.marginMode));
}

/**
 * Parameters for `updateLeverage({ asset, isCross, leverage })`.
 *
 * `isCross = !onlyIsolated` (plus the `marginMode` constraint, see
 * {@link assetAllowsCross}): most HIP-3 pairs are isolated-only, and a
 * hard-coded `isCross: true` is rejected there. Set leverage BEFORE the entry
 * order; the call is idempotent but costs 0.3–1.5 s, so cache it (see
 * {@link leverageMatches}).
 */
export function leverageUpdateParams(meta: LeverageAssetMeta, wanted: number): { leverage: number; isCross: boolean } {
  return { leverage: clampLeverage(wanted, meta.maxLeverage), isCross: assetAllowsCross(meta) };
}

/**
 * Whether a live position's leverage already equals the target, so the
 * `updateLeverage` round-trip can be skipped:
 * `max(1, floor(current)) === max(1, floor(target))`. Returns `false` when the
 * current value is unknown (no position yet: leverage is only visible while a
 * position is open).
 */
export function leverageMatches(current: number | null | undefined, target: number): boolean {
  if (typeof current !== 'number' || !Number.isFinite(current) || !Number.isFinite(target)) return false;
  return Math.max(1, Math.floor(current)) === Math.max(1, Math.floor(target));
}

// ---------------------------------------------------------------------------
// ROE
// ---------------------------------------------------------------------------

/**
 * ROE from position fields: `unrealizedPnl / marginUsed`.
 *
 * This is THE formula for stops, peaks and UI (one formula on every path).
 * Do not use the API's `returnOnEquity` for stops: its denominator is the ENTRY
 * margin while `marginUsed` on cross is mark-based, so they diverge after a move.
 *
 * @returns `null` when `marginUsed <= 0` or a field is malformed: ROE cannot be
 * computed reliably and a stop engine should skip the position instead of
 * treating it as breakeven.
 */
export function roeFromPnl(unrealizedPnl: Numeric, marginUsed: Numeric): number | null {
  const pnl = toNum(unrealizedPnl);
  const margin = toNum(marginUsed);
  if (!Number.isFinite(pnl) || !Number.isFinite(margin) || !(margin > 0)) return null;
  return pnl / margin;
}

/**
 * Mark-based ROE, identical to `unrealizedPnl / marginUsed` on cross:
 * `dir × (mark − entry) / mark × leverage`.
 *
 * Identity: uPnL = szi × (mark − entry) and marginUsed = |szi| × mark / leverage,
 * so the size cancels. At 4x and +10% the entry-based form says +40%, the
 * correct value is +36.4%; an ROE-based exit computed with the entry form is
 * off by 3.6 points.
 *
 * @returns `null` for non-positive prices or leverage.
 */
export function roe(entryPx: number, markPx: number, leverage: number, side: PositionSide): number | null {
  if (!(entryPx > 0) || !(markPx > 0) || !(leverage > 0)) return null;
  return (sideSign(side) * (markPx - entryPx) * leverage) / markPx + 0; // + 0 turns -0 into 0
}

/**
 * Entry-based ROE: `dir × (price − entry) / entry × leverage`.
 *
 * Matches isolated margin (margin ≈ size × entry / leverage) and the API's
 * `returnOnEquity`; handy for a cheap price tick. On cross prefer {@link roe}.
 *
 * @returns `null` for non-positive prices or leverage.
 */
export function entryRoe(entryPx: number, price: number, leverage: number, side: PositionSide): number | null {
  if (!(entryPx > 0) || !(price > 0) || !(leverage > 0)) return null;
  return (sideSign(side) * (price - entryPx) * leverage) / entryPx + 0; // + 0 turns -0 into 0
}

// ---------------------------------------------------------------------------
// Liquidation (approximation)
// ---------------------------------------------------------------------------

/**
 * Maintenance margin as a fraction of notional: `1 / (2 × maxLeverage)`
 * (half of the initial margin at the asset's maximum leverage).
 *
 * @remarks Approximation for the base margin tier; large notionals fall into
 * tiers with a lower `maxLeverage` — pass that tier's value.
 * @throws RangeError when `maxLeverage <= 0`.
 */
export function maintenanceMarginFraction(maxLeverage: number): number {
  if (!(maxLeverage > 0) || !Number.isFinite(maxLeverage)) {
    throw new RangeError(`maxLeverage must be a positive number, got ${maxLeverage}`);
  }
  return 1 / (2 * maxLeverage);
}

function effectiveLiqLeverage(leverage: number, maxLeverage: number): { lev: number; maxLev: number } {
  if (!(leverage > 0) || !Number.isFinite(leverage)) {
    throw new RangeError(`leverage must be a positive number, got ${leverage}`);
  }
  // A missing maxLeverage (parsed as 0) must not produce a division by zero.
  const maxLev = maxLeverage > 0 && Number.isFinite(maxLeverage) ? maxLeverage : leverage;
  // Deliberately NOT clamped to maxLeverage: a position can carry more leverage
  // than the value passed here (the asset's max was lowered later, or the caller
  // passes the lower max of a large-notional margin tier). Clamping would place
  // liquidation farther away than it is and overstate the stop buffer.
  return { lev: leverage, maxLev };
}

/**
 * ROE at which the position is liquidated: `leverage / (2 × maxLeverage) − 1`.
 *
 * At leverage = maxLeverage liquidation comes at −50% ROE, so a −50% ROE stop
 * gives NO buffer at all. 10x on a 20x asset: −75%; 10x on 50x: −90%; 1x on
 * 10x: −95%.
 *
 * @remarks Approximation: base margin tier, ignores the rest of the cross
 * collateral and funding. Leverage above `maxLeverage` is NOT clamped: the
 * result moves toward 0 (already liquidatable), which is the conservative side.
 * A missing `maxLeverage` (<= 0) falls back to `leverage`. For large notionals
 * pass the `maxLeverage` of the margin tier the notional falls into
 * (`meta.marginTables`; e.g. a "tiered 40x" table drops to 20x above $150M).
 * @throws RangeError when `leverage <= 0`.
 */
export function liquidationRoe(leverage: number, maxLeverage: number): number {
  const { lev, maxLev } = effectiveLiqLeverage(leverage, maxLeverage);
  return lev / (2 * maxLev) - 1;
}

/**
 * Adverse price move (fraction of entry) until liquidation:
 * `1 / leverage − 1 / (2 × maxLeverage)`. 40x/40x: 1.25%; 20x/20x: 2.5%;
 * 10x/20x: 7.5%; 10x/50x: 9%; 1x/10x: 95%.
 *
 * @remarks Same approximation as {@link liquidationRoe}.
 */
export function liquidationPriceMove(leverage: number, maxLeverage: number): number {
  const { lev, maxLev } = effectiveLiqLeverage(leverage, maxLeverage);
  return 1 / lev - 1 / (2 * maxLev);
}

export interface LiquidationPriceInput {
  side: PositionSide;
  entryPx: number;
  leverage: number;
  maxLeverage: number;
}

/**
 * Rough liquidation price from {@link liquidationRoe}: `entry × (1 − dir × move)`
 * with `move = 1/leverage − 1/(2 × maxLeverage)`.
 *
 * @experimental Approximation only. It ignores other cross collateral and
 * positions, funding and margin tiers of large notionals; whether the chosen
 * leverage moves the liquidation price on CROSS at all is unverified (on cross
 * it may only change initial margin). Whether it agrees with the API's own
 * `liquidationPx` is not verified. Use it for sizing stop buffers, never as the
 * exchange's actual liquidation level. The result is unrounded.
 *
 * @returns `null` for a non-positive entry price, or when a long's estimate
 * would be <= 0.
 */
export function estimateLiquidationPrice(input: LiquidationPriceInput): number | null {
  if (!(input.entryPx > 0) || !Number.isFinite(input.entryPx)) return null;
  const move = liquidationPriceMove(input.leverage, input.maxLeverage);
  const px = input.entryPx * (1 - sideSign(input.side) * move);
  return px > 0 ? px : null;
}

/**
 * Buffer between a ROE stop and the liquidation ROE, in ROE units:
 * `stopRoe − liquidationRoe(leverage, maxLeverage)`.
 *
 * `<= 0` means the stop sits at or beyond liquidation and will never fire first.
 * Example: at leverage = maxLeverage liquidation is at −50% ROE, so a −25% stop
 * leaves 25 points. A stop executes past its level (reaction time, slippage),
 * so a small positive buffer does not guarantee the stop fires first.
 *
 * @param stopRoe Stop level as a (negative) ROE fraction, e.g. −0.25.
 */
export function stopLiquidationBuffer(stopRoe: number, leverage: number, maxLeverage: number): number {
  return stopRoe - liquidationRoe(leverage, maxLeverage);
}

// ---------------------------------------------------------------------------
// Account-level margin metrics
// ---------------------------------------------------------------------------

/**
 * Margin of one position: `|positionValue| / leverage`, 0 when leverage is
 * missing or <= 0.
 */
export function positionMargin(positionValue: Numeric, leverage: number | null | undefined): number {
  const pv = toNum(positionValue);
  if (!Number.isFinite(pv) || typeof leverage !== 'number' || !(leverage > 0) || !Number.isFinite(leverage)) return 0;
  return Math.abs(pv) / leverage;
}

/**
 * Gross account leverage: `Σ |positionValue| / equity` (0 when equity <= 0).
 * Malformed position values count as 0.
 */
export function grossLeverage(positionValues: readonly Numeric[], equity: Numeric): number {
  const eq = toNum(equity);
  if (!(eq > 0)) return 0;
  let gross = 0;
  for (const v of positionValues) {
    const n = toNum(v);
    if (Number.isFinite(n)) gross += Math.abs(n);
  }
  return gross / eq;
}

/**
 * Margin ratio `totalMarginUsed / accountValue`, FAIL-CLOSED.
 *
 * Pass sums over all dexes, with free spot stables added to `accountValue` on
 * a shared-collateral account. When `accountValue <= 0` but margin is in use
 * the ratio is `+Infinity`: the naive `accountValue <= 0 → ratio 0` let entries
 * through on an account at the edge of liquidation (`0 >= cap` is false).
 * Malformed input also yields `+Infinity`.
 *
 * Resting orders reserve margin too (plus fees, price drift and uPnL), so near
 * full utilization new orders are rejected with `Insufficient margin` before the
 * positions themselves use up equity. Check the shortfall against the TOTAL
 * `withdrawable` across dexes (collateral is shared on a Unified Account).
 */
export function marginRatio(totalMarginUsed: Numeric, accountValue: Numeric): number {
  const used = toNum(totalMarginUsed);
  const av = toNum(accountValue);
  if (!Number.isFinite(used) || !Number.isFinite(av)) return Number.POSITIVE_INFINITY;
  if (av > 0) return used / av;
  return used > 0 ? Number.POSITIVE_INFINITY : 0;
}
All files