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
625 lines (551 loc) • 18.6 kB
JavaScript
/**
* Plots module for QuantStats.js
* Simplified plotting functionality for Node.js
*/
import {
prepareReturns,
toDrawdownSeries,
portfolioValue,
aggregateReturns,
TRADING_DAYS_PER_YEAR
} from './utils.js';
import {
volatility,
monthlyReturns,
yearlyReturns
} from './stats.js';
/**
* Generate data for equity curve plot
* @param {Array} returns - Returns array
* @param {number} initialValue - Initial portfolio value (default 1000)
* @param {boolean} nans - Include NaN values (default false)
* @returns {Object} Plot data
*/
export function equityCurve(returns, initialValue = 1000, nans = false) {
const cleanReturns = prepareReturns(returns, 0, nans);
const values = portfolioValue(cleanReturns, initialValue);
return {
type: 'line',
data: values,
title: 'Equity Curve',
xLabel: 'Time',
yLabel: 'Portfolio Value',
description: 'Portfolio value over time'
};
}
/**
* Generate data for drawdown plot
* @param {Array} returns - Returns array
* @param {boolean} nans - Include NaN values (default false)
* @returns {Object} Plot data
*/
export function drawdownPlot(returns, nans = false) {
const cleanReturns = prepareReturns(returns, 0, nans);
const drawdowns = toDrawdownSeries(cleanReturns);
return {
type: 'area',
data: drawdowns.map(dd => dd * 100), // Convert to percentage
title: 'Drawdown',
xLabel: 'Time',
yLabel: 'Drawdown (%)',
description: 'Portfolio drawdown over time'
};
}
/**
* Generate data for returns distribution histogram
* @param {Array} returns - Returns array
* @param {number} bins - Number of bins (default 30)
* @param {boolean} nans - Include NaN values (default false)
* @returns {Object} Plot data
*/
export function returnsDistribution(returns, bins = 30, nans = false) {
const cleanReturns = prepareReturns(returns, 0, nans);
if (cleanReturns.length === 0) {
return {
type: 'histogram',
data: [],
bins: [],
title: 'Returns Distribution',
xLabel: 'Return',
yLabel: 'Frequency'
};
}
const min = Math.min(...cleanReturns);
const max = Math.max(...cleanReturns);
const binWidth = (max - min) / bins;
const histogram = new Array(bins).fill(0);
const binEdges = [];
for (let i = 0; i <= bins; i++) {
binEdges.push(min + i * binWidth);
}
for (const ret of cleanReturns) {
const binIndex = Math.min(Math.floor((ret - min) / binWidth), bins - 1);
histogram[binIndex]++;
}
return {
type: 'histogram',
data: histogram,
bins: binEdges,
title: 'Returns Distribution',
xLabel: 'Return',
yLabel: 'Frequency',
description: 'Distribution of daily returns'
};
}
/**
* Generate data for rolling statistics plot
* @param {Array} returns - Returns array
* @param {number} window - Rolling window size (default 252)
* @param {string} stat - Statistic to calculate ('volatility', 'sharpe')
* @param {boolean} nans - Include NaN values (default false)
* @returns {Object} Plot data
*/
export function rollingStats(returns, window = 252, stat = 'volatility', nans = false) {
const cleanReturns = prepareReturns(returns, 0, nans);
if (cleanReturns.length < window) {
return {
type: 'line',
data: [],
title: `Rolling ${stat.charAt(0).toUpperCase() + stat.slice(1)}`,
xLabel: 'Time',
yLabel: stat.charAt(0).toUpperCase() + stat.slice(1)
};
}
const rollingValues = [];
for (let i = window; i <= cleanReturns.length; i++) {
const windowReturns = cleanReturns.slice(i - window, i);
let value;
switch (stat.toLowerCase()) {
case 'volatility':
value = volatility(windowReturns, nans);
break;
case 'sharpe':
// Import sharpe function here to avoid circular dependency
const mean = windowReturns.reduce((sum, ret) => sum + ret, 0) / windowReturns.length;
const variance = windowReturns.reduce((sum, ret) => sum + Math.pow(ret - mean, 2), 0) / windowReturns.length;
const std = Math.sqrt(variance);
value = std === 0 ? 0 : (mean * Math.sqrt(TRADING_DAYS_PER_YEAR)) / (std * Math.sqrt(TRADING_DAYS_PER_YEAR));
break;
default:
value = 0;
}
rollingValues.push(value);
}
return {
type: 'line',
data: rollingValues,
title: `Rolling ${stat.charAt(0).toUpperCase() + stat.slice(1)} (${window} days)`,
xLabel: 'Time',
yLabel: stat.charAt(0).toUpperCase() + stat.slice(1),
description: `Rolling ${window}-day ${stat}`
};
}
/**
* Generate data for monthly returns heatmap
* @param {Array} returns - Returns array
* @param {boolean} nans - Include NaN values (default false)
* @returns {Object} Plot data
*/
export function monthlyHeatmap(returns, nans = false) {
const cleanReturns = prepareReturns(returns, 0, nans);
const monthly = monthlyReturns(cleanReturns, nans);
// Group by year and month (simplified)
const monthlyData = [];
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
for (let i = 0; i < monthly.length; i++) {
const monthIndex = i % 12;
const year = Math.floor(i / 12);
monthlyData.push({
month: months[monthIndex],
year: year,
return: monthly[i] * 100 // Convert to percentage
});
}
return {
type: 'heatmap',
data: monthlyData,
title: 'Monthly Returns Heatmap',
xLabel: 'Month',
yLabel: 'Year',
description: 'Monthly returns by year'
};
}
/**
* Generate data for yearly returns bar chart
* @param {Array} returns - Returns array
* @param {boolean} nans - Include NaN values (default false)
* @returns {Object} Plot data
*/
export function yearlyReturnsChart(returns, nans = false) {
const cleanReturns = prepareReturns(returns, 0, nans);
const yearly = yearlyReturns(cleanReturns, nans);
return {
type: 'bar',
data: yearly.map(ret => ret * 100), // Convert to percentage
title: 'Yearly Returns',
xLabel: 'Year',
yLabel: 'Return (%)',
description: 'Annual returns by year'
};
}
/**
* Generate snapshot plot data (key metrics visualization)
* @param {Array} returns - Returns array
* @param {boolean} nans - Include NaN values (default false)
* @returns {Object} Plot data with key metrics
*/
export function snapshot(returns, nans = false) {
const cleanReturns = prepareReturns(returns, 0, nans);
// Import stats functions here to avoid circular dependency
const totalRet = cleanReturns.reduce((prod, ret) => prod * (1 + ret), 1) - 1;
const vol = volatility(cleanReturns, nans);
const maxDD = Math.min(...toDrawdownSeries(cleanReturns));
// Calculate other metrics
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;
const std = Math.sqrt(variance);
const sharpeRatio = std === 0 ? 0 : (mean * Math.sqrt(TRADING_DAYS_PER_YEAR)) / (std * Math.sqrt(TRADING_DAYS_PER_YEAR));
return {
type: 'metrics',
data: {
totalReturn: totalRet * 100,
volatility: vol * 100,
sharpeRatio: sharpeRatio,
maxDrawdown: maxDD * 100,
winRate: cleanReturns.filter(ret => ret > 0).length / cleanReturns.length * 100,
avgWin: cleanReturns.filter(ret => ret > 0).reduce((sum, ret) => sum + ret, 0) / cleanReturns.filter(ret => ret > 0).length * 100 || 0,
avgLoss: cleanReturns.filter(ret => ret < 0).reduce((sum, ret) => sum + ret, 0) / cleanReturns.filter(ret => ret < 0).length * 100 || 0
},
title: 'Performance Snapshot',
description: 'Key performance metrics summary'
};
}
/**
* Export plot data as JSON for external visualization
* @param {Object} plotData - Plot data object
* @param {string} filename - Output filename (optional)
* @returns {string} JSON string
*/
export function exportPlotData(plotData, filename = null) {
const jsonData = JSON.stringify(plotData, null, 2);
return jsonData;
}
/**
* Generate comprehensive dashboard data
* @param {Array} returns - Returns array
* @param {boolean} nans - Include NaN values (default false)
* @returns {Object} Dashboard data with multiple plots
*/
export function dashboard(returns, nans = false) {
return {
equityCurve: equityCurve(returns, 1000, nans),
drawdown: drawdownPlot(returns, nans),
returnsDistribution: returnsDistribution(returns, 30, nans),
rollingVolatility: rollingStats(returns, 252, 'volatility', nans),
monthlyHeatmap: monthlyHeatmap(returns, nans),
yearlyReturns: yearlyReturnsChart(returns, nans),
snapshot: snapshot(returns, nans)
};
}
/**
* Generate earnings plot data
* Exactly matches Python implementation
* @param {Array} returns - Returns array
* @param {number} startBalance - Starting balance (default 1000)
* @param {boolean} nans - Include NaN values (default false)
* @returns {Object} Plot data
*/
export function earnings(returns, startBalance = 1000, nans = false) {
const cleanReturns = prepareReturns(returns, 0, nans);
const values = portfolioValue(cleanReturns, startBalance);
return {
type: 'line',
data: values,
title: 'Total Return',
xLabel: 'Time',
yLabel: 'Total Return',
description: 'Cumulative returns over time'
};
}
/**
* Generate returns plot data
* Exactly matches Python implementation
* @param {Array} returns - Returns array
* @param {boolean} nans - Include NaN values (default false)
* @returns {Object} Plot data
*/
export function returns(returns, nans = false) {
const cleanReturns = prepareReturns(returns, 0, nans);
return {
type: 'line',
data: cleanReturns.map(ret => ret * 100), // Convert to percentage
title: 'Returns',
xLabel: 'Time',
yLabel: 'Return (%)',
description: 'Daily returns over time'
};
}
/**
* Generate log returns plot data
* Exactly matches Python implementation
* @param {Array} returns - Returns array
* @param {boolean} nans - Include NaN values (default false)
* @returns {Object} Plot data
*/
export function logReturns(returns, nans = false) {
const cleanReturns = prepareReturns(returns, 0, nans);
const logRets = cleanReturns.map(ret => Math.log(1 + ret));
return {
type: 'line',
data: logRets.map(ret => ret * 100), // Convert to percentage
title: 'Log Returns',
xLabel: 'Time',
yLabel: 'Log Return (%)',
description: 'Log returns over time'
};
}
/**
* Generate daily returns plot data
* Exactly matches Python implementation
* @param {Array} returns - Returns array
* @param {boolean} nans - Include NaN values (default false)
* @returns {Object} Plot data
*/
export function dailyReturns(returns, nans = false) {
const cleanReturns = prepareReturns(returns, 0, nans);
return {
type: 'bar',
data: cleanReturns.map(ret => ret * 100), // Convert to percentage
title: 'Daily Returns',
xLabel: 'Time',
yLabel: 'Return (%)',
description: 'Daily returns as bars'
};
}
/**
* Generate distribution plot data
* Exactly matches Python implementation
* @param {Array} returns - Returns array
* @param {number} bins - Number of bins (default 50)
* @param {boolean} nans - Include NaN values (default false)
* @returns {Object} Plot data
*/
export function distribution(returns, bins = 50, nans = false) {
return returnsDistribution(returns, bins, nans);
}
/**
* Generate histogram plot data
* Exactly matches Python implementation
* @param {Array} returns - Returns array
* @param {number} bins - Number of bins (default 50)
* @param {boolean} nans - Include NaN values (default false)
* @returns {Object} Plot data
*/
export function histogram(returns, bins = 50, nans = false) {
return returnsDistribution(returns, bins, nans);
}
/**
* Generate drawdown plot data
* Exactly matches Python implementation
* @param {Array} returns - Returns array
* @param {boolean} nans - Include NaN values (default false)
* @returns {Object} Plot data
*/
export function drawdown(returns, nans = false) {
return drawdownPlot(returns, nans);
}
/**
* Generate drawdown periods plot data
* Exactly matches Python implementation
* @param {Array} returns - Returns array
* @param {boolean} nans - Include NaN values (default false)
* @returns {Object} Plot data
*/
export function drawdownsPeriods(returns, nans = false) {
const cleanReturns = prepareReturns(returns, 0, nans);
const drawdowns = toDrawdownSeries(cleanReturns);
// Find drawdown periods
const periods = [];
let inDrawdown = false;
let startIndex = 0;
for (let i = 0; i < drawdowns.length; i++) {
if (drawdowns[i] < 0 && !inDrawdown) {
inDrawdown = true;
startIndex = i;
} else if (drawdowns[i] >= 0 && inDrawdown) {
inDrawdown = false;
periods.push({
start: startIndex,
end: i - 1,
maxDrawdown: Math.min(...drawdowns.slice(startIndex, i))
});
}
}
return {
type: 'periods',
data: periods,
title: 'Drawdown Periods',
xLabel: 'Time',
yLabel: 'Drawdown (%)',
description: 'Individual drawdown periods'
};
}
/**
* Generate rolling beta plot data
* Exactly matches Python implementation
* @param {Array} returns - Returns array
* @param {Array} benchmark - Benchmark returns
* @param {number} window - Rolling window (default 252)
* @param {boolean} nans - Include NaN values (default false)
* @returns {Object} Plot data
*/
export function rollingBeta(returns, benchmark, window = 252, nans = false) {
const cleanReturns = prepareReturns(returns, 0, nans);
const cleanBenchmark = prepareReturns(benchmark, 0, nans);
if (cleanReturns.length !== cleanBenchmark.length || cleanReturns.length < window) {
return {
type: 'line',
data: [],
title: 'Rolling Beta',
xLabel: 'Time',
yLabel: 'Beta'
};
}
const rollingBetas = [];
for (let i = window; i <= cleanReturns.length; i++) {
const retWindow = cleanReturns.slice(i - window, i);
const benchWindow = cleanBenchmark.slice(i - window, i);
// Calculate beta
const retMean = retWindow.reduce((sum, ret) => sum + ret, 0) / retWindow.length;
const benchMean = benchWindow.reduce((sum, ret) => sum + ret, 0) / benchWindow.length;
let covariance = 0;
let benchVariance = 0;
for (let j = 0; j < retWindow.length; j++) {
const retDiff = retWindow[j] - retMean;
const benchDiff = benchWindow[j] - benchMean;
covariance += retDiff * benchDiff;
benchVariance += benchDiff * benchDiff;
}
covariance /= retWindow.length;
benchVariance /= benchWindow.length;
const beta = benchVariance === 0 ? 0 : covariance / benchVariance;
rollingBetas.push(beta);
}
return {
type: 'line',
data: rollingBetas,
title: `Rolling Beta (${window} days)`,
xLabel: 'Time',
yLabel: 'Beta',
description: `Rolling ${window}-day beta relative to benchmark`
};
}
/**
* Generate rolling volatility plot data
* Exactly matches Python implementation
* @param {Array} returns - Returns array
* @param {number} window - Rolling window (default 252)
* @param {boolean} nans - Include NaN values (default false)
* @returns {Object} Plot data
*/
export function rollingVolatility(returns, window = 252, nans = false) {
return rollingStats(returns, window, 'volatility', nans);
}
/**
* Generate rolling Sharpe plot data
* Exactly matches Python implementation
* @param {Array} returns - Returns array
* @param {number} window - Rolling window (default 252)
* @param {boolean} nans - Include NaN values (default false)
* @returns {Object} Plot data
*/
export function rollingSharpe(returns, window = 252, nans = false) {
return rollingStats(returns, window, 'sharpe', nans);
}
/**
* Generate rolling Sortino plot data
* Exactly matches Python implementation
* @param {Array} returns - Returns array
* @param {number} window - Rolling window (default 252)
* @param {boolean} nans - Include NaN values (default false)
* @returns {Object} Plot data
*/
export function rollingSortino(returns, window = 252, nans = false) {
const cleanReturns = prepareReturns(returns, 0, nans);
if (cleanReturns.length < window) {
return {
type: 'line',
data: [],
title: 'Rolling Sortino',
xLabel: 'Time',
yLabel: 'Sortino Ratio'
};
}
const rollingSortinos = [];
for (let i = window; i <= cleanReturns.length; i++) {
const windowReturns = cleanReturns.slice(i - window, i);
// Calculate Sortino ratio
const mean = windowReturns.reduce((sum, ret) => sum + ret, 0) / windowReturns.length;
const negativeReturns = windowReturns.filter(ret => ret < 0);
if (negativeReturns.length === 0) {
rollingSortinos.push(mean > 0 ? Infinity : 0);
continue;
}
const downsideVariance = negativeReturns.reduce((sum, ret) => sum + Math.pow(ret, 2), 0) / windowReturns.length;
const downsideDeviation = Math.sqrt(downsideVariance);
const sortino = downsideDeviation === 0 ? 0 : (mean * Math.sqrt(TRADING_DAYS_PER_YEAR)) / (downsideDeviation * Math.sqrt(TRADING_DAYS_PER_YEAR));
rollingSortinos.push(sortino);
}
return {
type: 'line',
data: rollingSortinos,
title: `Rolling Sortino (${window} days)`,
xLabel: 'Time',
yLabel: 'Sortino Ratio',
description: `Rolling ${window}-day Sortino ratio`
};
}
/**
* Generate monthly returns plot data
* Exactly matches Python implementation
* @param {Array} returns - Returns array
* @param {boolean} nans - Include NaN values (default false)
* @returns {Object} Plot data
*/
export function monthlyReturnsPlot(returns, nans = false) {
const cleanReturns = prepareReturns(returns, 0, nans);
const monthly = monthlyReturns(cleanReturns, nans);
return {
type: 'bar',
data: monthly.map(ret => ret * 100), // Convert to percentage
title: 'Monthly Returns',
xLabel: 'Month',
yLabel: 'Return (%)',
description: 'Monthly returns over time'
};
}
export default {
equityCurve,
drawdownPlot,
returnsDistribution,
rollingStats,
monthlyHeatmap,
yearlyReturnsChart,
snapshot,
dashboard,
exportPlotData,
earnings,
returns,
logReturns,
dailyReturns,
distribution,
histogram,
drawdown,
drawdownsPeriods,
rollingBeta,
rollingVolatility,
rollingSharpe,
rollingSortino,
monthlyReturnsPlot
};