Time-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.

import pandas as pd

import qrt as q
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.

Back to top