SPB Git

Repository Public

perp_engine.py 28.6 KB 564 lines python

1"""Event-driven backtester for perpetual futures: one cross-margin account in USDC, long and short, leverage,2funding, maintenance margin and liquidation. Model, formulas, sources and simplifications: docs/v4/quant-perps.md.34Timeline of bar i (it opens at ts[i]; the decision bar is i-1):56    open of bar i    1. FUNDING. Every settlement dated in (ts[i-1], ts[i]] is paid on the position held coming into7                        the bar: amount = -signed size x reference price x rate (a positive rate is paid by longs).8                        Reference price: the `index` column (last value known before the bar) when the data have9                        one, else open[i] of the perpetual.10                     2. GAP CHECK. If margin equity < maintenance margin at open[i], the account is liquidated at11                        the open.12                     3. FILL. The weights decided on the close of bar i-1 are traded at open[i]: taker fee on the13                        traded notional and slippage in bps, both charged to the collateral. Orders that reduce a14                        position are always executed; orders that add exposure must fit in the free collateral15                        (initial margin = notional / leverage for a perpetual, full payment for a spot leg) and are16                        otherwise CUT pro rata, or refused when the cut order is below the minimum order.17    inside bar i     4. LIQUIDATION. With every long at the bar's low and every short at its high (taken together:18                        the worst case), if margin equity <= maintenance margin, the whole account is liquidated at19                        the prices where the two are equal, pays the taker fee and a penalty on the notional, and20                        the fills are flagged `liquidation` in the trade log.21    close of bar i   5. Mark to market (the `mark` column when the data have one, else the close) -> equity[i].22                        Equity <= 0 marks the run WIPED OUT: positions are closed, the debt is written off23                        (reported, not hidden), equity stays at 0 and nothing trades any more.24                     6. DECISION. The strategy sees bars <= i (at most `lookback`), every column of them — funding25                        settled at or before ts[i] included — and returns target weights for the open of bar i+1.2627Weights are fractions of account equity: +0.5 = long for half the equity, -2 = short for twice the equity.28The size is computed at the fill from the equity at that open, truncated to the venue's lot.2930Money is exact. The ledger (collateral, realised PnL, funding, fees, slippage, penalties) is `decimal.Decimal`31arithmetic in a context that raises on any rounding; the only rounded quantities are the average entry price32(18 decimals, half-even) and the order sizes (truncated to the lot), as in packages/derivatives/src/margin.ts.33Floats are used for the strategy's inputs and for statistics only. The identity3435    final equity = initial + sum(realised PnL) + sum(funding) - sum(fees) - sum(slippage)36                   + unrealised PnL of the positions still open + debt written off (0 unless wiped out)3738holds to the last digit and is checked on every run (`PerpResult.ledger["reconciles"]`).39"""40from __future__ import annotations4142import math43from dataclasses import asdict, dataclass, field44from decimal import Decimal45from fractions import Fraction46from typing import Callable4748import numpy as np49import pandas as pd5051from .data.core import DataError, interval_seconds, to_utc52from .engine import check_aligned53from .margin import (CALC_SCALE, ONE, ZERO, Position, apply_order_to_position, dec, div, exact, initial_margin,54                     liquidation_price_cross, quantize_down, signed_size)55from .strategy import Strategy, is_spot_leg, validate_perp_weights5657if int(pd.__version__.split(".")[0]) < 3:  # pragma: no cover58    pd.options.mode.copy_on_write = True5960#: Hyperliquid, base tier, checked 2026-11-10 (docs/v4/venues/contract-differences.md)61HL_TAKER_BPS = 4.562HL_MAKER_BPS = 1.563HL_MIN_ORDER = 10.0646566@dataclass(frozen=True)67class PerpConfig:68    interval: str69    taker_fee_bps: float = HL_TAKER_BPS70    maker_fee_bps: float = HL_MAKER_BPS71    liquidity: str = "taker"               # which fee the fills pay; 'maker' is an optimistic what-if72    slippage_bps: float = 2.0              # on traded notional, against the order73    initial_cash: float = 1000.0           # USDC collateral74    min_order: float = HL_MIN_ORDER        # USD notional; smaller orders are not sent (a full exit always is)75    rebalance_threshold: float = 0.0       # weight distance under which a position is left alone76    decision_every: int = 177    leverage: float | None = None          # account leverage setting; None = META['max_leverage'] (1 for spot METAs)78    venue_max_leverage: dict = field(default_factory=dict)   # per symbol, e.g. {"BTC": 40}79    default_venue_max_leverage: float = 10.080    maintenance_fraction: float | None = None   # None = 1 / (2 x venue max leverage), Hyperliquid's rule81    liquidation_penalty_bps: float = 100.0      # of the liquidated notional, on top of the taker fee82    size_decimals: dict = field(default_factory=dict)        # per symbol lot = 10^-decimals83    default_size_decimals: int = 684    spot_fee_bps: float = 10.0             # spot legs ('X.SPOT')85    spot_slippage_bps: float = 10.08687    def __post_init__(self):88        interval_seconds(self.interval)89        costs = (self.taker_fee_bps, self.slippage_bps, self.liquidation_penalty_bps, self.spot_fee_bps,90                 self.spot_slippage_bps)91        if any(c < 0 for c in costs) or self.slippage_bps >= 10_000 or self.spot_slippage_bps >= 10_000:92            raise ValueError("fees, slippage and penalty must be >= 0 (slippage < 10000 bps)")93        if self.liquidity not in ("taker", "maker"):94            raise ValueError("liquidity must be 'taker' or 'maker'")95        if self.initial_cash <= 0 or self.min_order < 0 or not 0 <= self.rebalance_threshold:96            raise ValueError("initial_cash > 0, min_order >= 0 and rebalance_threshold >= 0 are required")97        if int(self.decision_every) != self.decision_every or self.decision_every < 1:98            raise ValueError("decision_every must be a positive integer")99        if self.leverage is not None and not self.leverage > 0:100            raise ValueError("leverage must be positive")101        if self.maintenance_fraction is not None and not 0 <= self.maintenance_fraction < 1:102            raise ValueError("maintenance_fraction must be in [0, 1)")103        if self.default_venue_max_leverage <= 0 or any(v <= 0 for v in self.venue_max_leverage.values()):104            raise ValueError("venue max leverage must be positive")105106    @property107    def fee_bps(self) -> float:108        """The fee actually charged on perpetual fills (v1 report key)."""109        return self.taker_fee_bps if self.liquidity == "taker" else self.maker_fee_bps110111    def venue_leverage(self, symbol: str) -> float:112        return float(self.venue_max_leverage.get(symbol, self.default_venue_max_leverage))113114    def maintenance(self, symbol: str) -> float:115        if self.maintenance_fraction is not None:116            return float(self.maintenance_fraction)117        return 1.0 / (2.0 * self.venue_leverage(symbol))118119    def to_dict(self) -> dict:120        return asdict(self)121122123TRADE_COLUMNS = ["ts", "decided_at", "symbol", "side", "size", "price", "fill_price", "notional", "fee", "slippage",124                 "realised_pnl", "kind", "liquidation", "penalty", "position_after", "cut"]125FUNDING_COLUMNS = ["ts", "symbol", "rate", "size", "price", "amount"]126127128@dataclass129class PerpResult:130    """Series are indexed by bar-open time and valued at that bar's CLOSE. Money columns of `trades` and `funding`131    hold `Decimal` objects (exact); the series are floats (statistics)."""132133    symbols: list[str]134    config: PerpConfig135    equity: pd.Series136    cash: pd.Series              # collateral: initial + realised + funding - fees - slippage (unrealised excluded)137    exposure: pd.Series          # gross exposure / equity (v1 name; the spot engine's "invested fraction")138    long_exposure: pd.Series     # long notional / equity139    short_exposure: pd.Series    # short notional / equity (positive number)140    margin_utilisation: pd.Series  # initial margin in use / margin equity141    worst_equity: pd.Series      # equity with every long at the bar's low and every short at its high142    weights: pd.DataFrame        # signed notional / equity per symbol at each close143    decisions: pd.DataFrame144    trades: pd.DataFrame145    funding: pd.DataFrame146    open_positions: list[dict]147    liquidations: list[dict]148    wiped_out: bool149    wiped_out_at: pd.Timestamp | None150    ledger: dict151    counters: dict152153    @property154    def returns(self) -> pd.Series:155        r = self.equity.pct_change().iloc[1:]156        return r.replace([np.inf, -np.inf], np.nan).fillna(0.0) if self.wiped_out else r157158    @property159    def net_exposure(self) -> pd.Series:160        return self.long_exposure - self.short_exposure161162163def _frac_to_dec(x: Fraction, mode: str) -> Decimal:164    return div(Decimal(x.numerator), Decimal(x.denominator), CALC_SCALE, mode)165166167def run_perp_backtest(168    strategy: Strategy | Callable,169    data: dict[str, pd.DataFrame],170    config: PerpConfig,171    *,172    start=None,173    end=None,174    params: dict | None = None,175    lookback: int | None = None,176) -> PerpResult:177    """Simulate `strategy` on the bars with start <= ts < end (same calling convention as engine.run_backtest).178179    `data[symbol]` is an OHLCV frame; a perpetual's frame may also carry `funding` (sum of the rates settled in180    (ts[i-1], ts[i]], NaN when the venue has no record), `premium`, `index` and `mark`. A symbol ending in '.SPOT'181    is a spot leg: bought with collateral, long only, no funding, never liquidated, not counted as margin.182    `strategy` is a loaded Strategy or a callable view -> weights | None (None = leave the account as it is).183    """184    if not isinstance(config, PerpConfig):185        raise TypeError("run_perp_backtest needs a PerpConfig")186    index = check_aligned(data)187    symbols = list(data.keys())188    k = len(symbols)189    spot = [is_spot_leg(s) for s in symbols]190    if all(spot):191        raise DataError("no perpetual among the symbols")192    step = interval_seconds(config.interval)193    a = 0 if start is None else int(index.searchsorted(to_utc(start), side="left"))194    b = len(index) if end is None else int(index.searchsorted(to_utc(end), side="left"))195    if b - a < 2:196        raise DataError("the backtest window needs at least two bars")197198    if isinstance(strategy, Strategy):199        lb = strategy.lookback if lookback is None else lookback200        account_lev = config.leverage or (float(strategy.meta["max_leverage"]) if strategy.kind == "perp" else 1.0)201202        def decide(view):203            return strategy(view, params)204    else:205        lb = lookback or 1206        account_lev = config.leverage or 1.0207        n_perp = k - sum(spot)208209        def decide(view):210            w = strategy(view)211            return None if w is None else validate_perp_weights(w, symbols, account_lev, account_lev * n_perp + sum(spot))212213    frames = [data[s] for s in symbols]214    cols = {c: np.column_stack([df[c].to_numpy(dtype="float64") for df in frames])215            for c in ("open", "high", "low", "close")}216    opens, highs, lows, closes = cols["open"], cols["high"], cols["low"], cols["close"]217    if not all(np.isfinite(x[a:b]).all() and (x[a:b] > 0).all() for x in cols.values()):218        raise DataError("non-finite or non-positive prices inside the backtest window")219220    def optional(name):221        return np.column_stack([df[name].to_numpy(dtype="float64") if name in df.columns and not spot[m]222                                else np.full(len(index), np.nan) for m, df in enumerate(frames)])223224    fund, idx_px, mark_px = optional("funding"), optional("index"), optional("mark")225    close_epoch = index.as_unit("s").asi8 + step226    due = (close_epoch % (step * int(config.decision_every))) == 0227228    with exact():229        bps = Decimal(10_000)230        fee_rate = [dec(config.spot_fee_bps if sp else config.fee_bps) / bps for sp in spot]231        slip = [dec(config.spot_slippage_bps if sp else config.slippage_bps) / bps for sp in spot]232        taker = dec(config.taker_fee_bps) / bps233        penalty_rate = dec(config.liquidation_penalty_bps) / bps234        lev = [ONE if sp else dec(min(account_lev, config.venue_leverage(s))) for s, sp in zip(symbols, spot)]235        mmf = [ZERO if sp else dec(config.maintenance(s)) for s, sp in zip(symbols, spot)]236        szdec = [int(config.size_decimals.get(s, config.default_size_decimals)) for s in symbols]237        min_order = dec(config.min_order)238        thr = dec(config.rebalance_threshold)239        initial = dec(config.initial_cash)240241    collateral = initial242    pos: list[Position | None] = [None] * k243    tot = {"realised": ZERO, "funding": ZERO, "fees": ZERO, "slippage": ZERO, "penalties": ZERO, "bad_debt": ZERO}244    counters = {"orders_cut": 0, "orders_refused": 0, "orders_skipped": 0, "funding_missing": 0,245                "funding_payments": 0}246    trades: list[tuple] = []247    fundings: list[tuple] = []248    liquidations: list[dict] = []249    n = b - a250    equity = np.zeros(n)251    cash_curve = np.zeros(n)252    long_x, short_x, util, worst = np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n)253    wts = np.zeros((n, k))254    decision_ts: list = []255    decision_w: list = []256    pending = None257    pending_at = None258    wiped_at = None259    perp_idx = [m for m in range(k) if not spot[m]]260    spot_idx = [m for m in range(k) if spot[m]]261262    def prices(arr, i):263        return [dec(float(arr[i, m])) for m in range(k)]264265    def upnl(m, px):266        p = pos[m]267        return p.size * (px - p.entry_price) if p.side == "long" else p.size * (p.entry_price - px)268269    def total_equity(px):270        return collateral + sum((upnl(m, px[m]) for m in range(k) if pos[m] is not None), ZERO)271272    def spot_cost():273        return sum((pos[m].size * pos[m].entry_price for m in spot_idx if pos[m] is not None), ZERO)274275    def margin_equity(px):276        return collateral - spot_cost() + sum((upnl(m, px[m]) for m in perp_idx if pos[m] is not None), ZERO)277278    def maintenance(px):279        return sum((pos[m].size * px[m] * mmf[m] for m in perp_idx if pos[m] is not None), ZERO)280281    def margin_used(px):282        return sum((initial_margin(pos[m].size * px[m], lev[m]) for m in perp_idx if pos[m] is not None), ZERO)283284    def fill(m, side, size, price, ts, decided_at, *, liquidation=False, cut=False, costless=False):285        nonlocal collateral286        ntl = size * price287        if costless:                       # closing the books of a wiped-out account288            fp, sl, fee, pen = price, ZERO, ZERO, ZERO289        elif liquidation:290            fp, sl, pen = price, ZERO, ntl * penalty_rate291            fee = ntl * taker + pen292        else:293            fp = price * (ONE + slip[m]) if side == "buy" else price * (ONE - slip[m])294            sl, pen = ntl * slip[m], ZERO295            fee = size * fp * fee_rate[m]296        eff = apply_order_to_position(pos[m], side, size, price)297        pos[m] = eff.position298        collateral += eff.realized_pnl - fee - sl299        tot["realised"] += eff.realized_pnl300        tot["fees"] += fee301        tot["slippage"] += sl302        tot["penalties"] += pen303        trades.append((ts, decided_at, symbols[m], side, size, price, fp, ntl, fee, sl, eff.realized_pnl, eff.kind,304                       bool(liquidation), pen, signed_size(eff.position), bool(cut)))305306    def liquidate(px, ts, reason):307        before = margin_equity(px)308        closed = []309        for m in perp_idx:310            if pos[m] is not None:311                side = "sell" if pos[m].side == "long" else "buy"312                closed.append({"symbol": symbols[m], "side": pos[m].side, "size": str(pos[m].size),313                               "price": str(px[m])})314                fill(m, side, pos[m].size, px[m], ts, None, liquidation=True)315        liquidations.append({"ts": ts, "reason": reason, "positions": closed,316                             "margin_equity_before": float(before), "margin_equity_after": float(margin_equity(px)),317                             "equity_after": float(total_equity(px))})318319    def execute(w, i, ts):320        px = prices(opens, i)321        eq = total_equity(px)322        if not eq > 0:323            return324        orders = []325        for m, s in enumerate(symbols):326            target = quantize_down(Fraction(dec(float(w[s])) * eq) / Fraction(px[m]), szdec[m]) if w[s] != 0 else ZERO327            cur = signed_size(pos[m])328            delta = target - cur329            if delta == 0:330                continue331            ntl = abs(delta) * px[m]332            full_exit = target == 0333            flip = cur != 0 and target != 0 and (cur > 0) != (target > 0)334            if not full_exit and (ntl < min_order or (not flip and ntl < thr * eq)):335                counters["orders_skipped"] += 1336                continue337            closing = min(abs(delta), abs(cur)) if cur != 0 and (cur > 0) != (delta > 0) else ZERO338            side = "buy" if delta > 0 else "sell"339            unit_cost = px[m] * slip[m] + (px[m] * (ONE + slip[m]) if side == "buy" else px[m] * (ONE - slip[m])) * fee_rate[m]340            orders.append({"m": m, "side": side, "closing": closing, "opening": abs(delta) - closing,341                           "unit_cost": unit_cost, "cut": False})342        if not orders:343            return344        # free collateral once every reducing part is executed (they release margin and are always allowed)345        closing_by = {o["m"]: o["closing"] for o in orders}346        free = margin_equity(px)347        for o in orders:348            free -= o["closing"] * o["unit_cost"]349            if spot[o["m"]]:350                free += o["closing"] * px[o["m"]]          # a spot sale turns the holding back into collateral351        for m in perp_idx:352            if pos[m] is not None:353                left = pos[m].size - closing_by.get(m, ZERO)354                if left > 0:355                    free -= initial_margin(left * px[m], lev[m])356357        def need(o, size):358            if size == 0:359                return ZERO360            m = o["m"]361            base = size * px[m] if spot[m] else initial_margin(size * px[m], lev[m])362            return base + size * o["unit_cost"]363364        total_need = sum((need(o, o["opening"]) for o in orders), ZERO)365        if total_need > 0 and total_need > free:366            scale = Fraction(max(free, ZERO)) / Fraction(total_need)367            for _ in range(6):368                for o in orders:369                    if o["opening"] > 0:370                        cut_size = quantize_down(Fraction(o["opening"]) * scale, szdec[o["m"]])371                        o["cut_size"] = cut_size if cut_size * px[o["m"]] >= min_order else ZERO372                if sum((need(o, o.get("cut_size", ZERO)) for o in orders), ZERO) <= max(free, ZERO):373                    break374                scale *= Fraction(999_999, 1_000_000)375            else:376                for o in orders:377                    o["cut_size"] = ZERO378            for o in orders:379                if o["opening"] > 0:380                    counters["orders_cut" if o["cut_size"] > 0 else "orders_refused"] += 1381                    o["opening"], o["cut"] = o["cut_size"], True382        for o in sorted(orders, key=lambda o: o["opening"] > 0):      # reducing orders first383            size = o["closing"] + o["opening"]384            if size > 0:385                fill(o["m"], o["side"], size, px[o["m"]], ts, pending_at, cut=o["cut"])386387    with exact():388        for j in range(n):389            i = a + j390            ts = index[i]391            if wiped_at is not None:392                continue                                   # equity stays 0, nothing trades393            has_perp = any(pos[m] is not None for m in perp_idx)394            liquidated = False395396            # 1. funding on the position held coming into the bar397            if has_perp and j > 0:398                for m in perp_idx:399                    if pos[m] is None:400                        continue401                    rate = fund[i, m]402                    if not math.isfinite(rate):403                        counters["funding_missing"] += 1404                        continue405                    if rate == 0.0:406                        continue407                    ref = idx_px[i - 1, m] if math.isfinite(idx_px[i - 1, m]) else opens[i, m]408                    ref_d, rate_d, sz = dec(float(ref)), dec(float(rate)), signed_size(pos[m])409                    amount = -(sz * ref_d * rate_d)410                    collateral += amount411                    tot["funding"] += amount412                    counters["funding_payments"] += 1413                    fundings.append((ts, symbols[m], rate_d, sz, ref_d, amount))414415            # 2. gap through the liquidation level between the last close and this open416            if has_perp:417                px_open = prices(opens, i)418                if margin_equity(px_open) < maintenance(px_open):419                    liquidate(px_open, ts, "gap at the open")420                    liquidated, has_perp = True, False421422            # 3. fill of the decision taken on the previous close423            if pending is not None:424                execute(pending, i, ts)425                pending = None426                has_perp = any(pos[m] is not None for m in perp_idx)427428            # 4. intrabar liquidation on the adverse extremes429            if has_perp:430                px_open = prices(opens, i)431                held = [m for m in perp_idx if pos[m] is not None]432                adverse = list(px_open)433                for m in held:434                    adverse[m] = dec(float(lows[i, m] if pos[m].side == "long" else highs[i, m]))435                liq_px = None436                if len(held) == 1:437                    m = held[0]438                    p = pos[m]439                    lp = liquidation_price_cross(p.side, p.size, p.entry_price, collateral - spot_cost(), mmf[m])440                    if lp is not None and (adverse[m] <= lp if p.side == "long" else adverse[m] >= lp):441                        liq_px = list(px_open)442                        # the level is never better than the open (a touch at the open liquidates at the open)443                        liq_px[m] = min(lp, px_open[m]) if p.side == "long" else max(lp, px_open[m])444                else:445                    g0 = margin_equity(px_open) - maintenance(px_open)446                    g1 = margin_equity(adverse) - maintenance(adverse)447                    if g1 <= 0:448                        lam = Fraction(g0) / Fraction(g0 - g1) if g0 > g1 else Fraction(0)449                        lam = min(max(lam, Fraction(0)), Fraction(1))450                        liq_px = list(px_open)451                        for m in held:452                            f = Fraction(px_open[m]) + lam * (Fraction(adverse[m]) - Fraction(px_open[m]))453                            liq_px[m] = _frac_to_dec(f, "up" if pos[m].side == "long" else "down")454                if liq_px is not None:455                    liquidate(liq_px, ts, "intrabar: adverse extreme crossed the liquidation level")456                    liquidated = True457458            # 5. mark to market at the close459            px_close = [dec(float(mark_px[i, m] if math.isfinite(mark_px[i, m]) else closes[i, m])) for m in range(k)]460            eq = total_equity(px_close)461            if not eq > 0:462                wiped_at = ts463                for m in range(k):464                    if pos[m] is not None:465                        fill(m, "sell" if pos[m].side == "long" else "buy", pos[m].size, px_close[m], ts, None,466                             liquidation=True, costless=True)467                tot["bad_debt"] = -collateral          # >= 0: what the account could not pay468                collateral = ZERO469                pending = None470                continue471            eq_f = float(eq)472            equity[j], cash_curve[j] = eq_f, float(collateral)473            lo_eq = eq_f474            for m in range(k):475                if pos[m] is None:476                    continue477                sz, mk = float(pos[m].size), float(px_close[m])478                ntl = sz * mk479                if pos[m].side == "long":480                    long_x[j] += ntl / eq_f481                    wts[j, m] = ntl / eq_f482                    lo_eq += sz * (lows[i, m] - mk)483                else:484                    short_x[j] += ntl / eq_f485                    wts[j, m] = -ntl / eq_f486                    lo_eq += sz * (mk - highs[i, m])487            worst[j] = max(min(lo_eq, eq_f), 0.0)488            if liquidated:489                worst[j] = min(worst[j], max(liquidations[-1]["equity_after"], 0.0))490            if any(pos[m] is not None for m in perp_idx):491                me = margin_equity(px_close)492                util[j] = float(Fraction(margin_used(px_close)) / Fraction(me)) if me > 0 else float("nan")493494            # 6. decision on the closed bar (also on the last bar: recorded as the current signal, never filled)495            if due[i] and i + 1 >= lb:496                lo = i + 1 - lb497                view = {s: df.iloc[lo:i + 1] for s, df in zip(symbols, frames)}498                w = decide(view)499                if w is not None:500                    pending, pending_at = w, ts501                    decision_ts.append(ts)502                    decision_w.append([w[s] for s in symbols])503504        # ------------------------------------------------------------------------------------------ closing books505        last_px = [dec(float(mark_px[b - 1, m] if math.isfinite(mark_px[b - 1, m]) else closes[b - 1, m]))506                   for m in range(k)]507        unrealised = sum((upnl(m, last_px[m]) for m in range(k) if pos[m] is not None), ZERO)508        final_equity = collateral + unrealised509        trade_df = pd.DataFrame(trades, columns=TRADE_COLUMNS)510        funding_df = pd.DataFrame(fundings, columns=FUNDING_COLUMNS)511        # second, independent path: the logs alone must rebuild the running balance512        from_logs = (initial + sum(trade_df["realised_pnl"], ZERO) + sum(funding_df["amount"], ZERO)513                     - sum(trade_df["fee"], ZERO) - sum(trade_df["slippage"], ZERO) + unrealised + tot["bad_debt"])514        ledger = {515            "initial": initial, "realised_pnl": tot["realised"], "funding": tot["funding"], "fees": tot["fees"],516            "liquidation_penalties": tot["penalties"], "slippage": tot["slippage"], "unrealised_pnl": unrealised,517            "bad_debt_written_off": tot["bad_debt"], "collateral": collateral, "final_equity": final_equity,518            "from_logs": from_logs,519            "reconciles": bool(from_logs == final_equity and final_equity == initial + tot["realised"]520                               + tot["funding"] - tot["fees"] - tot["slippage"] + unrealised + tot["bad_debt"]),521        }522        open_positions = [523            {"symbol": s, "side": pos[m].side, "size": pos[m].size, "entry_price": pos[m].entry_price,524             "mark": last_px[m], "unrealised_pnl": upnl(m, last_px[m]),525             "units": float(signed_size(pos[m])), "cost": float(pos[m].size * pos[m].entry_price),526             "value": float(pos[m].size * pos[m].entry_price + upnl(m, last_px[m]))}527            for m, s in enumerate(symbols) if pos[m] is not None528        ]529530    win = index[a:b]531    series = lambda x, name: pd.Series(x, index=win, name=name)  # noqa: E731532    return PerpResult(533        symbols=symbols, config=config,534        equity=series(equity, "equity"), cash=series(cash_curve, "cash"),535        exposure=series(long_x + short_x, "exposure"), long_exposure=series(long_x, "long_exposure"),536        short_exposure=series(short_x, "short_exposure"), margin_utilisation=series(util, "margin_utilisation"),537        worst_equity=series(worst, "worst_equity"),538        weights=pd.DataFrame(wts, index=win, columns=symbols),539        decisions=pd.DataFrame(decision_w, index=pd.DatetimeIndex(decision_ts, name="ts"), columns=symbols),540        trades=trade_df, funding=funding_df, open_positions=open_positions, liquidations=liquidations,541        wiped_out=wiped_at is not None, wiped_out_at=wiped_at, ledger=ledger, counters=counters,542    )543544545# ---------------------------------------------------------------------------------------------------- benchmarks546547def perp_hold(data, config: PerpConfig, *, start=None, end=None, weights: dict | None = None) -> PerpResult:548    """Perpetual long 1x INCLUDING funding: equal weights summing to 1 opened once at the first possible fill,549    then held (no rebalancing): price PnL plus every funding payment, with the same fee and slippage."""550    symbols = [s for s in data if not is_spot_leg(s)]551    sub = {s: data[s] for s in symbols}552    w = weights or {s: 1.0 / len(symbols) for s in symbols}553    state = {"done": False}554555    def decide(_view):556        if state["done"]:557            return None558        state["done"] = True559        return w560561    cfg = PerpConfig(**{**config.to_dict(), "decision_every": 1, "rebalance_threshold": 0.0, "min_order": 0.0,562                        "leverage": 1.0})563    return run_perp_backtest(decide, sub, cfg, start=start, end=end)564
Files main
Menu

Theme