This guide explains the complete lifecycle of trading signals in backtest-kit - from generation to closure. Understanding the signal state machine is critical for building reliable trading strategies.
A trading signal is a structured instruction to open a position in the market. Each signal contains:
Each signal progresses through one of six possible states. The framework strictly controls transitions between states.
Critical constraint: Only one signal can be active for a symbol-strategy pair at any given time. New signals wait until the previous signal reaches closed or cancelled state.
No active signal. Strategy is waiting for new signal generation.
When:
getSignal() function returned nullEvent data:
{
action: "idle",
signal: null,
currentPrice: 50000,
strategyName: "macd-crossover",
exchangeName: "binance",
symbol: "BTCUSDT"
}
Signal is waiting for price to reach priceOpen (limit order behavior).
When: getSignal() function returns a signal with specified priceOpen that hasn't been reached yet.
Example LONG signal:
{
position: "long",
priceOpen: 42000, // Entry when price drops to 42000
priceTakeProfit: 45000,
priceStopLoss: 40000,
minuteEstimatedTime: 120,
timestamp: Date.now()
}
What happens:
priceOpenpriceOpen → transition to opened statecancelled stateImportant characteristics:
currentPrice <= priceOpen (price drops to entry)currentPrice >= priceOpen (price rises to entry)Position has just been opened. This is an intermediate state that transitions to active on the next tick.
When:
idle → opened (when priceOpen not specified)scheduled → opened (when activation price reached)Event data:
{
action: "opened",
signal: {
id: "sig_123",
position: "long",
priceOpen: 42000,
priceTakeProfit: 45000,
priceStopLoss: 40000,
pendingAt: 1702800000000,
minuteEstimatedTime: 120
},
currentPrice: 42000,
strategyName: "macd-crossover",
exchangeName: "binance",
symbol: "BTCUSDT"
}
Usage: This moment is ideal for sending notifications about position entry or logging trade start.
Signal is being monitored for exit conditions (TP, SL, or time expiration).
Checked exit conditions:
currentPrice >= signal.priceTakeProfitcurrentPrice <= signal.priceStopLosscurrentTime - signal.pendingAt > signal.minuteEstimatedTime * 60 * 1000currentPrice <= signal.priceTakeProfitcurrentPrice >= signal.priceStopLossEvent data:
{
action: "active",
signal: { /* signal data */ },
currentPrice: 43500,
percentTp: 50, // Progress to TP: 50%
percentSl: 75, // Distance from SL: 75%
strategyName: "macd-crossover",
exchangeName: "binance",
symbol: "BTCUSDT"
}
Usage: Monitoring percentTp and percentSl allows tracking position progress in real-time.
Signal completed with final PNL calculation. This is a terminal state.
Close reasons:
"take_profit" - Price reached target profit level"stop_loss" - Protection level triggered"time_expired" - Maximum position lifetime expiredEvent data:
{
action: "closed",
signal: { /* signal data */ },
currentPrice: 45000,
closeReason: "take_profit",
closeTimestamp: 1702807200000,
pnl: {
pnlPercentage: 6.7, // +6.7% after all costs
priceOpen: 42000,
priceClose: 45000,
priceOpenAdjusted: 42168, // Including slippage and fees
priceCloseAdjusted: 44910
},
strategyName: "macd-crossover",
exchangeName: "binance",
symbol: "BTCUSDT"
}
For LONG positions:
Adjusted entry price:
priceOpenAdjusted = priceOpen × (1 + slippage) × (1 + fee)
Adjusted exit price:
priceCloseAdjusted = priceClose × (1 - slippage) × (1 - fee)
PNL percentage:
pnlPercentage = ((priceCloseAdjusted - priceOpenAdjusted) / priceOpenAdjusted) × 100
For SHORT positions:
Adjusted entry price:
priceOpenAdjusted = priceOpen × (1 - slippage) × (1 - fee)
Adjusted exit price:
priceCloseAdjusted = priceClose × (1 + slippage) × (1 + fee)
PNL percentage:
pnlPercentage = ((priceOpenAdjusted - priceCloseAdjusted) / priceOpenAdjusted) × 100
Default trading costs:
CC_PERCENT_SLIPPAGE = 0.1% (market impact)CC_PERCENT_FEE = 0.1% (exchange commission)Important: For breakeven, a signal must achieve at least 0.4% gross profit.
Scheduled signal was cancelled without opening a position. This is a terminal state.
Cancellation reasons:
CC_SCHEDULE_AWAIT_MINUTES (default 60 minutes)priceOpenEvent data:
{
action: "cancelled",
signal: {
id: "sig_124",
position: "long",
priceOpen: 42000,
scheduledAt: 1702800000000,
_isScheduled: true
},
currentPrice: 39000,
closeTimestamp: 1702803600000,
strategyName: "macd-crossover",
exchangeName: "binance",
symbol: "BTCUSDT"
}
Activation and cancellation logic for scheduled signals differs between LONG and SHORT positions due to opposite price movement directions.
Key rule: For LONG positions, stop-loss check has priority over activation check.
Rationale: If price drops to both levels (SL and priceOpen) on the same candle, the position should be cancelled (not opened and immediately closed), preventing unnecessary fees.
Key rule: For SHORT positions, stop-loss check has priority over activation check.
// Scheduled LONG signal created
{
position: "long",
priceOpen: 42000,
priceTakeProfit: 45000,
priceStopLoss: 40000,
scheduledAt: 10:00
}
// Price does not reach 42000 within 60 minutes
// Time: 11:00 - Timeout!
// Result: CANCELLED (reason: timeout)
// Scheduled LONG signal created
{
position: "long",
priceOpen: 42000,
priceStopLoss: 41000
}
// Price path: 43000 → 40500 (skips priceOpen, reaches SL)
// Result: CANCELLED (reason: SL before activation)
// Rationale: Opening at 42000 with immediate SL at 41000 wastes fees
// Scheduled SHORT signal created
{
position: "short",
priceOpen: 42000,
priceStopLoss: 44000
}
// Price path: 41000 → 45000 (skips priceOpen, reaches SL)
// Result: CANCELLED (reason: SL before activation)
The framework enforces strict validation rules to prevent invalid trades.
| Check | LONG | SHORT | Error if violated |
|---|---|---|---|
| Position TP/SL | TP > priceOpen > SL |
SL > priceOpen > TP |
Price logic violated |
| TP distance | ((TP - priceOpen) / priceOpen) × 100 ≥ 0.5% |
((priceOpen - TP) / priceOpen) × 100 ≥ 0.5% |
TP too close to cover fees |
| Min SL distance | ((priceOpen - SL) / priceOpen) × 100 ≥ 0.5% |
((SL - priceOpen) / priceOpen) × 100 ≥ 0.5% |
SL too close (instant stop) |
| Max SL distance | ((priceOpen - SL) / priceOpen) × 100 ≤ 20% |
((SL - priceOpen) / priceOpen) × 100 ≤ 20% |
SL too far (catastrophic loss) |
| Lifetime | minuteEstimatedTime ≤ 1440 minutes |
Same | Signal lifetime too long |
For signals that open immediately, additional checks prevent instant closure:
LONG immediate:
currentPrice MUST be between SL and TP:
priceStopLoss < currentPrice < priceTakeProfit
Error cases:
currentPrice <= priceStopLoss → "Signal will be immediately closed by stop-loss"currentPrice >= priceTakeProfit → "Profit opportunity already missed"SHORT immediate:
currentPrice MUST be between TP and SL:
priceTakeProfit < currentPrice < priceStopLoss
import { listenSignalBacktest } from "backtest-kit";
listenSignalBacktest((event) => {
console.log(`[${event.action}] ${event.symbol}`);
switch (event.action) {
case "idle":
console.log(" Waiting for new signal");
break;
case "scheduled":
console.log(` Scheduled: entry at ${event.signal.priceOpen}`);
break;
case "opened":
console.log(` Position opened: ${event.signal.position} @ ${event.currentPrice}`);
break;
case "active":
console.log(` Monitoring: TP ${event.percentTp}%, SL ${event.percentSl}%`);
break;
case "closed":
console.log(` Closed: ${event.closeReason}`);
console.log(` PNL: ${event.pnl.pnlPercentage.toFixed(2)}%`);
break;
case "cancelled":
console.log(` Scheduled signal cancelled`);
break;
}
});
Alternatively, use callbacks in the strategy schema:
addStrategy({
strategyName: "macd-crossover",
interval: "15m",
getSignal: async (symbol) => {
// Signal generation logic
},
callbacks: {
onSchedule: (symbol, signal, currentPrice, backtest) => {
console.log(`Scheduled signal created for ${symbol}`);
},
onOpen: (symbol, signal, currentPrice, backtest) => {
console.log(`Position opened: ${signal.position} @ ${currentPrice}`);
// Send notification, update UI, etc.
},
onActive: (symbol, signal, currentPrice, backtest) => {
// Called every tick during monitoring
// Use for progress tracking
},
onClose: (symbol, signal, priceClose, backtest) => {
console.log(`Position closed @ ${priceClose}`);
// Log trade results
},
onCancel: (symbol, signal, currentPrice, backtest) => {
console.log(`Scheduled signal cancelled @ ${currentPrice}`);
},
},
});
In live trading mode, active signals are saved to disk for crash protection.
| State | Saved? | Reason |
|---|---|---|
idle |
❌ No | No data to save |
scheduled |
✅ Yes | In PersistScheduleAdapter |
opened |
✅ Yes | In PersistSignalAdapter |
active |
✅ Yes | In PersistSignalAdapter |
closed |
❌ No | Position completed, cleared from disk |
cancelled |
❌ No | Scheduled signal cancelled, cleared from disk |
When the live trading process restarts, signals are restored from disk:
// On Live.background() startup
1. Read PersistSignalAdapter → restore active position
2. Read PersistScheduleAdapter → restore scheduled signal
3. Call appropriate callbacks (onActive / onSchedule)
4. Continue monitoring from restored state
Key guarantees:
import {
addStrategy,
listenSignalBacktest,
Backtest,
getCandles
} from "backtest-kit";
// Strategy with scheduled entry
addStrategy({
strategyName: "breakout-strategy",
interval: "15m",
getSignal: async (symbol) => {
// Fetch recent candles to determine market state
const candles = await getCandles(symbol, "15m", 20);
const currentPrice = candles[candles.length - 1].close;
// Scheduled LONG: wait for breakout down to 48000
return {
position: "long",
priceOpen: 48000, // Activate on drop
priceTakeProfit: 50000, // +4.17% target profit
priceStopLoss: 46500, // -3.13% max loss
minuteEstimatedTime: 240, // 4 hours
timestamp: Date.now(),
};
},
callbacks: {
onSchedule: (symbol, signal) => {
console.log(`✓ Scheduled signal: entry at ${signal.priceOpen}`);
},
onOpen: (symbol, signal, price) => {
console.log(`✓ Position opened at ${price}`);
},
onActive: (symbol, signal, price) => {
console.log(`→ Monitoring: current price ${price}`);
},
onClose: (symbol, signal, price) => {
console.log(`✓ Position closed at ${price}`);
},
onCancel: (symbol, signal) => {
console.log(`✗ Scheduled signal cancelled`);
},
},
});
// Monitor all events
listenSignalBacktest((event) => {
console.log(`[${event.action}] ${event.symbol} @ ${event.currentPrice}`);
if (event.action === "closed") {
console.log(` Reason: ${event.closeReason}`);
console.log(` PNL: ${event.pnl.pnlPercentage.toFixed(2)}%`);
}
});
// Run backtest
Backtest.background("BTCUSDT", {
strategyName: "breakout-strategy",
exchangeName: "binance",
frameName: "december-2025",
});
Possible output:
✓ Scheduled signal: entry at 48000
[scheduled] BTCUSDT @ 50000
[opened] BTCUSDT @ 48000
✓ Position opened at 48000
[active] BTCUSDT @ 48500
→ Monitoring: current price 48500
[active] BTCUSDT @ 49200
→ Monitoring: current price 49200
[closed] BTCUSDT @ 50000
✓ Position closed at 50000
Reason: take_profit
PNL: 3.76%
After understanding the signal lifecycle: