SPB Git

Repository Public

metrics.py 5.5 KB 139 lines python

1"""Performance metrics of one backtest window.23Definitions (r_t = equity_t / equity_{t-1} - 1 on bar closes, N = bars per 365-day year for a 24/7 market):45- total_return   equity_end / equity_start - 16- cagr           (equity_end / equity_start) ** (N / n_returns) - 17- volatility     std(r, ddof=1) * sqrt(N)8- sharpe         mean(r) / std(r, ddof=1) * sqrt(N), risk-free rate 0 (idle USDC earns nothing here)9- sortino        mean(r) / sqrt(mean(min(r, 0)^2)) * sqrt(N)   (downside deviation around 0, over all bars)10- max_drawdown   max over time of 1 - equity / running maximum, a POSITIVE fraction (0.35 = -35 %)11- calmar         cagr / max_drawdown12- turnover       traded notional (buys + sells) / average equity, over the window (not annualised);13                 turnover_annual is the same per year14- trades         number of fills15- win_rate, profit_factor   computed on closed lots: each sell realises a P&L against the average cost of the16                 position (fees and slippage included on both sides); positions still open at the end are counted17                 once, marked at the last close without exit costs. win_rate = share of lots with P&L > 0,18                 profit_factor = sum of gains / sum of losses. Both are null when undefined (no lot / no loss).19- fees_paid, slippage_paid  USD20- exposure       average invested fraction of equity2122Undefined ratios (zero variance, no drawdown, ...) are reported as null, never as infinity.23"""24from __future__ import annotations2526import math2728import numpy as np29import pandas as pd30from scipy import stats3132from .data.core import bars_per_year33from .engine import BacktestResult343536def _num(x):37    """JSON-safe float: None for NaN/inf."""38    if x is None:39        return None40    x = float(x)41    return x if math.isfinite(x) else None424344def max_drawdown(equity: np.ndarray) -> float:45    equity = np.asarray(equity, dtype="float64")46    if len(equity) == 0:47        return 0.048    peak = np.maximum.accumulate(equity)49    return float(np.max(1.0 - equity / peak))505152def sharpe_ratio(returns: np.ndarray, periods_per_year: float = 1.0) -> float:53    """mean/std(ddof=1) * sqrt(periods_per_year); NaN when undefined."""54    r = np.asarray(returns, dtype="float64")55    if len(r) < 2:56        return float("nan")57    sd = r.std(ddof=1)58    return float(r.mean() / sd * math.sqrt(periods_per_year)) if sd > 0 else float("nan")596061def sortino_ratio(returns: np.ndarray, periods_per_year: float = 1.0) -> float:62    r = np.asarray(returns, dtype="float64")63    if len(r) < 2:64        return float("nan")65    dd = math.sqrt(float(np.mean(np.minimum(r, 0.0) ** 2)))66    return float(r.mean() / dd * math.sqrt(periods_per_year)) if dd > 0 else float("nan")676869def return_distribution(returns: np.ndarray) -> dict:70    r = np.asarray(returns, dtype="float64")71    if len(r) < 2:72        return {"n": int(len(r))}73    constant = r.std() == 074    pct = np.percentile(r, [1, 5, 50, 95, 99])75    return {76        "n": int(len(r)),77        "mean": _num(r.mean()),78        "std": _num(r.std(ddof=1)),79        "skew": _num(0.0 if constant else stats.skew(r)),80        "excess_kurtosis": _num(0.0 if constant else stats.kurtosis(r)),81        "min": _num(r.min()),82        "p01": _num(pct[0]), "p05": _num(pct[1]), "median": _num(pct[2]), "p95": _num(pct[3]), "p99": _num(pct[4]),83        "max": _num(r.max()),84        "positive_fraction": _num((r > 0).mean()),85    }868788def lot_pnls(result: BacktestResult) -> np.ndarray:89    """Realised P&L of every sell, plus the unrealised P&L of each position still open at the end."""90    t = result.trades91    pnl = t.loc[t["side"] == "sell", "realised_pnl"].to_numpy(dtype="float64")92    still_open = np.array([p["value"] - p["cost"] for p in result.open_positions], dtype="float64")93    return np.concatenate([pnl, still_open])949596def compute_metrics(result: BacktestResult) -> dict:97    eq = result.equity.to_numpy(dtype="float64")98    r = eq[1:] / eq[:-1] - 1.099    n = len(r)100    N = bars_per_year(result.config.interval)101    growth = eq[-1] / eq[0]102    with np.errstate(over="ignore"):  # a huge annualised figure on a very short window becomes null103        cagr = growth ** (N / n) - 1.0 if n > 0 and growth > 0 else float("nan")104    mdd = max_drawdown(eq)105    t = result.trades106    notional = float(t["notional"].sum())107    turnover = notional / float(eq.mean())108    pnl = lot_pnls(result)109    gains, losses = float(pnl[pnl > 0].sum()), float(-pnl[pnl < 0].sum())110    return {111        "total_return": _num(growth - 1.0),112        "cagr": _num(cagr),113        "volatility": _num(r.std(ddof=1) * math.sqrt(N)) if n > 1 else None,114        "sharpe": _num(sharpe_ratio(r, N)),115        "sortino": _num(sortino_ratio(r, N)),116        "max_drawdown": _num(mdd),117        "calmar": _num(cagr / mdd) if mdd > 0 else None,118        "turnover": _num(turnover),119        "turnover_annual": _num(turnover * N / n) if n > 0 else None,120        "trades": int(len(t)),121        "closed_lots": int(len(pnl)),122        "win_rate": _num((pnl > 0).mean()) if len(pnl) else None,123        "profit_factor": _num(gains / losses) if losses > 0 else None,124        "fees_paid": _num(t["fee"].sum()),125        "slippage_paid": _num(t["slippage"].sum()),126        "exposure": _num(result.exposure.mean()),127        "bars": int(len(eq)),128        "final_equity": _num(eq[-1]),129        "returns": return_distribution(r),130    }131132133def window(result: BacktestResult) -> list[str]:134    return [result.equity.index[0].isoformat(), result.equity.index[-1].isoformat()]135136137def equity_points(equity: pd.Series) -> list:138    return [[ts.isoformat(), round(float(v), 6)] for ts, v in equity.items()]139
Files main
Menu

Theme