SPB Git

Repository Public

perp_metrics.py 8.0 KB 160 lines python

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

Theme