Avi-L commited on
Commit
90809d4
·
verified ·
1 Parent(s): ab83ea6

CPCV/purge/embargo splitters + leakage audit

Browse files
Files changed (1) hide show
  1. 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