789 lines
25 KiB
TypeScript
789 lines
25 KiB
TypeScript
/**
|
|
* Performance Metrics and Analysis
|
|
* Comprehensive performance measurement tools for trading strategies and portfolios
|
|
*/
|
|
|
|
import { PortfolioMetrics } from './index';
|
|
|
|
export interface TradePerformance {
|
|
totalTrades: number;
|
|
winningTrades: number;
|
|
losingTrades: number;
|
|
winRate: number;
|
|
averageWin: number;
|
|
averageLoss: number;
|
|
largestWin: number;
|
|
largestLoss: number;
|
|
profitFactor: number;
|
|
expectancy: number;
|
|
averageTradeReturn: number;
|
|
consecutiveWins: number;
|
|
consecutiveLosses: number;
|
|
}
|
|
|
|
export interface DrawdownAnalysis {
|
|
maxDrawdown: number;
|
|
maxDrawdownDuration: number;
|
|
averageDrawdown: number;
|
|
drawdownPeriods: Array<{
|
|
start: Date;
|
|
end: Date;
|
|
duration: number;
|
|
magnitude: number;
|
|
}>;
|
|
}
|
|
|
|
export interface ReturnAnalysis {
|
|
totalReturn: number;
|
|
annualizedReturn: number;
|
|
compoundAnnualGrowthRate: number;
|
|
volatility: number;
|
|
annualizedVolatility: number;
|
|
skewness: number;
|
|
kurtosis: number;
|
|
bestMonth: number;
|
|
worstMonth: number;
|
|
positiveMonths: number;
|
|
negativeMonths: number;
|
|
}
|
|
|
|
/**
|
|
* Calculate comprehensive trade performance metrics
|
|
*/
|
|
export function analyzeTradePerformance(trades: Array<{ pnl: number; date: Date }>): TradePerformance {
|
|
if (trades.length === 0) {
|
|
return {
|
|
totalTrades: 0,
|
|
winningTrades: 0,
|
|
losingTrades: 0,
|
|
winRate: 0,
|
|
averageWin: 0,
|
|
averageLoss: 0,
|
|
largestWin: 0,
|
|
largestLoss: 0,
|
|
profitFactor: 0,
|
|
expectancy: 0,
|
|
averageTradeReturn: 0,
|
|
consecutiveWins: 0,
|
|
consecutiveLosses: 0
|
|
};
|
|
}
|
|
|
|
const winningTrades = trades.filter(trade => trade.pnl > 0);
|
|
const losingTrades = trades.filter(trade => trade.pnl < 0);
|
|
|
|
const totalWins = winningTrades.reduce((sum, trade) => sum + trade.pnl, 0);
|
|
const totalLosses = Math.abs(losingTrades.reduce((sum, trade) => sum + trade.pnl, 0));
|
|
|
|
const averageWin = winningTrades.length > 0 ? totalWins / winningTrades.length : 0;
|
|
const averageLoss = losingTrades.length > 0 ? totalLosses / losingTrades.length : 0;
|
|
|
|
const largestWin = winningTrades.length > 0 ? Math.max(...winningTrades.map(t => t.pnl)) : 0;
|
|
const largestLoss = losingTrades.length > 0 ? Math.min(...losingTrades.map(t => t.pnl)) : 0;
|
|
|
|
const profitFactor = totalLosses > 0 ? totalWins / totalLosses : totalWins > 0 ? Infinity : 0;
|
|
const winRate = winningTrades.length / trades.length;
|
|
const expectancy = (winRate * averageWin) - ((1 - winRate) * averageLoss);
|
|
|
|
const totalPnL = trades.reduce((sum, trade) => sum + trade.pnl, 0);
|
|
const averageTradeReturn = totalPnL / trades.length;
|
|
|
|
// Calculate consecutive wins/losses
|
|
let consecutiveWins = 0;
|
|
let consecutiveLosses = 0;
|
|
let currentWinStreak = 0;
|
|
let currentLossStreak = 0;
|
|
|
|
for (const trade of trades) {
|
|
if (trade.pnl > 0) {
|
|
currentWinStreak++;
|
|
currentLossStreak = 0;
|
|
consecutiveWins = Math.max(consecutiveWins, currentWinStreak);
|
|
} else if (trade.pnl < 0) {
|
|
currentLossStreak++;
|
|
currentWinStreak = 0;
|
|
consecutiveLosses = Math.max(consecutiveLosses, currentLossStreak);
|
|
}
|
|
}
|
|
|
|
return {
|
|
totalTrades: trades.length,
|
|
winningTrades: winningTrades.length,
|
|
losingTrades: losingTrades.length,
|
|
winRate,
|
|
averageWin,
|
|
averageLoss,
|
|
largestWin,
|
|
largestLoss,
|
|
profitFactor,
|
|
expectancy,
|
|
averageTradeReturn,
|
|
consecutiveWins,
|
|
consecutiveLosses
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Analyze drawdown characteristics
|
|
*/
|
|
export function analyzeDrawdowns(equityCurve: Array<{ value: number; date: Date }>): DrawdownAnalysis {
|
|
if (equityCurve.length < 2) {
|
|
return {
|
|
maxDrawdown: 0,
|
|
maxDrawdownDuration: 0,
|
|
averageDrawdown: 0,
|
|
drawdownPeriods: []
|
|
};
|
|
}
|
|
|
|
let peak = equityCurve[0].value;
|
|
let peakDate = equityCurve[0].date;
|
|
let maxDrawdown = 0;
|
|
let maxDrawdownDuration = 0;
|
|
|
|
const drawdownPeriods: Array<{
|
|
start: Date;
|
|
end: Date;
|
|
duration: number;
|
|
magnitude: number;
|
|
}> = [];
|
|
|
|
let currentDrawdownStart: Date | null = null;
|
|
let drawdowns: number[] = [];
|
|
|
|
for (let i = 1; i < equityCurve.length; i++) {
|
|
const current = equityCurve[i];
|
|
|
|
if (current.value > peak) {
|
|
// New peak - end any current drawdown
|
|
if (currentDrawdownStart) {
|
|
const drawdownMagnitude = (peak - equityCurve[i - 1].value) / peak;
|
|
const duration = Math.floor((equityCurve[i - 1].date.getTime() - currentDrawdownStart.getTime()) / (1000 * 60 * 60 * 24));
|
|
|
|
drawdownPeriods.push({
|
|
start: currentDrawdownStart,
|
|
end: equityCurve[i - 1].date,
|
|
duration,
|
|
magnitude: drawdownMagnitude
|
|
});
|
|
|
|
drawdowns.push(drawdownMagnitude);
|
|
maxDrawdownDuration = Math.max(maxDrawdownDuration, duration);
|
|
currentDrawdownStart = null;
|
|
}
|
|
|
|
peak = current.value;
|
|
peakDate = current.date;
|
|
} else {
|
|
// In drawdown
|
|
if (!currentDrawdownStart) {
|
|
currentDrawdownStart = peakDate;
|
|
}
|
|
|
|
const drawdown = (peak - current.value) / peak;
|
|
maxDrawdown = Math.max(maxDrawdown, drawdown);
|
|
}
|
|
}
|
|
|
|
// Handle ongoing drawdown
|
|
if (currentDrawdownStart) {
|
|
const lastPoint = equityCurve[equityCurve.length - 1];
|
|
const drawdownMagnitude = (peak - lastPoint.value) / peak;
|
|
const duration = Math.floor((lastPoint.date.getTime() - currentDrawdownStart.getTime()) / (1000 * 60 * 60 * 24));
|
|
|
|
drawdownPeriods.push({
|
|
start: currentDrawdownStart,
|
|
end: lastPoint.date,
|
|
duration,
|
|
magnitude: drawdownMagnitude
|
|
});
|
|
|
|
drawdowns.push(drawdownMagnitude);
|
|
maxDrawdownDuration = Math.max(maxDrawdownDuration, duration);
|
|
}
|
|
|
|
const averageDrawdown = drawdowns.length > 0 ? drawdowns.reduce((sum, dd) => sum + dd, 0) / drawdowns.length : 0;
|
|
|
|
return {
|
|
maxDrawdown,
|
|
maxDrawdownDuration,
|
|
averageDrawdown,
|
|
drawdownPeriods
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Analyze return characteristics
|
|
*/
|
|
export function analyzeReturns(
|
|
returns: Array<{ return: number; date: Date }>,
|
|
periodsPerYear: number = 252
|
|
): ReturnAnalysis {
|
|
if (returns.length === 0) {
|
|
return {
|
|
totalReturn: 0,
|
|
annualizedReturn: 0,
|
|
compoundAnnualGrowthRate: 0,
|
|
volatility: 0,
|
|
annualizedVolatility: 0,
|
|
skewness: 0,
|
|
kurtosis: 0,
|
|
bestMonth: 0,
|
|
worstMonth: 0,
|
|
positiveMonths: 0,
|
|
negativeMonths: 0
|
|
};
|
|
}
|
|
|
|
const returnValues = returns.map(r => r.return);
|
|
|
|
// Calculate basic statistics
|
|
const totalReturn = returnValues.reduce((product, ret) => product * (1 + ret), 1) - 1;
|
|
const averageReturn = returnValues.reduce((sum, ret) => sum + ret, 0) / returnValues.length;
|
|
const annualizedReturn = Math.pow(1 + averageReturn, periodsPerYear) - 1;
|
|
|
|
// Calculate CAGR
|
|
const years = returns.length / periodsPerYear;
|
|
const cagr = years > 0 ? Math.pow(1 + totalReturn, 1 / years) - 1 : 0;
|
|
|
|
// Calculate volatility
|
|
const variance = returnValues.reduce((sum, ret) => sum + Math.pow(ret - averageReturn, 2), 0) / (returnValues.length - 1);
|
|
const volatility = Math.sqrt(variance);
|
|
const annualizedVolatility = volatility * Math.sqrt(periodsPerYear);
|
|
|
|
// Calculate skewness and kurtosis
|
|
const skewness = calculateSkewness(returnValues);
|
|
const kurtosis = calculateKurtosis(returnValues);
|
|
|
|
// Monthly analysis
|
|
const monthlyReturns = aggregateMonthlyReturns(returns);
|
|
const bestMonth = monthlyReturns.length > 0 ? Math.max(...monthlyReturns) : 0;
|
|
const worstMonth = monthlyReturns.length > 0 ? Math.min(...monthlyReturns) : 0;
|
|
const positiveMonths = monthlyReturns.filter(ret => ret > 0).length;
|
|
const negativeMonths = monthlyReturns.filter(ret => ret < 0).length;
|
|
|
|
return {
|
|
totalReturn,
|
|
annualizedReturn,
|
|
compoundAnnualGrowthRate: cagr,
|
|
volatility,
|
|
annualizedVolatility,
|
|
skewness,
|
|
kurtosis,
|
|
bestMonth,
|
|
worstMonth,
|
|
positiveMonths,
|
|
negativeMonths
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculate rolling performance metrics
|
|
*/
|
|
export function calculateRollingMetrics(
|
|
returns: number[],
|
|
windowSize: number,
|
|
metricType: 'sharpe' | 'volatility' | 'return' = 'sharpe'
|
|
): number[] {
|
|
if (returns.length < windowSize) return [];
|
|
|
|
const rollingMetrics: number[] = [];
|
|
|
|
for (let i = windowSize - 1; i < returns.length; i++) {
|
|
const window = returns.slice(i - windowSize + 1, i + 1);
|
|
|
|
switch (metricType) {
|
|
case 'sharpe':
|
|
rollingMetrics.push(calculateSharpeRatio(window));
|
|
break;
|
|
case 'volatility':
|
|
rollingMetrics.push(calculateVolatility(window));
|
|
break;
|
|
case 'return':
|
|
const avgReturn = window.reduce((sum, ret) => sum + ret, 0) / window.length;
|
|
rollingMetrics.push(avgReturn);
|
|
break;
|
|
}
|
|
}
|
|
|
|
return rollingMetrics;
|
|
}
|
|
|
|
/**
|
|
* Calculate performance attribution
|
|
*/
|
|
export function strategyPerformanceAttribution(
|
|
portfolioReturns: number[],
|
|
benchmarkReturns: number[],
|
|
sectorWeights: number[],
|
|
sectorReturns: number[]
|
|
): {
|
|
allocationEffect: number;
|
|
selectionEffect: number;
|
|
interactionEffect: number;
|
|
totalActiveReturn: number;
|
|
} {
|
|
if (portfolioReturns.length !== benchmarkReturns.length) {
|
|
throw new Error('Portfolio and benchmark returns must have same length');
|
|
}
|
|
|
|
const portfolioReturn = portfolioReturns.reduce((sum, ret) => sum + ret, 0) / portfolioReturns.length;
|
|
const benchmarkReturn = benchmarkReturns.reduce((sum, ret) => sum + ret, 0) / benchmarkReturns.length;
|
|
|
|
let allocationEffect = 0;
|
|
let selectionEffect = 0;
|
|
let interactionEffect = 0;
|
|
|
|
for (let i = 0; i < sectorWeights.length; i++) {
|
|
const portfolioWeight = sectorWeights[i];
|
|
const benchmarkWeight = 1 / sectorWeights.length; // Assuming equal benchmark weights
|
|
const sectorReturn = sectorReturns[i];
|
|
|
|
// Allocation effect: (portfolio weight - benchmark weight) * (benchmark sector return - benchmark return)
|
|
allocationEffect += (portfolioWeight - benchmarkWeight) * (sectorReturn - benchmarkReturn);
|
|
|
|
// Selection effect: benchmark weight * (portfolio sector return - benchmark sector return)
|
|
selectionEffect += benchmarkWeight * (sectorReturn - sectorReturn); // Simplified
|
|
|
|
// Interaction effect: (portfolio weight - benchmark weight) * (portfolio sector return - benchmark sector return)
|
|
interactionEffect += (portfolioWeight - benchmarkWeight) * (sectorReturn - sectorReturn); // Simplified
|
|
}
|
|
|
|
const totalActiveReturn = portfolioReturn - benchmarkReturn;
|
|
|
|
return {
|
|
allocationEffect,
|
|
selectionEffect,
|
|
interactionEffect,
|
|
totalActiveReturn
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculate Omega ratio
|
|
*/
|
|
export function omegaRatio(returns: number[], threshold: number = 0): number {
|
|
if (returns.length === 0) return 0;
|
|
|
|
const gains = returns.filter(ret => ret > threshold).reduce((sum, ret) => sum + (ret - threshold), 0);
|
|
const losses = returns.filter(ret => ret < threshold).reduce((sum, ret) => sum + Math.abs(ret - threshold), 0);
|
|
|
|
return losses === 0 ? Infinity : gains / losses;
|
|
}
|
|
|
|
/**
|
|
* Calculate gain-to-pain ratio
|
|
*/
|
|
export function gainToPainRatio(returns: number[]): number {
|
|
if (returns.length === 0) return 0;
|
|
|
|
const totalGain = returns.reduce((sum, ret) => sum + ret, 0);
|
|
const totalPain = returns.filter(ret => ret < 0).reduce((sum, ret) => sum + Math.abs(ret), 0);
|
|
|
|
return totalPain === 0 ? (totalGain > 0 ? Infinity : 0) : totalGain / totalPain;
|
|
}
|
|
|
|
/**
|
|
* Calculate Martin ratio (modified Sharpe with downside deviation)
|
|
*/
|
|
export function martinRatio(returns: number[], riskFreeRate: number = 0): number {
|
|
if (returns.length === 0) return 0;
|
|
|
|
const averageReturn = returns.reduce((sum, ret) => sum + ret, 0) / returns.length;
|
|
const downsideReturns = returns.filter(ret => ret < riskFreeRate);
|
|
|
|
if (downsideReturns.length === 0) return Infinity;
|
|
|
|
const downsideDeviation = Math.sqrt(
|
|
downsideReturns.reduce((sum, ret) => sum + Math.pow(ret - riskFreeRate, 2), 0) / returns.length
|
|
);
|
|
|
|
return downsideDeviation === 0 ? Infinity : (averageReturn - riskFreeRate) / downsideDeviation;
|
|
}
|
|
|
|
/**
|
|
* Calculate comprehensive portfolio metrics
|
|
*/
|
|
export function calculateStrategyMetrics(
|
|
equityCurve: Array<{ value: number; date: Date }>,
|
|
benchmarkReturns?: number[],
|
|
riskFreeRate: number = 0.02
|
|
): PortfolioMetrics {
|
|
if (equityCurve.length < 2) {
|
|
return {
|
|
totalValue: 0,
|
|
totalReturn: 0,
|
|
totalReturnPercent: 0,
|
|
dailyReturn: 0,
|
|
dailyReturnPercent: 0,
|
|
maxDrawdown: 0,
|
|
sharpeRatio: 0,
|
|
beta: 0,
|
|
alpha: 0,
|
|
volatility: 0
|
|
};
|
|
}
|
|
|
|
const returns = [];
|
|
for (let i = 1; i < equityCurve.length; i++) {
|
|
const ret = (equityCurve[i].value - equityCurve[i - 1].value) / equityCurve[i - 1].value;
|
|
returns.push(ret);
|
|
}
|
|
|
|
const totalValue = equityCurve[equityCurve.length - 1].value;
|
|
const totalReturn = totalValue - equityCurve[0].value;
|
|
const totalReturnPercent = (totalReturn / equityCurve[0].value) * 100;
|
|
|
|
const dailyReturn = returns[returns.length - 1];
|
|
const dailyReturnPercent = dailyReturn * 100;
|
|
|
|
const maxDrawdown = analyzeDrawdowns(equityCurve).maxDrawdown;
|
|
const sharpeRatio = calculateSharpeRatio(returns, riskFreeRate);
|
|
const volatility = calculateVolatility(returns);
|
|
|
|
let beta = 0;
|
|
let alpha = 0;
|
|
|
|
if (benchmarkReturns && benchmarkReturns.length === returns.length) {
|
|
beta = calculateBeta(returns, benchmarkReturns);
|
|
alpha = calculateAlpha(returns, benchmarkReturns, riskFreeRate);
|
|
}
|
|
|
|
return {
|
|
totalValue,
|
|
totalReturn,
|
|
totalReturnPercent,
|
|
dailyReturn,
|
|
dailyReturnPercent,
|
|
maxDrawdown,
|
|
sharpeRatio,
|
|
beta,
|
|
alpha,
|
|
volatility
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculate Calmar Ratio
|
|
*/
|
|
export function calmarRatio(returns: number[], equityCurve: Array<{ value: number; date: Date }>, riskFreeRate: number = 0): number {
|
|
const maxDrawdown = analyzeDrawdowns(equityCurve).maxDrawdown;
|
|
const avgReturn = returns.reduce((sum, ret) => sum + ret, 0) / returns.length;
|
|
|
|
return maxDrawdown === 0 ? 0 : (avgReturn - riskFreeRate) / maxDrawdown;
|
|
}
|
|
|
|
/**
|
|
* Calculate Sterling Ratio
|
|
*/
|
|
export function sterlingRatio(returns: number[], equityCurve: Array<{ value: number; date: Date }>, riskFreeRate: number = 0): number {
|
|
const averageDrawdown = analyzeDrawdowns(equityCurve).averageDrawdown;
|
|
const avgReturn = returns.reduce((sum, ret) => sum + ret, 0) / returns.length;
|
|
|
|
return averageDrawdown === 0 ? 0 : (avgReturn - riskFreeRate) / averageDrawdown;
|
|
}
|
|
|
|
/**
|
|
* Calculate Ulcer Index
|
|
*/
|
|
export function ulcerIndex(equityCurve: Array<{ value: number; date: Date }>): number {
|
|
let sumSquaredDrawdown = 0;
|
|
let peak = equityCurve[0].value;
|
|
|
|
for (const point of equityCurve) {
|
|
peak = Math.max(peak, point.value);
|
|
const drawdownPercent = (peak - point.value) / peak * 100;
|
|
sumSquaredDrawdown += drawdownPercent * drawdownPercent;
|
|
}
|
|
|
|
return Math.sqrt(sumSquaredDrawdown / equityCurve.length);
|
|
}
|
|
|
|
/**
|
|
* Calculate Ulcer Performance Index (UPI)
|
|
*/
|
|
export function ulcerPerformanceIndex(returns: number[], equityCurve: Array<{ value: number; date: Date }>, riskFreeRate: number = 0): number {
|
|
const ui = ulcerIndex(equityCurve);
|
|
const avgReturn = returns.reduce((sum, ret) => sum + ret, 0) / returns.length;
|
|
|
|
return ui === 0 ? 0 : (avgReturn - riskFreeRate) / ui;
|
|
}
|
|
|
|
/**
|
|
* Calculate Information Ratio
|
|
*/
|
|
export function informationRatio(portfolioReturns: number[], benchmarkReturns: number[]): number {
|
|
if (portfolioReturns.length !== benchmarkReturns.length) {
|
|
throw new Error("Portfolio and benchmark returns must have the same length.");
|
|
}
|
|
|
|
const excessReturns = portfolioReturns.map((portfolioReturn, index) => portfolioReturn - benchmarkReturns[index]);
|
|
const trackingError = calculateVolatility(excessReturns);
|
|
const avgExcessReturn = excessReturns.reduce((sum, ret) => sum + ret, 0) / excessReturns.length;
|
|
|
|
return trackingError === 0 ? 0 : avgExcessReturn / trackingError;
|
|
}
|
|
|
|
/**
|
|
* Calculate Treynor Ratio
|
|
*/
|
|
export function treynorRatio(portfolioReturns: number[], marketReturns: number[], riskFreeRate: number): number {
|
|
const beta = calculateBeta(portfolioReturns, marketReturns);
|
|
const avgPortfolioReturn = portfolioReturns.reduce((sum, ret) => sum + ret, 0) / portfolioReturns.length;
|
|
|
|
return beta === 0 ? 0 : (avgPortfolioReturn - riskFreeRate) / beta;
|
|
}
|
|
|
|
/**
|
|
* Calculate Jensen's Alpha (same as Alpha, but included for clarity)
|
|
*/
|
|
export function jensensAlpha(portfolioReturns: number[], marketReturns: number[], riskFreeRate: number): number {
|
|
return calculateAlpha(portfolioReturns, marketReturns, riskFreeRate);
|
|
}
|
|
|
|
/**
|
|
* Calculate Capture Ratio (Up Capture and Down Capture)
|
|
*/
|
|
export function captureRatio(portfolioReturns: number[], benchmarkReturns: number[]): { upCaptureRatio: number; downCaptureRatio: number } {
|
|
let upCapture = 0;
|
|
let downCapture = 0;
|
|
let upMarketPeriods = 0;
|
|
let downMarketPeriods = 0;
|
|
|
|
for (let i = 0; i < portfolioReturns.length; i++) {
|
|
if (benchmarkReturns[i] > 0) {
|
|
upCapture += portfolioReturns[i];
|
|
upMarketPeriods++;
|
|
} else if (benchmarkReturns[i] < 0) {
|
|
downCapture += portfolioReturns[i];
|
|
downMarketPeriods++;
|
|
}
|
|
}
|
|
|
|
const upCaptureRatio = upMarketPeriods > 0 ? (upCapture / upMarketPeriods) / (benchmarkReturns.filter(r => r > 0).reduce((sum, r) => sum + r, 0) / upMarketPeriods) : 0;
|
|
const downCaptureRatio = downMarketPeriods > 0 ? (downCapture / downMarketPeriods) / (benchmarkReturns.filter(r => r < 0).reduce((sum, r) => sum + r, 0) / downMarketPeriods) : 0;
|
|
|
|
return { upCaptureRatio, downCaptureRatio };
|
|
}
|
|
|
|
/**
|
|
* Calculate Sortino Ratio
|
|
*/
|
|
export function sortinoRatio(returns: number[], riskFreeRate: number = 0): number {
|
|
const avgReturn = returns.reduce((sum, ret) => sum + ret, 0) / returns.length;
|
|
const downsideReturns = returns.filter(ret => ret < riskFreeRate);
|
|
const downsideDeviation = Math.sqrt(
|
|
downsideReturns.reduce((sum, ret) => sum + Math.pow(ret - riskFreeRate, 2), 0) / returns.length
|
|
);
|
|
|
|
return downsideDeviation === 0 ? 0 : (avgReturn - riskFreeRate) / downsideDeviation;
|
|
}
|
|
|
|
/**
|
|
* Calculate Tail Ratio
|
|
*/
|
|
export function tailRatio(returns: number[], tailPercent: number = 0.1): number {
|
|
const numReturns = returns.length;
|
|
const tailSize = Math.floor(numReturns * tailPercent);
|
|
|
|
if (tailSize === 0) return 0;
|
|
|
|
const sortedReturns = [...returns].sort((a, b) => a - b);
|
|
const worstTail = sortedReturns.slice(0, tailSize);
|
|
const bestTail = sortedReturns.slice(numReturns - tailSize);
|
|
|
|
const avgWorst = worstTail.reduce((sum, ret) => sum + ret, 0) / tailSize;
|
|
const avgBest = bestTail.reduce((sum, ret) => sum + ret, 0) / tailSize;
|
|
|
|
return avgWorst === 0 ? 0 : avgBest / Math.abs(avgWorst);
|
|
}
|
|
|
|
/**
|
|
* Calculate Value at Risk (VaR)
|
|
*/
|
|
export function valueAtRisk(returns: number[], confidenceLevel: number = 0.05): number {
|
|
const sortedReturns = [...returns].sort((a, b) => a - b);
|
|
const varIndex = Math.floor(confidenceLevel * returns.length);
|
|
return sortedReturns[varIndex];
|
|
}
|
|
|
|
/**
|
|
* Calculate Conditional Value at Risk (CVaR) / Expected Shortfall
|
|
*/
|
|
export function conditionalValueAtRisk(returns: number[], confidenceLevel: number = 0.05): number {
|
|
const sortedReturns = [...returns].sort((a, b) => a - b);
|
|
const varIndex = Math.floor(confidenceLevel * returns.length);
|
|
const tailReturns = sortedReturns.slice(0, varIndex + 1);
|
|
return tailReturns.reduce((sum, ret) => sum + ret, 0) / tailReturns.length;
|
|
}
|
|
|
|
/**
|
|
* Calculate Rolling Beta
|
|
*/
|
|
export function calculateRollingBeta(portfolioReturns: number[], marketReturns: number[], windowSize: number): number[] {
|
|
if (portfolioReturns.length !== marketReturns.length || portfolioReturns.length < windowSize) return [];
|
|
|
|
const rollingBetas: number[] = [];
|
|
|
|
for (let i = windowSize; i <= portfolioReturns.length; i++) {
|
|
const portfolioWindow = portfolioReturns.slice(i - windowSize, i);
|
|
const marketWindow = marketReturns.slice(i - windowSize, i);
|
|
rollingBetas.push(calculateBeta(portfolioWindow, marketWindow));
|
|
}
|
|
|
|
return rollingBetas;
|
|
}
|
|
|
|
/**
|
|
* Calculate Rolling Alpha
|
|
*/
|
|
export function calculateRollingAlpha(portfolioReturns: number[], marketReturns: number[], riskFreeRate: number, windowSize: number): number[] {
|
|
if (portfolioReturns.length !== marketReturns.length || portfolioReturns.length < windowSize) return [];
|
|
|
|
const rollingAlphas: number[] = [];
|
|
|
|
for (let i = windowSize; i <= portfolioReturns.length; i++) {
|
|
const portfolioWindow = portfolioReturns.slice(i - windowSize, i);
|
|
const marketWindow = marketReturns.slice(i - windowSize, i);
|
|
rollingAlphas.push(calculateAlpha(portfolioWindow, marketWindow, riskFreeRate));
|
|
}
|
|
|
|
return rollingAlphas;
|
|
}
|
|
|
|
/**
|
|
* Calculate Time Weighted Rate of Return (TWRR)
|
|
*/
|
|
export function timeWeightedRateOfReturn(cashFlows: Array<{ amount: number; date: Date; value: number }>): number {
|
|
let totalReturn = 1;
|
|
let previousValue = cashFlows[0].value;
|
|
|
|
for (let i = 1; i < cashFlows.length; i++) {
|
|
const current = cashFlows[i];
|
|
const periodReturn = (current.value - previousValue - current.amount) / (previousValue + current.amount);
|
|
totalReturn *= (1 + periodReturn);
|
|
previousValue = current.value;
|
|
}
|
|
|
|
return totalReturn - 1;
|
|
}
|
|
|
|
/**
|
|
* Calculate Money Weighted Rate of Return (MWRR) - Approximation using IRR
|
|
*/
|
|
export function moneyWeightedRateOfReturn(cashFlows: Array<{ amount: number; date: Date; value: number }>): number {
|
|
// Approximate MWRR using Internal Rate of Return (IRR)
|
|
// This requires a numerical method or library for accurate IRR calculation
|
|
// This is a simplified example and may not be accurate for all cases
|
|
|
|
let totalCashFlow = 0;
|
|
let totalWeightedCashFlow = 0;
|
|
const startDate = cashFlows[0].date.getTime();
|
|
|
|
for (const cf of cashFlows) {
|
|
const timeDiff = (cf.date.getTime() - startDate) / (1000 * 60 * 60 * 24 * 365); // Years
|
|
totalCashFlow += cf.amount;
|
|
totalWeightedCashFlow += cf.amount * timeDiff;
|
|
}
|
|
|
|
// Simplified approximation: MWRR ≈ totalCashFlow / totalWeightedCashFlow - 1
|
|
return totalCashFlow / totalWeightedCashFlow - 1;
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
function calculateSharpeRatio(returns: number[], riskFreeRate: number = 0): number {
|
|
if (returns.length < 2) return 0;
|
|
|
|
const avgReturn = returns.reduce((sum, ret) => sum + ret, 0) / returns.length;
|
|
const variance = returns.reduce((sum, ret) => sum + Math.pow(ret - avgReturn, 2), 0) / (returns.length - 1);
|
|
const stdDev = Math.sqrt(variance);
|
|
|
|
return stdDev === 0 ? 0 : (avgReturn - riskFreeRate) / stdDev;
|
|
}
|
|
|
|
function calculateVolatility(returns: number[]): number {
|
|
if (returns.length < 2) return 0;
|
|
|
|
const mean = returns.reduce((sum, ret) => sum + ret, 0) / returns.length;
|
|
const variance = returns.reduce((sum, ret) => sum + Math.pow(ret - mean, 2), 0) / (returns.length - 1);
|
|
|
|
return Math.sqrt(variance);
|
|
}
|
|
|
|
function calculateBeta(portfolioReturns: number[], marketReturns: number[]): number {
|
|
if (portfolioReturns.length !== marketReturns.length || portfolioReturns.length < 2) return 0;
|
|
|
|
const portfolioMean = portfolioReturns.reduce((sum, ret) => sum + ret, 0) / portfolioReturns.length;
|
|
const marketMean = marketReturns.reduce((sum, ret) => sum + ret, 0) / marketReturns.length;
|
|
|
|
let covariance = 0;
|
|
let marketVariance = 0;
|
|
|
|
for (let i = 0; i < portfolioReturns.length; i++) {
|
|
const portfolioDiff = portfolioReturns[i] - portfolioMean;
|
|
const marketDiff = marketReturns[i] - marketMean;
|
|
|
|
covariance += portfolioDiff * marketDiff;
|
|
marketVariance += marketDiff * marketDiff;
|
|
}
|
|
|
|
covariance /= (portfolioReturns.length - 1);
|
|
marketVariance /= (marketReturns.length - 1);
|
|
|
|
return marketVariance === 0 ? 0 : covariance / marketVariance;
|
|
}
|
|
|
|
function calculateAlpha(
|
|
portfolioReturns: number[],
|
|
marketReturns: number[],
|
|
riskFreeRate: number
|
|
): number {
|
|
const portfolioMean = portfolioReturns.reduce((sum, ret) => sum + ret, 0) / portfolioReturns.length;
|
|
const marketMean = marketReturns.reduce((sum, ret) => sum + ret, 0) / marketReturns.length;
|
|
const beta = calculateBeta(portfolioReturns, marketReturns);
|
|
|
|
return portfolioMean - (riskFreeRate + beta * (marketMean - riskFreeRate));
|
|
}
|
|
|
|
function calculateSkewness(returns: number[]): number {
|
|
if (returns.length < 3) return 0;
|
|
|
|
const mean = returns.reduce((sum, ret) => sum + ret, 0) / returns.length;
|
|
const variance = returns.reduce((sum, ret) => sum + Math.pow(ret - mean, 2), 0) / returns.length;
|
|
const stdDev = Math.sqrt(variance);
|
|
|
|
if (stdDev === 0) return 0;
|
|
|
|
const skew = returns.reduce((sum, ret) => sum + Math.pow((ret - mean) / stdDev, 3), 0) / returns.length;
|
|
|
|
return skew;
|
|
}
|
|
|
|
function calculateKurtosis(returns: number[]): number {
|
|
if (returns.length < 4) return 0;
|
|
|
|
const mean = returns.reduce((sum, ret) => sum + ret, 0) / returns.length;
|
|
const variance = returns.reduce((sum, ret) => sum + Math.pow(ret - mean, 2), 0) / returns.length;
|
|
const stdDev = Math.sqrt(variance);
|
|
|
|
if (stdDev === 0) return 0;
|
|
|
|
const kurt = returns.reduce((sum, ret) => sum + Math.pow((ret - mean) / stdDev, 4), 0) / returns.length;
|
|
|
|
return kurt - 3; // Excess kurtosis
|
|
}
|
|
|
|
function aggregateMonthlyReturns(returns: Array<{ return: number; date: Date }>): number[] {
|
|
const monthlyReturns: { [key: string]: number } = {};
|
|
|
|
for (const ret of returns) {
|
|
const monthKey = `${ret.date.getFullYear()}-${ret.date.getMonth()}`;
|
|
if (!monthlyReturns[monthKey]) {
|
|
monthlyReturns[monthKey] = 1;
|
|
}
|
|
monthlyReturns[monthKey] *= (1 + ret.return);
|
|
}
|
|
|
|
return Object.values(monthlyReturns).map(cumReturn => cumReturn - 1);
|
|
}
|