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.

Backtest Kit is a time execution engine, not a data-processing library. The engine treats time as a first-class stream — your strategy is evaluated step by step as virtual time advances through historical candles (backtest) or the real clock (live). Understanding this distinction makes everything else click into place.

The Execution Engine

The engine is built on two JavaScript primitives: async generators for streaming results and AsyncLocalStorage for implicit time context propagation.

Async Generators for Streaming

Both Backtest.run() and Live.run() return AsyncIterableIterator objects. Results are yielded one at a time — the engine never accumulates a large array of trade results in memory. This matters for long backtests spanning months or years.
// Backtest generator — completes after all timeframes
// Yields: IStrategyTickResultOpened | IStrategyTickResultScheduled |
//         IStrategyTickResultActive | IStrategyTickResultClosed | IStrategyTickResultCancelled
async *run(symbol: string): AsyncGenerator<IStrategyBacktestResult> {
  while (i < timeframes.length) {
    // ...process tick
    if (shouldYield) yield result;
    i++;
  }
}

// Live generator — infinite loop, never completes
async *run(symbol: string): AsyncIterableIterator<IStrategyTickResult> {
  while (true) {
    // ...process tick
    if (shouldYield) yield result;
    await sleep(TICK_TTL);
  }
}
Early termination is possible in both modes using break:
for await (const result of Backtest.run('BTCUSDT', context)) {
  if (result.action === 'closed' && result.pnl.pnlPercentage < -10) {
    break; // terminate early, free resources
  }
}

Virtual Time via AsyncLocalStorage

When your strategy calls getCandles(symbol, '1h', 24), the function needs to know when it is so it can return the correct slice of history. Backtest Kit solves this without passing a when parameter everywhere — it uses Node.js AsyncLocalStorage to attach the current virtual timestamp to the entire async call chain. Every ExecutionContextService.runInContext({ symbol, when, backtest }, ...) call sets the implicit clock for all descendants. This makes the identical getSignal function safe in both modes: in backtest, when is the candle timestamp; in live, when is new Date(). Look-ahead bias is structurally impossible.

Schema Registration Pattern

Before running anything, you register four types of schemas. Each schema is stored in a ToolRegistry keyed by its name. All methods that need to route to the right implementation look up the name from the active MethodContextService.context.
SchemaFunctionWhat it configures
ExchangeaddExchangeSchemaCandle data source, price/quantity formatting
StrategyaddStrategySchemaSignal generation logic, interval throttling, risk profile
FrameaddFrameSchemaBacktest date range and tick interval
RiskaddRiskSchemaValidation functions applied before signal acceptance
Schemas can be partially overridden after registration:
import { overrideFrameSchema } from 'backtest-kit';

overrideFrameSchema({
  frameName: '1d-test',
  endDate: new Date('2026-01-01'), // extend the backtest window
});

Signal Lifecycle

Every trading signal moves through a strict sequence of states represented as a discriminated union:
idle → opened (or scheduled → waiting) → active → closed
                     └──▶ cancelled
  • idle — no signal is active; getSignal is called if the interval has elapsed
  • scheduled — signal with a priceOpen target was just created; emitted once on creation
  • waiting — scheduled signal is still waiting for price to reach priceOpen; emitted on subsequent monitoring ticks
  • opened — signal just opened at current price (market entry) or at priceOpen (limit activation)
  • active — signal is monitoring for TP/SL conditions every minute
  • closed — signal exited with closeReason: take_profit, stop_loss, time_expired, or closed (manual)
  • cancelled — scheduled signal expired or was cancelled before price reached priceOpen
The action discriminator field makes type-safe branching straightforward:
listenSignalBacktest((event) => {
  if (event.action === 'closed') {
    console.log(event.closeReason, event.pnl.pnlPercentage);
  }
});
See the Signal Lifecycle guide for the full type definitions of each state.

Two Execution Modes

The only difference between backtest and live is the class you call and whether you supply a frameName.
PropertyBacktestLive
ClassBacktestLive
Time sourceClientFrame.getTimeframe()Date[]new Date()
Frame requiredYesNo
PersistenceSkipped (memory only)Atomic file writes
LoopIterates timeframes[]while(true) with sleep()
Signal recoveryN/AwaitForInit() loads last state
The strategy schemas, exchange schemas, and risk schemas are registered once and reused by both modes without modification.

VWAP Pricing

All entry, exit, and TP/SL checks use VWAP (Volume Weighted Average Price) calculated from the last 5 one-minute candles (configurable via CC_AVG_PRICE_CANDLES_COUNT):
typicalPrice = (high + low + close) / 3
VWAP = Σ(typicalPrice × volume) / Σ(volume)
If total volume is zero across all candles, the engine falls back to a simple average of close prices. VWAP smooths out single-candle price spikes and produces a more realistic fill price for simulated trades. In backtest mode, each candle in the backtest(candles[]) array drives one VWAP calculation. The engine starts at index 4 (needs at least 5 candles) and checks TP/SL on every subsequent candle until the signal closes or time expires.

Crash-Safe Persistence

In live mode, every signal state mutation goes through setPendingSignal(signal), which atomically writes the signal JSON to disk using writeFileAtomic. On restart, ClientStrategy.waitForInit() reads the last saved state and resumes monitoring without re-entering or duplicating the position.
// On startup (live mode only)
public waitForInit = singleshot(async () => {
  this._pendingSignal = await PersistSignalAdapter.readSignalData(
    this.params.strategyName,
    this.params.execution.context.symbol
  );
});

// Every state change
public async setPendingSignal(pendingSignal: ISignalRow | null) {
  this._pendingSignal = pendingSignal;
  if (!this.params.execution.context.backtest) {
    await PersistSignalAdapter.writeSignalData(
      this._pendingSignal,
      this.params.strategyName,
      this.params.execution.context.symbol
    );
  }
}
The persistence layer is pluggable — swap PersistSignalAdapter.usePersistSignalAdapter(RedisPersist) to use MongoDB + Redis instead of files.

Deeper Guides

Schemas

Full interface reference for all four schema types with working code examples.

Backtesting

Event-driven vs async iterator execution, multi-symbol parallelism, and report generation.

Live Trading

Crash recovery, PersistSignalAdapter, Broker.enable(), and graceful shutdown.

Signal Lifecycle

All four states, discriminated union types, validation, and interval throttling.

Risk Management

Custom validation functions, IRiskCheckArgs, position tracking, and listenRisk.

Build docs developers (and LLMs) love