Validation¶
A full-sample backtest is exploratory evidence. QuantLab provides chronological holdout, walk-forward evaluation and robustness tools to test narrower claims.
Chronological splits and holdout¶
quantlab.validation.chronological_split creates contiguous train, validation
and test blocks in time order. Ratios must be finite, between zero and one, and
sum to exactly one within floating-point tolerance.
The holdout report slices one continuously simulated return series. Positions, warm-up and costs therefore carry naturally across block boundaries; each block is not restarted from cash. The test block is out of sample only when the strategy and its parameters were fixed without consulting that block.
Walk-forward validation¶
WalkForwardValidator.run(data, parameter_grid, train_window,
validation_window, test_window, expanding=True, step=None) performs these
steps:
- Evaluate candidate parameters on each validation block -- each candidate is its own fresh backtest, restarted from cash on that block alone, not chained to any other candidate or fold.
- Select the best finite score using
validation.optimization_metric. - Apply that choice to the untouched test block.
- Stitch every fold's test-block returns into one continuous OOS curve, preserving portfolio, turnover and accounting state across fold boundaries only -- the OOS curve is one simulated run, but candidate selection within a fold never sees that chained state.
- Report the stitched OOS curve as
WalkForwardResult.oos_result.
step controls how far each fold's train window advances relative to the
previous one. It defaults to test_window (contiguous, non-overlapping test
blocks -- the still-recommended default). A smaller step overlaps test
blocks for denser evaluation (more folds, more compute); on an overlapping
date the stitched OOS curve keeps the most recent fold's decision, and two
folds whose test blocks collapse onto the same first execution date are
rejected outright rather than silently misattributing observations. step
must not exceed test_window: a larger step would leave gaps in the
stitched OOS curve that CAGR/annualisation (which assume regularly spaced
observations) cannot account for, so this is rejected at run time too.
The Python API accepts an explicit parameter_grid. A YAML experiment can set
the same candidates under validation.parameter_grid. The CLI and momentum
research notebook both call parameter_grid_for_config(config), which returns
that YAML grid when present and otherwise uses the small built-in grid from
default_parameter_grid(config). This keeps their candidate sets identical;
call the validator directly only when a grid should remain outside the saved
experiment definition.
validation:
method: walk_forward
train_window: 1000
validation_window: 252
test_window: 126
step: 126 # optional; defaults to test_window
optimization_metric: sharpe
parameter_grid:
lookback_period: [126, 189, 252]
skip_period: [0, 21]
Every candidate and every cross-parameter combination is validated when the
YAML is loaded. Structural choices such as long-only versus long/short should
normally remain fixed in strategy.parameters, so one experiment answers one
research question.
WalkForwardResult.parameter_stability() reports coefficients of variation for
numeric selected parameters. Low variation is descriptive evidence of
consistent selection, not proof that the parameter or strategy is robust.
quantlab walk-forward --config configs/momentum_sp500.yaml
This writes the walk-forward CSV artefacts and incorporates compatible evidence
into the generated HTML report. Progress (and an ETA) is shown live in the
terminal or dashboard while it runs. An interruption (Ctrl+C, a crash, closing
the terminal) is resumed automatically the next time the same command runs
against the same experiment, config, data and code — only completed folds
are skipped; whichever fold was still in progress at the moment of
interruption is discarded and recomputed from its start, not resumed
mid-fold. Pass --fresh to discard all saved progress and start over
instead.
The same applies to stress-test, sensitivity and robustness in
walk-forward mode.
Parameter sensitivity¶
run_parameter_sensitivity(data, config, parameter_x, values_x, parameter_y,
values_y) records Sharpe, CAGR, drawdown, turnover and trade count for every
combination. Failed configurations remain visible with a status and error
message. The useful pattern is a region with comparable behaviour, not merely
one isolated optimum.
Bootstrap¶
bootstrap_returns(returns, n_iterations, block_size, seed) resamples returns
i.i.d. when block_size=1 or in circular blocks otherwise. It reports the
sampled distributions of CAGR, Sharpe, maximum drawdown and final value. Block
sampling retains dependence within each sampled block, but does not reproduce
the complete time-series process. These are historical sampling estimates, not
forecasts.
BootstrapResult.summary(confidence_level=0.90) reports each statistic's
median plus a p_lower/p_upper percentile band at the requested confidence
level (0.90 -> the 5th/95th percentiles, the default). Set
robustness.bootstrap.confidence_level in YAML to change it for a saved
experiment's own bootstrap run.
Random-sign test¶
monte_carlo_permutation randomly flips the sign of per-period excess returns
around the configured risk-free rate, while preserving their magnitudes. Its
empirical p-value is the share of randomised Sharpes at least as high as the
observed Sharpe, with a finite-sample correction. A low value is evidence
against this specific random-sign null; it is not the probability that the
strategy is genuine, profitable or likely to work in the future.
Stress tests¶
run_stress_tests(data, config) evaluates elevated commissions and
slippage, an extra execution-delay period, removal of the best days, and a
reduced tradable universe. Every scenario -- including one whose universe is
too small to leave at least 2 tradable symbols, or that fails for any other
reason -- keeps its own row in the table with status="failed" and an error
message, rather than being silently omitted or aborting the whole run.
Every scenario's magnitude comes from robustness.stress_test in YAML, each
a list so more than one magnitude can be evaluated per scenario type (e.g.
execution_delays: [1, 2, 5] adds a scenario row per delay); an empty list
disables that scenario type entirely. commission_multipliers/
slippage_multipliers must be strictly greater than 1.0 -- these model
elevated, adverse costs, not a cheaper-than-baseline scenario. The default
configuration evaluates:
robustness:
stress_test:
enabled: true
commission_multipliers: [2.0, 5.0]
slippage_multipliers: [2.0]
execution_delays: [1]
best_days_removed: [10]
reduce_universe_by: [1] # symbols dropped from the tail of the universe
What none of this proves¶
Positive holdout, walk-forward, sensitivity, bootstrap and stress-test results support a more careful research process. They do not guarantee future profitability. See Limitations.