Parameter search
Search a parameter grid — `vike-cli backtest run` over a profile carrying a [paramscan] table, which replaced the retired top-level `sweep` verb.
`vike-cli sweep` is retired — the command is `vike-cli backtest run`
A search is a backtest run many times and ranked, so it stopped being a verb of its own. The old
spelling is refused by name and prints its replacement: crates/vike-cli/src/lib.rs's
RETIRED_COMMANDS carries the row and the sentence, so it is not the unknown-command shrug a
genuinely non-existent verb gets. (crates/vike-cli/src/cmd/backtest.rs's LIST_PARAMS_RETIRED is
the same doctrine one level in, for the --list-params mode flag.)
What to type instead: put a non-empty [paramscan] table in the profile and run
vike-cli backtest run --profile search.toml. --optimizer, --trials, --seed, --euler-depth
and --rank-by steer it; they do not decide that a search happens.
The profile TABLE was renamed with the verb, and [sweep] is kept as a permanent serde alias —
so no committed profile breaks. Writing both spellings in one file is a duplicate-field error.
vike-cli backtest run ships a profile's TOML verbatim to a compute daemon next to the data and
prints what comes back. What that profile SAYS decides what runs: a non-empty [paramscan] table
makes it a parameter search — the grid is expanded and the results ranked on the far side — and
everything else is one backtest. The predicate is
crates/vike-backtest/src/harness/profile.rs's is_paramscan (present and non-empty), and the
engine and the compute server both branch on it.
crates/vike-cli/src/cmd/backtest.rs's declares_a_paramscan_grid is this side's own presence
check over the same key — a separate function because vike-cli links no vike-backtest,
deliberately — so all three answer the same question the same way.
vike-cli backtest run --profile search.toml
vike-cli backtest run --profile search.toml --rank-by sharpe --addr 127.0.0.1:7880
vike-cli backtest run --profile search.toml --optimizer tpe --trials 128 --seed 7
vike-cli backtest run --local --profile search.toml --optimizer euler --euler-depth 3[paramscan] is the name; [sweep] keeps loading forever
This is the pairing a reader most easily gets wrong, so it is stated as one rule:
[paramscan]is the name to write. It replaced[sweep]when the verb moved.[sweep]still loads, permanently.crates/vike-backtest/src/harness/profile.rs'sparamscanfield carries#[serde(alias = "sweep")], and its doc argues why that alias has no end date:BacktestProfileisdeny_unknown_fields, so removing the alias would make every profile ever written fail to load with "unknown field". It is not a deprecation.both_the_new_section_and_the_legacy_one_load_and_parse_identicallypins that both spellings produce the identical parsed grid.- Writing both in one file is refused. serde reports a duplicate field, and that is the correct
answer rather than a gap — two grids in one profile has no meaning, and a silent winner is exactly
the different-answer defect the rename exists to end
(
writing_both_spellings_in_one_profile_is_refused). - What kept the old spelling is what a PEER matches literally — the Rust names moved with
the section (
is_paramscan,expand_paramscan,run_paramscan,declares_a_paramscan_grid), andPROFILE_TOP_LEVEL_KEYScarries its own correction of the narrower claim. What still sayssweep: the wire tags (#[serde(rename = "RunSweep")]and its siblings), the"sweep"field key, the capability stringsrun_sweep_profile/run_sweep, the MCP tool namerun_sweep, and the alias above.crates/vike-cli/src/cmd/backtest.rs'sdeclares_a_paramscan_gridcarries that list and the reason: an identifier may move, a string another process compares against may not.
So: rename the section when you edit a profile, or leave it alone. Never add the new name beside the old one.
# search.toml — everything a single-run profile has, plus the grid
name = "sma-cross-search"
[data]
venue = "binance"
symbols = ["BTCUSDT"]
kind = "bar"
interval = "1h"
from = "2026-01-01T00"
to = "2026-02-01T00"
[engine]
cash = 100000.0
fee_rate = 0.001
[strategy]
name = "sma_cross"
[strategy.params]
fast = 10
slow = 20
[paramscan]
fast = [5, 10, 20]
slow = [20, 40][data] and [engine] are there because they are required: neither field of
crates/vike-backtest/src/harness/profile.rs's BacktestProfile carries #[serde(default)], so a
profile without them fails to load with a missing-field error before anything runs.
The grid grammar
crates/vike-backtest/src/harness/sweep.rs's expand_paramscan turns that table into the cartesian
product of single-run profiles:
- Each key names a
strategy.paramsfield the point overrides. - Each value must be a non-empty TOML array — that is one axis of the grid.
sweep_axis_arrayis the one reader every optimizer lane shares, so a non-array or an empty array is refused with the same sentence whichever method runs. - The order is deterministic: axes sorted by key, values in their array order.
- Each expanded point clears the grid section, so every point is itself a plain, runnable
single-backtest profile (
ParamscanPoint). - An absent or empty table expands to exactly one point with no overrides — the non-search path, unchanged.
The profile above is 3 × 2 = 6 points.
--param cannot build a grid. A range-looking value is refused by
crates/vike-cli/src/cmd/backtest.rs's parse_run_args, which tells you to declare a [paramscan]
table instead — a --param that wrote a grid would silently reroute the request and change the
response document with nothing in the output saying so.
The flags that steer a search
| flag | meaning |
|---|---|
--profile <path> | the profile, as a BASE the other flags override. Optional: a grid can also be built with no file at all, since --set paramscan.fast='[5, 10, 20]' lands a non-empty table on the text that crosses the wire. A command line carrying neither a file nor anything to build one from is refused |
--rank-by <metric> | sharpe · return · max_dd · equity · multi. Absent = whichever side runs applies its own default, annualized Sharpe |
--optimizer <method> | grid · euler · tpe · genetic. Absent is forwarded as absent — this side substitutes no default |
--euler-depth <n> · --trials <n> · --seed <n> | the per-method knobs, forwarded verbatim |
--addr <host:port> | the compute daemon; default 127.0.0.1:7880 |
--local | run on this machine by driving the standalone engine instead of dialling a server |
--json | print the server's JSON verbatim |
--show-effective | print the resolved profile with each override's origin and stop, exit 0, dialling nothing |
-h, --help | print the usage to stdout and exit 0 |
That --set route is not an accident of the parser:
crates/vike-cli/src/cmd/backtest.rs's PROFILE_TOP_LEVEL_KEYS names both paramscan and
sweep precisely so a key the engine accepts cannot be a CLI usage error, and a bracketed value is
typed as a TOML array by parse_scalar.
--rank-by and --optimizer are validated locally against their rosters
(crates/vike-cli/src/cmd/backtest.rs's RANK_METRICS and OPTIMIZERS, through one_of), so a
typo is a usage error instead of a wasted round trip. That is a spelling check and never a second
implementation: what a metric or a method MEANS is computed by vike_backtest::harness, on whichever
side runs. The three method knobs are not parsed here at all —
crates/vike-cli/src/cmd/backtest.rs's wire_search says why: vike_backtest::harness::search_select
is the one authority for what --trials abc means and for the sentence that refuses it, this crate
cannot call it, and forwarding the token is what keeps that sentence single on both routes.
--rank-by names how to ORDER results, not what work to do. A valid value is therefore ignored
— not an error — on a profile with no grid. --optimizer is the opposite: it says what work to do,
so crates/vike-cli/src/cmd/backtest.rs's route_of sends a gridless profile to the search verb
anyway, where the side that runs refuses it by name. search_requested is the predicate, and it
deliberately excludes --rank-by.
One guard did NOT come across, and the difference is worth knowing
sweep refused a profile with no grid before doing anything, because it PROMISED a grid.
backtest run promises nothing of the kind — the profile decides — so the same input is now one
backtest, on both routes, at exit 0. That is the correct answer rather than a lost check, and
crates/vike-cli/src/cmd/backtest.rs's module doc keeps the dropped guard beside its reason. What
is still covered is the case that asks for work which cannot happen: --optimizer on a gridless
profile, routed to the search verb by route_of and refused on whichever side runs.
The whole TOML crosses the wire, and that is still the point
The profile text is shipped as written; the SERVER parses and validates it with
crates/vike-backtest/src/harness/profile.rs's from_toml_str. There is one profile parser in the
workspace and one date parser, and a search therefore honours the whole profile — [engine].fee,
[engine.impact], [engine.resolution], [risk], snap_to_properties, a tick slice, a
cross-venue [[data.series]] slice — under the same costs as the same profile's single backtest.
Ranking and every printed statistic are the server's own BacktestReport numbers; nothing is
recomputed client-side, per the rule that no metric is reimplemented outside vike-backtest.
A search once ran under different costs than the same profile's backtest
That rule is load-bearing rather than tidiness, and the list above is the bill. This command used to
re-parse the profile into WireSpec/WireSlice/WireSweep DTOs — a SECOND profile parser and a
SECOND date parser in the workspace — and that mapping could carry none of those keys: a profile
that declared a fee, an impact model, a resolution, a [risk] table, a tick slice or a cross-venue
series ran here with them and ran on the server without them, silently. It also shipped a local
re-implementation of Sharpe, max drawdown and total return, which went with it.
--preset and --script are client-side rewrites of the profile text, applied before anything
is sent, so they are resolved against YOUR filesystem rather than the server's. Both behave
identically under --local: crates/vike-cli/src/cmd/backtest.rs's execute_local stages the
rewritten text and hands the engine that, so a local run is a byte-identical rehearsal of a remote
one. Routing runs on the rewritten text too, so a grid that arrives through a preset routes as a
search.
To see exactly what would be sent, without sending it:
vike-cli backtest run --profile search.toml --preset fast.toml --show-effective
vike-cli backtest run --profile search.toml --write-profile resolved.toml--write-profile writes the built profile and then runs. It refuses an existing path — this is the
one file the verb writes, and clobbering a committed profile from a flag typo is unrecoverable.
The output
One row per grid point, in the order the server ranked them, rendered by
crates/vike-cli/src/cmd/backtest.rs's print_paramscan. Every number is read straight out of that
row's report.
parameter search — 6 point(s), ranked server-side by sharpe
rank overrides final_equity return sharpe max_dd tradesThe overrides column renders each row's pairs as k=v, k=v, printed through the values' own
Display so nothing is re-typed on the way — they are TOML scalars serialized into JSON by then,
so it is serde_json's (fmt_overrides). A method that has a budget — euler, tpe
— prints its own cost line BETWEEN those two lines, above the column header; the exhaustive grid
carries none and prints none.
A point that failed never fails the whole search: it comes back as that row's error and the row still prints, so you can see which point failed and why.
7 fast=5, slow=40 FAILED: <the server's message>An empty grid prints (the search returned no grid points).
--json prints the server's document verbatim. There are three documents, one per route — a
single report, a ranked search report, a walk-forward report — and which one arrived is decided by
the route, not by a flag.
vike-cli backtest run --profile search.toml --jsonWhere it runs, and what it costs
The address is a three-rung ladder, folded by crates/vike-cli/src/cmd/backtest.rs's
resolve_addr: --addr → settings/config.toml's backtest_addr → the workspace constant
DEFAULT_BACKTEST_ADDR, 127.0.0.1:7880. A blank value is treated as absent rather than dialled.
That function's own doc names the digits — 7880 the compute daemon (vike-backend backtest --addr), 7878 the data server — and the module doc above it adds that the daemon binds localhost
only, so reach a remote one over a tunnel:
ssh -L 7880:localhost:7880 <host>Auth scope is Control, not Observe. Every profile-running verb is grouped with the write and
destructive ones because each one compiles client-supplied Rhai on the server. With no keys the dial
is unauthenticated, which is what a key-less server has always answered.
A daemon too old to honour a search selector is refused locally, and nothing is sent. It would otherwise decode the frame, drop the selector, run the exhaustive grid and report success — a silent downgrade. The client checks the handshake's feature list first and fails on the ordinary failure rung, naming the capability.
Parallelism multiplies memory, so it is bounded. Every point re-materializes its own data slice,
so peak RSS is threads × slice, not 1 × slice. The points run on a bounded pool of
crates/vike-backtest/src/harness/sweep.rs's sweep_threads workers — min(4, cores) by default,
raised with VIKE_SWEEP_THREADS on the box that RUNS the search — and VIKE_SWEEP_SEQUENTIAL=1 pins
that process back to one at a time. Both produce byte-identical reports: the collect preserves input
order and the ranking sort is stable, so scheduling cannot change the answer (ParamscanExec).
--local
--local drives the standalone backtest engine on this machine instead of dialling anything —
spawned, never linked, because this crate's identity is being DataFusion-free. --store DIR names
the hist-store root and --engine PATH names the binary outright; both are refused without
--local, where they would describe the wrong machine. --addr beside --local is refused for the
mirror-image reason.
Since the wire carries a method selector, --optimizer, --euler-depth, --trials, --seed and
--rank-by multi all work on both routes, and a knob refused under the wrong method is refused
with the same sentence whichever one ran.
A release attaches that engine beside vike-cli on Linux only, so on Windows this arm needs an
engine you built or fetched yourself; the missing-engine message says so.
crates/vike-cli/src/cmd/engine.rs's locate is what looks for it, and that module's doc is the
authority on the asymmetry and on the search order —
the standalone backtest binary is its reference page.
Composing with a walk-forward
[walkforward] is a sibling table, and the two axes do not compete: the grid says WHAT is computed,
[walkforward] says HOW it is validated. A profile declaring both is carried by the walk-forward
wire verb, and nothing on this side refuses the pair — crates/vike-cli/src/cmd/backtest.rs's
route_of is the whole map.
Whether each window re-searches the grid is a key in the profile, not the pairing itself.
crates/vike-backtest/src/harness/profile.rs's window_search reads [walkforward].search:
absent or "none" is the no-search CONTROL — every window trades the profile's own
[strategy.params], grid or no grid — while "sweep" scores the grid on each window's training
half and carries only the winner onto its validation half. "grid" was a synonym for one release
and is now refused by name telling you to write "sweep", and "sweep" with no grid is refused at
load by BacktestProfile::validate, because every window would "select" the one parameter set it
already has. So the composing profile says:
[walkforward]
n_splits = 5
search = "sweep"⚠ Declared residual, and it is on the far side. Ruling 4 deletes that key — the presence of the
two sections becomes the statement — and until it lands, a profile declaring both tables and
omitting search routes correctly from here and runs UNSEARCHED there, at exit 0, with nothing in
the report saying so. route_of's own doc carries it.
Two consequences for this page's flags. --rank-by and --optimizer are refused by name on a
walk-forward profile rather than dropped, because the wire verb that carries it takes the profile
TOML and nothing else — a window's ranking is [walkforward].rank_by, in the profile
(refuse_a_walkforward_flag). And --local cannot run one at all: the standalone engine has one
profile path, branching on the grid and nothing else, so there is nothing to spawn
(WALKFORWARD_HAS_NO_LOCAL_ARM).
Exit codes
| code | when |
|---|---|
0 | the server returned a report |
2 | a bad command line — an unknown flag, a --rank-by/--optimizer typo, a mode conflict |
3 | the daemon could not be reached |
1 | everything else — an unreadable profile or preset, a server-side error response |
The failure message goes to stderr on every rung.
Related
backtest is this verb's own reference page — the profile-building
flags, the single-run output, the stored-run sub-verbs. search and
sweeps are the difference between an exhaustive grid, a halving
schedule and a TPE run; running a search on the data service is
the end-to-end walkthrough including the tunnel.
walkforward is the anchored out-of-sample validation you run over
a search's winner, and validation is why. The Studio's own wire verb —
still spelled RunSweep, like every other peer-visible string — is untouched and still serves the
GUI, which holds a picker's spec and slice rather than a profile file, and its wire verb is DTO-shaped — see
remote runs.