This page documents the IRiskSchema structure and validation function system used to implement portfolio-level risk controls in backtest-kit. Risk profiles enable custom validation logic including maximum concurrent position limits, risk/reward ratio requirements, symbol filtering, and cross-position analysis. Multiple strategies can share a single risk profile by referencing the same riskName. For risk validation execution flow, see Risk Validation. For position tracking implementation details, see Position Tracking. For risk schema registration API, see Risk Schemas.
Risk profiles provide portfolio-level risk management through custom validation functions. A risk profile is identified by a unique riskName and contains a validations array where each function can reject signals by throwing errors. Common validation patterns include:
activePositionCountriskNameRisk profiles are shared constraints that multiple strategies opt into by specifying riskName in their schema. They are not per-strategy limits.
The IRiskSchema interface defines a risk profile registered via addRisk():
interface IRiskSchema {
riskName: RiskName; // Unique identifier
note?: string; // Optional documentation
callbacks?: Partial<IRiskCallbacks>; // onRejected, onAllowed
validations: (IRiskValidation | IRiskValidationFn)[]; // Custom validation logic
}
Key Components:
| Field | Type | Purpose |
|---|---|---|
riskName |
string |
Unique identifier for this risk profile (e.g., "conservative", "aggressive") |
note |
string (optional) |
Developer documentation explaining risk profile purpose |
callbacks |
Partial<IRiskCallbacks> (optional) |
Event handlers for rejected/allowed signals |
validations |
Array |
Custom validation functions that throw errors to reject signals |
Validation Array Format:
Validations can be provided as:
(payload: IRiskValidationPayload) => void | Promise<void>{ validate: Function, note?: string } for documentationEach riskName creates an isolated risk profile with its own:
Diagram: Risk Profile Isolation Architecture
Multiple strategies sharing riskName: "conservative" all contribute to the same activePositionCount. Strategies using different risk profiles have independent position tracking.
Validation functions receive IRiskValidationPayload and throw errors to reject signals:
interface IRiskValidationPayload extends IRiskCheckArgs {
activePositionCount: number; // Total positions in this risk profile
activePositions: IRiskActivePosition[]; // Full position details
// From IRiskCheckArgs:
symbol: string;
strategyName: StrategyName;
exchangeName: ExchangeName;
currentPrice: number;
timestamp: number;
}
Enforces portfolio-wide position limits using activePositionCount from the validation payload. This is the most common risk control pattern.
addRisk({
riskName: "max-5-positions",
validations: [
({ activePositionCount }) => {
if (activePositionCount >= 5) {
throw new Error("Maximum 5 concurrent positions exceeded");
}
}
]
});
Implementation Details:
activePositionCount reflects all positions across strategies sharing this riskNameaddSignal() from being calledValidates minimum reward-to-risk ratios to ensure favorable trade setups. Commonly requires at least 1:2 risk/reward (risking $1 to make $2).
addRisk({
riskName: "min-2-to-1-rr",
validations: [
{
validate: ({ pendingSignal, currentPrice }) => {
const { priceOpen = currentPrice, priceTakeProfit, priceStopLoss, position } = pendingSignal;
// Calculate reward (TP distance)
const reward = position === "long"
? priceTakeProfit - priceOpen
: priceOpen - priceTakeProfit;
// Calculate risk (SL distance)
const risk = position === "long"
? priceOpen - priceStopLoss
: priceStopLoss - priceOpen;
if (risk <= 0) {
throw new Error("Invalid SL: risk must be positive");
}
const rrRatio = reward / risk;
if (rrRatio < 2) {
throw new Error(`RR ratio ${rrRatio.toFixed(2)} < 2:1 minimum`);
}
},
note: "Requires minimum 1:2 risk/reward ratio"
}
]
});
Calculation Logic:
reward = priceTakeProfit - priceOpen, risk = priceOpen - priceStopLossreward = priceOpen - priceTakeProfit, risk = priceStopLoss - priceOpenrrRatio = reward / risk (must be >= 2 for 1:2 requirement)Blocks trading on specific symbols or enforces per-symbol concentration limits.
addRisk({
riskName: "symbol-controls",
validations: [
// Block high-volatility symbols
({ symbol }) => {
const blockedSymbols = ["DOGEUSDT", "SHIBAINU"];
if (blockedSymbols.includes(symbol)) {
throw new Error(`${symbol} blocked due to volatility`);
}
},
// Limit positions per symbol
({ activePositions, symbol }) => {
const symbolCount = activePositions.filter(p => p.signal.symbol === symbol).length;
if (symbolCount >= 2) {
throw new Error(`Already have ${symbolCount} positions on ${symbol}`);
}
}
]
});
Analyzes relationships between active positions for advanced portfolio management.
addRisk({
riskName: "diversified",
validations: [
({ activePositions, pendingSignal }) => {
// Count long vs short positions
const longCount = activePositions.filter(p => p.signal.position === "long").length;
const shortCount = activePositions.filter(p => p.signal.position === "short").length;
// Enforce balance
if (pendingSignal.position === "long" && longCount - shortCount >= 3) {
throw new Error("Too many long positions relative to shorts");
}
}
]
});
Uses IRiskValidation object format to include documentation with validation logic.
addRisk({
riskName: "complex",
validations: [
{
validate: async ({ activePositionCount, currentPrice }) => {
if (activePositionCount >= 10 && currentPrice > 50000) {
throw new Error("Too many high-value positions");
}
},
note: "Prevents excessive exposure during high-price periods"
}
]
});
Diagram: Risk Validation and Position Tracking Flow
The DO_VALIDATION_FN wrapper catches errors and converts them to false return values, preventing exceptions from propagating. Validation errors are also emitted to validationSubject for observability.
ClientRisk tracks positions using Map<string, IRiskActivePosition> with keys generated by GET_KEY_FN:
const GET_KEY_FN = (strategyName: string, symbol: string) => `${strategyName}:${symbol}`;
Key Structure: strategyName:symbol (e.g., "momentum-strategy:BTCUSDT")
This allows:
Diagram: Position Tracking State Machine
interface IRiskActivePosition {
signal: ISignalRow; // Signal details (id, prices, timestamps)
strategyName: string; // Owning strategy
exchangeName: string; // Exchange name
openTimestamp: number; // When position opened (Date.now())
}
Note: The signal field is stored as null in the actual implementation (src/client/ClientRisk.ts:121) since detailed signal information isn't needed for risk validation - only counts and keys matter.
Diagram: Cross-Strategy Position Limit Enforcement
When macd-long attempts to open a new signal, it sees positions from rsi-long and macd-short because they all share riskName: "shared-5". The validation activePositionCount >= 5 checks the combined count across all strategies.
| Approach | Use Case | Position Count |
|---|---|---|
| One riskName per strategy | Independent limits per strategy | Each strategy has its own activePositionCount |
| Shared riskName | Portfolio-level limit | All strategies contribute to same activePositionCount |
| Multiple risk profiles | Different risk tiers (conservative, aggressive) | Separate tracking per risk profile |
Example:
// Isolated risk per strategy
addStrategy({ strategyName: "strat-1", riskName: "risk-1" }); // max 5 positions
addStrategy({ strategyName: "strat-2", riskName: "risk-2" }); // max 5 positions
// Total possible positions: 10 (5 + 5)
// Shared risk across strategies
addStrategy({ strategyName: "strat-1", riskName: "shared" }); // max 5 positions
addStrategy({ strategyName: "strat-2", riskName: "shared" }); // max 5 positions
// Total possible positions: 5 (shared limit)
PersistRiskAdapter provides crash-safe position tracking for live trading:
// Position data format
type RiskData = Array<[string, IRiskActivePosition]>;
// Adapter methods
PersistRiskAdapter.writePositionData(positions, riskName);
PersistRiskAdapter.readPositionData(riskName);
File Location: risk-{riskName}.json (configurable via custom adapter)
Isolation: Each riskName has its own persistence file, ensuring data isolation between risk profiles.
Diagram: Crash Recovery Initialization Flow
The singleshot pattern ensures waitForInit() only executes once per ClientRisk instance, even if called multiple times concurrently.
Callbacks provide observability into risk decisions:
interface IRiskCallbacks {
onRejected: (symbol: string, params: IRiskCheckArgs) => void;
onAllowed: (symbol: string, params: IRiskCheckArgs) => void;
}
addRisk({
riskName: "monitored",
validations: [
({ activePositionCount }) => {
if (activePositionCount >= 3) {
throw new Error("Max 3 positions");
}
}
],
callbacks: {
onRejected: (symbol, params) => {
console.log(`Signal rejected for ${symbol}:`, params);
// Log to monitoring system, send alert, etc.
},
onAllowed: (symbol, params) => {
console.log(`Signal allowed for ${symbol}:`, params);
// Track allowed signals for analytics
}
}
});
Callback Execution Points:
This example demonstrates a comprehensive risk profile combining max concurrent positions, risk/reward ratio validation, symbol filtering, and observability callbacks.
import { addRisk, addStrategy } from "backtest-kit";
// Define shared risk profile
addRisk({
riskName: "portfolio-conservative",
note: "Conservative risk with max 5 positions, 1:2 RR, and symbol limits",
validations: [
{
validate: ({ activePositionCount }) => {
if (activePositionCount >= 5) {
throw new Error("Maximum 5 concurrent positions");
}
},
note: "Portfolio-level position limit"
},
{
validate: ({ pendingSignal, currentPrice }) => {
const { priceOpen = currentPrice, priceTakeProfit, priceStopLoss, position } = pendingSignal;
// Calculate risk/reward ratio
const reward = position === "long"
? priceTakeProfit - priceOpen
: priceOpen - priceTakeProfit;
const risk = position === "long"
? priceOpen - priceStopLoss
: priceStopLoss - priceOpen;
if (risk <= 0) {
throw new Error("Invalid SL: risk must be positive");
}
const rrRatio = reward / risk;
if (rrRatio < 2) {
throw new Error(`RR ratio ${rrRatio.toFixed(2)} < 2:1 minimum`);
}
},
note: "Minimum 1:2 risk/reward ratio"
},
{
validate: ({ activePositions, symbol }) => {
const symbolPositions = activePositions.filter(
p => p.signal.symbol === symbol
);
if (symbolPositions.length >= 2) {
throw new Error(`Already have ${symbolPositions.length} positions on ${symbol}`);
}
},
note: "Per-symbol concentration limit (max 2)"
},
({ symbol }) => {
// Block high-volatility symbols
const volatileSymbols = ["DOGEUSDT", "SHIBAINU"];
if (volatileSymbols.includes(symbol)) {
throw new Error(`Symbol ${symbol} blocked due to volatility`);
}
}
],
callbacks: {
onRejected: (symbol, params) => {
console.warn(`Risk rejected ${symbol} for ${params.strategyName}`);
// Log to monitoring system, send alerts, etc.
},
onAllowed: (symbol, params) => {
console.log(`Risk allowed ${symbol} for ${params.strategyName}`);
// Track approved signals for analytics
}
}
});
// Multiple strategies share this risk profile
addStrategy({
strategyName: "momentum-long",
riskName: "portfolio-conservative", // Shares all validation rules
interval: "5m",
getSignal: async (symbol, when) => {
// Strategy logic
}
});
addStrategy({
strategyName: "mean-reversion-short",
riskName: "portfolio-conservative", // Shares all validation rules
interval: "15m",
getSignal: async (symbol, when) => {
// Strategy logic
}
});
Validation Execution Order:
If any validation throws an error, the signal is immediately rejected and onRejected callback fires.
riskName share position limitsaddSignal/removeSignal lifecycle