Repository Public
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