vike
Backtesting

Timeframes

How a backtest synthesises higher timeframes from one fine base stream, and why a coarse bar is never visible before its window closes.

A backtest runs on one base stream per symbol. Every coarser timeframe a strategy reads is synthesised from it, in process, by crates/vike-backtest/src/timeframe.rs — an exact port of the original core/timeframe.py — and served through a buffer that is look-ahead-safe by construction: a coarse bar becomes visible only once its window has fully elapsed, and the engine enforces that visibility.

The interval vocabulary

parse_timeframe accepts a string like "30s", "1m", "4h" or "1d" and returns milliseconds. It delegates to the single interval vocabulary in vike_model::time::interval_ms: an integer count followed by exactly one unit character — s, m, h or d — with anything malformed rejected. That delegation is why seconds work: the module's own test pins "30s" to 30,000 ms, "1m" to 60,000 and "4h" to 14,400,000, and records that a "30s" series which loaded fine from the history store used to be an invalid backtest timeframe before the vocabulary was unified.

The interval also sets the report's annualisation. crates/vike-backtest/src/harness/report.rs's periods_per_year derives the number of return observations a year produces from the interval — 252 · (86,400,000 / interval_ms), so 1h gives 6,048 and 1m 362,880 — with the 1d anchor preserved bit for bit. It used to return 252 for every interval, which understated every intraday Sharpe by sqrt(1440) ≈ 37.9x. See the report.

Resampling

resample(bars, target_ms) aggregates fine bars into coarse ones aligned to epoch windows of target_ms. A bar's window start is ts − ts.rem_euclid(target_ms) (Python's floor-mod), and one coarse bar is emitted per window in chronological order: open from the first fine bar, high and low folded across the window, close from the last, volume summed. The final window may be partial. funding, bid, ask and symbol are None on a resampled bar.

The buffer

crates/vike-backtest/src/bar_buffer.rs's BarSeriesBuffer is the shared multi-timeframe buffer, ported from core/bar_buffer.py with bisect becoming partition_point. Where the Python shared its base list by reference between engine and buffer, here the buffer owns the base-bar Vec and the engine reads through it — one owner, same observable behaviour. BarSeriesBuffer::new(bars, timeframes) resamples every registered timeframe up front; add_live_bar appends a base bar and refreshes each aggregate, the forward mode.

Two reads answer the strategy, both keyed on now:

  • bars_for(tf, now) returns the completed higher-timeframe bars visible at now — deliver-on-complete, no look-ahead: the coarse list up to but not including the window that contains now. Asking for an unregistered timeframe panics with timeframe … not registered.
  • forming_for(tf, now) returns the still-forming coarse bar up to now, or None when no base bar has started the current window. Its volume is summed with vike_model::py_sum, because the Python it ports used the builtin sum(), and its high and low are folded in order — the rule from the parity gate.

In the engine

The portfolio engine carries the same aggregates per symbol. EngineParams.timeframes (crates/vike-backtest/src/engine/sim_broker.rs, default empty) names the timeframes to register, and each SymbolState holds its tf list — "(tf, target_ms, coarse bars)". SimBroker::bars_for(symbol, tf) and SimBroker::forming_for(symbol, tf) mirror the single-engine buffer per symbol, cutting the coarse list at the window containing self.now and building the forming bar from the live base series, so the reads a strategy makes inside on_bar see exactly what the buffer would have shown.

Multi-timeframe reads are simulator-only

vike_model::strategy's module doc lists multi-timeframe reads among the powers a strategy takes by being written against the concrete SimBroker — impl Strategy<SimBroker> — rather than the portable impl<B: Broker> Strategy<B>. The type system then documents that the strategy is not live-portable. The portable Broker::bars read is the closed base bars up to and including the current step, nothing coarser.

The multi-timeframe strategy tutorial reads two synthesised timeframes from one base stream; the portfolio engine describes the step loop these reads are answered inside, and authoring in Rust covers the two forms a strategy can take.

On this page