This page documents the risk profile system in backtest-kit, explaining how IRiskSchema structures define portfolio-level risk controls, how risk profiles are isolated by riskName, and how multiple strategies share risk limits through custom validation functions. 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 across multiple strategies. A risk profile is identified by a unique riskName and defines custom validation functions that can reject signals based on active positions, portfolio state, or any custom logic. Multiple strategies can reference the same riskName to share risk limits (e.g., maximum 5 concurrent positions across all strategies using "conservative-risk").
Risk profiles are not per-strategy limits. They are shared constraints that strategies opt into by specifying riskName in their schema.
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;
}
Pattern 1: Limit Concurrent Positions
addRisk({
riskName: "max-5-positions",
validations: [
({ activePositionCount }) => {
if (activePositionCount >= 5) {
throw new Error("Maximum 5 concurrent positions exceeded");
}
}
]
});
Pattern 2: Symbol-Based Filtering
addRisk({
riskName: "no-doge",
validations: [
({ symbol }) => {
if (symbol === "DOGEUSDT") {
throw new Error("DOGE trading not allowed");
}
}
]
});
Pattern 3: Cross-Position Analysis
addRisk({
riskName: "diversified",
validations: [
({ activePositions, symbol }) => {
const symbolCount = activePositions.filter(p => p.signal.symbol === symbol).length;
if (symbolCount >= 2) {
throw new Error(`Already have ${symbolCount} positions on ${symbol}`);
}
}
]
});
Pattern 4: Documented Validation
addRisk({
riskName: "complex",
validations: [
{
validate: async ({ activePositionCount, currentPrice }) => {
// Custom async logic
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:
import { addRisk, addStrategy } from "backtest-kit";
// Define shared risk profile
addRisk({
riskName: "portfolio-conservative",
note: "Conservative risk with max 5 positions and symbol limits",
validations: [
{
validate: ({ activePositionCount }) => {
if (activePositionCount >= 5) {
throw new Error("Maximum 5 concurrent positions");
}
},
note: "Portfolio-level position limit"
},
{
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"
},
({ currentPrice, symbol }) => {
// Block high-risk 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}`);
},
onAllowed: (symbol, params) => {
console.log(`Risk allowed ${symbol} for ${params.strategyName}`);
}
}
});
// Multiple strategies share this risk profile
addStrategy({
strategyName: "momentum-long",
riskName: "portfolio-conservative", // Shares limits
// ... strategy config
});
addStrategy({
strategyName: "mean-reversion-short",
riskName: "portfolio-conservative", // Shares limits
// ... strategy config
});
riskName share position limitsaddSignal/removeSignal lifecycle