
Walk Forward Validation
- 282 installs
- 257 repo stars
- Updated June 24, 2026
- agiprolabs/claude-trading-skills
walk-forward-validation is a Claude Code skill that validates trading strategies and financial ML models with time-series-aware splits, purging, embargo, CPCV, and overfit detection.
About
A Claude Code skill for validating trading strategies and financial ML models without lookahead bias. It provides rolling and expanding walk-forward windows, purging and embargo, combinatorial purged cross-validation, and overfit-detection metrics like the deflated Sharpe ratio. Developers use it to test whether a backtested edge is real before deploying it.
- Time-series-aware splits: rolling and expanding windows, purging, embargo
- Combinatorial purged cross-validation (CPCV) for many test paths
- Overfit detection via the deflated Sharpe ratio
Walk Forward Validation by the numbers
- 282 all-time installs (skills.sh)
- Ranked #340 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
walk-forward-validation capabilities & compatibility
Free; runs locally in Python with NumPy and scipy
- Capabilities
- walk forward validation · backtesting · overfit detection · cross validation · parameter optimization
- Use cases
- testing · data analysis · trading
- Pricing
- Free
What walk-forward-validation says it does
Standard cross-validation (k-fold, random splits) fails catastrophically for financial time series because it introduces lookahead bias and ignores autocorrelation.
CPCV (Lopez de Prado, 2018) generates all possible train/test combinations from `N` groups while maintaining temporal ordering.
Add a buffer gap between the end of training and start of testing to account for serial correlation that purging alone does not eliminate.
npx skills add https://github.com/agiprolabs/claude-trading-skills --skill walk-forward-validationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 282 |
|---|---|
| repo stars | ★ 257 |
| Last updated | June 24, 2026 |
| Repository | agiprolabs/claude-trading-skills ↗ |
What it does
Validate a trading strategy or ML model with time-series-aware splits and overfit detection before deployment.
Who is it for?
Testing whether a backtested trading edge holds out-of-sample without lookahead bias.
Skip if: Running the initial backtest or executing trades.
When should I use this skill?
You need to validate a strategy against overfitting before trusting its backtest.
What you get
Out-of-sample validation results and overfit-adjusted performance for a strategy.
- Time-series validation folds and overfit-detection metrics
By the numbers
- CPCV example N=6, k=2 gives 15 backtest paths
- Embargo rule of thumb >= 2x label horizon
Files
Walk-Forward Validation
Walk-forward validation framework for trading strategies and ML models. Standard cross-validation (k-fold, random splits) fails catastrophically for financial time series because it introduces lookahead bias and ignores autocorrelation. This skill covers proper time-series validation techniques including rolling and expanding windows, purged cross-validation, combinatorial purged cross-validation (CPCV), and overfit detection metrics.
Why Standard Cross-Validation Fails
Standard k-fold CV assumes data points are independent and identically distributed (IID). Financial time series violate both assumptions:
1. Lookahead bias — Random splits let the model train on future data and predict past data, artificially inflating performance. 2. Autocorrelation — Adjacent observations are correlated. A random split that puts Monday in test and Tuesday in train leaks information. 3. Regime dependence — Markets shift between regimes. A model trained on a bull market and tested on a bull market tells you nothing about bear market performance. 4. Label overlap — If labels are computed over windows (e.g., 24h forward return), adjacent train/test samples share label computation periods, leaking information.
Walk-Forward Framework
Rolling Window (Fixed Train Size)
The train window has a fixed size and slides forward in time. This is preferred when you believe older data is less relevant (common in crypto).
Window 1: [===TRAIN===][=TEST=]
Window 2: [===TRAIN===][=TEST=]
Window 3: [===TRAIN===][=TEST=]Parameters:
train_size: Number of bars/days in the training windowtest_size: Number of bars/days in the test windowstep_size: How far to advance between folds (often equalstest_size)
Expanding Window (Growing Train)
The train window starts at the beginning and expands forward. This uses all available historical data, which helps when data is scarce.
Window 1: [==TRAIN==][=TEST=]
Window 2: [====TRAIN====][=TEST=]
Window 3: [======TRAIN======][=TEST=]Parameters:
min_train_size: Minimum training samples before first foldtest_size: Fixed test window sizestep_size: How far to advance between folds
Choosing Between Them
| Factor | Rolling | Expanding |
|---|---|---|
| Data recency | Prioritizes recent data | Uses all history |
| Regime changes | Better adapts to new regimes | May dilute recent regime |
| Sample size | Fixed, may be small | Grows over time |
| Crypto preference | Preferred for < 6mo horizons | Better for regime-stable models |
Purging and Embargo
Purging
Remove training samples whose labels overlap with the test set's time range. If a label is computed as the 24h forward return starting at time t, any training sample where t + 24h extends into the test period must be purged.
def purge_train_indices(
train_idx: list[int],
test_start: int,
label_horizon: int,
timestamps: list[int],
) -> list[int]:
"""Remove train samples whose label windows overlap test period."""
test_start_time = timestamps[test_start]
return [
i for i in train_idx
if timestamps[i] + label_horizon < test_start_time
]Embargo
Add a buffer gap between the end of training and start of testing to account for serial correlation that purging alone does not eliminate.
[===TRAIN===][--EMBARGO--][=TEST=]Typical embargo sizes:
- 1-minute bars: 60–240 bars (1–4 hours)
- 5-minute bars: 12–48 bars (1–4 hours)
- Hourly bars: 6–24 bars (6–24 hours)
- Daily bars: 2–5 bars (2–5 days)
- Crypto rule of thumb: Embargo >= 2x the label computation horizon
Combinatorial Purged Cross-Validation (CPCV)
CPCV (Lopez de Prado, 2018) generates all possible train/test combinations from N groups while maintaining temporal ordering. This produces far more test paths than standard walk-forward, enabling statistical tests for overfitting.
Key properties:
- Splits data into
Ncontiguous groups - For each combination of
ktest groups, the remainingN-kgroups form the training set - Applies purging and embargo at each train/test boundary
- Produces
C(N, k)backtest paths (e.g., N=6, k=2 gives 15 paths)
See references/methodology.md for the full CPCV algorithm and formulas.
Overfit Detection
Deflated Sharpe Ratio (DSR)
The observed Sharpe ratio must be adjusted for:
- Number of strategies tested (multiple testing)
- Non-normality of returns (skewness, kurtosis)
- Length of the backtest
import numpy as np
from scipy.stats import norm
def deflated_sharpe_ratio(
observed_sr: float,
num_trials: int,
backtest_length: int,
skewness: float = 0.0,
kurtosis: float = 3.0,
) -> float:
"""Compute the probability that observed SR > 0 after deflation.
Args:
observed_sr: Annualized Sharpe ratio of the selected strategy.
num_trials: Number of strategies tested (including discarded ones).
backtest_length: Number of return observations.
skewness: Skewness of returns.
kurtosis: Excess kurtosis of returns.
Returns:
p-value (probability SR is genuinely > 0).
"""
sr_std = np.sqrt(
(1 - skewness * observed_sr + (kurtosis - 1) / 4 * observed_sr**2)
/ (backtest_length - 1)
)
# Expected max SR under null (Euler-Mascheroni approximation)
euler_mascheroni = 0.5772156649
expected_max_sr = norm.ppf(1 - 1 / num_trials) * (
1 - euler_mascheroni
) + euler_mascheroni * norm.ppf(1 - 1 / (num_trials * np.e))
dsr = norm.cdf((observed_sr - expected_max_sr) / sr_std)
return dsrA DSR below 0.95 suggests the observed performance is likely due to overfitting across the trials tested.
Probability of Backtest Overfitting (PBO)
PBO uses CPCV to measure the fraction of backtest paths where the in-sample optimal strategy underperforms the median out-of-sample. A PBO above 0.50 indicates more-likely-than-not overfitting.
See references/overfit_detection.md for complete derivations and implementation details.
Crypto-Specific Considerations
1. Shorter windows: Crypto regimes change faster than equities. A 90-day rolling window may be more appropriate than 252 days. 2. 24/7 markets: No weekends or holidays to account for, but funding rate resets (every 8h on perps) create microstructure effects. 3. Survivorship bias: Many tokens delist. Validation must include delisted tokens or at minimum acknowledge this limitation. 4. Liquidity regime shifts: A token's liquidity profile can change dramatically (new CEX listing, liquidity mining end). Train/test splits should ideally not straddle major liquidity events. 5. Data availability: Many tokens have < 1 year of data. Expanding windows with small min_train_size may be necessary.
Practical Window Sizes for Crypto
| Strategy Timeframe | Train Window | Test Window | Embargo |
|---|---|---|---|
| Scalping (1-5min) | 3-7 days | 1 day | 2-4 hours |
| Intraday (15min-1h) | 14-30 days | 3-7 days | 12-24 hours |
| Swing (4h-daily) | 30-90 days | 7-14 days | 2-5 days |
| Position (daily-weekly) | 90-180 days | 30 days | 5-10 days |
Quick Start
from walk_forward import WalkForwardValidator, WalkForwardConfig
config = WalkForwardConfig(
train_size=90,
test_size=14,
step_size=14,
window_type="rolling",
embargo_size=3,
purge_horizon=1,
)
validator = WalkForwardValidator(config)
for fold in validator.split(price_data):
model.fit(fold.train_X, fold.train_y)
predictions = model.predict(fold.test_X)
fold.record_performance(predictions, fold.test_y)
results = validator.aggregate_results()
print(f"OOS Sharpe: {results.oos_sharpe:.3f}")
print(f"Train/Test Sharpe ratio: {results.sharpe_ratio_ratio:.2f}")Files
References
references/methodology.md— Walk-forward theory, window types, purging, embargo, CPCV algorithm with formulasreferences/overfit_detection.md— Deflated Sharpe ratio, probability of backtest overfitting, multiple testing correctionsreferences/practical_guide.md— Window size selection for crypto, regime considerations, common validation mistakes
Scripts
scripts/walk_forward.py— Walk-forward validation engine with rolling and expanding windows;--demomode with synthetic datascripts/overfit_detector.py— Deflated Sharpe ratio and PBO computation;--demomode with synthetic backtest results
Walk-Forward Validation — Methodology
Overview
Walk-forward validation is the gold standard for evaluating trading strategies and financial ML models. It simulates the real-world process of training on historical data and trading on unseen future data.
Time Series Splitting
Rolling Window
Fixed-size training window slides forward through data:
Time: t0 ──────────────────────────────────────────── tN
Fold 1: [────TRAIN────][─TEST─]
Fold 2: [────TRAIN────][─TEST─]
Fold 3: [────TRAIN────][─TEST─]Formal definition:
- Given data
X[0..T], train sizeW, test sizeH, stepS - Fold
i: train =X[i*S .. i*S + W - 1], test =X[i*S + W .. i*S + W + H - 1] - Number of folds:
floor((T - W - H + 1) / S) + 1
Expanding Window
Training window grows from a fixed start:
Time: t0 ──────────────────────────────────────────── tN
Fold 1: [──TRAIN──][─TEST─]
Fold 2: [──────TRAIN──────][─TEST─]
Fold 3: [──────────TRAIN──────────][─TEST─]Formal definition:
- Given data
X[0..T], minimum train sizeW_min, test sizeH, stepS - Fold
i: train =X[0 .. W_min + i*S - 1], test =X[W_min + i*S .. W_min + i*S + H - 1] - Number of folds:
floor((T - W_min - H + 1) / S) + 1
Purging
The Problem
When labels are computed over a forward-looking horizon (e.g., "5-day forward return"), a training sample at time t uses information up to time t + horizon. If the test set begins at time t_test, any training sample where t + horizon >= t_test has label information that overlaps with the test period.
The Solution
Remove (purge) training samples whose label computation windows overlap with the test set:
purged_train = {i in train : timestamp[i] + label_horizon < test_start_time}Example
- Label = 24-hour forward return
- Test starts at hour 100
- Training sample at hour 98 uses data from hours 98–122 (overlaps test)
- Training sample at hour 75 uses data from hours 75–99 (overlaps test by 1 hour at boundary)
- Training sample at hour 74 uses data from hours 74–98 (safe, does not reach hour 100)
- Purge samples 75–99 from training
Embargo
The Problem
Even after purging, serial correlation in features means that training samples just before the test boundary carry information about the test period through correlated features.
The Solution
Add a buffer (embargo) between the last training sample and the first test sample:
[===TRAIN===][~~EMBARGO~~][===TEST===]The embargo period is excluded from both training and testing. Typical size: 1–5x the autocorrelation decay length of the features.
Sizing the Embargo
Compute the autocorrelation function (ACF) of your features. The embargo should span enough lags for the ACF to decay below a significance threshold (commonly 2/sqrt(N)):
import numpy as np
def estimate_embargo_size(feature_series: np.ndarray, threshold: float = 0.05) -> int:
"""Estimate embargo size from autocorrelation decay."""
n = len(feature_series)
mean = np.mean(feature_series)
var = np.var(feature_series)
if var == 0:
return 1
acf_values = []
for lag in range(1, min(n // 4, 100)):
c = np.mean((feature_series[:-lag] - mean) * (feature_series[lag:] - mean)) / var
acf_values.append(abs(c))
if abs(c) < threshold:
return lag
return len(acf_values)Combinatorial Purged Cross-Validation (CPCV)
Motivation
Standard walk-forward produces a small number of test paths (typically 5–20 folds). This is insufficient for statistical tests like PBO, which need many independent backtest paths. CPCV solves this by generating all valid combinations.
Algorithm
1. Partition the data into N contiguous, non-overlapping groups: G_1, G_2, ..., G_N 2. Choose k groups as the test set (typically k = 2) 3. Train on the remaining N - k groups 4. Purge training samples at each boundary between a training group and an adjacent test group 5. Embargo additional samples after each purge boundary 6. Repeat for all C(N, k) combinations
Number of Paths
With N groups and k test groups:
- Number of combinations:
C(N, k) = N! / (k! * (N-k)!) - Each combination produces one backtest path
| N | k | Combinations |
|---|---|---|
| 6 | 2 | 15 |
| 8 | 2 | 28 |
| 10 | 2 | 45 |
| 10 | 3 | 120 |
| 12 | 2 | 66 |
Purging at Internal Boundaries
Unlike simple walk-forward, CPCV may have train-test boundaries in the middle of the data (not just at the end of training). Every boundary between a training group and a test group requires purging:
Groups: [G1:train][G2:test][G3:train][G4:test][G5:train]
Purge zones:
- End of G1 (train before G2 test)
- Start of G3 (train after G2 test)
- End of G3 (train before G4 test)
- Start of G5 (train after G4 test)CPCV Implementation Sketch
from itertools import combinations
def cpcv_splits(
n_samples: int,
n_groups: int,
n_test_groups: int,
purge_window: int = 0,
embargo_window: int = 0,
) -> list[tuple[list[int], list[int]]]:
"""Generate all CPCV train/test splits."""
group_size = n_samples // n_groups
groups = []
for i in range(n_groups):
start = i * group_size
end = (i + 1) * group_size if i < n_groups - 1 else n_samples
groups.append(list(range(start, end)))
splits = []
for test_combo in combinations(range(n_groups), n_test_groups):
test_set = set()
for g in test_combo:
test_set.update(groups[g])
train_set = set(range(n_samples)) - test_set
# Purge and embargo at each boundary
for g in test_combo:
test_start = groups[g][0]
test_end = groups[g][-1]
# Purge before test
for j in range(max(0, test_start - purge_window), test_start):
train_set.discard(j)
# Embargo after test
for j in range(test_end + 1, min(n_samples, test_end + 1 + embargo_window)):
train_set.discard(j)
splits.append((sorted(train_set), sorted(test_set)))
return splitsAggregating Results
Per-Fold Metrics
For each fold, compute:
- Out-of-sample Sharpe ratio:
mean(oos_returns) / std(oos_returns) * sqrt(annualization_factor) - Out-of-sample returns: Total return over the test period
- Hit rate: Fraction of correct directional predictions
- Maximum drawdown: Worst peak-to-trough decline in test period
Cross-Fold Aggregation
- Mean OOS Sharpe: Average Sharpe across all folds (primary metric)
- Sharpe ratio of Sharpe ratios: Stability measure — high variance across folds suggests overfitting
- Train/Test Sharpe ratio: If train Sharpe >> test Sharpe, the model is overfitting
- Rank correlation (IS vs OOS): Spearman correlation between in-sample and out-of-sample rankings across parameter sets
A train/test Sharpe ratio above 2.0 is a strong signal of overfitting. Ratios near 1.0 indicate robust generalization.
Overfit Detection Methods
The Overfitting Problem in Finance
Every backtest is a hypothesis test. When you test many strategies and select the best performer, you are conducting multiple comparisons. The probability that at least one strategy appears profitable by chance increases rapidly with the number of trials.
If you test N strategies at a 5% significance level, the probability that at least one falsely appears significant is 1 - (1 - 0.05)^N. With 20 strategies, this exceeds 64%.
Deflated Sharpe Ratio (DSR)
Concept
The Deflated Sharpe Ratio (Bailey and Lopez de Prado, 2014) adjusts the observed Sharpe ratio for:
1. Number of trials — How many strategies/parameter sets were tested 2. Non-normality — Skewness and kurtosis of returns 3. Backtest length — More data points increase statistical reliability
Formula
The DSR computes the probability that the observed Sharpe ratio exceeds the expected maximum Sharpe ratio under the null hypothesis (all strategies have zero true Sharpe):
DSR = Phi((SR_observed - SR_expected_max) / sigma_SR)Where:
Phi= standard normal CDFSR_observed= Sharpe ratio of the selected strategySR_expected_max= expected maximum SR fromNIID trialssigma_SR= standard error of the Sharpe ratio estimator
Standard Error of the Sharpe Ratio
sigma_SR = sqrt((1 - gamma_3 * SR + (gamma_4 - 1)/4 * SR^2) / (T - 1))Where:
gamma_3= skewness of returnsgamma_4= kurtosis of returns (not excess kurtosis)T= number of return observationsSR= observed Sharpe ratio (non-annualized)
Expected Maximum Sharpe Ratio
Under the null hypothesis that all N strategies have zero true Sharpe ratio, the expected maximum observed SR is approximately:
E[max(SR)] ≈ Z_inv(1 - 1/N) * (1 - gamma) + gamma * Z_inv(1 - 1/(N*e))Where:
Z_inv= inverse standard normal CDF (quantile function)gamma ≈ 0.5772= Euler-Mascheroni constantN= number of independent trialse ≈ 2.7183= Euler's number
Interpretation
| DSR Value | Interpretation |
|---|---|
| > 0.95 | Strong evidence of genuine skill |
| 0.80 – 0.95 | Moderate evidence, proceed with caution |
| 0.50 – 0.80 | Weak evidence, likely partially overfitted |
| < 0.50 | Likely overfitted, performance is noise |
Practical Notes
Nmust include ALL strategies tested, including those discarded. This is the hardest part — researchers forget or undercount.- Non-annualized SR should be used in the formula; annualize the result afterward.
- For crypto with 24/7 trading, annualization factor is 365 (daily) or 8760 (hourly).
Probability of Backtest Overfitting (PBO)
Concept
PBO (Bailey et al., 2017) uses combinatorial splits to measure the probability that in-sample optimization selects a strategy that underperforms out-of-sample.
Algorithm
1. Generate multiple train/test splits using CPCV (N groups, k=2 test groups) 2. For each split: a. Rank all S strategies by in-sample performance b. Identify the IS-optimal strategy (best in-sample rank) c. Record its out-of-sample rank 3. Compute the relative OOS rank: w_bar = OOS_rank / S 4. PBO = fraction of splits where w_bar > 0.5 (IS-best is worse than OOS median)
Logit Transformation
For better statistical properties, apply the logit transform before aggregating:
lambda = ln(w_bar / (1 - w_bar))PBO is then estimated as the fraction of splits where lambda > 0 (i.e., OOS rank is worse than median).
Interpretation
| PBO Value | Interpretation |
|---|---|
| < 0.10 | Low overfitting risk |
| 0.10 – 0.30 | Moderate risk, additional validation recommended |
| 0.30 – 0.50 | High risk, results are unreliable |
| > 0.50 | More likely than not overfitted |
Stochastic Dominance
PBO can be extended by checking if the IS-optimal strategy's OOS performance distribution stochastically dominates a uniform distribution. If it does not, the strategy selection process has no predictive power.
Multiple Testing Corrections
When testing multiple hypotheses simultaneously, use corrections to control the family-wise error rate (FWER) or false discovery rate (FDR).
Bonferroni Correction
The simplest approach — divide the significance level by the number of tests:
alpha_adjusted = alpha / NConservative but guarantees FWER control. With 100 strategies at alpha=0.05, each strategy must achieve p < 0.0005.
Holm-Bonferroni (Step-Down)
Less conservative than Bonferroni while still controlling FWER:
1. Sort p-values: p_(1) <= p_(2) <= ... <= p_(N) 2. For each i, reject H_i if p_(i) <= alpha / (N - i + 1) 3. Stop at the first non-rejection
Benjamini-Hochberg (FDR Control)
Controls the expected proportion of false discoveries rather than FWER:
1. Sort p-values: p_(1) <= p_(2) <= ... <= p_(N) 2. Find largest k where p_(k) <= k/N * alpha 3. Reject hypotheses 1 through k
Which to Use
- Bonferroni: When false positives are catastrophic (capital at risk)
- Holm: When you want more power than Bonferroni with same FWER guarantee
- BH: When testing many strategies and some false positives are acceptable (screening stage)
For trading: Use Bonferroni or Holm when selecting a single strategy to deploy. Use BH when screening a large universe of strategies for further investigation.
Minimum Backtest Length (MinBTL)
The minimum number of observations needed for a Sharpe ratio to be statistically significant:
MinBTL = 1 + (1 - gamma_3 * SR + (gamma_4 - 1)/4 * SR^2) * (Z_alpha / SR)^2Where:
Z_alpha= critical value for desired significance levelSR= target non-annualized Sharpe ratiogamma_3,gamma_4= skewness, kurtosis of returns
Practical Implications
For a strategy with daily SR = 0.1 (annualized ~1.6) and normal returns:
- At 95% confidence: MinBTL ≈ 385 daily observations (1.05 years)
- At 99% confidence: MinBTL ≈ 664 daily observations (1.82 years)
For crypto with hourly SR = 0.01:
- At 95% confidence: MinBTL ≈ 38,416 hourly observations (4.4 years)
This underscores why high-frequency strategies need very long backtests or very high Sharpe ratios to be statistically validated.
Combining DSR and PBO
Use both metrics together for robust overfit detection:
1. DSR answers: "Is this Sharpe ratio likely real given how many things I tried?" 2. PBO answers: "Does my strategy selection process have any predictive power?"
If DSR > 0.95 AND PBO < 0.20, the strategy has strong evidence of genuine edge. If either metric fails, additional out-of-sample testing (preferably paper trading) is essential before deploying capital.
Walk-Forward Validation — Practical Guide for Crypto
Window Size Selection
Principles
1. Train window must span at least one full market cycle — or else the model only learns one regime. In crypto, a "cycle" can be as short as 2–4 weeks for altcoins. 2. Train window should not be so long that stale data dilutes signal — crypto market structure changes rapidly. Data from 2 years ago may be from a fundamentally different market. 3. Test window must be long enough for statistical significance — a 1-day test window produces noisy estimates. Aim for at least 30 independent observations. 4. Step size determines compute cost — smaller steps = more folds = better estimates but longer runtime.
Recommended Window Sizes
| Strategy Type | Train | Test | Step | Embargo | Rationale |
|---|---|---|---|---|---|
| HFT / Scalping (1-5min bars) | 3-7 days | 1 day | 1 day | 2-4 hours | Microstructure changes fast |
| Intraday (15min-1h bars) | 14-30 days | 3-7 days | 3 days | 12-24 hours | Need multiple day/night cycles |
| Swing (4h-daily bars) | 30-90 days | 7-14 days | 7 days | 2-5 days | Must span regime transitions |
| Position (daily-weekly) | 90-180 days | 14-30 days | 14 days | 5-10 days | Longer horizon, need full cycles |
| ML Features (daily) | 60-120 days | 14-21 days | 7 days | 3-5 days | Balance data volume vs recency |
Crypto vs Equities
| Aspect | Equities | Crypto |
|---|---|---|
| Typical train window | 1-3 years (252-756 bars) | 1-6 months (30-180 bars) |
| Market hours | 6.5h/day, 252 days/year | 24/7/365 |
| Regime change frequency | Quarterly to yearly | Weekly to monthly |
| Data availability | Decades | 2-8 years for most tokens |
| Survivorship bias severity | Moderate | Severe (tokens delist constantly) |
| Recommended approach | Expanding window | Rolling window |
Regime-Aware Validation
Why Regimes Matter
A strategy that works in high-volatility trending markets may lose money in low-volatility mean-reverting markets. If your train and test windows both fall within the same regime, validation results are misleading.
Simple Regime Detection for Splits
Use volatility and trend to classify market regime before splitting:
import numpy as np
def classify_regime(
returns: np.ndarray,
lookback: int = 20,
) -> np.ndarray:
"""Classify each bar as trending-volatile, trending-quiet, etc."""
n = len(returns)
regimes = np.empty(n, dtype="U20")
for i in range(lookback, n):
window = returns[i - lookback : i]
vol = np.std(window) * np.sqrt(365) # Annualized
trend = np.sum(window) # Cumulative return
if vol > 0.8: # High vol threshold (crypto)
regimes[i] = "trending-up" if trend > 0 else "trending-down"
else:
regimes[i] = "quiet-up" if trend > 0 else "quiet-down"
regimes[:lookback] = "unknown"
return regimesRegime-Aware Split Strategy
1. Classify each bar into a regime 2. Verify that each train window spans at least 2 different regimes 3. Track the regime composition of each test window 4. Report performance broken down by regime 5. Flag folds where train and test are the same regime
If most folds have matching train/test regimes, the validation is unreliable for out-of-regime performance.
Common Validation Mistakes
1. Not Purging Labels
Mistake: Using 5-day forward returns as labels without purging the 5-day overlap between train and test.
Result: Information leakage inflates accuracy by 10–30%.
Fix: Always purge label_horizon bars from the end of training data.
2. Using Future Information in Features
Mistake: Normalizing features using the full dataset's mean/std before splitting.
Result: Test set statistics leak into training features.
Fix: Fit scalers/normalizers on training data only. Transform test data using training statistics.
# WRONG
scaler.fit(all_data)
train_scaled = scaler.transform(train_data)
test_scaled = scaler.transform(test_data)
# RIGHT
scaler.fit(train_data)
train_scaled = scaler.transform(train_data)
test_scaled = scaler.transform(test_data)3. Optimizing Hyperparameters on Test Data
Mistake: Using the test fold to tune hyperparameters (learning rate, lookback periods, thresholds).
Result: Hyperparameters are fit to the test set, destroying its validity.
Fix: Use a three-way split: train / validation / test. Tune on validation, evaluate on test. Or use nested cross-validation.
4. Ignoring Transaction Costs
Mistake: Computing walk-forward returns without accounting for spreads, slippage, and fees.
Result: A strategy with 200 trades/day and 0.5 bps edge appears profitable, but 5 bps per round-trip cost makes it a loser.
Fix: Always include realistic transaction costs. For Solana DEX trades, minimum 0.3% (swap fee + slippage).
5. Too Few Folds
Mistake: Using 3 walk-forward folds and reporting the average.
Result: High variance in the estimate. One good or bad fold dominates.
Fix: Use at least 8–10 folds, or CPCV to generate 15+ paths.
6. Reporting In-Sample Metrics
Mistake: Reporting training set performance alongside (or instead of) test set performance.
Result: Misleading impression of strategy quality.
Fix: Only report out-of-sample metrics. Track the train/test ratio as an overfitting diagnostic.
7. Not Accounting for Multiple Testing
Mistake: Testing 50 parameter combinations, selecting the best, and reporting its backtest result.
Result: The best of 50 random strategies will look profitable by chance alone.
Fix: Use the Deflated Sharpe Ratio to adjust for the number of trials.
8. Survivorship Bias in Token Universe
Mistake: Backtesting on tokens that exist today, ignoring delisted tokens.
Result: Returns are inflated because you only test tokens that survived.
Fix: Include delisted tokens in your universe, or at minimum report that survivorship bias is present and estimate its magnitude.
Validation Checklist
Before trusting any backtest result, verify:
- [ ] Time series ordering is respected (no future data in training)
- [ ] Labels are purged at train/test boundaries
- [ ] Embargo period is applied after purging
- [ ] Features are normalized using training data only
- [ ] Transaction costs are included (realistic for the venue)
- [ ] At least 8 walk-forward folds or 15 CPCV paths
- [ ] Deflated Sharpe Ratio > 0.95 (accounting for all trials)
- [ ] PBO < 0.30 (if using strategy selection)
- [ ] Train/test Sharpe ratio < 2.0
- [ ] Results reported per-regime if possible
- [ ] Hyperparameters tuned on validation set, not test set
- [ ] Survivorship bias acknowledged or addressed
Reporting Template
When presenting walk-forward results, include:
Walk-Forward Validation Report
==============================
Window type: Rolling (90-day train, 14-day test)
Number of folds: 12
Embargo: 3 days
Purge horizon: 1 day
Date range: 2025-01-01 to 2025-12-31
Strategies tested: 25
Out-of-Sample Results:
Mean Sharpe: 1.42
Sharpe Std Dev: 0.38
Mean Return: +3.2% per fold
Win Rate: 58.3%
Max Drawdown: -8.7%
Overfitting Metrics:
Train/Test SR: 1.65
Deflated SR: 0.87
PBO: 0.22
Regime Breakdown:
Trending-Up: SR 2.1 (4 folds)
Trending-Down: SR 0.8 (3 folds)
Quiet: SR 1.1 (5 folds)#!/usr/bin/env python3
"""Overfit detection: Deflated Sharpe Ratio and Probability of Backtest Overfitting.
Computes the Deflated Sharpe Ratio (DSR) to assess whether an observed Sharpe
ratio is statistically significant after accounting for multiple testing,
non-normality, and backtest length. Also computes the Probability of Backtest
Overfitting (PBO) using combinatorial purged cross-validation.
Usage:
python scripts/overfit_detector.py --demo
python scripts/overfit_detector.py --help
Dependencies:
uv pip install numpy scipy
Environment Variables:
None required (--demo mode uses synthetic data).
"""
from __future__ import annotations
import argparse
import dataclasses
import sys
from itertools import combinations
from typing import Optional
import numpy as np
from scipy.stats import norm
# ── Data Classes ────────────────────────────────────────────────────
@dataclasses.dataclass
class DSRResult:
"""Results from Deflated Sharpe Ratio analysis.
Attributes:
observed_sr: The observed annualized Sharpe ratio.
expected_max_sr: Expected maximum SR under the null.
sr_std_error: Standard error of the SR estimator.
dsr_pvalue: Probability that observed SR > 0 after deflation.
num_trials: Number of strategies tested.
backtest_length: Number of return observations.
skewness: Return skewness.
kurtosis: Return kurtosis (not excess).
is_significant: Whether DSR > 0.95.
"""
observed_sr: float
expected_max_sr: float
sr_std_error: float
dsr_pvalue: float
num_trials: int
backtest_length: int
skewness: float
kurtosis: float
is_significant: bool
@dataclasses.dataclass
class PBOResult:
"""Results from Probability of Backtest Overfitting analysis.
Attributes:
pbo: Probability of backtest overfitting.
n_paths: Number of CPCV paths evaluated.
n_overfit_paths: Number of paths where IS-best underperforms OOS median.
logit_values: Logit-transformed relative ranks for each path.
mean_oos_rank: Mean relative OOS rank of IS-optimal strategy.
is_overfit: Whether PBO > 0.50.
"""
pbo: float
n_paths: int
n_overfit_paths: int
logit_values: list[float]
mean_oos_rank: float
is_overfit: bool
@dataclasses.dataclass
class MinBTLResult:
"""Minimum Backtest Length result.
Attributes:
min_length: Minimum number of observations needed.
target_sr: Target Sharpe ratio (non-annualized).
confidence: Confidence level used.
skewness: Assumed skewness.
kurtosis: Assumed kurtosis.
"""
min_length: int
target_sr: float
confidence: float
skewness: float
kurtosis: float
# ── Deflated Sharpe Ratio ───────────────────────────────────────────
def deflated_sharpe_ratio(
observed_sr: float,
num_trials: int,
backtest_length: int,
skewness: float = 0.0,
kurtosis: float = 3.0,
annualization: float = 1.0,
) -> DSRResult:
"""Compute the Deflated Sharpe Ratio.
Adjusts the observed Sharpe ratio for multiple testing, non-normality,
and backtest length. Returns the probability that the observed SR is
genuinely greater than zero.
Args:
observed_sr: Annualized Sharpe ratio of the selected strategy.
num_trials: Total number of strategies tested (including discarded).
backtest_length: Number of return observations.
skewness: Skewness of strategy returns.
kurtosis: Kurtosis of strategy returns (not excess; normal = 3.0).
annualization: Annualization factor (e.g., sqrt(365) for daily crypto).
The observed_sr is de-annualized internally.
Returns:
DSRResult with all computed values.
"""
if num_trials < 1:
raise ValueError("num_trials must be >= 1")
if backtest_length < 10:
raise ValueError("backtest_length must be >= 10")
# De-annualize the SR for the formula
sr = observed_sr / annualization if annualization > 1.0 else observed_sr
# Standard error of the Sharpe ratio estimator
sr_std = np.sqrt(
(1.0 - skewness * sr + (kurtosis - 1.0) / 4.0 * sr**2)
/ (backtest_length - 1)
)
# Expected maximum SR under null (all strategies have zero true SR)
euler_mascheroni = 0.5772156649
if num_trials == 1:
expected_max_sr = 0.0
else:
z1 = norm.ppf(1.0 - 1.0 / num_trials)
z2 = norm.ppf(1.0 - 1.0 / (num_trials * np.e))
expected_max_sr = z1 * (1.0 - euler_mascheroni) + euler_mascheroni * z2
# Deflated SR p-value
if sr_std > 0:
dsr = float(norm.cdf((sr - expected_max_sr) / sr_std))
else:
dsr = 1.0 if sr > expected_max_sr else 0.0
return DSRResult(
observed_sr=observed_sr,
expected_max_sr=expected_max_sr * annualization,
sr_std_error=sr_std * annualization,
dsr_pvalue=dsr,
num_trials=num_trials,
backtest_length=backtest_length,
skewness=skewness,
kurtosis=kurtosis,
is_significant=dsr > 0.95,
)
# ── Probability of Backtest Overfitting ─────────────────────────────
def probability_of_backtest_overfitting(
strategy_returns: np.ndarray,
n_groups: int = 6,
n_test_groups: int = 2,
) -> PBOResult:
"""Compute Probability of Backtest Overfitting using CPCV.
Splits the data into groups, generates all C(N,k) train/test combinations,
and measures how often the in-sample optimal strategy underperforms
out-of-sample.
Args:
strategy_returns: 2D array of shape (n_observations, n_strategies).
Each column is a strategy's return time series.
n_groups: Number of contiguous groups to split data into.
n_test_groups: Number of groups to use as test in each combination.
Returns:
PBOResult with PBO estimate and diagnostics.
"""
n_obs, n_strategies = strategy_returns.shape
if n_strategies < 2:
raise ValueError("Need at least 2 strategies for PBO")
if n_groups < 3:
raise ValueError("Need at least 3 groups for meaningful CPCV")
group_size = n_obs // n_groups
if group_size < 5:
raise ValueError(
f"Each group has only {group_size} observations. "
f"Reduce n_groups or provide more data."
)
# Create group boundaries
group_bounds: list[tuple[int, int]] = []
for i in range(n_groups):
start = i * group_size
end = (i + 1) * group_size if i < n_groups - 1 else n_obs
group_bounds.append((start, end))
logit_values: list[float] = []
n_overfit = 0
for test_combo in combinations(range(n_groups), n_test_groups):
test_set = set(test_combo)
# Build train and test index arrays
train_indices: list[int] = []
test_indices: list[int] = []
for g_idx in range(n_groups):
start, end = group_bounds[g_idx]
indices = list(range(start, end))
if g_idx in test_set:
test_indices.extend(indices)
else:
train_indices.extend(indices)
if not train_indices or not test_indices:
continue
train_arr = np.array(train_indices)
test_arr = np.array(test_indices)
# Compute in-sample performance (mean return) for each strategy
is_performance = np.mean(strategy_returns[train_arr], axis=0)
# Select IS-best strategy
is_best_idx = int(np.argmax(is_performance))
# Compute out-of-sample performance for all strategies
oos_performance = np.mean(strategy_returns[test_arr], axis=0)
# Rank the IS-best strategy in OOS performance
oos_rank = int(np.sum(oos_performance > oos_performance[is_best_idx]))
relative_rank = (oos_rank + 1) / n_strategies # 1-based, normalized
# Logit transform (clamp to avoid log(0))
clamped = np.clip(relative_rank, 0.01, 0.99)
logit = float(np.log(clamped / (1.0 - clamped)))
logit_values.append(logit)
if relative_rank > 0.5:
n_overfit += 1
n_paths = len(logit_values)
pbo = n_overfit / n_paths if n_paths > 0 else 1.0
mean_rank = float(np.mean([
(1.0 / (1.0 + np.exp(-lv))) for lv in logit_values
])) if logit_values else 1.0
return PBOResult(
pbo=pbo,
n_paths=n_paths,
n_overfit_paths=n_overfit,
logit_values=logit_values,
mean_oos_rank=mean_rank,
is_overfit=pbo > 0.50,
)
# ── Minimum Backtest Length ─────────────────────────────────────────
def minimum_backtest_length(
target_sr: float,
confidence: float = 0.95,
skewness: float = 0.0,
kurtosis: float = 3.0,
) -> MinBTLResult:
"""Compute minimum backtest length for a target Sharpe ratio.
Args:
target_sr: Target non-annualized Sharpe ratio.
confidence: Desired confidence level (e.g., 0.95).
skewness: Assumed skewness of returns.
kurtosis: Assumed kurtosis (normal = 3.0).
Returns:
MinBTLResult with the minimum number of observations.
"""
if target_sr <= 0:
raise ValueError("target_sr must be > 0")
z_alpha = norm.ppf(confidence)
variance_factor = 1.0 - skewness * target_sr + (kurtosis - 1.0) / 4.0 * target_sr**2
min_length = int(np.ceil(1.0 + variance_factor * (z_alpha / target_sr) ** 2))
return MinBTLResult(
min_length=min_length,
target_sr=target_sr,
confidence=confidence,
skewness=skewness,
kurtosis=kurtosis,
)
# ── Multiple Testing Corrections ───────────────────────────────────
def bonferroni_correction(p_values: list[float], alpha: float = 0.05) -> list[bool]:
"""Apply Bonferroni correction to a list of p-values.
Args:
p_values: List of p-values from individual strategy tests.
alpha: Family-wise significance level.
Returns:
List of booleans (True = reject null = strategy is significant).
"""
n = len(p_values)
adjusted_alpha = alpha / n
return [p < adjusted_alpha for p in p_values]
def holm_correction(p_values: list[float], alpha: float = 0.05) -> list[bool]:
"""Apply Holm-Bonferroni step-down correction.
Args:
p_values: List of p-values.
alpha: Family-wise significance level.
Returns:
List of booleans (True = reject null).
"""
n = len(p_values)
sorted_indices = np.argsort(p_values)
results = [False] * n
for rank, idx in enumerate(sorted_indices):
threshold = alpha / (n - rank)
if p_values[idx] <= threshold:
results[idx] = True
else:
break # Stop at first non-rejection
return results
# ── Demo ────────────────────────────────────────────────────────────
def generate_synthetic_strategies(
n_obs: int = 500,
n_strategies: int = 20,
n_genuine: int = 2,
seed: int = 42,
) -> tuple[np.ndarray, list[str]]:
"""Generate synthetic strategy return streams.
Creates a mix of strategies with zero true alpha (noise) and a few
with small genuine alpha, to test overfitting detection.
Args:
n_obs: Number of daily observations per strategy.
n_strategies: Total number of strategies.
n_genuine: Number of strategies with genuine (small) alpha.
seed: Random seed.
Returns:
Tuple of (returns array [n_obs x n_strategies], strategy names).
"""
rng = np.random.default_rng(seed)
daily_vol = 0.03 # ~57% annualized
returns = np.empty((n_obs, n_strategies))
names: list[str] = []
for i in range(n_strategies):
if i < n_genuine:
# Genuine alpha: small positive drift
drift = 0.0003 # ~11% annualized
returns[:, i] = rng.normal(drift, daily_vol, n_obs)
names.append(f"alpha_{i+1}")
else:
# Zero alpha: pure noise
returns[:, i] = rng.normal(0.0, daily_vol, n_obs)
names.append(f"noise_{i-n_genuine+1}")
return returns, names
def run_demo() -> None:
"""Run the overfit detection demo."""
print("=" * 72)
print("Overfit Detection Demo")
print("=" * 72)
print()
# Generate synthetic strategies
n_strategies = 20
n_genuine = 2
n_obs = 500
returns, names = generate_synthetic_strategies(
n_obs=n_obs,
n_strategies=n_strategies,
n_genuine=n_genuine,
seed=42,
)
print(f"Generated {n_strategies} strategies ({n_genuine} with genuine alpha, "
f"{n_strategies - n_genuine} noise)")
print(f"Each strategy has {n_obs} daily return observations")
print()
# ── Part 1: Compute Sharpe ratios ──────────────────────────────
print("-" * 72)
print("Part 1: Raw Sharpe Ratios (Annualized)")
print("-" * 72)
annualization = np.sqrt(365)
sharpes = []
for i in range(n_strategies):
sr = float(np.mean(returns[:, i]) / np.std(returns[:, i]) * annualization)
sharpes.append(sr)
# Sort by SR for display
sorted_idx = np.argsort(sharpes)[::-1]
print(f"\n{'Rank':>4} {'Strategy':>12} {'Sharpe':>10} {'Type':>8}")
print("-" * 40)
for rank, idx in enumerate(sorted_idx[:10]):
stype = "ALPHA" if idx < n_genuine else "noise"
print(f"{rank+1:>4} {names[idx]:>12} {sharpes[idx]:>10.3f} {stype:>8}")
print(" ...")
print()
best_idx = int(sorted_idx[0])
best_sr = sharpes[best_idx]
best_name = names[best_idx]
print(f"Best strategy: {best_name} with SR = {best_sr:.3f}")
print(f"Is it genuine alpha? {'Yes' if best_idx < n_genuine else 'No — it is noise!'}")
print()
# ── Part 2: Deflated Sharpe Ratio ──────────────────────────────
print("-" * 72)
print("Part 2: Deflated Sharpe Ratio")
print("-" * 72)
best_returns = returns[:, best_idx]
skew = float(np.mean(((best_returns - np.mean(best_returns)) / np.std(best_returns)) ** 3))
kurt = float(np.mean(((best_returns - np.mean(best_returns)) / np.std(best_returns)) ** 4))
dsr_result = deflated_sharpe_ratio(
observed_sr=best_sr,
num_trials=n_strategies,
backtest_length=n_obs,
skewness=skew,
kurtosis=kurt,
annualization=annualization,
)
print(f"\n Observed SR: {dsr_result.observed_sr:.3f}")
print(f" Expected Max SR: {dsr_result.expected_max_sr:.3f}")
print(f" SR Std Error: {dsr_result.sr_std_error:.3f}")
print(f" Strategies tested: {dsr_result.num_trials}")
print(f" Backtest length: {dsr_result.backtest_length} obs")
print(f" Return skewness: {dsr_result.skewness:.3f}")
print(f" Return kurtosis: {dsr_result.kurtosis:.3f}")
print(f" DSR p-value: {dsr_result.dsr_pvalue:.4f}")
print(f" Significant (>0.95)? {'Yes' if dsr_result.is_significant else 'No'}")
print()
if not dsr_result.is_significant:
print(" The DSR says: this Sharpe ratio is not significant after adjusting")
print(" for the number of strategies tested. Likely overfitted.")
else:
print(" The DSR says: this Sharpe ratio remains significant even after")
print(" adjusting for multiple testing.")
print()
# ── Part 3: Probability of Backtest Overfitting ────────────────
print("-" * 72)
print("Part 3: Probability of Backtest Overfitting (PBO)")
print("-" * 72)
pbo_result = probability_of_backtest_overfitting(
strategy_returns=returns,
n_groups=6,
n_test_groups=2,
)
print(f"\n CPCV paths evaluated: {pbo_result.n_paths}")
print(f" Overfit paths: {pbo_result.n_overfit_paths}")
print(f" PBO: {pbo_result.pbo:.3f}")
print(f" Mean OOS rank: {pbo_result.mean_oos_rank:.3f}")
print(f" Is overfit (>0.50)? {'Yes' if pbo_result.is_overfit else 'No'}")
print()
if pbo_result.pbo > 0.50:
print(" PBO > 0.50: Strategy selection process is more likely than not")
print(" to produce overfitted results.")
elif pbo_result.pbo > 0.30:
print(" PBO 0.30-0.50: Elevated overfitting risk. Use additional")
print(" out-of-sample validation before deploying.")
else:
print(" PBO < 0.30: Low overfitting risk. Strategy selection process")
print(" appears to have genuine predictive power.")
print()
# ── Part 4: Minimum Backtest Length ────────────────────────────
print("-" * 72)
print("Part 4: Minimum Backtest Length")
print("-" * 72)
for sr_target, label in [(0.05, "Low SR (daily ~0.05, ann ~0.96)"),
(0.10, "Med SR (daily ~0.10, ann ~1.91)"),
(0.20, "High SR (daily ~0.20, ann ~3.82)")]:
result = minimum_backtest_length(
target_sr=sr_target, confidence=0.95, skewness=skew, kurtosis=kurt
)
years = result.min_length / 365
print(f" {label}: {result.min_length} obs ({years:.1f} years)")
print()
# ── Part 5: Multiple Testing Correction ────────────────────────
print("-" * 72)
print("Part 5: Multiple Testing Correction")
print("-" * 72)
# Compute p-values for each strategy (one-sided test: SR > 0)
p_values: list[float] = []
for i in range(n_strategies):
sr_i = np.mean(returns[:, i]) / np.std(returns[:, i])
se_i = 1.0 / np.sqrt(n_obs)
p = 1.0 - float(norm.cdf(sr_i / se_i))
p_values.append(p)
bonf = bonferroni_correction(p_values, alpha=0.05)
holm_results = holm_correction(p_values, alpha=0.05)
print(f"\n{'Strategy':>12} {'p-value':>10} {'Bonferroni':>12} {'Holm':>8} {'Type':>8}")
print("-" * 56)
for i in sorted_idx[:10]:
stype = "ALPHA" if i < n_genuine else "noise"
print(
f"{names[i]:>12} {p_values[i]:>10.4f} "
f"{'Sig' if bonf[i] else '---':>12} "
f"{'Sig' if holm_results[i] else '---':>8} "
f"{stype:>8}"
)
n_bonf_sig = sum(bonf)
n_holm_sig = sum(holm_results)
print(f"\n Bonferroni significant: {n_bonf_sig}/{n_strategies}")
print(f" Holm significant: {n_holm_sig}/{n_strategies}")
print()
# ── Summary ────────────────────────────────────────────────────
print("=" * 72)
print("Summary")
print("=" * 72)
print(f" Best raw Sharpe: {best_sr:.3f} ({best_name})")
print(f" DSR p-value: {dsr_result.dsr_pvalue:.4f} ({'significant' if dsr_result.is_significant else 'NOT significant'})")
print(f" PBO: {pbo_result.pbo:.3f} ({'overfit' if pbo_result.is_overfit else 'acceptable'})")
print(f" Bonferroni pass: {n_bonf_sig} strategies")
print(f" Holm pass: {n_holm_sig} strategies")
print()
print(" Key takeaway: Always adjust for multiple testing. A Sharpe of")
print(f" {best_sr:.2f} looks great in isolation, but after testing {n_strategies} strategies,")
print(f" the DSR reduces it to a p-value of {dsr_result.dsr_pvalue:.3f}.")
print()
# ── Main ────────────────────────────────────────────────────────────
def main() -> None:
"""Entry point for overfit detection."""
parser = argparse.ArgumentParser(
description="Overfit detection: Deflated Sharpe Ratio and PBO."
)
parser.add_argument(
"--demo",
action="store_true",
help="Run demo with synthetic backtest results.",
)
args = parser.parse_args()
if not args.demo:
print("Run with --demo to see overfit detection on synthetic strategies.")
print("Example: python scripts/overfit_detector.py --demo")
print()
print("Or use the functions programmatically:")
print(" from overfit_detector import deflated_sharpe_ratio, probability_of_backtest_overfitting")
sys.exit(0)
run_demo()
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Walk-forward validation engine with rolling and expanding windows.
Provides a configurable walk-forward splitter that respects time series ordering,
supports purging and embargo, and produces per-fold metrics. Includes a --demo
mode that generates synthetic price data and runs a simple moving-average
crossover strategy through the validator.
Usage:
python scripts/walk_forward.py --demo
python scripts/walk_forward.py --help
Dependencies:
uv pip install numpy pandas
Environment Variables:
None required (--demo mode uses synthetic data).
"""
from __future__ import annotations
import argparse
import dataclasses
import sys
from typing import Iterator, Literal, Optional
import numpy as np
import pandas as pd
# ── Configuration ───────────────────────────────────────────────────
@dataclasses.dataclass
class WalkForwardConfig:
"""Configuration for walk-forward validation.
Attributes:
train_size: Number of bars in the training window.
test_size: Number of bars in the test window.
step_size: Number of bars to advance between folds.
window_type: 'rolling' (fixed train) or 'expanding' (growing train).
purge_size: Number of bars to purge from end of training set
to avoid label leakage.
embargo_size: Number of bars to skip between train and test
to avoid autocorrelation leakage.
"""
train_size: int = 90
test_size: int = 14
step_size: int = 14
window_type: Literal["rolling", "expanding"] = "rolling"
purge_size: int = 0
embargo_size: int = 0
@dataclasses.dataclass
class Fold:
"""A single train/test fold.
Attributes:
fold_idx: Zero-based fold index.
train_indices: Array indices for the training set.
test_indices: Array indices for the test set.
train_start: Datetime of first training bar (if available).
train_end: Datetime of last training bar (if available).
test_start: Datetime of first test bar (if available).
test_end: Datetime of last test bar (if available).
"""
fold_idx: int
train_indices: np.ndarray
test_indices: np.ndarray
train_start: Optional[str] = None
train_end: Optional[str] = None
test_start: Optional[str] = None
test_end: Optional[str] = None
@dataclasses.dataclass
class FoldResult:
"""Performance metrics for a single fold."""
fold_idx: int
train_sharpe: float
test_sharpe: float
train_return: float
test_return: float
test_max_drawdown: float
test_hit_rate: float
n_train: int
n_test: int
# ── Walk-Forward Splitter ───────────────────────────────────────────
class WalkForwardValidator:
"""Walk-forward validation splitter with purging and embargo."""
def __init__(self, config: WalkForwardConfig) -> None:
self.config = config
self._validate_config()
def _validate_config(self) -> None:
"""Validate configuration parameters."""
if self.config.train_size < 10:
raise ValueError("train_size must be >= 10")
if self.config.test_size < 1:
raise ValueError("test_size must be >= 1")
if self.config.step_size < 1:
raise ValueError("step_size must be >= 1")
if self.config.purge_size < 0:
raise ValueError("purge_size must be >= 0")
if self.config.embargo_size < 0:
raise ValueError("embargo_size must be >= 0")
if self.config.window_type not in ("rolling", "expanding"):
raise ValueError("window_type must be 'rolling' or 'expanding'")
def split(
self,
n_samples: int,
dates: Optional[pd.DatetimeIndex] = None,
) -> Iterator[Fold]:
"""Generate walk-forward train/test splits.
Args:
n_samples: Total number of observations.
dates: Optional datetime index for labelling folds.
Yields:
Fold objects with train and test indices.
"""
cfg = self.config
min_required = cfg.train_size + cfg.purge_size + cfg.embargo_size + cfg.test_size
if n_samples < min_required:
raise ValueError(
f"Need at least {min_required} samples, got {n_samples}"
)
fold_idx = 0
offset = 0
while True:
if cfg.window_type == "rolling":
train_start = offset
train_end = offset + cfg.train_size
else: # expanding
train_start = 0
train_end = cfg.train_size + offset
# Apply purge: remove purge_size bars from end of training
effective_train_end = train_end - cfg.purge_size
# Apply embargo: skip embargo_size bars after effective train end
test_start = train_end + cfg.embargo_size
test_end = test_start + cfg.test_size
if test_end > n_samples:
break
train_indices = np.arange(train_start, effective_train_end)
test_indices = np.arange(test_start, test_end)
fold = Fold(
fold_idx=fold_idx,
train_indices=train_indices,
test_indices=test_indices,
)
if dates is not None:
fold.train_start = str(dates[train_start])
fold.train_end = str(dates[effective_train_end - 1])
fold.test_start = str(dates[test_start])
fold.test_end = str(dates[test_end - 1])
yield fold
fold_idx += 1
offset += cfg.step_size
def count_folds(self, n_samples: int) -> int:
"""Return the number of folds without generating them."""
return sum(1 for _ in self.split(n_samples))
# ── Metrics ─────────────────────────────────────────────────────────
def compute_sharpe(returns: np.ndarray, annualization: float = 365.0) -> float:
"""Compute annualized Sharpe ratio from a returns array.
Args:
returns: Array of periodic returns.
annualization: Periods per year (365 for daily crypto).
Returns:
Annualized Sharpe ratio, or 0.0 if std is zero.
"""
if len(returns) < 2 or np.std(returns) == 0:
return 0.0
return float(np.mean(returns) / np.std(returns) * np.sqrt(annualization))
def compute_max_drawdown(returns: np.ndarray) -> float:
"""Compute maximum drawdown from a returns array.
Args:
returns: Array of periodic returns.
Returns:
Maximum drawdown as a negative float (e.g., -0.15 = -15%).
"""
cumulative = np.cumprod(1.0 + returns)
running_max = np.maximum.accumulate(cumulative)
drawdowns = cumulative / running_max - 1.0
return float(np.min(drawdowns))
def compute_hit_rate(predictions: np.ndarray, actuals: np.ndarray) -> float:
"""Compute directional hit rate.
Args:
predictions: Predicted direction signals (positive = long).
actuals: Actual returns.
Returns:
Fraction of correct directional predictions.
"""
if len(predictions) == 0:
return 0.0
correct = np.sign(predictions) == np.sign(actuals)
return float(np.mean(correct))
# ── Demo Strategy ───────────────────────────────────────────────────
def generate_synthetic_prices(
n_bars: int = 500,
seed: int = 42,
base_price: float = 100.0,
annual_return: float = 0.10,
annual_vol: float = 0.80,
) -> pd.DataFrame:
"""Generate synthetic daily price data with realistic crypto volatility.
Args:
n_bars: Number of daily bars to generate.
seed: Random seed for reproducibility.
base_price: Starting price.
annual_return: Annualized drift.
annual_vol: Annualized volatility.
Returns:
DataFrame with 'date', 'close', and 'returns' columns.
"""
rng = np.random.default_rng(seed)
daily_return = annual_return / 365
daily_vol = annual_vol / np.sqrt(365)
# Add regime switching for realism
regime_length = n_bars // 4
returns = np.empty(n_bars)
for i in range(4):
start = i * regime_length
end = (i + 1) * regime_length if i < 3 else n_bars
regime_vol = daily_vol * rng.uniform(0.5, 2.0)
regime_drift = daily_return * rng.uniform(-2.0, 3.0)
segment = rng.normal(regime_drift, regime_vol, end - start)
returns[start:end] = segment
prices = base_price * np.cumprod(1.0 + returns)
dates = pd.date_range("2024-01-01", periods=n_bars, freq="D")
return pd.DataFrame({
"date": dates,
"close": prices,
"returns": returns,
})
def sma_crossover_signals(
prices: np.ndarray,
fast_period: int = 10,
slow_period: int = 30,
) -> np.ndarray:
"""Generate signals from SMA crossover: +1 (long) or -1 (flat/short).
Args:
prices: Array of closing prices.
fast_period: Fast SMA lookback.
slow_period: Slow SMA lookback.
Returns:
Array of signals (+1 or -1), NaN for warmup period.
"""
signals = np.full(len(prices), np.nan)
for i in range(slow_period, len(prices)):
fast_sma = np.mean(prices[i - fast_period : i])
slow_sma = np.mean(prices[i - slow_period : i])
signals[i] = 1.0 if fast_sma > slow_sma else -1.0
return signals
def run_walk_forward(
config: WalkForwardConfig,
df: pd.DataFrame,
fast_period: int = 10,
slow_period: int = 30,
) -> list[FoldResult]:
"""Run walk-forward validation on a SMA crossover strategy.
Args:
config: Walk-forward configuration.
df: DataFrame with 'close' and 'returns' columns.
fast_period: Fast SMA period.
slow_period: Slow SMA period.
Returns:
List of FoldResult for each fold.
"""
validator = WalkForwardValidator(config)
prices = df["close"].values
returns = df["returns"].values
dates = pd.DatetimeIndex(df["date"])
results: list[FoldResult] = []
for fold in validator.split(len(prices), dates):
# Train: compute signals on training data
train_prices = prices[fold.train_indices]
train_returns = returns[fold.train_indices]
train_signals = sma_crossover_signals(train_prices, fast_period, slow_period)
# Only use valid (non-NaN) signals
valid_train = ~np.isnan(train_signals)
train_strat_returns = train_signals[valid_train] * train_returns[valid_train]
# Test: compute signals and returns on test data
# We need lookback prices before test period for SMA computation
lookback_start = max(0, fold.test_indices[0] - slow_period)
extended_prices = prices[lookback_start : fold.test_indices[-1] + 1]
extended_signals = sma_crossover_signals(extended_prices, fast_period, slow_period)
# Extract only the test portion of signals
test_offset = fold.test_indices[0] - lookback_start
test_signals = extended_signals[test_offset:]
test_returns = returns[fold.test_indices]
valid_test = ~np.isnan(test_signals)
if not np.any(valid_test):
continue
test_strat_returns = test_signals[valid_test] * test_returns[valid_test]
result = FoldResult(
fold_idx=fold.fold_idx,
train_sharpe=compute_sharpe(train_strat_returns),
test_sharpe=compute_sharpe(test_strat_returns),
train_return=float(np.sum(train_strat_returns)),
test_return=float(np.sum(test_strat_returns)),
test_max_drawdown=compute_max_drawdown(test_strat_returns),
test_hit_rate=compute_hit_rate(test_signals[valid_test], test_returns[valid_test]),
n_train=int(np.sum(valid_train)),
n_test=int(np.sum(valid_test)),
)
results.append(result)
return results
# ── Display ─────────────────────────────────────────────────────────
def print_results(results: list[FoldResult], config: WalkForwardConfig) -> None:
"""Print walk-forward validation results.
Args:
results: List of per-fold results.
config: The walk-forward configuration used.
"""
print("=" * 72)
print("Walk-Forward Validation Report")
print("=" * 72)
print(f" Window type: {config.window_type}")
print(f" Train size: {config.train_size} bars")
print(f" Test size: {config.test_size} bars")
print(f" Step size: {config.step_size} bars")
print(f" Purge: {config.purge_size} bars")
print(f" Embargo: {config.embargo_size} bars")
print(f" Folds: {len(results)}")
print()
# Per-fold table
print(f"{'Fold':>4} {'Train SR':>10} {'Test SR':>10} {'Test Ret':>10} "
f"{'Test MDD':>10} {'Hit Rate':>10}")
print("-" * 60)
for r in results:
print(
f"{r.fold_idx:>4} "
f"{r.train_sharpe:>10.2f} "
f"{r.test_sharpe:>10.2f} "
f"{r.test_return:>9.2%} "
f"{r.test_max_drawdown:>9.2%} "
f"{r.test_hit_rate:>9.1%}"
)
# Aggregate
print()
train_sharpes = [r.train_sharpe for r in results]
test_sharpes = [r.test_sharpe for r in results]
test_returns = [r.test_return for r in results]
mean_train_sr = np.mean(train_sharpes)
mean_test_sr = np.mean(test_sharpes)
std_test_sr = np.std(test_sharpes)
sr_ratio = mean_train_sr / mean_test_sr if mean_test_sr != 0 else float("inf")
print("Aggregate Metrics:")
print(f" Mean Train Sharpe: {mean_train_sr:.3f}")
print(f" Mean Test Sharpe: {mean_test_sr:.3f}")
print(f" Test Sharpe StdDev: {std_test_sr:.3f}")
print(f" Train/Test SR Ratio: {sr_ratio:.2f}")
print(f" Mean Test Return: {np.mean(test_returns):.2%}")
print(f" Total Test Return: {np.sum(test_returns):.2%}")
print()
# Overfit warning
if sr_ratio > 2.0:
print(" WARNING: Train/Test SR ratio > 2.0 suggests overfitting.")
elif sr_ratio > 1.5:
print(" CAUTION: Train/Test SR ratio > 1.5, moderate overfit risk.")
else:
print(" Train/Test SR ratio looks reasonable.")
print()
# ── Main ────────────────────────────────────────────────────────────
def main() -> None:
"""Entry point for walk-forward validation."""
parser = argparse.ArgumentParser(
description="Walk-forward validation engine for trading strategies."
)
parser.add_argument(
"--demo",
action="store_true",
help="Run demo with synthetic price data and SMA crossover strategy.",
)
parser.add_argument("--train-size", type=int, default=90, help="Training window size (bars).")
parser.add_argument("--test-size", type=int, default=14, help="Test window size (bars).")
parser.add_argument("--step-size", type=int, default=14, help="Step size between folds (bars).")
parser.add_argument(
"--window-type",
choices=["rolling", "expanding"],
default="rolling",
help="Window type: rolling or expanding.",
)
parser.add_argument("--purge", type=int, default=1, help="Purge size (bars).")
parser.add_argument("--embargo", type=int, default=3, help="Embargo size (bars).")
parser.add_argument("--n-bars", type=int, default=500, help="Number of synthetic bars (demo).")
parser.add_argument("--seed", type=int, default=42, help="Random seed (demo).")
args = parser.parse_args()
if not args.demo:
print("Run with --demo to see walk-forward validation on synthetic data.")
print("Example: python scripts/walk_forward.py --demo")
print()
print("Or use WalkForwardValidator programmatically:")
print(" from walk_forward import WalkForwardValidator, WalkForwardConfig")
sys.exit(0)
print("Generating synthetic price data...")
df = generate_synthetic_prices(n_bars=args.n_bars, seed=args.seed)
print(f" {len(df)} daily bars from {df['date'].iloc[0].date()} to {df['date'].iloc[-1].date()}")
print(f" Price range: {df['close'].min():.2f} – {df['close'].max():.2f}")
print()
# Run with rolling window
config_rolling = WalkForwardConfig(
train_size=args.train_size,
test_size=args.test_size,
step_size=args.step_size,
window_type="rolling",
purge_size=args.purge,
embargo_size=args.embargo,
)
print("Running rolling-window walk-forward validation...")
print()
results_rolling = run_walk_forward(config_rolling, df)
print_results(results_rolling, config_rolling)
# Run with expanding window
config_expanding = WalkForwardConfig(
train_size=args.train_size,
test_size=args.test_size,
step_size=args.step_size,
window_type="expanding",
purge_size=args.purge,
embargo_size=args.embargo,
)
print("Running expanding-window walk-forward validation...")
print()
results_expanding = run_walk_forward(config_expanding, df)
print_results(results_expanding, config_expanding)
# Compare
print("=" * 72)
print("Comparison: Rolling vs Expanding")
print("=" * 72)
rolling_sr = np.mean([r.test_sharpe for r in results_rolling])
expanding_sr = np.mean([r.test_sharpe for r in results_expanding])
print(f" Rolling Mean OOS Sharpe: {rolling_sr:.3f}")
print(f" Expanding Mean OOS Sharpe: {expanding_sr:.3f}")
print()
if abs(rolling_sr - expanding_sr) < 0.3:
print(" Results are consistent across window types — good sign.")
else:
print(" Divergence between window types — investigate regime sensitivity.")
print()
if __name__ == "__main__":
main()
Related skills
FAQ
Why not use standard cross-validation?
Random splits introduce lookahead bias and ignore autocorrelation in financial time series, artificially inflating performance.
What is CPCV?
Combinatorial purged cross-validation generates many train/test paths with purging and embargo, enabling statistical overfit tests.