vike
Backtesting

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:

  1. 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.
  2. 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.
  3. 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.
  4. The fill phase. With the cash gate off, each symbol runs its own pass — fill_pending, or fill_pending_granular when sub-bars exist for this step. With it on, fill_step_gated runs 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.
  5. Price, funding and excursions. The last price becomes the close; a held position is charged funding when a rate exists — a recorded Bar.funding wins, else the opt-in funding source — and the running high and low since entry advance for MAE/MFE.
  6. 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.
  7. The strategy. Past warmup, on_bar fires per symbol in symbols order, then any due scheduled callbacks (scheduling).
  8. 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).

On this page