SPB Git

Repository Public

margin.py 10.9 KB 247 lines python

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
Files main
Menu

Theme