"""Performance statistics, uncertainty (block bootstrap), multiple-testing (deflated Sharpe), and stress scenarios.""" from __future__ import annotations import math from typing import Any import numpy as np import pandas as pd from scipy import stats def drawdown_series(equity: pd.Series) -> pd.Series: peak = equity.cummax() return equity / peak - 1.0 def compute_metrics(equity: pd.DataFrame, trades: list, interval_min: int, initial_cash: float, fees_paid: float, slippage_paid: float, turnover: float) -> dict[str, Any]: eq = equity["equity"].to_numpy(dtype=float) n = len(eq) if n < 3: return {"bars": n, "error": "not enough bars"} r = np.diff(np.log(np.maximum(eq, 1e-12))) bars_per_year = 365 * 24 * 60 / interval_min years = n / bars_per_year total_return = eq[-1] / eq[0] - 1 cagr = (eq[-1] / eq[0]) ** (1 / years) - 1 if years > 0 and eq[-1] > 0 else float("nan") vol = float(np.std(r, ddof=1) * math.sqrt(bars_per_year)) if n > 2 else float("nan") mean_ann = float(np.mean(r) * bars_per_year) sharpe = mean_ann / vol if vol and vol > 0 else float("nan") downside = r[r < 0] dvol = float(np.sqrt(np.mean(downside**2)) * math.sqrt(bars_per_year)) if len(downside) else float("nan") sortino = mean_ann / dvol if dvol and dvol > 0 else float("nan") dd = drawdown_series(equity["equity"]) max_dd = float(dd.min()) calmar = cagr / abs(max_dd) if max_dd < 0 and not math.isnan(cagr) else float("nan") exposure = float(equity["exposure"].abs().mean()) # trade-level (round trips approximated by consecutive buy→sell pairs) pnl_trades = _round_trips(trades) wins = [p for p in pnl_trades if p > 0] losses = [p for p in pnl_trades if p < 0] hit = len(wins) / len(pnl_trades) if pnl_trades else float("nan") pf = (sum(wins) / abs(sum(losses))) if losses else (float("inf") if wins else float("nan")) var95 = float(np.quantile(r, 0.05)) if n > 20 else float("nan") cvar95 = float(r[r <= var95].mean()) if n > 20 and np.any(r <= var95) else float("nan") fee_drag = fees_paid / initial_cash slip_drag = slippage_paid / initial_cash underwater = int((dd < 0).sum()) return { "bars": n, "years": years, "total_return": total_return, "cagr": cagr, "volatility": vol, "sharpe": sharpe, "sortino": sortino, "max_drawdown": max_dd, "calmar": calmar, "exposure": exposure, "turnover_annual": turnover, "n_trades": len(trades), "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"), "var_95_bar": var95, "cvar_95_bar": cvar95, "fees_paid": fees_paid, "fee_drag": fee_drag, "slippage_paid": slippage_paid, "slippage_drag": slip_drag, "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"), } def _round_trips(trades: list) -> list[float]: """FIFO round-trip P&L (buy lots closed by sells), fees included.""" lots: list[list[float]] = [] # [qty, price_incl_fee] out: list[float] = [] for t in trades: if t.side == "buy": lots.append([t.qty, (t.notional + t.fee) / t.qty if t.qty else t.price]) else: remaining = t.qty proceeds_per_unit = (t.notional - t.fee) / t.qty if t.qty else t.price pnl = 0.0 while remaining > 1e-12 and lots: lot = lots[0] take = min(lot[0], remaining) pnl += take * (proceeds_per_unit - lot[1]) lot[0] -= take remaining -= take if lot[0] <= 1e-12: lots.pop(0) out.append(pnl) return out def rolling_sharpe(equity: pd.DataFrame, interval_min: int, window_bars: int) -> list[dict[str, float]]: r = np.log(equity["equity"]).diff() bars_per_year = 365 * 24 * 60 / interval_min rs = (r.rolling(window_bars).mean() / r.rolling(window_bars).std(ddof=1)) * math.sqrt(bars_per_year) return [{"ts": int(t), "sharpe": float(s)} for t, s in zip(equity["ts"], rs, strict=False) if not math.isnan(s)] def block_bootstrap_ci(returns: np.ndarray, bars_per_year: float, n_boot: int = 500, block: int | None = None, seed: int = 42) -> dict[str, Any]: """Stationary (circular) block bootstrap of the Sharpe ratio and CAGR.""" rng = np.random.default_rng(seed) n = len(returns) if n < 30: return {"sharpe_ci": [None, None], "cagr_ci": [None, None], "n_boot": 0} block = block or max(5, int(round(n ** (1 / 3)))) sharpes, cagrs = [], [] nb = int(math.ceil(n / block)) for _ in range(n_boot): starts = rng.integers(0, n, size=nb) idx = (starts[:, None] + np.arange(block)[None, :]).ravel() % n s = returns[idx[:n]] sd = s.std(ddof=1) sharpes.append(s.mean() / sd * math.sqrt(bars_per_year) if sd > 0 else 0.0) cagrs.append(math.exp(s.sum() * (bars_per_year / n)) - 1) 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} def 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]: """Bailey & López de Prado (2014) Deflated Sharpe Ratio: probability that the observed SR exceeds the expected max SR of N trials under H0.""" if n_obs < 10 or not math.isfinite(sharpe_annual): return {"dsr": None, "sr0": None} sr = sharpe_annual / math.sqrt(bars_per_year) # per-bar Sharpe 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) em = 0.5772156649 n_trials = max(n_trials, 1) if n_trials == 1: sr0 = 0.0 else: sr0 = math.sqrt(var_trials) * ((1 - em) * stats.norm.ppf(1 - 1 / n_trials) + em * stats.norm.ppf(1 - 1 / (n_trials * math.e))) denom = math.sqrt(max(1 - skew * sr + (kurt_excess + 3 - 1) / 4 * sr**2, 1e-12)) z = (sr - sr0) * math.sqrt(n_obs - 1) / denom return {"dsr": float(stats.norm.cdf(z)), "sr0_annual": sr0 * math.sqrt(bars_per_year), "n_trials": n_trials} def stress_scenarios(run_fn, base_cost, equity_df: pd.DataFrame) -> dict[str, Any]: """Re-run the backtest under adverse cost/market assumptions. `run_fn(cost_model, df_override=None)` → metrics dict.""" out: dict[str, Any] = {} cm = base_cost out["fees_x2"] = run_fn(cm.model_copy(update={"fee_multiplier": 2.0})) out["spread_x3"] = run_fn(cm.model_copy(update={"spread_multiplier": 3.0})) out["slippage_plus_20bps"] = run_fn(cm.model_copy(update={"slippage_bps": cm.slippage_bps + 20})) out["latency_plus_1_bar"] = run_fn(cm.model_copy(update={"latency_bars": cm.latency_bars + 1})) out["liquidity_collapse"] = run_fn(cm.model_copy(update={"max_participation": max(cm.max_participation / 10, 1e-4), "impact_coeff": cm.impact_coeff * 5})) return out def shuffled_trades_test(trade_pnls: list[float], n: int = 1000, seed: int = 7) -> dict[str, Any]: """Monte-Carlo reordering of trade P&Ls: distribution of max drawdown of the cumulative P&L path.""" if len(trade_pnls) < 5: return {"n": 0} rng = np.random.default_rng(seed) arr = np.array(trade_pnls) mdds = [] for _ in range(n): p = rng.permutation(arr).cumsum() peak = np.maximum.accumulate(np.concatenate([[0.0], p]))[1:] mdds.append(float((p - peak).min())) 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())}