Repository Public
1"""Margin arithmetic of the perp backtester: a Python port of `packages/derivatives/src/margin.ts`.23Same formulas, same rounding, same results on the same inputs (tests/test_margin.py ports the TypeScript examples):45- every value is a `decimal.Decimal`; sums, differences and products are EXACT (they run in a context that raises6 on any inexact result, see `EXACT`);7- every division states its scale (CALC_SCALE = 18 decimals) and its rounding mode, chosen on the cautious side:8 required margin and leverage round up, a long's liquidation price rounds up and a short's rounds down;9- `size` is a positive quantity of the base asset, the direction is `side` ('long' | 'short').1011Like the TypeScript engine, these functions ignore funding accrual, tiered (size-dependent) maintenance margin,12venue-specific mark-price rules and auto-deleveraging. Their results are estimates of what a venue would compute.13"""14from __future__ import annotations1516from dataclasses import dataclass17from decimal import Context, Decimal, DivisionByZero, Inexact, InvalidOperation, Overflow, localcontext18from fractions import Fraction1920CALC_SCALE = 1821#: context in which the ledger runs: 200 significant digits, and any result that would need rounding raises22EXACT = Context(prec=200, traps=[Inexact, InvalidOperation, DivisionByZero, Overflow])23ZERO = Decimal(0)24ONE = Decimal(1)252627def exact():28 """`with exact(): ...` — Decimal arithmetic that raises instead of rounding."""29 return localcontext(EXACT)303132def dec(x) -> Decimal:33 """Decimal from a decimal string, an int or a Decimal. A float goes through its shortest repr (0.1 -> '0.1')."""34 if isinstance(x, Decimal):35 return x36 if isinstance(x, bool):37 raise TypeError("cannot build a Decimal from a bool")38 if isinstance(x, int):39 return Decimal(x)40 if isinstance(x, float):41 d = Decimal(repr(x))42 else:43 d = Decimal(str(x))44 if not d.is_finite():45 raise ValueError(f"not a finite decimal: {x!r}")46 return d474849def dstr(x: Decimal) -> str:50 """Canonical decimal string of the TypeScript `D.str`: no exponent, no trailing zeros, '0' for zero."""51 if x == 0:52 return "0"53 s = format(x, "f")54 if "." in s:55 s = s.rstrip("0").rstrip(".")56 return s575859def div(a: Decimal, b: Decimal, scale: int = CALC_SCALE, mode: str = "half_even") -> Decimal:60 """a / b at `scale` decimals. mode: 'down' (towards zero), 'up' (away from zero), 'half_even'."""61 if b == 0:62 raise ZeroDivisionError("division by zero")63 if mode not in ("down", "up", "half_even"):64 raise ValueError(f"unknown rounding mode: {mode}")65 q = Fraction(a) / Fraction(b) * 10 ** scale66 num, den = q.numerator, q.denominator # den > 067 n = abs(num) // den68 r = abs(num) % den69 if r:70 if mode == "up" or (mode == "half_even" and (2 * r > den or (2 * r == den and n % 2 == 1))):71 n += 172 return Decimal(-n if num < 0 else n).scaleb(-scale, EXACT)737475def quantize_down(x: Decimal, decimals: int) -> Decimal:76 """Truncate towards zero at `decimals` decimals (lot size 10^-decimals)."""77 unit = 10 ** decimals78 f = Fraction(x) * unit79 n = abs(f.numerator) // f.denominator80 return Decimal(-n if f < 0 else n).scaleb(-decimals, EXACT)818283def _positive(x: Decimal, name: str):84 if not x > 0:85 raise ValueError(f"{name} must be positive")868788def _non_negative(x: Decimal, name: str):89 if x < 0:90 raise ValueError(f"{name} must not be negative")919293def notional(size: Decimal, price: Decimal) -> Decimal:94 """|size| x price. Exact."""95 with exact():96 return abs(size) * price979899def initial_margin(notional_value: Decimal, leverage: Decimal) -> Decimal:100 """IM = notional / leverage, rounded up at 18 decimals (flat fraction 1/leverage, no size tiers)."""101 _non_negative(notional_value, "notional")102 _positive(leverage, "leverage")103 return div(notional_value, leverage, CALC_SCALE, "up")104105106def maintenance_margin(notional_value: Decimal, maintenance_fraction: Decimal) -> Decimal:107 """notional x maintenance fraction. Exact (one flat fraction for the whole position)."""108 _non_negative(notional_value, "notional")109 _non_negative(maintenance_fraction, "maintenance fraction")110 with exact():111 return notional_value * maintenance_fraction112113114def unrealized_pnl(side: str, size: Decimal, entry_price: Decimal, mark_price: Decimal) -> Decimal:115 """long: size x (mark - entry); short: size x (entry - mark). Exact; linear USD-margined contract."""116 with exact():117 diff = mark_price - entry_price if side == "long" else entry_price - mark_price118 return abs(size) * diff119120121def effective_leverage(equity: Decimal, gross_exposure: Decimal) -> Decimal | None:122 """gross exposure / equity, rounded up; None when equity <= 0 (callers treat None as 'over any limit')."""123 if not equity > 0:124 return None125 return div(gross_exposure, equity, CALC_SCALE, "up")126127128def liquidation_price_isolated(side: str, size: Decimal, entry_price: Decimal, margin: Decimal,129 maintenance_fraction: Decimal, fee_rate: Decimal = ZERO) -> Decimal | None:130 """Price at which position equity equals the maintenance requirement, k = maintenance fraction + fee rate:131132 long : margin + size (p - entry) = k size p => p = (entry size - margin) / (size (1 - k)) rounded up133 short: margin + size (entry - p) = k size p => p = (entry size + margin) / (size (1 + k)) rounded down134135 None when no positive price liquidates a long (its margin covers the whole notional). A long value >= entry136 (short <= entry) means the position is already at or past its threshold.137 """138 _positive(size, "size")139 _positive(entry_price, "entry price")140 _non_negative(maintenance_fraction, "maintenance fraction")141 _non_negative(fee_rate, "fee rate")142 with exact():143 k = maintenance_fraction + fee_rate144 entry_notional = entry_price * size145 if side == "long":146 den = size * (ONE - k)147 if not den > 0:148 raise ValueError("maintenance fraction + fee rate must be below 1")149 num = entry_notional - margin150 if not num > 0:151 return None152 else:153 num = entry_notional + margin154 if not num > 0:155 return ZERO156 den = size * (ONE + k)157 return div(num, den, CALC_SCALE, "up" if side == "long" else "down")158159160def liquidation_price_at_leverage(side: str, entry_price: Decimal, leverage: Decimal,161 maintenance_fraction: Decimal, fee_rate: Decimal = ZERO) -> Decimal | None:162 """Isolated position opened at `leverage` with exactly its initial margin (per unit of size)."""163 return liquidation_price_isolated(side, ONE, entry_price, initial_margin(entry_price, leverage),164 maintenance_fraction, fee_rate)165166167def liquidation_price_cross(side: str, size: Decimal, entry_price: Decimal, account_collateral: Decimal,168 maintenance_fraction: Decimal, other_unrealized_pnl: Decimal = ZERO,169 other_maintenance_margin: Decimal = ZERO, fee_rate: Decimal = ZERO) -> Decimal | None:170 """Cross margin, single-position approximation: the isolated formula with171 margin = account collateral + PnL of the other positions - their maintenance margin (both frozen).172 Exact only when this is the account's only position."""173 with exact():174 margin = account_collateral + other_unrealized_pnl - other_maintenance_margin175 return liquidation_price_isolated(side, size, entry_price, margin, maintenance_fraction, fee_rate)176177178@dataclass(frozen=True)179class Position:180 side: str # 'long' | 'short'181 size: Decimal # > 0182 entry_price: Decimal183184 @property185 def signed(self) -> Decimal:186 return self.size if self.side == "long" else -self.size187188189@dataclass(frozen=True)190class PositionEffect:191 kind: str # open | increase | reduce | close | flip192 position: Position | None # None when flat193 realized_pnl: Decimal # of the closed part, fees not included194 closed_size: Decimal195 opened_size: Decimal196197198def signed_size(p: Position | None) -> Decimal:199 return ZERO if p is None else p.signed200201202def apply_order_to_position(position: Position | None, side: str, size: Decimal, price: Decimal) -> PositionEffect:203 """Effect of an execution (side 'buy' | 'sell', size, price) on a position.204205 open / increase: entry = (old size x old entry + size x price) / (old size + size), half-even at 18 decimals206 (the only rounding). reduce / close: entry unchanged, realised PnL = closed x (price - entry) for a long,207 closed x (entry - price) for a short, exact. flip: the whole previous position is closed at `price` and the208 remainder opens the opposite side with entry = price.209 """210 _positive(size, "execution size")211 _positive(price, "execution price")212 direction = "long" if side == "buy" else "short"213 if position is None or position.size == 0:214 return PositionEffect("open", Position(direction, size, price), ZERO, ZERO, size)215 _positive(position.size, "position size")216 with exact():217 if position.side == direction:218 total = position.size + size219 cost = position.size * position.entry_price + size * price220 return PositionEffect("increase", Position(direction, total, div(cost, total, CALC_SCALE, "half_even")),221 ZERO, ZERO, size)222 closed = min(position.size, size)223 realized = unrealized_pnl(position.side, closed, position.entry_price, price)224 if size < position.size:225 return PositionEffect("reduce", Position(position.side, position.size - size, position.entry_price),226 realized, closed, ZERO)227 if size == position.size:228 return PositionEffect("close", None, realized, closed, ZERO)229 opened = size - position.size230 return PositionEffect("flip", Position(direction, opened, price), realized, closed, opened)231232233def free_collateral_after_order(equity: Decimal, margin_used: Decimal, side: str, size: Decimal, price: Decimal,234 leverage: Decimal, position: Position | None = None,235 fee_rate: Decimal = ZERO) -> Decimal:236 """equity - margin used - IM(added size x price, leverage) - size x price x fee rate, with237 added size = max(0, |position after| - |position before|). Margin released by a reduced part is not credited."""238 _positive(size, "order size")239 _positive(price, "order price")240 _positive(leverage, "leverage")241 with exact():242 before = signed_size(position)243 after = before + size if side == "buy" else before - size244 added = abs(after) - abs(before)245 margin = initial_margin(added * price, leverage) if added > 0 else ZERO246 return equity - margin_used - margin - size * price * fee_rate247