This document explains how the framework tracks active positions for portfolio-level risk management. Position tracking is implemented by ClientRisk and maintains a real-time registry of all open positions across strategies sharing the same risk profile.
For information about risk profile configuration, see Risk Profiles. For information about risk validation logic, see Risk Validation.
Position tracking enables cross-strategy risk analysis by maintaining a shared registry of active positions. Multiple ClientStrategy instances sharing the same riskName contribute to a single position count, allowing portfolio-level limits to be enforced across strategies.
The tracking system provides:
Diagram: Position Sharing Across Strategies
The _activePositions field stores active positions in a Map<string, IRiskActivePosition> where keys follow the pattern ${strategyName}:${symbol}.
| Component | Type | Purpose |
|---|---|---|
| Key | string |
Composite identifier: "strategyName:symbol" |
| Value | IRiskActivePosition |
Position metadata including timestamp |
interface IRiskActivePosition {
signal: ISignalRow; // Signal details (stored as null in practice)
strategyName: string; // Strategy owning the position
exchangeName: string; // Exchange name (empty string in practice)
openTimestamp: number; // When position was opened (Date.now())
}
The framework uses GET_KEY_FN to generate consistent keys:
const GET_KEY_FN = (strategyName: string, symbol: string) =>
`${strategyName}:${symbol}`;
This pattern ensures:
removeSignal operationsPositions are registered when signals open and removed when signals close. The lifecycle is managed through addSignal and removeSignal methods.
Diagram: Position Registration Sequence
The addSignal method registers a new position when a signal opens:
Key steps:
waitForInit() if _activePositions === POSITION_NEED_FETCHGET_KEY_FN(strategyName, symbol)_updatePositions()Method signature:
public async addSignal(
symbol: string,
context: { strategyName: string; riskName: string }
)
The removeSignal method removes a position when a signal closes:
Key steps:
GET_KEY_FN(strategyName, symbol)_updatePositions()Method signature:
public async removeSignal(
symbol: string,
context: { strategyName: string; riskName: string }
)
Position tracking uses lazy initialization to defer persistence loading until first use. This optimization prevents unnecessary disk I/O during backtest mode or when risk profiles are unused.
Diagram: Lazy Initialization State Machine
The initialization function is wrapped with singleshot to ensure it executes exactly once:
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);
};
Key characteristics:
singleshot at line 88 to prevent duplicate initializationPosition data persists to disk after every addSignal and removeSignal operation. This ensures crash safety in live trading where position state must survive restarts.
Diagram: Persistence and Recovery Flow
The _updatePositions method coordinates persistence after state changes:
Implementation:
Array.from(_activePositions)PersistRiskAdapter.writePositionData(array, riskName)Position data serializes as an array of tuples:
type PersistedData = Array<[string, IRiskActivePosition]>;
// Example:
[
["strategy1:BTCUSDT", {
signal: null,
strategyName: "strategy1",
exchangeName: "",
openTimestamp: 1234567890
}],
["strategy2:ETHUSDT", {
signal: null,
strategyName: "strategy2",
exchangeName: "",
openTimestamp: 1234567900
}]
]
This format:
new Map(array)Custom validation functions receive position data through the IRiskValidationPayload interface. The framework builds this payload before executing validations.
Diagram: Validation Payload Construction
The checkSignal method builds the validation payload:
Implementation details:
waitForInit()_activePositions to RiskMap typeIRiskValidationPayload with spread operatoractivePositionCount: riskMap.sizeactivePositions: Array.from(riskMap.values())Example validation accessing positions:
{
validate: ({ activePositionCount, activePositions }) => {
if (activePositionCount >= 5) {
throw new Error("Maximum 5 concurrent positions");
}
const btcPositions = activePositions.filter(
pos => pos.signal.symbol === "BTCUSDT"
);
if (btcPositions.length >= 2) {
throw new Error("Maximum 2 BTC positions");
}
}
}
Each risk profile maintains an isolated position registry. Strategies with different riskName values track positions independently, enabling separate risk policies.
| Component | Isolation Level | Mechanism |
|---|---|---|
ClientRisk instance |
Per riskName |
Memoized by RiskConnectionService |
_activePositions Map |
Per instance | Instance field |
| Persistence file | Per riskName |
PersistRiskAdapter namespace |
Diagram: Position Isolation by Risk Profile
Test case demonstrating isolation:
Scenario:
test-isolation-1test-isolation-2Result:
activePositionCount: 2activePositionCount: 1Position tracking integrates with the service layer through RiskGlobalService and RiskConnectionService. These services provide validation and routing to the correct ClientRisk instance.
Diagram: Position Tracking Service Integration
| Method | Flow | Purpose |
|---|---|---|
addSignal |
RiskGlobalService → RiskConnectionService → ClientRisk |
Register opened position |
removeSignal |
RiskGlobalService → RiskConnectionService → ClientRisk |
Unregister closed position |
checkSignal |
RiskGlobalService → RiskConnectionService → ClientRisk |
Validate with current positions |
RiskConnectionService.getRisk uses memoization to cache ClientRisk instances:
Cache key: riskName string
Benefits:
Format: ${strategyName}:${symbol}
Characteristics:
Example:
strategy1:BTCUSDT → Unique position
strategy2:BTCUSDT → Different position (same symbol, different strategy)
strategy1:ETHUSDT → Different position (same strategy, different symbol)
The implementation stores minimal metadata in position records:
Fields stored:
signal: null (not used for position tracking)strategyName: string (for identification)exchangeName: "" (empty string, not used)openTimestamp: number (for duration tracking)This simplification:
Position map updates occur synchronously in memory, followed by asynchronous persistence:
Pattern:
// Synchronous map update
riskMap.set(key, position);
// Asynchronous persistence (non-blocking)
await this._updatePositions();
Benefits: