import pandas as pd
import qrt as qTime-series split
q.dataset.TimeSeriesSplit creates repeated chronological train/test folds using sklearn.model_selection.TimeSeriesSplit. This notebook starts with every default, then changes one setting at a time to control the number of folds, test-window length, train/test separation, training-window length, and scheme name.
Create a small ordered dataset
A time-series split needs only an ordered Dataset. Targets, weights, and row metadata are optional, so this example uses the final 24 sessions of QRT’s bundled SPY history and one feature column. Twenty-four rows make the default fold sizes easy to inspect.
spy = q.data.datasets.load("spy").tail(24)
dataset = q.dataset.Dataset(X=spy[["close"]])
assert dataset.index.is_monotonic_increasing
assert not dataset.is_split
dataset.X| close | |
|---|---|
| datetime | |
| 2026-06-18 | 746.739990 |
| 2026-06-22 | 744.390015 |
| 2026-06-23 | 733.580017 |
| 2026-06-24 | 733.239990 |
| 2026-06-25 | 734.299988 |
| 2026-06-26 | 728.989990 |
| 2026-06-29 | 741.000000 |
| 2026-06-30 | 746.770020 |
| 2026-07-01 | 745.760010 |
| 2026-07-02 | 744.780029 |
| 2026-07-06 | 751.280029 |
| 2026-07-07 | 747.710022 |
| 2026-07-08 | 745.400024 |
| 2026-07-09 | 751.710022 |
| 2026-07-10 | 754.950012 |
| 2026-07-13 | 749.169983 |
| 2026-07-14 | 751.830017 |
| 2026-07-15 | 754.809998 |
| 2026-07-16 | 750.719971 |
| 2026-07-17 | 743.289978 |
| 2026-07-20 | 742.090027 |
| 2026-07-21 | 748.280029 |
| 2026-07-22 | 747.409973 |
| 2026-07-23 | 738.179993 |
1. Start with every default
The empty constructor creates the simplest expanding walk-forward scheme:
| Parameter | Default | Effect |
|---|---|---|
n_splits |
5 |
Creates five chronological folds |
max_train_size |
None |
Uses all available earlier rows, so training expands |
test_size |
None |
Lets sklearn infer n_samples // (n_splits + 1) rows |
gap |
0 |
Places test immediately after train |
name |
"walk_forward" |
Stores the scheme at dataset.splits["walk_forward"] |
basic = dataset.split(q.dataset.TimeSeriesSplit())
scheme = basic.splits["walk_forward"]
first_fold = scheme["fold_1"]
assert not dataset.is_split
assert basic.is_split
assert len(scheme) == 5
assert len(first_fold.test) == len(dataset) // 6
assert first_fold.train.index.max() < first_fold.test.index.min()
q.dataset.split_diagnostics(basic, "walk_forward", "fold_1")| role | rows | proportion | start | end | |
|---|---|---|---|---|---|
| partition | |||||
| train | fit | 4 | 0.166667 | 2026-06-18 | 2026-06-24 |
| test | holdout | 4 | 0.166667 | 2026-06-25 | 2026-06-30 |
| excluded | excluded | 16 | 0.666667 | 2026-07-01 | 2026-07-23 |
Inspect every fold
Each default test window contains four rows. Training expands by the previous test window at each step, while observations later than the current test window remain excluded for that fold.
flowchart TB
subgraph F1["fold_1"]
direction LR
F1T["train<br/>rows 1-4"] --> F1E["test<br/>rows 5-8"] --> F1X["excluded<br/>rows 9-24"]
end
subgraph F2["fold_2"]
direction LR
F2T["train<br/>rows 1-8"] --> F2E["test<br/>rows 9-12"] --> F2X["excluded<br/>rows 13-24"]
end
subgraph F3["fold_3"]
direction LR
F3T["train<br/>rows 1-12"] --> F3E["test<br/>rows 13-16"] --> F3X["excluded<br/>rows 17-24"]
end
subgraph F4["fold_4"]
direction LR
F4T["train<br/>rows 1-16"] --> F4E["test<br/>rows 17-20"] --> F4X["excluded<br/>rows 21-24"]
end
subgraph F5["fold_5"]
direction LR
F5T["train<br/>rows 1-20"] --> F5E["test<br/>rows 21-24"] --> F5X["excluded<br/>0 rows"]
end
classDef train fill:#d8f3dc,stroke:#2d6a4f,color:#16382a
classDef test fill:#ffefc1,stroke:#9c6b00,color:#4d3500
classDef excluded fill:#eceff1,stroke:#6b7280,color:#374151
class F1T,F2T,F3T,F4T,F5T train
class F1E,F2E,F3E,F4E,F5E test
class F1X,F2X,F3X,F4X,F5X excluded
An attached TimeSeriesSplit is a SplitScheme, not the single default split used by TemporalSplit. Iterate dataset.splits["walk_forward"] to receive dataset-bound folds, then access each fold’s lazy .train, .test, or ["excluded"] views. With max_train_size=None, the training window grows while the inferred four-row test window stays fixed.
def summarize_folds(split_dataset, scheme_name):
rows = []
for fold in split_dataset.splits[scheme_name]:
rows.append(
{
"fold": fold.name,
"train_rows": len(fold.train),
"train_start": fold.train.index.min(),
"train_end": fold.train.index.max(),
"test_rows": len(fold.test),
"test_start": fold.test.index.min(),
"test_end": fold.test.index.max(),
"excluded_rows": len(fold["excluded"]),
}
)
return pd.DataFrame(rows).set_index("fold")
default_summary = summarize_folds(basic, "walk_forward")
assert default_summary["train_rows"].tolist() == [4, 8, 12, 16, 20]
assert default_summary["test_rows"].eq(4).all()
default_summary| train_rows | train_start | train_end | test_rows | test_start | test_end | excluded_rows | |
|---|---|---|---|---|---|---|---|
| fold | |||||||
| fold_1 | 4 | 2026-06-18 | 2026-06-24 | 4 | 2026-06-25 | 2026-06-30 | 16 |
| fold_2 | 8 | 2026-06-18 | 2026-06-30 | 4 | 2026-07-01 | 2026-07-07 | 12 |
| fold_3 | 12 | 2026-06-18 | 2026-07-07 | 4 | 2026-07-08 | 2026-07-13 | 8 |
| fold_4 | 16 | 2026-06-18 | 2026-07-13 | 4 | 2026-07-14 | 2026-07-17 | 4 |
| fold_5 | 20 | 2026-06-18 | 2026-07-17 | 4 | 2026-07-20 | 2026-07-23 | 0 |
2. Change n_splits
n_splits controls how many train/test evaluations are produced. When test_size remains None, changing the fold count also changes sklearn’s inferred test size. With 24 rows and three folds, each test window contains 24 // (3 + 1) = 6 rows.
three_folds = dataset.split(q.dataset.TimeSeriesSplit(n_splits=3))
three_fold_summary = summarize_folds(three_folds, "walk_forward")
assert len(three_folds.splits["walk_forward"]) == 3
assert three_fold_summary["test_rows"].eq(6).all()
three_fold_summary| train_rows | train_start | train_end | test_rows | test_start | test_end | excluded_rows | |
|---|---|---|---|---|---|---|---|
| fold | |||||||
| fold_1 | 6 | 2026-06-18 | 2026-06-26 | 6 | 2026-06-29 | 2026-07-07 | 12 |
| fold_2 | 12 | 2026-06-18 | 2026-07-07 | 6 | 2026-07-08 | 2026-07-15 | 6 |
| fold_3 | 18 | 2026-06-18 | 2026-07-15 | 6 | 2026-07-16 | 2026-07-23 | 0 |
3. Set test_size explicitly
Set test_size when every evaluation period must contain a specific number of rows. A smaller test window leaves more observations available before the first test fold. The value counts rows, which are trading sessions for this SPY dataset, not calendar days.
fixed_test = dataset.split(
q.dataset.TimeSeriesSplit(
n_splits=3,
test_size=3,
name="fixed_test",
)
)
fixed_test_summary = summarize_folds(fixed_test, "fixed_test")
assert fixed_test_summary["test_rows"].eq(3).all()
assert fixed_test_summary.loc["fold_1", "train_rows"] == 15
fixed_test_summary| train_rows | train_start | train_end | test_rows | test_start | test_end | excluded_rows | |
|---|---|---|---|---|---|---|---|
| fold | |||||||
| fold_1 | 15 | 2026-06-18 | 2026-07-10 | 3 | 2026-07-13 | 2026-07-15 | 6 |
| fold_2 | 18 | 2026-06-18 | 2026-07-15 | 3 | 2026-07-16 | 2026-07-20 | 3 |
| fold_3 | 21 | 2026-06-18 | 2026-07-20 | 3 | 2026-07-21 | 2026-07-23 | 0 |
4. Add a gap
gap removes a fixed number of rows from the end of each training window before test begins. Those rows receive the excluded role for that fold. A gap can protect against short look-ahead effects, but it does not inspect label horizons; use PurgedTimeSeriesSplit when labels can overlap test observations.
gapped = dataset.split(
q.dataset.TimeSeriesSplit(
n_splits=3,
test_size=3,
gap=2,
name="gapped",
)
)
first_gapped_fold = gapped.splits["gapped"]["fold_1"]
test_start = dataset.index.get_loc(first_gapped_fold.test.index.min())
gap_rows = dataset.index[test_start - 2 : test_start]
assert gap_rows.isin(first_gapped_fold["excluded"].index).all()
assert first_gapped_fold.train.index.max() < gap_rows.min()
membership = gapped.splits["gapped"].split("fold_1").membership
gapped.X.assign(partition=membership)| close | partition | |
|---|---|---|
| datetime | ||
| 2026-06-18 | 746.739990 | train |
| 2026-06-22 | 744.390015 | train |
| 2026-06-23 | 733.580017 | train |
| 2026-06-24 | 733.239990 | train |
| 2026-06-25 | 734.299988 | train |
| 2026-06-26 | 728.989990 | train |
| 2026-06-29 | 741.000000 | train |
| 2026-06-30 | 746.770020 | train |
| 2026-07-01 | 745.760010 | train |
| 2026-07-02 | 744.780029 | train |
| 2026-07-06 | 751.280029 | train |
| 2026-07-07 | 747.710022 | train |
| 2026-07-08 | 745.400024 | train |
| 2026-07-09 | 751.710022 | excluded |
| 2026-07-10 | 754.950012 | excluded |
| 2026-07-13 | 749.169983 | test |
| 2026-07-14 | 751.830017 | test |
| 2026-07-15 | 754.809998 | test |
| 2026-07-16 | 750.719971 | excluded |
| 2026-07-17 | 743.289978 | excluded |
| 2026-07-20 | 742.090027 | excluded |
| 2026-07-21 | 748.280029 | excluded |
| 2026-07-22 | 747.409973 | excluded |
| 2026-07-23 | 738.179993 | excluded |
5. Set max_train_size for rolling windows
The default max_train_size=None keeps all eligible history and produces expanding windows. Set a maximum to keep only the most recent training rows. This converts the same splitter into a rolling-window scheme; older rows remain aligned but are excluded from that fold.
rolling = dataset.split(
q.dataset.TimeSeriesSplit(
n_splits=3,
test_size=3,
gap=2,
max_train_size=6,
name="rolling",
)
)
rolling_summary = summarize_folds(rolling, "rolling")
assert rolling.splits["rolling"].metadata["method"] == "rolling"
assert rolling_summary["train_rows"].eq(6).all()
rolling_summary| train_rows | train_start | train_end | test_rows | test_start | test_end | excluded_rows | |
|---|---|---|---|---|---|---|---|
| fold | |||||||
| fold_1 | 6 | 2026-06-30 | 2026-07-08 | 3 | 2026-07-13 | 2026-07-15 | 15 |
| fold_2 | 6 | 2026-07-06 | 2026-07-13 | 3 | 2026-07-16 | 2026-07-20 | 15 |
| fold_3 | 6 | 2026-07-09 | 2026-07-16 | 3 | 2026-07-21 | 2026-07-23 | 15 |
6. Use name to keep multiple schemes
Unlike TemporalSplit, TimeSeriesSplit.name names the whole multi-fold scheme. Distinct names let one dataset retain several walk-forward designs for comparison. Calling .split(...) returns a new dataset and preserves schemes already attached to the source.
compared = basic.split(
q.dataset.TimeSeriesSplit(
n_splits=3,
test_size=3,
max_train_size=6,
name="rolling",
)
)
assert tuple(compared.splits) == ("walk_forward", "rolling")
assert [fold.name for fold in compared.splits["rolling"]] == [
"fold_1",
"fold_2",
"fold_3",
]
pd.DataFrame(
{
"expanding_train_rows": summarize_folds(three_folds, "walk_forward")["train_rows"],
"rolling_train_rows": summarize_folds(compared, "rolling")["train_rows"],
}
)| expanding_train_rows | rolling_train_rows | |
|---|---|---|
| fold | ||
| fold_1 | 6 | 6 |
| fold_2 | 12 | 6 |
| fold_3 | 18 | 6 |
Choosing settings
Use TimeSeriesSplit when model evaluation should repeat across chronological folds. Start with the defaults, set test_size to match the evaluation horizon, add gap for a fixed row separation, and set max_train_size only when older history should leave the training window. The dataset index must be monotonic increasing. All window parameters count rows, so comparable fold durations require equally spaced observations. For one fixed holdout use TemporalSplit; for label-horizon purging and embargo use PurgedTimeSeriesSplit.