This document explains the step-by-step orchestration of backtesting execution through historical timeframes, focusing on the BacktestLogicPrivateService and its coordination with frame generation, signal processing, and candle data retrieval. The backtest execution uses an async generator pattern for memory-efficient streaming of results.
For information about configuring backtests and the Public API, see Backtest API. For details on timeframe generation itself, see Timeframe Generation. For the fast-forward simulation algorithm that processes opened signals, see Fast-Forward Simulation.
The backtest execution follows a pipeline where BacktestLogicPrivateService orchestrates the flow through three major service domains: Frame (timeframe generation), Strategy (signal lifecycle), and Exchange (historical data). The process streams results as an async generator, allowing early termination and preventing memory overflow on large backtests.
High-Level Execution Sequence
The backtest execution involves multiple service layers with clear separation of concerns. The Public service handles context injection, the Private service orchestrates the execution loop, and Global services provide domain-specific operations.
Service Layer Interaction Diagram
The BacktestLogicPublicService.run() method wraps the private service with MethodContextService.runAsyncIterator() to propagate context through all operations.
Context Propagation
| Context Type | Service | Purpose |
|---|---|---|
| Method Context | MethodContextService |
Routes to correct strategy/exchange/frame schemas |
| Execution Context | ExecutionContextService |
Provides symbol, current timestamp (when), backtest flag |
The private service begins by fetching the complete timeframe array from FrameGlobalService.getTimeframe(). This array contains all timestamps to iterate through, spaced according to the configured interval.
// From BacktestLogicPrivateService.run()
const timeframes = await this.frameGlobalService.getTimeframe(symbol);
The timeframe generation is configured via addFrame() and handled by ClientFrame. For a 24-hour backtest with 1-minute intervals, this produces 1,440 timestamps.
The service iterates through the timeframe array using a while loop with manual index management. This allows skipping timestamps when signals close.
For each timestamp, the service calls StrategyGlobalService.tick() with backtest=true. This executes the strategy's signal generation and validation logic.
Tick Results by Action
| Action | Description | Next Step |
|---|---|---|
idle |
No signal generated, throttling interval not elapsed | Increment i++ and continue |
active |
Should not occur in backtest mode (signals open and immediately backtest) | Increment i++ and continue |
opened |
New signal generated and validated | Proceed to fast-forward simulation |
When a signal opens (result.action === "opened"), the backtest flow transitions to fast-forward simulation mode rather than iterating through every timestamp manually.
The service fetches future candles using ExchangeGlobalService.getNextCandles() with the signal's minuteEstimatedTime parameter. This retrieves exactly the number of candles needed to simulate the signal's lifecycle.
// From BacktestLogicPrivateService at line 73-79
const candles = await this.exchangeGlobalService.getNextCandles(
symbol,
"1m",
signal.minuteEstimatedTime,
when,
true
);
If no candles are returned (end of historical data), the generator terminates early.
The ClientStrategy.backtest() method receives the candle array and iterates through it, calculating VWAP from rolling 5-candle windows and checking for TP/SL hits. The method always returns a closed result.
Backtest Algorithm Summary
candles[i-4:i+1]closeReason="time_expired"After receiving a closed result from backtest(), the iteration loop skips all timestamps between the current position and the signal's closeTimestamp. This prevents re-opening signals during periods when a signal was already active.
Skip Loop Implementation
// From BacktestLogicPrivateService at line 107-112
while (
i < timeframes.length &&
timeframes[i].getTime() < backtestResult.closeTimestamp
) {
i++;
}
Skipping Example Visualization
This skipping ensures:
The backtest execution is designed for memory efficiency, enabling backtests over millions of timestamps without exhausting memory.
Memory Efficiency Techniques
| Pattern | Implementation | Benefit |
|---|---|---|
| Async Generator | async *run() yields results one at a time |
Results streamed to consumer, not accumulated in array |
| Early Termination | Consumer can break out of for-await loop |
Allows stopping backtest early on criteria (e.g., max drawdown) |
| Single Result Yield | Only yields closed results, not idle/active |
Reduces memory footprint and consumer processing |
| Timestamp Skipping | Jumps to closeTimestamp after signal closes |
Avoids iterating through thousands of timestamps unnecessarily |
| No Signal State Storage | Signal state cleared after close in backtest mode | No memory accumulation across signal lifecycle |
The run() method is declared as an async generator function using async * syntax. This enables the function to yield results as they're produced rather than accumulating them in memory.
// From BacktestLogicPrivateService at line 48
public async *run(symbol: string) {
// ... execution logic
yield backtestResult; // Stream result to consumer
}
Consumer code can iterate with for await...of and break early:
for await (const result of backtestLogic.run("BTCUSDT")) {
console.log(result.pnl.pnlPercentage);
if (result.pnl.pnlPercentage < -10) break; // Stop on 10% loss
}
The following diagram traces a complete execution from the Public API through all service layers to the business logic and back.
End-to-End Execution Trace
The backtest execution includes several error handling mechanisms and edge case considerations.
Error Handling Scenarios
| Scenario | Detection | Handling |
|---|---|---|
| No Candles Available | candles.length === 0 after fetch |
Generator returns early at line 82 |
| Invalid Signal | Validation in ClientStrategy.tick() |
Throws error with detailed validation message |
| Insufficient Candles for VWAP | candles.length < 5 in backtest |
Warning logged, uses available candles |
| Timeframe Empty | timeframes.length === 0 |
Loop never executes, generator completes |
While the execution flow itself doesn't directly interact with reporting services, the yielded IStrategyTickResultClosed results are consumed by BacktestMarkdownService to accumulate statistics and generate performance reports.
The reporting integration happens at the consumer level, where the Public API's Backtest.run() or Backtest.background() methods pass results to the markdown service for accumulation.
For details on report generation, see Markdown Report Generation.
The backtest execution flow orchestrates historical simulation through a multi-layer architecture:
BacktestLogicPublicService wraps execution with context propagationBacktestLogicPrivateService manages the iteration loop and coordinates servicesThe async generator pattern enables memory-efficient streaming, early termination, and processing of arbitrarily large historical datasets. The fast-forward simulation via backtest() method accelerates execution by avoiding tick-by-tick iteration for opened signals.
Key Characteristics: