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.
For information about strategy schemas (which reference risk schemas), see Strategy Schemas. For information about the ClientRisk implementation that executes risk logic, see ClientRisk.
Risk schemas provide portfolio-level risk management by:
Risk schemas are registered via addRisk() and referenced by strategies via the riskName field in IStrategySchema.
The schema consists of:
| Field | Type | Required | Description |
|---|---|---|---|
riskName |
RiskName (string) |
Yes | Unique identifier for the risk profile |
note |
string |
No | Developer documentation for the risk profile |
validations |
(IRiskValidation | IRiskValidationFn)[] |
Yes | Array of validation functions or objects |
callbacks |
Partial<IRiskCallbacks> |
No | Lifecycle event hooks |
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") |
strategyName |
StrategyName |
Strategy requesting position |
exchangeName |
ExchangeName |
Exchange name |
currentPrice |
number |
Current VWAP price |
timestamp |
number |
Current timestamp |
activePositionCount |
number |
Number of active positions |
activePositions |
IRiskActivePosition[] |
List of all active positions |
The activePositions array provides access to all currently open positions across all strategies sharing this risk profile. Each entry includes the signal details, strategy name, exchange name, and open timestamp.
Diagram: 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`);
}
}
]
});