← All field notes football prediction backtest chronological split

Split Football Results for an Honest Model Backtest

A backtest asks what a model could have known when it made a forecast. Randomly mixing past and future fixtures can let later-season information influence earlier predictions, especially when features include rolling form or season averages. Start with a chronological split and an explicit cutoff before deciding how sophisticated the model should be.

Make the forecast time part of the dataset

A backtest asks what a model could have known when it made a forecast. Randomly mixing past and future fixtures can let later-season information influence earlier predictions, especially when features include rolling form or season averages. Start with a chronological split and an explicit cutoff before deciding how sophisticated the model should be.

The fixture below separates matches whose kickoff precedes a cutoff from later matches. That is a useful first selection rule, but kickoff alone does not prove a result was available at the cutoff. In a real historical evaluation, retain the observations available at each forecast time or clearly disclose when you only have a later corrected snapshot.

Build features using training observations only

If you estimate scoring rates from historical finished results, calculate those rates inside the training window. Do not compute a full-season average first and then split rows; that leaks information from the evaluation period into the model. Apply the same restriction to normalization, feature selection and any parameter tuning.

Use separate intervals for fitting, model choice and final evaluation when your data permits. Once you inspect final-test performance to tune a parameter, that period has become part of model selection. Keep an untouched later period if you need a less biased final comparison.

Run the reproducible example

This Python 3 example uses deliberately synthetic inputs, not a downloaded match or a forecast for a real fixture. Save it as example.py and run python3 example.py. It uses only the standard library and its assertions check the behavior discussed here. Replace the fixture inputs with validated API records when connecting your application; demo identifiers are not production lookup values.

Complete offline example python
from datetime import datetime

def instant(value):
    parsed = datetime.fromisoformat(value.replace("Z","+00:00"))
    if parsed.tzinfo is None:
        raise ValueError("timezone required")
    return parsed

def split(rows, cutoff):
    ids = [r["id"] for r in rows]
    if len(set(ids)) != len(ids):
        raise ValueError("duplicate match")
    ordered = sorted(rows,key=lambda r:instant(r["starts_at"]))
    boundary = instant(cutoff)
    return ([r for r in ordered if instant(r["starts_at"]) < boundary],
            [r for r in ordered if instant(r["starts_at"]) >= boundary])

rows = [{"id":"match_before","starts_at":"2026-09-01T12:00:00Z"},
        {"id":"match_at","starts_at":"2026-10-01T00:00:00Z"},
        {"id":"match_after","starts_at":"2026-10-03T12:00:00Z"}]
train,test = split(rows,"2026-10-01T00:00:00Z")
assert [r["id"] for r in train] == ["match_before"]
assert [r["id"] for r in test] == ["match_at","match_after"]
assert not ({r["id"] for r in train} & {r["id"] for r in test})
print({"train":len(train),"test":len(test)})

Read the result

The synthetic cutoff produces one training match and two later matches. The exact-boundary fixture belongs to the later set. These assertions verify partitioning and identity separation, not the quality of a prediction model.

Validate the split mechanically

The code sorts projected match records, partitions them at an offset-aware instant and asserts that no ID occurs in both sides. A fixture exactly at the cutoff goes into the later set. It rejects duplicate identities before splitting so a repeated record cannot appear to be two independent observations.

Record the competition, season, date filters, selected IDs and retrieval date with the split. Stats API supplies match facts and nullable scores; the training labels and feature pipeline are your application artifacts. Unknown scores and unsupported fields should be excluded or handled under a documented policy, not filled from memory.

Compare against a simple baseline

A more complicated model is useful only if it improves the result you care about under a fair comparison. Evaluate a simple historical scoring-rate baseline on the same eligible matches and report sample size alongside the metric. Separate changes in data coverage from changes in predictive performance.

Do not describe one small successful sample as profitable or reliable for betting. Keep probabilities, evaluation rules and exclusions reproducible. A backtest can reveal weaknesses, but it cannot guarantee future results, and missing contemporaneous source observations limit what a historical snapshot can establish.

Connect this to your project

Use this split utility alongside retained match observations and a versioned feature pipeline. The Poisson tool can help inspect a simple baseline, while the grounded prediction guide explains how to keep estimates distinct from observed football facts.

Inspect a Poisson baseline → Read match data docs → Explore the football API → Build the prediction evidence trail →