CPCV/purge/embargo splitters + leakage audit
Browse files- crypto_eval/cv.py +127 -0
crypto_eval/cv.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Walk-forward / CPCV splitting with purge and embargo.
|
| 2 |
+
|
| 3 |
+
Anchored on López de Prado, Advances in Financial Machine Learning (2018),
|
| 4 |
+
ch. 7 and ch. 11-12, implemented on top of the `purgedcv` package (MIT,
|
| 5 |
+
pypi purgedcv==0.1.11).
|
| 6 |
+
|
| 7 |
+
Conventions used by this eval:
|
| 8 |
+
- labels carry a horizon h (forward return over h bars); training samples
|
| 9 |
+
whose label window [t, t+h] overlaps a test block are PURGED
|
| 10 |
+
- EMBARGO of e bars is additionally applied after each test block;
|
| 11 |
+
default e = max(label horizon, one funding interval in bars) = 8 bars at 1h
|
| 12 |
+
- CPCV: N blocks, K held out per combination -> C(N, K) test sets, which
|
| 13 |
+
recombine into backtest paths via purgedcv.reconstruct_paths.
|
| 14 |
+
Defaults N=6, K=2: 15 combinations, C(5,1)=5 paths.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
import pandas as pd
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
from purgedcv import (
|
| 24 |
+
CombinatorialPurgedCV,
|
| 25 |
+
reconstruct_paths,
|
| 26 |
+
)
|
| 27 |
+
except ImportError as e: # pragma: no cover
|
| 28 |
+
raise ImportError(
|
| 29 |
+
"purgedcv is required: pip install purgedcv==0.1.11"
|
| 30 |
+
) from e
|
| 31 |
+
|
| 32 |
+
__all__ = ["make_cpcv", "make_walkforward", "SplitAudit"]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class SplitAudit:
|
| 36 |
+
"""No-lookahead audit for a produced split."""
|
| 37 |
+
|
| 38 |
+
def __init__(self, n_samples: int, horizon: int, embargo: int):
|
| 39 |
+
self.n_samples = n_samples
|
| 40 |
+
self.horizon = horizon
|
| 41 |
+
self.embargo = embargo
|
| 42 |
+
self.violations: list[str] = []
|
| 43 |
+
|
| 44 |
+
def check(self, train_idx: np.ndarray, test_idx: np.ndarray, label: str) -> None:
|
| 45 |
+
if len(test_idx) == 0 or len(train_idx) == 0:
|
| 46 |
+
self.violations.append(f"{label}: empty fold")
|
| 47 |
+
return
|
| 48 |
+
# CPCV test sets are the UNION of K blocks: train samples between
|
| 49 |
+
# two test blocks are legitimate. Audit each contiguous test
|
| 50 |
+
# segment separately instead of the whole [min, max] range.
|
| 51 |
+
breaks = np.flatnonzero(np.diff(np.sort(test_idx)) > 1)
|
| 52 |
+
segments = np.split(np.sort(test_idx), breaks + 1)
|
| 53 |
+
total_bad = 0
|
| 54 |
+
for seg in segments:
|
| 55 |
+
t_min, t_max = int(seg.min()), int(seg.max())
|
| 56 |
+
# overlap iff i <= t_max+embargo and i+h >= t_min
|
| 57 |
+
leak_zone_end = t_max + self.embargo
|
| 58 |
+
bad = int(((train_idx <= leak_zone_end)
|
| 59 |
+
& (train_idx + self.horizon >= t_min)).sum())
|
| 60 |
+
total_bad += bad
|
| 61 |
+
if total_bad:
|
| 62 |
+
self.violations.append(
|
| 63 |
+
f"{label}: {total_bad} training samples overlap test window "
|
| 64 |
+
f"(leakage)"
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
@property
|
| 68 |
+
def ok(self) -> bool:
|
| 69 |
+
return not self.violations
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def make_cpcv(index: pd.DatetimeIndex, horizon: int, embargo: int,
|
| 73 |
+
freq: str = "1h", n_blocks: int = 6, k: int = 2):
|
| 74 |
+
"""Return the purgedcv CPCV splitter and audit it.
|
| 75 |
+
|
| 76 |
+
index: bar start times (UTC). horizon/embargo are in BARS.
|
| 77 |
+
purgedcv 0.1.11 requires prediction/evaluation times and takes
|
| 78 |
+
purge/embargo as timedeltas; it purges internally, SplitAudit is
|
| 79 |
+
kept as an independent leakage check.
|
| 80 |
+
"""
|
| 81 |
+
freq_td = pd.Timedelta(freq)
|
| 82 |
+
horizon_td = horizon * freq_td
|
| 83 |
+
embargo_td = embargo * freq_td
|
| 84 |
+
if embargo_td < horizon_td:
|
| 85 |
+
raise ValueError(
|
| 86 |
+
f"embargo ({embargo} bars) must be >= label horizon ({horizon} bars)"
|
| 87 |
+
)
|
| 88 |
+
splitter = CombinatorialPurgedCV(
|
| 89 |
+
n_splits=n_blocks,
|
| 90 |
+
n_test_groups=k,
|
| 91 |
+
prediction_times=index.values,
|
| 92 |
+
evaluation_times=(index + freq_td).values,
|
| 93 |
+
purge_horizon=horizon_td,
|
| 94 |
+
embargo=embargo_td,
|
| 95 |
+
)
|
| 96 |
+
n_samples = len(index)
|
| 97 |
+
audit = SplitAudit(n_samples, horizon, embargo)
|
| 98 |
+
combos = list(splitter.split(np.arange(n_samples)))
|
| 99 |
+
for j, (train_idx, test_idx) in enumerate(combos):
|
| 100 |
+
audit.check(np.asarray(train_idx), np.asarray(test_idx), f"cpcv-combo-{j}")
|
| 101 |
+
if not audit.ok:
|
| 102 |
+
raise RuntimeError(
|
| 103 |
+
"no-lookahead audit failed:\n" + "\n".join(audit.violations)
|
| 104 |
+
)
|
| 105 |
+
return splitter, combos, audit
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def make_walkforward(n_samples: int, n_folds: int, horizon: int, embargo: int):
|
| 109 |
+
"""Expanding-window walk-forward with purge + embargo, audited."""
|
| 110 |
+
if embargo < horizon:
|
| 111 |
+
raise ValueError(
|
| 112 |
+
f"embargo ({embargo}) must be >= label horizon ({horizon})"
|
| 113 |
+
)
|
| 114 |
+
fold_bounds = np.linspace(0, n_samples, n_folds + 2, dtype=int)
|
| 115 |
+
folds = []
|
| 116 |
+
audit = SplitAudit(n_samples, horizon, embargo)
|
| 117 |
+
for j in range(n_folds):
|
| 118 |
+
test_start, test_end = fold_bounds[j + 1], fold_bounds[j + 2]
|
| 119 |
+
train_idx = np.arange(0, test_start - embargo - horizon)
|
| 120 |
+
test_idx = np.arange(test_start, test_end)
|
| 121 |
+
audit.check(train_idx, test_idx, f"wf-fold-{j}")
|
| 122 |
+
folds.append((train_idx, test_idx))
|
| 123 |
+
if not audit.ok:
|
| 124 |
+
raise RuntimeError(
|
| 125 |
+
"no-lookahead audit failed:\n" + "\n".join(audit.violations)
|
| 126 |
+
)
|
| 127 |
+
return folds, audit
|