Risk Management

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.

Mermaid Diagram

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.

Mermaid Diagram

Risk validation occurs twice for scheduled signals:

  1. Initial Check: When getSignal() returns signal with priceOpen (src/client/ClientStrategy.ts:376-387)
  2. Activation Check: When price reaches 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.

Mermaid Diagram

Active positions are tracked using composite keys:

{symbol}:{strategyName}:{riskName}

Example: "BTCUSDT:my-strategy:demo-risk"

This allows:

  • Multiple strategies on same symbol with different risk profiles
  • Same strategy on different symbols
  • Multiple risk profiles per strategy (via 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.

Mermaid Diagram

Position changes are written atomically using PersistRiskAdapter:

  1. Write to temporary file: {riskName}.json.tmp
  2. fsync() to ensure disk write
  3. Atomic rename: {riskName}.json.tmp{riskName}.json

This prevents partial writes during crashes.

  • Backtest: No persistence (in-memory only for speed)
  • Live: Full persistence after every addSignal/removeSignal

Risk rejections emit events to riskSubject for monitoring and alerting.

Mermaid Diagram

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.

Mermaid Diagram

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:

Mermaid Diagram

  1. Validation function throws Error
  2. ClientRisk catches error
  3. Error emitted to validationSubject (for debugging)
  4. onRejected callback invoked with error message
  5. riskSubject emits RiskContract with rejection details
  6. checkSignal() returns false
  • Validations run sequentially in array order
  • First validation that throws stops execution (short-circuit)
  • Async validations are supported (use await)
  • Validation timing tracked via performanceEmitter
  • _activePositionsMap is in-memory Map for O(1) lookups
  • Persistence only in live mode (backtest skips I/O)
  • Atomic file writes prevent corruption
  • Separate files per riskName for parallel writes

ClientRisk instances are memoized per riskName in RiskConnectionService, ensuring singleton behavior and preventing duplicate position tracking.