This document covers the risk management system in backtest-kit, which provides portfolio-level controls to prevent signals that violate configured limits. The system tracks active positions across strategies, executes custom validation logic, and persists state for crash recovery.
For information about risk profiles in strategy schemas, see Component Types. For risk validation callback configuration, see Strategy Schemas.
The risk management system prevents signal generation when portfolio conditions violate defined limits. Key capabilities:
Architecture Flow
Risk profiles are registered via addRisk() and define validation logic through custom functions.
IRiskSchema Structure
| Field | Type | Description |
|---|---|---|
riskName |
RiskName (string) |
Unique identifier for the risk profile |
note |
string (optional) |
Developer documentation |
validations |
Array<IRiskValidation | IRiskValidationFn> |
Custom validation functions array |
callbacks |
Partial<IRiskCallbacks> (optional) |
Event callbacks (onRejected, onAllowed) |
Registration Example
addRisk({
riskName: "conservative",
note: "Maximum 3 concurrent positions",
validations: [
{
validate: ({ activePositionCount }) => {
if (activePositionCount >= 3) {
throw new Error("Max 3 positions exceeded");
}
},
note: "Limit concurrent positions"
}
],
callbacks: {
onRejected: (symbol, params) => {
console.log(`Signal rejected for ${symbol}`);
}
}
});
Validation Function Types
Validations can be provided as:
(payload: IRiskValidationPayload) => void | Promise<void>{ validate: IRiskValidationFn, note?: string }Both throw errors to reject signals. The object form allows documentation via note field.
Risk validation occurs before signal creation. The process executes all custom validations with full portfolio state access.
Validation Payload Structure
Validation Execution Process
The validation flow in ClientRisk.checkSignal:165-217 follows this sequence:
waitForInit()onRejected or onAllowed based on resulttrue if allowed, false if rejectedError Handling
Validations wrapped by DO_VALIDATION_FN src/client/ClientRisk.ts:31-46:
falseLoggerServicevalidationSubject for monitoringClientRisk tracks active positions across all strategies sharing the same risk profile. Positions are identified by ${strategyName}:${symbol} keys.
Position Lifecycle
Active Position Map
The _activePositions field src/client/ClientRisk.ts:79 uses a discriminated type:
POSITION_NEED_FETCH symbolMap<string, IRiskActivePosition>Key generation via GET_KEY_FN src/client/ClientRisk.ts:27-28:
const GET_KEY_FN = (strategyName: string, symbol: string) =>
`${strategyName}:${symbol}`;
Position Data Structure
interface IRiskActivePosition {
signal: ISignalRow; // Signal details (null in current implementation)
strategyName: string; // Strategy owning position
exchangeName: string; // Exchange name
openTimestamp: number; // Unix timestamp when opened
}
addSignal Flow
ClientRisk.addSignal:107-128 registers a new position:
${strategyName}:${symbol}_activePositions Map_updatePositions()removeSignal Flow
ClientRisk.removeSignal:134-150 removes a closed position:
${strategyName}:${symbol}_activePositions Map_updatePositions()Isolation by Risk Profile
Each ClientRisk instance has its own _activePositions Map. Multiple strategies can share a risk profile to enable cross-strategy position limits.
Risk positions are persisted via PersistRiskAdapter for crash recovery in live mode. Backtest mode skips persistence.
PersistRiskAdapter Architecture
Persistence Operations
| Operation | Method | Description |
|---|---|---|
| Write | PersistRiskAdapter.writePositionData() |
Converts Map to Array, writes to disk |
| Read | PersistRiskAdapter.readPositionData() |
Reads from disk, returns Array for Map conversion |
| Initialize | WAIT_FOR_INIT_FN() |
Restores positions on first use via singleshot |
Crash Recovery Pattern
The initialization pattern 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);
};
Wrapped with singleshot src/client/ClientRisk.ts:88 to ensure initialization happens exactly once.
Risk management operates through a three-layer service architecture with explicit context propagation.
Service Dependency Graph
Layer Responsibilities
| Layer | Class | Responsibility |
|---|---|---|
| Global | RiskGlobalService |
Validation orchestration, public API delegation |
| Connection | RiskConnectionService |
Memoized ClientRisk instance management |
| Client | ClientRisk |
Position tracking, validation execution |
| Schema | RiskSchemaService |
Schema storage and retrieval |
| Validation | RiskValidationService |
Schema validation checks |
Memoization Pattern
RiskConnectionService.getRisk:56-65 memoizes ClientRisk instances by riskName:
public getRisk = memoize(
([riskName]) => `${riskName}`,
(riskName: RiskName) => {
const schema = this.riskSchemaService.get(riskName);
return new ClientRisk({
...schema,
logger: this.loggerService,
});
}
);
Ensures one ClientRisk instance per risk profile, enabling shared position tracking across strategies.
Maximum Concurrent Positions
addRisk({
riskName: "max-positions",
validations: [
({ activePositionCount }) => {
if (activePositionCount >= 5) {
throw new Error("Maximum 5 concurrent positions");
}
}
]
});
Symbol Filtering
addRisk({
riskName: "symbol-filter",
validations: [
({ symbol }) => {
if (symbol === "DOGEUSDT") {
throw new Error("DOGE trading not allowed");
}
}
]
});
Per-Strategy Position Limits
addRisk({
riskName: "per-strategy-limit",
validations: [
({ activePositions, strategyName }) => {
const strategyPositions = activePositions.filter(
pos => pos.strategyName === strategyName
);
if (strategyPositions.length >= 2) {
throw new Error(`${strategyName}: max 2 positions`);
}
}
]
});
Time-Based Restrictions
addRisk({
riskName: "time-filter",
validations: [
({ timestamp }) => {
const hour = new Date(timestamp).getHours();
if (hour < 9 || hour > 16) {
throw new Error("Trading hours: 9:00-16:00");
}
}
]
});
Risk checks integrate into the signal generation flow via the riskName field in IStrategySchema.
Strategy Registration with Risk
addStrategy({
strategyName: "my-strategy",
interval: "1m",
riskName: "conservative", // Links to risk profile
getSignal: async (symbol) => {
// Signal generation logic
return {
position: "long",
priceTakeProfit: 51000,
priceStopLoss: 49000,
minuteEstimatedTime: 60
};
}
});
Risk Check Invocation
The strategy execution flow calls risk check before signal creation:
ClientStrategy.tick() calls getSignal()riskGlobalService.checkSignal()onRejected callbackriskGlobalService.addSignal()riskGlobalService.removeSignal()Callback Integration
addRisk({
riskName: "monitored",
validations: [/* ... */],
callbacks: {
onRejected: (symbol, params) => {
console.log(`Risk rejected: ${symbol} for ${params.strategyName}`);
// Custom monitoring/alerting logic
},
onAllowed: (symbol, params) => {
console.log(`Risk approved: ${symbol} for ${params.strategyName}`);
}
}
});
The IRiskValidationPayload interface provides complete portfolio state to validation functions.
Field Reference
| Field | Type | Source | Description |
|---|---|---|---|
symbol |
string |
IRiskCheckArgs | Trading pair symbol |
strategyName |
StrategyName |
IRiskCheckArgs | Strategy requesting position |
exchangeName |
ExchangeName |
IRiskCheckArgs | Exchange name |
currentPrice |
number |
IRiskCheckArgs | Current VWAP price |
timestamp |
number |
IRiskCheckArgs | Current timestamp |
activePositionCount |
number |
Computed | Total active positions |
activePositions |
IRiskActivePosition[] |
Computed | Array of all active positions |
IRiskActivePosition Fields
| Field | Type | Description |
|---|---|---|
signal |
ISignalRow |
Signal details (currently null) |
strategyName |
string |
Strategy owning the position |
exchangeName |
string |
Exchange name |
openTimestamp |
number |
Unix timestamp when opened |
Test Coverage Areas
addSignal() and removeSignal() accuracyPersistRiskAdapteronRejected and onAllowed invocationKey Test Patterns
From test/spec/risk.test.mjs:41-93:
From test/spec/risk.test.mjs:374-437: