Fast-forward simulation is the core optimization that enables efficient backtesting. Instead of calling ClientStrategy.tick() at every timeframe timestamp, the framework calls ClientStrategy.backtest() once per signal with a pre-fetched array of future candles. This method processes the entire signal lifecycle in a single pass through the candle array.
This page documents:
backtest() method implementation in ClientStrategyPROCESS_PENDING_SIGNAL_CANDLES_FN and PROCESS_SCHEDULED_SIGNAL_CANDLES_FNExchangeGlobalService.getNextCandles()BacktestLogicPrivateServiceGET_AVG_PRICE_FNKey Benefits:
candle.high/candle.low for exact TP/SL detectionAsyncGenerator, no intermediate state accumulationThe framework supports two execution modes for strategy evaluation:
| Mode | Method | Use Case | Time Progression | Result Type |
|---|---|---|---|---|
| Tick | strategy.tick() |
Live trading | Real-time (Date.now()) |
IStrategyTickResult (idle/opened/active/closed) |
| Backtest | strategy.backtest(candles) |
Historical simulation | Fast-forward via candle array | IStrategyBacktestResult (closed/cancelled) |
Backtest Execution Flow
The ClientStrategy.backtest() method processes a signal's complete lifecycle using a pre-fetched candle array. It iterates through candles checking for activation (scheduled signals) and closure conditions (TP/SL/time expiration).
Method Signature:
// From IStrategy interface
backtest: (
symbol: string,
strategyName: StrategyName,
candles: ICandleData[]
) => Promise<IStrategyBacktestResult>
Return Types:
IStrategyTickResultClosed: Signal closed via take_profit, stop_loss, or time_expiredIStrategyTickResultCancelled: Scheduled signal cancelled before activationSignal Lifecycle Processing Flow
Fast-forward simulation achieves accuracy by checking Take Profit and Stop Loss conditions against candle high/low prices rather than just close prices. This captures intra-candle price movements.
Detection Logic for Long Positions:
// PROCESS_PENDING_SIGNAL_CANDLES_FN logic
if (signal.position === "long") {
if (currentCandle.high >= signal.priceTakeProfit) {
closeReason = "take_profit";
// Use exact TP price, not candle high
} else if (currentCandle.low <= signal.priceStopLoss) {
closeReason = "stop_loss";
// Use exact SL price, not candle low
}
}
Detection Logic for Short Positions:
if (signal.position === "short") {
if (currentCandle.low <= signal.priceTakeProfit) {
closeReason = "take_profit";
} else if (currentCandle.high >= signal.priceStopLoss) {
closeReason = "stop_loss";
}
}
Price Resolution Diagram:
Key Implementation Details:
Exact Price Usage: When TP/SL is hit, the result uses the exact priceTakeProfit or priceStopLoss value, not the candle's high/low. This ensures consistent PNL calculations.
Priority Order: Time expiration is checked first, then TP/SL. If minuteEstimatedTime expires, the signal closes at current VWAP price regardless of TP/SL proximity.
VWAP Calculation: Average price is calculated using volume-weighted average of recent candles (controlled by CC_AVG_PRICE_CANDLES_COUNT).
The framework calculates Volume-Weighted Average Price (VWAP) for each candle to determine current market conditions. This provides more accurate pricing than simple close price.
VWAP Formula:
const GET_AVG_PRICE_FN = (candles: ICandleData[]): number => {
const sumPriceVolume = candles.reduce((acc, c) => {
const typicalPrice = (c.high + c.low + c.close) / 3;
return acc + typicalPrice * c.volume;
}, 0);
const totalVolume = candles.reduce((acc, c) => acc + c.volume, 0);
return totalVolume === 0
? candles.reduce((acc, c) => acc + c.close, 0) / candles.length
: sumPriceVolume / totalVolume;
};
VWAP Calculation Process
Window Size and Usage:
The VWAP window size is configured via GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT (default: 5).
// From PROCESS_PENDING_SIGNAL_CANDLES_FN
const candlesCount = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
for (let i = candlesCount - 1; i < candles.length; i++) {
// Sliding window: always use last N candles
const recentCandles = candles.slice(i - (candlesCount - 1), i + 1);
const averagePrice = GET_AVG_PRICE_FN(recentCandles);
// Check TP/SL against averagePrice
}
Scheduled signals require a two-phase fast-forward simulation: first monitoring for price activation (or cancellation), then monitoring TP/SL if activated.
Two-Phase Process Diagram:
Phase 1: Activation Monitoring (PROCESS_SCHEDULED_SIGNAL_CANDLES_FN)
This function iterates through candles looking for:
candle.timestamp - scheduled.scheduledAt >= CC_SCHEDULE_AWAIT_MINUTESpriceOpenPriority Logic:
// Timeout checked FIRST
const elapsedTime = candle.timestamp - scheduled.scheduledAt;
if (elapsedTime >= maxTimeToWait) {
return { cancelled: true, result: CancelledResult };
}
// Then check SL (cancel prioritized over activation)
if (scheduled.position === "long") {
if (candle.low <= scheduled.priceStopLoss) {
shouldCancel = true;
} else if (candle.low <= scheduled.priceOpen) {
shouldActivate = true;
}
}
Phase 2: TP/SL Monitoring
If the scheduled signal activates, the function:
pendingAt to activation timestampFuture Candle Fetching Strategy:
BacktestLogicPrivateService calculates candle count based on signal type:
// For opened signals (immediate entry)
const candles = await exchangeGlobalService.getNextCandles(
symbol,
"1m",
tickResult.signal.minuteEstimatedTime,
when,
true
);
// For scheduled signals (delayed entry)
const candles = await exchangeGlobalService.getNextCandles(
symbol,
"1m",
GLOBAL_CONFIG.CC_SCHEDULE_AWAIT_MINUTES + tickResult.signal.minuteEstimatedTime + 1,
when,
true
);
Candle Count Rationale:
| Signal Type | Candles Needed | Reason |
|---|---|---|
| Opened | minuteEstimatedTime |
Signal already active, only need TP/SL monitoring duration |
| Scheduled | CC_SCHEDULE_AWAIT_MINUTES + minuteEstimatedTime + 1 |
Need activation window + TP/SL duration + 1 for inclusive range |
For immediate signals (no priceOpen specified) or after scheduled signal activation, the framework processes the pending signal by monitoring TP/SL conditions.
Processing Flow (PROCESS_PENDING_SIGNAL_CANDLES_FN):
Critical Timing Detail:
The minuteEstimatedTime countdown starts from signal.pendingAt, not signal.scheduledAt. This distinction is critical for scheduled signals where activation occurs after creation:
// Time expiration check uses pendingAt
const signalTime = signal.pendingAt; // NOT scheduledAt!
const maxTimeToWait = signal.minuteEstimatedTime * 60 * 1000;
const elapsedTime = currentCandleTimestamp - signalTime;
if (elapsedTime >= maxTimeToWait) {
shouldClose = true;
closeReason = "time_expired";
}
Why This Matters:
For a scheduled signal:
scheduledAt: Timestamp when signal was createdpendingAt: Timestamp when price reached priceOpen and position activatedIf minuteEstimatedTime counted from scheduledAt, the signal would close prematurely, incurring trading fees without adequate time to reach TP.
The fast-forward mechanism integrates tightly with BacktestLogicPrivateService, which orchestrates the backtest loop.
Execution Context and Skip-Ahead Flow
Skip-Ahead Optimization Logic:
After backtest() completes, BacktestLogicPrivateService advances the loop counter to skip all intermediate timeframes:
// From BacktestLogicPrivateService.run()
const backtestResult = await strategyGlobalService.backtest(
symbol,
strategyName,
frameName,
candles,
when,
true
);
// Skip ahead to closeTimestamp
while (
i < timeframes.length &&
timeframes[i].getTime() < backtestResult.closeTimestamp
) {
i++;
}
yield backtestResult;
This prevents thousands of redundant tick() calls during the signal's active period.
Fast-forward simulation provides significant performance advantages over tick-by-tick iteration:
Performance Comparison:
| Aspect | Tick-by-Tick | Fast-Forward | Improvement |
|---|---|---|---|
| Function Calls | O(timeframes) = 1440 calls/day | O(signals) ≈ 10-100 calls/day | 10-100x reduction |
| Candle Fetches | None (uses frame timestamps) | 1 per signal | Batch fetch efficiency |
| State Management | Persist every tick (live) | No persistence (backtest) | No I/O overhead |
| Memory Usage | 1 timestamp at a time | N candles (typically 30-1440) | Minimal impact |
Timing Metrics:
The framework emits performance events via performanceEmitter to track execution times:
// Tracked metric types
"backtest_total" // Total backtest duration
"backtest_timeframe" // Single timeframe processing
"backtest_signal" // Single signal backtest() call
"live_tick" // Single tick() call in live mode
Example Measurements:
For a 30-day backtest with 15-minute signals:
Fast-forward simulation produces deterministic results because:
Frame.getTimeframe(), which returns a static arrayClientStrategy instanceReproducibility Guarantee:
Running the same backtest with identical parameters produces identical results:
// Same inputs
const config = {
strategyName: "my-strategy",
exchangeName: "my-exchange",
frameName: "2024-backtest",
};
// Run 1
const results1 = await Backtest.run("BTCUSDT", config);
// Run 2
const results2 = await Backtest.run("BTCUSDT", config);
// results1 === results2 (deep equality)
// - Same signals generated
// - Same TP/SL/time_expired outcomes
// - Same PNL percentages
// - Same closeTimestamps
This determinism is critical for:
Primary Classes and Functions:
| Entity | Location | Role |
|---|---|---|
ClientStrategy.backtest() |
src/client/ClientStrategy.ts:1188-1318 | Main fast-forward entry point |
PROCESS_SCHEDULED_SIGNAL_CANDLES_FN |
src/client/ClientStrategy.ts:1048-1134 | Phase 1: Activation monitoring |
PROCESS_PENDING_SIGNAL_CANDLES_FN |
src/client/ClientStrategy.ts:1136-1186 | Phase 2: TP/SL monitoring |
GET_AVG_PRICE_FN |
src/client/ClientStrategy.ts:285-296 | VWAP calculation |
BacktestLogicPrivateService.run() |
src/lib/services/logic/private/BacktestLogicPrivateService.ts:59-300 | Orchestration loop |
StrategyConnectionService.backtest() |
src/lib/services/connection/StrategyConnectionService.ts:132-150 | DI routing layer |
Configuration Parameters:
| Parameter | Default | Purpose |
|---|---|---|
CC_AVG_PRICE_CANDLES_COUNT |
3 | VWAP window size |
CC_SCHEDULE_AWAIT_MINUTES |
120 | Scheduled signal timeout |
CC_MAX_SIGNAL_LIFETIME_MINUTES |
10080 | Maximum signal duration (7 days) |