Custom risk validations allow you to define portfolio-level constraints and business rules that prevent signals from opening positions when specific conditions are not met. This guide covers how to implement custom validation functions using the IRiskValidation interface with access to IRiskValidationPayload.
For risk profile configuration basics, see Risk Profiles. For position tracking implementation, see Position Tracking. For risk validation chain execution, see Risk Validation.
Custom risk validations provide a declarative way to enforce trading constraints at the portfolio level before positions are opened. Each validation function receives comprehensive context including:
Validations execute synchronously in the order defined and can reject signals by throwing errors. This system prevents capital deployment that violates risk limits, ensuring disciplined execution.
Validation Timing: Risk validations execute after signal generation but before position opening. This ensures that portfolio state is current and no capital is committed until all checks pass.
interface IRiskValidation {
validate: IRiskValidationFn;
note?: string;
}
interface IRiskValidationFn {
(payload: IRiskValidationPayload): void | Promise<void>;
}
The IRiskValidation interface has two properties:
| Property | Type | Required | Purpose |
|---|---|---|---|
validate |
IRiskValidationFn |
Yes | Validation logic function |
note |
string |
No | Human-readable description for logging/debugging |
Key Characteristics:
Promise<void>void - all communication via exceptions| Property | Type | Description |
|---|---|---|
symbol |
string |
Trading pair (e.g., "BTCUSDT") |
pendingSignal |
ISignalDto |
Signal to be validated |
strategyName |
string |
Strategy requesting position |
exchangeName |
string |
Exchange identifier |
currentPrice |
number |
Current VWAP price |
timestamp |
number |
Unix timestamp in milliseconds |
| Property | Type | Description |
|---|---|---|
activePositionCount |
number |
Total open positions across all strategies |
activePositions |
IRiskActivePosition[] |
Full list of active positions with details |
Each active position contains:
interface IRiskActivePosition {
signal: ISignalRow; // Full signal details
strategyName: string; // Owning strategy
exchangeName: string; // Exchange used
openTimestamp: number; // When position opened (ms)
}
addRisk({
riskName: "my-risk",
validations: [
// Direct function - no note
({ activePositionCount }) => {
if (activePositionCount >= 3) {
throw new Error("Max 3 concurrent positions");
}
}
]
});
addRisk({
riskName: "my-risk",
validations: [
{
validate: ({ activePositionCount }) => {
if (activePositionCount >= 3) {
throw new Error("Max 3 concurrent positions");
}
},
note: "Limit portfolio to 3 concurrent positions for capital preservation"
}
]
});
Note Benefits:
riskSubjectPrevent over-leveraging by limiting concurrent positions:
{
validate: ({ activePositionCount }) => {
const MAX_POSITIONS = 5;
if (activePositionCount >= MAX_POSITIONS) {
throw new Error(`Position limit reached: ${activePositionCount}/${MAX_POSITIONS}`);
}
},
note: "Enforce maximum 5 concurrent positions"
}
Ensure minimum risk/reward ratio (e.g., 2:1):
{
validate: ({ pendingSignal, currentPrice }) => {
const { priceOpen = currentPrice, priceTakeProfit, priceStopLoss, position } = pendingSignal;
const reward = position === "long"
? priceTakeProfit - priceOpen
: priceOpen - priceTakeProfit;
const risk = position === "long"
? priceOpen - priceStopLoss
: priceStopLoss - priceOpen;
const ratio = reward / risk;
if (ratio < 2.0) {
throw new Error(`Poor R/R ratio: ${ratio.toFixed(2)}:1 (minimum 2:1 required)`);
}
},
note: "Enforce minimum 2:1 risk/reward ratio"
}
Ensure take profit covers trading fees:
{
validate: ({ pendingSignal, currentPrice }) => {
const { priceOpen = currentPrice, priceTakeProfit, position } = pendingSignal;
const tpDistance = position === "long"
? ((priceTakeProfit - priceOpen) / priceOpen) * 100
: ((priceOpen - priceTakeProfit) / priceOpen) * 100;
const MIN_TP_PERCENT = 1.0; // 1% minimum to cover fees
if (tpDistance < MIN_TP_PERCENT) {
throw new Error(`TP too close: ${tpDistance.toFixed(2)}% (minimum ${MIN_TP_PERCENT}%)`);
}
},
note: "Ensure TP distance covers trading fees (1% minimum)"
}
Limit exposure per trading pair:
{
validate: ({ symbol, activePositions }) => {
const MAX_PER_SYMBOL = 2;
const symbolCount = activePositions.filter(p => p.signal.symbol === symbol).length;
if (symbolCount >= MAX_PER_SYMBOL) {
throw new Error(`Max ${MAX_PER_SYMBOL} positions per symbol (${symbol} has ${symbolCount})`);
}
},
note: "Limit to 2 concurrent positions per trading pair"
}
Prevent single strategy from dominating portfolio:
{
validate: ({ strategyName, activePositions, activePositionCount }) => {
if (activePositionCount === 0) return; // Allow first position
const strategyCount = activePositions.filter(p => p.strategyName === strategyName).length;
const strategyPercent = (strategyCount / activePositionCount) * 100;
const MAX_STRATEGY_PERCENT = 40; // 40% max per strategy
if (strategyPercent >= MAX_STRATEGY_PERCENT) {
throw new Error(`Strategy ${strategyName} over-exposed: ${strategyPercent.toFixed(0)}% (max ${MAX_STRATEGY_PERCENT}%)`);
}
},
note: "Prevent single strategy from exceeding 40% of portfolio"
}
Allow or forbid opposing positions on same symbol:
{
validate: ({ symbol, pendingSignal, activePositions }) => {
const existingPosition = activePositions.find(p => p.signal.symbol === symbol);
if (existingPosition && existingPosition.signal.position !== pendingSignal.position) {
throw new Error(`Cannot open ${pendingSignal.position} on ${symbol} - existing ${existingPosition.signal.position} position conflicts`);
}
},
note: "Prevent opposing positions on same symbol (no hedging)"
}
Execution Rules:
awaited before proceedingValidation functions communicate rejection by throwing errors:
{
validate: ({ activePositionCount }) => {
if (activePositionCount >= 10) {
// Error message appears in logs and rejection events
throw new Error("Portfolio limit: 10 concurrent positions maximum");
}
// No throw = validation passes
}
}
Error Message Best Practices:
"activePositionCount=11")"maximum 10")Subscribe to riskSubject to monitor rejected signals:
import { listenRisk } from "backtest-kit";
listenRisk((event) => {
console.log(`[RISK REJECTED] ${event.symbol}`);
console.log(`Strategy: ${event.strategyName}`);
console.log(`Position: ${event.pendingSignal.position}`);
console.log(`Active positions: ${event.activePositionCount}`);
console.log(`Reason: ${event.comment}`); // From validation note or "N/A"
console.log(`Price: ${event.currentPrice}`);
});
RiskContract Structure:
| Property | Type | Description |
|---|---|---|
symbol |
string |
Trading pair |
strategyName |
string |
Strategy that generated signal |
exchangeName |
string |
Exchange identifier |
pendingSignal |
ISignalDto |
Rejected signal details |
currentPrice |
number |
VWAP at rejection time |
activePositionCount |
number |
Position count at rejection |
comment |
string |
Validation note or error message |
timestamp |
number |
Rejection timestamp (ms) |
Validations can be async for external lookups:
{
validate: async ({ symbol, pendingSignal }) => {
// Example: Check external API for symbol volatility
const volatility = await fetch(`https://api.example.com/volatility/${symbol}`)
.then(r => r.json())
.then(d => d.volatility);
const MAX_VOLATILITY = 0.05; // 5%
if (volatility > MAX_VOLATILITY) {
throw new Error(`High volatility: ${(volatility * 100).toFixed(2)}% (max ${(MAX_VOLATILITY * 100).toFixed(2)}%)`);
}
},
note: "Block signals during high volatility periods"
}
Async Considerations:
awaited sequentiallyLifecycle Integration Points:
addSignal() - rejected signals never enter _activePositionsMap_activePositionsMap and persisted atomicallyactivePositionCount in future validations_activePositionsMap via removeSignal()_activePositionsMap on restartTest validation functions in isolation:
import { test } from "worker-testbed";
const myValidation = {
validate: ({ activePositionCount }) => {
if (activePositionCount >= 3) {
throw new Error("Max 3 positions");
}
},
note: "Position limit test"
};
test("Validation rejects when limit reached", async ({ pass, fail }) => {
const payload = {
symbol: "BTCUSDT",
pendingSignal: {
position: "long",
priceTakeProfit: 42000,
priceStopLoss: 40000,
minuteEstimatedTime: 60
},
strategyName: "test-strategy",
exchangeName: "test-exchange",
currentPrice: 41000,
timestamp: Date.now(),
activePositionCount: 3, // At limit
activePositions: []
};
try {
await myValidation.validate(payload);
fail("Should have thrown error");
} catch (error) {
if (error.message.includes("Max 3 positions")) {
pass("Validation rejected correctly");
} else {
fail(`Unexpected error: ${error.message}`);
}
}
});
Test validations with full backtest execution:
import { addRisk, addStrategy, addExchange, addFrame, Backtest, listenRisk } from "backtest-kit";
import { Subject } from "functools-kit";
test("Risk validation blocks signal in backtest", async ({ pass, fail }) => {
let rejectionCount = 0;
addRisk({
riskName: "test-risk",
validations: [
{
validate: ({ currentPrice, pendingSignal }) => {
// Reject signals below 42000
if (currentPrice < 42000) {
throw new Error(`Price too low: ${currentPrice}`);
}
},
note: "Min price validation"
}
]
});
addStrategy({
strategyName: "test-strategy",
interval: "1m",
riskName: "test-risk",
getSignal: async () => ({
position: "long",
priceTakeProfit: 43000,
priceStopLoss: 40000,
minuteEstimatedTime: 60
})
});
// ... add exchange and frame ...
const awaitSubject = new Subject();
listenRisk((event) => {
rejectionCount++;
if (event.comment.includes("Min price validation")) {
awaitSubject.next();
}
});
Backtest.background("BTCUSDT", {
strategyName: "test-strategy",
exchangeName: "test-exchange",
frameName: "test-frame"
});
await awaitSubject.toPromise();
if (rejectionCount > 0) {
pass(`Validation rejected ${rejectionCount} signals`);
} else {
fail("No rejections detected");
}
});
Restrict trading to specific hours:
{
validate: ({ timestamp }) => {
const date = new Date(timestamp);
const hour = date.getUTCHours();
const MARKET_OPEN = 9; // 09:00 UTC
const MARKET_CLOSE = 16; // 16:00 UTC
if (hour < MARKET_OPEN || hour >= MARKET_CLOSE) {
throw new Error(`Outside trading hours: ${hour}:00 UTC (${MARKET_OPEN}:00-${MARKET_CLOSE}:00 only)`);
}
},
note: "Restrict trading to 09:00-16:00 UTC"
}
Prevent correlated positions:
{
validate: ({ symbol, activePositions }) => {
// Define correlated pairs
const correlations = {
"BTCUSDT": ["ETHUSDT", "BNBUSDT"],
"ETHUSDT": ["BTCUSDT", "MATICUSDT"],
// ... more correlations
};
const correlated = correlations[symbol] || [];
const hasCorrelatedPosition = activePositions.some(p =>
correlated.includes(p.signal.symbol)
);
if (hasCorrelatedPosition) {
throw new Error(`Correlated position exists - cannot open ${symbol}`);
}
},
note: "Prevent correlated positions for diversification"
}
Enforce stricter stop loss during high volatility:
{
validate: ({ pendingSignal, currentPrice, activePositions }) => {
const { priceOpen = currentPrice, priceStopLoss, position } = pendingSignal;
// Calculate average position size to estimate volatility
const avgPositionTime = activePositions.length > 0
? activePositions.reduce((sum, p) => sum + (Date.now() - p.openTimestamp), 0) / activePositions.length
: 0;
const avgMinutes = avgPositionTime / 60000;
const isHighVolatility = avgMinutes < 30; // Positions closing fast = high volatility
const slDistance = position === "long"
? ((priceOpen - priceStopLoss) / priceOpen) * 100
: ((priceStopLoss - priceOpen) / priceOpen) * 100;
const maxSL = isHighVolatility ? 2.0 : 5.0; // Tighter SL during volatility
if (slDistance > maxSL) {
throw new Error(`SL too wide: ${slDistance.toFixed(2)}% (max ${maxSL}% in ${isHighVolatility ? 'high' : 'normal'} volatility)`);
}
},
note: "Enforce tighter stop loss during high volatility"
}
Ensure no single strategy dominates:
{
validate: ({ strategyName, activePositions, activePositionCount }) => {
if (activePositionCount < 3) return; // Allow until 3 positions
const strategyPositions = activePositions.filter(p => p.strategyName === strategyName);
const newCount = strategyPositions.length + 1; // Including this signal
const newPercent = (newCount / (activePositionCount + 1)) * 100;
const MAX_PERCENT = 50;
if (newPercent > MAX_PERCENT) {
throw new Error(`Strategy ${strategyName} would be ${newPercent.toFixed(0)}% of portfolio (max ${MAX_PERCENT}%)`);
}
},
note: "No strategy exceeds 50% of portfolio"
}
Strategies can use multiple risk profiles by specifying riskList:
addRisk({
riskName: "position-limit",
validations: [
({ activePositionCount }) => {
if (activePositionCount >= 5) {
throw new Error("Portfolio limit: 5 positions");
}
}
]
});
addRisk({
riskName: "risk-reward",
validations: [
({ pendingSignal, currentPrice }) => {
const { priceOpen = currentPrice, priceTakeProfit, priceStopLoss, position } = pendingSignal;
const reward = position === "long" ? priceTakeProfit - priceOpen : priceOpen - priceTakeProfit;
const risk = position === "long" ? priceOpen - priceStopLoss : priceStopLoss - priceOpen;
if (reward / risk < 2) {
throw new Error("Minimum 2:1 R/R required");
}
}
]
});
addStrategy({
strategyName: "my-strategy",
interval: "5m",
riskList: ["position-limit", "risk-reward"], // Both applied
getSignal: async (symbol, when) => {
// ... signal generation ...
}
});
Execution Order: All risk profiles in riskList are applied sequentially. Signal must pass all validations from all risk profiles.
Validations execute on every signal generation attempt:
// BAD: Expensive operation on every validation
{
validate: async ({ symbol }) => {
// This queries database on EVERY signal check!
const marketData = await database.query(`SELECT * FROM market_data WHERE symbol = ?`, [symbol]);
// ... validation logic ...
}
}
// GOOD: Cache expensive data
const marketDataCache = new Map();
{
validate: async ({ symbol }) => {
if (!marketDataCache.has(symbol)) {
const data = await database.query(`SELECT * FROM market_data WHERE symbol = ?`, [symbol]);
marketDataCache.set(symbol, data);
setTimeout(() => marketDataCache.delete(symbol), 60000); // 1min TTL
}
const marketData = marketDataCache.get(symbol);
// ... validation logic ...
}
}
Order validations from cheapest to most expensive:
validations: [
// Fast: Simple numeric comparison
({ activePositionCount }) => {
if (activePositionCount >= 10) throw new Error("Position limit");
},
// Medium: Array iteration
({ symbol, activePositions }) => {
const count = activePositions.filter(p => p.signal.symbol === symbol).length;
if (count >= 2) throw new Error("Symbol limit");
},
// Expensive: Async API call (runs only if previous checks pass)
async ({ symbol }) => {
const data = await externalAPI.check(symbol);
if (!data.allowed) throw new Error("External validation failed");
}
]
Rationale: Short-circuit on failure stops execution early, avoiding expensive operations for signals that would fail cheap checks.
ClientRisk instances are memoized per risk profile and execution context:
// Internal: StrategyConnectionService
const getStrategy = memoize((symbol: string, strategyName: string, backtest: boolean) => {
// Single ClientStrategy instance per (symbol, strategyName, backtest) tuple
return new ClientStrategy({...});
});
// Internal: RiskConnectionService
const getRisk = memoize((riskName: string, backtest: boolean) => {
// Single ClientRisk instance per (riskName, backtest) tuple
return new ClientRisk({...});
});
Implications:
_activePositionsMap is shared across strategies in same risk profileClientRisk instances (different backtest flag)Listen to validationSubject for raw validation errors:
import { listenValidation } from "backtest-kit";
listenValidation((error) => {
console.error("[VALIDATION ERROR]", error.message);
console.error(error.stack);
});
Use listenRisk to see full rejection context:
import { listenRisk } from "backtest-kit";
listenRisk((event) => {
console.log("=== RISK REJECTION ===");
console.log("Symbol:", event.symbol);
console.log("Strategy:", event.strategyName);
console.log("Position:", event.pendingSignal.position);
console.log("Current Price:", event.currentPrice);
console.log("Active Positions:", event.activePositionCount);
console.log("Reason:", event.comment);
console.log("Timestamp:", new Date(event.timestamp).toISOString());
console.log("======================");
});
Include rich error messages with context:
{
validate: ({ pendingSignal, currentPrice, activePositionCount }) => {
if (activePositionCount >= 5) {
throw new Error(
`Position limit exceeded:\n` +
` Active: ${activePositionCount}\n` +
` Limit: 5\n` +
` Signal: ${pendingSignal.position} on ${pendingSignal.symbol}\n` +
` Price: ${currentPrice}`
);
}
},
note: "Portfolio position limit (5 max)"
}
Risk Validations and Strategy Callbacks serve different purposes:
| Aspect | Risk Validations | Strategy Callbacks |
|---|---|---|
| Purpose | Gate-keep signal opening | React to lifecycle events |
| Timing | Before position opens | After state changes |
| Control | Can block signals (throw) | Cannot block (informational) |
| Scope | Portfolio-level (cross-strategy) | Strategy-level (single strategy) |
| Return | void or throw |
void (no effect on flow) |
| Examples | Position limits, R/R checks | Logging, notifications, analytics |
When to Use:
This document was informed by the following source files:
IRiskCheckArgs, IRiskValidationPayload, IRiskValidation, IRiskSchema)listenRisk, listenRiskOnce)riskSubject emitter definition