The most common reason a machine learning model shows 0.72 AUC in backtests and 0.51 AUC in production is cross-validation done wrong. Specifically: using sklearn.model_selection.KFold or train_test_split with shuffle=True on time-series data.
This post explains why random k-fold leaks information when your features depend on time, walks through walk-forward validation with concrete fold dates from APIndicators V2, and shows you the exact scikit-learn code to do it correctly.
The Problem: Random K-Fold Leaks the Future
Standard 5-fold cross-validation randomly shuffles your dataset and splits it into 5 chunks. Each chunk takes a turn as the test set while the other 4 train. For tabular data where rows are independent, this is fine.
For time series, it is catastrophic. Here is why.
Say your training row for BTCUSDT at 2026-03-15 10:00 has features like rsi_14, ema_20_minus_ema_50, and returns_last_24h. These features implicitly encode what happened in the hours before. If a later row at 2026-03-15 11:00 ends up in the training set while the 10:00 row ends up in the test set, your training data contains information from after the test row.
Even more subtle: with 5-minute bars and a 24-hour feature window, rows that are 3-6 hours apart share 85%+ of their raw input candles. You are not testing generalization; you are testing interpolation.
The result: your model looks like a genius in CV, fails in production.
Walk-Forward Validation: The Honest Approach
Walk-forward validation respects temporal order. You train on past data, test on future data, then slide the window forward and repeat.
APIndicators V2 uses four walk-forward folds. Each fold trains on an expanding window and tests on the next 5-day block. The fold dates for the most recent retrain:
Fold 1: train [start ... 2026-03-13] | test [2026-03-13 ... 2026-03-18]
Fold 2: train [start ... 2026-03-18] | test [2026-03-18 ... 2026-03-23]
Fold 3: train [start ... 2026-03-23] | test [2026-03-23 ... 2026-03-26]
Fold 4: train [start ... 2026-03-26] | test [2026-03-26 ... 2026-03-31]
Each fold simulates what the model would have seen if retrained on the given date and deployed for the next 5 days. This is what actually happens in production.
Look at how the test AUC varies across folds for our V2 XGBoost BUY model (real numbers from the Mar 31 retrain):
- Fold 1: 0.476 (weak — market was choppy)
- Fold 2: 0.714 (strong — clear trend emerged)
- Fold 3: 0.583
- Fold 4: 0.635
That spread tells you something k-fold never could: the model's edge is regime-dependent. Some weeks it crushes, some weeks it is random. This realism matters.
The Code: scikit-learn's TimeSeriesSplit
scikit-learn ships with TimeSeriesSplit, which implements the expanding-window approach:
import pandas as pd
import numpy as np
from sklearn.model_selection import TimeSeriesSplit
from sklearn.metrics import roc_auc_score
import lightgbm as lgb
df = pd.read_csv("btcusdt_1h.csv", parse_dates=["timestamp"])
df = df.sort_values("timestamp").reset_index(drop=True)
feature_cols = [c for c in df.columns if c not in ["timestamp", "target"]]
X = df[feature_cols].values
y = df["target"].values
tscv = TimeSeriesSplit(n_splits=4, test_size=120)
fold_aucs = []
for fold_idx, (train_idx, test_idx) in enumerate(tscv.split(X)):
X_train, X_test = X[train_idx], X[test_idx]
y_train, y_test = y[train_idx], y[test_idx]
model = lgb.LGBMClassifier(n_estimators=200, learning_rate=0.05)
model.fit(X_train, y_train)
preds = model.predict_proba(X_test)[:, 1]
auc = roc_auc_score(y_test, preds)
fold_aucs.append(auc)
train_end = df.loc[train_idx[-1], "timestamp"]
test_start = df.loc[test_idx[0], "timestamp"]
test_end = df.loc[test_idx[-1], "timestamp"]
print(f"Fold {fold_idx}: train ends {train_end}, test {test_start} -> {test_end}, AUC={auc:.3f}")
print(f"Mean OOS AUC: {np.mean(fold_aucs):.3f} +/- {np.std(fold_aucs):.3f}")
Three things to notice:
- Data is sorted by timestamp before splitting.
TimeSeriesSplitnever shuffles. Test set is always after train set.- We print the actual date ranges so we can eyeball regime shifts.
Gap and Purge: Extra Care for Overlapping Labels
If your label target depends on price movement over the next N candles (a common supervised setup for trading), adjacent rows have overlapping labels. Even walk-forward leaks a bit: the last training row's label peeks into the test period.
Fix: add a gap between train and test equal to the label horizon.
tscv = TimeSeriesSplit(n_splits=4, test_size=120, gap=24)
A gap=24 with 1-hour candles removes 24 hours between train and test, eliminating label overlap for a label defined as "does price move +/- 2% in the next 24 hours."
Weighting Folds by Recency
When you combine fold predictions into an ensemble or meta-model, the most recent folds reflect the current market regime best. APIndicators V2 weights each model by its out-of-sample AUC on the most recent fold, not the mean. If fold 4 AUC is NaN (rare edge case with tiny fold sizes), we fall back to equal 1/3 weights.
def weight_by_last_fold(fold_aucs):
last = fold_aucs[-1]
if np.isnan(last):
return 1.0 / 3.0
return max(0.0, last - 0.5)
This subtly biases the ensemble toward whatever works now, without overfitting to a single period.
Practical Checklist
- Never shuffle time-series data for CV.
- Sort by timestamp before splitting.
- Use at least 4 walk-forward folds to see regime variability.
- Add a
gapequal to your label horizon to prevent label leakage. - Report the spread across folds, not just the mean.
- Expect production to look like the worst fold, not the average.
APIndicators publishes V2 and V3 model fold AUCs after every retrain, so subscribers can see exactly where the models are strong and where they are fragile. Sign up at apindicators.com/pricing or read more about our architecture at apindicators.com/docs.