vike
Backtesting

The sim broker

The backtest fill engine and the context every strategy handler receives — its per-symbol state, the order verbs that all but one cross the live risk gate, the one fill-application path, and the diagnostics a run leaves on it.

crates/vike-backtest/src/engine/sim_broker.rs holds SimBroker: the engine state plus the strategy-facing verbs and reads — the ctx every Strategy handler receives. It was split out of engine.rs with behaviour byte-identical, and it carries the two types a run is configured with: EngineParams, the cost and knob surface, and SymbolState, the mutable state kept once per symbol.

Per-symbol state

Each symbol owns a SymbolState: the Position, the untagged pending orders, realized PnL, the armed protective stop, entry fee and timestamp, the last seen price, the running high and low since entry (for MAE/MFE), the higher-timeframe aggregates, the granular sub-bars, and the tagged map — resting tagged limit orders for the maker lane, keyed by the strategy's stable tag. That map is an IndexMap so fill order is deterministic insertion order, and it is kept separate from pending so the parity-gated untagged paths are untouched: it is empty for every non-maker strategy. A filled or cancelled tag is removed, which reproduces the live tag registry's observable semantics.

The parameters

EngineParams::default() is a fee-free, slippage-free run with 10_000.0 cash, multiplier 1.0, the Bar fill model and every opt-in knob off; each knob's absence is byte-identical to the engine before it existed. The ones you meet first:

  • fee_rate, maker_fee, taker_fee, fee_schedule. A schedule overrides the flat chain by its maker_taker_rates() — except the probability-scaled prediction-market curve, which has no flat equivalent and is routed to the exact per-fill FeeSchedule::commission rather than silently charging nothing.
  • fill_model: Bar (default), Tick (L1 spread-crossing) or L2Book (a taker consumes the replayed book for its own size; degrades to Tick where no book exists — pair it with slippage: 0.0, the walk is the slippage). See fill models.
  • leverage, risk_limits, clamp_to_leverage — the pre-trade gate, below.
  • maint_margin, liq_buffer (default 0.10), venue_style_liquidation — the margin call.
  • cash_gate, active_mask, the three open-position caps, volume_limit and sizer.
  • properties: an optional point-in-time grid lookup (venue, symbol, ts) → grid, a plain closure the harness builds over the store.
  • The tick-lane knobs (queue_model, latency_model, equity_sampling), the settlement lanes (resolution, option_specs, settlement_period_ms), max_price_staleness_ms, session_gate and emulator_release_stops.

Every verb but one crosses the same gate

The intake verbs are submit, submit_close, submit_limit, submit_stop, submit_trailing, submit_market_close, submit_limit_close, cancel_all, and strategy_order_target_percent, which sizes through vike_model::units_from_percent — the same converter the live target verb uses, so a multiplier-bearing instrument sizes identically live and in backtest.

Every opening verb runs the sizer first (size_entry, opening or increasing entries only, passed through when raw), then gate_order. The default gate is the literal live gate: the order crosses vike_exec::RiskGate::check over a RiskContext built from the engine's own state, mirroring the live construction field for field, and a refusal is whole — zero fill — recorded in dropped under the gate's own reason string, so a backtest denial is the live denial. build_risk_gate mounts it once: explicit risk_limits verbatim; else leverage: Some(L) mapped to an initial-margin requirement of 1/L (the doc carries the equivalence to the retired leverage-room formula and its limits); else no gate and a submit path byte-identical to the pre-gate engine. The mounted gate always has max_orders_per_window disarmed, because sim time is not wall time. clamp_to_leverage = true is the escape hatch: cap_to_leverage truncates the order to the remaining room and executes it silently, and the gate is never consulted.

Limits were once ungated

gate_order was gate_market_order and ran for the two market verbs alone; the limit, stop, trailing and tagged maker submits faced no pre-trade gate, so a backtest of a limit strategy ran with the operator's [risk] budget effectively off while live gated all of them — the backtest was the permissive side. Every order-submitting verb but submit_close is gated now. A covered reducing order bypasses the floors, the collar and buying power — on a bankrupt account included — but still faces the per-order notional and projected-exposure caps. The live half is risk and arming.

One fill-application path

Every fill dispatcher ends in apply_fill. It prices the adverse move through slippage_for — the flat slippage, plus the size-dependent impact addend for taker fills only, because an order that rested and got hit was supplying liquidity and charging it impact would fill a resting limit through its own price — then snaps to the point-in-time grid when one is configured, price to the tick and size to the step through vike_model::round_to. An opening fill below min_qty or min_notional is rejected under that reason; a closing fill always executes, falling back to the raw size if step-rounding zeroed a remainder, so a position is never stranded below the minimum. is_maker is decided by order kind — a Limit books as a maker fill even when marketable — and the fee is the flat fold or the exact schedule commission. The post-cost fold is shared with settle_at_payout, the fee-free, slippage-free, grid-free settlement a resolved market or expired option closes through.

The opening/closing split is direction-only, while the live gate's floor bypass requires coverage, so a below-minimum reversal executes whole here and is denied live; below_min_reversals counts it. The field's doc argues the divergence is unreachable above dust at real venue floors — reversing a position worth under about five dollars — and that fixing it would change fill decisions without converging the engines, so the counter stays as a claim a run can contradict.

Reads, and the diagnostics

The reads a strategy steers on: position_of, price_of, equity_now (cash plus signed notional, folded in symbols order with the compensated sum), drawdown_now, pending_of, protective_stop_of, in_flight_of, is_resolved, price_is_stale, symbol_is_open, and the higher-timeframe bars_for / forming_for. The predicates are total — an unknown symbol answers the permissive default (false for is_resolved and price_is_stale, true for symbol_is_open) rather than panicking inside a callback — while the write verbs fail loudly on one.

Under the latency gate, an in-flight order is invisible

With a latency model armed, a submitted order still in flight is in neither pending_of nor the position. The idiom if pending_of(s).is_empty() { submit(..) } re-fires on every tick of the entry window; write it as pending_of(s).is_empty() && in_flight_of(s) == 0. The tagged maker lane is immune — an insert under an existing tag replaces it. See latency models.

What a run leaves on the broker: dropped — (symbol, reason, size, weight) for every refused fill, with reasons including the order kind for cash-gate drops, "volume_cap", "latency_reject", "min_qty" / "min_notional" and the live gate's own strings; stale_deferrals and session_deferrals, so a run with few trades can say "the venue was shut" rather than "the strategy never traded"; slippage_saturations, fills priced on the adverse-move floor; intrabar_both_hit; settlements and variation_settlements; and the trade list. The open-position caps count positions, never resting or in-flight orders, at fill time. Under settlement_period_ms each period's mark-to-market is booked into realized and the cost basis reset, with equity invariant by construction.

On this page