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 itsmaker_taker_rates()— except the probability-scaled prediction-market curve, which has no flat equivalent and is routed to the exact per-fillFeeSchedule::commissionrather than silently charging nothing.fill_model:Bar(default),Tick(L1 spread-crossing) orL2Book(a taker consumes the replayed book for its own size; degrades toTickwhere no book exists — pair it withslippage: 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(default0.10),venue_style_liquidation— the margin call.cash_gate,active_mask, the three open-position caps,volume_limitandsizer.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_gateandemulator_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.
The vector engine
The vectorized time-by-symbol portfolio kernel — a deliberately narrow target-weight path with next-open fills on shared cash, reconciled bit-for-bit against the event engine on the path they share.
The queue lane
The opt-in queue-position fill lane of the tick replay — the queue-gated twins of the frozen per-tick fill pass, entered only when a queue model is configured, where a price touch no longer fills a resting limit on its own.