Sweep, then walk forward
Search a parameter grid with a ranked sweep, then put the winner through an anchored out-of-sample walk-forward that deliberately never re-fits.
A sweep answers "which parameters scored best on this slice". A walk-forward answers a different question — "was that profitable across the whole sample, or in one lucky stretch" — and it answers it without optimising anything. Running the second after the first is the point of this tutorial; running only the first is how a curve-fit ships.
Both profiles are shipped by vike-cli init (profiles/sweep.toml, profiles/walkforward.toml),
and both carry their script INLINE in [strategy.params].src, because neither the sweep nor the
walkforward verb takes --script. vike-cli init writes that inlined copy from the same source
as strategies/rhai/sma_cross/sma_cross.rhai, so a fresh scaffold agrees — edit one afterwards and
you must edit the other.
1. The sweep
[sweep]
fast = [5.0, 10.0, 20.0]
slow = [30.0, 50.0, 100.0]harness::sweep's expand_sweep turns a profile carrying that table into the cartesian product of
single-run profiles — each with strategy.params overridden for its point and its own sweep
field cleared, so every expanded point is itself a plain runnable single-backtest profile. A
profile with no [sweep] table expands to exactly one point with no overrides, which is why the
non-sweep path is unchanged.
vike-cli sweep --profile user_data/profiles/sweep.toml --rank-by sharperun_sweep runs each point through run_backtest, keeps a BacktestReport or the error string
per point, and ranks the successful rows; failed rows always sort last. --rank-by takes
sharpe (default), return, max_dd or equity. The name is validated locally and applied
server-side by the canonical RankMetric comparator, so the rows arrive already ordered best-first
and the printed statistics are the server's own report numbers.
The whole profile crosses the wire, and that is a correctness fix
vike-cli sweep used to re-parse the profile into WireSpec/WireSlice/WireSweep DTOs. That
mapping could not carry [engine].fee, [engine.impact], [engine.resolution], [risk],
snap_to_properties, a tick slice or a cross-venue [[data.series]] slice — so a sweep quietly
ran under different costs than the same profile's backtest. The text is now shipped verbatim and
the server's one BacktestProfile::from_toml_str parses it.
Three properties worth carrying:
- Determinism does not rest on scheduling. Points run on a rayon pool by default, but
par_iter().map().collect()is order-preserving and the ranking sort is a stableslice::sort_byover that same input order, so the bytes are identical to the one-at-a-time loop (parallel_and_sequential_sweeps_are_byte_identical). - Parallelism multiplies MEMORY, not just speed. The shared
Arc<dyn HistStore>shares the handle, not the loaded data: every point re-scans and fully materialises its ownVec<Bar>/Vec<Tick>, so peak RSS isN_threads × slice. The pool is therefore bounded rather than global —sweep_threadsdefaults tomin(4, available_parallelism())andVIKE_SWEEP_THREADSraises it. Size that against the slice, not the core count.VIKE_SWEEP_SEQUENTIAL=1pins the process back to the old loop. - Cost multiplies fast. The shipped grid is 3 × 3 = 9 runs; a fourth axis of five values is 45. Write the values as floats — the shipped profile's own warning — because a non-numeric value is skipped in silence.
2. What the sweep actually reported
The runs below use a smaller grid than the shipped profile, so the whole table fits on the page:
3 × 2 = 6 points over the scaffolded rsi_meanrev script's len and lo axes. The data is real
Binance BTCUSDT 1h bars for the first half of 2025, run at cash = 10000, fee_rate = 0.001 and
slippage = 0.
[sweep]
len = [7.0, 14.0, 21.0]
lo = [20.0, 30.0]Through the standalone backtest binary:
backtest --profile tut_rsi_sweep.toml --rank-by sharpesweep ranked by sharpe (6 points)
*#1 len=14.0 lo=30.0 ret=0.2129 sharpe=1.7800 max_dd=0.0835 trades=33
#2 len=21.0 lo=30.0 ret=0.1542 sharpe=1.1885 max_dd=0.1341 trades=15
#3 len=14.0 lo=20.0 ret=0.0755 sharpe=0.6565 max_dd=0.1402 trades=12
#4 len=7.0 lo=30.0 ret=0.0246 sharpe=0.2807 max_dd=0.0873 trades=103
#5 len=7.0 lo=20.0 ret=-0.0242 sharpe=-0.1026 max_dd=0.1137 trades=50
#6 len=21.0 lo=20.0 ret=-0.0992 sharpe=-0.6544 max_dd=0.1738 trades=2Two of the six points lost money, and the table keeps them. Ranking is not filtering: rows 5 and 6 print with their negative returns and negative Sharpes, in last place, because a grid point that failed is evidence about the grid.
The winner returned 21.29% at a Sharpe of 1.7800 over 33 trades. Notice where that ordering comes
from — every lo = 30.0 point outranks every lo = 20.0 point. This is a one-axis result. The
grid found an entry threshold that the first half of 2025 rewarded, and len only shuffles rows
within it.
Read the trade counts before the returns. Row 4 traded 103 times for +2.46%; row 6 traded twice for -9.92%. A Sharpe of -0.6544 computed over two trades is a number, not a measurement — and the same caution applies to the winner's 33.
What the table does not say is anything out-of-sample. All six points were scored on the same bars,
and the top row was then chosen by looking at those scores, so 1.7800 is the maximum of six draws
rather than an estimate of what len = 14, lo = 30 will do next. That is the gap the
walk-forward exists to measure.
The same grid through the service
vike-cli sweep ships the same profile text to a vike-datahub server, which parses it with the
one BacktestProfile::from_toml_str and ranks the rows itself:
vike-cli sweep --profile tut_rsi_sweep.toml --rank-by sharpeparameter sweep — 6 point(s), ranked server-side by sharpe
rank overrides final_equity return sharpe max_dd trades
1 len=14.0, lo=30.0 12128.52 +21.29% 1.7800 0.0835 33
2 len=21.0, lo=30.0 11542.23 +15.42% 1.1885 0.1341 15
3 len=14.0, lo=20.0 10755.26 +7.55% 0.6565 0.1402 12
4 len=7.0, lo=30.0 10246.32 +2.46% 0.2807 0.0873 103
5 len=7.0, lo=20.0 9758.30 -2.42% -0.1026 0.1137 50
6 len=21.0, lo=20.0 9008.27 -9.92% -0.6544 0.1738 2Same six points, same order, same numbers. Only the presentation differs: the standalone table
prints ret as a fraction where the service table prints a percentage, and the service table
carries final_equity as well.
Neither client computes a statistic. print_sweep in crates/vike-backtest/src/harness/sweep.rs reads each
field straight out of the server's own row, and the standalone table is SweepReport's Display
in crates/vike-backtest/src/harness/sweep.rs. The star on the standalone table's first row marks
rank 1.
3. The walk-forward
Take the winning row's parameters, put them in [strategy.params], and run:
vike-cli walkforward --profile user_data/profiles/walkforward.tomlharness::walkforward's run_walkforward is assembly, not new engine code: it resolves the
strategy through the same strategy_by_name registry, builds the same bar-lane EngineParams and
loads the same bars as a plain run_backtest of that profile, then hands the series to
walkforward::walk_forward_strategy. So a walk-forward can never disagree with a single run about
what the profile's [engine] means — including the fee SCHEDULE and [risk], which the old
DTO-shaped path could not carry.
It is BAR mode over ONE series, deliberately: the splitter divides a single &[Bar] by index, so a
tick or multi-symbol profile is a clean validation error rather than a silent first-symbol
fallback.
[walkforward] n_splits = 4 is the whole configuration. walk_forward_splits cuts the series into
n_splits + 1 chunks; window s tests [s·chunk, (s+1)·chunk) with the last window running to
the end, and under Anchored mode every window's training range starts at index 0. Each window
runs fresh at [engine] cash and the OOS curves are rebased onto one running equity, so the
stitched curve compounds.
It does not re-fit per window
The parameters are fixed and identical in every window. This is an out-of-sample STABILITY check, not an optimise-then-test protocol — which is exactly why you run it on the sweep's winner rather than instead of the sweep.
WalkForwardReport returns the per-window oos_returns, the stitched oos_equity_curve, its
oos_return and oos_sharpe, and wf_consistency — the fraction of windows that were profitable
out of sample. That last number is the one to look at first: a strong stitched return with a
consistency near 0.25 is one window carrying the strategy.
Point both profiles' [data] at a slice your store actually holds before the first run. The
shipped sweep names binance over the demo tape's dates, and the shipped walk-forward spans 2024
to 2026; an empty slice is a run with no trades, which reads like a bad strategy rather than an
empty query.
4. What the walk-forward actually reported
The profile below carries the sweep's winning row — len = 14.0 and lo = 30.0 — over the
identical [data] and [engine] block, plus [walkforward] n_splits = 4. The --addr flag
defaults to 127.0.0.1:7878, so a datahub on the same box needs no flag.
vike-cli walkforward --profile tut_rsi_wf.tomlwalk-forward: 4 out-of-sample window(s)
window test_range oos_return
1 [869, 1738) +11.27%
2 [1738, 2607) +5.01%
3 [2607, 3476) -3.44%
4 [3476, 4345) +6.05%
oos_return = +19.64% oos_sharpe = 1.983 wf_consistency = 75.0%Window 3 lost 3.44%. That is the result, not a run to repeat until it behaves: one of the four out-of-sample stretches of the first half of 2025 was a losing stretch for these parameters.
wf_consistency = 75.0% is that same fact counted — three of four windows finished above the cash
they started with. walk_forward_strategy in crates/vike-backtest/src/walkforward.rs counts a
window profitable on a strictly positive oos_return, so a perfectly flat window would count
against it too.
The index ranges are the anchored splitter showing its work. The series is 4345 bars;
walk_forward_splits in crates/vike-analytics/src/validation.rs sets
chunk = n / (n_splits + 1) = 869, window s tests [s·869, (s+1)·869), and the last window runs
to the end.
So the first 869 bars are never tested. They are training-only in every window — that is what anchored mode buys, and it is also what the stitched number is missing.
Which makes +19.64% not comparable with the +21.29% of the sweep's top row. The sweep scored the
whole slice; the walk-forward scored the last four fifths of it. The same goes for
oos_sharpe = 1.983, computed on the stitched curve and so covering the same four windows. The two
returns landing near each other is mildly reassuring and nothing more.
What the run does not say is that the strategy is stable. Four windows is four observations, and
75% and 100% are one window apart. At n_splits = 4 over six months of hourly bars each window is
about five weeks — short enough for a single trend leg to fill one. Raising n_splits buys more
windows, not more data.
Nor is this a forward test. These parameters were chosen by looking at these same bars in the sweep
above, so the selection has already seen every window it is now scored on. The honest reading is
narrow: with len = 14 and lo = 30 held fixed, this slice produced three up windows and one down
one.
Where next
- Sweeps, Search and Walk-forward.
- Bayesian parameter search — when the grid is too big to enumerate.
- Overfitting and Monte Carlo and overfitting.
- Sweep on the service.
A custom feature, proven
Implement a ColumnFeature over one numeric column and prove its streaming form equals its batch form bit for bit with the shipped parity harness, then write the same idea as a user indicator script and run it.
Bayesian parameter search
Run a TPE search over a sweep profile, compare it against successive halving and the exhaustive grid, and keep the result reproducible.