Files
2026-07-13 13:26:28 +08:00

983 lines
30 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ---
# jupyter:
# jupytext:
# cell_metadata_filter: tags,-all
# text_representation:
# extension: .py
# format_name: percent
# format_version: '1.3'
# jupytext_version: 1.19.3
# kernelspec:
# display_name: Python 3 (ipykernel)
# language: python
# name: python3
# ---
# %% [markdown]
# # Cross-Validation for Financial Machine Learning
#
# **ML4T Third Edition — Chapter 6: Strategy Definition**
#
# **Docker image**: `ml4t`
#
# This notebook builds cross-validation from first principles for time series:
#
# 1. **Decision-time admissibility** — the central design constraint
# 2. **Three dataset roles** — train, validation, and holdout test
# 3. **K-fold CV** — and why random shuffling fails for time series
# 4. **Walk-forward CV** — expanding and rolling windows
# 5. **Label buffer (purging)** — preventing forward-looking label leakage
# 6. **Feature buffer (embargo)** — preventing backward-looking feature leakage
# 7. **Calendar-aware CV** — trading days vs calendar days
# 8. **Nested walk-forward** — retuning across multiple test years
# 9. **Combinatorial purged CV (CPCV)** — multiple backtest paths
# 10. **Putting it together** — from config to protocol
#
# **Book Reference**: Section 6.5 (Evaluation Protocol for Time Series)
#
# **References**:
# - López de Prado (2018). *Advances in Financial Machine Learning*, Ch. 7
# - Bailey, Borwein, López de Prado, and Zhu (2014). "The Probability of Backtest Overfitting"
# %%
"""Cross-validation foundations for Chapter 6."""
import warnings
from math import comb
import exchange_calendars as xcals
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import plotly.graph_objects as go
from matplotlib.patches import Patch, Rectangle
from ml4t.diagnostic.splitters import CombinatorialCV, WalkForwardCV
from ml4t.diagnostic.splitters.config import WalkForwardConfig
from plotly.subplots import make_subplots
from sklearn.model_selection import KFold
from utils.modeling import get_cv_config
from utils.reproducibility import set_global_seeds
warnings.filterwarnings("ignore")
# %% tags=["parameters"]
N_VIZ = 504
SEED = 42
# %%
set_global_seeds(SEED)
# %% [markdown]
# ---
#
# ## Data Setup
#
# We use 12 years of NYSE trading sessions (20142025) throughout.
# %%
nyse = xcals.get_calendar("XNYS")
sessions = nyse.sessions_in_range("2014-01-01", "2025-12-31")
dates = pd.DatetimeIndex(sessions, tz="UTC")
df_dates = pd.DataFrame({"idx": np.arange(len(dates))}, index=dates)
print(f"NYSE sessions 20142025: {len(dates):,}")
# %% [markdown]
# ### Visualization Helper
# %%
def plot_splits(splits, dates, *, title="", figsize=(12, 3.5)):
"""Plot walk-forward splits as horizontal bars with real dates."""
n_folds = len(splits)
fig, ax = plt.subplots(figsize=figsize)
for i, (tr, va) in enumerate(splits):
y = n_folds - i
tr_start, tr_end = dates[tr[0]], dates[tr[-1]]
va_start, va_end = dates[va[0]], dates[va[-1]]
# Detect purge gap (more than 1 index between train end and val start)
gap = va[0] - tr[-1]
ax.barh(y, tr_end - tr_start, left=tr_start, height=0.6, color="0.75", edgecolor="0.5")
ax.barh(y, va_end - va_start, left=va_start, height=0.6, color="0.35", edgecolor="0.2")
if gap > 2:
ax.barh(
y,
va_start - tr_end,
left=tr_end,
height=0.6,
color="0.9",
edgecolor="0.7",
hatch="//",
linewidth=0.5,
)
ax.set_yticks(range(1, n_folds + 1))
ax.set_yticklabels([f"Fold {n_folds - i}" for i in range(n_folds)])
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
ax.xaxis.set_major_locator(mdates.YearLocator(2))
ax.set_title(title)
handles = [
Patch(facecolor="0.75", label="Training"),
Patch(facecolor="0.35", label="Validation"),
]
if any(va[0] - tr[-1] > 2 for tr, va in splits):
handles.insert(1, Patch(facecolor="0.9", edgecolor="0.7", hatch="//", label="Label buffer"))
ax.legend(handles=handles, loc="upper right")
fig.tight_layout()
return fig
# %% [markdown]
# ---
#
# ## 1. Decision-Time Admissibility
#
# The central question behind every CV design choice:
#
# > **At decision time $t$, which labeled samples were actually available?**
#
# Any sample whose label, feature, or selection criterion depends on
# information unavailable at $t$ must be excluded from training.
# Section 6.5 identifies five channels through which future information can
# leak into past decisions:
#
# | Leakage Channel | Example | Prevention |
# |---|---|---|
# | **Label leakage** | 21-day forward return overlaps validation | Label buffer (purging) |
# | **Standardization leakage** | Z-scoring with full-sample mean/std | Expanding-window transforms |
# | **Threshold leakage** | Percentile labels from full sample | Rolling percentile thresholds |
# | **Survivorship leakage** | Training only on surviving firms | Rebalance universe per period |
# | **Point-in-time leakage** | Fundamentals reported after decision | Conservative publication lag |
#
# The CV schemes in this notebook operationalize the first channel (label
# leakage via the **label buffer**) and flag where the others apply.
# %% [markdown]
# ---
#
# ## 2. Three Dataset Roles
#
# Before any experiment, partition data into three disjoint sets:
#
# | Dataset | Role | When Accessed |
# |---|---|---|
# | **Training** | Fit model parameters | During training |
# | **Validation** | Select hyperparameters, compare models | During development |
# | **Holdout Test** | Final unbiased performance estimate | Once, at the end |
#
# The **holdout test set is sealed** from the start. Using it for any
# development decision contaminates the final estimate.
# %%
# Partition: Train 5Y | Validation 5Y | Holdout 2Y
train_end = pd.Timestamp("2018-12-31", tz="UTC")
val_end = pd.Timestamp("2023-12-31", tz="UTC")
train_mask = dates <= train_end
val_mask = (dates > train_end) & (dates <= val_end)
test_mask = dates > val_end
partition_df = pd.DataFrame(
{
"Period": ["Training", "Validation", "Holdout Test"],
"Start": [
dates[0].date(),
dates[train_mask.sum()].date(),
dates[train_mask.sum() + val_mask.sum()].date(),
],
"End": [train_end.date(), val_end.date(), dates[-1].date()],
"Trading Days": [train_mask.sum(), val_mask.sum(), test_mask.sum()],
}
)
partition_df
# %% [markdown]
# **Rule**: The holdout test set is NEVER used for model selection,
# hyperparameter tuning, or any development decision.
# %% [markdown]
# ---
#
# ## 3. K-Fold Cross-Validation
#
# Standard k-fold CV shuffles samples randomly and rotates the held-out fold.
# This works for i.i.d. data but **destroys temporal structure** in time series:
# the model trains on future data to predict the past.
# %%
n_samples = 100
sample_indices = np.arange(n_samples)
kfold = KFold(n_splits=5, shuffle=True, random_state=SEED)
fig, axes = plt.subplots(5, 1, figsize=(12, 5), sharex=True)
for fold_idx, (train_idx, val_idx) in enumerate(kfold.split(sample_indices)):
ax = axes[fold_idx]
colors = np.zeros(n_samples)
colors[val_idx] = 1
for i in range(n_samples):
color = "0.3" if colors[i] == 1 else "0.8"
ax.bar(i, 1, width=1, color=color, edgecolor="none")
ax.set_ylabel(f"Fold {fold_idx + 1}", rotation=0, labelpad=30, va="center")
ax.set_ylim(0, 1)
ax.set_yticks([])
ax.set_xlim(-0.5, n_samples - 0.5)
axes[-1].set_xlabel("Sample Index (Time)")
axes[0].set_title("K-Fold CV: Random Train/Validation Distribution")
axes[0].legend(
handles=[Patch(facecolor="0.8", label="Training"), Patch(facecolor="0.3", label="Validation")],
loc="upper right",
fontsize=8,
)
fig.tight_layout()
fig.show()
# %% [markdown]
# In Fold 1, the model trains on samples from the future (indices 80100) to
# predict the past. This violates decision-time admissibility.
# %% [markdown]
# ---
#
# ## 4. Walk-Forward Cross-Validation
#
# **Walk-forward CV** respects temporal ordering: training always **precedes**
# validation. Two variants — **expanding** and **rolling** windows.
#
# We use `WalkForwardCV` from `ml4t.diagnostic.splitters` to generate the
# actual folds on NYSE trading sessions.
# %% [markdown]
# ### Expanding Window
#
# Training grows with each fold — uses all available history.
# %%
cv_exp = WalkForwardCV(n_splits=5, test_size=252, expanding=True)
splits_exp = list(cv_exp.split(df_dates))
fig = plot_splits(splits_exp, dates, title="Expanding Window Walk-Forward CV")
fig.show()
# %% [markdown]
# **Advantage**: Uses all available data. **Disadvantage**: Training set size
# varies, which can affect model behavior.
# %% [markdown]
# ### Rolling Window
#
# Fixed training window — old data drops off as we move forward.
# %%
cv_roll = WalkForwardCV(n_splits=5, test_size=252, train_size=1260, expanding=False)
splits_roll = list(cv_roll.split(df_dates))
fig = plot_splits(splits_roll, dates, title="Rolling Window Walk-Forward CV")
fig.show()
# %% [markdown]
# **Advantage**: Consistent training size; stale data doesn't influence the model.
# **Disadvantage**: Discards data. Choose expanding if older data is still
# relevant, rolling if you believe regimes change.
# %% [markdown]
# ---
#
# ## 5. Label Buffer (Purging)
#
# Walk-forward CV respects temporal order, but there's a subtler problem:
# **labels take time to materialize**. A 21-day forward return label computed
# at time $t$ uses prices from $t$ to $t+21$. If validation starts at $t+10$,
# the training label has "seen" 11 validation-period prices.
#
# This is **label leakage** — the most common form of look-ahead bias.
# %% [markdown]
# ### Visualizing the Overlap Problem
# %%
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=[52],
y=[0.5],
mode="markers",
marker=dict(size=20, color="steelblue"),
name="Training sample",
)
)
fig.add_trace(
go.Scatter(
x=[52, 57],
y=[0.4, 0.4],
mode="lines+markers",
line=dict(color="steelblue", width=3),
marker=dict(size=10),
name="Label horizon (5 days)",
)
)
fig.add_vrect(x0=53, x1=60, fillcolor="coral", opacity=0.2, line_width=0)
fig.add_annotation(x=56.5, y=0.7, text="Validation Set", showarrow=False, font=dict(size=12))
fig.add_vrect(x0=53, x1=55, fillcolor="yellow", opacity=0.5, line_width=0)
fig.add_annotation(
x=54,
y=0.25,
text="LEAKAGE!",
showarrow=False,
font=dict(color="red", size=16, family="Arial Black"),
)
fig.update_layout(
title="Label Overlap Creates Information Leakage",
xaxis_title="Day",
yaxis_visible=False,
template="plotly_white",
height=300,
showlegend=True,
)
fig.show()
# %% [markdown]
# ### The Solution: Label Buffer (Purge Gap)
#
# End training at least $h$ samples before validation starts, where $h$ is the
# label horizon. López de Prado (2018) calls this **purging**.
#
# $$\text{Training End} \leq \text{Validation Start} - h$$
# %%
fig = go.Figure()
fig.add_vrect(x0=30, x1=47, fillcolor="steelblue", opacity=0.3, line_width=0)
fig.add_annotation(
x=38.5, y=0.8, text="Training", showarrow=False, font=dict(size=12, color="steelblue")
)
fig.add_vrect(x0=48, x1=52, fillcolor="gray", opacity=0.2, line_width=0)
fig.add_annotation(
x=50, y=0.5, text="Label Buffer\n(5 days)", showarrow=False, font=dict(size=10, color="gray")
)
fig.add_vrect(x0=53, x1=70, fillcolor="coral", opacity=0.3, line_width=0)
fig.add_annotation(
x=61.5, y=0.8, text="Validation", showarrow=False, font=dict(size=12, color="coral")
)
fig.add_trace(
go.Scatter(
x=[47],
y=[0.3],
mode="markers+text",
marker=dict(size=15, color="steelblue"),
text=["Last train sample"],
textposition="middle left",
name="Training",
showlegend=False,
)
)
fig.add_trace(
go.Scatter(
x=[47, 52],
y=[0.3, 0.3],
mode="lines+markers",
line=dict(color="steelblue", width=2, dash="dot"),
marker=dict(size=8),
name="Label (safe)",
)
)
fig.update_layout(
title="Label Buffer Prevents Leakage",
xaxis_title="Day",
yaxis_visible=False,
template="plotly_white",
height=300,
showlegend=True,
)
fig.show()
# %% [markdown]
# ### Walk-Forward CV with Label Buffer
#
# `WalkForwardCV` implements label buffer via the `label_horizon` parameter.
# With `calendar='XNYS'`, the gap is counted in **NYSE trading days**.
# %%
# Expanding with 21-day label buffer
cv_purge_exp = WalkForwardCV(
n_splits=5,
test_size=252,
expanding=True,
label_horizon=21,
calendar="XNYS",
)
splits_purge_exp = list(cv_purge_exp.split(df_dates))
fig = plot_splits(
splits_purge_exp, dates, title="Expanding Window with Label Buffer (21 trading days)"
)
fig.show()
# %%
# Rolling with 21-day label buffer
cv_purge_roll = WalkForwardCV(
n_splits=5,
test_size=252,
train_size=1260,
expanding=False,
label_horizon=21,
calendar="XNYS",
)
splits_purge_roll = list(cv_purge_roll.split(df_dates))
fig = plot_splits(
splits_purge_roll, dates, title="Rolling Window with Label Buffer (21 trading days)"
)
fig.show()
# %% [markdown]
# The hatched regions are **label buffer gaps**: 21 NYSE trading days where
# training labels would overlap with validation. These samples are excluded
# from training but not used for validation — they are simply discarded.
# %% [markdown]
# ---
#
# ## 6. Feature Buffer (Embargo)
#
# The label buffer prevents **forward-looking** leakage: training labels that
# peek into the validation period. But there is a second leakage channel:
# **backward-looking features** computed from validation data that bleed into
# training.
#
# This matters in **CPCV** and **k-fold** schemes where a training block can
# appear *after* a validation block in calendar time. A 60-day rolling feature
# computed for the first training sample after the validation block uses 60 days
# of validation-period prices.
#
# López de Prado (2018) calls this the **embargo**.
#
# | Buffer | Direction | Protects Against | When It Matters |
# |---|---|---|---|
# | **Label buffer** (purge) | Forward-looking | Training labels peeking into validation | Always |
# | **Feature buffer** (embargo) | Backward-looking | Training features using validation data | CPCV, k-fold |
#
# In pure walk-forward CV (training always precedes validation), the feature
# buffer is **zero** because no training sample follows a validation block.
# %%
fig, axes = plt.subplots(1, 2, figsize=(13, 3.5))
# Panel (a): Label buffer — forward-looking
ax = axes[0]
ax.barh(1, 5, left=0, height=0.6, color="0.75", edgecolor="0.5")
ax.barh(1, 1.5, left=5, height=0.6, color="0.9", edgecolor="0.7", hatch="//", linewidth=0.5)
ax.barh(1, 3, left=6.5, height=0.6, color="0.35", edgecolor="0.2")
ax.annotate(
"", xy=(6.3, 0.55), xytext=(5.2, 0.55), arrowprops=dict(arrowstyle="->", color="0.4", lw=1.5)
)
ax.text(5.75, 0.45, "label horizon", ha="center", va="center", fontsize=8, color="0.4")
ax.set_xlim(-0.5, 10)
ax.set_ylim(0.3, 1.7)
ax.set_yticks([])
ax.set_title("(a) Label Buffer (Purge)")
ax.legend(
handles=[
Patch(facecolor="0.75", label="Train"),
Patch(facecolor="0.9", hatch="//", label="Buffer"),
Patch(facecolor="0.35", label="Validation"),
],
fontsize=7,
loc="upper right",
)
# Panel (b): Feature buffer — backward-looking (CPCV scenario)
ax = axes[1]
ax.barh(1, 3, left=0, height=0.6, color="0.35", edgecolor="0.2")
ax.barh(1, 1.5, left=3, height=0.6, color="0.9", edgecolor="0.7", hatch="\\\\", linewidth=0.5)
ax.barh(1, 5, left=4.5, height=0.6, color="0.75", edgecolor="0.5")
ax.annotate(
"", xy=(3.2, 0.55), xytext=(4.3, 0.55), arrowprops=dict(arrowstyle="->", color="0.4", lw=1.5)
)
ax.text(3.75, 0.45, "feature lookback", ha="center", va="center", fontsize=8, color="0.4")
ax.set_xlim(-0.5, 10)
ax.set_ylim(0.3, 1.7)
ax.set_yticks([])
ax.set_title("(b) Feature Buffer (Embargo)")
ax.legend(
handles=[
Patch(facecolor="0.35", label="Validation"),
Patch(facecolor="0.9", hatch="\\\\", label="Buffer"),
Patch(facecolor="0.75", label="Train"),
],
fontsize=7,
loc="upper right",
)
fig.tight_layout()
fig.show()
# %% [markdown]
# **(a)** In walk-forward, training precedes validation. The label buffer
# removes training samples whose labels extend into the validation period.
#
# **(b)** In CPCV, a training block can follow a validation block. The feature
# buffer removes training samples whose backward-looking features use
# validation data.
# %% [markdown]
# ---
#
# ## 7. Calendar-Aware Cross-Validation
#
# A **critical subtlety**: financial markets don't trade every calendar day.
# The NYSE has ~252 trading days per year, not 365. When we specify a label
# horizon of 21 days for monthly forward returns, we mean **21 trading days**.
# %% [markdown]
# ### The Problem: Naive Calendar-Day Purging
#
# January 2024 has 31 calendar days but only 21 NYSE trading days.
# A naive 21-calendar-day purge before February 1 only removes 15 trading
# days — allowing 6 trading days of leakage.
# %%
fig, ax = plt.subplots(figsize=(12, 4))
jan_dates = pd.date_range("2024-01-01", "2024-01-31", freq="D")
holidays = pd.to_datetime(["2024-01-01", "2024-01-15"]) # New Year's, MLK Day
for i, d in enumerate(jan_dates):
is_weekend = d.dayofweek >= 5
is_holiday = d in holidays
is_non_trading = is_weekend or is_holiday
color = "0.9" if is_non_trading else "white"
edge = "0.7" if is_non_trading else "0.4"
rect = Rectangle((i, 0), 0.9, 0.9, facecolor=color, edgecolor=edge, linewidth=0.5)
ax.add_patch(rect)
ax.text(i + 0.45, 0.45, str(d.day), ha="center", va="center", fontsize=7)
# Naive purge region (Jan 11-31 = 21 calendar days before Feb 1)
for i in range(10, 31):
rect = Rectangle(
(i, 0),
0.9,
0.9,
facecolor="0.55",
edgecolor="0.3",
linewidth=1.2,
alpha=0.6,
zorder=2,
)
ax.add_patch(rect)
# Correct additional purge (Jan 2-10)
for i in range(1, 10):
rect = Rectangle(
(i, 0),
0.9,
0.9,
facecolor="0.8",
edgecolor="0.5",
linewidth=1.2,
alpha=0.6,
zorder=2,
)
ax.add_patch(rect)
ax.text(
15.5,
1.4,
"Naive: 21 calendar days (only 15 trading days!)",
ha="center",
fontsize=9,
fontweight="bold",
)
ax.text(5, -1.2, "Correct: extend purge to get\n21 TRADING days", ha="center", fontsize=8)
ax.set_xlim(-0.5, 31.5)
ax.set_ylim(-1.6, 1.9)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title("January 2024: Naive vs Trading-Day-Aware Purging", pad=14)
ax.legend(
handles=[
Rectangle((0, 0), 1, 1, facecolor="0.55", edgecolor="0.3", label="Naive purge only"),
Rectangle((0, 0), 1, 1, facecolor="0.8", edgecolor="0.5", label="Additional correct purge"),
Rectangle((0, 0), 1, 1, facecolor="0.9", edgecolor="0.7", label="Non-trading day"),
],
loc="lower right",
bbox_to_anchor=(1.0, -0.05),
fontsize=7,
ncol=3,
)
fig.show()
# %% [markdown]
# **Rule**: Always count purge gaps in **trading days**, not calendar days.
# `WalkForwardCV` handles this automatically when given a `calendar` parameter.
# %% [markdown]
# ### Calendar-Aware Splits in Practice
#
# Passing `calendar='XNYS'` ensures `WalkForwardCV` counts the label buffer
# in NYSE trading days, skipping weekends and holidays.
# %%
# Calendar-aware splits: 21 trading-day label buffer
cv_nyse = WalkForwardCV(
n_splits=3, test_size=252, expanding=True, label_horizon=21, calendar="XNYS"
)
splits_nyse = list(cv_nyse.split(df_dates))
print("Walk-forward with 21 trading-day label buffer (NYSE calendar):\n")
for i, (tr, va) in enumerate(splits_nyse):
gap_sessions = va[0] - tr[-1]
train_end_date = dates[tr[-1]]
val_start_date = dates[va[0]]
gap_calendar = (val_start_date - train_end_date).days
print(f" Fold {i + 1}: train ends {train_end_date.date()}, val starts {val_start_date.date()}")
print(f" gap = {gap_sessions} trading days ({gap_calendar} calendar days)")
# %% [markdown]
# Each fold's label buffer spans 22 trading sessions, which covers ~30 calendar
# days due to weekends and holidays. A naive implementation that counts 21
# *calendar* days would only purge ~15 trading days — leaving 6 days of label
# leakage. The `calendar='XNYS'` parameter ensures the gap is measured in
# actual trading days.
# %% [markdown]
# ---
#
# ## 8. Nested Walk-Forward
#
# Standard walk-forward CV tunes hyperparameters on one validation period
# (e.g., 20192023), then tests once on the holdout (20242025). By the time
# we reach 2025, those hyperparameters may be stale.
#
# **Nested walk-forward** adds an **outer loop** that rolls the test window
# forward, retuning hyperparameters at each step.
#
# - **Inner loop**: Walk-forward CV selects hyperparameters $\lambda^*$
# - **Outer loop**: Advances the test window and reruns the inner loop
#
# This produces **multiple test points**, each with freshly tuned
# hyperparameters — capturing tuning instability over time.
# %%
# Two outer folds: test on 2024 (tune on 2019-2023), test on 2025 (tune on 2020-2024)
nested_folds = []
for fold in range(5):
val_year = 2019 + fold
nested_folds.append(
{
"Outer": 1,
"Inner Fold": fold + 1,
"Train": f"2014{val_year - 1}",
"Validation": str(val_year),
"Test": 2024,
}
)
for fold in range(5):
val_year = 2020 + fold
nested_folds.append(
{
"Outer": 2,
"Inner Fold": fold + 1,
"Train": f"2014{val_year - 1}",
"Validation": str(val_year),
"Test": 2025,
}
)
pd.DataFrame(nested_folds)
# %% [markdown]
# ### Nested Walk-Forward Timeline
#
# Each outer fold runs a full inner walk-forward CV to select
# $\lambda^*$, then evaluates on its test year.
# %%
fig, ax = plt.subplots(figsize=(13, 4.5))
outer_folds = [
{"test_year": 2024, "val_years": list(range(2019, 2024)), "color_test": "0.2"},
{"test_year": 2025, "val_years": list(range(2020, 2025)), "color_test": "0.2"},
]
y = 0
outer_boundaries = []
for oi, outer in enumerate(outer_folds):
# Inner folds
for fi, val_yr in enumerate(outer["val_years"]):
y += 1
train_s = pd.Timestamp("2014-01-02", tz="UTC")
train_e = pd.Timestamp(f"{val_yr - 1}-12-31", tz="UTC")
val_s = pd.Timestamp(f"{val_yr}-01-02", tz="UTC")
val_e = pd.Timestamp(f"{val_yr}-12-31", tz="UTC")
ax.barh(y, train_e - train_s, left=train_s, height=0.5, color="0.8", edgecolor="0.6")
ax.barh(y, val_e - val_s, left=val_s, height=0.5, color="0.5", edgecolor="0.3")
# Test bar (spans all inner folds visually)
test_s = pd.Timestamp(f"{outer['test_year']}-01-02", tz="UTC")
test_e = pd.Timestamp(f"{outer['test_year']}-12-31", tz="UTC")
y_mid = y - len(outer["val_years"]) / 2 + 0.5
ax.barh(
y_mid,
test_e - test_s,
left=test_s,
height=len(outer["val_years"]) * 0.55,
color="0.2",
edgecolor="0.1",
alpha=0.3,
)
ax.text(
test_s + (test_e - test_s) / 2,
y_mid,
f"Test {outer['test_year']}",
ha="center",
va="center",
fontsize=9,
fontweight="bold",
color="white",
)
# Outer-fold group label (left of training bars)
ax.text(
pd.Timestamp("2013-04-01", tz="UTC"),
y_mid,
f"Outer\nfold {oi + 1}",
ha="right",
va="center",
fontsize=8,
fontweight="bold",
color="0.3",
)
outer_boundaries.append(y + 0.5)
# Separator between outer folds
if oi < len(outer_folds) - 1:
y += 1.0
# Draw horizontal dividers between outer folds
for boundary in outer_boundaries[:-1]:
ax.axhline(boundary + 0.5, color="0.5", linestyle="--", linewidth=0.8, alpha=0.7)
ax.set_yticks([])
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
ax.xaxis.set_major_locator(mdates.YearLocator(2))
ax.set_title("Nested Walk-Forward: 2 Outer Folds × 5 Inner Folds")
ax.legend(
handles=[
Patch(facecolor="0.8", label="Train (inner)"),
Patch(facecolor="0.5", label="Validation (inner)"),
Patch(facecolor="0.2", alpha=0.3, label="Test (outer)"),
],
loc="upper left",
)
fig.tight_layout()
fig.show()
# %% [markdown]
# Each outer fold produces test predictions with freshly selected $\lambda^*$.
# If $\lambda^*_1 \neq \lambda^*_2$, that signals hyperparameter instability —
# a red flag for production deployment.
# %% [markdown]
# ---
#
# ## 9. Combinatorial Purged CV (CPCV)
#
# Walk-forward CV produces **one backtest path** — a single sequence of
# out-of-sample predictions. That path might reflect luck.
#
# **CPCV** divides time into $N$ contiguous blocks, holds out $k$ blocks for
# validation, trains on the remaining $N - k$ (with purging at boundaries),
# and repeats for all $\binom{N}{k}$ combinations. The validation predictions
# are then assembled into **multiple complete backtest paths**, each covering
# every time block exactly once.
#
# Each path is a complete out-of-sample backtest — one prediction per block,
# assembled from different splits so no block's prediction depends on its own
# training data.
# %%
N, K = 6, 2
n_splits = comb(N, K) # C(6,2) = 15
n_paths = (K * n_splits) // N # = 5
print(f"N={N} blocks, k={K} held out → {n_splits} splits, {n_paths} backtest paths")
# %%
# Visualize first 4 splits
set_global_seeds(SEED)
X_viz = np.random.randn(N_VIZ, 5)
y_viz = np.random.randn(N_VIZ)
cv_cpcv = CombinatorialCV(n_groups=6, n_test_groups=2, label_horizon=5, embargo_size=2)
splits_cpcv = list(cv_cpcv.split(X_viz))
fig = make_subplots(rows=4, cols=1, shared_xaxes=True, vertical_spacing=0.08)
for split_idx in range(min(4, len(splits_cpcv))):
train_idx, val_idx = splits_cpcv[split_idx]
split_type = np.zeros(N_VIZ)
split_type[train_idx] = 1
split_type[val_idx] = 2
for stype, (color, label) in enumerate(
[("gray", "Purged"), ("steelblue", "Training"), ("coral", "Validation")]
):
mask = split_type == stype
if np.any(mask):
fig.add_trace(
go.Scatter(
x=np.arange(N_VIZ)[mask],
y=y_viz[mask],
mode="markers",
marker=dict(color=color, size=6),
name=label if split_idx == 0 else None,
showlegend=(split_idx == 0),
),
row=split_idx + 1,
col=1,
)
fig.update_yaxes(title_text=f"Split {split_idx + 1}", row=split_idx + 1, col=1)
fig.update_layout(
title=f"CPCV: First 4 of {n_splits} Splits (N={N}, k={K})",
height=500,
width=900,
template="plotly_white",
showlegend=True,
margin=dict(l=80),
)
fig.update_xaxes(title_text="Sample Index", row=4, col=1)
fig.update_yaxes(title_standoff=8)
fig.show()
# %% [markdown]
# Gray regions are samples removed by the label buffer (purge) and feature
# buffer (embargo) at block boundaries. With $N=6$, $k=2$: 15 splits produce
# 5 independent backtest paths.
#
# If all paths show similar Sharpe ratios → robust. If they vary wildly →
# path-dependent (possibly overfit). Bailey et al. (2014) formalize this into
# the **Probability of Backtest Overfitting (PBO)** — the fraction of paths
# whose in-sample rank doesn't hold out-of-sample. We return to PBO in
# Chapter 17 when assembling full backtest results.
# %% [markdown]
# ---
#
# ## 10. Putting It Together
#
# ### CV Method Comparison
#
# | Method | Paths | Label Buffer | Feature Buffer | When to Use |
# |---|---|---|---|---|
# | Walk-Forward | 1 | Yes | No (train < val) | Standard evaluation |
# | Nested Walk-Forward | 1 per test year | Yes | No | Multi-year test with retuning |
# | CPCV | Multiple | Yes | Yes | Robustness testing (Ch17) |
# %% [markdown]
# ### From Config to Protocol
#
# `WalkForwardConfig` encodes all CV design decisions in a single object.
# Each case study's `config/setup.yaml` stores these commitments; downstream
# notebooks load them via `get_cv_config()`.
# %%
config = WalkForwardConfig(
n_splits=5,
test_size=252,
train_size=1260,
label_horizon=21,
fold_direction="forward",
calendar_id="NYSE",
)
config_table = pd.DataFrame(
{
"Parameter": list(config.model_dump().keys()),
"Value": [str(v) for v in config.model_dump().values()],
}
)
config_table
# %% [markdown]
# ### Loading a Case Study Protocol
#
# Each case study stores its CV protocol in `case_studies/{id}/config/setup.yaml`.
# The `get_cv_config()` function loads it into a `WalkForwardConfig`.
# %%
etf_config = get_cv_config("etfs")
print("ETF case study CV protocol:")
for k, v in etf_config.model_dump().items():
print(f" {k}: {v}")
# %% [markdown]
# This protocol specifies 8 walk-forward splits with 10-year rolling
# training windows, 1-year test windows, and a 21-trading-day label buffer
# (matching the 1-month forward return labels). The holdout period
# (20242025) is sealed for final confirmation.
# %% [markdown]
# ---
#
# ## Key Takeaways
#
# ### Decision-Time Admissibility
# - The central constraint: only information available at decision time $t$
# can enter training for predictions at $t$
# - Five leakage channels: label, standardization, threshold, survivorship,
# point-in-time
#
# ### Walk-Forward CV
# - Training always precedes validation (respects temporal order)
# - Expanding (all history) vs rolling (fixed window) — depends on regime beliefs
#
# ### Label Buffer (Purging)
# - Forward-looking labels can leak validation data into training
# - Remove training samples within one label horizon of validation start
# - Count in **trading days** using a proper market calendar
#
# ### Feature Buffer (Embargo)
# - Backward-looking features can leak validation data into training
# - Matters in CPCV / k-fold where training follows validation in time
# - Zero in pure walk-forward (training always precedes validation)
#
# ### Nested Walk-Forward
# - Outer loop advances test window; inner loop retunes hyperparameters
# - Captures tuning instability — $\lambda^*$ should be stable across periods
#
# ### Combinatorial Purged CV
# - Multiple backtest paths from the same data reveal robustness
# - Connects to Probability of Backtest Overfitting (Bailey et al. 2014)
# - Full treatment in Chapter 17
#
# **Next**: See `case_studies/*/01_feasibility_analysis.py` for applying these concepts
# to real trading strategies.