Source code for fynance.research.overfit

#!/usr/bin/env python
# -*- coding: utf-8 -*-

""" Combinatorially symmetric cross-validation (CSCV) overfitting guard.

Complements :mod:`fynance.research.guards`: where the permutation test and
the (deflated) Sharpe ratio ask *"is this one strategy's edge real?"*, the
**probability of backtest overfitting** (PBO) asks *"across everything I
tried, did I just pick the best-in-sample fluke?"*. It implements the CSCV
procedure of Bailey, Borwein, Lopez de Prado & Zhu, "The probability of
backtest overfitting", Journal of Computational Finance (2015): split the
track record into blocks, replay every balanced in-sample/out-of-sample split,
and measure how often the in-sample winner turns out to be an out-of-sample
laggard.

All functions are data-agnostic and pure numpy; nothing here reads real data
or stores results.

"""

# Built-in
from __future__ import annotations

import math
from dataclasses import dataclass
from itertools import combinations
from typing import Callable

# Third-party
import numpy as np
from numpy.typing import NDArray

# Local
from fynance.research.experiment import Experiment

__all__ = ['PBOResult', 'pbo', 'returns_panel']

# CSCV enumerates C(n_blocks, n_blocks / 2) IS/OOS splits; this is the value
# at the recommended n_blocks=16 ceiling (Bailey et al. run 16 blocks in their
# reference examples) — beyond it the combinatorics explode silently.
_MAX_COMBINATIONS = 12870


[docs] def returns_panel(experiments: list[Experiment]) -> NDArray[np.float64]: """ Build a ``(T, n_configs)`` returns panel from research experiments. One column per :class:`~fynance.research.Experiment`, taken from its stored ``series["returns"]`` (the per-period net returns :func:`~fynance.research.run_experiment` records). When an experiment carries only ``series["equity"]`` (e.g. a hand-built record), the column is derived as the equity curve's percentage change, ``R_t = E_t / E_{t-1} - 1`` for ``t = 1..T-1`` — one observation shorter than the equity curve, since the first equity point has no preceding value to return from. Parameters ---------- experiments : list of Experiment The configurations to stack into a panel (>= 1). Returns ------- numpy.ndarray, shape (T, n_configs) Per-period returns, one config per column. Raises ------ ValueError If ``experiments`` is empty, an experiment carries neither a ``"returns"`` nor an ``"equity"`` series (or an equity curve too short to derive a return from), or the resulting columns have mismatched lengths. Examples -------- >>> from fynance.research import Experiment, returns_panel >>> a = Experiment(name="a", series={"returns": [0.01, -0.02, 0.03]}) >>> b = Experiment(name="b", series={"equity": [100.0, 101.0, 99.0, 102.0]}) >>> returns_panel([a, b]).shape (3, 2) """ if not experiments: raise ValueError("returns_panel needs at least one experiment") columns: list[NDArray[np.float64]] = [] for exp in experiments: series = exp.series or {} if series.get("returns") is not None: col = np.asarray(series["returns"], dtype=np.float64) elif series.get("equity") is not None: equity = np.asarray(series["equity"], dtype=np.float64) if equity.shape[0] < 2: raise ValueError( f"experiment {exp.name!r} equity curve is too short " f"({equity.shape[0]} point(s)) to derive returns from" ) col = equity[1:] / equity[:-1] - 1.0 else: raise ValueError( f"experiment {exp.name!r} carries neither a 'returns' nor an " f"'equity' series" ) columns.append(col) lengths = {col.shape[0] for col in columns} if len(lengths) > 1: details = {exp.name: col.shape[0] for exp, col in zip(experiments, columns)} raise ValueError( f"experiments have mismatched curve lengths, cannot form a panel: " f"{details}" ) return np.column_stack(columns)
def _default_metric(returns: NDArray[np.float64]) -> float: """ Annualization-free Sharpe: mean over std of the per-period returns. Deliberately does not annualize by a ``period`` (CSCV only compares in-sample vs. out-of-sample *ranks* across configs, so a constant scale factor cancels) and does not assume ``returns`` is a price/equity curve the way :func:`fynance.metrics.sharpe` does — here it is already the per-period return series of one config's block-concatenated stretch. """ if returns.size < 2: return 0.0 std = float(np.std(returns, ddof=1)) if std == 0.0: # Degenerate (constant) stretch: no dispersion to divide by. Return a # large, finite, sign-carrying value rather than 0 or inf so a # genuinely constant-positive stretch still outranks noisy ones # without poisoning downstream arithmetic (OLS, rank ties) with inf. mean = float(np.mean(returns)) return 0.0 if mean == 0.0 else math.copysign(1.0e6, mean) return float(np.mean(returns)) / std def _rankdata_average(x: NDArray[np.float64]) -> NDArray[np.float64]: """ Ascending, 1-indexed ranks with ties averaged (pure-numpy). A minimal analogue of ``scipy.stats.rankdata(x, method="average")`` — written locally to avoid adding a ``scipy.stats`` dependency for a single small helper on arrays sized ``n_configs``. """ order = np.argsort(x, kind="mergesort") sorted_x = x[order] ranks = np.empty(x.shape[0], dtype=np.float64) n = x.shape[0] i = 0 while i < n: j = i while j + 1 < n and sorted_x[j + 1] == sorted_x[i]: j += 1 # Ranks i+1..j+1 (1-indexed) tied -> their average. ranks[order[i:j + 1]] = (i + j) / 2.0 + 1.0 i = j + 1 return ranks def _ols(x: NDArray[np.float64], y: NDArray[np.float64]) -> tuple[float, float]: """ OLS slope/intercept of ``y`` on ``x``; flat line if ``x`` has no spread. """ x_mean, y_mean = float(np.mean(x)), float(np.mean(y)) var_x = float(np.sum((x - x_mean) ** 2)) if var_x == 0.0: return 0.0, y_mean slope = float(np.sum((x - x_mean) * (y - y_mean)) / var_x) return slope, y_mean - slope * x_mean
[docs] @dataclass class PBOResult: """ Result of a :func:`pbo` run. Attributes ---------- pbo : float Probability of backtest overfitting: the share of IS/OOS splits where the in-sample winner's out-of-sample relative rank falls at or below the median (``logit <= 0``). logits : numpy.ndarray, shape (n_combinations,) Rank logit ``ln(r / (1 - r))`` of the IS winner's OOS performance, one per split. prob_oos_loss : float Share of splits where the IS winner's OOS performance is negative. slope : float OLS slope of OOS performance on IS performance (of the IS winner) across splits — a robust-performing selection procedure has a positive slope; near-zero or negative indicates the IS ranking carries no out-of-sample information. intercept : float OLS intercept of the same regression. is_perf : numpy.ndarray, shape (n_combinations,) The IS winner's own in-sample metric value, one per split. oos_perf : numpy.ndarray, shape (n_combinations,) The IS winner's out-of-sample metric value, one per split. """ pbo: float logits: NDArray[np.float64] prob_oos_loss: float slope: float intercept: float is_perf: NDArray[np.float64] oos_perf: NDArray[np.float64]
[docs] def pbo( returns_panel: NDArray[np.float64], n_blocks: int = 16, metric: Callable[[NDArray[np.float64]], float] | None = None, ) -> PBOResult: r""" Probability of backtest overfitting via combinatorially symmetric CV. Implements the CSCV procedure of Bailey, Borwein, Lopez de Prado & Zhu (2015): split the ``T`` periods into ``n_blocks`` contiguous blocks; for every way to assign half the blocks to an in-sample (IS) half and the other half to the complementary out-of-sample (OOS) half (:math:`\binom{n\_blocks}{n\_blocks / 2}` splits total), pick the config that scores best on the IS half by ``metric``, then locate that same config's OOS-half performance among *all* configs' OOS performances as a relative rank :math:`r \in (0, 1)` (average-tie rank divided by ``n_configs + 1``, so the best OOS config sits close to 1 and the worst close to 0). The rank logit is :math:`\lambda = \ln(r / (1 - r))`; ``pbo`` is the share of splits with :math:`\lambda \le 0`, i.e. where the in-sample winner performs at or below the OOS median — a config search that overfits systematically sends the IS winner to the bottom half OOS. Parameters ---------- returns_panel : numpy.ndarray, shape (T, n_configs) Per-period returns, one column per configuration tried (see :func:`returns_panel` to build this from :class:`~fynance.research.Experiment` runs). n_blocks : int Number of contiguous blocks to split ``T`` into. Must be even (a balanced IS/OOS split) and no greater than 16 — beyond that :math:`\binom{n\_blocks}{n\_blocks/2}` exceeds 12,870 splits. metric : callable, optional ``metric(returns) -> float`` scoring a 1-D per-period return stretch, higher is better. Defaults to an annualization-free Sharpe (mean over std of the returns) — deliberately not :func:`fynance.metrics.sharpe`, which assumes a price/equity curve rather than a returns series. Returns ------- PBOResult The overfitting diagnostics (see :class:`PBOResult`). Raises ------ ValueError If ``returns_panel`` is not 2-D, ``n_blocks`` is odd, ``n_blocks`` implies more than 12,870 IS/OOS splits, or ``T < n_blocks``. Examples -------- >>> import numpy as np >>> from fynance.research import pbo >>> rng = np.random.default_rng(0) >>> panel = rng.normal(0.0, 0.01, size=(400, 8)) # pure noise, no edge >>> result = pbo(panel, n_blocks=8) >>> 0.0 <= result.pbo <= 1.0 True >>> result.logits.shape (70,) """ panel = np.asarray(returns_panel, dtype=np.float64) if panel.ndim != 2: raise ValueError( f"returns_panel must be 2-D (T, n_configs); got ndim={panel.ndim}" ) n_periods, n_configs = panel.shape if n_blocks % 2 != 0: raise ValueError(f"n_blocks must be even for a balanced split; got {n_blocks}") half = n_blocks // 2 n_combinations = math.comb(n_blocks, half) if n_combinations > _MAX_COMBINATIONS: raise ValueError( f"n_blocks={n_blocks} implies C({n_blocks}, {half})={n_combinations} " f"IS/OOS splits (> {_MAX_COMBINATIONS}, i.e. n_blocks > 16) — lower " f"n_blocks" ) if n_periods < n_blocks: raise ValueError( f"returns_panel has T={n_periods} periods, fewer than n_blocks=" f"{n_blocks}" ) fn = metric if metric is not None else _default_metric blocks = np.array_split(np.arange(n_periods), n_blocks) all_blocks = set(range(n_blocks)) logits = np.empty(n_combinations, dtype=np.float64) is_perf = np.empty(n_combinations, dtype=np.float64) oos_perf = np.empty(n_combinations, dtype=np.float64) for k, is_combo in enumerate(combinations(range(n_blocks), half)): oos_combo = sorted(all_blocks - set(is_combo)) is_idx = np.concatenate([blocks[b] for b in is_combo]) oos_idx = np.concatenate([blocks[b] for b in oos_combo]) is_metrics = np.array( [fn(panel[is_idx, c]) for c in range(n_configs)], dtype=np.float64 ) winner = int(np.argmax(is_metrics)) oos_metrics = np.array( [fn(panel[oos_idx, c]) for c in range(n_configs)], dtype=np.float64 ) is_perf[k] = is_metrics[winner] oos_perf[k] = oos_metrics[winner] ranks = _rankdata_average(oos_metrics) r = ranks[winner] / (n_configs + 1) logits[k] = np.log(r / (1.0 - r)) slope, intercept = _ols(is_perf, oos_perf) return PBOResult( pbo=float(np.mean(logits <= 0.0)), logits=logits, prob_oos_loss=float(np.mean(oos_perf < 0.0)), slope=slope, intercept=intercept, is_perf=is_perf, oos_perf=oos_perf, )