This document provides comprehensive documentation of the six discrete states that a trading signal can occupy during its lifecycle in backtest-kit. Each state is represented by a distinct TypeScript interface as part of a discriminated union (IStrategyTickResult), enabling type-safe state handling throughout the system.
For information about the complete signal lifecycle flow and state transitions, see Signal Lifecycle Overview. For details on signal generation and validation, see Signal Generation and Validation. For information on how signals are persisted across crashes in live mode, see Signal Persistence.
The signal state machine consists of six mutually exclusive states, represented as a discriminated union with the action field as the discriminator:
All signal states are unified under the IStrategyTickResult discriminated union type:
type IStrategyTickResult =
| IStrategyTickResultIdle
| IStrategyTickResultScheduled
| IStrategyTickResultOpened
| IStrategyTickResultActive
| IStrategyTickResultClosed
| IStrategyTickResultCancelled;
The action field serves as the discriminator, enabling TypeScript to narrow types in conditional branches:
// Type guard example
if (result.action === "closed") {
// TypeScript knows result is IStrategyTickResultClosed
console.log(result.closeReason); // ✓ Valid
console.log(result.pnl.pnlPercentage); // ✓ Valid
}
Interface: IStrategyTickResultIdle
Purpose: Represents the absence of an active trading position. The strategy is monitoring the market but has no open signal.
interface IStrategyTickResultIdle {
action: "idle";
signal: null;
strategyName: StrategyName;
exchangeName: ExchangeName;
symbol: string;
currentPrice: number;
}
| Property | Type | Description |
|---|---|---|
action |
"idle" |
Discriminator value for type narrowing |
signal |
null |
Always null in idle state (no active signal) |
strategyName |
StrategyName |
Strategy identifier for tracking |
exchangeName |
ExchangeName |
Exchange identifier for tracking |
symbol |
string |
Trading pair symbol (e.g., "BTCUSDT") |
currentPrice |
number |
Current VWAP price at idle state |
The idle state can transition to:
getSignal() returns a signal with priceOpen specifiedgetSignal() returns a signal without priceOpen (immediate execution)getSignal() returns nullThe idle state is returned by ClientStrategy.tick() when:
this._pendingSignal === null)this._scheduledSignal === null)getSignal() returns null or validation failsInterface: IStrategyTickResultScheduled
Purpose: Represents a limit order waiting for price to reach priceOpen before activation. This state models real-world limit orders where the trader specifies an entry price.
interface IStrategyTickResultScheduled {
action: "scheduled";
signal: IScheduledSignalRow;
strategyName: StrategyName;
exchangeName: ExchangeName;
symbol: string;
currentPrice: number;
}
| Property | Type | Description |
|---|---|---|
action |
"scheduled" |
Discriminator value for type narrowing |
signal |
IScheduledSignalRow |
Scheduled signal with priceOpen specified |
strategyName |
StrategyName |
Strategy identifier |
exchangeName |
ExchangeName |
Exchange identifier |
symbol |
string |
Trading pair symbol |
currentPrice |
number |
Current VWAP price when scheduled signal was created |
The IScheduledSignalRow extends ISignalRow and always has:
priceOpen: Defined (the activation price)_isScheduled: truescheduledAt: Timestamp when signal was createdpendingAt: Initially equals scheduledAt, updated to actual pending time upon activationThe scheduled state can transition to:
currentPrice reaches priceOpen (activation condition met)StopLoss is hit before priceOpen is reachedCC_SCHEDULE_AWAIT_MINUTES, default 120 minutes)For LONG positions:
currentPrice <= priceOpen (price dropped to or below entry point)currentPrice <= priceStopLoss (before activation)For SHORT positions:
currentPrice >= priceOpen (price rose to or above entry point)currentPrice >= priceStopLoss (before activation)Scheduled signal handling occurs in:
Interface: IStrategyTickResultOpened
Purpose: Represents the first tick immediately after a position is opened. This is a transient state that signals the creation of a new active position.
interface IStrategyTickResultOpened {
action: "opened";
signal: ISignalRow;
strategyName: StrategyName;
exchangeName: ExchangeName;
symbol: string;
currentPrice: number;
}
| Property | Type | Description |
|---|---|---|
action |
"opened" |
Discriminator value for type narrowing |
signal |
ISignalRow |
Newly created and validated signal with generated ID |
strategyName |
StrategyName |
Strategy identifier |
exchangeName |
ExchangeName |
Exchange identifier |
symbol |
string |
Trading pair symbol |
currentPrice |
number |
Current VWAP price at signal open |
The signal contains two critical timestamps:
scheduledAt: When the signal was first created (for both immediate and scheduled signals)pendingAt: When the position became active
pendingAt === scheduledAtpendingAt is updated upon activationThe opened state immediately transitions to:
The opened state is ephemeral and exists only for one tick to trigger onOpen callbacks.
When entering the opened state:
IStrategyCallbacks.onOpen is invoked src/client/ClientStrategy.ts:747-754Risk.addSignal() is called to register the position src/client/ClientStrategy.ts:742-745The opened state is emitted by:
Interface: IStrategyTickResultActive
Purpose: Represents an open position being continuously monitored for take profit, stop loss, or time expiration conditions.
interface IStrategyTickResultActive {
action: "active";
signal: ISignalRow;
currentPrice: number;
strategyName: StrategyName;
exchangeName: ExchangeName;
symbol: string;
percentTp: number;
percentSl: number;
}
| Property | Type | Description |
|---|---|---|
action |
"active" |
Discriminator value for type narrowing |
signal |
ISignalRow |
Currently monitored signal |
currentPrice |
number |
Current VWAP price for monitoring |
strategyName |
StrategyName |
Strategy identifier |
exchangeName |
ExchangeName |
Exchange identifier |
symbol |
string |
Trading pair symbol |
percentTp |
number |
Percentage progress towards take profit (0-100%, 0 if moving towards SL) |
percentSl |
number |
Percentage progress towards stop loss (0-100%, 0 if moving towards TP) |
The percentTp and percentSl fields indicate how close the current price is to hitting the respective target:
// For LONG position
const distanceToTp = priceTakeProfit - priceOpen;
const currentProgress = currentPrice - priceOpen;
percentTp = (currentProgress / distanceToTp) * 100;
// For SHORT position
const distanceToTp = priceOpen - priceTakeProfit;
const currentProgress = priceOpen - currentPrice;
percentTp = (currentProgress / distanceToTp) * 100;
Only one of percentTp or percentSl is non-zero at any given time, depending on which direction the price is moving.
During the active state, ClientPartial monitors for milestone levels:
revenuePercent > 0)revenuePercent < 0)Events are emitted only once per level via Set-based deduplication src/client/ClientPartial.ts:1-300.
The active state can transition to:
Active state monitoring occurs in:
Interface: IStrategyTickResultClosed
Purpose: Represents the final state of a completed signal with calculated profit/loss. This is a terminal state that marks the end of a signal's lifecycle.
interface IStrategyTickResultClosed {
action: "closed";
signal: ISignalRow;
currentPrice: number;
closeReason: StrategyCloseReason;
closeTimestamp: number;
pnl: IStrategyPnL;
strategyName: StrategyName;
exchangeName: ExchangeName;
symbol: string;
}
| Property | Type | Description |
|---|---|---|
action |
"closed" |
Discriminator value for type narrowing |
signal |
ISignalRow |
Completed signal with original parameters |
currentPrice |
number |
Final VWAP price at close |
closeReason |
StrategyCloseReason |
Why signal closed: "take_profit", "stop_loss", or "time_expired" |
closeTimestamp |
number |
Unix timestamp in milliseconds when signal closed |
pnl |
IStrategyPnL |
Profit/loss calculation with fees and slippage |
strategyName |
StrategyName |
Strategy identifier |
exchangeName |
ExchangeName |
Exchange identifier |
symbol |
string |
Trading pair symbol |
type StrategyCloseReason = "time_expired" | "take_profit" | "stop_loss";
| Reason | Description |
|---|---|
"take_profit" |
Price reached priceTakeProfit target |
"stop_loss" |
Price reached priceStopLoss limit |
"time_expired" |
minuteEstimatedTime elapsed without hitting TP/SL |
The IStrategyPnL structure includes:
interface IStrategyPnL {
pnlPercentage: number; // e.g., 1.5 for +1.5%, -2.3 for -2.3%
priceOpen: number; // Entry price adjusted with slippage and fees
priceClose: number; // Exit price adjusted with slippage and fees
}
Fee and Slippage Application:
CC_PERCENT_FEE)CC_PERCENT_SLIPPAGE)The calculation is performed by toProfitLossDto() helper src/helpers/toProfitLossDto.ts:1-100.
The closed state transitions to:
When entering the closed state:
Risk.removeSignal() is called to release risk limits src/client/ClientStrategy.ts:1147-1150ClientPartial.clear() removes partial tracking state src/client/ClientStrategy.ts:1151-1154IStrategyCallbacks.onClose is invoked src/client/ClientStrategy.ts:1139-1146Closed state creation occurs in:
Interface: IStrategyTickResultCancelled
Purpose: Represents a scheduled signal that was discarded without ever opening a position. This occurs when a limit order's conditions are never met or are invalidated before activation.
interface IStrategyTickResultCancelled {
action: "cancelled";
signal: IScheduledSignalRow;
currentPrice: number;
closeTimestamp: number;
strategyName: StrategyName;
exchangeName: ExchangeName;
symbol: string;
}
| Property | Type | Description |
|---|---|---|
action |
"cancelled" |
Discriminator value for type narrowing |
signal |
IScheduledSignalRow |
Cancelled scheduled signal (never activated) |
currentPrice |
number |
Final VWAP price at cancellation |
closeTimestamp |
number |
Unix timestamp in milliseconds when signal cancelled |
strategyName |
StrategyName |
Strategy identifier |
exchangeName |
ExchangeName |
Exchange identifier |
symbol |
string |
Trading pair symbol |
A scheduled signal is cancelled in two scenarios:
For LONG scheduled signals:
if (currentPrice <= signal.priceStopLoss && currentPrice > signal.priceOpen) {
// Price dropped past SL before reaching priceOpen
// Cancel to prevent opening a position that would immediately lose
}
For SHORT scheduled signals:
if (currentPrice >= signal.priceStopLoss && currentPrice < signal.priceOpen) {
// Price rose past SL before reaching priceOpen
// Cancel to prevent opening a position that would immediately lose
}
Rationale: Prevents opening positions that would trigger immediate stop loss, saving fees and slippage costs.
const elapsedTime = currentTime - signal.scheduledAt;
const maxTimeToWait = CC_SCHEDULE_AWAIT_MINUTES * 60 * 1000; // Default: 120 minutes
if (elapsedTime >= maxTimeToWait) {
// Scheduled signal waited too long
// Cancel to free up risk limits
}
Rationale: Prevents "zombie" scheduled signals from blocking risk limits indefinitely.
The cancelled state transitions to:
Unlike the closed state, cancelled signals have no PnL impact because:
When entering the cancelled state:
IStrategyCallbacks.onCancel is invoked src/client/ClientStrategy.ts:580-587Cancelled state creation occurs in:
| Property | idle |
scheduled |
opened |
active |
closed |
cancelled |
|---|---|---|---|---|---|---|
action |
"idle" |
"scheduled" |
"opened" |
"active" |
"closed" |
"cancelled" |
signal |
null |
IScheduledSignalRow |
ISignalRow |
ISignalRow |
ISignalRow |
IScheduledSignalRow |
currentPrice |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
strategyName |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
exchangeName |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
symbol |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
percentTp |
✗ | ✗ | ✗ | ✓ | ✗ | ✗ |
percentSl |
✗ | ✗ | ✗ | ✓ | ✗ | ✗ |
closeReason |
✗ | ✗ | ✗ | ✗ | ✓ | ✗ |
closeTimestamp |
✗ | ✗ | ✗ | ✗ | ✓ | ✓ |
pnl |
✗ | ✗ | ✗ | ✗ | ✓ | ✗ |
The following diagram maps signal states to their implementation in ClientStrategy:
The VALIDATE_SIGNAL_FN enforces the following rules when signals are created or activated:
| Position | Rule | Error Message Pattern |
|---|---|---|
| LONG | priceTakeProfit > priceOpen |
"priceTakeProfit must be > priceOpen" |
| LONG | priceStopLoss < priceOpen |
"priceStopLoss must be < priceOpen" |
| SHORT | priceTakeProfit < priceOpen |
"priceTakeProfit must be < priceOpen" |
| SHORT | priceStopLoss > priceOpen |
"priceStopLoss must be > priceOpen" |
| Parameter | Minimum | Maximum | Configuration |
|---|---|---|---|
| Take Profit | CC_MIN_TAKEPROFIT_DISTANCE_PERCENT |
N/A | Default: 0.5% |
| Stop Loss | CC_MIN_STOPLOSS_DISTANCE_PERCENT |
CC_MAX_STOPLOSS_DISTANCE_PERCENT |
Default: 0.1% - 10% |
| Parameter | Minimum | Maximum | Configuration |
|---|---|---|---|
minuteEstimatedTime |
> 0 | CC_MAX_SIGNAL_LIFETIME_MINUTES |
Default: 10080 min (7 days) |
For immediate signals (without priceOpen):
// LONG: currentPrice must be BETWEEN SL and TP
if (currentPrice <= priceStopLoss || currentPrice >= priceTakeProfit) {
throw new Error("Signal would be immediately closed");
}
// SHORT: currentPrice must be BETWEEN TP and SL
if (currentPrice <= priceTakeProfit || currentPrice >= priceStopLoss) {
throw new Error("Signal would be immediately closed");
}
For scheduled signals (with priceOpen):
// LONG: priceOpen must be BETWEEN SL and TP
if (priceOpen <= priceStopLoss || priceOpen >= priceTakeProfit) {
throw new Error("Signal would close immediately on activation");
}
// SHORT: priceOpen must be BETWEEN TP and SL
if (priceOpen <= priceTakeProfit || priceOpen >= priceStopLoss) {
throw new Error("Signal would close immediately on activation");
}
Rationale: Prevents signals that would incur fees/slippage without any opportunity for profit.
The following table shows which RxJS subjects emit events for each state:
| State | signalEmitter |
signalBacktestEmitter |
signalLiveEmitter |
Additional Events |
|---|---|---|---|---|
idle |
✓ | ✓ (if backtest) | ✓ (if live) | None |
scheduled |
✓ | ✓ (if backtest) | ✓ (if live) | None |
opened |
✓ | ✓ (if backtest) | ✓ (if live) | Risk.addSignal() called |
active |
✓ | ✓ (if backtest) | ✓ (if live) | partialProfitSubject / partialLossSubject |
closed |
✓ | ✓ (if backtest) | ✓ (if live) | Risk.removeSignal() called |
cancelled |
✓ | ✓ (if backtest) | ✓ (if live) | None |
All states emit to:
signalEmitter src/config/emitters.ts:19execution.context.backtest flag src/client/ClientStrategy.ts:1200-1229In live trading mode (when execution.context.backtest === false), signal states are persisted to disk for crash recovery:
| State | Persisted? | Adapter | File Path |
|---|---|---|---|
idle |
No | N/A | N/A |
scheduled |
Yes | PersistScheduleAdapter |
./dump/data/schedule/{strategy}/{symbol}.json |
opened |
Yes | PersistSignalAdapter |
./dump/data/signal/{strategy}/{symbol}.json |
active |
Yes | PersistSignalAdapter |
./dump/data/signal/{strategy}/{symbol}.json |
closed |
No (deleted) | PersistSignalAdapter |
Removed from disk |
cancelled |
No (deleted) | PersistScheduleAdapter |
Removed from disk |
For detailed information on persistence mechanics, see Signal Persistence and Crash Recovery.