| import pandas as pd |
| import numpy as np |
| from typing import List, Tuple, Generator |
|
|
|
|
| def purge_overlap_and_embargo( |
| train_dates: pd.DatetimeIndex, |
| test_dates: pd.DatetimeIndex, |
| label_horizon: int = 5, |
| embargo_days: int = 5 |
| ) -> pd.DatetimeIndex: |
| """ |
| Purges training dates whose forward labels overlap with the test set, |
| and applies embargo after the test set to eliminate autocorrelation leakage. |
| """ |
| if len(train_dates) == 0 or len(test_dates) == 0: |
| return train_dates |
|
|
| test_start = test_dates.min() |
| test_end = test_dates.max() |
|
|
| |
| purge_cutoff_start = test_start - pd.Timedelta(days=label_horizon * 2) |
| |
| |
| embargo_end = test_end + pd.Timedelta(days=embargo_days * 2) |
|
|
| clean_train_mask = ( |
| (train_dates < purge_cutoff_start) | (train_dates > embargo_end) |
| ) |
|
|
| return train_dates[clean_train_mask] |
|
|
|
|
| class PurgedWalkForwardCV: |
| """ |
| Purged Walk-Forward Cross Validation Generator for Financial Time Series: |
| - Train Window: e.g. 3 Years (756 trading days) |
| - Validation Window: e.g. 6 Months (126 trading days) |
| - Test Window: e.g. 6 Months (126 trading days) |
| - Step Size: 6 Months (126 trading days) |
| - Embargo: 5 trading days |
| """ |
|
|
| def __init__( |
| self, |
| train_days: int = 756, |
| val_days: int = 126, |
| test_days: int = 126, |
| label_horizon: int = 5, |
| embargo_days: int = 5, |
| ): |
| self.train_days = train_days |
| self.val_days = val_days |
| self.test_days = test_days |
| self.label_horizon = label_horizon |
| self.embargo_days = embargo_days |
|
|
| def split(self, df: pd.DataFrame) -> Generator[Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame], None, None]: |
| """ |
| Yields (train_df, val_df, test_df) tuples for each walk-forward fold. |
| """ |
| unique_dates = pd.DatetimeIndex(sorted(df["date"].unique())) |
| total_dates = len(unique_dates) |
|
|
| start = 0 |
| fold_idx = 0 |
|
|
| while start + self.train_days + self.val_days + self.test_days <= total_dates: |
| raw_train_dates = unique_dates[start : start + self.train_days] |
| val_dates = unique_dates[start + self.train_days : start + self.train_days + self.val_days] |
| test_dates = unique_dates[ |
| start + self.train_days + self.val_days : start + self.train_days + self.val_days + self.test_days |
| ] |
|
|
| |
| clean_train_dates = purge_overlap_and_embargo( |
| raw_train_dates, test_dates, label_horizon=self.label_horizon, embargo_days=self.embargo_days |
| ) |
|
|
| train_df = df[df["date"].isin(clean_train_dates)].copy() |
| val_df = df[df["date"].isin(val_dates)].copy() |
| test_df = df[df["date"].isin(test_dates)].copy() |
|
|
| fold_idx += 1 |
| yield train_df, val_df, test_df |
|
|
| start += self.test_days |
|
|