UNPKG

quantstats-js

Version:

Comprehensive portfolio analytics and professional tearsheet generation library for JavaScript/Node.js - create beautiful HTML reports with 14+ financial charts and 40+ metrics

1,512 lines (1,262 loc) 69 kB
/** * Statistics module for QuantStats.js * Exact mathematical implementations matching Python QuantStats */ import { prepareReturns, toDrawdownSeries, aggregateReturns, makePosNeg, normalInverseCDF, getPeriodicReturns, monthToDateReturns, yearToDateReturns, resampleMonthlySum, resampleYearlySum, filterMTDReturns, filterYTDReturns, filterMonthsBackReturns, filterYearsBackReturns } from './utils.js'; // Constants const TRADING_DAYS_PER_YEAR = 252; const TRADING_DAYS_PER_MONTH = 21; /** * Convert returns to prices * Exactly matches Python to_prices function * @param {Array} returns - Returns array * @param {number} base - Base price (default 100000) * @returns {Array} Price series */ function toPrices(returns, base = 100000) { if (returns.length === 0) { return []; } // Python: base + base * compsum(returns) // where compsum(returns) = returns.add(1).cumprod() - 1 const prices = [base]; let cumulative = 1; for (let i = 0; i < returns.length; i++) { cumulative *= (1 + returns[i]); prices.push(base + base * (cumulative - 1)); } return prices.slice(1); // Remove the initial base value } /** * Calculate total compounded returns * Exactly matches Python comp() function * @param {Array} returns - Returns array * @returns {number} Total compounded return */ function comp(returns) { if (returns.length === 0) { return 0; } // Python: returns.add(1).prod() - 1 return returns.reduce((prod, ret) => prod * (1 + ret), 1) - 1; } /** * Calculate CAGR (Compound Annual Growth Rate) * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {number} rfRate - Risk-free rate (default 0) * @param {boolean} nans - Include NaN values (default false) * @param {Array} dates - Optional dates array for proper time calculation * @returns {number} CAGR */ export function cagr(returns, rfRate = 0, nans = false, dates = null) { const cleanReturns = prepareReturns(returns, rfRate, nans); if (cleanReturns.length === 0) { return 0; } // Calculate total return using comp() function to match Python const totalReturn = comp(cleanReturns); // Calculate years - match Python's method let years; if (dates && dates.length >= 2) { // Use actual calendar days like Python: (returns.index[-1] - returns.index[0]).days / 365 const startDate = new Date(dates[0]); const endDate = new Date(dates[dates.length - 1]); const daysDiff = (endDate - startDate) / (1000 * 60 * 60 * 24); years = daysDiff / 365; } else { // Fallback to trading days method years = cleanReturns.length / TRADING_DAYS_PER_YEAR; } if (years === 0) { return 0; } // CAGR = abs(total + 1.0) ^ (1/years) - 1 (match Python exactly) return Math.pow(Math.abs(totalReturn + 1.0), 1 / years) - 1; } /** * Calculate Sharpe Ratio * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {number} rfRate - Risk-free rate (default 0) * @param {boolean} nans - Include NaN values (default false) * @returns {number} Sharpe ratio */ export function sharpe(returns, rfRate = 0, nans = false) { const cleanReturns = prepareReturns(returns, rfRate, nans); if (cleanReturns.length === 0) { return 0; } if (cleanReturns.length === 1) { return 0; // Cannot calculate Sharpe ratio with single value } const mean = cleanReturns.reduce((sum, ret) => sum + ret, 0) / cleanReturns.length; // Use sample standard deviation (ddof=1) like Python const variance = cleanReturns.reduce((sum, ret) => sum + Math.pow(ret - mean, 2), 0) / (cleanReturns.length - 1); const std = Math.sqrt(variance); if (std === 0) { return 0; } // Correct annualized Sharpe ratio: (mean / std) * sqrt(252) return (mean / std) * Math.sqrt(TRADING_DAYS_PER_YEAR); } /** * Calculate Sortino Ratio * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {number} rfRate - Risk-free rate (default 0) * @param {boolean} nans - Include NaN values (default false) * @returns {number} Sortino ratio */ export function sortino(returns, rfRate = 0, nans = false) { const cleanReturns = prepareReturns(returns, rfRate, nans); if (cleanReturns.length === 0) { return 0; } if (cleanReturns.length === 1) { return 0; // Cannot calculate Sortino ratio with single value } const mean = cleanReturns.reduce((sum, ret) => sum + ret, 0) / cleanReturns.length; // Calculate downside deviation (only negative returns) const negativeReturns = cleanReturns.filter(ret => ret < 0); if (negativeReturns.length === 0) { return Infinity; } // Use sample standard deviation approach (ddof=1 equivalent) const downsideVariance = negativeReturns.reduce((sum, ret) => sum + Math.pow(ret, 2), 0) / (cleanReturns.length - 1); const downsideStd = Math.sqrt(downsideVariance); if (downsideStd === 0) { return 0; } // Correct annualized Sortino ratio: (mean / downside_std) * sqrt(252) return (mean / downsideStd) * Math.sqrt(TRADING_DAYS_PER_YEAR); } /** * Calculate Calmar Ratio * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {number} rfRate - Risk-free rate (default 0) * @param {boolean} nans - Include NaN values (default false) * @param {Array} dates - Optional dates array for proper time calculation * @returns {number} Calmar ratio */ export function calmar(returns, rfRate = 0, nans = false, dates = null) { const cleanReturns = prepareReturns(returns, rfRate, nans); const annualizedReturn = cagr(cleanReturns, 0, nans, dates); const maxDD = maxDrawdown(cleanReturns, nans); if (maxDD === 0) { return 0; } return annualizedReturn / Math.abs(maxDD); } /** * Calculate Volatility (annualized standard deviation) * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Volatility */ export function volatility(returns, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); if (cleanReturns.length === 0) { return 0; } if (cleanReturns.length === 1) { return 0; // Cannot calculate volatility with single value } const mean = cleanReturns.reduce((sum, ret) => sum + ret, 0) / cleanReturns.length; // Use sample standard deviation (ddof=1) like Python const variance = cleanReturns.reduce((sum, ret) => sum + Math.pow(ret - mean, 2), 0) / (cleanReturns.length - 1); const std = Math.sqrt(variance); // Annualized volatility return std * Math.sqrt(TRADING_DAYS_PER_YEAR); } /** * Calculate Maximum Drawdown * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Maximum drawdown */ /** * Calculate maximum drawdown * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Maximum drawdown */ export function maxDrawdown(returns, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); if (cleanReturns.length === 0) { return 0; } // Convert returns to prices like Python's _prepare_prices const prices = toPrices(cleanReturns); // Calculate expanding max and drawdown like Python // Python: (prices / prices.expanding(min_periods=0).max()).min() - 1 const expandingMax = []; let currentMax = prices[0]; for (let i = 0; i < prices.length; i++) { if (prices[i] > currentMax) { currentMax = prices[i]; } expandingMax.push(currentMax); } // Calculate drawdown series const drawdowns = prices.map((price, i) => price / expandingMax[i] - 1); // Return minimum drawdown return Math.min(...drawdowns); } /** * Calculate drawdown series * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {Array} Drawdown series */ export function drawdown(returns, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); return toDrawdownSeries(cleanReturns); } /** * Calculate drawdown details - periods, max drawdowns, etc. * Matches Python drawdown_details function * @param {Array} drawdownSeries - Drawdown series (from drawdown function) * @returns {Array} Array of drawdown periods with details */ export function getDrawdownDetails(drawdownSeries) { const details = []; let inDrawdown = false; let currentPeriod = null; for (let i = 0; i < drawdownSeries.length; i++) { const dd = drawdownSeries[i]; if (dd < 0 && !inDrawdown) { // Start of new drawdown period inDrawdown = true; currentPeriod = { start: i, valley: i, minDrawdown: dd, days: 1 }; } else if (dd < 0 && inDrawdown) { // Continue drawdown period currentPeriod.days++; if (dd < currentPeriod.minDrawdown) { currentPeriod.minDrawdown = dd; currentPeriod.valley = i; } } else if (dd >= 0 && inDrawdown) { // End of drawdown period currentPeriod.end = i - 1; details.push(currentPeriod); inDrawdown = false; currentPeriod = null; } } // Handle case where series ends in drawdown if (inDrawdown && currentPeriod) { currentPeriod.end = drawdownSeries.length - 1; details.push(currentPeriod); } return details; } /** * Calculate average drawdown (mean of max drawdowns from each period) * Matches Python implementation: ret_dd["max drawdown"].mean() / 100 * @param {Array} returns - Returns array * @returns {number} Average drawdown */ export function averageDrawdown(returns, dates = null) { const drawdownSeries = drawdown(returns); if (dates) { const details = drawdownDetailsWithDates(drawdownSeries, dates); if (details.length === 0) return 0; const maxDrawdowns = details.map(period => period.minDrawdown); return maxDrawdowns.reduce((sum, dd) => sum + dd, 0) / maxDrawdowns.length; } else { const details = drawdownDetails(drawdownSeries); if (details.length === 0) return 0; const maxDrawdowns = details.map(period => period.minDrawdown); return maxDrawdowns.reduce((sum, dd) => sum + dd, 0) / maxDrawdowns.length; } } /** * Calculate Win Rate * Exactly matches Python implementation: len(series[series > 0]) / len(series[series != 0]) * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Win rate (0-1) */ export function winRate(returns, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); if (cleanReturns.length === 0) { return 0; } const wins = cleanReturns.filter(ret => ret > 0).length; const nonZeroReturns = cleanReturns.filter(ret => ret !== 0).length; if (nonZeroReturns === 0) { return 0; } return wins / nonZeroReturns; } /** * Calculate Average Win * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Average win */ export function avgWin(returns, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); const wins = cleanReturns.filter(ret => ret > 0); if (wins.length === 0) { return 0; } return wins.reduce((sum, ret) => sum + ret, 0) / wins.length; } /** * Calculate Average Loss * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Average loss */ export function avgLoss(returns, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); const losses = cleanReturns.filter(ret => ret < 0); if (losses.length === 0) { return 0; } return losses.reduce((sum, ret) => sum + ret, 0) / losses.length; } /** * Calculate Profit Factor * Exactly matches Python implementation: abs(wins_sum / losses_sum) where wins >= 0 * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Profit factor */ export function profitFactor(returns, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); // Python: returns[returns >= 0].sum() / returns[returns < 0].sum() const wins = cleanReturns.filter(ret => ret >= 0); const losses = cleanReturns.filter(ret => ret < 0); const grossProfit = wins.reduce((sum, ret) => sum + ret, 0); const grossLoss = Math.abs(losses.reduce((sum, ret) => sum + ret, 0)); if (grossLoss === 0) { return Infinity; } return Math.abs(grossProfit / grossLoss); } /** * Calculate Expected Return * Exactly matches Python implementation with optional aggregation * @param {Array} returns - Returns array * @param {string} aggregate - Optional aggregation ('M', 'A', etc.) * @param {boolean} compounded - Use compounded returns (default true) * @param {boolean} nans - Include NaN values (default false) * @returns {number} Expected return */ export function expectedReturn(returns, aggregate = null, compounded = true, nans = false, dates = null) { let workingReturns = prepareReturns(returns, 0, nans); // Apply aggregation if specified - now with date-awareness if (aggregate === 'M' && dates) { // Use date-aware monthly resampling workingReturns = resampleMonthlySum(workingReturns, dates, compounded); } else if (aggregate === 'A' && dates) { // Use date-aware yearly resampling workingReturns = resampleYearlySum(workingReturns, dates, compounded); } else if (aggregate) { // Fallback to approximation workingReturns = aggregateReturns(workingReturns, aggregate, compounded); } if (workingReturns.length === 0) { return 0; } // Python: np.product(1 + returns) ** (1 / len(returns)) - 1 // This is the geometric mean (GHPR) const product = workingReturns.reduce((prod, ret) => prod * (1 + ret), 1); return Math.pow(product, 1 / workingReturns.length) - 1; } /** * Calculate Value at Risk (VaR) * Exactly matches Python implementation using variance-covariance method * @param {Array} returns - Returns array * @param {number} sigma - Sigma multiplier (default 1) * @param {number} confidence - Confidence level (default 0.95) * @param {boolean} nans - Include NaN values (default false) * @returns {number} Value at Risk */ export function valueAtRisk(returns, sigma = 1, confidence = 0.95, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); if (cleanReturns.length === 0) { return 0; } // Calculate mean and standard deviation (use ddof=1 like Python pandas) const mu = cleanReturns.reduce((sum, val) => sum + val, 0) / cleanReturns.length; const variance = cleanReturns.reduce((sum, val) => sum + Math.pow(val - mu, 2), 0) / (cleanReturns.length - 1); const std = Math.sqrt(variance); const sigmaStd = sigma * std; // Convert confidence to appropriate format if needed let conf = confidence; if (conf > 1) { conf = conf / 100; } // Ensure conf is in valid range for normalInverseCDF conf = Math.max(0.001, Math.min(0.999, conf)); try { // Calculate normal inverse CDF (ppf) // This matches Python's _norm.ppf(1 - confidence, mu, sigma) const prob = 1 - conf; if (prob <= 0 || prob >= 1) { return mu - 1.96 * sigmaStd; // Fallback to 95% confidence } return normalInverseCDF(prob, mu, sigmaStd); } catch (error) { console.warn('VaR calculation failed:', error.message); // Return fallback using 95% confidence normal approximation return mu - 1.96 * sigmaStd; } } /** * Calculate Conditional Value at Risk (CVaR/Expected Shortfall) * Exactly matches Python implementation using empirical method * @param {Array} returns - Returns array * @param {number} sigma - Sigma multiplier (default 1) * @param {number} confidence - Confidence level (default 0.95) * @param {boolean} nans - Include NaN values (default false) * @returns {number} Conditional Value at Risk */ export function cvar(returns, sigma = 1, confidence = 0.95, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); if (cleanReturns.length === 0) { return 0; } // First calculate VaR using variance-covariance method (Python implementation) const var95 = valueAtRisk(returns, sigma, confidence, nans); // Python: returns[returns < var].values.mean() const belowVar = cleanReturns.filter(ret => ret < var95); if (belowVar.length === 0) { return var95; // Return VaR if no returns below threshold } const cVarResult = belowVar.reduce((sum, ret) => sum + ret, 0) / belowVar.length; // Python: return c_var if ~np.isnan(c_var) else var return !isNaN(cVarResult) ? cVarResult : var95; } /** * Calculate Skewness * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Skewness */ export function skew(returns, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); const n = cleanReturns.length; if (n <= 1) { return 0; } const mean = cleanReturns.reduce((sum, ret) => sum + ret, 0) / n; // Use sample standard deviation (divide by n-1) const variance = cleanReturns.reduce((sum, ret) => sum + Math.pow(ret - mean, 2), 0) / (n - 1); const std = Math.sqrt(variance); if (std === 0) { return 0; } // Calculate sample skewness with bias correction const skewness = cleanReturns.reduce((sum, ret) => sum + Math.pow((ret - mean) / std, 3), 0) / n; // Apply bias correction factor (matches pandas) const biasCorrection = Math.sqrt(n * (n - 1)) / (n - 2); return n <= 2 ? 0 : skewness * biasCorrection; } /** * Calculate Kurtosis * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Kurtosis */ export function kurtosis(returns, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); const n = cleanReturns.length; if (n <= 1) { return 0; } const mean = cleanReturns.reduce((sum, ret) => sum + ret, 0) / n; // Use sample standard deviation (divide by n-1) const variance = cleanReturns.reduce((sum, ret) => sum + Math.pow(ret - mean, 2), 0) / (n - 1); const std = Math.sqrt(variance); if (std === 0) { return 0; } // Calculate sample kurtosis with bias correction const kurtosis = cleanReturns.reduce((sum, ret) => sum + Math.pow((ret - mean) / std, 4), 0) / n; // Apply bias correction factor (matches pandas) const biasCorrection = (n - 1) * ((n + 1) * kurtosis - 3 * (n - 1)) / ((n - 2) * (n - 3)); // Return excess kurtosis (pandas default) return n <= 3 ? 0 : biasCorrection; } /** * Calculate Kelly Criterion * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Kelly criterion */ export function kelly(returns, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); const { positive, negative } = makePosNeg(cleanReturns); if (positive.length === 0 || negative.length === 0) { return 0; } // Use Python logic: win_prob = len(positive) / len(non_zero) const nonZeroReturns = cleanReturns.filter(ret => ret !== 0); if (nonZeroReturns.length === 0) { return 0; } const winProb = positive.length / nonZeroReturns.length; const loseProb = 1 - winProb; const avgWinReturn = positive.reduce((sum, ret) => sum + ret, 0) / positive.length; const avgLossReturn = Math.abs(negative.reduce((sum, ret) => sum + ret, 0) / negative.length); if (avgLossReturn === 0) { return 0; } // Python formula: ((win_loss_ratio * win_prob) - lose_prob) / win_loss_ratio const payoffRatio = avgWinReturn / avgLossReturn; return ((payoffRatio * winProb) - loseProb) / payoffRatio; } /** * Calculate Total Return * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Total return */ export function totalReturn(returns, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); if (cleanReturns.length === 0) { return 0; } return cleanReturns.reduce((prod, ret) => prod * (1 + ret), 1) - 1; } /** * Calculate Compound Return * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Compound return */ export function compoundReturn(returns, nans = false) { return totalReturn(returns, nans); } /** * Calculate Beta relative to benchmark * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {Array} benchmark - Benchmark returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Beta */ export function beta(returns, benchmark, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); const cleanBenchmark = prepareReturns(benchmark, 0, nans); const minLength = Math.min(cleanReturns.length, cleanBenchmark.length); if (minLength === 0) { return 0; } const returnsSlice = cleanReturns.slice(0, minLength); const benchmarkSlice = cleanBenchmark.slice(0, minLength); // Calculate covariance and variance const returnsMean = returnsSlice.reduce((sum, ret) => sum + ret, 0) / returnsSlice.length; const benchmarkMean = benchmarkSlice.reduce((sum, ret) => sum + ret, 0) / benchmarkSlice.length; let covariance = 0; let benchmarkVariance = 0; for (let i = 0; i < minLength; i++) { const returnsDiff = returnsSlice[i] - returnsMean; const benchmarkDiff = benchmarkSlice[i] - benchmarkMean; covariance += returnsDiff * benchmarkDiff; benchmarkVariance += benchmarkDiff * benchmarkDiff; } covariance /= minLength; benchmarkVariance /= minLength; if (benchmarkVariance === 0) { return 0; } return covariance / benchmarkVariance; } /** * Calculate Alpha relative to benchmark * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {Array} benchmark - Benchmark returns array * @param {number} rfRate - Risk-free rate (default 0) * @param {boolean} nans - Include NaN values (default false) * @returns {number} Alpha */ export function alpha(returns, benchmark, rfRate = 0, nans = false) { const cleanReturns = prepareReturns(returns, rfRate, nans); const cleanBenchmark = prepareReturns(benchmark, rfRate, nans); const portfolioBeta = beta(cleanReturns, cleanBenchmark, nans); const portfolioReturn = expectedReturn(cleanReturns, null, nans); const benchmarkReturn = expectedReturn(cleanBenchmark, null, nans); // Alpha = Portfolio Return - (Beta * Benchmark Return) return portfolioReturn - (portfolioBeta * benchmarkReturn); } /** * Calculate Ulcer Index * Exactly matches Python implementation: sqrt(sum(dd^2) / (n-1)) * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Ulcer Index */ export function ulcerIndex(returns, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); const drawdowns = toDrawdownSeries(cleanReturns); if (drawdowns.length === 0) { return 0; } // Python: np.sqrt(np.divide((dd**2).sum(), returns.shape[0] - 1)) const squaredDrawdowns = drawdowns.map(dd => Math.pow(dd, 2)); const sumSquaredDrawdowns = squaredDrawdowns.reduce((sum, sq) => sum + sq, 0); return Math.sqrt(sumSquaredDrawdowns / (cleanReturns.length - 1)); } /** * Calculate Ulcer Performance Index * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Ulcer Performance Index */ export function ulcerPerformanceIndex(returns, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); const annualizedReturn = cagr(cleanReturns, 0, nans); const ulcer = ulcerIndex(cleanReturns, nans); if (ulcer === 0) { return 0; } return annualizedReturn / ulcer; } /** * Calculate Downside Deviation * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Downside deviation */ export function downsideDeviation(returns, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); const negativeReturns = cleanReturns.filter(ret => ret < 0); if (negativeReturns.length === 0) { return 0; } const variance = negativeReturns.reduce((sum, ret) => sum + Math.pow(ret, 2), 0) / cleanReturns.length; return Math.sqrt(variance) * Math.sqrt(TRADING_DAYS_PER_YEAR); } /** * Calculate monthly returns using date-aware resampling * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @param {Array} dates - Optional dates array for proper resampling * @param {boolean} compounded - Whether to compound returns (default true) * @returns {Array} Monthly returns */ export function monthlyReturns(returns, nans = false, dates = null, compounded = true) { const cleanReturns = prepareReturns(returns, 0, nans); if (dates && dates.length === cleanReturns.length) { // Use date-aware resampling like Python pandas .resample('M') return resampleMonthlySum(cleanReturns, dates, compounded); } else { // Fallback to approximation return aggregateReturns(cleanReturns, 'monthly', compounded); } } /** * Calculate yearly returns using date-aware resampling * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @param {Array} dates - Optional dates array for proper resampling * @param {boolean} compounded - Whether to compound returns (default true) * @returns {Array} Yearly returns */ export function yearlyReturns(returns, nans = false, dates = null, compounded = true) { const cleanReturns = prepareReturns(returns, 0, nans); if (dates && dates.length === cleanReturns.length) { // Use date-aware resampling like Python pandas .resample('A') return resampleYearlySum(cleanReturns, dates, compounded); } else { // Fallback to approximation return aggregateReturns(cleanReturns, 'yearly', compounded); } } /** * Returns outliers from returns array * Exactly matches Python to_prices function * @param {Array} returns - Returns array * @param {number} quantile - Quantile threshold (default 0.95) * @param {boolean} nans - Include NaN values (default false) * @returns {Array} Outliers */ export function outliers(returns, quantile = 0.95, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); const sorted = [...cleanReturns].sort((a, b) => a - b); const threshold = sorted[Math.floor(sorted.length * quantile)]; return cleanReturns.filter(ret => ret > threshold); } /** * Remove outliers from returns array * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {number} quantile - Quantile threshold (default 0.95) * @param {boolean} nans - Include NaN values (default false) * @returns {Array} Returns without outliers */ export function removeOutliers(returns, quantile = 0.95, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); const sorted = [...cleanReturns].sort((a, b) => a - b); const threshold = sorted[Math.floor(sorted.length * quantile)]; return cleanReturns.filter(ret => ret < threshold); } /** * Returns the best return for a period * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Best return */ export function best(returns, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); return Math.max(...cleanReturns); } /** * Returns the worst return for a period * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Worst return */ export function worst(returns, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); return Math.min(...cleanReturns); } /** * Calculate consecutive wins * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Maximum consecutive wins */ export function consecutiveWins(returns, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); let maxConsecutive = 0; let currentConsecutive = 0; for (const ret of cleanReturns) { if (ret > 0) { currentConsecutive++; maxConsecutive = Math.max(maxConsecutive, currentConsecutive); } else { currentConsecutive = 0; } } return maxConsecutive; } /** * Calculate consecutive losses * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Maximum consecutive losses */ export function consecutiveLosses(returns, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); let maxConsecutive = 0; let currentConsecutive = 0; for (const ret of cleanReturns) { if (ret < 0) { currentConsecutive++; maxConsecutive = Math.max(maxConsecutive, currentConsecutive); } else { currentConsecutive = 0; } } return maxConsecutive; } /** * Calculate exposure (percentage of time in market) * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Market exposure percentage */ export function exposure(returns, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); const nonZeroReturns = cleanReturns.filter(ret => ret !== 0); return Math.ceil((nonZeroReturns.length / cleanReturns.length) * 100) / 100; } /** * Calculate geometric mean * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Geometric mean */ export function geometricMean(returns, nans = false) { return expectedReturn(returns, null, nans); } /** * Calculate gain-to-pain ratio * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {number} rfRate - Risk-free rate (default 0) * @param {boolean} nans - Include NaN values (default false) * @returns {number} Gain-to-pain ratio */ export function gainToPainRatio(returns, rfRate = 0, nans = false) { const cleanReturns = prepareReturns(returns, rfRate, nans); if (cleanReturns.length === 0) { return 0; } // Python: returns.sum() / abs(returns[returns < 0].sum()) const totalReturns = cleanReturns.reduce((sum, ret) => sum + ret, 0); const negativeReturns = cleanReturns.filter(ret => ret < 0); if (negativeReturns.length === 0) { return totalReturns > 0 ? Infinity : 0; } const downside = Math.abs(negativeReturns.reduce((sum, ret) => sum + ret, 0)); return downside === 0 ? 0 : totalReturns / downside; } // Monthly Gain/Pain ratio export function gainToPainRatioMonthly(returns, riskFreeRate = 0, dates = null) { if (dates) { // Use proper Python-style monthly resampling with dates const preparedReturns = prepareReturns(returns, riskFreeRate); const monthlyReturns = resampleMonthlySum(preparedReturns, dates); if (monthlyReturns.length === 0) return 0; // Python: returns.sum() / abs(returns[returns < 0].sum()) const totalReturn = monthlyReturns.reduce((sum, ret) => sum + ret, 0); const negativeReturns = monthlyReturns.filter(ret => ret < 0); const totalLosses = Math.abs(negativeReturns.reduce((sum, loss) => sum + loss, 0)); if (totalLosses === 0) return totalReturn > 0 ? Infinity : 0; return totalReturn / totalLosses; } else { // Fallback to old method if no dates provided const preparedReturns = prepareReturns(returns, riskFreeRate); const monthlyReturns = aggregateReturns(preparedReturns, 'monthly', false); if (monthlyReturns.length === 0) return 0; const totalReturn = monthlyReturns.reduce((sum, ret) => sum + ret, 0); const negativeReturns = monthlyReturns.filter(ret => ret < 0); const totalLosses = Math.abs(negativeReturns.reduce((sum, loss) => sum + loss, 0)); if (totalLosses === 0) return totalReturn > 0 ? Infinity : 0; return totalReturn / totalLosses; } } /** * Calculate Treynor ratio * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {Array} benchmark - Benchmark returns * @param {number} rfRate - Risk-free rate (default 0) * @param {boolean} nans - Include NaN values (default false) * @returns {number} Treynor ratio */ export function treynorRatio(returns, benchmark, rfRate = 0, nans = false) { const cleanReturns = prepareReturns(returns, rfRate, nans); const cleanBenchmark = prepareReturns(benchmark, rfRate, nans); const portfolioBeta = beta(cleanReturns, cleanBenchmark, nans); const excessReturn = expectedReturn(cleanReturns, null, nans) - rfRate; return portfolioBeta === 0 ? 0 : excessReturn / portfolioBeta; } /** * Calculate risk of ruin * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Risk of ruin */ export function riskOfRuin(returns, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); if (cleanReturns.length === 0) { return 0; } // Python: ((1 - wins) / (1 + wins)) ** len(returns) const wins = winRate(cleanReturns, nans); if (wins === 1) return 0; // No losses, no risk of ruin if (wins === 0) return 1; // No wins, certain ruin return Math.pow((1 - wins) / (1 + wins), cleanReturns.length); } /** * Calculate serenity index * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {number} rfRate - Risk-free rate (default 0) * @param {boolean} nans - Include NaN values (default false) * @returns {number} Serenity index */ export function serenityIndex(returns, rfRate = 0, nans = false) { // Don't use prepareReturns here - Python uses original returns directly // Get drawdown series from original returns const drawdowns = toDrawdownSeries(returns); // Calculate pitfall = -cvar(dd) / returns.std() const cvarDD = cvar(drawdowns, 1, 0.95, nans); // CVaR of drawdowns // Calculate sample standard deviation (ddof=1, pandas default) const returnsMean = returns.reduce((sum, ret) => sum + ret, 0) / returns.length; const sampleVariance = returns.reduce((sum, ret) => sum + Math.pow(ret - returnsMean, 2), 0) / (returns.length - 1); // N-1 denominator const returnsStd = Math.sqrt(sampleVariance); const pitfall = -cvarDD / returnsStd; // Calculate ulcer index from original returns const ulcer = ulcerIndex(returns, nans); // Calculate sum of original returns const returnsSum = returns.reduce((sum, ret) => sum + ret, 0); if (ulcer === 0 || pitfall === 0) { return 0; } // Python: (returns.sum() - rf) / (ulcer_index(returns) * pitfall) return (returnsSum - rfRate) / (ulcer * pitfall); } /** * Calculate rolling annual return * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {number} rfRate - Risk-free rate (default 0) * @param {boolean} nans - Include NaN values (default false) * @returns {number} Rolling annual return */ export function rar(returns, rfRate = 0, nans = false) { return cagr(returns, rfRate, nans); } /** * Calculate compounded sum of returns * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {Array} Compounded sum series */ export function compsum(returns, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); const result = []; let compound = 1; for (const ret of cleanReturns) { compound *= (1 + ret); result.push(compound - 1); } return result; } /** * Calculate Omega ratio * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {number} rfRate - Risk-free rate (default 0) * @param {number} requiredReturn - Required return threshold (default 0) * @param {number} periods - Periods per year (default 252) * @param {boolean} nans - Include NaN values (default false) * @returns {number} Omega ratio */ export function omega(returns, rfRate = 0, requiredReturn = 0, periods = 252, nans = false) { const cleanReturns = prepareReturns(returns, rfRate, nans); if (cleanReturns.length < 2) { return NaN; } if (requiredReturn <= -1) { return NaN; } const returnThreshold = periods === 1 ? requiredReturn : Math.pow(1 + requiredReturn, 1 / periods) - 1; const returnsLessThresh = cleanReturns.map(ret => ret - returnThreshold); const numer = returnsLessThresh.filter(ret => ret > 0).reduce((sum, ret) => sum + ret, 0); const denom = -1 * returnsLessThresh.filter(ret => ret < 0).reduce((sum, ret) => sum + ret, 0); if (denom > 0) { return numer / denom; } return NaN; } /** * Calculate Information Ratio * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {Array} benchmark - Benchmark returns * @param {boolean} nans - Include NaN values (default false) * @returns {number} Information ratio */ export function informationRatio(returns, benchmark, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); const cleanBenchmark = prepareReturns(benchmark, 0, nans); // Calculate excess returns const excessReturns = cleanReturns.map((ret, i) => ret - (cleanBenchmark[i] || 0)); // Calculate mean and standard deviation of excess returns const mean = excessReturns.reduce((sum, ret) => sum + ret, 0) / excessReturns.length; const variance = excessReturns.reduce((sum, ret) => sum + Math.pow(ret - mean, 2), 0) / (excessReturns.length - 1); const std = Math.sqrt(variance); return std === 0 ? 0 : mean / std; } /** * Calculate Greeks (alpha and beta) * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {Array} benchmark - Benchmark returns * @param {number} periods - Periods per year (default 252) * @param {boolean} nans - Include NaN values (default false) * @returns {Object} Object with alpha and beta */ export function greeks(returns, benchmark, periods = 252, nans = false) { const portfolioBeta = beta(returns, benchmark, nans); const portfolioAlpha = alpha(returns, benchmark, 0, nans); return { alpha: portfolioAlpha, beta: portfolioBeta }; } /** * Calculate autocorrelation penalty * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {boolean} nans - Include NaN values (default false) * @returns {number} Autocorr penalty */ export function autocorrPenalty(returns, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); if (cleanReturns.length < 2) { return 1; } // Python: coef = np.abs(np.corrcoef(returns[:-1], returns[1:])[0, 1]) const returns1 = cleanReturns.slice(0, -1); // returns[:-1] const returns2 = cleanReturns.slice(1); // returns[1:] const mean1 = returns1.reduce((sum, ret) => sum + ret, 0) / returns1.length; const mean2 = returns2.reduce((sum, ret) => sum + ret, 0) / returns2.length; let numerator = 0; let sum1sq = 0; let sum2sq = 0; for (let i = 0; i < returns1.length; i++) { const diff1 = returns1[i] - mean1; const diff2 = returns2[i] - mean2; numerator += diff1 * diff2; sum1sq += diff1 * diff1; sum2sq += diff2 * diff2; } const denominator = Math.sqrt(sum1sq * sum2sq); const coef = denominator === 0 ? 0 : Math.abs(numerator / denominator); // Python: corr = [((num - x) / num) * coef**x for x in range(1, num)] // Python: return np.sqrt(1 + 2 * np.sum(corr)) const num = cleanReturns.length; let corrSum = 0; for (let x = 1; x < num; x++) { corrSum += ((num - x) / num) * Math.pow(coef, x); } return Math.sqrt(1 + 2 * corrSum); } /** * Calculate smart Sharpe ratio (with autocorr penalty) * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {number} rfRate - Risk-free rate (default 0) * @param {number} periods - Periods per year (default 252) * @param {boolean} nans - Include NaN values (default false) * @returns {number} Smart Sharpe ratio */ export function smartSharpe(returns, rfRate = 0, periods = 252, nans = false) { const cleanReturns = prepareReturns(returns, rfRate, nans); if (cleanReturns.length === 0) { return 0; } // Python: divisor = divisor * autocorr_penalty(returns) const meanReturn = cleanReturns.reduce((sum, ret) => sum + ret, 0) / cleanReturns.length; const variance = cleanReturns.reduce((sum, ret) => sum + Math.pow(ret - meanReturn, 2), 0) / (cleanReturns.length - 1); const stdDev = Math.sqrt(variance); const penalty = autocorrPenalty(cleanReturns, nans); const adjustedStdDev = stdDev * penalty; if (adjustedStdDev === 0) return 0; const smartSharpeRatio = meanReturn / adjustedStdDev; return smartSharpeRatio * Math.sqrt(TRADING_DAYS_PER_YEAR); } /** * Calculate smart Sortino ratio (with autocorr penalty) * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {number} rfRate - Risk-free rate (default 0) * @param {number} periods - Periods per year (default 252) * @param {boolean} nans - Include NaN values (default false) * @returns {number} Smart Sortino ratio */ export function smartSortino(returns, rfRate = 0, periods = 252, nans = false) { const cleanReturns = prepareReturns(returns, rfRate, nans); if (cleanReturns.length === 0) { return 0; } // Calculate excess returns const meanReturn = cleanReturns.reduce((sum, ret) => sum + ret, 0) / cleanReturns.length; // Calculate downside deviation with autocorr penalty const negativeReturns = cleanReturns.filter(ret => ret < 0); if (negativeReturns.length === 0) { return Infinity; } const downsideVariance = negativeReturns.reduce((sum, ret) => sum + Math.pow(ret, 2), 0) / cleanReturns.length; const downsideStd = Math.sqrt(downsideVariance); const penalty = autocorrPenalty(cleanReturns, nans); const adjustedDownsideStd = downsideStd * penalty; if (adjustedDownsideStd === 0) return 0; const smartSortinoRatio = meanReturn / adjustedDownsideStd; return smartSortinoRatio * Math.sqrt(TRADING_DAYS_PER_YEAR); } /** * Calculate Sortino ratio divided by √2 * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {number} rfRate - Risk-free rate (default 0) * @param {boolean} nans - Include NaN values (default false) * @returns {number} Sortino ratio divided by √2 */ export function sortinoSqrt2(returns, rfRate = 0, nans = false) { return sortino(returns, rfRate, nans) / Math.sqrt(2); } /** * Calculate Smart Sortino ratio divided by √2 * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {number} rfRate - Risk-free rate (default 0) * @param {boolean} nans - Include NaN values (default false) * @returns {number} Smart Sortino ratio divided by √2 */ export function smartSortinoSqrt2(returns, rfRate = 0, nans = false) { return smartSortino(returns, rfRate, nans) / Math.sqrt(2); } /** * Calculate probabilistic ratio * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {Array} benchmark - Benchmark returns * @param {number} periods - Periods per year (default 252) * @param {boolean} nans - Include NaN values (default false) * @returns {number} Probabilistic ratio */ export function probabilisticRatio(returns, benchmark, periods = 252, nans = false) { const cleanReturns = prepareReturns(returns, 0, nans); const cleanBenchmark = prepareReturns(benchmark, 0, nans); if (cleanReturns.length !== cleanBenchmark.length) { throw new Error('Returns and benchmark must have the same length'); } const excessReturns = cleanReturns.map((ret, i) => ret - cleanBenchmark[i]); const mean = excessReturns.reduce((sum, ret) => sum + ret, 0) / excessReturns.length; const variance = excessReturns.reduce((sum, ret) => sum + Math.pow(ret - mean, 2), 0) / (excessReturns.length - 1); const std = Math.sqrt(variance); if (std === 0) return 0; const sharpe = (mean * Math.sqrt(periods)) / (std * Math.sqrt(periods)); const n = excessReturns.length; // Probabilistic Sharpe Ratio calculation const psr = normalCDF(sharpe * Math.sqrt(n - 1) / Math.sqrt(1 - sharpe * sharpe / n)); return psr; } /** * Normal cumulative distribution function * @param {number} x - Input value * @returns {number} CDF value */ function normalCDF(x) { return 0.5 * (1 + erf(x / Math.sqrt(2))); } /** * Error function approximation * @param {number} x - Input value * @returns {number} Error function value */ function erf(x) { // Abramowitz and Stegun approximation const a1 = 0.254829592; const a2 = -0.284496736; const a3 = 1.421413741; const a4 = -1.453152027; const a5 = 1.061405429; const p = 0.3275911; const sign = x < 0 ? -1 : 1; x = Math.abs(x); const t = 1.0 / (1.0 + p * x); const y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-x * x); return sign * y; } /** * Calculate probabilistic Sharpe ratio * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {number} rfRate - Risk-free rate (default 0) * @param {number} periods - Periods per year (default 252) * @param {boolean} nans - Include NaN values (default false) * @returns {number} Probabilistic Sharpe ratio */ export function probabilisticSharpeRatio(returns, rfRate = 0, periods = 252, nans = false) { // Don't adjust returns by rfRate - let Python handle rf subtraction in ratio calculation const cleanReturns = prepareReturns(returns, 0, nans); // Python: base = sharpe(series, periods=periods, annualize=False, smart=smart) // We need NON-ANNUALIZED Sharpe ratio! const mean = cleanReturns.reduce((sum, ret) => sum + ret, 0) / cleanReturns.length; const variance = cleanReturns.reduce((sum, ret) => sum + Math.pow(ret - mean, 2), 0) / (cleanReturns.length - 1); const std = Math.sqrt(variance); const baseSharpe = std === 0 ? 0 : mean / std; // NON-annualized Sharpe const skewVal = skew(cleanReturns, nans); const kurtosisVal = kurtosis(cleanReturns, nans); const n = cleanReturns.length; // Python formula from probabilistic_ratio function const sigmaSr = Math.sqrt( (1 + (0.5 * Math.pow(baseSharpe, 2)) - (skewVal * baseSharpe) + (((kurtosisVal - 3) / 4) * Math.pow(baseSharpe, 2))) / (n - 1) ); // Python: ratio = (base - rf) / sigma_sr const ratio = (baseSharpe - rfRate) / sigmaSr; const psr = normalCDF(ratio); return psr; } /** * Calculate probabilistic Sortino ratio * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {number} rfRate - Risk-free rate (default 0) * @param {number} periods - Periods per year (default 252) * @param {boolean} nans - Include NaN values (default false) * @returns {number} Probabilistic Sortino ratio */ export function probabilisticSortinoRatio(returns, rfRate = 0, periods = 252, nans = false) { const cleanReturns = prepareReturns(returns, rfRate, nans); const sortinoVal = sortino(cleanReturns, 0, nans); const n = cleanReturns.length; if (sortinoVal === 0) return 0; return normalCDF(sortinoVal * Math.sqrt(n - 1) / Math.sqrt(1 - sortinoVal * sortinoVal / n)); } /** * Calculate probabilistic adjusted Sortino ratio * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {number} rfRate - Risk-free rate (default 0) * @param {number} periods - Periods per year (default 252) * @param {boolean} nans - Include NaN values (default false) * @returns {number} Probabilistic adjusted Sortino ratio */ export function probabilisticAdjustedSortinoRatio(returns, rfRate = 0, periods = 252, nans = false) { const cleanReturns = prepareReturns(returns, rfRate, nans); const adjustedSortinoVal = adjustedSortino(cleanReturns, 0, periods, false, nans); const n = cleanReturns.length; if (adjustedSortinoVal === 0) return 0; return normalCDF(adjustedSortinoVal * Math.sqrt(n - 1) / Math.sqrt(1 - adjustedSortinoVal * adjustedSortinoVal / n)); } /** * Calculate expected shortfall (CVaR) * Exactly matches Python implementation * @param {Array} returns - Returns array * @param {number} sigma - Sigma multiplier (default 1) * @param {number} confidence - Confidence level (default 0.95) * @param {boolean} nans - Include NaN values (default false) * @returns {number} Expected shortfall */ export fu