This page explains how trading signals are generated by user-defined strategies and validated by the framework before execution. Signal validation ensures financial safety by rejecting signals with invalid prices, insufficient profit margins, excessive risk, or unrealistic lifetimes.
Scope: This page covers the GET_SIGNAL_FN wrapper, VALIDATE_SIGNAL_FN validation logic, signal augmentation from ISignalDto to ISignalRow, and global configuration parameters controlling validation thresholds. For signal lifecycle management after validation, see Signal States. For risk management validation, see Risk Validation.
When a strategy's tick() method executes, the framework calls the user's getSignal() function through the GET_SIGNAL_FN wrapper, which orchestrates signal generation, validation, and augmentation.
The framework transforms user-provided ISignalDto objects into validated ISignalRow objects through augmentation with auto-generated metadata.
| Field | ISignalDto | ISignalRow | IScheduledSignalRow | Description |
|---|---|---|---|---|
id |
Optional | Required (UUID v4) | Required | Auto-generated if not provided |
position |
Required | Required | Required | Trade direction: "long" or "short" |
priceOpen |
Optional | Required | Required | Entry price (current VWAP if omitted) |
priceTakeProfit |
Required | Required | Required | Take profit target price |
priceStopLoss |
Required | Required | Required | Stop loss exit price |
minuteEstimatedTime |
Required | Required | Required | Expected signal lifetime in minutes |
note |
Optional | Optional | Optional | Human-readable description |
symbol |
- | Required | Required | Trading pair (e.g., "BTCUSDT") |
exchangeName |
- | Required | Required | Exchange identifier |
strategyName |
- | Required | Required | Strategy identifier |
scheduledAt |
- | Required | Required | Signal creation timestamp (ms) |
pendingAt |
- | Required | Required | Position activation timestamp (ms) |
_isScheduled |
- | Required | Required | Runtime marker for scheduled signals |
Key Differences:
ISignalDto: User-facing DTO returned by getSignal()ISignalRow: Framework-augmented signal with execution contextIScheduledSignalRow: Extends ISignalRow with guaranteed priceOpen (delayed entry signal)The VALIDATE_SIGNAL_FN performs comprehensive validation of signal parameters to prevent financial losses from invalid trades. All validation checks throw descriptive errors that are caught by GET_SIGNAL_FN's trycatch wrapper.
Prevents calculation explosions from non-finite values.
// Lines 43-58
if (!isFinite(signal.priceOpen)) {
errors.push(`priceOpen must be a finite number, got ${signal.priceOpen}`);
}
if (!isFinite(signal.priceTakeProfit)) {
errors.push(`priceTakeProfit must be a finite number, got ${signal.priceTakeProfit}`);
}
if (!isFinite(signal.priceStopLoss)) {
errors.push(`priceStopLoss must be a finite number, got ${signal.priceStopLoss}`);
}
Rationale: NaN or Infinity in prices causes all PnL calculations to become NaN, corrupting backtest results.
Ensures all prices are positive numbers (physically possible prices).
// Lines 61-71
if (isFinite(signal.priceOpen) && signal.priceOpen <= 0) {
errors.push(`priceOpen must be positive, got ${signal.priceOpen}`);
}
if (isFinite(signal.priceTakeProfit) && signal.priceTakeProfit <= 0) {
errors.push(`priceTakeProfit must be positive, got ${signal.priceTakeProfit}`);
}
if (isFinite(signal.priceStopLoss) && signal.priceStopLoss <= 0) {
errors.push(`priceStopLoss must be positive, got ${signal.priceStopLoss}`);
}
Rationale: Negative or zero prices are impossible on exchanges and cause undefined behavior in price calculations.
Validates TP/SL relationship to priceOpen based on trade direction.
// Lines 74-110
if (signal.position === "long") {
// TP must be ABOVE priceOpen (profit on price increase)
if (signal.priceTakeProfit <= signal.priceOpen) {
errors.push(`Long: priceTakeProfit (${signal.priceTakeProfit}) must be > priceOpen (${signal.priceOpen})`);
}
// SL must be BELOW priceOpen (limit loss on price decrease)
if (signal.priceStopLoss >= signal.priceOpen) {
errors.push(`Long: priceStopLoss (${signal.priceStopLoss}) must be < priceOpen (${signal.priceOpen})`);
}
}
Rationale: Long positions profit when price increases, so TP must be higher than entry price, and SL must be lower to cut losses.
// Lines 114-150
if (signal.position === "short") {
// TP must be BELOW priceOpen (profit on price decrease)
if (signal.priceTakeProfit >= signal.priceOpen) {
errors.push(`Short: priceTakeProfit (${signal.priceTakeProfit}) must be < priceOpen (${signal.priceOpen})`);
}
// SL must be ABOVE priceOpen (limit loss on price increase)
if (signal.priceStopLoss <= signal.priceOpen) {
errors.push(`Short: priceStopLoss (${signal.priceStopLoss}) must be > priceOpen (${signal.priceOpen})`);
}
}
Rationale: Short positions profit when price decreases, so TP must be lower than entry price, and SL must be higher to cut losses.
Ensures TakeProfit is far enough from entry to cover trading fees and slippage.
// Lines 87-97 (Long example)
const tpDistancePercent = ((signal.priceTakeProfit - signal.priceOpen) / signal.priceOpen) * 100;
if (tpDistancePercent < GLOBAL_CONFIG.CC_MIN_TAKEPROFIT_DISTANCE_PERCENT) {
errors.push(
`Long: TakeProfit too close to priceOpen (${tpDistancePercent.toFixed(3)}%). ` +
`Minimum distance: ${GLOBAL_CONFIG.CC_MIN_TAKEPROFIT_DISTANCE_PERCENT}% to cover trading fees. ` +
`Current: TP=${signal.priceTakeProfit}, Open=${signal.priceOpen}`
);
}
Default Configuration: CC_MIN_TAKEPROFIT_DISTANCE_PERCENT = 0.1%
Rationale: Trading fees (0.1% entry + 0.1% exit) plus slippage require minimum 0.2-0.3% profit margin to avoid net losses on winning trades.
Example Rejection:
priceOpen = 42000, priceTakeProfit = 42010 → Distance = 0.024% → REJECTEDLimits maximum loss per signal to protect capital from extreme StopLoss values.
// Lines 100-110 (Long example)
const slDistancePercent = ((signal.priceOpen - signal.priceStopLoss) / signal.priceOpen) * 100;
if (slDistancePercent > GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT) {
errors.push(
`Long: StopLoss too far from priceOpen (${slDistancePercent.toFixed(3)}%). ` +
`Maximum distance: ${GLOBAL_CONFIG.CC_MAX_STOPLOSS_DISTANCE_PERCENT}% to protect capital. ` +
`Current: SL=${signal.priceStopLoss}, Open=${signal.priceOpen}`
);
}
Default Configuration: CC_MAX_STOPLOSS_DISTANCE_PERCENT = 20%
Rationale: Prevents single trades from causing catastrophic portfolio drawdowns. A 50% loss requires 100% gain to recover.
Example Rejection:
priceOpen = 42000, priceStopLoss = 20000 → Distance = 52.4% → REJECTEDPrevents signals from blocking risk limits indefinitely.
// Lines 161-170
if (signal.minuteEstimatedTime > GLOBAL_CONFIG.CC_MAX_SIGNAL_LIFETIME_MINUTES) {
const days = (signal.minuteEstimatedTime / 60 / 24).toFixed(1);
const maxDays = (GLOBAL_CONFIG.CC_MAX_SIGNAL_LIFETIME_MINUTES / 60 / 24).toFixed(0);
errors.push(
`minuteEstimatedTime too large (${signal.minuteEstimatedTime} minutes = ${days} days). ` +
`Maximum: ${GLOBAL_CONFIG.CC_MAX_SIGNAL_LIFETIME_MINUTES} minutes (${maxDays} days) to prevent strategy deadlock. ` +
`Eternal signals block risk limits and prevent new trades.`
);
}
Default Configuration: CC_MAX_SIGNAL_LIFETIME_MINUTES = 1440 (1 day)
Rationale: Active signals count against maxConcurrentPositions risk limits. A signal with minuteEstimatedTime = 50000 (34 days) blocks the risk slot for over a month, preventing new trades.
Example Rejection:
minuteEstimatedTime = 50000 minutes (34.7 days) → REJECTEDThe GET_SIGNAL_FN wrapper coordinates signal generation, validation, and error handling through a trycatch wrapper with automatic error emission.
The INTERVAL_MINUTES map converts SignalInterval strings to milliseconds for throttling:
// Lines 31-38
const INTERVAL_MINUTES: Record<SignalInterval, number> = {
"1m": 1,
"3m": 3,
"5m": 5,
"15m": 15,
"30m": 30,
"1h": 60,
};
Logic (lines 196-208):
const intervalMinutes = INTERVAL_MINUTES[self.params.interval];
const intervalMs = intervalMinutes * 60 * 1000;
// Only call getSignal if enough time has passed
if (self._lastSignalTimestamp !== null &&
currentTime - self._lastSignalTimestamp < intervalMs) {
return null;
}
self._lastSignalTimestamp = currentTime;
Purpose: Prevents excessive API calls and ensures strategies generate signals at specified intervals (e.g., interval: "5m" → max 1 signal per 5 minutes).
The trycatch wrapper (lines 187-283) catches validation errors and prevents crashes:
const GET_SIGNAL_FN = trycatch(
async (self: ClientStrategy): Promise<ISignalRow | IScheduledSignalRow | null> => {
// ... signal generation logic ...
},
{
defaultValue: null,
fallback: (error) => {
backtest.loggerService.warn("ClientStrategy exception thrown", {
error: errorData(error),
message: getErrorMessage(error),
});
errorEmitter.next(error);
},
}
);
Behavior:
errorEmitter, returns nullObservability: Errors are emitted to errorEmitter for monitoring via listenError() event listener.
Signals can be immediate (open at current price) or scheduled (wait for specific entry price).
// User returns ISignalDto without priceOpen
{
position: "long",
priceTakeProfit: 42000,
priceStopLoss: 40000,
minuteEstimatedTime: 60,
}
// Framework augments to ISignalRow (lines 256-269)
{
id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
priceOpen: 41000, // ← currentPrice from VWAP
position: "long",
priceTakeProfit: 42000,
priceStopLoss: 40000,
minuteEstimatedTime: 60,
symbol: "BTCUSDT",
exchangeName: "binance",
strategyName: "my-strategy",
scheduledAt: 1704067200000, // ← Same timestamp
pendingAt: 1704067200000, // ← Same timestamp (immediate entry)
_isScheduled: false,
}
Behavior: Position opens immediately at current VWAP price. Both scheduledAt and pendingAt are identical.
// User returns ISignalDto with priceOpen
{
position: "long",
priceOpen: 41000, // ← User specifies entry price
priceTakeProfit: 42000,
priceStopLoss: 40000,
minuteEstimatedTime: 60,
}
// Framework augments to IScheduledSignalRow (lines 234-251)
{
id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
priceOpen: 41000, // ← User's specified price
position: "long",
priceTakeProfit: 42000,
priceStopLoss: 40000,
minuteEstimatedTime: 60,
symbol: "BTCUSDT",
exchangeName: "binance",
strategyName: "my-strategy",
scheduledAt: 1704067200000, // ← Creation time
pendingAt: 1704067200000, // ← Temporary (updated on activation)
_isScheduled: true,
}
// On activation (lines 510-515)
{
// ... same fields ...
pendingAt: 1704067800000, // ← Updated to activation time (+10min)
_isScheduled: false, // ← Converted to regular signal
}
Behavior: Signal waits until market price reaches priceOpen, then activates. The pendingAt timestamp is updated at activation time to accurately track position duration.
Timeout: Scheduled signals cancel after CC_SCHEDULE_AWAIT_MINUTES (default: 120 minutes) if price never reaches priceOpen.
Global configuration parameters control validation thresholds. These can be modified via setConfig() to customize validation behavior.
| Parameter | Default | Unit | Purpose | Validation Target |
|---|---|---|---|---|
CC_MIN_TAKEPROFIT_DISTANCE_PERCENT |
0.1 |
% | Minimum TP-priceOpen distance | Fee coverage check |
CC_MAX_STOPLOSS_DISTANCE_PERCENT |
20 |
% | Maximum SL-priceOpen distance | Catastrophic loss prevention |
CC_MAX_SIGNAL_LIFETIME_MINUTES |
1440 |
minutes | Maximum signal lifetime | Eternal signal prevention |
CC_SCHEDULE_AWAIT_MINUTES |
120 |
minutes | Scheduled signal timeout | Scheduled signal cancellation |
import { setConfig } from "backtest-kit";
// Customize validation thresholds
setConfig({
CC_MIN_TAKEPROFIT_DISTANCE_PERCENT: 0.3, // Require 0.3% profit margin
CC_MAX_STOPLOSS_DISTANCE_PERCENT: 10, // Limit max loss to 10%
CC_MAX_SIGNAL_LIFETIME_MINUTES: 720, // Max 12-hour signal lifetime
});
Effect: All subsequent signal validations use the updated thresholds.
Testing Strategy: Test suites disable validation for legacy tests by setting permissive values:
// test/config/setup.mjs:36-41
setConfig({
CC_MIN_TAKEPROFIT_DISTANCE_PERCENT: 0, // No TP distance check
CC_MAX_STOPLOSS_DISTANCE_PERCENT: 100, // Allow any SL
CC_MAX_SIGNAL_LIFETIME_MINUTES: 999999, // No lifetime limit
});
This section demonstrates common validation failures and their error messages.
// User's getSignal returns:
{
position: "long",
priceOpen: 42000,
priceTakeProfit: 42010, // Only +0.024% profit
priceStopLoss: 41000,
minuteEstimatedTime: 60,
}
// VALIDATE_SIGNAL_FN throws:
// "Long: TakeProfit too close to priceOpen (0.024%).
// Minimum distance: 0.1% to cover trading fees.
// Current: TP=42010, Open=42000"
Why Rejected: Profit of 0.024% cannot cover trading fees (0.1% entry + 0.1% exit = 0.2%). Net result would be -0.176% loss despite hitting TP.
// User's getSignal returns:
{
position: "long",
priceOpen: 42000,
priceTakeProfit: 43000,
priceStopLoss: 20000, // -52.4% loss!
minuteEstimatedTime: 60,
}
// VALIDATE_SIGNAL_FN throws:
// "Long: StopLoss too far from priceOpen (52.381%).
// Maximum distance: 20% to protect capital.
// Current: SL=20000, Open=42000"
Why Rejected: Single trade could lose >50% of position value, requiring >100% gain to recover.
// User's getSignal returns:
{
position: "long",
priceOpen: 42000,
priceTakeProfit: 43000,
priceStopLoss: 41000,
minuteEstimatedTime: 50000, // 34.7 days!
}
// VALIDATE_SIGNAL_FN throws:
// "minuteEstimatedTime too large (50000 minutes = 34.7 days).
// Maximum: 1440 minutes (1 days) to prevent strategy deadlock.
// Eternal signals block risk limits and prevent new trades."
Why Rejected: Signal would occupy risk limit slot for >1 month, preventing new trades.
// User's getSignal returns:
{
position: "long",
priceOpen: NaN, // Invalid!
priceTakeProfit: 43000,
priceStopLoss: 41000,
minuteEstimatedTime: 60,
}
// VALIDATE_SIGNAL_FN throws:
// "priceOpen must be a finite number, got NaN (number)"
Why Rejected: NaN propagates through all calculations, corrupting PnL results.
// User's getSignal returns:
{
position: "long",
priceOpen: 41000,
priceTakeProfit: 40000, // TP BELOW priceOpen!
priceStopLoss: 39000,
minuteEstimatedTime: 60,
}
// VALIDATE_SIGNAL_FN throws:
// "Long: priceTakeProfit (40000) must be > priceOpen (41000)"
Why Rejected: Long positions profit when price increases. TP must be above entry price.
The GET_SIGNAL_FN is called from ClientStrategy.tick() method during strategy execution.
Signal generation and validation in backtest-kit ensures financial safety through:
GLOBAL_CONFIG parametersKey Validation Checks: