This page documents the signal generation process and the multi-layer validation pipeline that ensures all trading signals meet safety and logical requirements before execution. Signal generation occurs in the getSignal function defined in IStrategySchema, and validation is performed by VALIDATE_SIGNAL_FN within ClientStrategy.
Scope: This page covers the mechanics of signal creation from getSignal() through validation. For information about signal state transitions after validation, see Signal States. For the actual risk checking integration, see Risk Management. For signal persistence after validation, see Signal Persistence.
Signal generation is the entry point for all trading decisions. The getSignal function is called by ClientStrategy at intervals specified by the strategy's interval parameter, respecting throttling limits to prevent excessive signal generation.
The getSignal function returns an ISignalDto object containing the signal parameters. This DTO is then validated and augmented with metadata to create an ISignalRow.
| Field | Type | Required | Description |
|---|---|---|---|
id |
string |
No | Optional signal ID (UUID v4 auto-generated if omitted) |
position |
"long" | "short" |
Yes | Trade direction: "long" for buy, "short" for sell |
note |
string |
No | Human-readable description of signal reason |
priceOpen |
number |
No | Entry price for limit order. If omitted, opens immediately at current VWAP |
priceTakeProfit |
number |
Yes | Target exit price for profit |
priceStopLoss |
number |
Yes | Exit price for loss protection |
minuteEstimatedTime |
number |
Yes | Expected duration in minutes before time expiration |
After validation, ISignalDto is augmented with system metadata to create ISignalRow:
| Additional Field | Type | Description |
|---|---|---|
id |
string |
Guaranteed non-empty (generated if missing) |
priceOpen |
number |
Guaranteed defined (set to currentPrice if omitted) |
exchangeName |
ExchangeName |
Exchange identifier from strategy context |
strategyName |
StrategyName |
Strategy identifier from strategy context |
symbol |
string |
Trading pair symbol (e.g., "BTCUSDT") |
scheduledAt |
number |
Signal creation timestamp (milliseconds) |
pendingAt |
number |
Position activation timestamp (milliseconds) |
_isScheduled |
boolean |
Internal marker for scheduled signals |
The validation function VALIDATE_SIGNAL_FN performs comprehensive safety checks before a signal is allowed to proceed. This function throws an error if any validation fails, preventing invalid signals from being executed.
Type validation ensures all required fields are present and have valid types. This prevents runtime errors from missing or malformed data.
// Example validation checks (pseudocode from VALIDATE_SIGNAL_FN)
if (signal.id === undefined || signal.id === null || signal.id === '') {
errors.push('id is required and must be a non-empty string');
}
if (signal.position !== "long" && signal.position !== "short") {
errors.push(`position must be "long" or "short", got "${signal.position}"`);
}
Validation Rules:
id: Must be non-empty string (auto-generated if missing in DTO, but required in ISignalRow)exchangeName: Must be defined and non-emptystrategyName: Must be defined and non-emptysymbol: Must be defined and non-empty string_isScheduled: Must be booleanposition: Must be exactly "long" or "short" (no other values allowed)Price validation protects against NaN and Infinity values that could cause financial losses or system crashes. All price fields must be finite positive numbers.
| Check | Purpose | Example Error |
|---|---|---|
isFinite(currentPrice) |
Prevent NaN/Infinity in current market price | "currentPrice must be a finite number, got NaN" |
currentPrice > 0 |
Prevent negative or zero prices | "currentPrice must be positive, got -42000" |
isFinite(priceOpen) |
Prevent NaN/Infinity in entry price | "priceOpen must be a finite number, got Infinity" |
priceOpen > 0 |
Prevent negative or zero entry | "priceOpen must be positive, got 0" |
isFinite(priceTakeProfit) |
Prevent NaN/Infinity in TP | "priceTakeProfit must be a finite number" |
priceTakeProfit > 0 |
Prevent negative or zero TP | "priceTakeProfit must be positive" |
isFinite(priceStopLoss) |
Prevent NaN/Infinity in SL | "priceStopLoss must be a finite number" |
priceStopLoss > 0 |
Prevent negative or zero SL | "priceStopLoss must be positive" |
Critical Protection: These checks prevent catastrophic scenarios:
Logic validation enforces the mathematical relationships between prices based on position direction. LONG and SHORT positions have opposite requirements.
LONG Position Requirements:
priceStopLoss < priceOpen < priceTakeProfit
priceStopLoss < currentPrice < priceTakeProfit
priceStopLoss < priceOpen < priceTakeProfit
Example Error Messages:
Long: priceTakeProfit (42000) must be > priceOpen (43000)
Long immediate: currentPrice (41000) <= priceStopLoss (41500).
Signal would be immediately closed by stop loss.
Long scheduled: priceOpen (40000) <= priceStopLoss (40500).
Signal would be immediately cancelled on activation.
SHORT Position Requirements:
priceTakeProfit < priceOpen < priceStopLoss
priceTakeProfit < currentPrice < priceStopLoss
priceTakeProfit < priceOpen < priceStopLoss
Example Error Messages:
Short: priceTakeProfit (44000) must be < priceOpen (43000)
Short immediate: currentPrice (45000) >= priceStopLoss (44500).
Signal would be immediately closed by stop loss.
Short scheduled: priceOpen (46000) >= priceStopLoss (45500).
Signal would be immediately cancelled on activation.
Distance validation enforces minimum and maximum distances between prices to prevent unprofitable or overly risky trades. These thresholds are configured globally via setConfig().
| Validation | Config Parameter | Purpose |
|---|---|---|
| Minimum TP Distance | CC_MIN_TAKEPROFIT_DISTANCE_PERCENT |
Ensures TP is far enough to cover fees + slippage (default: no minimum) |
| Minimum SL Distance | CC_MIN_STOPLOSS_DISTANCE_PERCENT |
Prevents micro-SL that triggers on normal volatility (default: no minimum) |
| Maximum SL Distance | CC_MAX_STOPLOSS_DISTANCE_PERCENT |
Caps maximum loss per trade to protect capital (default: no maximum) |
Calculation (LONG):
const tpDistancePercent = ((priceTakeProfit - priceOpen) / priceOpen) * 100;
// Example: ((43000 - 42000) / 42000) * 100 = 2.38%
Calculation (SHORT):
const tpDistancePercent = ((priceOpen - priceTakeProfit) / priceOpen) * 100;
// Example: ((43000 - 42000) / 43000) * 100 = 2.33%
Protection: With fees (0.1%) + slippage (0.1%) = 0.2% total overhead, a minimum TP distance (e.g., 0.5%) ensures trades can be profitable after costs.
Purpose: Prevents "micro-stoploss" that triggers on normal market volatility before the trade has a chance to succeed.
Example: If CC_MIN_STOPLOSS_DISTANCE_PERCENT = 0.3, then:
Error Example:
Long: StopLoss too close to priceOpen (0.120%).
Minimum distance: 0.300% to avoid instant stop out on market volatility.
Current: SL=41950, Open=42000
Purpose: Caps the maximum loss per trade to prevent catastrophic losses from a single position.
Example: If CC_MAX_STOPLOSS_DISTANCE_PERCENT = 5.0, then:
Risk Management: Protects portfolio from single-trade wipeout scenarios. Combined with position sizing, this enforces maximum risk per trade.
Error Example:
Long: StopLoss too far from priceOpen (8.500%).
Maximum distance: 5.000% to protect capital.
Current: SL=38430, Open=42000
Time validation ensures signal lifetime parameters are reasonable and prevents pathological edge cases like eternal signals or instant timeouts.
| Check | Requirement | Protection |
|---|---|---|
minuteEstimatedTime > 0 |
Positive duration | Prevents instant timeout (0 minutes = immediate close) |
Number.isInteger(minuteEstimatedTime) |
Whole number | Ensures precise minute-based timing |
minuteEstimatedTime <= CC_MAX_SIGNAL_LIFETIME_MINUTES |
Maximum duration cap | Prevents eternal signals that block risk limits indefinitely |
scheduledAt > 0 |
Valid timestamp | Ensures signal creation time is tracked |
pendingAt > 0 |
Valid timestamp | Ensures activation time is tracked |
Configuration: CC_MAX_SIGNAL_LIFETIME_MINUTES (default: undefined = unlimited)
Problem: Signals with extremely long minuteEstimatedTime (e.g., 43200 minutes = 30 days) can:
Example Error:
minuteEstimatedTime too large (43200 minutes = 30.0 days).
Maximum: 10080 minutes (7 days) to prevent strategy deadlock.
Eternal signals block risk limits and prevent new trades.
The signal generation logic determines whether to create an immediate entry (_isScheduled=false) or a scheduled entry (_isScheduled=true) based on the priceOpen parameter and current market price.
Trigger: priceOpen is undefined in ISignalDto
Behavior:
exchange.getAveragePrice(symbol)priceOpen = currentPriceISignalRow with _isScheduled = falsescheduledAt and pendingAt are set to same timestampUse Case: Market orders that execute at current market price without waiting.
Trigger: priceOpen is specified AND price has NOT yet reached entry point
Conditions:
currentPrice > priceOpen (waiting for price to fall)currentPrice < priceOpen (waiting for price to rise)Behavior:
IScheduledSignalRow with _isScheduled = truepriceOpen is stored from DTOscheduledAt = currentTime (signal creation time)pendingAt = currentTime (temporary, will update on activation)priceOpenUse Case: Limit orders that wait for better entry price before activating.
Trigger: priceOpen is specified AND price has ALREADY reached entry point
Conditions:
currentPrice <= priceOpen (price already fell to entry)currentPrice >= priceOpen (price already rose to entry)Behavior:
ISignalRow (not IScheduledSignalRow)_isScheduled = false (activates immediately)priceOpen from DTO (uses specified entry price, not current price)scheduledAt and pendingAt set to current timeCritical Logic: This prevents the system from creating a scheduled signal when the target price has already been reached. Instead, it treats it as an immediate entry at the specified priceOpen.
After the internal validation checks pass, the signal must also pass risk management checks via ClientRisk.checkSignal(). This is a separate validation layer that enforces portfolio-level constraints.
Risk Check Parameters (IRiskCheckArgs):
symbol: Trading pairpendingSignal: The ISignalDto being validatedstrategyName: Strategy requesting the signalexchangeName: Exchange identifiercurrentPrice: Current VWAP pricetimestamp: Current time in millisecondsRisk Payload (IRiskValidationPayload extends IRiskCheckArgs):
activePositionCount: Number of currently open positionsactivePositions: Array of IRiskActivePosition objects with signal detailsRejection Behavior:
checkSignal() returns falsenullonRejected) are fired for monitoringValidation errors are caught by trycatch() wrapper around GET_SIGNAL_FN, preventing crashes and enabling graceful degradation.
Error Handling Behavior:
trycatch() wrapper catches all exceptions in signal generationbacktest.loggerService.warn()errorEmitter.next(error) for monitoringnull (no signal) instead of crashingExample Error Log:
{
message: "ClientStrategy exception thrown",
payload: {
error: { ... },
message: "Invalid signal for long position:\nLong: priceTakeProfit (42000) must be > priceOpen (43000)"
}
}
The validation pipeline is extensively tested to ensure all edge cases are handled correctly and financial safety is maintained.
| Test Category | Test Case | File Reference |
|---|---|---|
| LONG Logic | Limit order activates BEFORE StopLoss | test/e2e/defend.test.mjs:26-146 |
| SHORT Logic | Limit order activates BEFORE StopLoss | test/e2e/defend.test.mjs:158-278 |
| Instant TP | Scheduled signal activated and closed on same candle | test/e2e/defend.test.mjs:291-439 |
| Timeout | Scheduled signal cancelled at 120min boundary | test/e2e/defend.test.mjs:446-537 |
| Invalid TP | LONG signal rejected (TP below priceOpen) | test/e2e/defend.test.mjs:545-642 |
| Invalid TP | SHORT signal rejected (TP above priceOpen) | test/e2e/defend.test.mjs:649-744 |
| Invalid SL | LONG signal rejected (SL >= priceOpen) | test/e2e/defend.test.mjs:752-846 |
| Zero SL | Signal rejected (StopLoss = 0) | test/e2e/defend.test.mjs:858-950 |
| Inverted Logic | SHORT signal rejected (TP > priceOpen) | test/e2e/defend.test.mjs:963-1057 |
| Zero Time | Signal rejected (minuteEstimatedTime = 0) | test/e2e/defend.test.mjs:1069-1162 |
| Equal TP | Signal rejected (TP equals priceOpen) | test/e2e/defend.test.mjs:1174-1269 |
| SL Cancellation | Scheduled LONG cancelled by SL before activation | test/e2e/defend.test.mjs:1393-1507 |
| Extreme Volatility | Price crosses both TP and SL (TP wins) | test/e2e/defend.test.mjs:1520-1653 |
| Infrastructure | Exchange.getCandles throws error | test/e2e/defend.test.mjs:1664-1743 |
Test Philosophy: Each test validates that the system rejects invalid signals before they can cause financial harm. Tests verify error messages contain actionable information.
| Function | Location | Purpose |
|---|---|---|
GET_SIGNAL_FN |
src/client/ClientStrategy.ts:332-476 | Main signal generation logic with throttling and risk checks |
VALIDATE_SIGNAL_FN |
src/client/ClientStrategy.ts:45-330 | Multi-layer validation pipeline |
getSignal (user-defined) |
IStrategySchema.getSignal |
User-provided signal generation callback |
| Type | Location | Description |
|---|---|---|
ISignalDto |
src/interfaces/Strategy.interface.ts:24-39 | Signal data transfer object from getSignal |
ISignalRow |
src/interfaces/Strategy.interface.ts:45-62 | Validated signal with metadata |
IScheduledSignalRow |
src/interfaces/Strategy.interface.ts:70-73 | Scheduled signal awaiting activation |
IRiskCheckArgs |
types.d.ts:339-356 | Risk validation parameters |
IRiskValidationPayload |
types.d.ts:380-390 | Extended risk check with portfolio state |
| Constant | Type | Purpose |
|---|---|---|
CC_MIN_TAKEPROFIT_DISTANCE_PERCENT |
number | undefined |
Minimum TP distance percentage |
CC_MIN_STOPLOSS_DISTANCE_PERCENT |
number | undefined |
Minimum SL distance percentage |
CC_MAX_STOPLOSS_DISTANCE_PERCENT |
number | undefined |
Maximum SL distance percentage |
CC_MAX_SIGNAL_LIFETIME_MINUTES |
number | undefined |
Maximum signal duration |
CC_SCHEDULE_AWAIT_MINUTES |
number |
Scheduled signal timeout (default: 120) |
addStrategy({
strategyName: "example",
interval: "5m",
getSignal: async (symbol, when) => {
const candles = await getCandles(symbol, "1h", 24);
// Calculate indicators
const rsi = calculateRSI(candles);
// No signal condition
if (rsi > 30 && rsi < 70) {
return null; // ✅ Return null when no trade opportunity
}
// Signal condition
return {
position: "long",
priceTakeProfit: currentPrice * 1.02,
priceStopLoss: currentPrice * 0.98,
minuteEstimatedTime: 60,
note: `RSI ${rsi.toFixed(2)} oversold`
};
}
});
// Wait for better entry price
return {
position: "long",
priceOpen: 42000, // ✅ Wait for price to drop to 42000
priceTakeProfit: 43000,
priceStopLoss: 41000,
minuteEstimatedTime: 120,
note: "Limit order at 42000"
};
const currentPrice = await getAveragePrice(symbol);
return {
position: "long",
// ✅ TP is 2% above entry (covers fees + slippage)
priceTakeProfit: currentPrice * 1.02,
// ✅ SL is 1% below entry (reasonable risk)
priceStopLoss: currentPrice * 0.99,
// ✅ Integer minutes, reasonable duration
minuteEstimatedTime: 60,
note: "Valid signal parameters"
};
// ❌ WRONG: TP equals entry = zero profit
return {
position: "long",
priceOpen: 42000,
priceTakeProfit: 42000, // ❌ No profit, only fees!
priceStopLoss: 41000,
minuteEstimatedTime: 60
};
// This will be REJECTED by validation
// ❌ WRONG: SHORT with TP > priceOpen
return {
position: "short",
priceOpen: 42000,
priceTakeProfit: 43000, // ❌ SHORT needs TP < priceOpen!
priceStopLoss: 44000,
minuteEstimatedTime: 60
};
// This will be REJECTED by validation
// ❌ WRONG: 30 days = blocks risk limits for a month
return {
position: "long",
priceOpen: 42000,
priceTakeProfit: 45000,
priceStopLoss: 40000,
minuteEstimatedTime: 43200 // ❌ 30 days!
};
// This will be REJECTED if CC_MAX_SIGNAL_LIFETIME_MINUTES is set