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-effectiveThe 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 windowA 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.
| key | takes | when absent | what it does |
|---|---|---|---|
n_splits | integer | absent | Number of out-of-sample windows — window form (a), the one that existed first. |
train | string | absent | Window 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. |
test | string | absent | Window form (b)/(c) — the VALIDATION window's length. Required with Self::train. |
step | string | absent | How far each window advances. Absent ⇒ equal to Self::test, which tiles the validation windows edge to edge. |
purge | string | absent | Bars dropped BETWEEN train and test, so test_start - train_end == purge exactly. |
embargo | string | absent | The 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. |
search | string | absent | Which 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. |
mode | string | absent | The 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_by | string | absent | How 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:
| refused | raised 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 one | walkforward_preflight |
| the duration window form needs BOTH walkforward.train and walkforward.test (step defaults to test) — a half-declared window has no shape | WalkforwardCfg::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 0 | BacktestProfile::refusals |
| walkforward.n_splits must be >= 1, got 0 | resolve_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 suffix | to_bars |
walkforward.{key} is shorter than one {interval} bar — it would resolve to zero bars and change nothing; widen it or use the bars suffix | to_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.02max_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:
| kind | requires | wraps a base sizer |
|---|---|---|
pass_through | — nothing | no — a base is refused |
fixed_dollar | amount | no — a base is refused |
fixed_shares | shares | no — a base is refused |
pct_equity | pct | no — a base is refused |
pct_volatility | pct | no — a base is refused |
max_risk_pct | pct | no — a base is refused |
portfolio_heat | max_heat | yes — [engine.sizer.base] is required |
drawdown_throttle | sensitivity, floor | yes — [engine.sizer.base] is required |
| key | takes | when absent | what it does |
|---|---|---|---|
kind | string | required | One 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). |
amount | number | absent | crate::sizing::FixedDollarSizer::amount — fixed cash notional per entry. Required for "fixed_dollar". |
shares | number | absent | crate::sizing::FixedSharesSizer::shares — fixed share/contract count per entry. Required for "fixed_shares". |
pct | number | absent | The 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_heat | number | absent | crate::sizing::PortfolioHeatSizer::max_heat — total open-risk cap as a fraction of equity. Required for "portfolio_heat". |
sensitivity | number | absent | crate::sizing::DrawdownThrottleSizer::sensitivity. Required for "drawdown_throttle". |
floor | number | absent | crate::sizing::DrawdownThrottleSizer::floor. Required for "drawdown_throttle". |
base | table | absent | The 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. |
| refused | raised 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 it | SizerCfg::need |
| engine.sizer.kind = "portfolio_heat" wraps a base sizer — add an [engine.sizer.base] table naming it | SizerCfg::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 kind | SizerCfg::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.02A 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:
| table | keys | absent is legal | reached as |
|---|---|---|---|
| the root | 7 | — always present | --set <key>=<value> |
[data] | 14 | no — required | --set data.<key>=<value> |
[engine] | 35 | no — required | --set engine.<key>=<value> |
[strategy] | 2 | no — required | --set strategy.<key>=<value> |
[risk] | 11 | yes | --set risk.<key>=<value> |
[walkforward] | 9 | yes | --set walkforward.<key>=<value> |
[data.series] | 3 | yes | --set data.series.<key>=<value> |
[engine.impact] | 5 | yes | --set engine.impact.<key>=<value> |
[engine.fee] | 14 | yes | --set engine.fee.<key>=<value> |
[engine.resolution] | 5 | yes | --set engine.resolution.<key>=<value> |
[engine.sizer] | 8 | yes | --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
| key | takes | when absent | what it does |
|---|---|---|---|
name | string | absent | Free-form label (logs/report titles only — not used for lookup). |
data | table | required | — |
engine | table | required | — |
strategy | table | required | — |
risk | table | absent | Optional 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). |
paramscan | any TOML value | absent | Optional 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". |
walkforward | table | absent | Optional 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| key | takes | when absent | what it does |
|---|---|---|---|
data.venue | string | absent | Single-venue form: the one venue every entry of Self::symbols lives under. Mutually exclusive with Self::series. |
data.symbols | array of string | [] | Single-venue form: the symbols to load under Self::venue. Mutually exclusive with Self::series. |
data.series | array 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.interval | string | 1d | — |
data.from | string | required | Bare epoch-ms integer (as a string) or YYYY-MM-DDTHH UTC — see BacktestProfile::range. |
data.to | string | required | Same format as from. |
data.warmup | string | absent | How 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_interval | string | absent | Opt-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.explain | true / false | false | PLAN-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_coverage | true / false | false | ARM 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_gap | string | absent | How 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_gap | string | absent | What 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.universe | string | absent | POINT-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:
| refused | raised 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 ignored | BacktestProfile::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 base | DataCfg::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 replaying | DataCfg::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 hold | DataCfg::detail_interval_ms |
| data.detail_interval {raw:?} resolves to {ms}ms — a zero-length window makes every sub-bar boundary the same instant | DataCfg::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 it | DataCfg::detail_interval_ms |
| data.interval {:?} is not a valid interval, so engine.timeframes cannot be checked against it | BacktestProfile::refusals |
| data.interval {:?} is not a valid interval, so the duration data.warmup {raw:?} cannot be converted to a step count | DataCfg::warmup_steps |
| data.interval {:?} resolves to {base}ms, so the duration data.warmup {raw:?} names no number of steps | DataCfg::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 tolerance | BacktestProfile::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 duration | DataCfg::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 disposition | BacktestProfile::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 require | DataCfg::on_gap |
| data.series[{:?}].kind = {:?} is tick-mode only: a bar series has no quote/trade/book lanes to filter | BacktestProfile::validate_data_slice |
| data.symbols must not be empty | BacktestProfile::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 names | DataCfg::universe_mode |
| data.venue is required alongside data.symbols | 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 |
| 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 here | DataCfg::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' venue | BacktestProfile::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-labelled | BacktestProfile::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 suffix | to_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 quietThe 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| key | takes | when absent | what it does |
|---|---|---|---|
engine.cash | number | required | — |
engine.fee_rate | number | 0 | — |
engine.slippage | number | 0 | — |
engine.snap_to_properties | true / false | false | — |
engine.attach_funding | true / false | false | Opt-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_latency | true / false | false | Opt-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_ms | integer | 0 | Opt-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_ms | integer | 0 | Opt-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_model | string | absent | Opt-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_ms | integer | absent | — |
engine.impact | table | absent | Opt-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_stops | true / false | false | Stop-verb release timing (see crate::EngineParams::emulator_release_stops). |
engine.fee | table | absent | Opt-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.resolution | table | absent | Opt-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_model | string | absent | Opt-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_depth | number | absent | Fallback 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_ms | integer | absent | Minimum-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_every | integer | absent | Equity-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.timeframes | array 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_gate | true / false | false | Admit a fill only when the shared cash pool can fund it. Default false, which is what every profile written before this key did. |
engine.decide | string | absent | WHEN 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_margin | number | 0 | Maintenance-margin RATE (a fraction of adverse notional), folded as |size| · adverse · multiplier · maint_margin by the liquidation watchdog. |
engine.liq_buffer | number | 0.10 | Equity 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_liquidation | true / false | false | Opt-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_limit | number | absent | Participation 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_positions | integer | 0 | Ceiling 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_long | integer | 0 | Ceiling on concurrently open LONG positions. ⚠ 0 means unlimited, as above. |
engine.max_open_short | integer | 0 | Ceiling on concurrently open SHORT positions. ⚠ 0 means unlimited, as above. |
engine.multiplier | number | 1.0 | Contract 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.multipliers | table 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.leverage | number | absent | The 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_leverage | true / false | false | Clamp an order down to what Self::leverage allows instead of rejecting it. |
engine.settlement_period_ms | integer | absent | Variation-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_gate | true / false | false | Defer 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.sizer | table | absent | How 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:
| refused | raised 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 ignored | 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 base | DataCfg::detail_interval_ms |
| data.interval {:?} is not a valid interval, so engine.timeframes cannot be checked against it | BacktestProfile::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 feed | BacktestProfile::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 nothing | BacktestProfile::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 alone | BacktestProfile::refusals |
| engine.clamp_to_leverage needs engine.leverage: there is nothing to clamp to | BacktestProfile::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 nothing | BacktestProfile::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 exist | BacktestProfile::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 thin | BacktestProfile::refusals |
| engine.fee and a non-zero engine.fee_rate ({}) are two different cost models — set exactly one | BacktestProfile::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 carry | BacktestProfile::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 mapping | BacktestProfile::refusals |
| engine.leverage must be > 0, got {l} — omit the key for unlevered | BacktestProfile::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 meaning | BacktestProfile::refusals |
| engine.multiplier must be > 0, got {} — it scales every position's notional, so zero makes every trade worth nothing | BacktestProfile::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 drive | BacktestProfile::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 settle | BacktestProfile::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 it | SizerCfg::need |
| engine.sizer.kind = "portfolio_heat" wraps a base sizer — add an [engine.sizer.base] table naming it | SizerCfg::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 kind | SizerCfg::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' venue | BacktestProfile::validate_data_slice |
| engine.timeframes is bar-mode only: the coarse series are resampled from the base BAR stream, and a tick profile has none | BacktestProfile::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-labelled | BacktestProfile::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 instant | BacktestProfile::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 it | BacktestProfile::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 runsBefore 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.
| key | takes | when absent | what it does |
|---|---|---|---|
strategy.name | string | required | — |
strategy.params | any TOML value | {} | — |
What [strategy] refuses, verbatim from the code that refuses it:
| refused | raised by |
|---|---|
| strategy.params must be a table | require_overridable_params |
| strategy.params must be a table | profile_with_overrides |
[risk]
a table · optional · refuses an undeclared key · read from ProfileRisk
| key | takes | when absent | what it does |
|---|---|---|---|
risk.tick_size | number | required | — |
risk.lot_size | number | required | — |
risk.min_notional | number | required | — |
risk.min_qty | number | required | per-order minimum quantity floor (venue lot-size grid); checked on the lot-rounded qty. |
risk.max_notional_per_order | number | required | risk 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_exposure | number | required | risk 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_window | integer | required | — |
risk.window_ms | integer | 0 | throttle window (ms); only active when max_orders_per_window is set |
risk.max_leverage | number | required | THE 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_overshoot | true / false | false | — |
risk.required_free_bp_pct | number | 0 | LEAN free-buying-power haircut on equity, [0.0, 1.0) |
What [risk] refuses, verbatim from the code that refuses it:
| refused | raised 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 alone | BacktestProfile::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 exist | BacktestProfile::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 mapping | BacktestProfile::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 honored | BacktestProfile::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.| key | takes | when absent | what it does |
|---|---|---|---|
walkforward.n_splits | integer | absent | Number of out-of-sample windows — window form (a), the one that existed first. |
walkforward.train | string | absent | Window 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.test | string | absent | Window form (b)/(c) — the VALIDATION window's length. Required with Self::train. |
walkforward.step | string | absent | How far each window advances. Absent ⇒ equal to Self::test, which tiles the validation windows edge to edge. |
walkforward.purge | string | absent | Bars dropped BETWEEN train and test, so test_start - train_end == purge exactly. |
walkforward.embargo | string | absent | The 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.search | string | absent | Which 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.mode | string | absent | The 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_by | string | absent | How 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:
| refused | raised 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 one | walkforward_preflight |
| the duration window form needs BOTH walkforward.train and walkforward.test (step defaults to test) — a half-declared window has no shape | WalkforwardCfg::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 0 | BacktestProfile::refusals |
| walkforward.n_splits must be >= 1, got 0 | resolve_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
| key | takes | when absent | what it does |
|---|---|---|---|
data.series.venue | string | required | — |
data.series.symbol | string | required | — |
data.series.kind | "tick" | "quote" | "trade" | "book" | type default | — |
What [data.series] refuses, verbatim from the code that refuses it:
| refused | raised 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' venue | BacktestProfile::validate_data_slice |
[engine.impact]
a table · optional · refuses an undeclared key · read from ImpactCfg
| key | takes | when absent | what it does |
|---|---|---|---|
engine.impact.model | string | required | which model — currently only "almgren_chriss" |
engine.impact.exec_time | number | 1.0 | Execution 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.window | integer | 21 | Rolling 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.gamma | number | 0.314 | Permanent-impact coefficient, defaulting to the published crate::impact::AC_GAMMA. |
engine.impact.eta | number | 0.142 | Temporary-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:
| refused | raised 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
| key | takes | when absent | what it does |
|---|---|---|---|
engine.fee.kind | string | required | Which vike_model::FeeSchedule shape this table describes: "probability_scaled" | "per_share_with_floor" | "percent_of_underlying" | "percent_maker_taker" | "venue" | "free". |
engine.fee.taker_rate | number | 0 | probability_scaled: the TAKER fraction of the p(1−p)-scaled share count. |
engine.fee.maker_rate | number | 0 | probability_scaled: the MAKER fraction, before any rebate. |
engine.fee.maker_rebate_share | number | 0 | probability_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_share | number | absent | per_share_with_floor: the per-SHARE fee. REQUIRED by that kind. |
engine.fee.min | number | absent | per_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_pct | number | absent | per_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.bps | number | absent | percent_of_underlying: basis points of the UNDERLYING notional. REQUIRED by that kind. |
engine.fee.premium_cap_pct | number | absent | percent_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_bps | number | absent | percent_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_bps | number | absent | percent_maker_taker: taker basis points of quote-notional. See Self::maker_bps. |
engine.fee.venue | string | absent | venue: 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.symbol | string | absent | venue: 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_curve | true / false | absent | venue, 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:
| refused | raised 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
| key | takes | when absent | what it does |
|---|---|---|---|
engine.resolution.kind | string | required | currently only "binary_outcome" |
engine.resolution.window_secs | integer | 300 | Seconds 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.path | string | absent | Optional slug,winning_index CSV. Relative paths resolve against the profile file's own directory (BacktestProfile::base_dir), not the CWD. |
engine.resolution.winners | table of integer | {} | Optional inline slug = winning_index table. Merged over path's rows. |
engine.resolution.end_ts | string | absent | Explicit 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:
| refused | raised 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| key | takes | when absent | what it does |
|---|---|---|---|
engine.sizer.kind | string | required | One 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.amount | number | absent | crate::sizing::FixedDollarSizer::amount — fixed cash notional per entry. Required for "fixed_dollar". |
engine.sizer.shares | number | absent | crate::sizing::FixedSharesSizer::shares — fixed share/contract count per entry. Required for "fixed_shares". |
engine.sizer.pct | number | absent | The 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_heat | number | absent | crate::sizing::PortfolioHeatSizer::max_heat — total open-risk cap as a fraction of equity. Required for "portfolio_heat". |
engine.sizer.sensitivity | number | absent | crate::sizing::DrawdownThrottleSizer::sensitivity. Required for "drawdown_throttle". |
engine.sizer.floor | number | absent | crate::sizing::DrawdownThrottleSizer::floor. Required for "drawdown_throttle". |
engine.sizer.base | table | absent | The 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:
| refused | raised by |
|---|---|
| engine.sizer.kind = "drawdown_throttle" wraps a base sizer — add an [engine.sizer.base] table naming it | SizerCfg::need |
| engine.sizer.kind = "portfolio_heat" wraps a base sizer — add an [engine.sizer.base] table naming it | SizerCfg::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 kind | SizerCfg::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.
| refused | raised 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 ignored | 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 base | DataCfg::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 replaying | DataCfg::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 hold | DataCfg::detail_interval_ms |
| data.detail_interval {raw:?} resolves to {ms}ms — a zero-length window makes every sub-bar boundary the same instant | DataCfg::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 it | DataCfg::detail_interval_ms |
| data.interval {:?} is not a valid interval, so engine.timeframes cannot be checked against it | BacktestProfile::refusals |
| data.interval {:?} is not a valid interval, so the duration data.warmup {raw:?} cannot be converted to a step count | DataCfg::warmup_steps |
| data.interval {:?} resolves to {base}ms, so the duration data.warmup {raw:?} names no number of steps | DataCfg::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 tolerance | BacktestProfile::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 duration | DataCfg::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 disposition | BacktestProfile::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 require | DataCfg::on_gap |
| data.series[{:?}].kind = {:?} is tick-mode only: a bar series has no quote/trade/book lanes to filter | BacktestProfile::validate_data_slice |
| data.symbols must not be empty | BacktestProfile::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 names | DataCfg::universe_mode |
| data.venue is required alongside data.symbols | 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 |
| 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 here | DataCfg::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 symbol | BacktestProfile::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 feed | BacktestProfile::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 nothing | BacktestProfile::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 alone | BacktestProfile::refusals |
| engine.clamp_to_leverage needs engine.leverage: there is nothing to clamp to | BacktestProfile::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 nothing | BacktestProfile::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 exist | BacktestProfile::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 thin | BacktestProfile::refusals |
| engine.fee and a non-zero engine.fee_rate ({}) are two different cost models — set exactly one | BacktestProfile::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 carry | BacktestProfile::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 mapping | BacktestProfile::refusals |
| engine.leverage must be > 0, got {l} — omit the key for unlevered | BacktestProfile::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 meaning | BacktestProfile::refusals |
| engine.multiplier must be > 0, got {} — it scales every position's notional, so zero makes every trade worth nothing | BacktestProfile::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 drive | BacktestProfile::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 settle | BacktestProfile::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' venue | BacktestProfile::validate_data_slice |
| engine.timeframes is bar-mode only: the coarse series are resampled from the base BAR stream, and a tick profile has none | BacktestProfile::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-labelled | BacktestProfile::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 instant | BacktestProfile::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 it | BacktestProfile::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 number | EngineCfg::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 honored | BacktestProfile::refusals |
| strategy.params must be a table | require_overridable_params |
| strategy.params must be a table | profile_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 array | sweep_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 symbol | walkforward_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 axes | numeric_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:
| file | owns |
|---|---|
crates/vike-backtest/src/harness/profile.rs | the profile schema |
crates/vike-backtest/src/hist_replay.rs | [[data.series]] |
Related
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.
backtest
Every flag, default, refusal and closed set of vike-cli backtest's 12 sub-verbs, rendered from the CLI's own exported surface.
research
The plane that investigates a signal and fits a model — the step before a strategy exists — by asking the compute daemon to run a compiled study against the hist store it holds.