A multi-timeframe strategy
Synthesise a higher timeframe from one fine base stream, read it without look-ahead, and know which of the two reads is portable.
A multi-timeframe strategy trades one series while consulting a coarser one — enter on the 1-minute bar only when the 4-hour trend agrees. Nothing in Vike Trader asks you to load two series to do that. crates/vike-backtest/src/timeframe.rs synthesises the coarse stream from the fine one, and its header states the property the whole tutorial rests on: the module is look-ahead-safe by construction, because a coarse bar only becomes visible once its window has fully elapsed, and the engine enforces that visibility.
The vocabulary
parse_timeframe converts a string to milliseconds by delegating to the single interval vocabulary in vike_model::time::interval_ms, so a "30s" series that loads from the history store is a valid backtest timeframe alongside "1m", "4h" and "1d"; anything else comes back as an error naming what was expected. parse_timeframe_accepts_seconds_and_rejects_garbage pins both halves, including the rejections ("1x", "m").
resample(bars, target_ms) aggregates fine bars into coarse ones aligned to epoch windows — one coarse bar per window, chronological, and the final window may be partial. Window membership is a floor-mod of each bar's timestamp, so a "4h" series starts at 00:00, 04:00, 08:00 UTC regardless of where your slice begins.
The two reads, and why they differ
The engine builds the aggregates for you. EngineParams carries a timeframes: Vec<String> field, and crates/vike-backtest/src/engine.rs resamples each registered timeframe per symbol at construction. A strategy then asks the broker:
// One resample per (symbol, timeframe), done once at engine construction.
let params = EngineParams { timeframes: vec!["1h".into(), "4h".into()], ..Default::default() };
// Inside on_bar, per base bar:
let closed: &[Bar] = broker.bars_for(&self.symbol, "4h"); // complete windows only
let forming: Option<Bar> = broker.forming_for(&self.symbol, "4h");BarSeriesBuffer::bars_for documents the rule both spellings implement: the coarse list up to — but not including — the window that contains now, delivered on complete, no look-ahead. SimBroker::bars_for is that read per symbol, with the same arithmetic. That exclusion is the no-look-ahead rule: on a base bar stamped 09:15 you can see the 04:00–08:00 coarse bar and never the 08:00 one, because it has not closed. forming_for is the explicit opposite: the still-forming coarse bar up to now, or None when no base bar has started the current window. Asking for a timeframe that was never registered panics with timeframe … not registered, so a typo in a string is loud rather than silently empty.
bars_for is a SimBroker method, not a Broker method
The trait every strategy is generic over exposes bars(symbol) — the base series — and nothing else. bars_for and forming_for are inherent methods on SimBroker, so a strategy that calls them is written impl Strategy<SimBroker> and is bound to the simulator. A strategy meant to stay portable across brokers resamples for itself from broker.bars(symbol), using the same resample function the engine uses — same arithmetic, same window alignment, one owner of the law.
The shared buffer
BarSeriesBuffer (crates/vike-backtest/src/bar_buffer.rs) is that second path packaged. Construct it with the base bars and the timeframes you want; bars_for(tf, now) and forming_for(tf, now) answer exactly as the engine's per-symbol twins do, and add_live_bar appends a base bar and refreshes every registered timeframe's aggregate — the forward mode, for a series that is still growing. Its header records one Rust adaptation worth knowing before you read the code: the buffer owns the base-bar Vec and the engine reads through it, where the ported original shared a list by reference. One owner, same observable behaviour.
One numeric detail is deliberate rather than incidental. forming_for folds the window's volume through vike_model::py_sum, the Neumaier-compensated sum, while high and low fold in order — the mirror of the parity gate's rule that a ported sum() site uses py_sum and an explicit accumulation loop stays a naive fold. Reproduce that if you write your own aggregation; a plain .sum() there is a bit-level divergence.
What a profile cannot reach
There is no [engine] key for this. crates/vike-backtest/src/harness/profile.rs's EngineCfg declares no timeframes field, so a TOML run profile cannot register a coarse timeframe and the backtest bin cannot run a multi-timeframe strategy that depends on bars_for. Multi-timeframe work today is a Rust one: construct EngineParams yourself, or resample inside the strategy from broker.bars(symbol) — which is also the version that survives being mounted anywhere else.
Verify it
Two checks are worth doing before you trust a signal built this way.
- Count the coarse bars against the window arithmetic. Over a slice of
none-minute bars, a"4h"resample yields one bar per 240-minute epoch window the slice touches, with the last one partial. If your count disagrees, your slice boundaries are not where you think. - Assert the exclusion. At a base timestamp inside a coarse window,
bars_formust not return that window's bar andforming_formust return it. A strategy that reads a coarse close before the window shut is the classic backtest that cannot be reproduced live, and this is the one line of test that catches it.
Next: timeframes for the reference page, the portfolio engine for how per-symbol state is laid out, and tick replay when the base stream is ticks rather than bars.
Bayesian parameter search
Run a TPE search over a sweep profile, compare it against successive halving and the exhaustive grid, and keep the result reproducible.
Pairs stat-arb with a cost gate
Run the pairs_zscore template over two real Binance symbols with its round-trip cost floor armed, and read the negative result it exists to produce.