Download algotrader/validation/pbo.py from ParallelLLC/algorithmic_trading: direct link, hf CLI and curl.
- Browser
- Download file 4.67 kB
-
https://huggingface.co/ParallelLLC/algorithmic_trading/resolve/main/algotrader/validation/pbo.py
- Command line
-
hf download hf://ParallelLLC/algorithmic_trading/algotrader/validation/pbo.py
-
curl -L -o pbo.py https://huggingface.co/ParallelLLC/algorithmic_trading/resolve/main/algotrader/validation/pbo.py
4.67 kB
| """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) | |
| # Absolute floor, not `> 0`: a flat column's std is float noise, and | |
| # dividing by it would hand a do-nothing variant an enormous rank. | |
| 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])) | |
| # Relative rank of the chosen variant in the OOS ranking, in (0, 1). | |
| 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) | |
| # Slope of OOS on IS: negative means better in-sample fits do *worse* live. | |
| 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": "", | |
| } | |