/**
* Risk metrics calculations
*/
import { RiskMetrics, VaRParams, VaRResult } from "./types";
import { simpleReturns, mean, stdDev, TRADING_DAYS_PER_YEAR } from "./utils";
/**
* Compute comprehensive risk metrics from an equity curve
*
* @param equityCurve - Array of portfolio values over time (daily)
* @param riskFreeRate - Annual risk-free rate (e.g., 0.02 for 2%)
* @returns Risk metrics including Sharpe, Sortino, max drawdown
*/
export function computeRiskMetrics(
equityCurve: number[],
riskFreeRate: number = 0
): RiskMetrics {
if (equityCurve.length < 2) {
throw new Error("Need at least 2 data points for risk metrics");
}
const returns = simpleReturns(equityCurve);
const n = equityCurve.length;
// Total return
const totalReturn = equityCurve[n - 1] / equityCurve[0] - 1;
// Daily metrics
const avgDailyReturn = mean(returns);
const dailyVol = stdDev(returns, true);
// Annualized metrics
const annualizedReturn = Math.pow(1 + avgDailyReturn, TRADING_DAYS_PER_YEAR) - 1;
const annualizedVol = dailyVol * Math.sqrt(TRADING_DAYS_PER_YEAR);
// Sharpe ratio
let sharpe: number | null = null;
if (annualizedVol > 0) {
sharpe = (annualizedReturn - riskFreeRate) / annualizedVol;
}
// Sortino ratio (downside deviation)
const negativeReturns = returns.filter((r) => r < 0);
let sortino: number | null = null;
if (negativeReturns.length > 0) {
// Downside deviation: std dev of negative returns (treating target as 0)
const downsideSum = negativeReturns.reduce((sum, r) => sum + r * r, 0);
const downsideVol = Math.sqrt(downsideSum / returns.length);
const annualizedDownsideVol = downsideVol * Math.sqrt(TRADING_DAYS_PER_YEAR);
if (annualizedDownsideVol > 0) {
sortino = (annualizedReturn - riskFreeRate) / annualizedDownsideVol;
}
}
// Maximum drawdown
const maxDrawdown = computeMaxDrawdown(equityCurve);
// Sample size info
const sampleSize = returns.length;
const isReliable = sampleSize >= 30; // Statistical rule of thumb
return {
totalReturn,
annualizedReturn,
annualizedVol,
sharpe,
sortino,
maxDrawdown,
sampleSize,
isReliable,
};
}
/**
* Compute maximum drawdown from peak to trough
*
* @param equityCurve - Array of portfolio values
* @returns Maximum drawdown as positive percentage (e.g., 0.2 = 20%)
*/
export function computeMaxDrawdown(equityCurve: number[]): number {
if (equityCurve.length < 2) return 0;
let maxDrawdown = 0;
let peak = equityCurve[0];
for (const value of equityCurve) {
if (value > peak) {
peak = value;
}
const drawdown = (peak - value) / peak;
if (drawdown > maxDrawdown) {
maxDrawdown = drawdown;
}
}
return maxDrawdown;
}
/**
* Compute drawdown series
*
* @param equityCurve - Array of portfolio values
* @returns Array of drawdown values at each point
*/
export function computeDrawdownSeries(equityCurve: number[]): number[] {
const drawdowns: number[] = [];
let peak = equityCurve[0];
for (const value of equityCurve) {
if (value > peak) {
peak = value;
}
drawdowns.push((peak - value) / peak);
}
return drawdowns;
}
/**
* Compute Historical Value at Risk (VaR)
*
* @param params - VaR parameters (returns array and confidence level)
* @returns VaR and Expected Shortfall
*/
export function computeHistoricalVaR(params: VaRParams): VaRResult {
const { returns, confidence } = params;
if (returns.length === 0) {
throw new Error("Returns array cannot be empty");
}
if (confidence <= 0 || confidence >= 1) {
throw new Error("Confidence must be between 0 and 1");
}
// Sort returns ascending (worst to best)
const sorted = [...returns].sort((a, b) => a - b);
// VaR is the quantile at (1 - confidence)
// e.g., 95% confidence means we look at the 5th percentile
const index = Math.floor(sorted.length * (1 - confidence));
const varValue = -sorted[index]; // Return as positive loss
// Expected Shortfall: average of returns worse than VaR
const tailReturns = sorted.slice(0, index + 1);
const expectedShortfall =
tailReturns.length > 0 ? -mean(tailReturns) : varValue;
return {
var: varValue,
expectedShortfall,
};
}
/**
* Compute parametric (Gaussian) VaR
*
* @param meanReturn - Mean return (daily)
* @param stdReturn - Standard deviation of returns (daily)
* @param confidence - Confidence level (e.g., 0.95)
* @returns VaR estimate
*/
export function computeParametricVaR(
meanReturn: number,
stdReturn: number,
confidence: number
): number {
// Z-score for given confidence (using normal approximation)
// For 95%: ~1.645, for 99%: ~2.326
const zScores: Record<number, number> = {
0.9: 1.282,
0.95: 1.645,
0.99: 2.326,
0.995: 2.576,
0.999: 3.09,
};
// Interpolate or use closest
const z = zScores[confidence] ?? 1.645;
return -(meanReturn - z * stdReturn);
}
/**
* Compute Calmar ratio (CAGR / Max Drawdown)
*
* @param annualizedReturn - Annualized return
* @param maxDrawdown - Maximum drawdown
* @returns Calmar ratio (null if drawdown is 0)
*/
export function computeCalmarRatio(
annualizedReturn: number,
maxDrawdown: number
): number | null {
if (maxDrawdown === 0) return null;
return annualizedReturn / maxDrawdown;
}