/**
* Risk Management Module
*
* Applies risk limits to strategy decisions.
* Pure functions - no side effects or external I/O.
*/
import {
StrategyContext,
StrategyDecision,
RiskLimits,
RiskState,
RiskAdjustedDecision,
DEFAULT_RISK_LIMITS,
} from "./types";
/**
* Create initial risk state.
*/
export function createRiskState(initialEquity: number): RiskState {
return {
peakEquity: initialEquity,
dailyPnl: 0,
dayStartEquity: initialEquity,
currentDate: undefined,
};
}
/**
* Update risk state with new equity value.
* Call this after each bar to track peak equity and daily PnL.
*/
export function updateRiskState(
state: RiskState,
newEquity: number,
date: Date
): RiskState {
const newState = { ...state };
// Update peak equity
if (newEquity > newState.peakEquity) {
newState.peakEquity = newEquity;
}
// Check for day change - reset daily PnL
const currentDateStr = date.toISOString().split("T")[0];
const prevDateStr = newState.currentDate?.toISOString().split("T")[0];
if (currentDateStr !== prevDateStr) {
// New day - reset daily tracking
newState.dayStartEquity = newEquity;
newState.dailyPnl = 0;
newState.currentDate = date;
} else {
// Same day - update daily PnL
newState.dailyPnl = newEquity - newState.dayStartEquity;
}
return newState;
}
/**
* Calculate current drawdown from peak.
*/
export function calculateDrawdown(currentEquity: number, peakEquity: number): number {
if (peakEquity <= 0) return 0;
return (peakEquity - currentEquity) / peakEquity;
}
/**
* Calculate daily loss as fraction of day start equity.
*/
export function calculateDailyLoss(state: RiskState): number {
if (state.dayStartEquity <= 0) return 0;
if (state.dailyPnl >= 0) return 0;
return Math.abs(state.dailyPnl) / state.dayStartEquity;
}
/**
* Calculate notional exposure for a position.
*/
export function calculateNotional(shares: number, price: number): number {
return Math.abs(shares) * price;
}
/**
* Calculate max shares allowed by notional limit.
*/
export function maxSharesByNotional(
equity: number,
price: number,
maxNotionalFraction: number
): number {
if (price <= 0) return 0;
const maxNotional = equity * maxNotionalFraction;
return Math.floor(maxNotional / price);
}
/**
* Apply risk management to a strategy decision.
*
* This function:
* 1. Checks drawdown limits
* 2. Checks daily loss limits
* 3. Caps position size by notional limits
* 4. Returns adjusted decision with annotations
*/
export function applyRiskManagement(
decision: StrategyDecision,
context: StrategyContext,
limits: RiskLimits,
state: RiskState
): RiskAdjustedDecision {
const annotations: string[] = [];
let targetShares = decision.targetPositionShares;
let wasAdjusted = false;
let reason = decision.reason;
// 1. Check portfolio drawdown
const drawdown = calculateDrawdown(context.equity, state.peakEquity);
if (drawdown > limits.maxPortfolioDrawdown) {
if (targetShares !== 0) {
targetShares = 0;
wasAdjusted = true;
reason = `forced flat: max drawdown exceeded (${(drawdown * 100).toFixed(1)}% > ${(limits.maxPortfolioDrawdown * 100).toFixed(1)}%)`;
annotations.push(reason);
}
}
// 2. Check daily loss limit
const dailyLoss = calculateDailyLoss(state);
if (dailyLoss > limits.maxDailyLoss) {
if (targetShares !== 0) {
targetShares = 0;
wasAdjusted = true;
reason = `forced flat: daily loss limit hit (${(dailyLoss * 100).toFixed(1)}% > ${(limits.maxDailyLoss * 100).toFixed(1)}%)`;
annotations.push(reason);
}
}
// 3. Cap by notional limit (only if not already forced flat)
if (targetShares !== 0) {
const maxShares = maxSharesByNotional(
context.equity,
context.price,
limits.maxNotionalPerSymbol
);
const absTarget = Math.abs(targetShares);
if (absTarget > maxShares) {
const sign = targetShares > 0 ? 1 : -1;
targetShares = sign * maxShares;
wasAdjusted = true;
const annotation = `capped position: notional limit (${absTarget} → ${maxShares} shares)`;
annotations.push(annotation);
reason = `${decision.reason} [${annotation}]`;
}
}
// 4. Check gross exposure limit
if (targetShares !== 0) {
const proposedNotional = calculateNotional(targetShares, context.price);
const maxNotional = context.equity * limits.maxGrossExposure;
if (proposedNotional > maxNotional) {
const maxByExposure = Math.floor(maxNotional / context.price);
const sign = targetShares > 0 ? 1 : -1;
targetShares = sign * maxByExposure;
wasAdjusted = true;
const annotation = `capped by gross exposure limit`;
annotations.push(annotation);
}
}
return {
targetPositionShares: targetShares,
confidence: decision.confidence,
reason,
originalDecision: decision,
wasAdjusted,
riskAnnotations: annotations,
};
}
/**
* Validate risk limits configuration.
*/
export function validateRiskLimits(limits: RiskLimits): string[] {
const errors: string[] = [];
if (limits.maxNotionalPerSymbol <= 0 || limits.maxNotionalPerSymbol > 1) {
errors.push("maxNotionalPerSymbol must be between 0 and 1");
}
if (limits.maxPortfolioDrawdown <= 0 || limits.maxPortfolioDrawdown > 1) {
errors.push("maxPortfolioDrawdown must be between 0 and 1");
}
if (limits.maxDailyLoss <= 0 || limits.maxDailyLoss > 1) {
errors.push("maxDailyLoss must be between 0 and 1");
}
if (limits.maxGrossExposure <= 0) {
errors.push("maxGrossExposure must be positive");
}
return errors;
}
/**
* Create risk limits with partial overrides.
*/
export function createRiskLimits(overrides: Partial<RiskLimits> = {}): RiskLimits {
return {
...DEFAULT_RISK_LIMITS,
...overrides,
};
}