Repository Public
1"""Metrics of one perpetual-futures backtest window: every key of spq.metrics (same definitions) plus23- realised_pnl, funding_pnl USD; funding_pnl < 0 means the account paid funding on balance4- fees_paid USD, trading fees + liquidation penalties; liquidation_penalties is the penalty part5- slippage_paid USD6- exposure mean gross exposure / equity (the v1 key: for a long-only spot run this was the7 invested fraction)8- long_exposure, short_exposure mean long notional / equity and mean short notional / equity (both positive)9- gross_leverage_mean / _max (long + short notional) / equity at bar closes10- net_leverage_mean / _max / _min (long - short notional) / equity; _max and _min are the extremes reached11- time_in_market share of bars that end with at least one position12- avg_holding_bars / _hours mean length of an uninterrupted run of bars with a position of the same sign in13 one symbol (a flip ends a run; a run still open at the end counts as it stands)14- liquidations account liquidations (each closes every perpetual position)15- wiped_out true when equity reached zero: the run stopped there16- margin_utilisation_mean / _max initial margin in use / margin equity, over the bars with a perpetual position17- worst_intrabar_drawdown max over bars of 1 - (equity with every long at the bar's low and every short at18 its high) / (highest closing equity before that bar). It is >= max_drawdown, which19 only looks at closes. Positive fraction.20- orders_cut, orders_refused orders reduced or dropped by the initial-margin check21- funding_payments, funding_missing settlements charged, and settlements the data did not have while a position22 was open (charged as zero)2324win_rate and profit_factor are on closing fills: realised PnL of the closed part minus the fee and slippage of25that fill (entry costs are not allocated to lots), plus the unrealised PnL of each position still open.26"""27from __future__ import annotations2829import math3031import numpy as np3233from .data.core import bars_per_year, interval_seconds34from .metrics import _num, max_drawdown, return_distribution, sharpe_ratio, sortino_ratio35from .perp_engine import PerpResult3637PERP_KEYS = [38 "realised_pnl", "funding_pnl", "liquidation_penalties", "long_exposure", "short_exposure",39 "gross_leverage_mean", "gross_leverage_max", "net_leverage_mean", "net_leverage_max", "net_leverage_min",40 "time_in_market", "avg_holding_bars", "avg_holding_hours", "liquidations", "wiped_out",41 "margin_utilisation_mean", "margin_utilisation_max", "worst_intrabar_drawdown", "orders_cut", "orders_refused",42 "funding_payments", "funding_missing",43]444546def perp_lot_pnls(result: PerpResult) -> np.ndarray:47 """Net PnL of every closing fill, plus the unrealised PnL of each position still open at the end."""48 t = result.trades49 closing = t[t["kind"].isin(["reduce", "close", "flip"])]50 pnl = np.array([float(r - f - s) for r, f, s in zip(closing["realised_pnl"], closing["fee"], closing["slippage"])],51 dtype="float64")52 still_open = np.array([float(p["unrealised_pnl"]) for p in result.open_positions], dtype="float64")53 return np.concatenate([pnl, still_open])545556def holding_runs(weights: np.ndarray) -> list[int]:57 """Lengths (bars) of the runs of consecutive bars with the same non-zero sign, per column."""58 runs = []59 sign = np.sign(weights)60 for m in range(sign.shape[1]):61 col = sign[:, m]62 length, cur = 0, 0.063 for v in col:64 if v != 0 and v == cur:65 length += 166 else:67 if cur != 0:68 runs.append(length)69 cur, length = v, (1 if v != 0 else 0)70 if cur != 0:71 runs.append(length)72 return runs737475def worst_intrabar_drawdown(result: PerpResult) -> float:76 eq = result.equity.to_numpy(dtype="float64")77 worst = result.worst_equity.to_numpy(dtype="float64")78 if len(eq) == 0:79 return 0.080 peak_before = np.maximum.accumulate(np.concatenate([[float(result.config.initial_cash)], eq[:-1]]))81 with np.errstate(divide="ignore", invalid="ignore"):82 dd = 1.0 - np.minimum(worst, eq) / peak_before83 return float(np.nanmax(dd))848586def compute_perp_metrics(result: PerpResult) -> dict:87 eq = result.equity.to_numpy(dtype="float64")88 r = result.returns.to_numpy(dtype="float64")89 n = len(r)90 N = bars_per_year(result.config.interval)91 growth = eq[-1] / eq[0] if eq[0] > 0 else 0.092 with np.errstate(over="ignore"):93 cagr = growth ** (N / n) - 1.0 if n > 0 and growth > 0 else (-1.0 if result.wiped_out else float("nan"))94 mdd = max_drawdown(eq) if eq[0] > 0 else 1.095 t = result.trades96 notional = float(sum(t["notional"])) if len(t) else 0.097 mean_eq = float(eq.mean())98 turnover = notional / mean_eq if mean_eq > 0 else float("nan")99 pnl = perp_lot_pnls(result)100 gains, losses = float(pnl[pnl > 0].sum()), float(-pnl[pnl < 0].sum())101 gross = result.exposure.to_numpy()102 net = result.net_exposure.to_numpy()103 w = result.weights.to_numpy()104 in_market = np.abs(w).sum(axis=1) > 0105 runs = holding_runs(w)106 util = result.margin_utilisation.to_numpy()107 util = util[(util > 0) & np.isfinite(util)]108 led = result.ledger109 return {110 "total_return": _num(growth - 1.0),111 "cagr": _num(cagr),112 "volatility": _num(r.std(ddof=1) * math.sqrt(N)) if n > 1 else None,113 "sharpe": _num(sharpe_ratio(r, N)),114 "sortino": _num(sortino_ratio(r, N)),115 "max_drawdown": _num(mdd),116 "calmar": _num(cagr / mdd) if mdd > 0 else None,117 "turnover": _num(turnover),118 "turnover_annual": _num(turnover * N / n) if n > 0 else None,119 "trades": int(len(t)),120 "closed_lots": int(len(pnl)),121 "win_rate": _num((pnl > 0).mean()) if len(pnl) else None,122 "profit_factor": _num(gains / losses) if losses > 0 else None,123 "fees_paid": _num(float(led["fees"])),124 "slippage_paid": _num(float(led["slippage"])),125 "exposure": _num(gross.mean()),126 "bars": int(len(eq)),127 "final_equity": _num(eq[-1]),128 "returns": return_distribution(r),129 # ------------------------------------------------------------------------------------------- perps130 "realised_pnl": _num(float(led["realised_pnl"])),131 "funding_pnl": _num(float(led["funding"])),132 "liquidation_penalties": _num(float(led["liquidation_penalties"])),133 "long_exposure": _num(result.long_exposure.mean()),134 "short_exposure": _num(result.short_exposure.mean()),135 "gross_leverage_mean": _num(gross.mean()),136 "gross_leverage_max": _num(gross.max()),137 "net_leverage_mean": _num(net.mean()),138 "net_leverage_max": _num(net.max()),139 "net_leverage_min": _num(net.min()),140 "time_in_market": _num(in_market.mean()),141 "avg_holding_bars": _num(np.mean(runs)) if runs else None,142 "avg_holding_hours": _num(np.mean(runs) * interval_seconds(result.config.interval) / 3600.0) if runs else None,143 "liquidations": int(len(result.liquidations)),144 "wiped_out": bool(result.wiped_out),145 "margin_utilisation_mean": _num(util.mean()) if len(util) else 0.0,146 "margin_utilisation_max": _num(util.max()) if len(util) else 0.0,147 "worst_intrabar_drawdown": _num(worst_intrabar_drawdown(result)),148 "orders_cut": int(result.counters["orders_cut"]),149 "orders_refused": int(result.counters["orders_refused"]),150 "funding_payments": int(result.counters["funding_payments"]),151 "funding_missing": int(result.counters["funding_missing"]),152 }153154155def ledger_report(result: PerpResult) -> dict:156 """The exact ledger as decimal strings (JSON has no exact decimal type)."""157 from .margin import dstr158159 return {k: (dstr(v) if not isinstance(v, bool) else v) for k, v in result.ledger.items()}160