Download crypto_eval/cv.py from Avi-L/crypto-quant-eval: direct link, hf CLI and curl.
- Browser
- Download file 4.86 kB
-
https://huggingface.co/Avi-L/crypto-quant-eval/resolve/main/crypto_eval/cv.py
- Command line
-
hf download hf://Avi-L/crypto-quant-eval/crypto_eval/cv.py
-
curl -L -o cv.py https://huggingface.co/Avi-L/crypto-quant-eval/resolve/main/crypto_eval/cv.py
4.86 kB
| """Walk-forward / CPCV splitting with purge and embargo. | |
| Anchored on López de Prado, Advances in Financial Machine Learning (2018), | |
| ch. 7 and ch. 11-12, implemented on top of the `purgedcv` package (MIT, | |
| pypi purgedcv==0.1.11). | |
| Conventions used by this eval: | |
| - labels carry a horizon h (forward return over h bars); training samples | |
| whose label window [t, t+h] overlaps a test block are PURGED | |
| - EMBARGO of e bars is additionally applied after each test block; | |
| default e = max(label horizon, one funding interval in bars) = 8 bars at 1h | |
| - CPCV: N blocks, K held out per combination -> C(N, K) test sets, which | |
| recombine into backtest paths via purgedcv.reconstruct_paths. | |
| Defaults N=6, K=2: 15 combinations, C(5,1)=5 paths. | |
| """ | |
| from __future__ import annotations | |
| import numpy as np | |
| import pandas as pd | |
| try: | |
| from purgedcv import ( | |
| CombinatorialPurgedCV, | |
| reconstruct_paths, | |
| ) | |
| except ImportError as e: # pragma: no cover | |
| raise ImportError( | |
| "purgedcv is required: pip install purgedcv==0.1.11" | |
| ) from e | |
| __all__ = ["make_cpcv", "make_walkforward", "SplitAudit"] | |
| class SplitAudit: | |
| """No-lookahead audit for a produced split.""" | |
| def __init__(self, n_samples: int, horizon: int, embargo: int): | |
| self.n_samples = n_samples | |
| self.horizon = horizon | |
| self.embargo = embargo | |
| self.violations: list[str] = [] | |
| def check(self, train_idx: np.ndarray, test_idx: np.ndarray, label: str) -> None: | |
| if len(test_idx) == 0 or len(train_idx) == 0: | |
| self.violations.append(f"{label}: empty fold") | |
| return | |
| # CPCV test sets are the UNION of K blocks: train samples between | |
| # two test blocks are legitimate. Audit each contiguous test | |
| # segment separately instead of the whole [min, max] range. | |
| breaks = np.flatnonzero(np.diff(np.sort(test_idx)) > 1) | |
| segments = np.split(np.sort(test_idx), breaks + 1) | |
| total_bad = 0 | |
| for seg in segments: | |
| t_min, t_max = int(seg.min()), int(seg.max()) | |
| # overlap iff i <= t_max+embargo and i+h >= t_min | |
| leak_zone_end = t_max + self.embargo | |
| bad = int(((train_idx <= leak_zone_end) | |
| & (train_idx + self.horizon >= t_min)).sum()) | |
| total_bad += bad | |
| if total_bad: | |
| self.violations.append( | |
| f"{label}: {total_bad} training samples overlap test window " | |
| f"(leakage)" | |
| ) | |
| def ok(self) -> bool: | |
| return not self.violations | |
| def make_cpcv(index: pd.DatetimeIndex, horizon: int, embargo: int, | |
| freq: str = "1h", n_blocks: int = 6, k: int = 2): | |
| """Return the purgedcv CPCV splitter and audit it. | |
| index: bar start times (UTC). horizon/embargo are in BARS. | |
| purgedcv 0.1.11 requires prediction/evaluation times and takes | |
| purge/embargo as timedeltas; it purges internally, SplitAudit is | |
| kept as an independent leakage check. | |
| """ | |
| freq_td = pd.Timedelta(freq) | |
| horizon_td = horizon * freq_td | |
| embargo_td = embargo * freq_td | |
| if embargo_td < horizon_td: | |
| raise ValueError( | |
| f"embargo ({embargo} bars) must be >= label horizon ({horizon} bars)" | |
| ) | |
| splitter = CombinatorialPurgedCV( | |
| n_splits=n_blocks, | |
| n_test_groups=k, | |
| prediction_times=index.values, | |
| evaluation_times=(index + freq_td).values, | |
| purge_horizon=horizon_td, | |
| embargo=embargo_td, | |
| ) | |
| n_samples = len(index) | |
| audit = SplitAudit(n_samples, horizon, embargo) | |
| combos = list(splitter.split(np.arange(n_samples))) | |
| for j, (train_idx, test_idx) in enumerate(combos): | |
| audit.check(np.asarray(train_idx), np.asarray(test_idx), f"cpcv-combo-{j}") | |
| if not audit.ok: | |
| raise RuntimeError( | |
| "no-lookahead audit failed:\n" + "\n".join(audit.violations) | |
| ) | |
| return splitter, combos, audit | |
| def make_walkforward(n_samples: int, n_folds: int, horizon: int, embargo: int): | |
| """Expanding-window walk-forward with purge + embargo, audited.""" | |
| if embargo < horizon: | |
| raise ValueError( | |
| f"embargo ({embargo}) must be >= label horizon ({horizon})" | |
| ) | |
| fold_bounds = np.linspace(0, n_samples, n_folds + 2, dtype=int) | |
| folds = [] | |
| audit = SplitAudit(n_samples, horizon, embargo) | |
| for j in range(n_folds): | |
| test_start, test_end = fold_bounds[j + 1], fold_bounds[j + 2] | |
| train_idx = np.arange(0, test_start - embargo - horizon) | |
| test_idx = np.arange(test_start, test_end) | |
| audit.check(train_idx, test_idx, f"wf-fold-{j}") | |
| folds.append((train_idx, test_idx)) | |
| if not audit.ok: | |
| raise RuntimeError( | |
| "no-lookahead audit failed:\n" + "\n".join(audit.violations) | |
| ) | |
| return folds, audit | |