Docs / Concepts

Splits and the holdout

Immutable ranges, a frozen gap policy, and one evaluation per strategy for the lifetime of that strategy.

Register once#

split_register(
  train   = ["2021-01-01", "2024-06-30"],
  test    = ["2024-07-01", "2025-06-30"],
  holdout = ["2025-07-01", "2026-06-30"],
)

Ranges are immutable. There is no split_update. If a split turns out to be wrong you register a new one, which is visible as a new split rather than as a silent revision of the old one.

The reason is that a movable boundary is not a boundary. If the test period can be adjusted after seeing test results, the test period is training data with extra steps.

The gap policy is frozen at registration#

Market data is shared between tenants — bars are the expensive resource and OHLCV for BTC-USD is the same fact for everyone. But the policy for handling gaps in that data is not shared: a split records the gap policy in force at the moment it was registered.

Without this, a later change to how missing bars are treated would retroactively alter what an already-registered backtest saw, and a result would stop being reproducible without anything in the ledger changing.

Set the policy explicitly if you care:

data_gap_policy_set(policy="forward_fill_max_3")

Purging and embargo#

Cross-validation on time series leaks unless you actively prevent it. A label computed at time t with horizon h depends on data up to t+h, so a training example at t+1 overlaps a test label at t even though t+1 is strictly later.

The server purges both edges of each test block. Test labels run to hi + h, so training events after the block up to hi + h overlap them. Purging only backwards — which is the common implementation — leaks that right edge whenever the label horizon is positive.

An embargo period after each test block further separates the folds.

The holdout is unreadable#

Holdout data cannot be read by any tool until the strategy is frozen:

gate_check(strategy_id="...")      # are you ready?
strategy_freeze(strategy_id="...") # spec hash is locked
holdout_evaluate(strategy_id="...")# once, ever

holdout_one_shot is unique on (owner_id, strategy_id). The grant is checked against the ledger, not merely held in application memory, so a retry, a reconnect or a second client cannot produce a second evaluation.

Note the owner_id in that uniqueness constraint. Scoped to strategy_id alone, one tenant could have spent another tenant's only attempt.

Why one shot#

The holdout's value is entirely in never having influenced a decision. Look at it twice and the second look is conditioned on the first — you now know something about the holdout, and any subsequent choice is fitted to it.

At that point it is a test set, and you no longer have a holdout at all.

Run gate_check first. It reports whether the in-sample evidence justifies spending the attempt, and it costs nothing to call.

Read this page as Markdown: /docs/concepts/splits.md