This page documents the four signal states in the backtest-kit framework and their corresponding TypeScript interfaces. Each state is represented by a distinct interface in a discriminated union, enabling type-safe signal lifecycle management.
For information about how signals are generated and validated, see Signal Generation and Validation. For details on state persistence between crashes, see Signal Persistence. For PnL calculation logic in the closed state, see PnL Calculation.
Signals progress through six distinct states during their lifecycle. The framework uses a discriminated union pattern with an action property as the type discriminator. All state interfaces are defined in types.d.ts:654-765 and returned by ClientStrategy.tick() in src/client/ClientStrategy.ts.
| State | Action Value | Description | Signal Type | Price Monitoring |
|---|---|---|---|---|
| Idle | "idle" |
No active signal exists | N/A (null) |
Current VWAP only |
| Scheduled | "scheduled" |
Waiting for price to reach priceOpen |
IScheduledSignalRow |
Activation price monitoring |
| Opened | "opened" |
Signal just created/activated | ISignalRow |
Entry price |
| Active | "active" |
Monitoring TP/SL conditions | ISignalRow |
Continuous VWAP |
| Closed | "closed" |
Signal completed with PnL | ISignalRow |
Final close price |
| Cancelled | "cancelled" |
Scheduled signal timeout/rejected | IScheduledSignalRow |
Cancellation price |
The key distinction is between immediate signals (no priceOpen specified, opens instantly) and scheduled signals (priceOpen specified, waits for price activation).
The IStrategyTickResult type is a discriminated union defined at types.d.ts:770. TypeScript uses the action property to narrow types in conditional blocks:
// Type narrowing with discriminator
const result: IStrategyTickResult = await strategy.tick();
if (result.action === "closed") {
// TypeScript knows result is IStrategyTickResultClosed
console.log(result.pnl.pnlPercentage); // OK
console.log(result.closeReason); // OK
}
if (result.action === "scheduled") {
// TypeScript knows result is IStrategyTickResultScheduled
console.log(result.signal.priceOpen); // OK
console.log(result.currentPrice); // OK
}
if (result.action === "cancelled") {
// TypeScript knows result is IStrategyTickResultCancelled
console.log(result.closeTimestamp); // OK
console.log(result.signal.id); // OK
}
The state machine is implemented in ClientStrategy.tick(). The _pendingSignal field at src/client/ClientStrategy.ts:1093 tracks active signals, while _scheduledSignal at src/client/ClientStrategy.ts:1094 tracks scheduled signals waiting for price activation.
| Path | Trigger | Entry State | Activation Condition | Implementation |
|---|---|---|---|---|
| Immediate | getSignal() returns ISignalDto without priceOpen |
opened |
Opens at current VWAP instantly | OPEN_NEW_PENDING_SIGNAL_FN at src/client/ClientStrategy.ts:623-673 |
| Scheduled | getSignal() returns ISignalDto with priceOpen |
scheduled |
Waits for price to reach priceOpen |
OPEN_NEW_SCHEDULED_SIGNAL_FN at src/client/ClientStrategy.ts:578-621 |
The idle state indicates no active or scheduled signal exists. This is the default state when both ClientStrategy._pendingSignal and ClientStrategy._scheduledSignal are null.
Defined at types.d.ts:654-667:
interface IStrategyTickResultIdle {
action: "idle";
signal: null;
strategyName: StrategyName;
exchangeName: ExchangeName;
symbol: string;
currentPrice: number;
}
Implementation in ClientStrategy.tick():
GET_SIGNAL_FN() at src/client/ClientStrategy.ts:187-283 to check if a new signal should be generatedGET_SIGNAL_FN enforces interval-based throttling using _lastSignalTimestamp at src/client/ClientStrategy.ts:194-208risk.checkSignal() at src/client/ClientStrategy.ts:212-222 before generating signalexchange.getAveragePrice() in RETURN_IDLE_FN at src/client/ClientStrategy.ts:816-846callbacks.onIdle if configured at src/client/ClientStrategy.ts:820-826IStrategyTickResultIdle with current price at src/client/ClientStrategy.ts:828-834The idle state is returned when both _pendingSignal and _scheduledSignal remain null after attempting signal generation.
The scheduled state represents a signal waiting for price to reach priceOpen before activating. This occurs when getSignal() returns ISignalDto with priceOpen specified.
Defined at types.d.ts:672-685:
interface IStrategyTickResultScheduled {
action: "scheduled";
signal: IScheduledSignalRow;
strategyName: StrategyName;
exchangeName: ExchangeName;
symbol: string;
currentPrice: number;
}
Defined at types.d.ts:588-591:
interface IScheduledSignalRow extends ISignalRow {
priceOpen: number;
}
This interface inherits all properties from ISignalRow and explicitly requires priceOpen to be set (not optional).
Implementation in OPEN_NEW_SCHEDULED_SIGNAL_FN at src/client/ClientStrategy.ts:578-621:
GET_SIGNAL_FN() returns ISignalDto with priceOpen field populated at src/client/ClientStrategy.ts:233-254randomString() at src/client/ClientStrategy.ts:235IScheduledSignalRow with _isScheduled: true marker at src/client/ClientStrategy.ts:234-248VALIDATE_SIGNAL_FN() checks prices, TP/SL distances, and lifetime at src/client/ClientStrategy.ts:40-185_scheduledSignal field (not persisted to disk) at src/client/ClientStrategy.ts:580callbacks.onSchedule at src/client/ClientStrategy.ts:594-601IStrategyTickResultScheduled at src/client/ClientStrategy.ts:603-610On subsequent ticks with _scheduledSignal set, the framework monitors for three outcomes:
| Outcome | Condition | Implementation | Next State |
|---|---|---|---|
| Activation | Current price reaches priceOpen |
ACTIVATE_SCHEDULED_SIGNAL_FN at src/client/ClientStrategy.ts:459-551 |
opened |
| Timeout | CC_SCHEDULE_AWAIT_MINUTES elapsed |
CHECK_SCHEDULED_SIGNAL_TIMEOUT_FN at src/client/ClientStrategy.ts:332-386 |
cancelled |
| StopLoss Hit | Price crosses StopLoss before activation | CANCEL_SCHEDULED_SIGNAL_BY_STOPLOSS_FN at src/client/ClientStrategy.ts:424-457 |
idle |
The opened state occurs when a signal is activated - either immediately (no priceOpen) or after scheduled signal activation. This state is yielded exactly once per signal.
Defined at types.d.ts:690-703:
interface IStrategyTickResultOpened {
action: "opened";
signal: ISignalRow;
strategyName: StrategyName;
exchangeName: ExchangeName;
symbol: string;
currentPrice: number;
}
Implementation in OPEN_NEW_PENDING_SIGNAL_FN at src/client/ClientStrategy.ts:623-673:
GET_SIGNAL_FN() returns ISignalDto without priceOpen at src/client/ClientStrategy.ts:256-271randomString() at src/client/ClientStrategy.ts:257priceOpen at src/client/ClientStrategy.ts:258VALIDATE_SIGNAL_FN() checks prices and constraints at src/client/ClientStrategy.ts:40-185risk.checkSignal() at src/client/ClientStrategy.ts:627-637, returns null if rejectedsetPendingSignal(signal) atomically writes to disk (live mode) at src/client/ClientStrategy.ts:1105-1118risk.addSignal() at src/client/ClientStrategy.ts:641-644callbacks.onOpen at src/client/ClientStrategy.ts:646-653IStrategyTickResultOpened at src/client/ClientStrategy.ts:655-662Implementation in ACTIVATE_SCHEDULED_SIGNAL_FN at src/client/ClientStrategy.ts:459-551:
null if _isStopped flag is set at src/client/ClientStrategy.ts:465-472risk.checkSignal() again at activation time at src/client/ClientStrategy.ts:489-506pendingAt to activation time (was scheduledAt during scheduled state) at src/client/ClientStrategy.ts:511-515IScheduledSignalRow to ISignalRow with _isScheduled: false at src/client/ClientStrategy.ts:511-515setPendingSignal(activatedSignal) writes activated signal at src/client/ClientStrategy.ts:517risk.addSignal() at src/client/ClientStrategy.ts:519-522callbacks.onOpen at src/client/ClientStrategy.ts:524-531IStrategyTickResultOpened at src/client/ClientStrategy.ts:533-540After returning opened state, the next tick() call transitions to active state since _pendingSignal now exists.
The active state represents ongoing monitoring of take profit, stop loss, and time expiration conditions. This state repeats across multiple ticks until a close condition is met.
Defined at types.d.ts:708-721:
interface IStrategyTickResultActive {
action: "active";
signal: ISignalRow;
currentPrice: number;
strategyName: StrategyName;
exchangeName: ExchangeName;
symbol: string;
}
Implementation in RETURN_PENDING_SIGNAL_ACTIVE_FN at src/client/ClientStrategy.ts:791-814:
if (_pendingSignal) evaluates to trueexchange.getAveragePrice()CHECK_PENDING_SIGNAL_COMPLETION_FN at src/client/ClientStrategy.ts:675-734callbacks.onActive at src/client/ClientStrategy.ts:806-813 if configuredIStrategyTickResultActive with current VWAP at src/client/ClientStrategy.ts:796-803The condition checks at src/client/ClientStrategy.ts:675-734 evaluate in order:
| Check | Long Position | Short Position | Implementation |
|---|---|---|---|
| Time Expiration | currentTime >= pendingAt + minuteEstimatedTime * 60 * 1000 |
Same | Line 680-683 |
| Take Profit | averagePrice >= priceTakeProfit |
averagePrice <= priceTakeProfit |
Lines 686-703 |
| Stop Loss | averagePrice <= priceStopLoss |
averagePrice >= priceStopLoss |
Lines 705-724 |
If any condition is true, returns IStrategyTickResultClosed instead of proceeding to active state. Uses exact TP/SL prices for close (not current VWAP) at lines 700, 709, 719, 728.
Time expiration calculation uses signal.pendingAt at src/client/ClientStrategy.ts:681, NOT signal.scheduledAt. This ensures scheduled signals only start their lifetime countdown after activation, not from initial creation.
The closed state represents signal completion with calculated profit/loss. This is the terminal state for an active signal before returning to idle.
Defined at types.d.ts:726-745:
interface IStrategyTickResultClosed {
action: "closed";
signal: ISignalRow;
currentPrice: number;
closeReason: StrategyCloseReason;
closeTimestamp: number;
pnl: IStrategyPnL;
strategyName: StrategyName;
exchangeName: ExchangeName;
symbol: string;
}
The StrategyCloseReason type at types.d.ts:638 has three possible values:
| Close Reason | Condition | Long Example | Short Example |
|---|---|---|---|
"take_profit" |
VWAP reached priceTakeProfit |
Price rises to TP | Price falls to TP |
"stop_loss" |
VWAP reached priceStopLoss |
Price falls to SL | Price rises to SL |
"time_expired" |
currentTime >= pendingAt + minuteEstimatedTime * 60 * 1000 |
Duration elapsed | Duration elapsed |
Implementation in CLOSE_PENDING_SIGNAL_FN at src/client/ClientStrategy.ts:736-789:
toProfitLossDto(signal, currentPrice) at src/client/ClientStrategy.ts:742 to compute fees/slippage-adjusted profit/losscallbacks.onClose at src/client/ClientStrategy.ts:752-759risk.removeSignal() at src/client/ClientStrategy.ts:761-764setPendingSignal(null) at src/client/ClientStrategy.ts:766 to clear persistencecloseTimestamp from execution.context.when at src/client/ClientStrategy.ts:773IStrategyTickResultClosed with all metadata at src/client/ClientStrategy.ts:768-777callbacks.onTick at src/client/ClientStrategy.ts:779-784Close prices use exact TP/SL values at src/client/ClientStrategy.ts:700-728, not the current VWAP that triggered the close:
signal.priceTakeProfit as currentPrice parametersignal.priceStopLoss as currentPrice parameterThis ensures PnL calculations are based on the actual limit prices, not approximations.
The ClientStrategy.backtest() method at src/client/ClientStrategy.ts:1001-1179 always returns IStrategyBacktestResult which can be either IStrategyTickResultClosed or IStrategyTickResultCancelled. For active signals, it:
time_expired if duration elapses without hitting TP/SLThe cancelled state represents a scheduled signal that failed to activate. This occurs when the signal times out or hits stop loss before price reaches priceOpen.
Defined at types.d.ts:750-765:
interface IStrategyTickResultCancelled {
action: "cancelled";
signal: IScheduledSignalRow;
currentPrice: number;
closeTimestamp: number;
strategyName: StrategyName;
exchangeName: ExchangeName;
symbol: string;
}
Implementation in CHECK_SCHEDULED_SIGNAL_TIMEOUT_FN at src/client/ClientStrategy.ts:332-386:
currentTime - scheduledAt >= CC_SCHEDULE_AWAIT_MINUTES * 60 * 1000 at src/client/ClientStrategy.ts:339-343null if timeout not reached at src/client/ClientStrategy.ts:342-344_scheduledSignal = null at src/client/ClientStrategy.ts:355callbacks.onCancel at src/client/ClientStrategy.ts:357-364IStrategyTickResultCancelled at src/client/ClientStrategy.ts:366-373callbacks.onTick at src/client/ClientStrategy.ts:375-380Implementation in CHECK_SCHEDULED_SIGNAL_PRICE_ACTIVATION_FN at src/client/ClientStrategy.ts:388-422:
Checks for stop loss conditions before activation:
| Position | Cancellation Condition | Implementation |
|---|---|---|
| Long | currentPrice <= priceStopLoss |
Line 398-399 |
| Short | currentPrice >= priceStopLoss |
Line 411-412 |
When cancelled by stop loss:
shouldCancel: true from price activation checkCANCEL_SCHEDULED_SIGNAL_BY_STOPLOSS_FN at src/client/ClientStrategy.ts:424-457_scheduledSignal and returns idle (not cancelled) at src/client/ClientStrategy.ts:437-455| Cancellation Type | Returns State | Reason |
|---|---|---|
| Timeout (CC_SCHEDULE_AWAIT_MINUTES) | cancelled |
Explicit cancellation event for tracking |
| StopLoss Hit | idle |
Treated as price rejection, not cancellation |
In backtest mode, CANCEL_SCHEDULED_SIGNAL_IN_BACKTEST_FN at src/client/ClientStrategy.ts:848-895 handles scheduled signal cancellations and always returns IStrategyTickResultCancelled (not idle).
The discriminated union enables exhaustive type checking:
async function handleTick(strategy: ClientStrategy) {
const result = await strategy.tick();
switch (result.action) {
case "idle":
// result is IStrategyTickResultIdle
console.log(`No signal, price: ${result.currentPrice}`);
break;
case "opened":
// result is IStrategyTickResultOpened
console.log(`Signal opened: ${result.signal.id}`);
console.log(`Entry price: ${result.currentPrice}`);
break;
case "active":
// result is IStrategyTickResultActive
console.log(`Monitoring signal: ${result.signal.id}`);
console.log(`Current VWAP: ${result.currentPrice}`);
break;
case "closed":
// result is IStrategyTickResultClosed
console.log(`Signal closed: ${result.closeReason}`);
console.log(`PnL: ${result.pnl.pnlPercentage}%`);
console.log(`Close timestamp: ${result.closeTimestamp}`);
break;
default:
// TypeScript ensures exhaustiveness
const _exhaustive: never = result;
}
}
The LiveLogicPrivateService at src/lib/services/logic/LiveLogicPrivateService.ts filters active states to reduce noise. Only opened and closed states are yielded to the user in live trading, while backtest mode yields all states for analysis.