The risk management system enforces portfolio-level constraints and validation rules that prevent signals from opening when they violate risk parameters. Risk profiles are defined via addRisk() and referenced by strategies using riskName or riskList. The system tracks active positions across all strategies and evaluates custom validation functions before allowing new signals to open.
For signal lifecycle information, see Signal Lifecycle. For strategy configuration, see Strategy Schemas. For event monitoring, see Event Listeners.
Risk profiles are registered via addRisk() and define a set of validation rules that signals must pass before opening. Each risk profile has a unique riskName identifier.
interface IRiskSchema {
riskName: string; // Unique identifier
note?: string; // Optional documentation
callbacks?: Partial<IRiskCallbacks>; // onRejected, onAllowed
validations: (IRiskValidation | IRiskValidationFn)[]; // Validation chain
}
Strategies reference risk profiles using riskName (single profile) or riskList (multiple profiles that must all pass).
The validations array can contain either IRiskValidation objects (with validate function and optional note) or raw validation functions (IRiskValidationFn).
interface IRiskValidation {
validate: IRiskValidationFn; // Function that throws on failure
note?: string; // Documentation for this validation
}
type IRiskValidationFn = (payload: IRiskValidationPayload) => void | Promise<void>;
Validation functions receive IRiskValidationPayload containing complete context about the signal and portfolio state.
The payload passed to validation functions contains both signal details and portfolio state.
| Field | Type | Description |
|---|---|---|
symbol |
string |
Trading pair (e.g., "BTCUSDT") |
pendingSignal |
ISignalDto |
Signal attempting to open |
strategyName |
StrategyName |
Strategy requesting signal |
exchangeName |
ExchangeName |
Exchange being used |
currentPrice |
number |
Current VWAP price |
timestamp |
number |
Unix timestamp (ms) |
activePositionCount |
number |
Number of currently active positions |
activePositions |
IRiskActivePosition[] |
Details of all active positions |
Validation functions throw Error to reject signals. The error message becomes the rejection reason logged to riskSubject.
The risk validation system integrates with the signal lifecycle at two critical points: signal generation and scheduled signal activation.
Risk validation occurs twice for scheduled signals:
getSignal() returns signal with priceOpen (src/client/ClientStrategy.ts:376-387)priceOpen (src/client/ClientStrategy.ts:712-729)This dual-check prevents race conditions where portfolio state changes between signal creation and activation.
ClientRisk implements the IRisk interface and manages portfolio-wide position tracking.
Active positions are tracked using composite keys:
{symbol}:{strategyName}:{riskName}
Example: "BTCUSDT:my-strategy:demo-risk"
This allows:
riskList)interface IRisk {
checkSignal(params: IRiskCheckArgs): Promise<boolean>;
addSignal(symbol: string, context: { strategyName: string; riskName: string }): Promise<void>;
removeSignal(symbol: string, context: { strategyName: string; riskName: string }): Promise<void>;
}
Active positions are persisted to disk to survive process crashes in live mode.
Position changes are written atomically using PersistRiskAdapter:
{riskName}.json.tmpfsync() to ensure disk write{riskName}.json.tmp → {riskName}.jsonThis prevents partial writes during crashes.
addSignal/removeSignalRisk rejections emit events to riskSubject for monitoring and alerting.
Events emitted to riskSubject contain:
| Field | Type | Description |
|---|---|---|
symbol |
string |
Trading pair |
pendingSignal |
ISignalDto |
Rejected signal |
strategyName |
StrategyName |
Strategy that generated signal |
exchangeName |
ExchangeName |
Exchange being used |
currentPrice |
number |
Current VWAP price |
activePositionCount |
number |
Active positions at rejection time |
comment |
string |
Rejection reason (from validation note or error message) |
timestamp |
number |
Unix timestamp (ms) |
// Continuous monitoring
const unsubscribe = listenRisk((event) => {
console.log(`[RISK] ${event.symbol} rejected: ${event.comment}`);
console.log(`Active positions: ${event.activePositionCount}`);
});
// Wait for first rejection matching filter
listenRiskOnce(
(event) => event.symbol === "BTCUSDT",
(event) => console.log("BTCUSDT signal rejected!")
);
Limit total number of active positions across all strategies:
addRisk({
riskName: "max-3-positions",
validations: [
({ activePositionCount }) => {
if (activePositionCount >= 3) {
throw new Error("Max 3 concurrent positions exceeded");
}
}
]
});
Ensure TP is sufficiently far from entry to cover fees:
addRisk({
riskName: "min-tp-distance",
validations: [
({ pendingSignal, currentPrice }) => {
const { priceOpen = currentPrice, priceTakeProfit, position } = pendingSignal;
const tpDistance = position === "long"
? ((priceTakeProfit - priceOpen) / priceOpen) * 100
: ((priceOpen - priceTakeProfit) / priceOpen) * 100;
if (tpDistance < 1.0) {
throw new Error(`TP too close: ${tpDistance.toFixed(2)}%`);
}
}
]
});
Enforce minimum R/R ratio (e.g., 2:1):
addRisk({
riskName: "min-rr-2to1",
validations: [
({ pendingSignal, currentPrice }) => {
const { priceOpen = currentPrice, priceTakeProfit, priceStopLoss, position } = pendingSignal;
const reward = position === "long"
? priceTakeProfit - priceOpen
: priceOpen - priceTakeProfit;
const risk = position === "long"
? priceOpen - priceStopLoss
: priceStopLoss - priceOpen;
if (reward / risk < 2.0) {
throw new Error(`Poor R/R: ${(reward/risk).toFixed(2)}:1`);
}
}
]
});
Prevent signals during specific hours (e.g., low liquidity periods):
addRisk({
riskName: "trading-hours",
validations: [
({ timestamp }) => {
const hour = new Date(timestamp).getUTCHours();
// Block 00:00-02:00 UTC (low liquidity)
if (hour >= 0 && hour < 2) {
throw new Error("Trading disabled during low liquidity hours");
}
}
]
});
Limit positions per symbol:
addRisk({
riskName: "one-per-symbol",
validations: [
({ symbol, activePositions }) => {
const countOnSymbol = activePositions.filter(
pos => pos.signal.symbol === symbol
).length;
if (countOnSymbol > 0) {
throw new Error(`Already have position on ${symbol}`);
}
}
]
});
Risk validation integrates with multiple strategy lifecycle points.
Strategies can require multiple risk profiles to all pass:
addStrategy({
strategyName: "conservative-btc",
riskList: ["max-3-positions", "min-rr-2to1", "trading-hours"],
getSignal: async (symbol, when) => {
// All three risk profiles must validate
}
});
All risk profiles in riskList are checked sequentially. If any validation fails, the signal is rejected.
Global risk parameters are configured via setConfig():
setConfig({
CC_MIN_TAKEPROFIT_DISTANCE_PERCENT: 0.3, // Min TP distance (%)
CC_MIN_STOPLOSS_DISTANCE_PERCENT: 0.1, // Min SL distance (%)
CC_MAX_STOPLOSS_DISTANCE_PERCENT: 5.0, // Max SL distance (%)
CC_MAX_SIGNAL_LIFETIME_MINUTES: 10080, // Max 7 days
CC_SCHEDULE_AWAIT_MINUTES: 120, // Scheduled signal timeout
});
These parameters are enforced by VALIDATE_SIGNAL_FN before risk validation:
| Parameter | Default | Purpose |
|---|---|---|
CC_MIN_TAKEPROFIT_DISTANCE_PERCENT |
0.3 |
Minimum TP distance to cover fees |
CC_MIN_STOPLOSS_DISTANCE_PERCENT |
0.1 |
Minimum SL distance to avoid instant stops |
CC_MAX_STOPLOSS_DISTANCE_PERCENT |
5.0 |
Maximum SL distance to protect capital |
CC_MAX_SIGNAL_LIFETIME_MINUTES |
10080 |
Maximum signal duration (7 days) |
CC_SCHEDULE_AWAIT_MINUTES |
120 |
Scheduled signal timeout (2 hours) |
Validation errors are caught and handled gracefully:
ErrorClientRisk catches errorvalidationSubject (for debugging)onRejected callback invoked with error messageriskSubject emits RiskContract with rejection detailscheckSignal() returns falseawait)performanceEmitter_activePositionsMap is in-memory Map for O(1) lookupsriskName for parallel writesClientRisk instances are memoized per riskName in RiskConnectionService, ensuring singleton behavior and preventing duplicate position tracking.