vike
TraderReferenceCli

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's paramscan field carries #[serde(alias = "sweep")], and its doc argues why that alias has no end date: BacktestProfile is deny_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_identically pins 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), and PROFILE_TOP_LEVEL_KEYS carries its own correction of the narrower claim. What still says sweep: the wire tags (#[serde(rename = "RunSweep")] and its siblings), the "sweep" field key, the capability strings run_sweep_profile/run_sweep, the MCP tool name run_sweep, and the alias above. crates/vike-cli/src/cmd/backtest.rs's declares_a_paramscan_grid carries 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.params field the point overrides.
  • Each value must be a non-empty TOML array — that is one axis of the grid. sweep_axis_array is 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.

flagmeaning
--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
--localrun on this machine by driving the standalone engine instead of dialling a server
--jsonprint the server's JSON verbatim
--show-effectiveprint the resolved profile with each override's origin and stop, exit 0, dialling nothing
-h, --helpprint 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   trades

The 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 --json

Where 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

codewhen
0the server returned a report
2a bad command line — an unknown flag, a --rank-by/--optimizer typo, a mode conflict
3the daemon could not be reached
1everything else — an unreadable profile or preset, a server-side error response

The failure message goes to stderr on every rung.

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.

On this page