| """Probability of Backtest Overfitting via Combinatorially Symmetric CV. |
| |
| Bailey, Borwein, López de Prado & Zhu (2016), "The Probability of Backtest |
| Overfitting". |
| |
| Take the N parameter variants you tried, cut the timeline into S chunks, and |
| for every way of splitting those chunks half-and-half: pick the variant that |
| won in-sample, then look up where it ranked out-of-sample. If your selection |
| process has skill, the in-sample winner should keep winning. If it is fitting |
| noise, the winner lands in the bottom half about as often as not — and PBO |
| approaches 0.5. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import itertools |
| from typing import Dict, Sequence |
|
|
| import numpy as np |
|
|
| __all__ = ["probability_of_backtest_overfitting"] |
|
|
|
|
| def _sharpe_columns(matrix: np.ndarray) -> np.ndarray: |
| """Per-column Sharpe (per-period, unannualised — ranks are all we need).""" |
| if matrix.shape[0] < 2: |
| return np.zeros(matrix.shape[1]) |
| mean = matrix.mean(axis=0) |
| sd = matrix.std(axis=0, ddof=1) |
| |
| |
| with np.errstate(divide="ignore", invalid="ignore"): |
| out = np.where(sd > 1e-12, mean / sd, 0.0) |
| return np.nan_to_num(out, nan=0.0, posinf=0.0, neginf=0.0) |
|
|
|
|
| def probability_of_backtest_overfitting( |
| returns_matrix: np.ndarray, |
| n_splits: int = 8, |
| labels: Sequence[str] | None = None, |
| max_combinations: int = 200, |
| ) -> Dict[str, object]: |
| """Compute PBO for a ``T x N`` matrix of per-period strategy returns. |
| |
| ``n_splits`` must be even. Returns PBO, the logit distribution, the |
| in-sample/out-of-sample Sharpe pairs for the selected variants, and the |
| rate at which the selected variant actually loses money out of sample. |
| """ |
| matrix = np.asarray(returns_matrix, dtype=float) |
| if matrix.ndim != 2: |
| raise ValueError("returns_matrix must be 2-D (time x strategy)") |
| matrix = np.nan_to_num(matrix, nan=0.0, posinf=0.0, neginf=0.0) |
| t_obs, n_strats = matrix.shape |
|
|
| if n_strats < 2 or t_obs < 2 * n_splits: |
| return { |
| "pbo": float("nan"), |
| "n_strategies": int(n_strats), |
| "n_combinations": 0, |
| "logits": np.array([]), |
| "is_sharpes": np.array([]), |
| "oos_sharpes": np.array([]), |
| "prob_oos_loss": float("nan"), |
| "performance_degradation": float("nan"), |
| "note": "Not enough variants or observations to estimate PBO.", |
| } |
|
|
| if n_splits % 2: |
| n_splits += 1 |
| chunks = np.array_split(np.arange(t_obs), n_splits) |
|
|
| combos = list(itertools.combinations(range(n_splits), n_splits // 2)) |
| if len(combos) > max_combinations: |
| step = len(combos) / max_combinations |
| combos = [combos[int(i * step)] for i in range(max_combinations)] |
|
|
| logits, is_sr, oos_sr, chosen = [], [], [], [] |
| for combo in combos: |
| is_idx = np.concatenate([chunks[c] for c in combo]) |
| oos_idx = np.concatenate([chunks[c] for c in range(n_splits) if c not in combo]) |
|
|
| is_perf = _sharpe_columns(matrix[is_idx]) |
| oos_perf = _sharpe_columns(matrix[oos_idx]) |
|
|
| best = int(np.argmax(is_perf)) |
| chosen.append(best) |
| is_sr.append(float(is_perf[best])) |
| oos_sr.append(float(oos_perf[best])) |
|
|
| |
| rank = float(np.sum(oos_perf <= oos_perf[best])) |
| omega = rank / (n_strats + 1.0) |
| omega = min(max(omega, 1e-6), 1.0 - 1e-6) |
| logits.append(float(np.log(omega / (1.0 - omega)))) |
|
|
| logits_arr = np.asarray(logits) |
| is_arr, oos_arr = np.asarray(is_sr), np.asarray(oos_sr) |
|
|
| |
| degradation = float("nan") |
| if is_arr.size > 2 and np.std(is_arr) > 1e-12: |
| degradation = float(np.polyfit(is_arr, oos_arr, 1)[0]) |
|
|
| counts = np.bincount(chosen, minlength=n_strats) |
| most_selected = int(np.argmax(counts)) |
|
|
| return { |
| "pbo": float(np.mean(logits_arr <= 0.0)), |
| "n_strategies": int(n_strats), |
| "n_combinations": int(len(combos)), |
| "logits": logits_arr, |
| "is_sharpes": is_arr, |
| "oos_sharpes": oos_arr, |
| "prob_oos_loss": float(np.mean(oos_arr <= 0.0)), |
| "performance_degradation": degradation, |
| "most_selected_index": most_selected, |
| "most_selected_label": (labels[most_selected] if labels is not None else str(most_selected)), |
| "selection_stability": float(counts[most_selected] / max(len(combos), 1)), |
| "note": "", |
| } |
|
|