vike
Backtesting

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.

crates/vike-backtest/src/vector_engine.rs is the multi-asset time×symbol portfolio kernel: the vectorized fast path for cross-sectional and target-weight strategies, an exact port of the Python core/portfolio_fastsim.py::_portfolio_kernel and fast_portfolio_backtest. Where the event engine steps a strategy object through callbacks, this kernel takes matrices and runs one pass.

The scope is the parity boundary

The header draws the line deliberately narrowly. In scope is the cash_gate=False, market-order, target-weight path only: next-open fills, column-order shared-cash application — cash may go negative; there is no gate, no reduce-first, no weight priority — taker fees, slippage, funding and cost-basis averaging. Out of scope, and the reason to use the event engine instead: stops and brackets, the cash gate, volume and position caps, leverage capping, membership masks, liquidation, sub-bars and sizers.

The inputs are row-major Matrix values of shape (T, S): opens, closes, funding and target_weights, plus the timestamp vector, the fee and slippage scalars, initial cash and one contract multiplier. VectorBacktestEngine::run is the one public entry point; the free function fast_portfolio_backtest is the implementation detail and the parity-gate target. S = 1 is the single-symbol case — there is no separate single engine.

The target-weights contract

weights[t, s] is the weight decided at bar t, from closes up to and including t, and realized at t+1's open. NaN means "no rebalance decided" for that symbol and bar — nothing is queued. A weight of 0.0 for a held symbol drives a full close. The last bar's row is never acted on, because there is no next open to fill at.

Per bar t, the kernel does four things in order:

  1. Fill the deltas queued at t−1 at opens[t, s], in column order, on the shared cash: the fill price is the adverse-moved open, the fee is charged on the filled notional, the signed notional moves cash, and the shared TradeFold steps this symbol's cost basis and emits a closed-portion Trade on a reduce, close or flip.
  2. Charge funding on held positions, marked at the close.
  3. Mark equity once — a single shared snapshot used for both the curve and the next sizing decision.
  4. Decide the next deltas from this bar's target weights: w · eq / close − pos, with a dead-band of 1e-12 below which nothing is queued, and a close == 0.0 guard mirroring the Python _engine_target's non-positive-price guard. Skipped on the last bar.

The output is the shared BacktestResult: the equity curve, final equity, the trade count and, when build_trades is set, the trade list, plus net funding paid.

The maker/taker law, and a dead parameter

Every fill this kernel produces is a rebalance crossing the market at the next bar's open. There is no resting-limit path, so is_maker is always false and every fill is charged taker_fee. That agrees with the event engine's own classification — maker if and only if the order kind is Limit — for the same strategy verb this kernel mirrors, SimBroker::strategy_order_target_percent, which only ever submits market rebalances. The two engines are reconciliation partners precisely because neither has a maker-classified fill on this path.

maker_fee is threaded through and dead

crates/vike-backtest/src/vector_engine.rs's fast_portfolio_backtest records it as a local trap: the kernel classifies every fill as taker, so its maker_fee parameter is dead at every call site. It exists so a future maker path must consciously wire it rather than silently defaulting to taker_fee by omission. The engine-kernel parity test's fee-schedule-armed row proves the two engines agree bit-for-bit under a schedule with maker ≠ taker precisely because this kernel never reads its maker side.

Reconciliation with the event engine

Engine-versus-kernel agreement is a second oracle, gated as a test in crates/vike-backtest/tests/parity/engine_kernel_parity.rs. The kernel shares the event engine's cost primitives — adverse_fill_price, fee and funding_charge from the cost model — and the same TradeFold, so the comparison is of two loops over one arithmetic, not two implementations of it.

The equivalence holds only on the path both engines have. crates/vike-backtest/src/engine/sim_broker.rs's EngineParams documentation names, knob by knob, what the kernel does not consult: the market impact model (it has no per-fill market context), the funding source closure (it takes funding as a fully materialized matrix), the binary-resolution and option-expiry settlement sources, the variation-margin cadence and the latency model. Reconciliation is meaningful only with every one of those left at its default, which is what the parity gate runs.

When to reach for it

If the strategy is a matrix of weights — a rotation, a cross-sectional rank, a fixed allocation rebalanced on a schedule — and you already hold the aligned open/close/funding matrices, this kernel answers with one pass and no callbacks. The moment the strategy needs to see a fill, rest an order, protect a position with a stop, or be told it cannot afford a trade, it belongs on the portfolio engine.

On this page