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