The portfolio engine
The one event engine — aligned per-symbol bars stepped on one shared cash account, orders filling at the next open, a fixed per-step order of gates and settlements, and a tick path that k-way-merges recorded events.
crates/vike-backtest/src/engine.rs's StrategyEngine is multi-asset backtesting on one shared
cash account — an exact port of the Python core/multi_symbol_engine.py::StrategyEngine, kept
line-for-line so that parity beats idiom. Aligned per-symbol bar series are stepped timestamp by
timestamp; an order submitted during a step fills at each symbol's next bar open, so there is
no look-ahead; equity is cash + Σ pos·price·mult.
The engine is generic over S: Strategy<SimBroker>. Handlers receive &mut SimBroker — the
context every verb and read lives on (the sim broker) — and the
fill dispatchers are engine methods rather than broker methods so that on_fill fires
synchronously after each applied fill, the Python engine's exact firing point.
Construction
StrategyEngine::new asserts every series has the same length — they must be aligned — and
pre-tags each bar's symbol once with format_instrument(default_venue, symbol): SYMBOL.VENUE
when a venue is set, the bare symbol otherwise. Per symbol it builds the higher-timeframe
aggregates named in timeframes (timeframes), buckets granular
sub-bars into their coarse step, resolves the contract multiplier once so hot paths index a vector
rather than scanning by string, resolves the session calendar if the gate is on, and mounts the
pre-trade risk gate. The queue tracker and the latency model are created here and consulted only
by the tick path.
One step of the bar loop
StrategyEngine::run executes the same sequence for every bar index:
- Terminal settlements. The opt-in binary-resolution and option-expiry checks run before anything can fill, so a symbol that resolved on this step can neither fill resting orders nor keep a position past resolution.
- Freshness bookkeeping, when the stale-price wait is configured, so a bar that is itself a fresh print has age zero and fills on the spot.
- Protective stops, per symbol, against the bar's open/low/high — except a symbol whose sub-bars drive its own fill pass this step; its stops are checked per sub-bar inside step 4 instead. A breach closes at the shared trigger oracle's price — a bar that gaps through the stop fills at the gapped open — and cancels the fired stop's OCO sibling exits, on every lane.
- The fill phase. With the cash gate off, each symbol runs its own pass —
fill_pending, orfill_pending_granularwhen sub-bars exist for this step. With it on,fill_step_gatedruns across all symbols: reductions and closes fill first and are never gated, opens and adds fill in descending weight order (ties by trigger order), and an order the shared cash cannot fund is dropped and recorded. - Price, funding and excursions. The last price becomes the close; a held position is charged
funding when a rate exists — a recorded
Bar.fundingwins, else the opt-in funding source — and the running high and low since entry advance for MAE/MFE. - Variation-margin settlement when a cadence is configured, then the forced close at the open of any symbol that left the active mask, then the margin call.
- The strategy. Past warmup,
on_barfires per symbol in symbols order, then any due scheduled callbacks (scheduling). - The equity sample: one per bar, plus a per-symbol realized-plus-unrealized curve.
Every untagged fill lane runs one per-order gate sequence through gate_pending — session,
staleness, the emulator stop-release, then the trigger price. Each lane used to spell those calls
inline, which is why the two wait disciplines had to be written five times and kept in sync by
hand. Orders that trigger together on one bar are ordered by the shared adverse-first resolver
(fill resolution).
After the loop, an end-of-run resolution sweep and option-expiry sweep run once — a real
prediction-market tape stops at the trading halt and the resolution posts later, so without the
sweep a position held to expiry would be marked at its last traded price instead of the payout —
then on_stop, then the BacktestResult.
The resolution probe sentinel
With no explicit resolution_end_ts, the end-of-run sweep asks the resolution source "did this
market ever resolve?" at RESOLUTION_PROBE_SENTINEL, which is i64::MAX / 4 rather than
i64::MAX. The source is caller code, and the natural way to write one does windowed arithmetic
on the probe timestamp; with i64::MAX any addition overflows — a debug panic, a silently wrong
wrap in release. The sentinel sits about 2.3 million years past the epoch: after every real
resolution, with three quarters of the range left as headroom.
Liquidation
check_liquidation runs when maint_margin is positive, judging the whole account at the bar's
intrabar adverse marks. The default is the shared scope-parameterized law in
vike_model::liquidation — the same pool_breached and cross_liquidation_plan the live watchdog
runs: breach when maintenance exceeds pool equity by the liq_buffer grace line (default 0.10,
the watchdog's own buffer), then close the biggest losers first, only the excess over the line,
stopping once healthy. That is a real behaviour change for margined backtests, which previously
wiped the whole account at the bare trigger; the old model is preserved bit-for-bit behind the
opt-in venue_style_liquidation stress knob. The live half is margin
calls.
The tick path
StrategyEngine::run_ticks replays recorded events: a stable k-way merge by timestamp across the
per-symbol streams, ties broken by stream order, each tick routed to its symbol slot by name.
on_bar does not fire — the strategy sees on_quote_tick, on_trade_tick and
on_order_book. A recorded book event folds into the symbol's replay book under the shared
L2Book::delta_decision law with strict sequencing: a delta applies only when it is the next
sequence number, and a gap, an orphan or a recorded gap marker drops the book until the next
snapshot re-anchors it — identical to what the live consumer knew at the time. Book events advance
the clock and settle a just-resolved symbol but never fill or bump the warmup index; quotes and
trades own price semantics. How store rows become that stream is tick
replay.
The tick path is the only place the latency model is armed — run_ticks installs the gate for
the replay and removes it afterwards, so the bar path and the vector kernel structurally cannot
consult one (latency models) — the only lane that consults
the queue tracker (the queue lane), and the only one that honours
EquitySampling. It takes two equity samples per tick on purpose: the peak is stamped before the
strategy callback so a drawdown_now read inside it already includes this tick's mark, and the
curve after, because SimBroker exposes cash and sym as public fields and a strategy can move
equity in-callback.
What comes out
BacktestResult carries the trade list and count, the equity curve and its timestamps, final
equity, per-symbol PnL and curves, the strategy's warmup, net funding paid, and the diagnostics
the broker accumulated: dropped (every refused fill with its reason), intrabar_both_hit,
stale_deferrals, session_deferrals and below_min_reversals. The zero-trade analyzer reads
the first of those to explain why orders did not fill (sizing and zero-trade
diagnosis).
Runs on disk
What a finished run leaves under user_data/runs — a common manifest every producer writes identically beside a report only its kind understands — and how the report's Sharpe is annualized from the profile's interval.
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.