vike
Research

vike-cli backtest --profile

Every table, key, default, accepted value and refusal of the TOML file vike-cli backtest --profile takes, derived from the Rust types that declare the schema.

A profile is the whole run as one TOML file: the data slice, the engine, the strategy, and — when you want them — a parameter grid and a walk-forward protocol. It is the only form a run takes that you can commit, diff, review and hand to a machine that is not yours.

You never have to write one by hand. Build a command line you like, then --write-profile it and keep the file it hands back:

# The shape: get a run right on the command line, then freeze it
vike-cli backtest run --venue binance --symbols BTCUSDT --interval 1h \
    --from 2026-01-01T00 --to 2026-02-01T00 --strategy buy_hold \
    --cash 10000 --fee-rate 0.001 --write-profile profiles/btc_1h.toml

# ...and from then on the profile IS the run
vike-cli backtest run --profile profiles/btc_1h.toml
vike-cli backtest run --profile profiles/btc_1h.toml --set engine.slippage=0.0005
vike-cli backtest run --profile profiles/btc_1h.toml --show-effective

The schema is CLOSED, so a typo is a hard failure rather than a silent default

Every table below refuses an undeclared key. That is the property worth knowing before you edit a profile by hand: a misspelt key does not fall back to its default and it does not warn — the load fails and names it. The same closure is what makes --set safe to paste, and it is why --show-effective is worth running once after any hand edit: it prints what actually resolved, which is the only way to tell a key you set from a key you thought you set.

The shape

A profile is 11 possible tables, 5 of them at the top level, and 113 keys across all of them. 11 keys are required and the rest have a default — so the shortest profile that loads is much smaller than this page:

[data]
from = "2026-01-01T00"
interval = "1h"
kind = "bar"
symbols = ["BTCUSDT", "ETHUSDT"]
to = "2026-02-01T00"
venue = "binance"
max_gap = "1d"
on_gap = "warn"
universe = "strict"

[engine]
cash = 10000
decide = "sequential"
fee_rate = 0.001
slippage = 0.0005

[strategy]
name = "buy_hold"

Every value above is one the CLI itself advertises as parsing, placed at the key that flag writes — the example is joined from the two exported tables rather than typed here, so it cannot drift from either.

The required keys, in full: data.kind, data.from, data.to, engine.cash, strategy.name, data.series.venue, data.series.symbol, engine.impact.model, engine.fee.kind, engine.resolution.kind, engine.sizer.kind. Each of the optional tables is required ONCE YOU DECLARE IT: [risk], [walkforward], [data.series], [engine.impact], [engine.fee], [engine.resolution], [engine.sizer] may all be absent, and a table that is present must satisfy its own required keys.

Walk-forward windows

A [walkforward] table turns a single run into a sequence of train/validate windows, and it must declare exactly one window form. There are two, and they are not two spellings of one idea:

# form (a) — a SPLIT COUNT. The window length is whatever the range divided by N happens to be,
# so it moves when the range moves.
[walkforward]
n_splits = 6

# form (b) — DURATIONS. The window length is what you wrote, whatever the range does.
[walkforward]
train = "12mo"   test = "3mo"   step = "3mo"
train = "5000bars"   test = "1000bars"   # calendar and bar suffixes mix freely

# purge and embargo are legal on the DURATION form only — the gap has to be measured in the same
# units as the windows, and a split count has no length to measure against.
[walkforward]
train = "12mo"   test = "3mo"
purge   = "8h"   # bars dropped BETWEEN train and test
embargo = "1d"   # the minimum gap AFTER a validation window

A step shorter than test is refused — by the RUNNER, not at load

step is how far each window advances, and an absent step is test, which tiles the windows edge to edge: each window starts where the last one ended. A larger step is legal and leaves a gap — the same separation embargo produces deliberately. A shorter one is refused by name, because the walk folds every window's out-of-sample curve onto ONE running equity, so overlapping windows would compound the same bars more than once and report a return and a Sharpe roughly the overlap factor too high, with nothing in the report showing it.

Two things worth knowing about WHERE that refusal lives. It is the walk-forward runner's, not the profile loader's, so it fires when the walk starts rather than when the file parses. And it compares resolved bar counts against data.interval, both floored — so a step shorter than test in wall-clock but flooring to the same number of bars passes.

Tiling is not the same as judging every bar

Edge-to-edge windows mean no bar inside the walked region is validated twice and none is skipped between windows. They do not mean every bar is judged: the leading train bars (plus any purge gap) sit before the first validation window, and a trailing remainder shorter than test is dropped rather than shortened. Those bars are never validated at all, and no field in the report says how many they were.

purge is a gap; embargo DROPS WINDOWS — and it is not López de Prado's embargo

purge is the gap between a training half and the validation window that follows it, so a label or feature computed over a lookahead cannot straddle the boundary. A strategy whose features look back further than purge still leaks: the knob is a length you have to match to your own feature window, not a switch.

⚠ embargo does something different from what its name promises. It is a minimum gap required between one validation window's END and the next window's START, and a window that falls inside that gap is dropped from the window list — not shifted, and no bars are held out of any training half. So it borrows the word without the semantics: the form that removes post-validation bars from a later window's TRAIN side is a named reopener in the decision record, not what this key does. Read it as "thin the windows until they are this far apart". Both keys are legal on the duration form only, and both round UP to a whole number of bars.

keytakeswhen absentwhat it does
n_splitsintegerabsentNumber of out-of-sample windows — window form (a), the one that existed first.
trainstringabsentWindow form (b)/(c) — the TRAIN window's length, as a vike_model::time::parse_span duration ("12mo", "90d", "5000bars"). Mutually exclusive with Self::n_splits; requires Self::test.
teststringabsentWindow form (b)/(c) — the VALIDATION window's length. Required with Self::train.
stepstringabsentHow far each window advances. Absent ⇒ equal to Self::test, which tiles the validation windows edge to edge.
purgestringabsentBars dropped BETWEEN train and test, so test_start - train_end == purge exactly.
embargostringabsentThe minimum gap AFTER a validation window before the next one may start. A window whose test_start falls inside the zone is DROPPED, not moved — moving it would make Self::step mean something other than what was written. Same duration-form-only rule as Self::purge, and the same ROUNDS-UP rule.
searchstringabsentWhich search each window runs on its OWN training half — the knob that chooses between the two walk-forward protocols. Absent or "none" is the CONTROL (WindowSearch::None): every window trades the profile's own strategy.params, which is the fixed-parameter walk this section has always described. "sweep" — named for the table it expands — scores this profile's sweep grid on each window's train half and carries only the winner onto its validation half.
modestringabsentThe TRAIN-window shape (WalkMode): absent or "anchored" is the expanding window every split trains from bar 0; "rolling" trains on the ONE chunk immediately preceding the window. Case-insensitive and trimmed (Self::walk_mode), unknown values refused at load.
rank_bystringabsentHow a window SCORES its candidates when Self::search runs one: any of the four RankMetric names the sweep's own --rank-by takes ("sharpe" | "return" | "max_dd" | "equity", case-insensitive), defaulting to RankMetric::default. Consulted only when a search actually runs — the control scores nothing.

What it refuses, verbatim. These are the sentences the binary prints, not a paraphrase of them — which is why they carry the accepted set for the three string knobs above and the default each one takes when absent:

refusedraised by
[walkforward] declares no window form — set n_splits = N, or a train/test pair (e.g. train = "12mo", test = "3mo")WalkforwardCfg::window_form
data.detail_interval is unavailable to a [walkforward] profile: the walk hands each window a sliced coarse series while nothing slices the detail tape, so every window's last step would fill against bars from AFTER the window ended. That is look-ahead, not merely waste. Run the profile without [walkforward], or without the detail tape.BacktestProfile::refusals
profile has no [walkforward] table — a walk-forward needs a window form, e.g. [walkforward] n_splits = 4 or [walkforward] train = "12mo" test = "3mo"walkforward_preflight
the [walkforward] window form yields NO windows over the {} bar(s) of {symbol} in this range — a walk that evaluates nothing must not return a report that looks like onewalkforward_preflight
the duration window form needs BOTH walkforward.train and walkforward.test (step defaults to test) — a half-declared window has no shapeWalkforwardCfg::window_form
unknown walkforward.mode {other:?} (want anchored | rolling; absent = anchored)WalkforwardCfg::walk_mode
unknown walkforward.rank_by {raw:?} (want sharpe | return | max_dd | equity; absent = sharpe)WalkforwardCfg::rank_metric
unknown walkforward.search {other:?} (want none | sweep; absent = none, the no-search control)WalkforwardCfg::window_search
walkforward.n_splits and walkforward.train/test/step are two PROTOCOLS, not two spellings — a split count is defined BY the range and moves when the range moves, a duration does not. Set exactly one.WalkforwardCfg::window_form
walkforward.n_splits must be >= 1, got 0BacktestProfile::refusals
walkforward.n_splits must be >= 1, got 0resolve_windows
walkforward.purge / walkforward.embargo need the DURATION window form (train/test) — the split-count walk stays gap-free (decision 0046 — the bar mode walk forward has no purge). Replace n_splits with a train/test pair, or drop the key.WalkforwardCfg::window_form
walkforward.search = "grid" was renamed to "sweep" — one name for one knob, and it is the table it expands ([sweep]). Write search = "sweep".WalkforwardCfg::window_search
walkforward.search = "sweep" needs a [sweep] table to search — this profile has none, so every window would 'select' the one parameter set it already has. Add the grid, or drop the key for the no-search control.BacktestProfile::refusals
walkforward.step resolves to {step} bar(s) and walkforward.test to {test} — a step shorter than the validation window makes consecutive windows OVERLAP, and the stitched out-of-sample curve would compound the same bars more than once, reporting a return and a Sharpe roughly the overlap factor too high. Set step >= test: equal tiles the windows edge to edge (that is also what an absent step means), and larger leaves a gap between them.resolve_windows
walkforward.{key} is a duration, so it needs a bar interval to convert against — data.interval {interval:?} is not one (want e.g. "1m", "1h", "1d"), or spell the window with the bars suffixto_bars
walkforward.{key} is shorter than one {interval} bar — it would resolve to zero bars and change nothing; widen it or use the bars suffixto_bars
walkforward.{key}: {e}span_field

Position sizing

[engine.sizer] decides how big each entry is, and it is the knob most worth setting explicitly: left alone, a strategy's own requested size passes straight through, so the size that reaches the book is whatever the strategy happened to ask for rather than a share of the account you chose.

Each kind reads one knob of its own, and two of them wrap another sizer instead of sizing anything themselves — which is what lets a budget and a throttle compose:

# the common case: every entry targets 2% of current equity as notional
[engine.sizer]
kind = "pct_equity"   pct = 0.02

# a chain: the same 2%-of-equity sizer, scaled down while the account is in drawdown
[engine.sizer]
kind = "drawdown_throttle"   sensitivity = 2.0   floor = 0.25

[engine.sizer.base]
kind = "pct_equity"   pct = 0.02

max_risk_pct with no stop sizes to ZERO — and a zero-size entry is dropped in silence

max_risk_pct sizes each entry so that hitting its stop costs exactly pct of equity: qty = pct × equity / (|entry − stop| × multiplier). It needs the stop to arrive on the entry call, and only the plain market and limit entries carry one — the stop, trailing and close verbs all pass none.

When no stop arrives it returns zero, the submit guards drop the order without pushing it, and nothing is refused at load. So a profile pairing this sizer with a strategy that enters through those verbs trades NOTHING, reports a flat curve, and raises no error anywhere. If you set this sizer, check the first few fills exist before you believe the result. (pct_volatility is the sibling that degrades instead: with no ATR it falls back to sizing on equity.)

The kinds, and the knob each one requires. Derived from the arms of the sizer's own constructor, so a kind cannot appear here without being buildable and cannot be buildable without appearing here:

kindrequireswraps a base sizer
pass_through— nothingno — a base is refused
fixed_dollaramountno — a base is refused
fixed_sharessharesno — a base is refused
pct_equitypctno — a base is refused
pct_volatilitypctno — a base is refused
max_risk_pctpctno — a base is refused
portfolio_heatmax_heatyes — [engine.sizer.base] is required
drawdown_throttlesensitivity, flooryes — [engine.sizer.base] is required
keytakeswhen absentwhat it does
kindstringrequiredOne of "pass_through" | "fixed_dollar" | "fixed_shares" | "pct_equity" | "pct_volatility" | "max_risk_pct" | "portfolio_heat" | "drawdown_throttle" — every concrete vike_analytics::sizing::PositionSizer this crate ships and nothing else (Self::build).
amountnumberabsentcrate::sizing::FixedDollarSizer::amount — fixed cash notional per entry. Required for "fixed_dollar".
sharesnumberabsentcrate::sizing::FixedSharesSizer::shares — fixed share/contract count per entry. Required for "fixed_shares".
pctnumberabsentThe knob "pct_equity" / "pct_volatility" / "max_risk_pct" each read as their own fraction of equity (target notional, ATR-risk budget, and stop-risk budget respectively — see vike_analytics::sizing's doc on each). Required for those three kinds.
max_heatnumberabsentcrate::sizing::PortfolioHeatSizer::max_heat — total open-risk cap as a fraction of equity. Required for "portfolio_heat".
sensitivitynumberabsentcrate::sizing::DrawdownThrottleSizer::sensitivity. Required for "drawdown_throttle".
floornumberabsentcrate::sizing::DrawdownThrottleSizer::floor. Required for "drawdown_throttle".
basetableabsentThe wrapped sizer for "portfolio_heat" / "drawdown_throttle" — required for those two kinds (Self::build refuses their absence), and REFUSED under every other kind, which reads it nowhere: a base under a scalar kind would run one sizer while the profile read as a chain. Boxed because SizerCfg nests itself — see MAX_SIZER_DEPTH for the bound on how far.
refusedraised by
engine.sizer nesting is {depth} levels deep ([engine.sizer{}]) — MAX_SIZER_DEPTH is {MAX_SIZER_DEPTH}; flatten the base chain (no composition of the two wrapping kinds needs to nest this deep)SizerCfg::build_at_depth
engine.sizer.kind = "drawdown_throttle" wraps a base sizer — add an [engine.sizer.base] table naming itSizerCfg::need
engine.sizer.kind = "portfolio_heat" wraps a base sizer — add an [engine.sizer.base] table naming itSizerCfg::need
engine.sizer.kind = {kind:?} does not wrap a base sizer, but [engine.sizer{}.base] is set — only "portfolio_heat" and "drawdown_throttle" read it, so the chain would run {kind:?} alone. Remove the base table, or name a wrapping kindSizerCfg::need
engine.sizer.{field} is required when engine.sizer.kind = {kind:?}SizerCfg::need
unknown engine.sizer.kind {other:?} (want pass_through | fixed_dollar | fixed_shares | pct_equity | pct_volatility | max_risk_pct | portfolio_heat | drawdown_throttle)SizerCfg::need

What --set reaches

--set <dotted key>=<value> writes straight into the profile before it is sent, so it reaches any scalar leaf under any table the schema declares — not only the keys that have a flag of their own. Only the FIRST path segment is checked here; a wrong deeper key is refused by the far side, because the schema is closed.

Three limits are worth knowing before you reach for it. There is no array-index grammar, so the keys of [[data.series]] cannot be set per element — that table needs a file. A leaf that names a whole TABLE is refused rather than replaced, so set its leaves instead; an inline-table replacement has no --set spelling. And the closed schema does not reach the two untyped subtrees: a typo under strategy.params.* or paramscan.* is a silent no-op that neither side catches.

# costs and fills
vike-cli backtest run --profile p.toml --set engine.fee_rate=0.001 --set engine.slippage=0.0002

# money you actually have — OFF unless you turn it on
vike-cli backtest run --profile p.toml --set engine.cash_gate=true --set engine.maint_margin=0.005

# the volume-participation cap, and position shape
vike-cli backtest run --profile p.toml --set engine.volume_limit=0.05 --set engine.max_open_positions=10

# a nested table is reached by its dotted path, like any other key
vike-cli backtest run --profile p.toml --set engine.sizer.kind=pct_equity --set engine.sizer.pct=0.02

A default run is frictionless — know what is off

Cash admission (engine.cash_gate → false), the margin and liquidation watchdog (engine.maint_margin → 0.0, which short-circuits the check entirely) and the volume-participation cap (engine.volume_limit → absent, uncapped) are all off unless you turn them on. So a default run can spend money it does not have and fill more size than the event's own volume, and it will report the result without complaint. Turn them on before you believe a number, and confirm with --show-effective.

⚠ engine.liq_buffer is not in that group, and reading it as one is a mistake worth avoiding: it defaults to 0.10, a deliberate cushion rather than zero. It only sits idle in a default run because the watchdog maint_margin gates is off — arm that and the buffer is already at 10%.

Some engine behaviours no key reaches — and the list is not gated

Several engine behaviours are implemented and reachable from no profile key at all: the per-symbol sessions table (session_gate exposes the gate alone), the point-in-time membership mask that keeps a delisted instrument out of a replay, the interpolated order-latency model (the latency keys reach the constant model only), the funding-rate source seam, per-symbol sub-bar series, the stale-print fill deferral and option-expiry settlement.

⚠ Do not treat that as a complete list. Nothing checks this direction — the gate on the Rust side asserts every profile KEY reaches the engine, never that every engine field has a key — so the set is derived by diffing the two types and it can grow without anything noticing. If your run depends on one of these, the dependency is currently unexpressable in a profile, and a clean load is not evidence it was honoured.

Every table it reaches, and how many keys each one has. A nested table is reached by its dotted path; the count is the file's, not this page's:

tablekeysabsent is legalreached as
the root7— always present--set <key>=<value>
[data]14no — required--set data.<key>=<value>
[engine]35no — required--set engine.<key>=<value>
[strategy]2no — required--set strategy.<key>=<value>
[risk]11yes--set risk.<key>=<value>
[walkforward]9yes--set walkforward.<key>=<value>
[data.series]3yes--set data.series.<key>=<value>
[engine.impact]5yes--set engine.impact.<key>=<value>
[engine.fee]14yes--set engine.fee.<key>=<value>
[engine.resolution]5yes--set engine.resolution.<key>=<value>
[engine.sizer]8yes--set engine.sizer.<key>=<value>

Every key, table by table

The whole schema, in the order the types declare it. Nothing above needs this section to be read — it is here so a question about one key always has an answer.

The root table

the profile's own keys · refuses an undeclared key · read from BacktestProfile

keytakeswhen absentwhat it does
namestringabsentFree-form label (logs/report titles only — not used for lookup).
datatablerequired—
enginetablerequired—
strategytablerequired—
risktableabsentOptional pre-trade RiskGate limits (runprofile-wiring-step2). The SAME risk schema AND the SAME compile-checked converter (vike_exec::ProfileRisk::to_risk_limits) vike-core's RunProfile uses for paper/live — so a limit proven here (via BacktestProfile::validate + a real SimBroker denial) carries unchanged into paper and live, rather than being re-derived by a parallel converter that could drift. Absent (the default) ⇒ crate::EngineParams::risk_limits stays None ⇒ byte-identical to every profile written before this field existed: engine today has no leverage knob either, so risk is the ONLY way a harness profile arms the gate at all (crate::engine::SimBroker::build_risk_gate's (None, None) => None arm).
paramscanany TOML valueabsentOptional PARAMETER-SEARCH grid: each key is a strategy.params field name, each value an array of TOML values to cross-product over (expansion lives in super::sweep::expand_paramscan). Absent or empty means "not a parameter search".
walkforwardtableabsentOptional anchored WALK-FORWARD config — the paramscan sibling: present means this profile can ALSO be run through super::walkforward::run_walkforward over walkforward.n_splits out-of-sample windows. Absent (the default) is byte-identical to every profile written before this field existed, and run_backtest/run_paramscan ignore the section entirely — exactly as they ignore a sweep table they were not asked to expand.

[data]

a table · required · refuses an undeclared key · read from DataCfg

Choosing the slice.

# Name the slice with the sugar flags — one venue, its symbols, the bar interval
vike-cli backtest run --profile p.toml --venue binance --interval 1h   # the single-venue form; two venues need [[data.series]], which --set cannot reach
vike-cli backtest run --profile p.toml --symbol BTCUSDT,ETHUSDT   # one comma list becomes ONE data.symbols array, in argv order
vike-cli backtest run --profile p.toml --from 2026-01-01T00 --to 2026-02-01T00   # YYYY-MM-DDTHH is UTC, and from must be <= to
vike-cli backtest run --profile p.toml --from 1767225600000 --to 1769904000000   # bare epoch-ms works here because the sugar keeps it a STRING

# The same keys, spelled the way the profile spells them
vike-cli backtest run --profile p.toml --set data.venue=bybit --set data.interval=4h   # the universal channel, but it skips --interval's own grammar check
vike-cli backtest run --profile p.toml --set data.to=2026-03-01T00   # a punctuated date stays a string; --set data.from=1767225600000 types an INTEGER and is refused far-side

# Bars or recorded ticks
vike-cli backtest run --profile p.toml --kind bar --interval 1h   # bar|tick is exact and case-sensitive; engine.timeframes must be COARSER than this interval
vike-cli backtest run --profile p.toml --kind tick   # the bar-only engine keys (timeframes, cash_gate, attach_funding) are then refused

# Read the slice back before spending any compute
vike-cli backtest run --profile p.toml --venue bybit --from 2026-02-01T00 --to 2026-03-01T00 --show-effective   # prints the resolved profile with each override's origin, then exits 0 — no daemon, no engine, no store
vike-cli backtest run --venue binance --symbol BTCUSDT --interval 1h --from 2026-01-01T00 --to 2026-02-01T00 --show-effective   # --profile is optional, so this reads back a slice built from flags alone
vike-cli backtest run --profile p.toml --from 2026-02-01T00 --to 2026-03-01T00 --write-profile feb.toml --show-effective   # freeze the shifted slice to a new file (it refuses an existing path) and stop before the run
keytakeswhen absentwhat it does
data.venuestringabsentSingle-venue form: the one venue every entry of Self::symbols lives under. Mutually exclusive with Self::series.
data.symbolsarray of string[]Single-venue form: the symbols to load under Self::venue. Mutually exclusive with Self::series.
data.seriesarray of tables[]Cross-venue form ([data.series]): one entry per series, each naming its own venue, symbol and (tick mode only) lane kind. Mutually exclusive with Self::venue/Self::symbols.
data.kind"bar" | "tick"required—
data.intervalstring1d—
data.fromstringrequiredBare epoch-ms integer (as a string) or YYYY-MM-DDTHH UTC — see BacktestProfile::range.
data.tostringrequiredSame format as from.
data.warmupstringabsentHow much history the strategy's indicators need before the run's FIRST tradeable event — a bar/event count ("200bars") or, in bar mode, a duration ("7d") the base Self::interval divides into one. Absent (the default) is byte-identical to every profile written before this key existed: the gate stays exactly Strategy::warmup().
data.detail_intervalstringabsentOpt-in INTRABAR DETAIL TAPE: a per-symbol series FINER than Self::interval, loaded over the same range and bucketed into each coarse step, against which every order is resolved while the strategy keeps deciding on the coarse timeframe. Absent (the default) is byte-identical to every profile written before this key existed. BAR MODE ONLY.
data.explaintrue / falsefalsePLAN-THEN-APPLY: resolve this profile's whole data slice, report what the store holds for it, and EXIT without computing. Absent (the default) is byte-identical to every profile written before this key existed.
data.require_coveragetrue / falsefalseARM THE COVERAGE GATE: refuse (or warn on) a run whose window the store does not cover. Absent (the default) is byte-identical to every profile written before this key existed — the run loads whatever is there and says nothing.
data.max_gapstringabsentHow much of the window Self::require_coverage tolerates missing in ONE span, as a fixed duration ("1d", "4h", "900000" ms). Absent = ZERO tolerance: any missing span at all is a finding.
data.on_gapstringabsentWhat Self::require_coverage DOES about a finding: "refuse" (the default — nothing runs), "warn" (the findings are logged and the run proceeds unchanged), or "run" (the gate is inert).
data.universestringabsentPOINT-IN-TIME UNIVERSE MEMBERSHIP — the survivorship defence. "declared" (the default, byte-identical: the symbol list is taken verbatim), "covered" (a member whose tape does not span the window is NAMED and the run proceeds), or "strict" (that run is REFUSED).

What [data] refuses, verbatim from the code that refuses it:

refusedraised by
data.detail_interval and engine.cash_gate are mutually exclusive: cash_gate routes the whole bar step through the gated fill lane and UNCONDITIONALLY disables the granular sub-bar lane, so the detail tape would be loaded, scanned and then ignored while every order was reported as resolved against it. Set one.BacktestProfile::refusals
data.detail_interval and engine.decide = "simultaneous" are mutually exclusive: the simultaneous mode implies the shared-cash admission phase, which routes the whole bar step through fill_step_gated and disables the granular sub-bar lane — the detail tape would be loaded, paid for and ignoredBacktestProfile::refusals
data.detail_interval is unavailable to a [walkforward] profile: the walk hands each window a sliced coarse series while nothing slices the detail tape, so every window's last step would fill against bars from AFTER the window ended. That is look-ahead, not merely waste. Run the profile without [walkforward], or without the detail tape.BacktestProfile::refusals
data.detail_interval {raw:?} ({ms}ms) must be strictly FINER than data.interval {:?} ({base}ms) — at this resolution each coarse step buckets at most one sub-bar, which resolves no stop-versus-target ambiguity while paying for a second whole store scan. This is the inverse of the engine.timeframes rule, where a synthesised series must be COARSER than the baseDataCfg::detail_interval_ms
data.detail_interval {raw:?} is bar-mode only: the intrabar detail tape is bucketed into COARSE BAR steps and consulted by StrategyEngine::run's bar fill phase, and run_ticks never reads those buckets — a tick replay already resolves every order against the tape it is replayingDataCfg::detail_interval_ms
data.detail_interval {raw:?} is not a valid interval (want a count then one of s/m/h/d, e.g. "1m") — it is handed straight to the hist store as a series interval, so it must name a series the store can holdDataCfg::detail_interval_ms
data.detail_interval {raw:?} resolves to {ms}ms — a zero-length window makes every sub-bar boundary the same instantDataCfg::detail_interval_ms
data.from ({start}) must be <= data.to ({end})BacktestProfile::refusals
data.interval {:?} is not a valid interval, so data.detail_interval {raw:?} cannot be checked as finer than itDataCfg::detail_interval_ms
data.interval {:?} is not a valid interval, so engine.timeframes cannot be checked against itBacktestProfile::refusals
data.interval {:?} is not a valid interval, so the duration data.warmup {raw:?} cannot be converted to a step countDataCfg::warmup_steps
data.interval {:?} resolves to {base}ms, so the duration data.warmup {raw:?} names no number of stepsDataCfg::warmup_steps
data.max_gap is set without data.require_coverage = true, so the gate whose tolerance it names is not armed and this key is read by nothing. Arm the gate, or remove the toleranceBacktestProfile::refusals
data.max_gap {raw:?} is a BAR COUNT, and a gap in this store is a wall-clock span in epoch-ms: converting {n} bars to a duration needs an interval, and a tick profile has none. Write the tolerance as fixed time — "4h", "1d"DataCfg::max_gap_ms
data.max_gap {raw:?} is a CALENDAR span, and a calendar month is not a fixed number of milliseconds (one month from 31 January is 28 days, from 31 March 30) — so the tolerance would depend on where data.from happens to sit, and two runs naming the same tolerance would accept different amounts. Write it in fixed time — "30d"DataCfg::max_gap_ms
data.max_gap {raw:?} resolves to {ms}ms — a non-positive tolerance is what an ABSENT data.max_gap already means (tolerate nothing), so this spelling can only mislead. Remove the key, or name a real durationDataCfg::max_gap_ms
data.max_gap: {e}DataCfg::max_gap_ms
data.on_gap is set without data.require_coverage = true, so the gate it configures is not armed and this key is read by nothing — while it reads as though a run over an uncovered window would be stopped. Arm the gate, or remove the dispositionBacktestProfile::refusals
data.on_gap {raw:?} is not one of {roster}. It says what data.require_coverage DOES about a window the store does not cover: refuse stops the run (the default, because a gate that only warns is invisible on every run nobody reads), warn logs the findings and runs unchanged, run leaves the gate inert while keeping the file's statement of what this run is supposed to requireDataCfg::on_gap
data.series[{:?}].kind = {:?} is tick-mode only: a bar series has no quote/trade/book lanes to filterBacktestProfile::validate_data_slice
data.symbols must not be emptyBacktestProfile::validate_data_slice
data.symbols must not be empty (or use the cross-venue [[data.series]] form)BacktestProfile::validate_data_slice
data.universe {raw:?} is not one of {roster}. It is the POINT-IN-TIME membership rule: declared takes the symbol list verbatim (the default), covered names every member whose tape does not span the window and runs anyway, strict refuses that run. None of the three ever drops a member — a universe a run narrowed silently would not be the one the profile namesDataCfg::universe_mode
data.venue is required alongside data.symbolsBacktestProfile::validate_data_slice
data.venue/data.symbols and [[data.series]] are two ways to name the SAME slice — set exactly one (the [[data.series]] form is the cross-venue one)BacktestProfile::validate_data_slice
data.warmup {raw:?} is a CALENDAR span, and a calendar month is not a fixed number of steps (one month from 31 January is 28 days, from 31 March 30) — so its length would depend on where data.from happens to sit, which is the irreproducibility this key exists to remove. Write it in bars ("200bars") or in fixed time ("90d")DataCfg::warmup_steps
data.warmup {raw:?} is a DURATION, which is bar-mode only: a tick tape has no fixed cadence to divide it by, so no number of ticks corresponds to it. The engine gates run_ticks on an EVENT index, so write the requirement as a count — e.g. "200bars" means 200 ticks hereDataCfg::warmup_steps
data.warmup: {e}DataCfg::warmup_steps
engine.fee.kind = "venue" needs engine.fee.venue — it is not inferred from [data]. EngineParams::fee_schedule is ONE schedule for the whole run, so a cross-venue slice has no single answer to infer, and naming the venue is the operator asserting which venue's book this run is costed at.FeeCfg::validate_shape
engine.resolution: {} window slug(s) in data.series have no binary winning_index (first: {names:?}). A window with no payout would end the run marked at its last traded price — remove it from the slice or supply its resolution.ResolutionCfg::build
engine.snap_to_properties is single-venue only: the point-in-time instrument grid is looked up under one default_venue, so a cross-venue [[data.series]] slice would snap every series to the first series' venueBacktestProfile::validate_data_slice
engine.timeframes {tf:?} ({ms}ms) must be coarser than data.interval {:?} ({base}ms) — a finer window cannot be synthesised from the base stream and would return the base bars re-labelledBacktestProfile::refusals
unknown engine.decide {other:?} (want sequential | simultaneous; absent = sequential, the per-symbol walk in the order data.symbols lists them)EngineCfg::decide_mode
walk-forward is bar-mode only: the splitter divides ONE bar series by index, which a tick replay does not produce — set data.kind = "bar"walkforward_preflight
walkforward.{key} is a duration, so it needs a bar interval to convert against — data.interval {interval:?} is not one (want e.g. "1m", "1h", "1d"), or spell the window with the bars suffixto_bars

[engine]

a table · required · refuses an undeclared key · read from EngineCfg

Costs and fills.

# Cost per fill — nothing in this group is mode-gated, so it applies in bar mode too
vike-cli backtest run --profile p.toml --fee 0.001 --slippage 0.0005   # sugar for engine.fee_rate + engine.slippage
vike-cli backtest run --profile p.toml --set engine.fee_rate=0.0004 --set engine.slippage=0.0002   # the taker rate you measured, 2bp of slip
vike-cli backtest run --profile p.toml --set engine.fee.kind=probability_scaled --set engine.fee.taker_rate=0.02   # the [engine.fee] model INSTEAD: a non-zero engine.fee_rate beside it is refused
vike-cli backtest run --profile p.toml --set engine.fee.maker_rebate_share=0.5   # credit half the maker rebate back

# Stop the tick lane filling your whole size at the quote
vike-cli backtest run --profile p.toml --kind tick --set engine.fill_model=l2book --set engine.slippage=0.0   # l2book rests what the book cannot cover, so the walk IS the slippage
vike-cli backtest run --profile p.toml --kind tick --set engine.fill_model=l2book --fee 0.0004   # tick-only per its doc, yet profile.json carries NO refusal row — a bar run accepts it and nothing changes

# Make a passive maker earn its fills — queue position first, then reaction time
vike-cli backtest run --profile p.toml --kind tick --set engine.queue_model=risk_adverse   # the conservative bound: the front shrinks only on hard evidence
vike-cli backtest run --profile p.toml --kind tick --set engine.queue_model=prob_power:2   # probabilistic, f(x)=x^2; prob_log is the third spelling
vike-cli backtest run --profile p.toml --kind tick --set engine.queue_model=prob_log --set engine.queue_seed_depth=500.0   # size-ahead fallback where neither book nor quote shows a depth
vike-cli backtest run --profile p.toml --kind tick --set engine.queue_model=risk_adverse --set engine.queue_min_hold_ms=2000   # refuse a fabricated sub-2s maker round-trip
vike-cli backtest run --profile p.toml --kind tick --set engine.order_latency_ms=50 --set engine.fill_latency_ms=50   # entry leg + response leg; tick-only too, and likewise unrefused in bar mode

# Confirm the stack before you spend an engine run
vike-cli backtest run --profile p.toml --kind tick --set engine.queue_model=prob_power:2 --show-effective   # prints each override's origin and STOPS — no engine, exit 0
vike-cli backtest run --profile p.toml --kind bar --set engine.queue_model=prob_power:2   # the ONE knob here bar mode refuses outright; the others just go quiet

The money gates — every one of these is off until you set it.

# Admit a fill only when the shared cash pool can fund it — OFF by default, so a run can overspend
vike-cli backtest run --profile p.toml --kind bar --set engine.cash_gate=true              # bar mode only: the tick lane never consults it
vike-cli backtest run --profile p.toml --kind bar --set engine.cash_gate=true --cash 25000 # size the pool it admits against; must be > 0

# Arm the liquidation watchdog — maint_margin defaults to 0, and 0 means the watchdog never fires
vike-cli backtest run --profile p.toml --set engine.maint_margin=0.005                     # 0.5% of adverse notional, a perp-like maintenance rate
vike-cli backtest run --profile p.toml --set engine.maint_margin=0.005 --set engine.liq_buffer=0.02   # cushion tighter than the 0.10 default
vike-cli backtest run --profile p.toml --set engine.maint_margin=0.005 --set engine.liq_buffer=0.0    # no cushion at all — the harshest honest run

# Bound one fill by what the market printed, and the position by margin — both uncapped by default
vike-cli backtest run --profile p.toml --set engine.volume_limit=0.1                       # at most 10% of the EVENT's own volume per fill; must be in (0, 1]
vike-cli backtest run --profile p.toml --set engine.volume_limit=1.0                       # the loosest value that is still a cap: all of one print, never more
vike-cli backtest run --profile p.toml --set engine.leverage=3.0                           # a 1/L margin requirement — only on a profile with NO [risk] table
vike-cli backtest run --profile p.toml --set engine.leverage=3.0 --set engine.clamp_to_leverage=true  # clamp an oversized order down instead of rejecting it

# Read back what this command line set, with each origin — no engine, no store, exit 0
vike-cli backtest run --profile p.toml --kind bar --set engine.cash_gate=true --set engine.maint_margin=0.005 --set engine.volume_limit=0.1 --show-effective
vike-cli backtest run --profile p.toml --set engine.liq_buffer=0.02 --show-effective       # the only way to SEE liq_buffer: unset, its 0.10 default lives where this side cannot read it
keytakeswhen absentwhat it does
engine.cashnumberrequired—
engine.fee_ratenumber0—
engine.slippagenumber0—
engine.snap_to_propertiestrue / falsefalse—
engine.attach_fundingtrue / falsefalseOpt-in: window-join the stored market funding series onto the replayed bars' Bar.funding at each funding-event timestamp, for perp funding accrual + funding-reading strategies. The market funding-rate backfill (#761) writes a (venue, symbol, "funding") bar series whose each Bar.funding = Some(rate) sits at a funding-event ts; with this on, crate::harness::window_join_funding attaches each rate to the ONE price bar whose window contains the event (NEVER forward-filled onto every bar — that would make the SimBroker bar-loop accrual charge every bar instead of once per 8h/1h interval). Both consumers then work: the accrual (engine.rs, charges when Bar.funding.is_some()) and a funding-reading strategy's signal (FundingCarryController remembers the last rate in its funding book).
engine.feed_latencytrue / falsefalseOpt-in feed-latency replay (see crate::TickReplayConfig::feed_latency): deliver ticks to the strategy in recorded ARRIVAL (local_ts) order instead of venue order, while order matching stays on venue ts. Absent (the default false) = today's venue-ordered replay, byte-identical. TICK MODE ONLY — a bar has no local_ts, so a profile that sets this with data.kind = "bar" is rejected by BacktestProfile::validate rather than silently ignored (the mirror of the bar-only attach_funding rule).
engine.order_latency_msinteger0Opt-in ORDER latency (see crate::latency): a fixed entry-leg delay in MILLISECONDS on every strategy order action (place / modify / cancel). The maker's quotes, re-prices and pulls only reach the matching engine order_latency_ms after it decides them, so it cannot react within one tick — the missing clock skew that otherwise lets a maker enter and exit in the same instant. 0 (the default) = zero latency, byte-identical. TICK MODE ONLY (only run_ticks arms the latency gate; the bar lane and vector kernel never consult it).
engine.fill_latency_msinteger0Opt-in FILL-notification latency (the response leg, see crate::latency): a fixed delay in MILLISECONDS before the strategy LEARNS of a fill. The fill books at the real time (equity/PnL are exact), but the strategy-visible shadow position (vike_model::HftBroker::position) does not advance until fill_latency_ms later — so a maker that polls its inventory cannot react (place its exit) inside that gap. Models the "time to realise you're filled" half of a real round-trip reaction. 0 (the default) = off, byte-identical. TICK MODE ONLY.
engine.fill_modelstringabsentOpt-in tick-lane fill-model override: "l2book" (the ONE accepted value; case-insensitive, like Self::queue_model) selects the depth-capped crate::engine::FillModelKind::L2Book (a resting order fills only up to the DISPLAYED book depth; when the within-limit depth cannot cover its size it RESTS rather than filling size the market never showed), vs the default L1 spread-crossing Tick model (fills the full size at the quote). Absent ⇒ Tick, byte-identical. Any OTHER value is a fail-fast HarnessError::Validation at load (EngineCfg::fill_model_kind, the mirror of the Self::queue_model rule) — a typo like "l2_book" must not silently select the optimistic Tick model and undo the depth-cap realism knob. TICK MODE ONLY. Pair with slippage = 0.0 (the book walk IS the slippage).
engine.seed_bar_interval_msintegerabsent—
engine.impacttableabsentOpt-in market-impact slippage on top of the flat slippage (see crate::impact). Absent (the default) = the frozen flat-slippage cost, byte-identical.
engine.emulator_release_stopstrue / falsefalseStop-verb release timing (see crate::EngineParams::emulator_release_stops).
engine.feetableabsentOpt-in fee SCHEDULE (port backlog G7) — the shapes a flat Self::fee_rate cannot express. Absent (the default) = the flat fee_rate path, byte-identical. Setting BOTH is a validation error: two cost models would be configured and only one could win.
engine.resolutiontableabsentOpt-in binary-resolution settlement (port backlog G6): builds the crate::EngineParams::resolution source + resolution_end_ts the engine has always consumed. Absent (the default) = no settlement source, byte-identical.
engine.queue_modelstringabsentOpt-in QUEUE-POSITION fill model for the tick lane (see crate::queue_model): a resting limit — INCLUDING a tagged MAKER quote — no longer fills the instant price touches it, but only once a taker TRADE has consumed the size AHEAD of it in the FIFO queue (seeded from the replayed L2 book's size at that price, or the last L1 quote, or Self::queue_seed_depth). This is the realistic passive-maker fill: a quote earns the spread only when flow actually hits it, with partial fills on the trade's excess over the front. Absent (the default) = the frozen simple-crossing fill (fill_tagged), byte-identical. TICK MODE ONLY — the queue lane is consulted exclusively by run_ticks, so a bar-mode profile setting it is rejected by BacktestProfile::validate (the mirror of the feed_latency rule). Values (case-insensitive): "risk_adverse" (the conservative bound — front shrinks only on hard evidence), "prob_power" / "prob_power:N" (probabilistic, f(x)=x^N, default N=1), "prob_log". For a real maker backtest feed the TRADE tape (and, for true size-ahead seeding, the BOOK).
engine.queue_seed_depthnumberabsentFallback front-of-queue depth (in size units) seeded when neither the replayed L2 book nor an L1 quote gives a size at a resting order's price — see crate::queue_model. Only consulted when Self::queue_model is set. Absent ⇒ 0.0 (a quote with no observed size-ahead fills on the first at-price trade).
engine.queue_min_hold_msintegerabsentMinimum-hold floor in MS for the queued lane (crate::EngineParams::queue_min_hold_ms): a position-reducing (closing) fill is deferred until this long after the position opened, so the backtest can't fabricate a sub-second maker round-trip. Only consulted when Self::queue_model is set. Absent ⇒ 0 (off). Calibrate from real MM flip times (Polymarket BTC-5m ≈ 2s p10).
engine.equity_sample_everyintegerabsentEquity-curve density for the tick lane (see crate::EquitySampling) — the SWEEP knob for a very long tape. The curve is two Vecs grown 16 bytes per priced tick, so a 100M-tick replay carries 1.6 GB of samples per run whether or not anything reads them.
engine.timeframesarray of string[]Higher timeframes synthesised from the base bar stream, look-ahead safe: a coarse bar becomes visible only once its window has fully elapsed. Each entry is an vike_model::time::interval_ms spelling ("4h", "1d"). Absent (the default) registers nothing, which is byte-identical to before this key existed.
engine.cash_gatetrue / falsefalseAdmit a fill only when the shared cash pool can fund it. Default false, which is what every profile written before this key did.
engine.decidestringabsentWHEN a multi-symbol step is decided — "sequential" (absent = the default, byte-identical to every profile written before this key) or "simultaneous". Resolved by decide_mode, which owns the unknown-spelling refusal; the ARGUMENT is in crate::DecideMode, and this doc deliberately does not restate it.
engine.maint_marginnumber0Maintenance-margin RATE (a fraction of adverse notional), folded as |size| · adverse · multiplier · maint_margin by the liquidation watchdog.
engine.liq_buffernumber0.10Equity cushion held above the maintenance requirement before the watchdog liquidates. ⚠ Defaults to 0.10 to match EngineParams::default() — a bare #serde(default) would give 0.0 and silently change every profile that omits the key.
engine.venue_style_liquidationtrue / falsefalseOpt-in stress knob. ⚠ true selects the retired TOTAL-WIPE liquidation model — once eq_adv \<= maint_margin * notional_adv at the intrabar adverse marks, it force-closes the WHOLE account (bar mode, StrategyEngine::check_liquidation) / the triggering symbol in full (tick mode, StrategyEngine::check_liquidation_tick). false (the default) runs the shared LEAN law (vike_model::cross_liquidation_plan) instead: PARTIAL, losers-first liquidation with the liq_buffer grace line — the opposite of a total wipe. Despite its name, true is the CRUDER model, not the gentler one.
engine.volume_limitnumberabsentParticipation cap: the fraction of the EVENT's own volume any one fill may take. None (the default) means uncapped, which is what every profile did before this key — and which lets a backtest "trade" more than the market traded.
engine.max_open_positionsinteger0Ceiling on concurrently open positions. ⚠ 0 is the engine's UNLIMITED, not "none" — it is the default, and it is what every profile written before this key did.
engine.max_open_longinteger0Ceiling on concurrently open LONG positions. ⚠ 0 means unlimited, as above.
engine.max_open_shortinteger0Ceiling on concurrently open SHORT positions. ⚠ 0 means unlimited, as above.
engine.multipliernumber1.0Contract multiplier applied to every symbol without its own row in Self::multipliers. ⚠ Defaults to 1.0, matching EngineParams::default() — a bare #serde(default) would give 0.0 and zero every position's notional.
engine.multiplierstable of number{}Per-symbol contract multipliers, e.g. ES = 50.0. A BTreeMap rather than a HashMap because the engine takes an ordered Vec and a run must not depend on hash order.
engine.leveragenumberabsentThe run's own leverage. ⚠ Distinct from risk max_leverage, which reaches the same gate through ProfileRisk::im_requirement but is a CEILING — this is what the run uses, that is what it may not exceed.
engine.clamp_to_leveragetrue / falsefalseClamp an order down to what Self::leverage allows instead of rejecting it.
engine.settlement_period_msintegerabsentVariation-settlement cadence in milliseconds — how often open-position profit is realised into cash instead of accruing. None (the default) never settles, which is what every profile did before this key.
engine.session_gatetrue / falsefalseDefer an order that falls outside its symbol's trading session instead of filling it. Deferrals are already counted and already reach the report as BacktestResult::session_deferrals.
engine.sizertableabsentHow a strategy's requested size becomes an order size (vike_analytics::sizing::PositionSizer, the WealthLab PosSizer port). None (the default) passes the request through unchanged (crate::sizing::PassThroughSizer) — what every profile did before this key existed (StrategyEngine::new installs it whenever EngineParams::sizer is None).

What [engine] refuses, verbatim from the code that refuses it:

refusedraised by
data.detail_interval and engine.cash_gate are mutually exclusive: cash_gate routes the whole bar step through the gated fill lane and UNCONDITIONALLY disables the granular sub-bar lane, so the detail tape would be loaded, scanned and then ignored while every order was reported as resolved against it. Set one.BacktestProfile::refusals
data.detail_interval and engine.decide = "simultaneous" are mutually exclusive: the simultaneous mode implies the shared-cash admission phase, which routes the whole bar step through fill_step_gated and disables the granular sub-bar lane — the detail tape would be loaded, paid for and ignoredBacktestProfile::refusals
data.detail_interval {raw:?} ({ms}ms) must be strictly FINER than data.interval {:?} ({base}ms) — at this resolution each coarse step buckets at most one sub-bar, which resolves no stop-versus-target ambiguity while paying for a second whole store scan. This is the inverse of the engine.timeframes rule, where a synthesised series must be COARSER than the baseDataCfg::detail_interval_ms
data.interval {:?} is not a valid interval, so engine.timeframes cannot be checked against itBacktestProfile::refusals
engine.attach_funding is bar-mode only: the per-interval funding accrual runs in the bar loop; the tick lane carries no funding fold to feedBacktestProfile::refusals
engine.cash must be > 0, got {}BacktestProfile::refusals
engine.cash_gate is bar-mode only: the shared-cash admission phase is StrategyEngine::run's own bar step (fill_step_gated), and run_ticks never consults it — a tick profile setting it would change nothingBacktestProfile::refusals
engine.clamp_to_leverage is unavailable to a profile carrying [risk]: SimBroker::build_risk_gate returns None on clamp_to_leverage BEFORE it reads risk_limits, so the clamp disarms the pre-trade gate entirely and EVERY [risk] limit — not just max_leverage — is discarded. Drop clamp_to_leverage to keep [risk]'s gate, or remove [risk] and clamp against engine.leverage aloneBacktestProfile::refusals
engine.clamp_to_leverage needs engine.leverage: there is nothing to clamp toBacktestProfile::refusals
engine.decide = "simultaneous" is bar-mode only: the cross-sectional allocator it orders is StrategyEngine::fill_step_gated, reached only from StrategyEngine::run — run_ticks consults neither it nor cash_gate, so a tick profile setting it would change nothingBacktestProfile::refusals
engine.decide = "simultaneous" is not built for a run with a pre-trade risk gate: remove [risk] and engine.leverage, or keep engine.decide unset. With a gate armed, SimBroker::gate_order folds the pending set as it ACCUMULATES during the on_bar fan-out, so the symbol judged first faces the whole step budget and later ones face the remainder — the DECISION is still taken in symbol-list order and no fill-phase ranking can repair it. Closing it needs a step-boundary budget snapshot, which does not existBacktestProfile::refusals
engine.equity_sample_every is tick-mode only: the bar lane records one equity sample per BAR (bounded by the bar count), so there is nothing to thinBacktestProfile::refusals
engine.fee and a non-zero engine.fee_rate ({}) are two different cost models — set exactly oneBacktestProfile::refusals
engine.fee.kind = "per_share_with_floor" needs BOTH per_share and min. The floor is the whole reason this shape exists — a per-share fee with no minimum is a flat rate wearing a different name, and it is the configuration that makes a small-size high-frequency run backtest as viable and then lose to the broker minimum live. Write min = 0.0 to assert there is genuinely no floor.FeeCfg::validate_shape
engine.fee.kind = "per_share_with_floor" needs engine.multiplier = 1.0 (the default) and no engine.multipliers row other than 1.0: the fee path scales the schedule's answer by the contract multiplier, so a per-ORDER minimum would be charged per CONTRACT. This shape models an equities book. Drop the multiplier, or price a multiplied contract with percent_maker_taker.BacktestProfile::refusals
engine.fee.kind = "percent_maker_taker" needs at least one of maker_bps / taker_bps — a table naming neither describes no cost, which is what kind = "free" says on purpose. A genuinely zero-fee side is written as the explicit 0.0, so the zero is an assertion rather than an omission.FeeCfg::validate_shape
engine.fee.kind = "percent_of_underlying" needs BOTH bps and premium_cap_pct — the cap is half the rule it models (min(bps x underlying, cap x premium)), and it is the half that binds for a cheap deep-OTM option.FeeCfg::validate_shape
engine.fee.kind = "venue" needs engine.fee.venue — it is not inferred from [data]. EngineParams::fee_schedule is ONE schedule for the whole run, so a cross-venue slice has no single answer to infer, and naming the venue is the operator asserting which venue's book this run is costed at.FeeCfg::validate_shape
engine.fee.kind = "venue" resolves ONE schedule for the whole run, and this run's {venue:?} series straddle {} fee lanes ({}) — a venue prices its lanes completely differently (crates/vike-model/src/fees.rs's fee_schedule_for carries the measurement per lane), so one row would misprice the other lane. Split the run per lane, or set engine.fee.symbol to name the lane this run is costed at.FeeCfg::fee_lane_for
engine.fee.pm_curve is polymarket-only, and this table names venue {:?} — vike_model::fee_schedule_for_with_pm_curve delegates to fee_schedule_for for every other venue, so the flag would configure nothing while the run was read as curve-priced. Drop it.FeeCfg::validate_shape
engine.fee.premium_cap_pct must be > 0, got {} — a zero cap is not "no cap". FeeSchedule::commission on this shape is min(bps x premium, cap x premium), so a zero cap zeroes the whole commission and the run pays no fee at all while the profile reads as fee-modelled. crates/vike-model/src/fees.rs's fee_schedule_for carries the real Deribit cap in its "deribit" arm.FeeCfg::validate_shape
engine.fee.venue = {venue:?} names a venue this run does not load, so there is no symbol to resolve its fee LANE from — and answering with the bare-venue row would charge a perp run at the venue's SPOT schedule, the mispricing the lane key exists to end. This run's series are on: {}. Name a venue the slice trades, or set engine.fee.symbol outright.FeeCfg::fee_lane_for
engine.fee.{key} is a knob of kind {owner:?}, and this table declares kind {kind:?} — nothing would read it. A COST knob that configures nothing is worse than an absent one: the operator reads the profile as priced at what they wrote and the run prices at something else. Set the kind this knob belongs to, or drop the knob.FeeCfg::validate_shape
engine.fee.{name} must be finite and >= 0, got {v}FeeCfg::validate_shape
engine.feed_latency is tick-mode only: it re-orders recorded ticks by their machine receive stamp (local_ts), which a bar series does not carryBacktestProfile::refusals
engine.impact.exec_time must be > 0, got {}ImpactCfg::build
engine.impact.window must be >= 3 (two returns), got {}ImpactCfg::build
engine.impact.{name} must be > 0 and finite, got {v}ImpactCfg::build
engine.leverage and [risk] are two different margin sources for the same gate — set exactly one. With clamp_to_leverage off (the default) there are three configurations and [risk] wins all of them: SimBroker::build_risk_gate's (Some(l), _) arm takes risk_limits unconditionally, so a [risk] that sets max_leverage answers with ITS number, a [risk] that does not sets no margin requirement at all, and either way engine.leverage's 1/L im_requirement is silently discarded. (With clamp_to_leverage on, neither reaches a gate — that combination is refused separately.) Set risk.max_leverage instead, or remove [risk] to use engine.leverage's 1/L margin mappingBacktestProfile::refusals
engine.leverage must be > 0, got {l} — omit the key for unleveredBacktestProfile::refusals
engine.liq_buffer must be >= 0, got {}BacktestProfile::refusals
engine.maint_margin must be >= 0, got {} — 0 means the liquidation watchdog is off, and a negative rate has no meaningBacktestProfile::refusals
engine.multiplier must be > 0, got {} — it scales every position's notional, so zero makes every trade worth nothingBacktestProfile::refusals
engine.multipliers.{sym} must be > 0, got {m}BacktestProfile::refusals
engine.multipliers.{sym} names a symbol this run does not load — the engine matches a multiplier row by EXACT symbol name and otherwise falls back to the global engine.multiplier, so this row would change nothing. The data slice is: {}BacktestProfile::refusals
engine.queue_model is tick-mode only: the FIFO queue lane runs in the tick/book replay path, which a bar series does not driveBacktestProfile::refusals
engine.resolution.window_secs must be > 0, got {}ResolutionCfg::validate_shape
engine.resolution.winners[{slug:?}] = {wi} is not a binary outcome index (0 or 1). Non-binary and unresolved (-1) values are real on-chain facts and are never coerced to "both sides lose" — drop the window from the run instead.ResolutionCfg::validate_shape
engine.resolution: {} window slug(s) in data.series have no binary winning_index (first: {names:?}). A window with no payout would end the run marked at its last traded price — remove it from the slice or supply its resolution.ResolutionCfg::build
engine.settlement_period_ms must be > 0, got {ms} — omit the key to never settleBacktestProfile::refusals
engine.sizer nesting is {depth} levels deep ([engine.sizer{}]) — MAX_SIZER_DEPTH is {MAX_SIZER_DEPTH}; flatten the base chain (no composition of the two wrapping kinds needs to nest this deep)SizerCfg::build_at_depth
engine.sizer.kind = "drawdown_throttle" wraps a base sizer — add an [engine.sizer.base] table naming itSizerCfg::need
engine.sizer.kind = "portfolio_heat" wraps a base sizer — add an [engine.sizer.base] table naming itSizerCfg::need
engine.sizer.kind = {kind:?} does not wrap a base sizer, but [engine.sizer{}.base] is set — only "portfolio_heat" and "drawdown_throttle" read it, so the chain would run {kind:?} alone. Remove the base table, or name a wrapping kindSizerCfg::need
engine.sizer.{field} is required when engine.sizer.kind = {kind:?}SizerCfg::need
engine.snap_to_properties is single-venue only: the point-in-time instrument grid is looked up under one default_venue, so a cross-venue [[data.series]] slice would snap every series to the first series' venueBacktestProfile::validate_data_slice
engine.timeframes is bar-mode only: the coarse series are resampled from the base BAR stream, and a tick profile has noneBacktestProfile::refusals
engine.timeframes {tf:?} ({ms}ms) must be coarser than data.interval {:?} ({base}ms) — a finer window cannot be synthesised from the base stream and would return the base bars re-labelledBacktestProfile::refusals
engine.timeframes {tf:?} is not a valid interval (want a count then one of s/m/h/d, e.g. "4h")BacktestProfile::refusals
engine.timeframes {tf:?} resolves to {ms}ms — a zero-length window makes every boundary the same instantBacktestProfile::refusals
engine.volume_limit must be in (0, 1], got {v} — it is the fraction of the EVENT's own volume one fill may take (a bar's volume in bar mode, ONE PRINT's in tick mode), so 1.0 is all of itBacktestProfile::refusals
unknown engine.decide {other:?} (want sequential | simultaneous; absent = sequential, the per-symbol walk in the order data.symbols lists them)EngineCfg::decide_mode
unknown engine.fee.kind {kind:?} (known: {}; a single flat fraction of notional may also stay on engine.fee_rate, which is the same shape as percent_maker_taker charged to both sides)FeeCfg::validate_shape
unknown engine.fee.kind {other:?} (known: {})FeeCfg::build_for
unknown engine.impact.model {other:?} (known: "almgren_chriss")ImpactCfg::build
unknown engine.resolution.kind {:?} (known: "binary_outcome")ResolutionCfg::validate_shape
unknown engine.sizer.kind {other:?} (want pass_through | fixed_dollar | fixed_shares | pct_equity | pct_volatility | max_risk_pct | portfolio_heat | drawdown_throttle)SizerCfg::need

[strategy]

a table · required · refuses an undeclared key · read from StrategyCfg

Naming a strategy and passing its knobs.

# Name the strategy and fill [strategy.params] — --param takes ONE FLAT scalar, so a table knob needs the file
vike-cli backtest run --profile p.toml --strategy momentum --param qty=0.25           # --strategy is sugar for strategy.name
vike-cli backtest run --profile p.toml --set strategy.name=momentum --param tp=0.01   # the un-sugared spelling of that same key
vike-cli backtest run --profile p.toml --strategy grid --param rungs=4                # rungs is Integer: `rungs=4.0` sets nothing, silently
vike-cli backtest run --profile p.toml --strategy momentum --param threshold=0.002    # a knob name is checked by NOBODY — a typo is a no-op

# The script form: --script fills strategy.params.src and implies strategy.name = "rhai"
vike-cli backtest run --profile p.toml --script s.rhai                                # the file's SOURCE TEXT is injected, not its path
vike-cli backtest run --profile p.toml --script s.rhai --param qty=2                  # every numeric param but `src` overrides a param() default

# Prove what [strategy] resolved to before spending a run
vike-cli backtest run --profile p.toml --strategy momentum --param qty=0.25 --show-effective    # resolved TOML + each override's origin, exit 0
vike-cli backtest run --profile p.toml --strategy momentum --param qty=0.25 --write-profile momentum-qty.toml   # ...the same document, then it runs

Before you type a name, ask what exists. backtest strategies prints the roster and backtest params --strategy <name> prints each declared key with the TOML type its reader takes — both documented where they live, on the command surface. Those four lines used to be repeated here, and a command line that lives in two places is one that can drift between them.

keytakeswhen absentwhat it does
strategy.namestringrequired—
strategy.paramsany TOML value{}—

What [strategy] refuses, verbatim from the code that refuses it:

refusedraised by
strategy.params must be a tablerequire_overridable_params
strategy.params must be a tableprofile_with_overrides

[risk]

a table · optional · refuses an undeclared key · read from ProfileRisk

keytakeswhen absentwhat it does
risk.tick_sizenumberrequired—
risk.lot_sizenumberrequired—
risk.min_notionalnumberrequired—
risk.min_qtynumberrequiredper-order minimum quantity floor (venue lot-size grid); checked on the lot-rounded qty.
risk.max_notional_per_ordernumberrequiredrisk max_notional_per_order — the per-order notional cap the pre-trade gate evaluates on EVERY order this engine admits, whatever its origin (strategy-emitted, control-socket-originated, reconcile-originated). It lands verbatim in RiskLimits::max_notional_per_order and is denied as over-max-notional.
risk.max_total_exposurenumberrequiredrisk max_total_exposure — cap on ONE SYMBOL's projected open notional at ONE VENUE, in the account's quote currency. Despite the name it is NOT an account-wide, cross-symbol or cross-venue total: it lands verbatim in RiskLimits::max_total_exposure, whose doc is the authority on the scope, on why the name is kept, and on the test that pins it. Size it for a SINGLE instrument — an operator who sizes it for a whole book of N symbols gets an N× weaker cap than they wrote.
risk.max_orders_per_windowintegerrequired—
risk.window_msinteger0throttle window (ms); only active when max_orders_per_window is set
risk.max_leveragenumberrequiredTHE leverage knob (>= 1.0), e.g. 10.0 ⇒ 10x. Arms the pre-trade buying-power check by converting to RiskLimits::im_requirement (1.0 / max_leverage) — see ProfileRisk::im_requirement and the module doc's "One leverage concept, one name". Omitted ⇒ the buying-power lane stays off here (a live mount then applies its own conservative 1× rescue).
risk.block_reduce_only_overshoottrue / falsefalse—
risk.required_free_bp_pctnumber0LEAN free-buying-power haircut on equity, [0.0, 1.0)

What [risk] refuses, verbatim from the code that refuses it:

refusedraised by
engine.clamp_to_leverage is unavailable to a profile carrying [risk]: SimBroker::build_risk_gate returns None on clamp_to_leverage BEFORE it reads risk_limits, so the clamp disarms the pre-trade gate entirely and EVERY [risk] limit — not just max_leverage — is discarded. Drop clamp_to_leverage to keep [risk]'s gate, or remove [risk] and clamp against engine.leverage aloneBacktestProfile::refusals
engine.decide = "simultaneous" is not built for a run with a pre-trade risk gate: remove [risk] and engine.leverage, or keep engine.decide unset. With a gate armed, SimBroker::gate_order folds the pending set as it ACCUMULATES during the on_bar fan-out, so the symbol judged first faces the whole step budget and later ones face the remainder — the DECISION is still taken in symbol-list order and no fill-phase ranking can repair it. Closing it needs a step-boundary budget snapshot, which does not existBacktestProfile::refusals
engine.leverage and [risk] are two different margin sources for the same gate — set exactly one. With clamp_to_leverage off (the default) there are three configurations and [risk] wins all of them: SimBroker::build_risk_gate's (Some(l), _) arm takes risk_limits unconditionally, so a [risk] that sets max_leverage answers with ITS number, a [risk] that does not sets no margin requirement at all, and either way engine.leverage's 1/L im_requirement is silently discarded. (With clamp_to_leverage on, neither reaches a gate — that combination is refused separately.) Set risk.max_leverage instead, or remove [risk] to use engine.leverage's 1/L margin mappingBacktestProfile::refusals
risk.max_orders_per_window is a wall-clock order-rate throttle — meaningless in backtest sim time (a replayed month can execute in under a second, which would spuriously rate-limit every order; SimBroker::build_risk_gate always disarms this field for the identical reason on the live-parity gate it mounts). Remove it from [risk] — every other risk.* limit is honoredBacktestProfile::refusals

[walkforward]

a table · optional · refuses an undeclared key · read from WalkforwardCfg

Declaring a window form, and what gets refused.

# Slice the range you already load into N out-of-sample windows
vike-cli backtest run --profile wf.toml --set walkforward.n_splits=6 --addr 127.0.0.1:7880   # --local is REFUSED on a [walkforward] profile, so every line here dials the compute daemon
vike-cli backtest run --profile wf.toml --set walkforward.n_splits=6 --from 2024-01-01T00 --to 2026-01-01T00   # a split count is defined BY the range: widen it and every window grows
vike-cli backtest run --profile wf.toml --set walkforward.n_splits=6 --set walkforward.mode=rolling   # train on the one chunk before each window instead of from bar 0
vike-cli backtest run --profile wf.toml --set walkforward.n_splits=6 --show-effective   # confirm what resolved: prints the profile and STOPS, no dial, no engine

# Fixed-length windows instead — the form whose windows keep their length when the range moves
vike-cli backtest run --profile wf.toml --set walkforward.train=12mo --set walkforward.test=3mo   # step defaults to test, which tiles the validation windows edge to edge
vike-cli backtest run --profile wf.toml --set walkforward.train=12mo --set walkforward.test=3mo --set walkforward.step=1mo   # overlap them: a fresh window every month
vike-cli backtest run --profile wf.toml --set walkforward.train=90d --set walkforward.test=30d --set walkforward.purge=2d   # drop 2d between the halves, so test_start - train_end == 2d exactly
vike-cli backtest run --profile wf.toml --set walkforward.train=90d --set walkforward.test=30d --set walkforward.embargo=2d   # a window whose test_start lands in the zone is DROPPED, not moved

# Let each window choose its parameters on its own training half, and say how it chooses
vike-cli backtest run --profile wf.toml --set walkforward.search=sweep   # scores this profile's own [sweep] grid per window; absent (or "none") is the fixed-parameter control
vike-cli backtest run --profile wf.toml --set walkforward.search=sweep --set walkforward.rank_by=max_dd   # a window's ranking is this key — the --rank-by FLAG is refused on a walk-forward profile

# Refused at load, and worth pasting once to read the message
vike-cli backtest run --profile wf.toml --set walkforward.n_splits=6 --set walkforward.purge=2d
  # walkforward.purge / walkforward.embargo need the DURATION window form (train/test) — the split-count
  # walk stays gap-free (decision 0046). Replace n_splits
  # with a train/test pair, or drop the key.
vike-cli backtest run --profile wf.toml --set walkforward.train=12mo --set walkforward.test=3mo --set walkforward.n_splits=6
  # walkforward.n_splits and walkforward.train/test/step are two PROTOCOLS, not two spellings — a split
  # count is defined BY the range and moves when the range moves, a duration does not. Set exactly one.
keytakeswhen absentwhat it does
walkforward.n_splitsintegerabsentNumber of out-of-sample windows — window form (a), the one that existed first.
walkforward.trainstringabsentWindow form (b)/(c) — the TRAIN window's length, as a vike_model::time::parse_span duration ("12mo", "90d", "5000bars"). Mutually exclusive with Self::n_splits; requires Self::test.
walkforward.teststringabsentWindow form (b)/(c) — the VALIDATION window's length. Required with Self::train.
walkforward.stepstringabsentHow far each window advances. Absent ⇒ equal to Self::test, which tiles the validation windows edge to edge.
walkforward.purgestringabsentBars dropped BETWEEN train and test, so test_start - train_end == purge exactly.
walkforward.embargostringabsentThe minimum gap AFTER a validation window before the next one may start. A window whose test_start falls inside the zone is DROPPED, not moved — moving it would make Self::step mean something other than what was written. Same duration-form-only rule as Self::purge, and the same ROUNDS-UP rule.
walkforward.searchstringabsentWhich search each window runs on its OWN training half — the knob that chooses between the two walk-forward protocols. Absent or "none" is the CONTROL (WindowSearch::None): every window trades the profile's own strategy.params, which is the fixed-parameter walk this section has always described. "sweep" — named for the table it expands — scores this profile's sweep grid on each window's train half and carries only the winner onto its validation half.
walkforward.modestringabsentThe TRAIN-window shape (WalkMode): absent or "anchored" is the expanding window every split trains from bar 0; "rolling" trains on the ONE chunk immediately preceding the window. Case-insensitive and trimmed (Self::walk_mode), unknown values refused at load.
walkforward.rank_bystringabsentHow a window SCORES its candidates when Self::search runs one: any of the four RankMetric names the sweep's own --rank-by takes ("sharpe" | "return" | "max_dd" | "equity", case-insensitive), defaulting to RankMetric::default. Consulted only when a search actually runs — the control scores nothing.

What [walkforward] refuses, verbatim from the code that refuses it:

refusedraised by
[walkforward] declares no window form — set n_splits = N, or a train/test pair (e.g. train = "12mo", test = "3mo")WalkforwardCfg::window_form
data.detail_interval is unavailable to a [walkforward] profile: the walk hands each window a sliced coarse series while nothing slices the detail tape, so every window's last step would fill against bars from AFTER the window ended. That is look-ahead, not merely waste. Run the profile without [walkforward], or without the detail tape.BacktestProfile::refusals
profile has no [walkforward] table — a walk-forward needs a window form, e.g. [walkforward] n_splits = 4 or [walkforward] train = "12mo" test = "3mo"walkforward_preflight
the [walkforward] window form yields NO windows over the {} bar(s) of {symbol} in this range — a walk that evaluates nothing must not return a report that looks like onewalkforward_preflight
the duration window form needs BOTH walkforward.train and walkforward.test (step defaults to test) — a half-declared window has no shapeWalkforwardCfg::window_form
unknown walkforward.mode {other:?} (want anchored | rolling; absent = anchored)WalkforwardCfg::walk_mode
unknown walkforward.rank_by {raw:?} (want sharpe | return | max_dd | equity; absent = sharpe)WalkforwardCfg::rank_metric
unknown walkforward.search {other:?} (want none | sweep; absent = none, the no-search control)WalkforwardCfg::window_search
walkforward.n_splits and walkforward.train/test/step are two PROTOCOLS, not two spellings — a split count is defined BY the range and moves when the range moves, a duration does not. Set exactly one.WalkforwardCfg::window_form
walkforward.n_splits must be >= 1, got 0BacktestProfile::refusals
walkforward.n_splits must be >= 1, got 0resolve_windows
walkforward.purge / walkforward.embargo need the DURATION window form (train/test) — the split-count walk stays gap-free (decision 0046 — the bar mode walk forward has no purge). Replace n_splits with a train/test pair, or drop the key.WalkforwardCfg::window_form
walkforward.search = "grid" was renamed to "sweep" — one name for one knob, and it is the table it expands ([sweep]). Write search = "sweep".WalkforwardCfg::window_search
walkforward.search = "sweep" needs a [sweep] table to search — this profile has none, so every window would 'select' the one parameter set it already has. Add the grid, or drop the key for the no-search control.BacktestProfile::refusals
walkforward.step resolves to {step} bar(s) and walkforward.test to {test} — a step shorter than the validation window makes consecutive windows OVERLAP, and the stitched out-of-sample curve would compound the same bars more than once, reporting a return and a Sharpe roughly the overlap factor too high. Set step >= test: equal tiles the windows edge to edge (that is also what an absent step means), and larger leaves a gap between them.resolve_windows

[data.series]

an ARRAY of tables · optional · refuses an undeclared key · read from SeriesRef

keytakeswhen absentwhat it does
data.series.venuestringrequired—
data.series.symbolstringrequired—
data.series.kind"tick" | "quote" | "trade" | "book"type default—

What [data.series] refuses, verbatim from the code that refuses it:

refusedraised by
data.symbols must not be empty (or use the cross-venue [[data.series]] form)BacktestProfile::validate_data_slice
data.venue/data.symbols and [[data.series]] are two ways to name the SAME slice — set exactly one (the [[data.series]] form is the cross-venue one)BacktestProfile::validate_data_slice
engine.snap_to_properties is single-venue only: the point-in-time instrument grid is looked up under one default_venue, so a cross-venue [[data.series]] slice would snap every series to the first series' venueBacktestProfile::validate_data_slice

[engine.impact]

a table · optional · refuses an undeclared key · read from ImpactCfg

keytakeswhen absentwhat it does
engine.impact.modelstringrequiredwhich model — currently only "almgren_chriss"
engine.impact.exec_timenumber1.0Execution horizon, in whatever period this run's context is measured over — bar-periods in bar mode, TRADE PRINTS in tick mode (see crate::impact::AlmgrenChriss::exec_time and crate::impact::TickWindow). The default 1.0 means "worked inside one period".
engine.impact.windowinteger21Rolling sigma/volume lookback — in BARS in bar mode, in TRADE PRINTS in tick mode. The default is one trading month of daily bars; on a liquid tape it is a fraction of a second, so a tick profile should set it rather than inherit it. Lengthening it averages away sampling noise; it does NOT change the UNIT the context is measured in.
engine.impact.gammanumber0.314Permanent-impact coefficient, defaulting to the published crate::impact::AC_GAMMA.
engine.impact.etanumber0.142Temporary-impact coefficient, defaulting to the published crate::impact::AC_ETA. The twin of Self::gamma for the half a book walk already pays — so it moves the bar and L1-tick lanes and is inert on an L2 fill that walked a book. Same rationale, same fence around the exponents.

What [engine.impact] refuses, verbatim from the code that refuses it:

refusedraised by
engine.impact.exec_time must be > 0, got {}ImpactCfg::build
engine.impact.window must be >= 3 (two returns), got {}ImpactCfg::build
unknown engine.impact.model {other:?} (known: "almgren_chriss")ImpactCfg::build

[engine.fee]

a table · optional · refuses an undeclared key · read from FeeCfg

keytakeswhen absentwhat it does
engine.fee.kindstringrequiredWhich vike_model::FeeSchedule shape this table describes: "probability_scaled" | "per_share_with_floor" | "percent_of_underlying" | "percent_maker_taker" | "venue" | "free".
engine.fee.taker_ratenumber0probability_scaled: the TAKER fraction of the p(1−p)-scaled share count.
engine.fee.maker_ratenumber0probability_scaled: the MAKER fraction, before any rebate.
engine.fee.maker_rebate_sharenumber0probability_scaled: the maker's rebate as a share of the EQUIVALENT TAKER fee, so a zero maker_rate with a positive share is a NEGATIVE maker commission (a rebate — the sign convention on vike_model::FeeSchedule).
engine.fee.per_sharenumberabsentper_share_with_floor: the per-SHARE fee. REQUIRED by that kind.
engine.fee.minnumberabsentper_share_with_floor: the per-order MINIMUM. REQUIRED by that kind, and required deliberately — an absent floor defaults to no floor, which is the flat-rate behaviour the whole shape exists to escape. Write min = 0.0 to assert there is genuinely none.
engine.fee.max_pctnumberabsentper_share_with_floor: the maximum fraction of trade value the fee may reach. Optional — absent is 0.0, which FeeSchedule::commission treats as NO ceiling (it caps only on a positive notional bound), so omitting it charges the uncapped per-share-or-floor figure. That default is safe in the direction that matters: it can only over-charge.
engine.fee.bpsnumberabsentpercent_of_underlying: basis points of the UNDERLYING notional. REQUIRED by that kind.
engine.fee.premium_cap_pctnumberabsentpercent_of_underlying: the cap, as a fraction of the PREMIUM notional (Deribit's real rule caps the underlying term at 12.5 % of the premium). REQUIRED, and a non-positive value is REFUSED rather than read as "no cap": FeeSchedule::commission on this shape is min(bps × premium, cap × premium), so a zero cap makes the whole commission zero — the silent free backtest, wearing a configured fee model.
engine.fee.maker_bpsnumberabsentpercent_maker_taker: maker basis points of quote-notional. At least one of this and Self::taker_bps is required; the other defaults to 0.0.
engine.fee.taker_bpsnumberabsentpercent_maker_taker: taker basis points of quote-notional. See Self::maker_bps.
engine.fee.venuestringabsentvenue: which venue's published schedule to cost the run at. REQUIRED by that kind, and it is not inferred from data — EngineParams::fee_schedule is ONE schedule for the whole run, so on a cross-venue [data.series] slice there is no single right answer to infer and naming it is the operator asserting which venue's book this run is priced at.
engine.fee.symbolstringabsentvenue: which SYMBOL selects the venue's fee LANE. Absent (the normal case) resolves from the run's own series on Self::venue, which is what keeps the .P suffix from being typed twice — see FeeCfg::build_for for the two ways that resolution refuses.
engine.fee.pm_curvetrue / falseabsentvenue, polymarket only: take the verified 2026 V2 fee regime (vike_model::POLYMARKET_V2_FEE_CURVE, via vike_model::fee_schedule_for_with_pm_curve) instead of the registry's Free.

What [engine.fee] refuses, verbatim from the code that refuses it:

refusedraised by
engine.fee.kind = "per_share_with_floor" needs BOTH per_share and min. The floor is the whole reason this shape exists — a per-share fee with no minimum is a flat rate wearing a different name, and it is the configuration that makes a small-size high-frequency run backtest as viable and then lose to the broker minimum live. Write min = 0.0 to assert there is genuinely no floor.FeeCfg::validate_shape
engine.fee.kind = "per_share_with_floor" needs engine.multiplier = 1.0 (the default) and no engine.multipliers row other than 1.0: the fee path scales the schedule's answer by the contract multiplier, so a per-ORDER minimum would be charged per CONTRACT. This shape models an equities book. Drop the multiplier, or price a multiplied contract with percent_maker_taker.BacktestProfile::refusals
engine.fee.kind = "percent_maker_taker" needs at least one of maker_bps / taker_bps — a table naming neither describes no cost, which is what kind = "free" says on purpose. A genuinely zero-fee side is written as the explicit 0.0, so the zero is an assertion rather than an omission.FeeCfg::validate_shape
engine.fee.kind = "percent_of_underlying" needs BOTH bps and premium_cap_pct — the cap is half the rule it models (min(bps x underlying, cap x premium)), and it is the half that binds for a cheap deep-OTM option.FeeCfg::validate_shape
engine.fee.kind = "venue" needs engine.fee.venue — it is not inferred from [data]. EngineParams::fee_schedule is ONE schedule for the whole run, so a cross-venue slice has no single answer to infer, and naming the venue is the operator asserting which venue's book this run is costed at.FeeCfg::validate_shape
engine.fee.kind = "venue" resolves ONE schedule for the whole run, and this run's {venue:?} series straddle {} fee lanes ({}) — a venue prices its lanes completely differently (crates/vike-model/src/fees.rs's fee_schedule_for carries the measurement per lane), so one row would misprice the other lane. Split the run per lane, or set engine.fee.symbol to name the lane this run is costed at.FeeCfg::fee_lane_for
engine.fee.pm_curve is polymarket-only, and this table names venue {:?} — vike_model::fee_schedule_for_with_pm_curve delegates to fee_schedule_for for every other venue, so the flag would configure nothing while the run was read as curve-priced. Drop it.FeeCfg::validate_shape
engine.fee.premium_cap_pct must be > 0, got {} — a zero cap is not "no cap". FeeSchedule::commission on this shape is min(bps x premium, cap x premium), so a zero cap zeroes the whole commission and the run pays no fee at all while the profile reads as fee-modelled. crates/vike-model/src/fees.rs's fee_schedule_for carries the real Deribit cap in its "deribit" arm.FeeCfg::validate_shape
engine.fee.venue = {venue:?} names a venue this run does not load, so there is no symbol to resolve its fee LANE from — and answering with the bare-venue row would charge a perp run at the venue's SPOT schedule, the mispricing the lane key exists to end. This run's series are on: {}. Name a venue the slice trades, or set engine.fee.symbol outright.FeeCfg::fee_lane_for
unknown engine.fee.kind {kind:?} (known: {}; a single flat fraction of notional may also stay on engine.fee_rate, which is the same shape as percent_maker_taker charged to both sides)FeeCfg::validate_shape
unknown engine.fee.kind {other:?} (known: {})FeeCfg::build_for

[engine.resolution]

a table · optional · refuses an undeclared key · read from ResolutionCfg

keytakeswhen absentwhat it does
engine.resolution.kindstringrequiredcurrently only "binary_outcome"
engine.resolution.window_secsinteger300Seconds from a window's open (sts) to its resolution. Defaults to vike_model::fair::UPDOWN_WINDOW_SECS (300). NOTE the symbol GRAMMAR is independent of this: TokenId::parse pins sts to a multiple of UPDOWN_WINDOW_SECS, so a different window_secs moves the payout TIME without redefining the grid.
engine.resolution.pathstringabsentOptional slug,winning_index CSV. Relative paths resolve against the profile file's own directory (BacktestProfile::base_dir), not the CWD.
engine.resolution.winnerstable of integer{}Optional inline slug = winning_index table. Merged over path's rows.
engine.resolution.end_tsstringabsentExplicit override for crate::EngineParams::resolution_end_ts (epoch-ms or YYYY-MM-DDTHH). Absent = the LATEST resolution among the run's own series (max(sts) + window_secs), which is what pins the end-of-run sweep to the window rather than to the RESOLUTION_PROBE_SENTINEL.

What [engine.resolution] refuses, verbatim from the code that refuses it:

refusedraised by
engine.resolution.window_secs must be > 0, got {}ResolutionCfg::validate_shape
engine.resolution.winners[{slug:?}] = {wi} is not a binary outcome index (0 or 1). Non-binary and unresolved (-1) values are real on-chain facts and are never coerced to "both sides lose" — drop the window from the run instead.ResolutionCfg::validate_shape
unknown engine.resolution.kind {:?} (known: "binary_outcome")ResolutionCfg::validate_shape

[engine.sizer]

a table · optional · refuses an undeclared key · read from SizerCfg

Sizing an entry, and composing two sizers.

# The three kinds whose knob is theirs alone — the kind plus its one required key, one line each
vike-cli backtest run --profile p.toml --set engine.sizer.kind=pass_through                                 # the implicit default written down: the strategy's request goes through untouched
vike-cli backtest run --profile p.toml --set engine.sizer.kind=fixed_dollar --set engine.sizer.amount=2500  # the same cash per entry whatever the price
vike-cli backtest run --profile p.toml --set engine.sizer.kind=fixed_shares --set engine.sizer.shares=1     # one contract per entry — the size-blind control run

# The three that all read `pct` — one key, three meanings, so the KIND is the thing you are choosing
vike-cli backtest run --profile p.toml --set engine.sizer.kind=pct_equity --set engine.sizer.pct=0.02       # 2% of equity as target NOTIONAL
vike-cli backtest run --profile p.toml --set engine.sizer.kind=pct_volatility --set engine.sizer.pct=0.02   # 2% of equity as an ATR-risk budget: a volatile symbol gets fewer units
vike-cli backtest run --profile p.toml --set engine.sizer.kind=max_risk_pct --set engine.sizer.pct=0.01     # 1% of equity risked to the STOP, so stop distance decides the size

# The two wrapping kinds need a base, and a base is a NESTED table — --set has no grammar for one, so write it
cat > chain.toml <<'TOML'
[engine.sizer]
kind = "portfolio_heat"   # a wrapper: caps TOTAL open risk, sizes nothing on its own
max_heat = 0.06           # 6% of equity across every open position

[engine.sizer.base]       # required under both wrapping kinds, REFUSED under the other six
kind = "pct_equity"       # a base takes the same field set as [engine.sizer] — it nests itself
pct = 0.02
TOML
vike-cli backtest run --profile chain.toml --show-effective   # confirm it resolved as a CHAIN, not as one sizer
vike-cli backtest run --profile chain.toml --set engine.sizer.kind=drawdown_throttle --set engine.sizer.sensitivity=1.5 --set engine.sizer.floor=0.25 --show-effective   # the other wrapper over the same written base: its own two knobs ARE flat leaves, so they flag

# Keep the sizer a report was produced by, then replay exactly it
vike-cli backtest run --profile p.toml --set engine.sizer.kind=max_risk_pct --set engine.sizer.pct=0.01 --write-profile run-01.toml   # refuses an existing path, so every run keeps its own
vike-cli backtest run --profile run-01.toml --local --store /data --json   # the same sizer, on this box, as machine-readable report
keytakeswhen absentwhat it does
engine.sizer.kindstringrequiredOne of "pass_through" | "fixed_dollar" | "fixed_shares" | "pct_equity" | "pct_volatility" | "max_risk_pct" | "portfolio_heat" | "drawdown_throttle" — every concrete vike_analytics::sizing::PositionSizer this crate ships and nothing else (Self::build).
engine.sizer.amountnumberabsentcrate::sizing::FixedDollarSizer::amount — fixed cash notional per entry. Required for "fixed_dollar".
engine.sizer.sharesnumberabsentcrate::sizing::FixedSharesSizer::shares — fixed share/contract count per entry. Required for "fixed_shares".
engine.sizer.pctnumberabsentThe knob "pct_equity" / "pct_volatility" / "max_risk_pct" each read as their own fraction of equity (target notional, ATR-risk budget, and stop-risk budget respectively — see vike_analytics::sizing's doc on each). Required for those three kinds.
engine.sizer.max_heatnumberabsentcrate::sizing::PortfolioHeatSizer::max_heat — total open-risk cap as a fraction of equity. Required for "portfolio_heat".
engine.sizer.sensitivitynumberabsentcrate::sizing::DrawdownThrottleSizer::sensitivity. Required for "drawdown_throttle".
engine.sizer.floornumberabsentcrate::sizing::DrawdownThrottleSizer::floor. Required for "drawdown_throttle".
engine.sizer.basetableabsentThe wrapped sizer for "portfolio_heat" / "drawdown_throttle" — required for those two kinds (Self::build refuses their absence), and REFUSED under every other kind, which reads it nowhere: a base under a scalar kind would run one sizer while the profile read as a chain. Boxed because SizerCfg nests itself — see MAX_SIZER_DEPTH for the bound on how far.

What [engine.sizer] refuses, verbatim from the code that refuses it:

refusedraised by
engine.sizer.kind = "drawdown_throttle" wraps a base sizer — add an [engine.sizer.base] table naming itSizerCfg::need
engine.sizer.kind = "portfolio_heat" wraps a base sizer — add an [engine.sizer.base] table naming itSizerCfg::need
engine.sizer.kind = {kind:?} does not wrap a base sizer, but [engine.sizer{}.base] is set — only "portfolio_heat" and "drawdown_throttle" read it, so the chain would run {kind:?} alone. Remove the base table, or name a wrapping kindSizerCfg::need
engine.sizer.{field} is required when engine.sizer.kind = {kind:?}SizerCfg::need
unknown engine.sizer.kind {other:?} (want pass_through | fixed_dollar | fixed_shares | pct_equity | pct_volatility | max_risk_pct | portfolio_heat | drawdown_throttle)SizerCfg::need

What else the profile refuses

117 refusals in all, 23 of them shown beside the tables above. The remaining 93 are the ones that judge a profile as a WHOLE — a knob that needs a mode it was not given, a value outside its range, a table that contradicts another. Each is printed verbatim, with the symbol that raises it; a format! hole stays as its template rather than being filled in with an invented value.

refusedraised by
data.detail_interval and engine.cash_gate are mutually exclusive: cash_gate routes the whole bar step through the gated fill lane and UNCONDITIONALLY disables the granular sub-bar lane, so the detail tape would be loaded, scanned and then ignored while every order was reported as resolved against it. Set one.BacktestProfile::refusals
data.detail_interval and engine.decide = "simultaneous" are mutually exclusive: the simultaneous mode implies the shared-cash admission phase, which routes the whole bar step through fill_step_gated and disables the granular sub-bar lane — the detail tape would be loaded, paid for and ignoredBacktestProfile::refusals
data.detail_interval {raw:?} ({ms}ms) must be strictly FINER than data.interval {:?} ({base}ms) — at this resolution each coarse step buckets at most one sub-bar, which resolves no stop-versus-target ambiguity while paying for a second whole store scan. This is the inverse of the engine.timeframes rule, where a synthesised series must be COARSER than the baseDataCfg::detail_interval_ms
data.detail_interval {raw:?} is bar-mode only: the intrabar detail tape is bucketed into COARSE BAR steps and consulted by StrategyEngine::run's bar fill phase, and run_ticks never reads those buckets — a tick replay already resolves every order against the tape it is replayingDataCfg::detail_interval_ms
data.detail_interval {raw:?} is not a valid interval (want a count then one of s/m/h/d, e.g. "1m") — it is handed straight to the hist store as a series interval, so it must name a series the store can holdDataCfg::detail_interval_ms
data.detail_interval {raw:?} resolves to {ms}ms — a zero-length window makes every sub-bar boundary the same instantDataCfg::detail_interval_ms
data.from ({start}) must be <= data.to ({end})BacktestProfile::refusals
data.interval {:?} is not a valid interval, so data.detail_interval {raw:?} cannot be checked as finer than itDataCfg::detail_interval_ms
data.interval {:?} is not a valid interval, so engine.timeframes cannot be checked against itBacktestProfile::refusals
data.interval {:?} is not a valid interval, so the duration data.warmup {raw:?} cannot be converted to a step countDataCfg::warmup_steps
data.interval {:?} resolves to {base}ms, so the duration data.warmup {raw:?} names no number of stepsDataCfg::warmup_steps
data.max_gap is set without data.require_coverage = true, so the gate whose tolerance it names is not armed and this key is read by nothing. Arm the gate, or remove the toleranceBacktestProfile::refusals
data.max_gap {raw:?} is a BAR COUNT, and a gap in this store is a wall-clock span in epoch-ms: converting {n} bars to a duration needs an interval, and a tick profile has none. Write the tolerance as fixed time — "4h", "1d"DataCfg::max_gap_ms
data.max_gap {raw:?} is a CALENDAR span, and a calendar month is not a fixed number of milliseconds (one month from 31 January is 28 days, from 31 March 30) — so the tolerance would depend on where data.from happens to sit, and two runs naming the same tolerance would accept different amounts. Write it in fixed time — "30d"DataCfg::max_gap_ms
data.max_gap {raw:?} resolves to {ms}ms — a non-positive tolerance is what an ABSENT data.max_gap already means (tolerate nothing), so this spelling can only mislead. Remove the key, or name a real durationDataCfg::max_gap_ms
data.max_gap: {e}DataCfg::max_gap_ms
data.on_gap is set without data.require_coverage = true, so the gate it configures is not armed and this key is read by nothing — while it reads as though a run over an uncovered window would be stopped. Arm the gate, or remove the dispositionBacktestProfile::refusals
data.on_gap {raw:?} is not one of {roster}. It says what data.require_coverage DOES about a window the store does not cover: refuse stops the run (the default, because a gate that only warns is invisible on every run nobody reads), warn logs the findings and runs unchanged, run leaves the gate inert while keeping the file's statement of what this run is supposed to requireDataCfg::on_gap
data.series[{:?}].kind = {:?} is tick-mode only: a bar series has no quote/trade/book lanes to filterBacktestProfile::validate_data_slice
data.symbols must not be emptyBacktestProfile::validate_data_slice
data.symbols must not be empty (or use the cross-venue [[data.series]] form)BacktestProfile::validate_data_slice
data.universe {raw:?} is not one of {roster}. It is the POINT-IN-TIME membership rule: declared takes the symbol list verbatim (the default), covered names every member whose tape does not span the window and runs anyway, strict refuses that run. None of the three ever drops a member — a universe a run narrowed silently would not be the one the profile namesDataCfg::universe_mode
data.venue is required alongside data.symbolsBacktestProfile::validate_data_slice
data.venue/data.symbols and [[data.series]] are two ways to name the SAME slice — set exactly one (the [[data.series]] form is the cross-venue one)BacktestProfile::validate_data_slice
data.warmup {raw:?} is a CALENDAR span, and a calendar month is not a fixed number of steps (one month from 31 January is 28 days, from 31 March 30) — so its length would depend on where data.from happens to sit, which is the irreproducibility this key exists to remove. Write it in bars ("200bars") or in fixed time ("90d")DataCfg::warmup_steps
data.warmup {raw:?} is a DURATION, which is bar-mode only: a tick tape has no fixed cadence to divide it by, so no number of ticks corresponds to it. The engine gates run_ticks on an EVENT index, so write the requirement as a count — e.g. "200bars" means 200 ticks hereDataCfg::warmup_steps
data.warmup: {e}DataCfg::warmup_steps
duplicate symbol {:?} in the data slice — the engine routes ticks to a symbol slot BY NAME, so two series cannot share one symbolBacktestProfile::validate_data_slice
engine.attach_funding is bar-mode only: the per-interval funding accrual runs in the bar loop; the tick lane carries no funding fold to feedBacktestProfile::refusals
engine.cash must be > 0, got {}BacktestProfile::refusals
engine.cash_gate is bar-mode only: the shared-cash admission phase is StrategyEngine::run's own bar step (fill_step_gated), and run_ticks never consults it — a tick profile setting it would change nothingBacktestProfile::refusals
engine.clamp_to_leverage is unavailable to a profile carrying [risk]: SimBroker::build_risk_gate returns None on clamp_to_leverage BEFORE it reads risk_limits, so the clamp disarms the pre-trade gate entirely and EVERY [risk] limit — not just max_leverage — is discarded. Drop clamp_to_leverage to keep [risk]'s gate, or remove [risk] and clamp against engine.leverage aloneBacktestProfile::refusals
engine.clamp_to_leverage needs engine.leverage: there is nothing to clamp toBacktestProfile::refusals
engine.decide = "simultaneous" is bar-mode only: the cross-sectional allocator it orders is StrategyEngine::fill_step_gated, reached only from StrategyEngine::run — run_ticks consults neither it nor cash_gate, so a tick profile setting it would change nothingBacktestProfile::refusals
engine.decide = "simultaneous" is not built for a run with a pre-trade risk gate: remove [risk] and engine.leverage, or keep engine.decide unset. With a gate armed, SimBroker::gate_order folds the pending set as it ACCUMULATES during the on_bar fan-out, so the symbol judged first faces the whole step budget and later ones face the remainder — the DECISION is still taken in symbol-list order and no fill-phase ranking can repair it. Closing it needs a step-boundary budget snapshot, which does not existBacktestProfile::refusals
engine.equity_sample_every is tick-mode only: the bar lane records one equity sample per BAR (bounded by the bar count), so there is nothing to thinBacktestProfile::refusals
engine.fee and a non-zero engine.fee_rate ({}) are two different cost models — set exactly oneBacktestProfile::refusals
engine.fee.kind = "per_share_with_floor" needs BOTH per_share and min. The floor is the whole reason this shape exists — a per-share fee with no minimum is a flat rate wearing a different name, and it is the configuration that makes a small-size high-frequency run backtest as viable and then lose to the broker minimum live. Write min = 0.0 to assert there is genuinely no floor.FeeCfg::validate_shape
engine.fee.kind = "per_share_with_floor" needs engine.multiplier = 1.0 (the default) and no engine.multipliers row other than 1.0: the fee path scales the schedule's answer by the contract multiplier, so a per-ORDER minimum would be charged per CONTRACT. This shape models an equities book. Drop the multiplier, or price a multiplied contract with percent_maker_taker.BacktestProfile::refusals
engine.fee.kind = "percent_maker_taker" needs at least one of maker_bps / taker_bps — a table naming neither describes no cost, which is what kind = "free" says on purpose. A genuinely zero-fee side is written as the explicit 0.0, so the zero is an assertion rather than an omission.FeeCfg::validate_shape
engine.fee.kind = "percent_of_underlying" needs BOTH bps and premium_cap_pct — the cap is half the rule it models (min(bps x underlying, cap x premium)), and it is the half that binds for a cheap deep-OTM option.FeeCfg::validate_shape
engine.fee.kind = "venue" needs engine.fee.venue — it is not inferred from [data]. EngineParams::fee_schedule is ONE schedule for the whole run, so a cross-venue slice has no single answer to infer, and naming the venue is the operator asserting which venue's book this run is costed at.FeeCfg::validate_shape
engine.fee.kind = "venue" resolves ONE schedule for the whole run, and this run's {venue:?} series straddle {} fee lanes ({}) — a venue prices its lanes completely differently (crates/vike-model/src/fees.rs's fee_schedule_for carries the measurement per lane), so one row would misprice the other lane. Split the run per lane, or set engine.fee.symbol to name the lane this run is costed at.FeeCfg::fee_lane_for
engine.fee.pm_curve is polymarket-only, and this table names venue {:?} — vike_model::fee_schedule_for_with_pm_curve delegates to fee_schedule_for for every other venue, so the flag would configure nothing while the run was read as curve-priced. Drop it.FeeCfg::validate_shape
engine.fee.premium_cap_pct must be > 0, got {} — a zero cap is not "no cap". FeeSchedule::commission on this shape is min(bps x premium, cap x premium), so a zero cap zeroes the whole commission and the run pays no fee at all while the profile reads as fee-modelled. crates/vike-model/src/fees.rs's fee_schedule_for carries the real Deribit cap in its "deribit" arm.FeeCfg::validate_shape
engine.fee.venue = {venue:?} names a venue this run does not load, so there is no symbol to resolve its fee LANE from — and answering with the bare-venue row would charge a perp run at the venue's SPOT schedule, the mispricing the lane key exists to end. This run's series are on: {}. Name a venue the slice trades, or set engine.fee.symbol outright.FeeCfg::fee_lane_for
engine.fee.{key} is a knob of kind {owner:?}, and this table declares kind {kind:?} — nothing would read it. A COST knob that configures nothing is worse than an absent one: the operator reads the profile as priced at what they wrote and the run prices at something else. Set the kind this knob belongs to, or drop the knob.FeeCfg::validate_shape
engine.fee.{name} must be finite and >= 0, got {v}FeeCfg::validate_shape
engine.feed_latency is tick-mode only: it re-orders recorded ticks by their machine receive stamp (local_ts), which a bar series does not carryBacktestProfile::refusals
engine.impact.exec_time must be > 0, got {}ImpactCfg::build
engine.impact.window must be >= 3 (two returns), got {}ImpactCfg::build
engine.impact.{name} must be > 0 and finite, got {v}ImpactCfg::build
engine.leverage and [risk] are two different margin sources for the same gate — set exactly one. With clamp_to_leverage off (the default) there are three configurations and [risk] wins all of them: SimBroker::build_risk_gate's (Some(l), _) arm takes risk_limits unconditionally, so a [risk] that sets max_leverage answers with ITS number, a [risk] that does not sets no margin requirement at all, and either way engine.leverage's 1/L im_requirement is silently discarded. (With clamp_to_leverage on, neither reaches a gate — that combination is refused separately.) Set risk.max_leverage instead, or remove [risk] to use engine.leverage's 1/L margin mappingBacktestProfile::refusals
engine.leverage must be > 0, got {l} — omit the key for unleveredBacktestProfile::refusals
engine.liq_buffer must be >= 0, got {}BacktestProfile::refusals
engine.maint_margin must be >= 0, got {} — 0 means the liquidation watchdog is off, and a negative rate has no meaningBacktestProfile::refusals
engine.multiplier must be > 0, got {} — it scales every position's notional, so zero makes every trade worth nothingBacktestProfile::refusals
engine.multipliers.{sym} must be > 0, got {m}BacktestProfile::refusals
engine.multipliers.{sym} names a symbol this run does not load — the engine matches a multiplier row by EXACT symbol name and otherwise falls back to the global engine.multiplier, so this row would change nothing. The data slice is: {}BacktestProfile::refusals
engine.queue_model is tick-mode only: the FIFO queue lane runs in the tick/book replay path, which a bar series does not driveBacktestProfile::refusals
engine.resolution.window_secs must be > 0, got {}ResolutionCfg::validate_shape
engine.resolution.winners[{slug:?}] = {wi} is not a binary outcome index (0 or 1). Non-binary and unresolved (-1) values are real on-chain facts and are never coerced to "both sides lose" — drop the window from the run instead.ResolutionCfg::validate_shape
engine.resolution: {} window slug(s) in data.series have no binary winning_index (first: {names:?}). A window with no payout would end the run marked at its last traded price — remove it from the slice or supply its resolution.ResolutionCfg::build
engine.settlement_period_ms must be > 0, got {ms} — omit the key to never settleBacktestProfile::refusals
engine.snap_to_properties is single-venue only: the point-in-time instrument grid is looked up under one default_venue, so a cross-venue [[data.series]] slice would snap every series to the first series' venueBacktestProfile::validate_data_slice
engine.timeframes is bar-mode only: the coarse series are resampled from the base BAR stream, and a tick profile has noneBacktestProfile::refusals
engine.timeframes {tf:?} ({ms}ms) must be coarser than data.interval {:?} ({base}ms) — a finer window cannot be synthesised from the base stream and would return the base bars re-labelledBacktestProfile::refusals
engine.timeframes {tf:?} is not a valid interval (want a count then one of s/m/h/d, e.g. "4h")BacktestProfile::refusals
engine.timeframes {tf:?} resolves to {ms}ms — a zero-length window makes every boundary the same instantBacktestProfile::refusals
engine.volume_limit must be in (0, 1], got {v} — it is the fraction of the EVENT's own volume one fill may take (a bar's volume in bar mode, ONE PRINT's in tick mode), so 1.0 is all of itBacktestProfile::refusals
euler search supports at most {MAX_AXES} sweep axes, got {} — the 3^d refinement neighbourhood stops being cheaper than the grid past that; use the {} optimizer (--optimizer {})numeric_axes
queue_model "prob_power:{tail}": {tail:?} is not a numberEngineCfg::queue_model_kind
rhai compile error: {e}strategy_by_name
rhai strategy requires a src param (the inline Rhai script source)strategy_by_name
risk.max_orders_per_window is a wall-clock order-rate throttle — meaningless in backtest sim time (a replayed month can execute in under a second, which would spuriously rate-limit every order; SimBroker::build_risk_gate always disarms this field for the identical reason on the live-parity gate it mounts). Remove it from [risk] — every other risk.* limit is honoredBacktestProfile::refusals
strategy.params must be a tablerequire_overridable_params
strategy.params must be a tableprofile_with_overrides
sweep.{key} mixes integer and float values; {search} search must render every coordinate as one TOML type, which would change what the strategy receives — make the axis all-integer or all-float, or use the {grid} optimizer (--optimizer {grid})numeric_sweep_axes
sweep.{key} must be a non-empty arraysweep_axis_array
sweep.{key} must be an array of values to sweep over, got {value:?}sweep_axis_array
unknown engine.decide {other:?} (want sequential | simultaneous; absent = sequential, the per-symbol walk in the order data.symbols lists them)EngineCfg::decide_mode
unknown engine.fee.kind {kind:?} (known: {}; a single flat fraction of notional may also stay on engine.fee_rate, which is the same shape as percent_maker_taker charged to both sides)FeeCfg::validate_shape
unknown engine.fee.kind {other:?} (known: {})FeeCfg::build_for
unknown engine.impact.model {other:?} (known: "almgren_chriss")ImpactCfg::build
unknown engine.resolution.kind {:?} (known: "binary_outcome")ResolutionCfg::validate_shape
unknown fill_model {other:?} (want l2book; absent = the default Tick model)EngineCfg::fill_model_kind
unknown queue_model {other:?} (want risk_adverse | prob_power[:n] | prob_log)EngineCfg::queue_model_kind
unknown strategy {n:?} (known: {}; user: {})strategy_by_name
walk-forward is bar-mode only: the splitter divides ONE bar series by index, which a tick replay does not produce — set data.kind = "bar"walkforward_preflight
walk-forward optimization needs a [sweep] table to search — this profile has none, so every window would 'select' the one parameter set it already has. Add the grid, or run the fixed-parameter walk instead.run_walkforward_optimized_with
walk-forward runs over ONE bar series — this profile resolves {}; name a single symbolwalkforward_preflight
{search} search needs a [sweep] table (a single-point profile has nothing to search)numeric_sweep_axes
{search} search needs numeric sweep axes, but sweep.{key} contains {v:?} — use the {grid} optimizer (--optimizer {grid}) for non-numeric axesnumeric_sweep_axes

Where this came from

Every table, key, default, accepted set and refusal above is rendered from profile.json, which the Rust workspace DERIVES from the types that declare the schema rather than from a table somebody maintains beside them. The sources it was read from:

fileowns
crates/vike-backtest/src/harness/profile.rsthe profile schema
crates/vike-backtest/src/hist_replay.rs[[data.series]]
  • vike-cli backtest — the command surface: every flag, its default and every refusal, rendered from the CLI's own exported table.
  • Run profiles — the same file from the harness's side: how a slice is resolved, which knobs are bar-only or tick-only, and where a relative path resolves from.
  • Walk-forward and sweeps — what the two optional tables are FOR, rather than what they accept.

On this page