Repository Public
1/**2 * Margin engine (sp-margin-engine). Pure functions on `Dec`; no clock, no network, no `number` in any value.3 *4 * Conventions5 * - `size` is a positive quantity of the base asset; the direction is `side` ('long' | 'short').6 * - Products are exact. Every division states its scale (CALC_SCALE decimals) and its rounding, chosen on the cautious side:7 * required margin and leverage round up, a long's liquidation price rounds up and a short's rounds down (closer to the mark).8 * - A result that is an approximation of what the venue would compute is returned as9 * `{ value, source: 'estimate', method }`. The venue's own figures are the truth (principle 5); these are not.10 * - Unless a function says otherwise it ignores funding accrual, fees, tiered (size-dependent) maintenance margin,11 * venue-specific mark-price rules and auto-deleveraging.12 */13import { D, type Dec } from './decimal.ts';14import type { Side } from './models.ts';1516/** decimals kept by the divisions of this module */17export const CALC_SCALE = 18;1819export interface Estimate<T> { readonly value: T; readonly source: 'estimate'; readonly method: string }20function estimate<T>(value: T, method: string): Estimate<T> { return { value, source: 'estimate', method }; }2122/** the minimum needed to describe a position for the computations below */23export interface PositionLite { side: Side; size: Dec; entryPrice: Dec }24/** a position valued at a mark price, with the asset its exposure is counted under */25export interface ExposureInput { asset: string; side: Side; size: Dec; markPrice: Dec }2627function requirePositive(x: Dec, name: string): void { if (!D.isPos(x)) throw new RangeError(`${name} must be positive`); }28function requireNonNegative(x: Dec, name: string): void { if (D.isNeg(x)) throw new RangeError(`${name} must not be negative`); }2930/** signed size of a position: positive when long, negative when short, zero when there is none */31export function signedSize(p: { side: Side; size: Dec } | null | undefined): Dec {32 if (!p) return D.zero;33 return p.side === 'long' ? D.abs(p.size) : D.neg(D.abs(p.size));34}3536/** notional = |size| · price. Exact. */37export function notional(size: Dec, price: Dec): Dec { return D.mul(D.abs(size), price); }3839/**40 * Initial margin for a notional at a leverage: IM = notional / leverage, rounded up at CALC_SCALE decimals.41 * Assumes a flat initial-margin fraction of 1/leverage (no size tiers).42 */43export function initialMargin(notionalValue: Dec, leverage: Dec): Dec {44 requireNonNegative(notionalValue, 'notional'); requirePositive(leverage, 'leverage');45 return D.div(notionalValue, leverage, CALC_SCALE, 'up');46}4748/**49 * Maintenance margin = notional · maintenanceFraction. Exact.50 * Assumes one flat maintenance fraction for the whole position (venues with tiers apply a schedule by size).51 */52export function maintenanceMargin(notionalValue: Dec, maintenanceFraction: Dec): Dec {53 requireNonNegative(notionalValue, 'notional'); requireNonNegative(maintenanceFraction, 'maintenance fraction');54 return D.mul(notionalValue, maintenanceFraction);55}5657/**58 * Unrealized PnL at a mark price: long = size · (mark − entry); short = size · (entry − mark). Exact.59 * Linear (USD-margined) contract; ignores funding accrued but not yet paid and the fee of closing.60 */61export function unrealizedPnl(side: Side, size: Dec, entryPrice: Dec, markPrice: Dec): Dec {62 const diff = side === 'long' ? D.sub(markPrice, entryPrice) : D.sub(entryPrice, markPrice);63 return D.mul(D.abs(size), diff);64}6566/** gross exposure = Σ |size_i| · mark_i. Exact. */67export function grossExposure(positions: readonly { size: Dec; markPrice: Dec }[]): Dec {68 return positions.reduce((acc, p) => D.add(acc, notional(p.size, p.markPrice)), D.zero);69}7071/** net exposure = Σ (long notionals) − Σ (short notionals). Exact; positive = net long. Sums across assets in the quote currency. */72export function netExposure(positions: readonly { side: Side; size: Dec; markPrice: Dec }[]): Dec {73 return positions.reduce((acc, p) => (p.side === 'long' ? D.add(acc, notional(p.size, p.markPrice)) : D.sub(acc, notional(p.size, p.markPrice))), D.zero);74}7576export interface AssetExposure { long: Dec; short: Dec; net: Dec; gross: Dec }77/** per-asset exposure: for each asset, long and short notionals at mark, net = long − short, gross = long + short. Exact. */78export function exposureByAsset(positions: readonly ExposureInput[]): Record<string, AssetExposure> {79 const out: Record<string, AssetExposure> = Object.create(null) as Record<string, AssetExposure>;80 for (const p of positions) {81 const cur = out[p.asset] ?? { long: D.zero, short: D.zero, net: D.zero, gross: D.zero };82 const n = notional(p.size, p.markPrice);83 const long = p.side === 'long' ? D.add(cur.long, n) : cur.long;84 const short = p.side === 'short' ? D.add(cur.short, n) : cur.short;85 out[p.asset] = { long, short, net: D.sub(long, short), gross: D.add(long, short) };86 }87 return out;88}8990/**91 * Effective leverage of an account = gross exposure / equity, rounded up at CALC_SCALE decimals.92 * `equity` is collateral + unrealized PnL at the same marks. Returns null when equity ≤ 0 (leverage is not defined:93 * the account is insolvent at these marks) — callers must treat null as "over any limit".94 */95export function effectiveLeverage(equity: Dec, positions: readonly { size: Dec; markPrice: Dec }[]): Dec | null {96 if (!D.isPos(equity)) return null;97 return D.div(grossExposure(positions), equity, CALC_SCALE, 'up');98}99100export interface IsolatedLiquidationInput {101 side: Side; size: Dec; entryPrice: Dec;102 /** collateral attached to this position alone, excluding its unrealized PnL */103 margin: Dec;104 /** maintenance margin as a fraction of the notional at the liquidation price, e.g. 0.005 */105 maintenanceFraction: Dec;106 /** optional fee rate charged on the notional of the liquidating close; ignored (zero) when absent */107 feeRate?: Dec;108}109110/**111 * Estimated liquidation price of an isolated position.112 *113 * Liquidation is where position equity equals the maintenance requirement, with k = maintenanceFraction + feeRate:114 * long : margin + size·(p − entry) = k·size·p ⇒ p = (entry·size − margin) / (size·(1 − k))115 * short: margin + size·(entry − p) = k·size·p ⇒ p = (entry·size + margin) / (size·(1 + k))116 * Rounded towards the entry side that is reached first (long: up, short: down), CALC_SCALE decimals.117 *118 * Assumptions: linear contract; flat maintenance fraction applied to the notional at the liquidation price; no funding accrual;119 * no fee unless `feeRate` is given; margin is not topped up or withdrawn; liquidation happens exactly at the threshold on the120 * mark price. `value` is null when no positive price liquidates the position (a long whose margin covers the whole notional).121 * A long value ≥ entry (short ≤ entry) means the position is already at or past its threshold.122 */123export function liquidationPriceIsolated(i: IsolatedLiquidationInput): Estimate<Dec | null> {124 requirePositive(i.size, 'size'); requirePositive(i.entryPrice, 'entry price'); requireNonNegative(i.maintenanceFraction, 'maintenance fraction');125 if (i.feeRate) requireNonNegative(i.feeRate, 'fee rate');126 const k = D.add(i.maintenanceFraction, i.feeRate ?? D.zero);127 const method = `isolated, ${i.side}: equity = maintenance at p; flat maintenance fraction${i.feeRate ? ' + closing fee' : ', no fees'}; no funding accrual`;128 const entryNotional = D.mul(i.entryPrice, i.size);129 if (i.side === 'long') {130 const den = D.mul(i.size, D.sub(D.one, k));131 if (!D.isPos(den)) throw new RangeError('maintenance fraction + fee rate must be below 1');132 const num = D.sub(entryNotional, i.margin);133 if (!D.isPos(num)) return estimate(null, `${method}; margin covers the whole notional: no liquidation price`);134 return estimate(D.div(num, den, CALC_SCALE, 'up'), method);135 }136 const num = D.add(entryNotional, i.margin);137 if (!D.isPos(num)) return estimate(D.zero, `${method}; margin deficit exceeds the notional`);138 return estimate(D.div(num, D.mul(i.size, D.add(D.one, k)), CALC_SCALE, 'down'), method);139}140141/**142 * Estimated liquidation price of an isolated position opened at `leverage` with exactly its initial margin:143 * margin = entry·size / leverage (rounded up), then the isolated formula. For a long this is144 * p = entry · (1 − 1/leverage) / (1 − k): below the entry when k < 1/leverage, and rising with leverage. Same assumptions.145 */146export function liquidationPriceAtLeverage(i: { side: Side; entryPrice: Dec; leverage: Dec; maintenanceFraction: Dec; feeRate?: Dec }): Estimate<Dec | null> {147 const margin = initialMargin(i.entryPrice, i.leverage); // per unit of size148 const r = liquidationPriceIsolated({ side: i.side, size: D.one, entryPrice: i.entryPrice, margin, maintenanceFraction: i.maintenanceFraction, ...(i.feeRate ? { feeRate: i.feeRate } : {}) });149 return estimate(r.value, `${r.method}; margin = entry notional / leverage`);150}151152export interface CrossLiquidationInput {153 side: Side; size: Dec; entryPrice: Dec;154 /** account collateral (cash balance), excluding the unrealized PnL of every position */155 accountCollateral: Dec;156 maintenanceFraction: Dec;157 /** unrealized PnL of the OTHER positions, held constant (default 0) */158 otherUnrealizedPnl?: Dec;159 /** maintenance margin of the OTHER positions, held constant (default 0) */160 otherMaintenanceMargin?: Dec;161 feeRate?: Dec;162}163164/**165 * Estimated liquidation price of a cross-margin position — SINGLE-POSITION APPROXIMATION.166 *167 * The whole account backs the position: the isolated formula with168 * margin = accountCollateral + otherUnrealizedPnl − otherMaintenanceMargin.169 * It is exact only if this is the account's only position. With several positions the other positions' PnL and maintenance170 * are frozen at their current values, which is wrong as soon as their prices move (correlated assets move together), so the171 * true liquidation price can be materially closer. Other assumptions as `liquidationPriceIsolated`.172 */173export function liquidationPriceCross(i: CrossLiquidationInput): Estimate<Dec | null> {174 const margin = D.sub(D.add(i.accountCollateral, i.otherUnrealizedPnl ?? D.zero), i.otherMaintenanceMargin ?? D.zero);175 const r = liquidationPriceIsolated({ side: i.side, size: i.size, entryPrice: i.entryPrice, margin, maintenanceFraction: i.maintenanceFraction, ...(i.feeRate ? { feeRate: i.feeRate } : {}) });176 return estimate(r.value, `cross, single-position approximation (other positions frozen); ${r.method}`);177}178179export type PositionEffectKind = 'open' | 'increase' | 'reduce' | 'close' | 'flip';180export interface PositionEffect {181 kind: PositionEffectKind;182 /** resulting position, null when flat */183 position: PositionLite | null;184 /** PnL realised by the closed part (zero when nothing is closed); fees are not included */185 realizedPnl: Dec;186 /** quantity of the previous position that was closed */187 closedSize: Dec;188 /** quantity that opens or adds exposure in the direction of the order */189 openedSize: Dec;190}191192/**193 * Effect of an execution (side, size, price) on a position.194 * - open / increase: new entry = (oldSize·oldEntry + size·price) / (oldSize + size), half-even at CALC_SCALE decimals195 * (the only rounding here; opening a position from flat has entry = price exactly).196 * - reduce / close: the entry price is unchanged; realised PnL = closedSize · (price − entry) for a long,197 * closedSize · (entry − price) for a short. Exact.198 * - flip: the whole previous position is closed at `price` (realised as above) and the remainder opens the opposite side199 * with entry = price.200 * Fees and funding are not part of the result; the caller accounts for them.201 */202export function applyOrderToPosition(position: PositionLite | null, exec: { side: 'buy' | 'sell'; size: Dec; price: Dec }): PositionEffect {203 requirePositive(exec.size, 'execution size'); requirePositive(exec.price, 'execution price');204 const dir: Side = exec.side === 'buy' ? 'long' : 'short';205 if (!position || D.isZero(position.size)) {206 return { kind: 'open', position: { side: dir, size: exec.size, entryPrice: exec.price }, realizedPnl: D.zero, closedSize: D.zero, openedSize: exec.size };207 }208 requirePositive(position.size, 'position size');209 if (position.side === dir) {210 const size = D.add(position.size, exec.size);211 const cost = D.add(D.mul(position.size, position.entryPrice), D.mul(exec.size, exec.price));212 return { kind: 'increase', position: { side: dir, size, entryPrice: D.div(cost, size, CALC_SCALE, 'half_even') }, realizedPnl: D.zero, closedSize: D.zero, openedSize: exec.size };213 }214 const closedSize = D.min(position.size, exec.size);215 const realizedPnl = unrealizedPnl(position.side, closedSize, position.entryPrice, exec.price);216 const c = D.cmp(exec.size, position.size);217 if (c < 0) return { kind: 'reduce', position: { side: position.side, size: D.sub(position.size, exec.size), entryPrice: position.entryPrice }, realizedPnl, closedSize, openedSize: D.zero };218 if (c === 0) return { kind: 'close', position: null, realizedPnl, closedSize, openedSize: D.zero };219 const openedSize = D.sub(exec.size, position.size);220 return { kind: 'flip', position: { side: dir, size: openedSize, entryPrice: exec.price }, realizedPnl, closedSize, openedSize };221}222223export interface HypotheticalOrder {224 /** account equity (collateral + unrealized PnL) and margin already used, both at current marks */225 equity: Dec; marginUsed: Dec;226 side: 'buy' | 'sell'; size: Dec;227 /** price the order is valued at (limit price, or the worst acceptable price of a market order) */228 price: Dec;229 leverage: Dec;230 /** current position on the same instrument, if any */231 position?: PositionLite | null;232 /** optional fee rate on the order's notional; ignored (zero) when absent */233 feeRate?: Dec;234}235236/**237 * Estimated free collateral after a hypothetical order:238 * free = equity − marginUsed − IM(addedSize · price, leverage) − size · price · feeRate239 * where addedSize = max(0, |position after| − |position before|): only the part that increases the absolute position needs new240 * initial margin; a reducing order needs none.241 *242 * Assumptions: the order fills entirely at `price`; margin released by the reduced part is NOT credited (cautious);243 * the realised PnL of a closed part is not added (it is already inside `equity` as unrealized PnL when equity is at mark);244 * flat initial margin 1/leverage; resting orders reserve nothing; no funding; fee only if `feeRate` is given.245 */246export function freeCollateralAfterOrder(o: HypotheticalOrder): Estimate<Dec> {247 requirePositive(o.size, 'order size'); requirePositive(o.price, 'order price'); requirePositive(o.leverage, 'leverage');248 const before = signedSize(o.position ?? null);249 const after = o.side === 'buy' ? D.add(before, o.size) : D.sub(before, o.size);250 const added = D.sub(D.abs(after), D.abs(before));251 const margin = D.isPos(added) ? initialMargin(D.mul(added, o.price), o.leverage) : D.zero;252 const fee = o.feeRate ? D.mul(D.mul(o.size, o.price), o.feeRate) : D.zero;253 const free = D.sub(D.sub(D.sub(o.equity, o.marginUsed), margin), fee);254 return estimate(free, `equity − margin used − initial margin of the added size at the order price${o.feeRate ? ' − fee' : ', no fees'}; full fill assumed; released margin not credited`);255}256