This document describes the risk management schema system in backtest-kit. Risk schemas define portfolio-level risk controls that prevent signals from violating configured limits. Multiple strategies can share the same risk profile, enabling cross-strategy risk analysis.
The IRiskSchema interface specifies four core fields: riskName (unique identifier), validations (array of validation functions), callbacks (lifecycle event hooks), and optional note (documentation). Position limits like maximum concurrent positions are implemented as custom validation functions, not as direct schema fields.
For information about strategy schemas (which reference risk schemas), see page 5.1. For information about the ClientRisk implementation that executes risk logic, see page 6.4.
Risk schemas provide portfolio-level risk management by:
activePositionCount and activePositions in validation payloadIRiskValidationPayloadClientRisk instancesRisk schemas are registered via addRisk() and referenced by strategies via the riskName or riskList fields in IStrategySchema. The validation functions throw errors to reject signals or return normally to allow them.
Diagram: Risk Schema Type Hierarchy
The IRiskSchema interface consists of:
| Field | Type | Required | Description |
|---|---|---|---|
riskName |
RiskName (string) |
Yes | Unique identifier for the risk profile |
note |
string |
No | Optional developer documentation for the risk profile |
validations |
(IRiskValidation | IRiskValidationFn)[] |
Yes | Array of validation functions (direct functions or objects with validate + note) |
callbacks |
Partial<IRiskCallbacks> |
No | Optional lifecycle event hooks (onRejected, onAllowed) |
Key Points:
validate function and note documentationIRiskValidationPayload with portfolio state (activePositionCount, activePositions)IRiskCheckArgs (without portfolio state additions)Risk validations can be provided in two forms:
1. Function form (direct validation function):
addRisk({
riskName: "my-risk",
validations: [
async ({ activePositionCount }) => {
if (activePositionCount >= 5) {
throw new Error("Max 5 positions");
}
}
]
});
2. Object form (with documentation):
addRisk({
riskName: "my-risk",
validations: [
{
validate: async ({ activePositionCount }) => {
if (activePositionCount >= 5) {
throw new Error("Max 5 positions");
}
},
note: "Limit concurrent positions to 5"
}
]
});
Validation functions receive a payload with all risk check context:
| Field | Type | Description |
|---|---|---|
symbol |
string |
Trading pair (e.g., "BTCUSDT") |
pendingSignal |
ISignalDto |
Signal attempting to open (priceOpen, priceTakeProfit, priceStopLoss, position, etc.) |
strategyName |
StrategyName |
Strategy requesting position |
exchangeName |
ExchangeName |
Exchange name |
currentPrice |
number |
Current VWAP price |
timestamp |
number |
Current timestamp in milliseconds |
activePositionCount |
number |
Number of active positions across all strategies using this risk profile |
activePositions |
IRiskActivePosition[] |
List of all active positions with full details |
Portfolio State Access:
activePositions array provides access to all currently open positions across all strategies sharing this risk profileIRiskActivePosition entry includes the signal details, strategy name, exchange name, and open timestampactivePositionCount is simply activePositions.length for conveniencependingSignal contains the signal attempting to open, allowing price/position validationDiagram: Risk Validation Execution Flow
Validation execution follows this pattern:
DO_VALIDATION_FN with automatic error catching (src/client/ClientRisk.ts:31-46)onRejected or onAllowed based on resultClientRisk maintains a Map<string, IRiskActivePosition> to track active positions across all strategies:
// Key format: `${strategyName}:${symbol}`
// Example: "my-strategy:BTCUSDT"
_activePositions: Map<string, IRiskActivePosition>
The map stores position metadata without full signal details:
interface IRiskActivePosition {
signal: ISignalRow; // Signal details (set to null in practice)
strategyName: string; // Strategy that opened position
exchangeName: string; // Exchange name (empty in practice)
openTimestamp: number; // Timestamp when position opened
}
Diagram: Position Tracking Lifecycle
Position tracking operations:
addSignal(): Called by StrategyConnectionService after signal opens (src/client/ClientRisk.ts:107-128)
GET_KEY_FN(strategyName, symbol)_activePositions Map_updatePositions()removeSignal(): Called when signal closes (src/client/ClientRisk.ts:134-150)
checkSignal(): Exposes position count and list to validations (src/client/ClientRisk.ts:165-217)
activePositionCount = riskMap.sizeactivePositions = Array.from(riskMap.values())Each riskName maintains independent position tracking:
Diagram: Risk Profile Isolation via Memoization
Each risk profile gets its own ClientRisk instance via memoization in RiskConnectionService.getRisk(). The memoization key is the riskName, ensuring complete isolation between risk profiles.
interface IRiskCallbacks {
/** Called when signal rejected due to risk limits */
onRejected: (symbol: string, params: IRiskCheckArgs) => void;
/** Called when signal passes risk checks */
onAllowed: (symbol: string, params: IRiskCheckArgs) => void;
}
Callbacks are invoked after all validations complete:
Both callbacks receive the original IRiskCheckArgs (without portfolio state additions).
Example usage:
addRisk({
riskName: "monitored-risk",
validations: [
({ activePositionCount }) => {
if (activePositionCount >= 3) {
throw new Error("Max 3 positions");
}
}
],
callbacks: {
onRejected: (symbol, params) => {
console.log(`[RISK REJECTED] ${symbol} by ${params.strategyName}`);
},
onAllowed: (symbol, params) => {
console.log(`[RISK ALLOWED] ${symbol} by ${params.strategyName}`);
}
}
});
Diagram: Risk Schema Registration Flow
The addRisk() function performs two operations:
riskName exists (src/function/add.ts:332-336)RiskSchemaService (src/function/add.ts:337-340)Example registration:
import { addRisk } from "backtest-kit";
addRisk({
riskName: "conservative",
note: "Conservative risk management with tight position limits",
validations: [
{
validate: ({ activePositionCount }) => {
if (activePositionCount >= 5) {
throw new Error("Maximum 5 concurrent positions allowed");
}
},
note: "Limit concurrent positions to 5"
},
({ symbol }) => {
if (symbol === "DOGEUSDT") {
throw new Error("DOGE trading not allowed");
}
}
],
callbacks: {
onRejected: (symbol, params) => {
console.log(`Risk rejected signal for ${symbol}`);
},
onAllowed: (symbol, params) => {
console.log(`Risk allowed signal for ${symbol}`);
}
}
});
Strategies reference risk profiles via the riskName field:
import { addStrategy, addRisk } from "backtest-kit";
// Register risk profile first
addRisk({
riskName: "my-risk",
validations: [
({ activePositionCount }) => {
if (activePositionCount >= 3) {
throw new Error("Max 3 positions");
}
}
]
});
// Reference from strategy
addStrategy({
strategyName: "my-strategy",
interval: "5m",
riskName: "my-risk", // Reference the risk profile
getSignal: async (symbol) => {
// Strategy logic...
return {
position: "long",
priceTakeProfit: 51000,
priceStopLoss: 49000,
minuteEstimatedTime: 60
};
}
});
Multiple strategies can share the same risk profile, enabling cross-strategy position limits.
Risk position data is persisted to disk in live mode to enable crash recovery:
Diagram: Risk Position Persistence Flow
Write Operation (src/client/ClientRisk.ts:93-101):
private async _updatePositions(): Promise<void> {
if (this._activePositions === POSITION_NEED_FETCH) {
await this.waitForInit();
}
await PersistRiskAdapter.writePositionData(
Array.from(<RiskMap>this._activePositions), // Convert Map to Array
this.params.riskName
);
}
Read Operation (src/client/ClientRisk.ts:53-59):
export const WAIT_FOR_INIT_FN = async (self: ClientRisk): Promise<void> => {
self.params.logger.debug("ClientRisk waitForInit");
const persistedPositions = await PersistRiskAdapter.readPositionData(
self.params.riskName
);
self._activePositions = new Map(persistedPositions); // Convert Array to Map
};
Persisted data is stored as an array of tuples:
type PersistedPositions = Array<[
string, // Key: "${strategyName}:${symbol}"
IRiskActivePosition // Position metadata
]>
This format allows direct conversion to/from the internal Map<string, IRiskActivePosition> structure.
Diagram: Risk Management Service Architecture
The risk system follows the standard service layer pattern:
| Layer | Class | Responsibility |
|---|---|---|
| Public API | addRisk() |
Schema registration entry point |
| Validation | RiskValidationService |
Duplicate checking, schema validation |
| Schema Storage | RiskSchemaService |
ToolRegistry-based schema storage |
| Global Service | RiskGlobalService |
Public API for risk operations |
| Connection | RiskConnectionService |
Memoized ClientRisk instance management |
| Client | ClientRisk |
Business logic: position tracking, validation execution |
| Persistence | PersistRiskAdapter |
Crash-safe file writes for live mode |
addRisk({
riskName: "max-positions",
validations: [
({ activePositionCount }) => {
if (activePositionCount >= 5) {
throw new Error("Maximum 5 concurrent positions allowed");
}
}
]
});
addRisk({
riskName: "symbol-filter",
validations: [
({ symbol }) => {
const blacklist = ["DOGEUSDT", "SHIBUSDT"];
if (blacklist.includes(symbol)) {
throw new Error(`Trading ${symbol} not allowed`);
}
}
]
});
addRisk({
riskName: "exposure-limits",
validations: [
({ activePositions, symbol }) => {
const symbolPositions = activePositions.filter(
pos => pos.signal.symbol === symbol
);
if (symbolPositions.length >= 2) {
throw new Error(`Max 2 positions per symbol`);
}
}
]
});
addRisk({
riskName: "strategy-limits",
validations: [
({ activePositions, strategyName }) => {
const strategyPositions = activePositions.filter(
pos => pos.strategyName === strategyName
);
if (strategyPositions.length >= 3) {
throw new Error(`Strategy ${strategyName} has max 3 positions`);
}
}
]
});