SPB Git forge
1commits 1branches 0releases
189.0 KBsize
maindefault branch
5 h agolast push
Python 54.7% TypeScript 42.5% SQL 1.9%
7.8 KB · 148 lines python
Raw Blame History
1"""Performance statistics, uncertainty (block bootstrap), multiple-testing (deflated Sharpe), and stress scenarios."""23from __future__ import annotations45import math6from typing import Any78import numpy as np9import pandas as pd10from scipy import stats111213def drawdown_series(equity: pd.Series) -> pd.Series:14    peak = equity.cummax()15    return equity / peak - 1.0161718def compute_metrics(equity: pd.DataFrame, trades: list, interval_min: int, initial_cash: float, fees_paid: float, slippage_paid: float, turnover: float) -> dict[str, Any]:19    eq = equity["equity"].to_numpy(dtype=float)20    n = len(eq)21    if n < 3:22        return {"bars": n, "error": "not enough bars"}23    r = np.diff(np.log(np.maximum(eq, 1e-12)))24    bars_per_year = 365 * 24 * 60 / interval_min25    years = n / bars_per_year26    total_return = eq[-1] / eq[0] - 127    cagr = (eq[-1] / eq[0]) ** (1 / years) - 1 if years > 0 and eq[-1] > 0 else float("nan")28    vol = float(np.std(r, ddof=1) * math.sqrt(bars_per_year)) if n > 2 else float("nan")29    mean_ann = float(np.mean(r) * bars_per_year)30    sharpe = mean_ann / vol if vol and vol > 0 else float("nan")31    downside = r[r < 0]32    dvol = float(np.sqrt(np.mean(downside**2)) * math.sqrt(bars_per_year)) if len(downside) else float("nan")33    sortino = mean_ann / dvol if dvol and dvol > 0 else float("nan")34    dd = drawdown_series(equity["equity"])35    max_dd = float(dd.min())36    calmar = cagr / abs(max_dd) if max_dd < 0 and not math.isnan(cagr) else float("nan")37    exposure = float(equity["exposure"].abs().mean())38    # trade-level (round trips approximated by consecutive buy→sell pairs)39    pnl_trades = _round_trips(trades)40    wins = [p for p in pnl_trades if p > 0]41    losses = [p for p in pnl_trades if p < 0]42    hit = len(wins) / len(pnl_trades) if pnl_trades else float("nan")43    pf = (sum(wins) / abs(sum(losses))) if losses else (float("inf") if wins else float("nan"))44    var95 = float(np.quantile(r, 0.05)) if n > 20 else float("nan")45    cvar95 = float(r[r <= var95].mean()) if n > 20 and np.any(r <= var95) else float("nan")46    fee_drag = fees_paid / initial_cash47    slip_drag = slippage_paid / initial_cash48    underwater = int((dd < 0).sum())49    return {50        "bars": n, "years": years, "total_return": total_return, "cagr": cagr, "volatility": vol, "sharpe": sharpe, "sortino": sortino,51        "max_drawdown": max_dd, "calmar": calmar, "exposure": exposure, "turnover_annual": turnover, "n_trades": len(trades),52        "n_round_trips": len(pnl_trades), "hit_rate": hit, "profit_factor": pf, "avg_trade_pnl": float(np.mean(pnl_trades)) if pnl_trades else float("nan"),53        "var_95_bar": var95, "cvar_95_bar": cvar95, "fees_paid": fees_paid, "fee_drag": fee_drag, "slippage_paid": slippage_paid, "slippage_drag": slip_drag,54        "underwater_bars_pct": underwater / n, "final_equity": float(eq[-1]), "skew": float(stats.skew(r)) if n > 3 else float("nan"), "kurtosis": float(stats.kurtosis(r)) if n > 3 else float("nan"),55    }565758def _round_trips(trades: list) -> list[float]:59    """FIFO round-trip P&L (buy lots closed by sells), fees included."""60    lots: list[list[float]] = []  # [qty, price_incl_fee]61    out: list[float] = []62    for t in trades:63        if t.side == "buy":64            lots.append([t.qty, (t.notional + t.fee) / t.qty if t.qty else t.price])65        else:66            remaining = t.qty67            proceeds_per_unit = (t.notional - t.fee) / t.qty if t.qty else t.price68            pnl = 0.069            while remaining > 1e-12 and lots:70                lot = lots[0]71                take = min(lot[0], remaining)72                pnl += take * (proceeds_per_unit - lot[1])73                lot[0] -= take74                remaining -= take75                if lot[0] <= 1e-12:76                    lots.pop(0)77            out.append(pnl)78    return out798081def rolling_sharpe(equity: pd.DataFrame, interval_min: int, window_bars: int) -> list[dict[str, float]]:82    r = np.log(equity["equity"]).diff()83    bars_per_year = 365 * 24 * 60 / interval_min84    rs = (r.rolling(window_bars).mean() / r.rolling(window_bars).std(ddof=1)) * math.sqrt(bars_per_year)85    return [{"ts": int(t), "sharpe": float(s)} for t, s in zip(equity["ts"], rs, strict=False) if not math.isnan(s)]868788def block_bootstrap_ci(returns: np.ndarray, bars_per_year: float, n_boot: int = 500, block: int | None = None, seed: int = 42) -> dict[str, Any]:89    """Stationary (circular) block bootstrap of the Sharpe ratio and CAGR."""90    rng = np.random.default_rng(seed)91    n = len(returns)92    if n < 30:93        return {"sharpe_ci": [None, None], "cagr_ci": [None, None], "n_boot": 0}94    block = block or max(5, int(round(n ** (1 / 3))))95    sharpes, cagrs = [], []96    nb = int(math.ceil(n / block))97    for _ in range(n_boot):98        starts = rng.integers(0, n, size=nb)99        idx = (starts[:, None] + np.arange(block)[None, :]).ravel() % n100        s = returns[idx[:n]]101        sd = s.std(ddof=1)102        sharpes.append(s.mean() / sd * math.sqrt(bars_per_year) if sd > 0 else 0.0)103        cagrs.append(math.exp(s.sum() * (bars_per_year / n)) - 1)104    return {"sharpe_ci": [float(np.quantile(sharpes, 0.025)), float(np.quantile(sharpes, 0.975))], "cagr_ci": [float(np.quantile(cagrs, 0.025)), float(np.quantile(cagrs, 0.975))], "n_boot": n_boot, "block": block}105106107def deflated_sharpe(sharpe_annual: float, n_obs: int, bars_per_year: float, n_trials: int, skew: float, kurt_excess: float, sharpe_var_trials: float | None = None) -> dict[str, Any]:108    """Bailey & López de Prado (2014) Deflated Sharpe Ratio: probability that the observed SR exceeds the expected max SR of N trials under H0."""109    if n_obs < 10 or not math.isfinite(sharpe_annual):110        return {"dsr": None, "sr0": None}111    sr = sharpe_annual / math.sqrt(bars_per_year)  # per-bar Sharpe112    var_trials = sharpe_var_trials if sharpe_var_trials is not None else (0.5 / bars_per_year)  # conservative default spread of trial SRs (per bar)113    em = 0.5772156649114    n_trials = max(n_trials, 1)115    if n_trials == 1:116        sr0 = 0.0117    else:118        sr0 = math.sqrt(var_trials) * ((1 - em) * stats.norm.ppf(1 - 1 / n_trials) + em * stats.norm.ppf(1 - 1 / (n_trials * math.e)))119    denom = math.sqrt(max(1 - skew * sr + (kurt_excess + 3 - 1) / 4 * sr**2, 1e-12))120    z = (sr - sr0) * math.sqrt(n_obs - 1) / denom121    return {"dsr": float(stats.norm.cdf(z)), "sr0_annual": sr0 * math.sqrt(bars_per_year), "n_trials": n_trials}122123124def stress_scenarios(run_fn, base_cost, equity_df: pd.DataFrame) -> dict[str, Any]:125    """Re-run the backtest under adverse cost/market assumptions. `run_fn(cost_model, df_override=None)` → metrics dict."""126    out: dict[str, Any] = {}127    cm = base_cost128    out["fees_x2"] = run_fn(cm.model_copy(update={"fee_multiplier": 2.0}))129    out["spread_x3"] = run_fn(cm.model_copy(update={"spread_multiplier": 3.0}))130    out["slippage_plus_20bps"] = run_fn(cm.model_copy(update={"slippage_bps": cm.slippage_bps + 20}))131    out["latency_plus_1_bar"] = run_fn(cm.model_copy(update={"latency_bars": cm.latency_bars + 1}))132    out["liquidity_collapse"] = run_fn(cm.model_copy(update={"max_participation": max(cm.max_participation / 10, 1e-4), "impact_coeff": cm.impact_coeff * 5}))133    return out134135136def shuffled_trades_test(trade_pnls: list[float], n: int = 1000, seed: int = 7) -> dict[str, Any]:137    """Monte-Carlo reordering of trade P&Ls: distribution of max drawdown of the cumulative P&L path."""138    if len(trade_pnls) < 5:139        return {"n": 0}140    rng = np.random.default_rng(seed)141    arr = np.array(trade_pnls)142    mdds = []143    for _ in range(n):144        p = rng.permutation(arr).cumsum()145        peak = np.maximum.accumulate(np.concatenate([[0.0], p]))[1:]146        mdds.append(float((p - peak).min()))147    return {"n": n, "max_dd_p50": float(np.median(mdds)), "max_dd_p95": float(np.quantile(mdds, 0.05)), "max_dd_worst": float(min(mdds)), "total_pnl": float(arr.sum())}148