Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/theonetrade/backtest-kit/llms.txt

Use this file to discover all available pages before exploring further.

Cron is a periodic and fire-once scheduler that runs in virtual time — the same time stream your strategies see in backtest mode. Rather than relying on wall-clock intervals, handlers fire on candle-interval boundaries (1m, 5m, 1h, 1d, …). When multiple Backtest.background() calls run in parallel (e.g. five symbols in one process), the first symbol to reach a given boundary opens a shared slot; the others await the same promise and are released together when it settles. This means your handler is called exactly once per boundary regardless of how many symbols are running — no duplicate Telegram fetches, no double cache warm-ups.

Public API

1

Register jobs

Use Cron.register() to create periodic or fire-once entries. The return value is a disposer — call it to unregister later.
const dispose = Cron.register({
  name: "my-job",
  interval: "1h",       // omit for fire-once
  symbols: [],          // omit for global, or provide whitelist for fan-out
  handler: async (symbol, when, backtest) => {
    await doWork(symbol, when);
  },
});
// Later:
dispose();              // same as Cron.unregister("my-job")
2

Enable the lifecycle bridge

Call Cron.enable() once at startup to subscribe Cron to the engine’s four lifecycle subjects (beforeStart, idlePing, activePing, schedulePing). After this every strategy tick is automatically forwarded into Cron.
Cron.enable();
3

Start backtests

Launch your parallel backtests. Cron fires transparently on every virtual boundary.
for (const symbol of symbols) {
  Backtest.background(symbol, { strategyName, exchangeName, frameName });
}
4

Shut down

On process exit, call Cron.disable() to unsubscribe from the lifecycle subjects.
Cron.disable();

Full API reference

name
string
required
Unique entry name. Re-registering the same name replaces the previous entry and bumps an internal generation counter — in-flight handlers from old registrations cannot pollute new ones.
interval
CandleInterval
When provided ("1m", "5m", "1h", etc.), the handler fires once per interval boundary — periodic mode. When omitted, the handler fires on the very first matching tick and never again until clear() or re-registerfire-once mode.
symbols
string[]
When empty or omitted, the handler fires once globally across all parallel backtests (first symbol to reach the boundary wins; others await). When non-empty, the handler fires once per whitelisted symbol per boundary — fan-out mode.
handler
CronCallback
required
async (symbol, when, backtest) => void. symbol is the winning symbol in global mode, or the current symbol in fan-out mode. when is the aligned virtual time. backtest mirrors the execution mode of the tick that opened the slot.
Additional methods:
MethodDescription
Cron.enable()Subscribe to lifecycle subjects. Wrapped in singleshot — call once.
Cron.disable()Tear down subscriptions. Safe to call before enable().
Cron.unregister(name)Remove a registered job by name.
Cron.clear(symbol?)Clear fire-once marks. With symbol → fan-out marks for that symbol only. Without → all marks.
Cron.dispose()Hard reset: disables + clears all entries and fire-once marks.

Two modes: periodic vs fire-once

Periodic jobs fire exactly once every time the virtual clock crosses an interval boundary. The handler is skipped for ticks that fall mid-interval.
// Fires every virtual hour — e.g. to parse Telegram signals into MongoDB
Cron.register({
  name: "tg-signal-parser",
  interval: "1h",
  handler: async (symbol, when, backtest) => {
    await parseTelegramSignalsToMongo(when);
  },
});

Two scopes: global vs fan-out

Without a symbols array the handler fires once per boundary across all parallel backtests. The first symbol to arrive opens the slot; the rest wait on the same promise.
Cron.register({
  name: "fetch-macro-news",
  interval: "4h",
  handler: async (symbol, when, backtest) => {
    // Runs once per 4h boundary regardless of how many symbols are running
    await fetchMacroNews(when);
  },
});

Complete example

The following snippet wires three common Cron patterns — a global periodic job, a per-symbol fan-out, and a fire-once warm-up — before launching five parallel backtests.
import { Cron, Backtest } from "backtest-kit";

// Global hourly job — fires once per virtual hour across all parallel backtests.
Cron.register({
  name: "tg-signal-parser",
  interval: "1h",
  handler: async (symbol, when, backtest) => {
    await parseTelegramSignalsToMongo(when);
  },
});

// Per-symbol fan-out — fires once per hour per whitelisted symbol.
Cron.register({
  name: "fetch-funding",
  interval: "1h",
  symbols: ["BTCUSDT", "ETHUSDT"],
  handler: async (symbol, when, backtest) => {
    await fetchFundingRate(symbol, when);
  },
});

// Fire-once warm-up — runs once globally on the very first tick.
Cron.register({
  name: "warm-cache",
  handler: async (symbol, when, backtest) => {
    await warmupCache();
  },
});

// Wire Cron to the engine once at startup.
Cron.enable();

const symbols = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "TRXUSDT"];
for (const symbol of symbols) {
  Backtest.background(symbol, { strategyName, exchangeName, frameName });
}

// On shutdown:
// Cron.disable();
Call Cron.enable() before starting backtests. If you enable after Backtest.background() has already emitted ticks, those early ticks will not reach Cron and your first-boundary jobs may be delayed or skipped.

Internal coordination — singlerun semantics

Cron.enable() subscribes a single singlerun-wrapped handler to four lifecycle subjects. singlerun serialises all four streams into one queue: at most one _tick runs at a time. This prevents concurrent ticks on the same (symbol, virtual-minute) from racing to open the same slot. Coordination keys are built as ${name}:${alignedMs}:${symbol?}:g${generation}. Parallel backtests that reach the same key share a single in-flight promise — the first opens the slot, the others await the same promise and release together. After .finally() the slot is removed so the next boundary gets a fresh promise.
A failed fire-once handler is not marked as fired. It retries on the next tick automatically. A failed periodic handler is silently logged and does not affect the next boundary — the slot clears in .finally() regardless of success or failure.

Build docs developers (and LLMs) love