A full-sample backtest is exploratory evidence. QuantLab provides chronological holdout, walk-forward evaluation and robustness tools to test narrower claims.
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.
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.yamlThis 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.
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_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.
bootstrap_returns/monte_carlo_permutation above make an inferential
statement about one stitched return series. A multi-asset walk-forward
study instead produces a panel: one row per entity (a pair, an asset)
per fold, where rows sharing a fold share a market environment
(cross-sectional dependence) and rows sharing an entity recur across folds
(the same entity is not an independent draw each fold). Treating every row
as independent -- a plain t-test, or OLS with i.i.d. standard errors --
understates uncertainty and can manufacture significance out of pure
cross-sectional co-movement within a fold.
quantlab.validation.panel_inference resamples, permutes, or clusters at
one caller-chosen dependence level instead of treating every row as an
independent draw:
cluster_bootstrap_difference(y, treated, cluster, statistic="mean"|"median", n_boot, seed)-- a CI forstat(y | treated) - stat(y | control), resampling WHOLE clusters (e.g. folds) with replacement rather than individual rows.within_cluster_permutation_test(y, treated, cluster, statistic, n_perm, seed)-- a p-value for the same quantity, keeping cluster membership fixed and instead shuffling the treated/control LABELS within each cluster (a different mechanism from the bootstrap above, not a second way to resample whole clusters). Both are single-dimensional: whicheverclusterlabel is passed is the only dependence structure corrected for -- entity-level dependence (the same entity recurring across folds) is not separately modelled unless the caller clusters by entity instead of by fold.fama_macbeth_difference(frame, outcome, treated, cluster)/per_cluster_spearman(frame, x, y, cluster)-- one statistic per cluster, then a t-test across clusters (n_clustersobservations, each treated as one independent unit for THIS test's own standard error, notn_rows). Also single-dimensional, with an additional assumption: the across-cluster t-test treats each cluster's own statistic as an independent draw, which is not automatically true for temporally adjacent or overlapping folds (e.g. a walk-forward run withstep < test_window) -- serial correlation between neighbouring folds' own outcomes is not corrected for here.clustered_regression(frame, outcome, regressor, fixed_effects=None, clusters=(...))-- OLS with optional fixed effects and one- or two-way cluster-robust standard errors. This is the one function in this module that can address fold and entity dependence simultaneously (e.g.clusters=(fold_column, entity_column)); every other function above addresses only whichever singleclustercolumn it is given.paired_cluster_bootstrap_sharpe_difference(returns_a, returns_b, cluster_of_date, n_boot, seed, periods_per_year, risk_free_rate)-- a paired cluster-block bootstrap CI for the Sharpe-ratio difference between two stitched return series sharing a calendar.risk_free_rateis an annual rate subtracted before computing each Sharpe ratio (0.0default, i.e. the inputs are assumed already excess returns unless set).p_value_two_sidedis an approximate bootstrap TAIL PROBABILITY (the same CI-exclusion duality a 95% CI already implies), not the general equivalent of a dedicated null-recentered or permutation-based test -- see the function's own docstring. Single-dimensional (clusters by date-derived label only).holm_adjust(p_values)-- Holm step-down family-wise error control for a set of p-values computed together (e.g. one per policy under test).cliffs_delta(x, y)-- a scale-free effect size, not a test.
Every function above that clusters or resamples enforces at least 2
distinct clusters, but that is a bare computability floor, not a claim
that inference from that few is reliable -- cluster-robust standard
errors and bootstrap/permutation confidence intervals are well known to
be fragile with only a handful of clusters, regardless of how many rows
each one has. For fama_macbeth_difference/per_cluster_spearman
specifically, the floor counts contributing clusters (both a treated
and a control row, and a finite per-cluster statistic) rather than raw
distinct labels in the input cluster column, which can be higher.
Use these instead of bootstrap_returns/monte_carlo_permutation
whenever the object under study is a panel with a fold/entity structure,
not a single stitched series -- and use clustered_regression specifically
when both fold and entity dependence need correcting at once, since it is
the only function here that supports two-way clustering. Every function
documents its own exact null hypothesis; none of them establish causality
-- they quantify whether an observed association is larger than
dependence-respecting resampling variation would typically produce, under
the single dependence structure the caller supplied, not every dependence
structure the panel might actually have. Each result-producing function
returns its own frozen dataclass (ClusterDifferenceResult,
PermutationTestResult, FamaMacBethResult, ClusterSpearmanResult,
PairedSharpeDifferenceResult), not a bare dict.
walk_forward_windows always produces a train/validation/test triple.
formation_test_windows(index, formation_window, test_window, step=None)
instead produces formation-then-test folds with no validation block, for
a Gatev, Goetzmann & Rouwenhorst (2006, "Pairs Trading: Performance of a
Relative-Value Arbitrage Rule")-style design where quantities are
estimated once on the formation window and a fixed rule is then applied
out of sample -- a middle validation block would only eat into the test
window for no purpose. Each FormationTestWindow carries positional
slices (formation, test) alongside start/end timestamps
(formation_start/formation_end/test_start/test_end); step
defaults to test_window like walk_forward_windows's own, but may
exceed it here -- each fold is evaluated independently rather than
stitched into one continuous curve, so a gap between test windows is a
legitimate sparser design, not a CAGR/annualisation hazard.
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.
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 universeIn walk-forward mode, a commission/slippage scenario reuses the baseline's own cached per-fold candidate weights -- a parameter combination's signal/allocation output never depends on costs, only its score does, so recomputing weights from scratch would be wasted work -- and re-scores every one of them under the new costs, re-selecting each fold's winner from those new scores. Which candidate wins can and does change under stressed costs; this is not a cost-invariant shortcut. The execution-delay and reduced-universe scenarios genuinely change the weights themselves, so each of those still re-runs selection end to end instead of reusing the cache.
Positive holdout, walk-forward, sensitivity, bootstrap and stress-test results support a more careful research process. They do not guarantee future profitability. See Limitations.