Skip to content

Backtest paths

Reconstruct CPCV predictions and summarise return paths. Predictions are not returns. Define the strategy and its costs before applying return metrics. See splitters for CPCV and statistical metrics for Sharpe inference.

purgedcv.PathMetricFn module-attribute

PathMetricFn = Callable[[NDArrayAny], Mapping[str, float]]

A path metric: maps one path's 1-D return series to a name -> value mapping.

Pass this callback as metric_fn to path_metrics. Each mapping key becomes a column in the result. The default callback is default_backtest_metrics; supply your own to choose different statistics or a different return convention.

purgedcv.reconstruct_paths

reconstruct_paths(fold_predictions: Sequence[NDArrayAny], fold_test_indices: Sequence[NDArrayAny], n_splits: int, n_test_groups: int, n_samples: int) -> NDArrayAny

Assemble Combinatorial Purged CV fold predictions into backtest paths.

Given the predictions and test indices for all C(N, K) folds produced by CombinatorialPurgedCV, returns a (n_paths, n_samples) array where each row is a complete time-ordered out-of-sample prediction sequence built from a different combination of the folds.

n_paths = C(n_splits - 1, n_test_groups - 1). Each sample is predicted in every path (no missing entries). The assignment of fold to path for each group is the canonical greedy positional rule: for group g, the k-th fold (in fold-iteration order) that contains g as a test group contributes g's predictions to path k.

NaN predictions in any fold propagate only to the paths that use that fold for the affected group(s).

Parameters:

Name Type Description Default
fold_predictions Sequence[NDArrayAny]

One array per fold; fold_predictions[f] is the prediction values for the rows in fold_test_indices[f], in the same order.

required
fold_test_indices Sequence[NDArrayAny]

One array per fold; the per-fold test_idx as yielded by CombinatorialPurgedCV._iter_test_indices. Used here to recover the group block layout.

required
n_splits int

Number of CPCV group blocks.

required
n_test_groups int

Number of test groups per fold.

required
n_samples int

Total number of samples in the dataset.

required

Returns:

Type Description
NDArrayAny

(n_paths, n_samples) array of predictions.

Raises:

Type Description
ValueError

on fold count or fold-prediction length mismatch.

Examples:

>>> import numpy as np
>>> from purgedcv import reconstruct_paths
>>> # Synthetic 4-fold output: 4 samples per fold's test set,
>>> # predictions equal to the fold index.
>>> fold_test = [
...     np.array([0, 1, 2, 3, 4, 5, 6, 7]),     # combo (0, 1)
...     np.array([0, 1, 2, 3, 8, 9, 10, 11]),   # combo (0, 2)
...     np.array([0, 1, 2, 3, 12, 13, 14, 15]), # combo (0, 3)
...     np.array([4, 5, 6, 7, 8, 9, 10, 11]),   # combo (1, 2)
...     np.array([4, 5, 6, 7, 12, 13, 14, 15]), # combo (1, 3)
...     np.array([8, 9, 10, 11, 12, 13, 14, 15]), # combo (2, 3)
... ]
>>> fold_preds = [
...     np.full(len(ti), float(f)) for f, ti in enumerate(fold_test)
... ]
>>> paths = reconstruct_paths(fold_preds, fold_test, 4, 2, 16)
>>> paths.shape
(3, 16)

purgedcv.path_metrics

path_metrics(paths: NDArrayAny, metric_fn: PathMetricFn | None = None, *, bars_per_year: int | None = None) -> pd.DataFrame

Reduce an (n_paths, n_samples) matrix to per-path metrics.

Applies metric_fn to each row and stacks the results into a DataFrame with one row per path and one column per metric.

Parameters:

Name Type Description Default
paths NDArrayAny

(n_paths, n_samples) array, as returned by reconstruct_paths or CombinatorialPurgedCV.backtest_paths. Each row is one path's per-period return series.

required
metric_fn PathMetricFn | None

Maps a 1-D path to a name -> value mapping. Defaults to default_backtest_metrics. A custom function lets you choose your own statistics and return convention.

None
bars_per_year int | None

Forwarded to default_backtest_metrics for annualisation. Ignored when a custom metric_fn is given (bind any such option into the function yourself).

None

Returns:

Type Description
DataFrame

DataFrame indexed 0 .. n_paths - 1 (index name "path") with

DataFrame

one column per metric. With the default metric_fn the

DataFrame

max_drawdown column is a positive magnitude (0.30 = a 30%

DataFrame

drawdown), so the worst path is df["max_drawdown"].idxmax(), not

DataFrame

idxmin(); see default_backtest_metrics.

Raises:

Type Description
ValueError

if paths is not 2-D.

Examples:

>>> import numpy as np
>>> from purgedcv import path_metrics
>>> paths = np.array([[0.01, -0.02, 0.03, 0.0], [0.0, 0.01, 0.01, 0.01]])
>>> df = path_metrics(paths)
>>> list(df.columns)
['sharpe', 'calmar', 'max_drawdown', 'total_return']
>>> df.shape
(2, 4)

purgedcv.default_backtest_metrics

default_backtest_metrics(path: NDArrayAny, *, bars_per_year: int | None = None) -> dict[str, float]

Summarise one path of per-period returns.

Non-finite entries (for example NaN from a fold that could not be fit) are dropped before the statistics are computed. A path with fewer than two finite returns yields all-NaN metrics.

Returns are treated as additive (equity curve = cumulative sum), the convention for log returns. For simple returns supply a custom metric.

Parameters:

Name Type Description Default
path NDArrayAny

1-D per-period return series.

required
bars_per_year int | None

If given, the Sharpe ratio is annualised by sqrt(bars_per_year) and Calmar uses the annualised mean return. If None, Sharpe is per-bar and Calmar uses the total return.

None

Returns:

Type Description
dict[str, float]

Dict with sharpe, calmar, max_drawdown, and

dict[str, float]

total_return. max_drawdown is a positive magnitude, not a

dict[str, float]

signed value: 0.30 means a 30% peak-to-trough drawdown (not

dict[str, float]

-0.30). So the worst path is the one with the largest

dict[str, float]

max_drawdown; sort descending, not ascending.

Examples:

>>> import numpy as np
>>> from purgedcv import default_backtest_metrics
>>> m = default_backtest_metrics(np.array([0.01, -0.02, 0.03, 0.0]))
>>> round(m["total_return"], 4)
0.02
>>> round(m["max_drawdown"], 4)
0.02