This document explains scheduled signals in backtest-kit: signals that wait for a specific entry price (priceOpen) to be reached before activating. Scheduled signals implement limit order behavior, allowing strategies to enter positions at predetermined prices rather than immediately at market price.
For general signal lifecycle information, see Signal Lifecycle Overview. For signal persistence mechanics, see Signal Persistence. For PNL calculation after closure, see PnL Calculation.
Signals in backtest-kit can be created in two modes based on the presence of priceOpen in the signal DTO returned by getSignal():
| Aspect | Immediate Signal | Scheduled Signal |
|---|---|---|
| priceOpen specified? | No (undefined) | Yes (explicit price) |
| Entry timing | Immediate at current VWAP | Delayed until price reaches priceOpen |
| Risk check timing | At signal creation | At activation (when price reached) |
| Initial state | opened |
scheduled |
| scheduledAt | Current timestamp | Current timestamp |
| pendingAt | Current timestamp | Activation timestamp (updated later) |
| Timeout logic | Uses minuteEstimatedTime from pendingAt |
Uses CC_SCHEDULE_AWAIT_MINUTES from scheduledAt, then minuteEstimatedTime from pendingAt after activation |
A scheduled signal is created when getSignal() returns a signal DTO with priceOpen explicitly specified:
// In your strategy's getSignal function
return {
position: "long",
priceOpen: 41000, // Explicit entry price = scheduled signal
priceTakeProfit: 42000,
priceStopLoss: 40000,
minuteEstimatedTime: 60,
note: "Wait for price to drop to 41000"
};
The framework augments this DTO into an IScheduledSignalRow with metadata:
const scheduledSignalRow: IScheduledSignalRow = {
id: randomString(), // Auto-generated UUID
priceOpen: signal.priceOpen, // From user DTO
// ... other fields from DTO
symbol: context.symbol,
exchangeName: context.exchangeName,
strategyName: context.strategyName,
scheduledAt: currentTime, // Timestamp when scheduled
pendingAt: currentTime, // Initially equals scheduledAt, updated on activation
_isScheduled: true, // Internal marker
};
The activation logic differs based on position type to implement proper limit order semantics:
LONG Position (buy lower):
priceOpencurrentPrice <= priceOpencurrentPrice <= priceStopLoss (before activation)SHORT Position (sell higher):
priceOpencurrentPrice >= priceOpencurrentPrice >= priceStopLoss (before activation)Scheduled signals automatically cancel if they do not activate within CC_SCHEDULE_AWAIT_MINUTES (default: 120 minutes):
const maxTimeToWait = GLOBAL_CONFIG.CC_SCHEDULE_AWAIT_MINUTES * 60 * 1000;
const elapsedTime = currentTime - scheduled.scheduledAt; // From scheduledAt, not pendingAt!
if (elapsedTime >= maxTimeToWait) {
// Cancel signal - timeout
return {
action: "cancelled",
signal: scheduled,
closeTimestamp: currentTime
};
}
Key timing rule: Timeout is calculated from scheduledAt (when signal was created), NOT from pendingAt.
CRITICAL: StopLoss cancellation is checked before activation to prevent physically impossible limit orders:
// LONG position: Cancel if price drops too far (below SL)
if (scheduled.position === "long") {
if (currentPrice <= scheduled.priceStopLoss) {
shouldCancel = true; // Cancel - price went too low
} else if (currentPrice <= scheduled.priceOpen) {
shouldActivate = true; // Activate - price reached entry
}
}
// SHORT position: Cancel if price rises too far (above SL)
if (scheduled.position === "short") {
if (currentPrice >= scheduled.priceStopLoss) {
shouldCancel = true; // Cancel - price went too high
} else if (currentPrice >= scheduled.priceOpen) {
shouldActivate = true; // Activate - price reached entry
}
}
Why this order matters: For a LONG limit order at priceOpen=41000 with SL=40000:
Scheduled signals have two critical timestamps that determine their lifecycle:
| Timestamp | Meaning | Set When | Used For |
|---|---|---|---|
scheduledAt |
When signal was created | Signal creation time | Timeout calculation (CC_SCHEDULE_AWAIT_MINUTES) |
pendingAt |
When signal activated (position opened) | Activation time (updated from scheduledAt) |
Duration calculation (minuteEstimatedTime) |
The critical rule: minuteEstimatedTime counts from pendingAt, NOT from scheduledAt.
When scheduled signal activates, pendingAt is updated from the original scheduledAt:
// CRITICAL: Update pendingAt on activation
const activatedSignal: ISignalRow = {
...scheduled,
pendingAt: activationTime, // Updated from scheduledAt!
_isScheduled: false,
};
When checking if position should close by timeout, the calculation uses pendingAt:
const signalTime = signal.pendingAt; // CRITICAL: use pendingAt, not scheduledAt!
const maxTimeToWait = signal.minuteEstimatedTime * 60 * 1000;
const elapsedTime = currentTime - signalTime;
if (elapsedTime >= maxTimeToWait) {
// Close by time_expired
}
What happens if you use scheduledAt instead? Signal closes prematurely because it includes the waiting time before activation. This was a critical bug fixed in the codebase.
In live mode, scheduled signals are monitored at each tick (every ~1 minute):
// Live mode: Check scheduled signal every tick
const result = await strategy.tick();
if (result.action === "scheduled") {
// Check timeout (from scheduledAt)
const timeoutResult = await CHECK_SCHEDULED_SIGNAL_TIMEOUT_FN(...);
if (timeoutResult) return timeoutResult;
// Check price activation and StopLoss
const currentPrice = await exchange.getAveragePrice(symbol);
const { shouldActivate, shouldCancel } = CHECK_SCHEDULED_SIGNAL_PRICE_ACTIVATION_FN(
scheduled,
currentPrice
);
if (shouldCancel) {
return CANCEL_SCHEDULED_SIGNAL_BY_STOPLOSS_FN(...);
}
if (shouldActivate) {
return ACTIVATE_SCHEDULED_SIGNAL_FN(...);
}
// Still waiting - return active
return { action: "active", signal: scheduled };
}
In backtest mode, scheduled signals are processed through historical candles for fast-forward simulation:
// Backtest mode: Process candles array
if (result.action === "scheduled") {
// Request candles for monitoring period + signal lifetime
const candlesNeeded = CC_SCHEDULE_AWAIT_MINUTES + signal.minuteEstimatedTime + 1;
const candles = await exchange.getNextCandles(symbol, "1m", candlesNeeded, when, true);
// Process each candle until activation/cancellation
const { activated, cancelled, activationIndex, result } =
await PROCESS_SCHEDULED_SIGNAL_CANDLES_FN(scheduled, candles);
if (cancelled) {
yield result; // Cancelled by timeout or StopLoss
}
if (activated) {
// Continue with remaining candles for TP/SL monitoring
const remainingCandles = candles.slice(activationIndex);
const closedResult = await PROCESS_PENDING_SIGNAL_CANDLES_FN(signal, remainingCandles);
yield closedResult;
}
}
Key difference: Backtest uses candle.high and candle.low for precise TP/SL detection within a candle, while live uses VWAP calculated from recent candles.
For a scheduled signal that successfully activates:
action: "scheduled"action: "active" while waiting)action: "opened"action: "active" while monitoring TP/SL)action: "closed"For a scheduled signal that times out:
action: "scheduled"action: "active" while waiting)action: "cancelled"CC_SCHEDULE_AWAIT_MINUTES: 120 // Default: 2 hours
Maximum time (in minutes) to wait for scheduled signal activation. Calculated from scheduledAt timestamp.
Use case: Prevents "eternal" scheduled signals that never activate, freeing up risk limits.
Boundary behavior: Signal cancels when elapsedTime >= CC_SCHEDULE_AWAIT_MINUTES (inclusive).
| Function | Purpose | Mode | File Location |
|---|---|---|---|
GET_SIGNAL_FN |
Creates IScheduledSignalRow when priceOpen specified |
Both | ClientStrategy.ts:187-283 |
CHECK_SCHEDULED_SIGNAL_TIMEOUT_FN |
Checks if scheduled signal timed out | Live | ClientStrategy.ts:332-386 |
CHECK_SCHEDULED_SIGNAL_PRICE_ACTIVATION_FN |
Determines if price conditions met for activation | Live | ClientStrategy.ts:388-422 |
CANCEL_SCHEDULED_SIGNAL_BY_STOPLOSS_FN |
Cancels scheduled signal when SL hit pre-activation | Live | ClientStrategy.ts:424-457 |
ACTIVATE_SCHEDULED_SIGNAL_FN |
Activates scheduled signal, updates pendingAt |
Live | ClientStrategy.ts:459-551 |
RETURN_SCHEDULED_SIGNAL_ACTIVE_FN |
Returns active state for scheduled signal still waiting | Live | ClientStrategy.ts:553-576 |
OPEN_NEW_SCHEDULED_SIGNAL_FN |
Returns scheduled result when signal first created | Live | ClientStrategy.ts:578-621 |
ACTIVATE_SCHEDULED_SIGNAL_IN_BACKTEST_FN |
Activates scheduled signal in backtest mode | Backtest | ClientStrategy.ts:897-973 |
CANCEL_SCHEDULED_SIGNAL_IN_BACKTEST_FN |
Cancels scheduled signal in backtest mode | Backtest | ClientStrategy.ts:848-895 |
PROCESS_SCHEDULED_SIGNAL_CANDLES_FN |
Processes candle array for scheduled signal | Backtest | ClientStrategy.ts:1048-1134 |
Wrong:
const elapsedTime = currentTime - signal.scheduledAt; // Includes wait time!
if (elapsedTime >= signal.minuteEstimatedTime * 60 * 1000) {
closeSignal(); // Closes prematurely!
}
Correct:
const elapsedTime = currentTime - signal.pendingAt; // Only active time
if (elapsedTime >= signal.minuteEstimatedTime * 60 * 1000) {
closeSignal(); // Closes at correct time
}
Wrong:
// Activation checked first - can activate when SL already hit
if (currentPrice <= priceOpen) activate();
if (currentPrice <= priceStopLoss) cancel(); // Too late!
Correct:
// StopLoss checked first - prevents impossible activation
if (currentPrice <= priceStopLoss) {
cancel(); // Priority: cancel before activating
} else if (currentPrice <= priceOpen) {
activate();
}
Wrong:
const activatedSignal = {
...scheduled,
_isScheduled: false,
// pendingAt still equals scheduledAt - BUG!
};
Correct:
const activatedSignal = {
...scheduled,
pendingAt: activationTime, // Must update!
_isScheduled: false,
};