src/numbers/margin.ts
v0.2.1 · 6 KB
// 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 };
}