src/risk/margin.ts
v0.3.0 · 13.2 KB
// 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;
}