Signal States

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:

Mermaid Diagram


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:

  • scheduled: When getSignal() returns a signal with priceOpen specified
  • opened: When getSignal() returns a signal without priceOpen (immediate execution)
  • idle: Remains idle if getSignal() returns null

The idle state is returned by ClientStrategy.tick() when:

  1. No pending signal exists (this._pendingSignal === null)
  2. No scheduled signal exists (this._scheduledSignal === null)
  3. getSignal() returns null or validation fails

Interface: 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: true
  • scheduledAt: Timestamp when signal was created
  • pendingAt: Initially equals scheduledAt, updated to actual pending time upon activation

The scheduled state can transition to:

  • opened: When currentPrice reaches priceOpen (activation condition met)
  • cancelled: When StopLoss is hit before priceOpen is reached
  • cancelled: When timeout occurs (exceeds CC_SCHEDULE_AWAIT_MINUTES, default 120 minutes)
  • idle: When risk validation fails at activation attempt

Mermaid Diagram

For LONG positions:

  • Activate when: currentPrice <= priceOpen (price dropped to or below entry point)
  • Cancel if: currentPrice <= priceStopLoss (before activation)

For SHORT positions:

  • Activate when: currentPrice >= priceOpen (price rose to or above entry point)
  • Cancel if: 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
    • For immediate signals: pendingAt === scheduledAt
    • For scheduled signals: pendingAt is updated upon activation

The opened state immediately transitions to:

  • active: On the next tick (monitoring begins)

The opened state is ephemeral and exists only for one tick to trigger onOpen callbacks.

When entering the opened state:

  1. IStrategyCallbacks.onOpen is invoked src/client/ClientStrategy.ts:747-754
  2. Risk.addSignal() is called to register the position src/client/ClientStrategy.ts:742-745
  3. Signal is persisted to disk (in live mode) src/client/ClientStrategy.ts:157-175

The 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.

Mermaid Diagram

During the active state, ClientPartial monitors for milestone levels:

  • Profit levels: 10%, 20%, 30%, ..., 100% (when revenuePercent > 0)
  • Loss levels: -10%, -20%, -30%, ..., -100% (when 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:

  • closed: When any exit condition is met (TP, SL, or time expiration)

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:

  • Entry fee: 0.1% (default CC_PERCENT_FEE)
  • Exit fee: 0.1%
  • Entry slippage: 0.1% (default CC_PERCENT_SLIPPAGE)
  • Exit slippage: 0.1%
  • Total cost: ~0.4% round-trip

The calculation is performed by toProfitLossDto() helper src/helpers/toProfitLossDto.ts:1-100.

The closed state transitions to:

  • idle: Signal is complete and removed from tracking

When entering the closed state:

  1. Risk.removeSignal() is called to release risk limits src/client/ClientStrategy.ts:1147-1150
  2. ClientPartial.clear() removes partial tracking state src/client/ClientStrategy.ts:1151-1154
  3. IStrategyCallbacks.onClose is invoked src/client/ClientStrategy.ts:1139-1146
  4. Signal is removed from persistence (in live mode) src/client/ClientStrategy.ts:157-175

Closed 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:

  • idle: Scheduled signal is removed without ever activating

Unlike the closed state, cancelled signals have no PnL impact because:

  • No position was ever opened
  • No entry fees were incurred
  • No slippage was applied
  • Risk limits are released immediately

When entering the cancelled state:

  1. IStrategyCallbacks.onCancel is invoked src/client/ClientStrategy.ts:580-587
  2. Scheduled signal is removed from persistence (in live mode) src/classes/Persist.ts:1-500

Cancelled 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:

Mermaid Diagram


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:


In 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

Mermaid Diagram

For detailed information on persistence mechanics, see Signal Persistence and Crash Recovery.