vike
Backtesting

Run profiles

The BacktestProfile TOML — a data slice, an engine cost configuration and a strategy by registry name — with every opt-in knob validated at load so a typo fails the profile rather than silently changing the result.

A backtest is described by one TOML file. crates/vike-backtest/src/harness/profile.rs's BacktestProfile names a venue/symbol/interval data slice, an engine cost and cash configuration, and a strategy by registry name plus arbitrary TOML params. Unknown top-level keys are a hard parse error — the struct is deny_unknown_fields — so a misspelt section cannot silently no-op.

name = "bar-demo"                      # a free-form label; logs and report titles only

[data]
venue = "binance"
symbols = ["BTCUSDT", "ETHUSDT"]
kind = "bar"                           # "bar" or "tick"
interval = "1h"                        # bars only; defaults to "1d"
from = "2026-01-01T00"                 # epoch-ms as a string, or YYYY-MM-DDTHH UTC
to   = "2026-01-02T00"

[engine]
cash = 100000.0
fee_rate = 0.001
slippage = 0.0

[strategy]
name = "buy_hold"
[strategy.params]
size = 1.0

from and to accept a bare epoch-millisecond integer written as a string or a YYYY-MM-DDTHH UTC hour label; BacktestProfile::range resolves both to an inclusive range, and validate rejects from > to.

The data slice, in one of two forms

DataCfg accepts either the single-venue pair venue = "…" plus symbols = [ … ] or an [[data.series]] array whose entries each carry their own venue, symbol and — in tick mode — lane kind. Exactly one form must be present; mixing them is a validation error, not a precedence rule. The array form is the cross-venue one, and its order is meaningful: the header's example puts a spot reference series first so that, on an equal-timestamp tie in the tick merge, the reference sample is delivered before the print it should inform.

Symbols must be unique across the resolved series — the engine routes events to a symbol slot by name, and two series sharing one symbol would silently land in one slot. A bar-mode profile may not set a series kind: a bar series has no quote/trade/book lanes to filter.

The engine table

EngineCfg carries cash (must be > 0), a flat fee_rate, a flat slippage, and opt-in knobs whose absence is byte-identical to the profile that never mentioned them. Several are mode-bound, and for five of them — attach_funding, [engine.impact], feed_latency, queue_model and equity_sample_every — BacktestProfile::validate rejects a profile that sets one in the wrong mode rather than ignoring it. The other tick-only knobs carry no such check: run_backtest's bar arm never reads them, so a bar profile that sets one runs as if it had not.

keymodewhat it does
attach_fundingbar only — rejected on tickwindow-joins the stored funding series onto the price bars, one rate per bar whose window contains the event — never forward-filled
[engine.impact]bar only — rejected on tickmarket-impact slippage on top of the flat slippage; the tick lane replays a real book and needs no model
feed_latencytick only — rejected on bardeliver ticks in recorded arrival order while matching stays on venue time
order_latency_ms, fill_latency_mstick only — inert on barthe entry leg and the response leg of order latency, in milliseconds; either > 0 arms the model
fill_model = "l2book"tick only — inert on barthe depth-capped book-walk fill instead of the default L1 Tick model; pair with slippage = 0.0
queue_modeltick only — rejected on bar"risk_adverse", "prob_power" / "prob_power:N" or "prob_log" — a resting limit fills only once flow has consumed the size ahead of it
queue_seed_depth, queue_min_hold_mstick only — inert on barthe queue lane's seed depth, and a minimum-hold floor on the tagged maker lane's closing fills; both read only when queue_model is set
equity_sample_everytick only — rejected on barthin the equity curve to one sample per N ticks (0 records nothing)

The string-valued knobs are resolved strictly. EngineCfg::fill_model_kind accepts exactly one spelling, case-insensitively, and anything else is a fail-fast validation error — the earlier lenience let a typo like "l2_book" quietly select the optimistic Tick model and undo the depth-cap it was meant to enable. queue_model_kind follows the same rule.

Thinning the equity curve changes the report

equity_sample_every exists for a sweep over a very long tape — the curve costs 16 bytes per priced tick, so a 100M-tick replay carries 1.6 GB of samples per run whether or not anything reads them. But every curve-derived figure (max drawdown, Sharpe, the return series) becomes an approximation, and 0 degenerates them entirely. final_equity, n_trades, the trade log and per_symbol_pnl never move.

Three sub-tables have their own pages. [engine.fee] selects a vike_model::FeeSchedule — one kind today, "probability_scaled", the prediction-market curve qty × rate × p(1−p) that a flat rate cannot express; setting it beside a non-zero fee_rate is a validation error, because two cost models would be configured and only one could win (the cost model). [engine.resolution] builds the binary-outcome settlement source (the sim broker). [engine.impact] names the "almgren_chriss" model with its exec_time and window (market impact).

emulator_release_stops defaults to true in the harness — a fired conditional stop is released as a market order that fills the next event, as the live emulator does — even though the low-level EngineParams default is false, so a profile run reproduces live fill timing without an explicit knob. snap_to_properties wires the store's point-in-time instrument grid into the fill path and is single-venue only: the grid is looked up under one default venue, so validate refuses it beside a cross-venue slice rather than snapping every series to the first series' venue.

Snapping re-tags your bars

With snap_to_properties on, bar-mode bars are re-tagged SYMBOL.VENUE for the grid lookup, so a symbol-inferring strategy — buy_hold reading bar.symbol is the example crates/vike-backtest/src/harness/run.rs's bar_engine_params gives — needs an explicit strategy.params.symbol to route.

Strategy, risk, sweep, walk-forward

[strategy] is a name and a params table the strategy's own constructor interprets; which names resolve is the harness registry.

[risk] carries the same schema and the same compile-checked converter (vike_exec::ProfileRisk::to_risk_limits) the live run profile uses, so a limit proven here by a real SimBroker denial carries unchanged into paper and live. It is the only way a harness profile arms the pre-trade gate — [engine] has no leverage knob. One field is rejected: risk.max_orders_per_window is a wall-clock order-rate throttle, and a replayed month can execute in under a second.

[paramscan] turns the profile into a grid — each key a strategy.params field, each value an array to cross-product over; absent or empty means "not a search". ⚠ The table is named paramscan, and sweep is an ALIAS of it — a permanent one, because the profile is deny_unknown_fields and every file written before the rename would otherwise stop loading. Both spellings work; paramscan is the field.

[walkforward] turns it into a sequence of train/validate windows. Both tables live in the profile rather than on the wire because a deny_unknown_fields profile must parse for the whole TOML to be shippable verbatim to a remote runner, and both are inert for a plain single run.

⚠ This paragraph said [walkforward] "carries one knob, n_splits >= 1" — it carries nine fields, and two of them (purge, embargo) are legal on one of its two window forms only. That sentence was true when it was written, which is why the table is no longer described by hand here: backtest --profile renders every key, default, accepted value and refusal from the Rust types that declare them. See also sweeps and walk-forward for what the two tables are FOR.

When a profile is loaded from a file, BacktestProfile::from_path records the file's directory as base_dir, and relative paths inside the profile — today [engine.resolution].path — resolve against it rather than the caller's working directory, so a profile and its sidecar move together.

On this page