This page describes the signal state machine and lifecycle management in the backtest-kit framework. A signal represents a single trading position from creation through closure, transitioning through well-defined states. This document covers signal generation, validation, state transitions, persistence, and closure conditions.
For information about how signals are executed in different modes (backtest vs live), see Execution Modes. For details on component registration and schema definitions, see Component Registration. For deep implementation details of the ClientStrategy class that manages signals, see ClientStrategy.
The signal lifecycle is modeled as a finite state machine with six distinct states. Signals transition between states based on market conditions, time constraints, and risk parameters.
| State | Discriminator | Signal Type | Description |
|---|---|---|---|
| idle | action: "idle" |
null |
No active signal exists. Strategy can generate new signal on next getSignal() call. |
| scheduled | action: "scheduled" |
IScheduledSignalRow |
Signal created with delayed entry. Waiting for market price to reach priceOpen. |
| opened | action: "opened" |
ISignalRow |
Signal just created (immediate) or activated (from scheduled). Position tracking begins. |
| active | action: "active" |
ISignalRow |
Signal being monitored for TP/SL/time expiration. Continues until close condition met. |
| closed | action: "closed" |
ISignalRow |
Final state with PnL calculation. Includes closeReason and closeTimestamp. |
| cancelled | action: "cancelled" |
IScheduledSignalRow |
Scheduled signal cancelled before activation. Timeout or StopLoss hit. |
The framework uses a discriminated union pattern for type-safe signal handling. Three core interfaces represent signals at different lifecycle stages.
Each state transition yields a specific tick result type, enabling type-safe handling with TypeScript discriminators:
| Result Type | Discriminator | Contains | Use Case |
|---|---|---|---|
IStrategyTickResultIdle |
action: "idle" |
signal: null |
No signal exists, strategy idle |
IStrategyTickResultScheduled |
action: "scheduled" |
signal: IScheduledSignalRow |
Scheduled signal created |
IStrategyTickResultOpened |
action: "opened" |
signal: ISignalRow |
Signal opened/activated |
IStrategyTickResultActive |
action: "active" |
signal: ISignalRow |
Signal monitoring continues |
IStrategyTickResultClosed |
action: "closed" |
signal: ISignalRow, pnl, closeReason |
Signal closed with result |
IStrategyTickResultCancelled |
action: "cancelled" |
signal: IScheduledSignalRow |
Scheduled signal cancelled |
Signal generation occurs via the user-defined getSignal() function, followed by framework-level validation and augmentation.
The VALIDATE_SIGNAL_FN enforces financial safety constraints to prevent invalid signals:
| Validation Category | Rule | Configuration |
|---|---|---|
| Price Finiteness | All prices must be finite numbers (no NaN/Infinity) | Hard-coded |
| Price Positivity | All prices must be > 0 | Hard-coded |
| Long Position Logic | priceTakeProfit > priceOpen > priceStopLoss |
Hard-coded |
| Short Position Logic | priceStopLoss > priceOpen > priceTakeProfit |
Hard-coded |
| Minimum TP Distance | TP must cover fees + minimum profit | CC_MIN_TAKEPROFIT_DISTANCE_PERCENT (default: 0.3%) |
| Maximum SL Distance | SL cannot exceed maximum loss threshold | CC_MAX_STOPLOSS_DISTANCE_PERCENT (default: 20%) |
| Time Constraints | minuteEstimatedTime > 0 |
Hard-coded |
| Maximum Lifetime | Signal cannot block risk limits indefinitely | CC_MAX_SIGNAL_LIFETIME_MINUTES (default: 1440 min = 1 day) |
User-provided ISignalDto is augmented with framework metadata:
// User provides (from getSignal):
{
position: "long",
priceTakeProfit: 45000,
priceStopLoss: 43000,
minuteEstimatedTime: 60
}
// Framework augments to ISignalRow:
{
id: "uuid-v4-generated", // Auto-generated
priceOpen: 44000, // currentPrice or user-specified
exchangeName: "binance", // From method context
strategyName: "momentum-strategy", // From method context
scheduledAt: 1640000000000, // Current timestamp
pendingAt: 1640000000000, // Same as scheduledAt initially
symbol: "BTCUSDT", // From execution context
_isScheduled: false, // true if priceOpen specified
// ... original fields
}
Occurs when getSignal() returns a signal with priceOpen specified. Signal waits for market price to reach entry point.
Key Implementation: Scheduled signals do NOT perform risk check at creation time. Risk validation occurs during activation when position opens.
Occurs when getSignal() returns a signal without priceOpen. Position opens immediately at current VWAP.
Occurs when market price reaches priceOpen for a scheduled signal. Triggers risk check at activation time. The pendingAt timestamp is updated during activation. Time-based expiration calculates from pendingAt, not scheduledAt.
Occurs when scheduled signal times out or StopLoss is hit before activation.
Timeout Condition:
const elapsedTime = currentTime - scheduled.scheduledAt;
const maxTimeToWait = CC_SCHEDULE_AWAIT_MINUTES * 60 * 1000;
if (elapsedTime >= maxTimeToWait) {
// Cancel signal
}
StopLoss Condition (Priority over activation):
currentPrice <= priceStopLoss → cancelcurrentPrice >= priceStopLoss → cancelOccurs when signal meets closure condition: TakeProfit hit, StopLoss hit, or time expired.
Critical: When TakeProfit or StopLoss triggers, the framework uses the exact TP/SL price for PnL calculation, not the current VWAP. This ensures deterministic results.
Signals track two distinct timestamps for lifecycle management:
| Timestamp | Set During | Purpose | Usage |
|---|---|---|---|
scheduledAt |
Signal creation | Records when signal was first generated | Timeout calculation for scheduled signals |
pendingAt |
Position activation | Records when position opened at priceOpen |
Time expiration calculation for active signals |
Immediate Signals (no priceOpen):
scheduledAt: currentTime, // Set at creation
pendingAt: currentTime // Same as scheduledAt
Scheduled Signals (with priceOpen):
// At creation:
scheduledAt: currentTime, // Set at creation
pendingAt: currentTime // Temporarily equals scheduledAt
// At activation:
scheduledAt: unchanged, // Original creation time
pendingAt: activationTime // Updated to activation timestamp
Timeout Calculation:
currentTime - scheduledAt >= CC_SCHEDULE_AWAIT_MINUTES * 60 * 1000currentTime - pendingAt >= minuteEstimatedTime * 60 * 1000Strategies can register callbacks to observe signal state transitions. All callbacks are optional.
For each state transition, callbacks execute in this order:
onOpen, onClose)onTick callback with tick resultExample for signal opening:
// 1. State-specific callback
if (callbacks.onOpen) {
callbacks.onOpen(symbol, signal, currentPrice, backtest);
}
// 2. onTick callback
if (callbacks.onTick) {
const result: IStrategyTickResultOpened = { action: "opened", ... };
callbacks.onTick(symbol, result, backtest);
}
The ClientStrategy class maintains internal state for signal tracking:
| Variable | Type | Purpose |
|---|---|---|
_pendingSignal |
ISignalRow | null |
Currently active signal being monitored |
_scheduledSignal |
IScheduledSignalRow | null |
Scheduled signal awaiting activation |
_lastSignalTimestamp |
number | null |
Timestamp of last getSignal() call (for throttling) |
_isStopped |
boolean |
Flag to prevent new signal generation |
Only one signal can exist per symbol at a time. The framework enforces mutual exclusion:
_pendingSignal and _scheduledSignal are never both non-nullIn live mode, signals persist to disk atomically for crash recovery:
// Persist after every state change
await PersistSignalAdapter.writeSignalData(
strategyName,
symbol,
pendingSignal
);
// Restore on initialization
const restored = await PersistSignalAdapter.readSignalData(
strategyName,
symbol
);
File Location: signal-{strategyName}-{symbol}.json
Atomic Write Pattern: Write to temp file, then rename for crash-safe persistence.
The signal lifecycle follows a deterministic state machine with six states: idle, scheduled, opened, active, closed, and cancelled. Signals are generated via user-defined getSignal() functions, validated by framework rules, and monitored through VWAP-based price checks. State transitions trigger lifecycle callbacks for observability. Timestamps (scheduledAt vs pendingAt) enable precise timeout and expiration calculations. In live mode, signals persist atomically to disk for crash recovery.
For implementation details of signal processing within specific execution modes, see Backtest Execution Flow and Live Execution Flow.