vike
Backtesting

Scheduling

Scheduled strategy callbacks fired at bar boundaries, and the one wall-clock rule the backtest shares with the live runtime.

A backtest strategy can ask to be called back on a schedule — the first bar of a new month, every twenty bars, 08:00 UTC each day — and crates/vike-backtest/src/schedule.rs fires those callbacks at bar boundaries. It ports the original core/schedule.py together with analysis/periods.py's period_key, with one Rust adaptation: Python registered arbitrary closures, but a stored closure capturing the strategy cannot coexist with the engine's &mut borrow of it, so a rule is registered with a string tag and the engine fires Strategy::on_schedule(ctx, tag) for each due rule. The firing points and cadence are identical.

Rules

DateRule is the question "should a scheduled callback fire at this bar?", in three shapes:

  • PeriodStart { period } fires on the first bar of each new calendar period. period_key labels the period containing a timestamp — "2024-03-15" daily, "2024-W11" weekly (ISO), "2024-03" monthly, "2024-Q1" quarterly, "2024" yearly — and the rule fires when the label changes. DateRule::month_start, week_start, quarter_start and year_start are the constructors. The label delegates to vike_analytics::periods::period_key: the two used to be hand-maintained twins, one chrono-free and one chrono, a divergence foot-gun, and the backtest is off the latency-gated path so the shared implementation is fine here.
  • EveryNBars { n } fires when bar_index % n == 0; every_n_bars(n) asserts n >= 1.
  • Time { rule } wraps a wall-clock TimeRule and fires on the first bar at or after each scheduled instant, latching the last instant so it fires once per instant.

Schedule is the registry of (rule, tag) pairs. Schedule::on(rule, tag) registers; check_due(ts, bar_index) returns the tags due at this bar, firing each rule at most once per bar_index. In crates/vike-backtest/src/engine.rs's step loop the engine fans on_bar out per symbol, then calls check_due and fires on_schedule per tag — and only once the step index has reached warmup(), the gate this loop puts in front of on_bar and on_schedule and the tick lane in front of on_quote_tick and on_trade_tick. on_fill is not behind it (fire_on_fill applies no warmup test), and neither is on_order_book: book events deliberately do not advance the warmup index and are delivered as soon as a valid book exists.

schedule is a public field on SimBroker, and the broker's impl Broker doc lists it with the other backtest-rich verbs that stay inherent on the concrete broker. Registering a rule therefore takes an impl Strategy<SimBroker>; vike_model::strategy's module doc names the bar-count schedule as one of the five simulator-only concepts — every_n_bars has no wall-clock meaning, and the live schedule takes wall-clock rules as mount configuration, unreachable from a strategy's on_start. See simulator-only strategies.

The shared wall-clock rule

TimeRule lives in crates/vike-model/src/schedule.rs, and its home is the point. It is a pure, allocation-free, clock-free Copy value type in the domain model, so both consumers reach it down-only: the live runtime fires it off the deadline timer wheel at the drain-loop boundary and keeps the LiveSchedule registry that owns a mount's rules, while the backtest drops the same TimeRule into DateRule::Time and checks it per bar. One rule, one law — TimeRule::last_instant_at_or_before is the single crossing primitive both paths call, so a rule computes byte-identical fire instants whether it is driven by bar timestamps or by the injected boundary clock. It is not on the hot path: the live registry is consulted only at the drain-loop boundary, never per market message. Timers describes the wheel.

A rule fires at minute_of_day (0 to 1439) in a fixed-offset timezone on a weekday bitmask (bit 0 Monday through bit 6 Sunday; 0 never fires). The constructors are daily_at_utc(hour, minute), daily_at(hour, minute, tz_offset_min), on_days(hour, minute, tz_offset_min, weekdays) — which panics on an out-of-range HH:MM, a programmer error — and session_offset(anchor_minute_of_day, offset_min, tz_offset_min, weekdays) for a market-open or market-close offset, wrapping into the day: "5 minutes before the 16:00 close" is session_offset(960, -5, tz, TimeRule::WEEKDAYS), which is 15:55. TimeRule::ALL_DAYS and TimeRule::WEEKDAYS are the two shipped masks.

The timezone model is deliberately DST-unaware

tz_offset_min is a fixed offset from UTC in minutes — UTC is 0, UTC+2 is 120, UTC−5 is −300. That covers the 00:00 UTC and 08:00 UTC expiry cases and any fixed-offset market; an IANA, DST-aware variant is named as a future extension because it needs a calendar dependency this pure core avoids.

The latch

TimeRule::crossed(now_ms, last) advances a per-rule latch and reports whether a new instant was crossed. The first call establishes the latch at the most recent past instant without firing, so a schedule mounted — or restored after a restart — mid-period fires at the next instant, never a spurious catch-up of the one that already passed. Thereafter it fires only when the most recent instant has advanced, coalescing any instants skipped across an idle gap into a single fire of the latest. DateRule::Time calls that same crossed, from the same definition, which is what makes a rule fire at byte-identical points on both stacks.

On this page