Skip to content
markpaper

src/numbers/margin.ts

v0.2.1 · 6 KB

Download file
// Initial margin fraction on Lighter comes in TWO scales (knowledge base: account-and-leverage.md §3).
//
//   - orderBookDetails.min_initial_margin_fraction / default_initial_margin_fraction: hundredths of a
//     percent, `1000` = 10%, `5000` = 50% -> divide by 10 000;
//   - account.positions[].initial_margin_fraction: percent as a string, `"50.00"` = 50% -> divide by 100.
//
// Mixing them up costs two orders of magnitude: leverage 200x instead of 2x, position margin /100,
// ROE x100, so ROE-based guards fire 100 times too early. The proof of the scale is the exchange's own
// arithmetic: Σ position_value * imf/100 over positions equals cross_initial_margin_requirement and
// equals total_asset_value - available_balance.

import { LighterNumbersError } from './errors.js';

/** Where each fraction comes from and what to divide it by to get a plain ratio (0.5 = 50%). */
export const MARGIN_FRACTION_SCALES = {
  market: {
    source: 'orderBookDetails.min_initial_margin_fraction | default_initial_margin_fraction',
    unit: 'hundredths of a percent',
    divisor: 10_000,
    example: '1000 = 10% (10x cap), 5000 = 50% (2x default)',
  },
  account: {
    source: 'account.positions[].initial_margin_fraction',
    unit: 'percent, string with two decimals',
    divisor: 100,
    example: '"50.00" = 50% = 2x, "20.00" = 20% = 5x',
  },
} as const;

function finite(value: unknown, label: string): number {
  const n = typeof value === 'string' ? Number(value.trim()) : (value as number);
  if (typeof n !== 'number' || !Number.isFinite(n)) throw new LighterNumbersError(`Invalid ${label}: ${String(value)}`);
  return n;
}

/** Plain ratio from a MARKET fraction (`1000` -> `0.1`). Accepts the raw string or number from the meta. */
export function imfFromMarketFraction(raw: string | number): number {
  return finite(raw, 'market initial_margin_fraction') / MARGIN_FRACTION_SCALES.market.divisor;
}

/** Plain ratio from an ACCOUNT fraction (`"50.00"` -> `0.5`). Accepts the raw string or number from the account. */
export function imfFromAccountFraction(raw: string | number): number {
  return finite(raw, 'account initial_margin_fraction') / MARGIN_FRACTION_SCALES.account.divisor;
}

/**
 * Leverage cap of a market: `floor(10000 / min_initial_margin_fraction)`, at least 1.
 * Garbage (non-positive, non-finite) yields 1 rather than Infinity: this number divides notional.
 */
export function maxLeverageFromImf(minInitialMarginFractionRaw: string | number): number {
  let raw: number;
  try {
    raw = finite(minInitialMarginFractionRaw, 'min_initial_margin_fraction');
  } catch {
    return 1;
  }
  if (!(raw > 0)) return 1;
  return Math.max(1, Math.floor(MARGIN_FRACTION_SCALES.market.divisor / raw));
}

/**
 * Leverage of a position from the ACCOUNT fraction in percent (`"50.00"` -> 2, `"20.00"` -> 5).
 * Garbage yields 1x, never Infinity.
 */
export function leverageFromAccountFraction(imfPercentRaw: string | number): number {
  let pct: number;
  try {
    pct = finite(imfPercentRaw, 'account initial_margin_fraction');
  } catch {
    return 1;
  }
  if (!(pct > 0)) return 1;
  return Math.max(1, Math.round(100 / pct));
}

/** Market fraction (hundredths of a percent) for a target leverage: `10000 / leverage`. */
export function marketFractionForLeverage(leverage: number): number {
  const lev = finite(leverage, 'leverage');
  if (!(lev >= 1)) throw new LighterNumbersError(`Invalid leverage: ${String(leverage)} (expected >= 1)`);
  return MARGIN_FRACTION_SCALES.market.divisor / lev;
}

/**
 * Clamps a wanted leverage into `[1, cap]` as an integer, the way `update_leverage` expects it.
 * A requested leverage above the market cap is clamped to the cap (and the caller should warn).
 */
export function clampLeverage(wanted: number, cap: number): number {
  const w = finite(wanted, 'wanted leverage');
  const c = finite(cap, 'leverage cap');
  return Math.max(1, Math.min(Math.round(w), Math.max(1, Math.floor(c))));
}

export interface MarginIdentityInput {
  /** Open positions: absolute notional and the ACCOUNT fraction in percent (`"50.00"` or 50). */
  positions: Array<{ positionValue: number | string; initialMarginFractionPercent: number | string }>;
  /** `cross_initial_margin_requirement` from `account?by=index`. */
  crossInitialMarginRequirement: number | string;
  /** Optional second witness: `total_asset_value - available_balance`. */
  totalAssetValue?: number | string;
  availableBalance?: number | string;
  /** Relative tolerance; default 0.5%. */
  tolerance?: number;
}

export interface MarginIdentity {
  /** Σ |position_value| * imf/100. */
  expected: number;
  reported: number;
  /** `total_asset_value - available_balance` when both were given. */
  fromBalances: number | undefined;
  /** Both witnesses agree with the expected sum within `tolerance`. */
  holds: boolean;
  relativeGap: number;
}

/**
 * The scale proof from the knowledge base as a function: run it on a live account before trusting any
 * leverage / margin arithmetic. If it does not hold, the fraction scale is wrong somewhere.
 */
export function marginIdentity(input: MarginIdentityInput): MarginIdentity {
  const tolerance = input.tolerance ?? 0.005;
  let expected = 0;
  for (const p of input.positions) {
    expected +=
      Math.abs(finite(p.positionValue, 'positionValue')) * imfFromAccountFraction(p.initialMarginFractionPercent);
  }
  const reported = finite(input.crossInitialMarginRequirement, 'crossInitialMarginRequirement');
  const fromBalances =
    input.totalAssetValue !== undefined && input.availableBalance !== undefined
      ? finite(input.totalAssetValue, 'totalAssetValue') - finite(input.availableBalance, 'availableBalance')
      : undefined;
  const rel = (a: number, b: number) => (a === 0 && b === 0 ? 0 : Math.abs(a - b) / Math.max(Math.abs(a), Math.abs(b)));
  const gapReported = rel(expected, reported);
  const gapBalances = fromBalances === undefined ? 0 : rel(expected, fromBalances);
  const relativeGap = Math.max(gapReported, gapBalances);
  return { expected, reported, fromBalances, holds: relativeGap <= tolerance, relativeGap };
}
All files