API reference¶
All public symbols from purgedcv.__all__, auto-rendered from the source
docstrings. The constructors of the splitters share a single set of
keyword arguments (prediction_times, evaluation_times,
purge_horizon, embargo, groups); see
BaseTemporalSplitter for the shared
contract.
Splitters¶
purgedcv.BaseTemporalSplitter ¶
BaseTemporalSplitter(*, prediction_times: Series, evaluation_times: Series, purge_horizon: HorizonLike | None = None, embargo: HorizonLike | None = None, groups: Series | None = None)
Bases: ABC
Duck-typed sklearn CV splitter with purge + embargo orchestration.
Concrete subclasses implement :meth:_iter_test_indices to yield the
raw test-index arrays for each fold. The base class handles purge,
embargo, optional group-disjointness, and the sklearn-compatible
:meth:split / :meth:get_n_splits protocol.
Times are bound to the splitter at construction. This couples the splitter to a specific dataset's timestamps, which is intentional: a splitter for one dataset is rarely meaningful for another.
.. note::
The subclassing interface (_iter_test_indices,
_candidate_train_idx) is not yet covered by the v0.3 stability
contract. Subclasses may need adjustments through v1.0.
get_n_splits
abstractmethod
¶
Return the total number of splits the iterator will yield.
split ¶
split(X: NDArrayAny | DataFrame, y: object = None, groups: object = None) -> Iterator[tuple[NDArrayAny, NDArrayAny]]
Yield (train_idx, test_idx) pairs for each fold.
The y and groups parameters of this method are accepted for
sklearn protocol compatibility but ignored — group information must
be bound at construction via the groups argument of __init__.
When groups were bound at construction,
:func:~purgedcv.diagnostics.assert_groups_disjoint is called on
every fold after purge and embargo; a
:class:~purgedcv.exceptions.GroupLeakageError is raised if any
group identifier appears in both train and test of the same fold.
with_times ¶
Return a copy of this splitter with new times bound. All other
parameters (n_splits, purge_horizon, embargo, groups,
and any subclass-specific state such as a cached unique-group list)
are preserved unchanged.
To change groups or any other construction parameter, build a
fresh splitter via the constructor — this avoids surprising
interactions between cached state and rebound inputs.
purgedcv.WalkForwardSplit ¶
WalkForwardSplit(n_splits: int, test_size: int, *, train_size: int | None = None, window: WindowMode = 'expanding', prediction_times: Series, evaluation_times: Series, purge_horizon: HorizonLike | None = None, embargo: HorizonLike | None = None)
Bases: BaseTemporalSplitter
Walk-forward CV with sliding or expanding training window.
For window="expanding" and zero purge/embargo, this matches
:class:sklearn.model_selection.TimeSeriesSplit. For
window="sliding", the training window has a fixed maximum length
(the most recent train_size samples before the test fold).
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> from purgedcv import WalkForwardSplit
>>> pred = pd.Series(pd.date_range("2024-01-01", periods=10, freq="D"))
>>> evalu = pred + pd.Timedelta(days=1)
>>> cv = WalkForwardSplit(
... n_splits=3, test_size=2,
... prediction_times=pred, evaluation_times=evalu,
... )
>>> cv.get_n_splits()
3
Configure a walk-forward CV splitter.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n_splits
|
int
|
Number of folds to yield. Test folds tile the END of the dataset; each fold trains only on data strictly before its test indices. |
required |
test_size
|
int
|
Number of consecutive rows in each test fold.
|
required |
train_size
|
int | None
|
Maximum number of training rows per fold AFTER
purge and embargo. Counts kept rows, not raw indices, so
with a 2-day embargo and |
None
|
window
|
WindowMode
|
|
'expanding'
|
prediction_times
|
Series
|
Per-sample prediction times for the dataset.
Bound at construction so :meth: |
required |
evaluation_times
|
Series
|
Per-sample evaluation times. Required to apply purge and embargo correctly. |
required |
purge_horizon
|
HorizonLike | None
|
Symmetric padding around the test fold's label
window; training rows whose label horizon overlaps the
padded test horizon are dropped. |
None
|
embargo
|
HorizonLike | None
|
Post-test embargo duration; training rows whose
prediction time falls in the closed window
|
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
split ¶
split(X: NDArrayAny | DataFrame, y: object = None, groups: object = None) -> Iterator[tuple[NDArrayAny, NDArrayAny]]
Yield (train_idx, test_idx) pairs for each walk-forward fold.
The base class handles X length validation, purge, and embargo;
this override additionally trims each training set to the most
recent train_size rows when window="sliding". Training
indices are always strictly less than the minimum index of the
test fold (the defining walk-forward property).
The number of pairs yielded equals self.n_splits. The y
and groups arguments are accepted for sklearn protocol
compatibility but ignored.
purgedcv.PurgedKFold ¶
PurgedKFold(n_splits: int, *, prediction_times: Series, evaluation_times: Series, purge_horizon: HorizonLike | None = None, embargo: HorizonLike | None = None)
Bases: BaseTemporalSplitter
K-fold CV with contiguous test folds and purge + embargo applied.
Test folds tile the index space contiguously: fold k holds rows
[start_k, start_k + size_k) where size_k differs across folds
by at most one due to integer division. Train = complement of the
test fold, with D2 purge and D3 embargo applied by the base class.
For zero purge_horizon and embargo the test folds are
identical to :class:sklearn.model_selection.KFold(shuffle=False).
Purge still drops any training row whose own label horizon
[prediction_time, evaluation_time) overlaps the test labels, so
the training set equals the full complement only when no two label
horizons overlap (for instance when evaluation_times equals
prediction_times). The splitter then degrades exactly to
KFold(shuffle=False).
The first n_samples % n_splits folds receive
n_samples // n_splits + 1 rows; the remaining folds receive
n_samples // n_splits. When n_splits > n_samples, the
excess folds are empty — the base class purge and embargo handle
empty arrays gracefully.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> from purgedcv import PurgedKFold
>>> pred = pd.Series(pd.date_range("2024-01-01", periods=20, freq="D"))
>>> evalu = pred + pd.Timedelta(days=1)
>>> cv = PurgedKFold(
... n_splits=5,
... prediction_times=pred, evaluation_times=evalu,
... )
>>> cv.get_n_splits()
5
Configure a purged k-fold splitter.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n_splits
|
int
|
Number of folds. Must be at least 2. |
required |
prediction_times
|
Series
|
Per-sample prediction times. Bound at
construction so :meth: |
required |
evaluation_times
|
Series
|
Per-sample evaluation times. |
required |
purge_horizon
|
HorizonLike | None
|
Symmetric padding around the test fold's label
window; training rows whose label horizon overlaps the
padded test horizon are dropped. |
None
|
embargo
|
HorizonLike | None
|
Post-test embargo duration; training rows whose
prediction time falls in any closed window
|
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
purgedcv.PurgedGroupKFold ¶
PurgedGroupKFold(n_splits: int, *, prediction_times: Series, evaluation_times: Series, groups: Series, purge_horizon: HorizonLike | None = None, embargo: HorizonLike | None = None)
Bases: BaseTemporalSplitter
Group-aware k-fold variant of :class:PurgedKFold.
Each test fold consists of all rows from a contiguous block of unique
group identifiers, so no group_id ever appears in both train and
test of the same fold. Purge and embargo still apply ACROSS group
boundaries — training rows from other groups whose horizons overlap
the test window are dropped by D2/D3 in the base class.
Groups are assigned to fold blocks in first-appearance order within
the groups Series (the order returned by pd.Series.unique()).
For temporal coherence — patients enrolled in chronological order,
assets in time-of-IPO order — ensure the groups Series is sorted
by first occurrence time.
The base class's :func:assert_groups_disjoint enforcement runs on
every fold automatically because groups is bound at construction.
Useful in clinical ML (no patient appears in both sides of a fold), asset CV (no symbol appears in both sides), and any setting where grouped observations must not leak across the train/test boundary.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> from purgedcv import PurgedGroupKFold
>>> pred = pd.Series(pd.date_range("2024-01-01", periods=12, freq="D"))
>>> evalu = pred + pd.Timedelta(days=1)
>>> groups = pd.Series(np.repeat([0, 1, 2, 3], 3))
>>> cv = PurgedGroupKFold(
... n_splits=2,
... prediction_times=pred, evaluation_times=evalu,
... groups=groups,
... )
>>> cv.get_n_splits()
2
Configure a group-aware purged k-fold splitter.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n_splits
|
int
|
Number of folds. Must be at least 2 and must not exceed the number of unique group identifiers. |
required |
prediction_times
|
Series
|
Per-sample prediction times. |
required |
evaluation_times
|
Series
|
Per-sample evaluation times. |
required |
groups
|
Series
|
Group identifier per sample. Must have the same
length as |
required |
purge_horizon
|
HorizonLike | None
|
Symmetric padding around the test fold's
label window; cross-group training rows whose label
horizon overlaps the padded test horizon are dropped.
|
None
|
embargo
|
HorizonLike | None
|
Post-test embargo duration. |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
purgedcv.CombinatorialPurgedCV ¶
CombinatorialPurgedCV(n_splits: int, n_test_groups: int, *, prediction_times: Series, evaluation_times: Series, purge_horizon: HorizonLike | None = None, embargo: HorizonLike | None = None)
Bases: BaseTemporalSplitter
Combinatorial Purged Cross-Validation (fold enumeration).
Partitions the time-ordered samples into n_splits contiguous group
blocks. For each combination of n_test_groups chosen from those
blocks, yields one fold whose test indices are the union of the
chosen blocks. Total folds: C(n_splits, n_test_groups).
Each group block appears as test in exactly C(n_splits - 1,
n_test_groups - 1) folds.
The base class applies D2 purge and D3 embargo to each fold's train
set. :meth:backtest_paths then assembles the C(N,K) folds into
n_paths time-ordered out-of-sample sequences.
See Advances in Financial Machine Learning (Lopez de Prado, Wiley 2018), chapter 12 section 12.4, for the original method.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> from purgedcv import CombinatorialPurgedCV
>>> pred = pd.Series(pd.date_range("2024-01-01", periods=24, freq="D"))
>>> evalu = pred + pd.Timedelta(days=1)
>>> cv = CombinatorialPurgedCV(
... n_splits=6, n_test_groups=2,
... prediction_times=pred, evaluation_times=evalu,
... )
>>> cv.get_n_splits()
15
Configure a Combinatorial Purged CV splitter.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n_splits
|
int
|
Number of contiguous group blocks to partition the samples into. Must be at least 2. |
required |
n_test_groups
|
int
|
Number of group blocks chosen as the test
set in each fold. Must be in |
required |
prediction_times
|
Series
|
Per-sample prediction times. |
required |
evaluation_times
|
Series
|
Per-sample evaluation times. |
required |
purge_horizon
|
HorizonLike | None
|
Symmetric padding around the test fold's
label window; training rows whose label horizon overlaps
the padded test horizon are dropped. |
None
|
embargo
|
HorizonLike | None
|
Post-test embargo duration; training rows whose
prediction time falls in any closed window
|
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
backtest_paths ¶
Fit estimator on each fold and reconstruct the C(N-1, K-1)
out-of-sample backtest paths.
For each of the C(N, K) folds:
- Clone the estimator (so per-fold fits do not contaminate each other or the original).
- Fit on the fold's training set (after purge + embargo).
- Predict on the fold's test set.
- If the fold has no training rows under an unusually aggressive purge/embargo configuration, the predictions for that fold are NaN.
The per-fold predictions are then handed to :func:reconstruct_paths,
which assembles them into an (n_paths, n_samples) matrix where
each row is a complete time-ordered out-of-sample prediction
sequence.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
estimator
|
object
|
A scikit-learn estimator with |
required |
X
|
NDArrayAny | DataFrame
|
Feature matrix of shape |
required |
y
|
NDArrayAny | Series
|
Target vector of shape |
required |
Returns:
| Type | Description |
|---|---|
NDArrayAny
|
|
NDArrayAny
|
with |
NDArrayAny
|
Affected rows contain NaN when an upstream fold could not be |
NDArrayAny
|
fit. |
Raises:
| Type | Description |
|---|---|
AttributeError or TypeError
|
if |
Examples:
>>> import warnings
>>> import numpy as np
>>> import pandas as pd
>>> from sklearn.dummy import DummyRegressor
>>> from sklearn.exceptions import FitFailedWarning
>>> from purgedcv import CombinatorialPurgedCV
>>> pred = pd.Series(pd.date_range("2024-01-01", periods=16, freq="D"))
>>> evalu = pred + pd.Timedelta(days=1)
>>> cv = CombinatorialPurgedCV(
... n_splits=4, n_test_groups=2,
... prediction_times=pred, evaluation_times=evalu,
... )
>>> X = np.arange(16).reshape(-1, 1).astype(float)
>>> y = np.arange(16).astype(float)
>>> with warnings.catch_warnings():
... warnings.simplefilter("ignore", FitFailedWarning)
... paths = cv.backtest_paths(DummyRegressor(strategy="mean"), X, y)
>>> paths.shape
(3, 16)
reconstruct_paths ¶
Assemble per-fold predictions into the C(N-1, K-1) backtest paths.
Ergonomic wrapper around :func:~purgedcv.reconstruct_paths. The
splitter already knows n_splits, n_test_groups, the fold
test-index layout, and n_samples (from the bound times), so the
caller supplies only one prediction array per fold, in
:meth:split order. Use this when you ran the fits yourself (for
example a per-fold backtest loop) rather than via
:meth:backtest_paths.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fold_predictions
|
Sequence[NDArrayAny]
|
One array per fold, in the same order as
:meth: |
required |
Returns:
| Type | Description |
|---|---|
NDArrayAny
|
|
NDArrayAny
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
on a fold-count or fold-prediction length mismatch
(propagated from :func: |
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> from purgedcv import CombinatorialPurgedCV
>>> pred = pd.Series(pd.date_range("2024-01-01", periods=16, freq="D"))
>>> evalu = pred + pd.Timedelta(days=1)
>>> cv = CombinatorialPurgedCV(
... n_splits=4, n_test_groups=2,
... prediction_times=pred, evaluation_times=evalu,
... )
>>> X = np.zeros((16, 1))
>>> fold_preds = [
... np.full(len(test), float(i))
... for i, (_, test) in enumerate(cv.split(X))
... ]
>>> cv.reconstruct_paths(fold_preds).shape
(3, 16)
purgedcv.CombinatoriallySymmetricCV ¶
CombinatoriallySymmetricCV(n_splits: int, *, prediction_times: Series, evaluation_times: Series, purge_horizon: HorizonLike | None = None, embargo: HorizonLike | None = None)
Bases: CombinatorialPurgedCV
Combinatorially Symmetric Cross-Validation (CSCV).
The special case of :class:CombinatorialPurgedCV with
n_test_groups = n_splits // 2: every fold cuts the timeline into two
equal halves, one in-sample and one out-of-sample. CSCV is the substrate
of :func:~purgedcv.probability_of_backtest_overfitting; expose it
directly when you want the symmetric IS/OOS folds without going through
the PBO estimator.
See Advances in Financial Machine Learning (Lopez de Prado, Wiley 2018), chapter 11.
Examples:
>>> import pandas as pd
>>> from purgedcv import CombinatoriallySymmetricCV
>>> pred = pd.Series(pd.date_range("2024-01-01", periods=24, freq="D"))
>>> evalu = pred + pd.Timedelta(days=1)
>>> cv = CombinatoriallySymmetricCV(
... n_splits=6, prediction_times=pred, evaluation_times=evalu,
... )
>>> cv.get_n_splits()
20
Configure a CSCV splitter.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n_splits
|
int
|
Number of contiguous group blocks. Must be even and at least 2; half are chosen as the test (out-of-sample) groups in each fold. |
required |
prediction_times
|
Series
|
Per-sample prediction times. |
required |
evaluation_times
|
Series
|
Per-sample evaluation times. |
required |
purge_horizon
|
HorizonLike | None
|
Optional purge horizon applied per fold. |
None
|
embargo
|
HorizonLike | None
|
Optional embargo horizon applied per fold. |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
Backtest paths¶
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
:class: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; |
required |
fold_test_indices
|
Sequence[NDArrayAny]
|
One array per fold; the per-fold test_idx as
yielded by :meth: |
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
|
|
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
|
|
required |
metric_fn
|
PathMetricFn | None
|
Maps a 1-D path to a name -> value mapping. Defaults to
:func: |
None
|
bars_per_year
|
int | None
|
Forwarded to :func: |
None
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
DataFrame indexed |
DataFrame
|
one column per metric. With the default |
DataFrame
|
|
DataFrame
|
drawdown), so the worst path is |
DataFrame
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
Examples:
purgedcv.default_backtest_metrics ¶
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
|
None
|
Returns:
| Type | Description |
|---|---|
dict[str, float]
|
Dict with |
dict[str, float]
|
|
dict[str, float]
|
signed value: |
dict[str, float]
|
|
dict[str, float]
|
|
Examples:
Row-level primitives¶
purgedcv.purge ¶
purge(train_idx: NDArrayAny, test_idx: NDArrayAny, prediction_times: Series, evaluation_times: Series, purge_horizon: Timedelta | None = None) -> NDArrayAny
Drop training rows whose half-open label horizon overlaps any test horizon.
Each test row contributes a half-open horizon
[prediction_time - purge_horizon, evaluation_time + purge_horizon).
The intervals are merged before filtering, so disjoint test blocks purge
only their local overlap zones rather than the full convex hull between
them.
A training row at position i is kept iff
its label horizon overlaps none of those merged test horizons.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
train_idx
|
NDArrayAny
|
positional indices of training rows. |
required |
test_idx
|
NDArrayAny
|
positional indices of test rows. |
required |
prediction_times
|
Series
|
prediction times for all rows. |
required |
evaluation_times
|
Series
|
evaluation times for all rows. |
required |
purge_horizon
|
Timedelta | None
|
optional symmetric padding ( |
None
|
Returns:
| Type | Description |
|---|---|
NDArrayAny
|
The subset of |
NDArrayAny
|
window. Input ordering and dtype are preserved. |
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> from purgedcv import purge
>>> pred = pd.Series(pd.date_range("2024-01-01", periods=10, freq="D"))
>>> evalu = pred + pd.Timedelta(days=3)
>>> train_idx = np.array([0, 1, 2, 3, 4, 8, 9])
>>> test_idx = np.array([5, 6, 7])
>>> purge(train_idx, test_idx, pred, evalu)
array([0, 1, 2])
purgedcv.apply_embargo ¶
apply_embargo(train_idx: NDArrayAny, test_idx: NDArrayAny, prediction_times: Series, evaluation_times: Series, embargo: Timedelta) -> NDArrayAny
Drop training rows whose prediction_time falls inside any closed
embargo window [test_evaluation_time, test_evaluation_time + embargo].
Embargo is asymmetric: rows whose prediction_time is strictly before
all test evaluation times are never dropped. embargo == 0 is the
identity (the embargo window is logically empty at zero width), avoiding
degenerate single-point windows.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
train_idx
|
NDArrayAny
|
positional indices of training rows. |
required |
test_idx
|
NDArrayAny
|
positional indices of test rows. |
required |
prediction_times
|
Series
|
prediction times for all rows. |
required |
evaluation_times
|
Series
|
evaluation times for all rows. |
required |
embargo
|
Timedelta
|
post-test embargo duration. |
required |
Returns:
| Type | Description |
|---|---|
NDArrayAny
|
The subset of |
NDArrayAny
|
post-test embargo window. Input ordering and dtype are preserved. |
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> from purgedcv import apply_embargo
>>> pred = pd.Series(pd.date_range("2024-01-01", periods=20, freq="D"))
>>> evalu = pred + pd.Timedelta(days=1)
>>> train_idx = np.array([11, 12, 13, 14])
>>> test_idx = np.arange(5, 10)
>>> apply_embargo(train_idx, test_idx, pred, evalu, pd.Timedelta(days=1))
array([12, 13, 14])
Time and horizon utilities¶
purgedcv.parse_horizon ¶
Coerce a horizon-like input to a non-negative pd.Timedelta.
Accepts pandas offset strings ("2D", "6h", "30min"),
pd.Timedelta, datetime.timedelta, and numpy.timedelta64.
Rejects missing/NaT values, negative durations, and
calendar-ambiguous offsets such as "M" (month) or "Y" (year),
which do not represent a fixed duration in seconds.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value
|
HorizonLike
|
The horizon to parse. |
required |
Returns:
| Type | Description |
|---|---|
Timedelta
|
A non-negative |
Raises:
| Type | Description |
|---|---|
ValueError
|
if the input is missing/ |
TypeError
|
if the input is not one of the supported types. |
Examples:
purgedcv.horizons_overlap ¶
horizons_overlap(a_start: Timestamp, a_end: Timestamp, b_start: Timestamp, b_end: Timestamp) -> bool
Return True iff half-open intervals [a_start, a_end) and
[b_start, b_end) overlap.
Touching intervals (a_end == b_start) do NOT overlap. The function
is symmetric in its arguments.
Examples:
>>> import pandas as pd
>>> from purgedcv import horizons_overlap
>>> horizons_overlap(
... pd.Timestamp("2024-01-01"), pd.Timestamp("2024-01-03"),
... pd.Timestamp("2024-01-02"), pd.Timestamp("2024-01-04"),
... )
True
>>> horizons_overlap(
... pd.Timestamp("2024-01-01"), pd.Timestamp("2024-01-02"),
... pd.Timestamp("2024-01-02"), pd.Timestamp("2024-01-03"),
... )
False
purgedcv.validate_times ¶
validate_times(prediction_times: Series, evaluation_times: Series, *, require_monotonic: bool = True) -> None
Validate that prediction_times and evaluation_times are well-formed.
Raises:
| Type | Description |
|---|---|
ValueError
|
on length mismatch, non-temporal dtype, NaT values,
|
Examples:
Statistical metrics¶
purgedcv.probabilistic_sharpe_ratio ¶
Probability that the true Sharpe ratio exceeds benchmark_skill.
Formula (Bailey & Lopez de Prado 2012, Eq. 7):
where \(\widehat{\gamma}_3\) is sample skew, \(\widehat{\gamma}_4\) is sample kurtosis (NOT excess kurtosis), and \(\Phi\) is the standard normal CDF.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
returns
|
NDArrayAny
|
1-D array of returns, length >= 2, finite values, non-zero variance. |
required |
benchmark_skill
|
float
|
The Sharpe-ratio threshold to test against. Use 0 for "is this strategy better than holding cash." |
required |
Returns:
| Type | Description |
|---|---|
float
|
Scalar probability in [0, 1]. |
Raises:
| Type | Description |
|---|---|
ValueError
|
on length < 2, non-finite values, or zero variance. |
Examples:
purgedcv.deflated_sharpe_ratio ¶
deflated_sharpe_ratio(returns: NDArrayAny, n_trials: int, var_sharpe: float, *, bars_per_year: int | None = None) -> float
Probability that the true Sharpe ratio exceeds the deflated
benchmark that accounts for n_trials independent hyperparameter
searches under the null.
Formula (Bailey & Lopez de Prado 2014):
where \(\gamma \approx 0.5772\) is the Euler-Mascheroni constant.
DSR is then :func:probabilistic_sharpe_ratio evaluated at the
deflated benchmark \(\text{SR}^\ast_n\).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
returns
|
NDArrayAny
|
1-D array of returns (passed through to PSR). |
required |
n_trials
|
int
|
Number of independent hyperparameter searches the user
ran before reporting this strategy's Sharpe. Must be >= 1.
With |
required |
var_sharpe
|
float
|
Estimated variance of Sharpe ratios across the
UNITS: |
required |
bars_per_year
|
int | None
|
If given, |
None
|
Returns:
| Type | Description |
|---|---|
float
|
Scalar probability in [0, 1]. |
Raises:
| Type | Description |
|---|---|
TypeError
|
if |
ValueError
|
on invalid |
Examples:
>>> import numpy as np
>>> from purgedcv import deflated_sharpe_ratio
>>> rng = np.random.default_rng(0)
>>> returns = rng.normal(0.001, 0.01, 252)
>>> dsr = deflated_sharpe_ratio(returns, n_trials=50, var_sharpe=0.01**2)
>>> 0.0 <= dsr <= 1.0
True
>>> # Annualised trial-Sharpe variance: pass bars_per_year to convert.
>>> annual = deflated_sharpe_ratio(returns, 50, 0.5**2, bars_per_year=252)
>>> per_bar = deflated_sharpe_ratio(returns, 50, 0.5**2 / 252)
>>> abs(annual - per_bar) < 1e-12
True
purgedcv.deflated_sharpe_ratio_full ¶
deflated_sharpe_ratio_full(returns: NDArrayAny, n_trials: int, var_sharpe: float, *, bars_per_year: int | None = None) -> DSRDiagnostics
Like :func:deflated_sharpe_ratio but return the intermediate
quantities alongside the probability.
When DSR is near 0 the scalar form does not tell you why: was the
deflated benchmark high because var_sharpe was large, because
n_trials was large, or was the observed Sharpe simply low? This
function returns all of those so the deflation can be inspected.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
returns
|
NDArrayAny
|
1-D array of returns. |
required |
n_trials
|
int
|
Number of independent searches (>= 1). |
required |
var_sharpe
|
float
|
Variance of Sharpe ratios across the candidates. Per
observation by default; pass |
required |
bars_per_year
|
int | None
|
If given, |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
DSRDiagnostics
|
class: |
DSRDiagnostics
|
attribute, e.g. |
|
DSRDiagnostics
|
func: |
|
DSRDiagnostics
|
|
|
DSRDiagnostics
|
(after any |
|
DSRDiagnostics
|
|
Raises:
| Type | Description |
|---|---|
TypeError
|
if |
ValueError
|
on invalid |
Examples:
>>> import numpy as np
>>> from purgedcv import deflated_sharpe_ratio, deflated_sharpe_ratio_full
>>> rng = np.random.default_rng(0)
>>> returns = rng.normal(0.001, 0.01, 252)
>>> diag = deflated_sharpe_ratio_full(returns, n_trials=50, var_sharpe=0.01**2)
>>> abs(diag.dsr - deflated_sharpe_ratio(returns, 50, 0.01**2)) < 1e-12
True
>>> diag.n_obs
252
>>> diag.sr_star > 0
True
purgedcv.min_track_record_length ¶
min_track_record_length(observed_sharpe: float, target_sharpe: float, alpha: float, skew: float, kurtosis: float) -> float
Minimum sample size such that PSR(target_sharpe) >= 1 - alpha.
Inverts the :func:probabilistic_sharpe_ratio formula for n:
Bailey & Lopez de Prado (2012, Eq. 11).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
observed_sharpe
|
float
|
The sample Sharpe ratio you actually observed. |
required |
target_sharpe
|
float
|
The benchmark you want to beat with confidence. |
required |
alpha
|
float
|
Significance level in (0, 1). PSR must meet 1 - alpha. |
required |
skew
|
float
|
Sample skew of the return distribution. |
required |
kurtosis
|
float
|
Sample kurtosis (NOT excess) of the return distribution. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
float
|
The minimum number of observations, as a |
|
float
|
|
|
float
|
establish the gap, so the answer is |
|
error |
float
|
"no length is long enough" is a well-defined result. Wrap |
float
|
in |
Raises:
| Type | Description |
|---|---|
ValueError
|
if any scalar input is non-finite, if
|
Examples:
>>> import math
>>> from purgedcv import min_track_record_length
>>> n = min_track_record_length(
... observed_sharpe=0.5, target_sharpe=0.2,
... alpha=0.05, skew=0.0, kurtosis=3.0,
... )
>>> n > 0
True
>>> # No track record proves a Sharpe you have not actually beaten:
>>> math.isinf(min_track_record_length(0.1, 0.2, 0.05, 0.0, 3.0))
True
purgedcv.minimum_backtest_length ¶
Minimum backtest length, in years, below which a high in-sample Sharpe
is not evidence of skill once n_trials configurations were tried.
Run n_trials independent backtests on a strategy with no real edge
(true Sharpe 0) and the best of them shows a positive Sharpe purely by
chance. The shorter the backtest, the larger that chance maximum. This
returns the length at which the expected maximum annualised Sharpe across
n_trials trials under the null equals target_sharpe: a reported
annualised Sharpe of target_sharpe over a shorter history is within what
selection alone would produce, so it is not yet evidence of skill.
Inverts the Bailey-Lopez de Prado expected-maximum approximation for the
length y in years:
where \(\gamma\) is the Euler-Mascheroni constant and \(\text{SR}^\ast\) is
target_sharpe. The numerator is the same bracket term that deflates the
Sharpe in :func:deflated_sharpe_ratio; MinBTL is its inverse, solving for
length instead of for a probability.
Bailey, Borwein, Lopez de Prado & Zhu (2014), "Pseudo-mathematics and financial charlatanism", Notices of the AMS 61(5). AFML Chapter 11.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n_trials
|
int
|
Number of independent configurations tried. Integer >= 1. |
required |
target_sharpe
|
float
|
The annualised Sharpe threshold to clear. Must be a positive finite number. Defaults to 1.0. |
1.0
|
Returns:
| Type | Description |
|---|---|
float
|
The minimum backtest length in years, as a |
float
|
the number of observations per year for a length in observations. A |
float
|
single trial ( |
float
|
is |
Raises:
| Type | Description |
|---|---|
TypeError
|
if |
ValueError
|
if |
Examples:
>>> from purgedcv import minimum_backtest_length
>>> # Trying 10 configs, an annualised Sharpe of 1 is not evidence of
>>> # skill until about 2.5 years of backtest:
>>> round(minimum_backtest_length(10, target_sharpe=1.0), 1)
2.5
>>> # More trials demand a longer backtest for the same Sharpe:
>>> minimum_backtest_length(100) > minimum_backtest_length(10)
True
>>> minimum_backtest_length(1) # a single trial: no selection
0.0
purgedcv.effective_n_trials ¶
Estimate the number of independent trials behind a correlated search.
:func:deflated_sharpe_ratio assumes the n_trials candidates were
drawn independently. Sequential samplers (Optuna's TPE, CMA-ES) draw each
trial conditioned on the previous ones, so the trials are autocorrelated
and the raw count overstates how many independent bets were really placed.
Feeding the raw count inflates the deflated benchmark and makes DSR
needlessly conservative (often numerically zero). This returns a smaller
effective count to pass to :func:deflated_sharpe_ratio instead.
The estimate is the run length divided by the integrated autocorrelation
time of the trial-performance series:
n_eff = n / (1 + 2 * sum_k rho_k), summing the autocorrelations
rho_k until the first non-positive lag (the initial-positive-sequence
truncation, Geyer 1992). It is a heuristic: it depends on the
truncation rule and assumes the trial order reflects the sampler's
dependence, so treat the result as an order-of-magnitude correction, not
an exact figure.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
trial_sharpes
|
NDArrayAny
|
1-D array of per-trial performance values (Sharpe or objective), in the order the trials were run. All finite. |
required |
method
|
str
|
Estimator to use. Only |
'autocorr'
|
Returns:
| Type | Description |
|---|---|
int
|
Effective trial count, an integer in |
int
|
series returns close to |
int
|
far fewer. A constant series returns 1 (every trial was the same). |
int
|
Fewer than 3 trials returns the raw count (too few to estimate). |
Raises:
| Type | Description |
|---|---|
ValueError
|
on an unknown |
Examples:
>>> import numpy as np
>>> from purgedcv import effective_n_trials
>>> rng = np.random.default_rng(0)
>>> indep = rng.standard_normal(400) # independent trials
>>> drift = np.cumsum(rng.standard_normal(400)) # strongly autocorrelated
>>> n_indep = effective_n_trials(indep)
>>> n_drift = effective_n_trials(drift)
>>> 1 <= n_drift < n_indep <= 400
True
Backtest overfitting¶
purgedcv.probability_of_backtest_overfitting ¶
probability_of_backtest_overfitting(returns: NDArrayAny, n_splits: int = 16, *, metric: PerformanceMetric = sharpe, prediction_times: Series | None = None, evaluation_times: Series | None = None, purge_horizon: HorizonLike | None = None, embargo: HorizonLike | None = None) -> PBOResult
Estimate the Probability of Backtest Overfitting (PBO) via CSCV.
Given the per-period returns of n_configs candidate configurations,
PBO is the probability that the configuration selected as best
in-sample performs below the median out-of-sample. The estimate is the
relative frequency of that event across all CSCV combinations.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
returns
|
NDArrayAny
|
|
required |
n_splits
|
int
|
Number of contiguous blocks the time axis is cut into
( |
16
|
metric
|
PerformanceMetric
|
Performance metric mapping a 1-D return slice to a scalar
(larger is better). Defaults to :func: |
sharpe
|
prediction_times
|
Series | None
|
Optional per-period prediction times. Supply with
|
None
|
evaluation_times
|
Series | None
|
Optional per-period evaluation times; see
|
None
|
purge_horizon
|
HorizonLike | None
|
Optional purge horizon (requires the time series). |
None
|
embargo
|
HorizonLike | None
|
Optional embargo horizon (requires the time series). |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
PBOResult
|
class: |
PBOResult
|
e.g. |
Raises:
| Type | Description |
|---|---|
ValueError
|
on a malformed |
Examples:
>>> import numpy as np
>>> from purgedcv import probability_of_backtest_overfitting
>>> rng = np.random.default_rng(0)
>>> returns = rng.standard_normal((10, 240)) # 10 configs, pure noise
>>> result = probability_of_backtest_overfitting(returns, n_splits=8)
>>> 0.0 <= result.pbo <= 1.0
True
>>> result.n_combos
70
Optuna integration¶
purgedcv.optuna_integration.TrialSharpeRecorder ¶
Optuna study callback that collects per-trial Sharpe ratios.
Pass an instance as a callback to study.optimize and store each
trial's Sharpe in a user attribute (default key "sharpe"); the
recorder accumulates them and reports the variance for
:func:~purgedcv.deflated_sharpe_ratio. If a trial has no such user
attribute, the recorder falls back to that trial's objective value.
Non-finite and missing values are ignored.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
attr
|
str
|
The |
'sharpe'
|
Examples:
>>> from types import SimpleNamespace
>>> from purgedcv.optuna_integration import TrialSharpeRecorder
>>> recorder = TrialSharpeRecorder()
>>> # Optuna calls the recorder with (study, frozen_trial) per trial;
>>> # SimpleNamespace stands in for the trial here.
>>> for s in (1.2, 0.8, 1.5):
... recorder(study=None, trial=SimpleNamespace(value=s, user_attrs={"sharpe": s}))
>>> recorder.n_trials()
3
>>> round(recorder.var_sharpe(), 4)
0.1233
Usage with a real study::
recorder = TrialSharpeRecorder()
study.optimize(objective, n_trials=300, callbacks=[recorder])
dsr = deflated_sharpe_ratio(
best_returns, n_trials=recorder.n_trials(), var_sharpe=recorder.var_sharpe()
)
__call__ ¶
Record one trial's Sharpe ratio. Matches the Optuna callback signature.
Missing, None, non-numeric, and non-finite values are skipped
rather than raised: a callback that throws would abort the whole
study.optimize run over a single malformed trial.
n_effective ¶
Effective number of independent trials, for correlated samplers.
TPE and CMA-ES draw each trial conditioned on the previous ones, so
the raw trial count overstates the independent search effort and makes
:func:~purgedcv.deflated_sharpe_ratio overly conservative. This
applies :func:~purgedcv.effective_n_trials to the recorded Sharpe
series; pass the result as n_trials to deflate more realistically.
Returns 0 when nothing has been recorded yet. See
:func:~purgedcv.effective_n_trials for the heuristic's caveats.
var_sharpe ¶
Variance of the recorded Sharpe ratios, for deflated_sharpe_ratio.
Returns nan until more than ddof trials have been recorded.
UNITS: the variance is in the units of whatever Sharpe you stored
per trial. :func:~purgedcv.deflated_sharpe_ratio compares against
the per-period Sharpe of its returns argument, so record a
per-period Sharpe in each trial (the same convention as the
returns you will later deflate). Do that and the recorder feeds
deflated_sharpe_ratio directly, with no unit conversion.
Diagnostics¶
purgedcv.diagnostics.compute_overlap_fraction ¶
compute_overlap_fraction(train_idx: NDArrayAny, test_idx: NDArrayAny, prediction_times: Series, evaluation_times: Series) -> float
Return the fraction of training rows whose half-open label horizon overlaps any test horizon.
Unlike :func:assert_no_temporal_leakage, this never raises — it
returns 0.0 for clean splits and 1.0 when every training row
leaks. Useful for logging splitter health metrics or for debugging
a splitter that produces unexpected behavior.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> from purgedcv.diagnostics import compute_overlap_fraction
>>> pred = pd.Series(pd.date_range("2024-01-01", periods=20, freq="D"))
>>> evalu = pred + pd.Timedelta(days=1)
>>> compute_overlap_fraction(np.arange(5), np.arange(10, 15), pred, evalu)
0.0
>>> compute_overlap_fraction(
... np.arange(10, 15), np.arange(10, 15), pred, evalu
... )
1.0
purgedcv.diagnostics.assert_no_temporal_leakage ¶
assert_no_temporal_leakage(train_idx: NDArrayAny, test_idx: NDArrayAny, prediction_times: Series, evaluation_times: Series, *, purge_horizon: HorizonLike | None = None) -> None
Raise :class:TemporalLeakageError if any training row's label horizon
overlaps any test label horizon, optionally padded on both sides by
purge_horizon.
Test horizons are checked as a union of half-open intervals, not as one convex hull. This matters for CPCV folds whose test groups are intentionally non-contiguous.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
train_idx
|
NDArrayAny
|
positional indices of training rows. |
required |
test_idx
|
NDArrayAny
|
positional indices of test rows. |
required |
prediction_times
|
Series
|
prediction times for all rows. |
required |
evaluation_times
|
Series
|
evaluation times for all rows. |
required |
purge_horizon
|
HorizonLike | None
|
optional padding (default: |
None
|
Raises:
| Type | Description |
|---|---|
TemporalLeakageError
|
with the offending training row index and the two overlapping intervals in the message. |
Examples:
purgedcv.diagnostics.assert_groups_disjoint ¶
Raise :class:GroupLeakageError if any group identifier appears in
both train_idx and test_idx.
Used by group-aware splitters to verify that no entity (patient, asset, user, etc.) is represented in both training and test of the same fold. The error message names a representative overlapping group plus the total count of overlapping groups, so the caller can scope follow-up.
Examples:
purgedcv.diagnostics.assert_embargo_respected ¶
assert_embargo_respected(train_idx: NDArrayAny, test_idx: NDArrayAny, prediction_times: Series, evaluation_times: Series, embargo: HorizonLike) -> None
Raise :class:EmbargoViolationError if any training row's
prediction_time falls inside any closed embargo window
[test_evaluation_time, test_evaluation_time + embargo].
Embargo is asymmetric: rows whose prediction_time is strictly before
all test evaluation times are never flagged. embargo == 0 is the
identity (no rows flagged) — the embargo window is logically empty at
zero width.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> from purgedcv.diagnostics import assert_embargo_respected
>>> pred = pd.Series(pd.date_range("2024-01-01", periods=20, freq="D"))
>>> evalu = pred + pd.Timedelta(days=1)
>>> assert_embargo_respected(
... np.array([18]), np.arange(5, 10), pred, evalu, embargo="2D"
... )
Exceptions¶
purgedcv.TemporalCVError ¶
Bases: ValueError
Base class for all purged-cross-validation errors.
purgedcv.TemporalLeakageError ¶
Bases: TemporalCVError
Raised when a training row's label horizon overlaps the test horizon.
purgedcv.EmbargoViolationError ¶
Bases: TemporalCVError
Raised when a training row falls inside the post-test embargo window.
purgedcv.GroupLeakageError ¶
Bases: TemporalCVError
Raised when a group_id appears in both training and test of the same fold.