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,209 lines (996 loc) • 108 kB
JavaScript
import * as stats from './stats.js';
import * as utils from './utils.js';
import * as plots from './plots.js';
/**
* Generate comprehensive portfolio metrics
* @param {Array} returns - Returns array
* @param {number} rfRate - Risk-free rate (default 0)
* @param {boolean} nans - Include NaN values (default false)
* @returns {Object} Comprehensive metrics object
*/
export function metrics(returns, rfRate = 0, nans = false) {
const cleanReturns = utils.prepareReturns(returns, rfRate, nans);
if (cleanReturns.length === 0) {
throw new Error('No valid returns data');
}
const metrics = {
// Core performance metrics
totalReturn: stats.compoundReturn(cleanReturns, nans),
cagr: stats.cagr(cleanReturns, nans),
volatility: stats.volatility(cleanReturns, 252, nans),
sharpe: stats.sharpe(cleanReturns, rfRate, 252, nans),
sortino: stats.sortino(cleanReturns, rfRate, 252, nans),
calmar: stats.calmar(cleanReturns, nans),
// Risk metrics
maxDrawdown: stats.maxDrawdown(cleanReturns, nans),
valueAtRisk: stats.valueAtRisk(cleanReturns, 1, 0.95, nans),
conditionalValueAtRisk: stats.cvar(cleanReturns, 1, 0.95, nans),
skew: stats.skew(cleanReturns, nans),
kurtosis: stats.kurtosis(cleanReturns, nans),
ulcerIndex: stats.ulcerIndex(cleanReturns, nans),
// Trading metrics
kelly: stats.kelly(cleanReturns, nans),
profitFactor: stats.profitFactor(cleanReturns, nans),
winRate: stats.winRate(cleanReturns, nans),
// Advanced metrics
probabilisticSharpeRatio: stats.probabilisticSharpeRatio(cleanReturns, 0, 252, nans),
omega: stats.omega(cleanReturns, 0, nans),
// Drawdown details
drawdownInfo: (() => {
const ddSeries = utils.toDrawdownSeries(cleanReturns, nans);
const ddDetails = utils.drawdownDetails(cleanReturns, nans);
const maxDd = Math.min(...ddSeries);
const longestDays = ddDetails.reduce((max, dd) => Math.max(max, dd.days), 0);
const avgDd = ddDetails.reduce((sum, dd) => sum + dd.maxDrawdown, 0) / ddDetails.length;
const avgDays = ddDetails.reduce((sum, dd) => sum + dd.days, 0) / ddDetails.length;
const totalReturn = cleanReturns.reduce((acc, ret) => acc * (1 + ret), 1) - 1;
const recoveryFactor = Math.abs(maxDd) > 0 ? totalReturn / Math.abs(maxDd) : 0;
return {
maxDrawdownSummary: maxDd,
longestDdDays: longestDays,
avgDrawdown: avgDd,
avgDdDays: avgDays,
recoveryFactor
};
})()
};
return metrics;
}
/**
* Generate SVG chart for equity curve
*/
/**
* Generate cumulative returns chart (like Python QuantStats)
*/
function generateCumulativeReturnsChart(returns, dates, title = 'Cumulative Returns', logScale = false) {
const width = 800; // Base width for calculations
const height = 400; // Base height for calculations
const margin = { top: 50, right: 80, bottom: 70, left: 80 };
const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;
// Calculate cumulative returns starting from 0%
let cumulativeValue = 1.0; // Start at 1 (representing 0% return)
const cumulativeReturns = [0]; // Start at 0% for display
for (let i = 0; i < returns.length; i++) {
cumulativeValue *= (1 + returns[i]);
cumulativeReturns.push((cumulativeValue - 1) * 100); // Convert to percentage
}
const minValue = Math.min(...cumulativeReturns);
const maxValue = Math.max(...cumulativeReturns);
let yTicks = [];
let roundedMin, roundedMax, valueRange;
if (logScale) {
// For log scale, we need to handle positive and negative values carefully
// Convert to 1-based values for log calculation (add 1 to percentage values)
const logValues = cumulativeReturns.map(v => v + 100); // Convert % to 1-based
const logMin = Math.min(...logValues);
const logMax = Math.max(...logValues);
// Create logarithmic ticks
const logMinValue = Math.max(0.1, logMin); // Avoid log(0)
const logMaxValue = logMax;
// Generate log scale ticks
const logStart = Math.floor(Math.log10(logMinValue));
const logEnd = Math.ceil(Math.log10(logMaxValue));
for (let i = logStart; i <= logEnd; i++) {
const baseValue = Math.pow(10, i);
[1, 2, 5].forEach(mult => {
const tickValue = baseValue * mult;
if (tickValue >= logMinValue && tickValue <= logMaxValue) {
yTicks.push(tickValue - 100); // Convert back to percentage
}
});
}
roundedMin = logMinValue - 100;
roundedMax = logMaxValue - 100;
valueRange = roundedMax - roundedMin;
} else {
// Linear scale (original logic)
const padding = Math.max(10, Math.abs(maxValue - minValue) * 0.1);
roundedMin = Math.floor((minValue - padding) / 25) * 25;
roundedMax = Math.ceil((maxValue + padding) / 25) * 25;
valueRange = roundedMax - roundedMin;
// Generate linear Y-axis ticks
const tickInterval = Math.max(25, Math.round(valueRange / 8 / 25) * 25);
for (let tick = roundedMin; tick <= roundedMax; tick += tickInterval) {
yTicks.push(tick);
}
}
// Generate path data for the line
const pathData = cumulativeReturns.map((value, index) => {
const x = margin.left + (index / (cumulativeReturns.length - 1)) * chartWidth;
let y;
if (logScale) {
// Logarithmic scaling
const logValue = Math.max(0.1, value + 100); // Convert to 1-based, avoid log(0)
const logMin = Math.max(0.1, roundedMin + 100);
const logMax = roundedMax + 100;
const logRange = Math.log10(logMax) - Math.log10(logMin);
y = margin.top + ((Math.log10(logMax) - Math.log10(logValue)) / logRange) * chartHeight;
} else {
// Linear scaling
y = margin.top + ((roundedMax - value) / valueRange) * chartHeight;
}
return `${index === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`;
}).join(' ');
// Add time axis labels (show start, middle, end + key points)
const timeLabels = [];
if (dates && dates.length > 0) {
const labelIndices = [
0, // Start
Math.floor(dates.length * 0.25), // 25%
Math.floor(dates.length * 0.5), // 50%
Math.floor(dates.length * 0.75), // 75%
dates.length - 1 // End
];
labelIndices.forEach(i => {
if (i < dates.length) {
const date = dates[i];
const x = margin.left + (i / (dates.length - 1)) * chartWidth;
const label = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
timeLabels.push(`<text x="${x}" y="${height - 15}" text-anchor="middle" font-size="11" fill="#555">${label}</text>`);
}
});
}
return `<svg width="100%" height="400" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="max-width: 100%; height: auto;">
<defs>
<linearGradient id="chartGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#e3f2fd;stop-opacity:0.3" />
<stop offset="100%" style="stop-color:#1976d2;stop-opacity:0.1" />
</linearGradient>
<filter id="dropShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="1" stdDeviation="1" flood-color="#000" flood-opacity="0.1"/>
</filter>
</defs>
<!-- Background -->
<rect width="${width}" height="${height}" fill="white"/>
<!-- Chart area background -->
<rect x="${margin.left}" y="${margin.top}" width="${chartWidth}" height="${chartHeight}"
fill="#fbfbfb" stroke="#e8e8e8" stroke-width="1" rx="2"/>
<!-- Horizontal grid lines -->
${yTicks.map(tick => {
const y = margin.top + ((roundedMax - tick) / valueRange) * chartHeight;
return `<line x1="${margin.left + 1}" y1="${y}" x2="${margin.left + chartWidth - 1}" y2="${y}"
stroke="#f0f0f0" stroke-width="${tick === 0 ? '1.5' : '0.5'}"
stroke-dasharray="${tick === 0 ? 'none' : '2,3'}"/>`;
}).join('')}
<!-- Area fill under the line -->
<path d="${pathData} L ${margin.left + chartWidth} ${margin.top + chartHeight} L ${margin.left} ${margin.top + chartHeight} Z"
fill="url(#chartGradient)" opacity="0.6"/>
<!-- Main chart line -->
<path d="${pathData}" fill="none" stroke="#1976d2" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round" filter="url(#dropShadow)"/>
<!-- Y-axis labels -->
${yTicks.map(tick => {
const y = margin.top + ((roundedMax - tick) / valueRange) * chartHeight;
return `<text x="${margin.left - 12}" y="${y + 4}" text-anchor="end"
font-size="11" fill="#666" font-family="Arial, sans-serif">${tick}%</text>`;
}).join('')}
<!-- Chart title -->
<text x="${width/2}" y="25" text-anchor="middle" font-size="16" font-weight="600"
fill="#333" font-family="Arial, sans-serif">${title}</text>
<!-- Time labels -->
${timeLabels.join('')}
<!-- Final value label -->
${(() => {
const finalValue = cumulativeReturns[cumulativeReturns.length - 1];
const finalX = margin.left + chartWidth;
const finalY = margin.top + ((roundedMax - finalValue) / valueRange) * chartHeight;
return `
<circle cx="${finalX - 5}" cy="${finalY}" r="4" fill="#1976d2" stroke="white" stroke-width="2"/>
<text x="${finalX + 15}" y="${finalY + 5}" font-size="12" font-weight="600"
fill="#1976d2" font-family="Arial, sans-serif">${finalValue.toFixed(1)}%</text>
`;
})()}
</svg>`;
}
/**
* Generate EOY Returns bar chart
*/
function generateEOYReturnsChart(returns, dates, title = 'EOY Returns') {
const width = 800; // Standardized width for consistency
const height = 400; // Standardized height for consistency
const margin = { top: 50, right: 40, bottom: 50, left: 70 };
const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;
// Properly handle returns data format
const returnsData = returns.values ? returns.values : (Array.isArray(returns) ? returns : []);
const datesData = dates || (returns.index ? returns.index : null);
// Calculate yearly returns using existing stats function
const yearlyReturns = stats.yearlyReturns(returnsData, false, datesData, true);
// Extract years for labeling
const years = [];
if (datesData && datesData.length > 0) {
const yearlyData = new Map();
for (let i = 0; i < returnsData.length; i++) {
const date = new Date(datesData[i]);
const yearKey = date.getUTCFullYear();
if (!yearlyData.has(yearKey)) {
yearlyData.set(yearKey, []);
years.push(yearKey);
}
yearlyData.get(yearKey).push(returnsData[i]);
}
years.sort((a, b) => a - b);
}
if (!yearlyReturns || yearlyReturns.length === 0) {
return `<svg width="720" height="380" viewBox="0 0 720 380">
<rect width="720" height="380" fill="#f8f9fa" stroke="#dee2e6"/>
<text x="360" y="190" text-anchor="middle" fill="#6c757d">No EOY data available</text>
</svg>`;
}
const minValue = Math.min(...yearlyReturns, -0.05);
const maxValue = Math.max(...yearlyReturns, 0.05);
const valueRange = maxValue - minValue || 1;
const numYears = yearlyReturns.length;
// Dynamic bar sizing - no gaps for long series, wider for short series
const barWidth = chartWidth / numYears;
// Function to convert value to y-coordinate
const valueToY = (value) => {
return margin.top + chartHeight - ((value - minValue) / valueRange) * chartHeight;
};
const zeroY = valueToY(0);
// Smart year labeling based on number of years
const getLabelInfo = (year, index) => {
if (numYears <= 10) {
// Show all years for short series
return { show: true, text: year.toString(), size: 11 };
} else if (numYears <= 20) {
// Show every other year
return { show: index % 2 === 0, text: year.toString(), size: 10 };
} else if (numYears <= 30) {
// Show every 5th year or decade boundaries
const show = (year % 5 === 0) || (index === 0) || (index === numYears - 1);
return { show: show, text: `'${year.toString().slice(-2)}`, size: 9 };
} else {
// Show only decade boundaries for very long series
const show = (year % 10 === 0) || (index === 0) || (index === numYears - 1);
const text = (year % 10 === 0) ? year.toString() : `'${year.toString().slice(-2)}`;
return { show: show, text: text, size: 9 };
}
};
// Generate bars and labels (no percentage labels on bars)
const bars = yearlyReturns.map((value, index) => {
const x = margin.left + index * barWidth;
const barY = valueToY(value);
const barHeight = Math.abs(barY - zeroY);
const isPositive = value >= 0;
const finalY = isPositive ? barY : zeroY;
const color = isPositive ? '#2E8B57' : '#DC143C';
const hoverColor = isPositive ? '#228B22' : '#B22222';
// Get the year for this bar
const year = years[index] || (new Date().getFullYear() - yearlyReturns.length + index + 1);
const labelInfo = getLabelInfo(year, index);
// Generate year label if needed
let yearLabel = '';
if (labelInfo.show) {
const labelX = x + barWidth/2;
const labelY = height - margin.bottom + 20;
yearLabel = `<text x="${labelX}" y="${labelY}" text-anchor="middle" font-size="${labelInfo.size}"
font-weight="500" fill="#555">${labelInfo.text}</text>`;
}
return `<rect x="${x}" y="${finalY}" width="${barWidth}" height="${barHeight}"
fill="${color}" opacity="0.85" stroke="${hoverColor}" stroke-width="0.5"/>
${yearLabel}`;
}).join('');
// Enhanced grid lines
const gridLines = [];
const numGridLines = 8;
for (let i = 0; i <= numGridLines; i++) {
const gridValue = minValue + (valueRange * i / numGridLines);
const y = valueToY(gridValue);
const isZero = Math.abs(gridValue) < 0.001;
gridLines.push(`<line x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}"
stroke="${isZero ? '#666' : '#e5e5e5'}"
stroke-width="${isZero ? '2' : '1'}"
${isZero ? '' : 'stroke-dasharray="3,3"'}/>`);
gridLines.push(`<text x="${margin.left - 15}" y="${y + 4}" text-anchor="end"
font-size="10" fill="#666" font-weight="500">
${(gridValue * 100).toFixed(0)}%</text>`);
}
return `<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="max-width: 100%; height: auto;">
<!-- Background -->
<rect width="${width}" height="${height}" fill="white"/>
<!-- Chart area background -->
<rect x="${margin.left-2}" y="${margin.top-2}" width="${chartWidth+4}" height="${chartHeight+4}"
fill="#fafafa" stroke="none"/>
<!-- Grid lines -->
${gridLines.join('')}
<!-- Chart border -->
<rect x="${margin.left}" y="${margin.top}" width="${chartWidth}" height="${chartHeight}"
fill="none" stroke="#ddd" stroke-width="1"/>
<!-- Bars -->
${bars}
<!-- Title -->
<text x="${width/2}" y="30" text-anchor="middle" font-size="16" font-weight="600" fill="#333">${title}</text>
<!-- Y-axis label -->
<text x="25" y="${height/2}" text-anchor="middle" font-size="12" fill="#666" font-weight="500"
transform="rotate(-90 25 ${height/2})">Annual Returns (%)</text>
<!-- Data range indicator for long series -->
${numYears > 15 ? `<text x="${width - 20}" y="${height - 10}" text-anchor="end" font-size="9" fill="#999">
${years[0]} - ${years[years.length-1]} (${numYears} years)</text>` : ''}
</svg>`;
}
/**
* Generate Monthly Distribution histogram chart
*/
function generateMonthlyDistChart(returns, dates, title = 'Monthly Distribution') {
const width = 800;
const height = 400;
const margin = { top: 40, right: 40, bottom: 80, left: 60 };
const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;
// Properly handle returns data format
const returnsData = returns.values ? returns.values : (Array.isArray(returns) ? returns : []);
const datesData = dates || (returns.index ? returns.index : null);
// Calculate monthly returns using existing stats function
const monthlyReturns = stats.monthlyReturns(returnsData, false, datesData, true);
if (!monthlyReturns || monthlyReturns.length === 0) {
return `<svg width="100%" height="400" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="max-width: 100%; height: auto;">
<rect width="${width}" height="${height}" fill="#f8f9fa" stroke="#dee2e6"/>
<text x="${width/2}" y="${height/2}" text-anchor="middle" fill="#6c757d">No monthly data available</text>
</svg>`;
}
// Create histogram bins
const numBins = 20;
const minReturn = Math.min(...monthlyReturns);
const maxReturn = Math.max(...monthlyReturns);
const binWidth = (maxReturn - minReturn) / numBins;
const bins = Array(numBins).fill(0);
monthlyReturns.forEach(ret => {
const binIndex = Math.min(Math.floor((ret - minReturn) / binWidth), numBins - 1);
bins[binIndex]++;
});
const maxCount = Math.max(...bins);
const barWidth = chartWidth / numBins;
// Generate histogram bars
const bars = bins.map((count, index) => {
const x = margin.left + index * barWidth;
const barHeight = (count / maxCount) * chartHeight;
const y = margin.top + chartHeight - barHeight;
return `<rect x="${x}" y="${y}" width="${barWidth - 1}" height="${barHeight}" fill="#1f77b4" opacity="0.7"/>`;
}).join('');
// Generate Y-axis labels (frequency counts)
const yTicks = [];
const tickCount = 5;
for (let i = 0; i <= tickCount; i++) {
const tickValue = Math.round((maxCount * i) / tickCount);
const y = margin.top + chartHeight - (i / tickCount) * chartHeight;
yTicks.push({
value: tickValue,
y: y
});
}
const yAxisLabels = yTicks.map(tick =>
`<text x="${margin.left - 12}" y="${tick.y + 4}" text-anchor="end" font-size="11" fill="#666">${tick.value}</text>`
).join('');
// Generate X-axis labels (return percentages) - more granular
const xTicks = [];
const xTickCount = 6;
for (let i = 0; i <= xTickCount; i++) {
const tickValue = minReturn + (maxReturn - minReturn) * (i / xTickCount);
const x = margin.left + (i / xTickCount) * chartWidth;
xTicks.push({
value: tickValue,
x: x
});
}
const xAxisLabels = xTicks.map(tick =>
`<text x="${tick.x}" y="${height - 20}" text-anchor="middle" font-size="11" fill="#666">${(tick.value * 100).toFixed(1)}%</text>`
).join('');
return `<svg width="100%" height="400" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="max-width: 100%; height: auto;">
<!-- Background -->
<rect width="${width}" height="${height}" fill="white"/>
<!-- Chart area -->
<rect x="${margin.left}" y="${margin.top}" width="${chartWidth}" height="${chartHeight}"
fill="#fafafa" stroke="#e8e8e8" stroke-width="1"/>
<!-- Grid lines -->
${yTicks.map(tick => `<line x1="${margin.left}" y1="${tick.y}" x2="${margin.left + chartWidth}" y2="${tick.y}" stroke="#f0f0f0" stroke-width="0.5"/>`).join('')}
${xTicks.map(tick => `<line x1="${tick.x}" y1="${margin.top}" x2="${tick.x}" y2="${margin.top + chartHeight}" stroke="#f0f0f0" stroke-width="0.5"/>`).join('')}
<!-- Histogram bars -->
${bars}
<!-- Title -->
<text x="${width/2}" y="25" text-anchor="middle" font-size="16" font-weight="600"
fill="#333" font-family="Arial, sans-serif">${title}</text>
<!-- Y-axis labels -->
${yAxisLabels}
<!-- X-axis labels -->
${xAxisLabels}
<!-- Y-axis title -->
<text x="20" y="${height/2}" text-anchor="middle" font-size="12" fill="#666" transform="rotate(-90, 20, ${height/2})">Frequency</text>
<!-- X-axis title -->
<text x="${width/2}" y="${height - 5}" text-anchor="middle" font-size="12" fill="#666">Monthly Returns (%)</text>
</svg>`;
}
/**
* Generate Daily Returns scatter chart
*/
function generateDailyReturnsChart(returns, dates, title = 'Daily Returns') {
const width = 800;
const height = 400;
const margin = { top: 40, right: 40, bottom: 80, left: 60 };
const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;
// Properly handle returns data format
const returnsData = returns.values ? returns.values : (Array.isArray(returns) ? returns : []);
if (!returnsData || returnsData.length === 0) {
return `<svg width="100%" height="400" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="max-width: 100%; height: auto;">
<rect width="${width}" height="${height}" fill="#f8f9fa" stroke="#dee2e6"/>
<text x="${width/2}" y="${height/2}" text-anchor="middle" fill="#6c757d">No daily data available</text>
</svg>`;
}
const minValue = Math.min(...returnsData);
const maxValue = Math.max(...returnsData);
const valueRange = maxValue - minValue || 1;
// Generate scatter points
const points = returnsData.map((value, index) => {
const x = margin.left + (index / (returnsData.length - 1)) * chartWidth;
const y = margin.top + ((maxValue - value) / valueRange) * chartHeight;
const color = value >= 0 ? '#2ca02c' : '#d62728';
const radius = Math.max(1, Math.min(3, Math.abs(value) * 1000));
return `<circle cx="${x}" cy="${y}" r="${radius}" fill="${color}" opacity="0.6"/>`;
}).join('');
// Zero line
const zeroY = margin.top + ((maxValue - 0) / valueRange) * chartHeight;
// Add time axis labels (show start, middle, end + key points)
const timeLabels = [];
if (dates && dates.length > 0) {
const labelIndices = [
0, // Start
Math.floor(dates.length * 0.25), // 25%
Math.floor(dates.length * 0.5), // 50%
Math.floor(dates.length * 0.75), // 75%
dates.length - 1 // End
];
labelIndices.forEach(i => {
if (i < dates.length) {
const date = dates[i];
const x = margin.left + (i / (dates.length - 1)) * chartWidth;
const label = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
timeLabels.push(`<text x="${x}" y="${height - 15}" text-anchor="middle" font-size="11" fill="#555">${label}</text>`);
}
});
}
return `<svg width="100%" height="400" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="max-width: 100%; height: auto;">
<!-- Background -->
<rect width="${width}" height="${height}" fill="white"/>
<!-- Chart area -->
<rect x="${margin.left}" y="${margin.top}" width="${chartWidth}" height="${chartHeight}"
fill="#fafafa" stroke="#e8e8e8" stroke-width="1"/>
<!-- Zero line -->
<line x1="${margin.left}" y1="${zeroY}" x2="${margin.left + chartWidth}" y2="${zeroY}"
stroke="#666" stroke-width="1" stroke-dasharray="2,3"/>
<!-- Scatter points -->
${points}
<!-- Title -->
<text x="${width/2}" y="25" text-anchor="middle" font-size="16" font-weight="600"
fill="#333" font-family="Arial, sans-serif">${title}</text>
<!-- Time labels -->
${timeLabels.join('')}
<!-- Y-axis labels -->
<text x="15" y="${margin.top + 10}" font-size="11" fill="#666">${(maxValue * 100).toFixed(1)}%</text>
<text x="15" y="${margin.top + chartHeight - 5}" font-size="11" fill="#666">${(minValue * 100).toFixed(1)}%</text>
</svg>`;
}
/**
* Generate Rolling Volatility chart
*/
function generateRollingVolatilityChart(returns, dates, title = 'Rolling Volatility (30 day)') {
const width = 800;
const height = 400;
const margin = { top: 40, right: 40, bottom: 80, left: 60 };
const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;
// Properly handle returns data format
const returnsData = returns.values ? returns.values : (Array.isArray(returns) ? returns : []);
if (!returnsData || returnsData.length < 30) {
return `<svg width="100%" height="400" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="max-width: 100%; height: auto;">
<rect width="${width}" height="${height}" fill="#f8f9fa" stroke="#dee2e6"/>
<text x="${width/2}" y="${height/2}" text-anchor="middle" fill="#6c757d">Insufficient data for rolling volatility</text>
</svg>`;
}
// Calculate 30-day rolling volatility
const window = 30;
const rollingVol = [];
for (let i = window - 1; i < returnsData.length; i++) {
const windowData = returnsData.slice(i - window + 1, i + 1);
const vol = stats.volatility(windowData, 252, false);
rollingVol.push(vol);
}
const minValue = Math.min(...rollingVol);
const maxValue = Math.max(...rollingVol);
const valueRange = maxValue - minValue || 1;
// Generate path data
const pathData = rollingVol.map((value, index) => {
const x = margin.left + (index / (rollingVol.length - 1)) * chartWidth;
const y = margin.top + ((maxValue - value) / valueRange) * chartHeight;
return `${index === 0 ? 'M' : 'L'} ${x} ${y}`;
}).join(' ');
// Add time axis labels (show start, middle, end + key points)
const timeLabels = [];
if (dates && dates.length > window) {
const adjustedDates = dates.slice(window - 1); // Skip first 29 dates since rolling starts at day 30
const labelIndices = [
0, // Start
Math.floor(adjustedDates.length * 0.25), // 25%
Math.floor(adjustedDates.length * 0.5), // 50%
Math.floor(adjustedDates.length * 0.75), // 75%
adjustedDates.length - 1 // End
];
labelIndices.forEach(i => {
if (i < adjustedDates.length) {
const date = adjustedDates[i];
const x = margin.left + (i / (adjustedDates.length - 1)) * chartWidth;
const label = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
timeLabels.push(`<text x="${x}" y="${height - 15}" text-anchor="middle" font-size="11" fill="#555">${label}</text>`);
}
});
}
return `<svg width="100%" height="400" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="max-width: 100%; height: auto;">
<!-- Background -->
<rect width="${width}" height="${height}" fill="white"/>
<!-- Chart area -->
<rect x="${margin.left}" y="${margin.top}" width="${chartWidth}" height="${chartHeight}"
fill="#fafafa" stroke="#e8e8e8" stroke-width="1"/>
<!-- Rolling volatility line -->
<path d="${pathData}" fill="none" stroke="#ff7f0e" stroke-width="2"/>
<!-- Title -->
<text x="${width/2}" y="25" text-anchor="middle" font-size="16" font-weight="600" fill="#333">${title}</text>
<!-- Time labels -->
${timeLabels.join('')}
<!-- Y-axis labels -->
<text x="15" y="${margin.top + 10}" font-size="11" fill="#666">${(maxValue * 100).toFixed(1)}%</text>
<text x="15" y="${margin.top + chartHeight - 5}" font-size="11" fill="#666">${(minValue * 100).toFixed(1)}%</text>
</svg>`;
}
/**
* Generate Rolling Sharpe chart
*/
function generateRollingSharpeChart(returns, dates, title = 'Rolling Sharpe (30 day)', rfRate = 0) {
const width = 800;
const height = 400;
const margin = { top: 40, right: 40, bottom: 80, left: 60 };
const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;
// Properly handle returns data format
const returnsData = returns.values ? returns.values : (Array.isArray(returns) ? returns : []);
if (!returnsData || returnsData.length < 30) {
return `<svg width="100%" height="400" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="max-width: 100%; height: auto;">
<rect width="${width}" height="${height}" fill="#f8f9fa" stroke="#dee2e6"/>
<text x="${width/2}" y="${height/2}" text-anchor="middle" fill="#6c757d">Insufficient data for rolling Sharpe</text>
</svg>`;
}
// Calculate 30-day rolling Sharpe ratio
const window = 30;
const rollingSharpe = [];
for (let i = window - 1; i < returnsData.length; i++) {
const windowData = returnsData.slice(i - window + 1, i + 1);
const sharpe = stats.sharpe(windowData, rfRate, 252, false);
rollingSharpe.push(sharpe);
}
const minValue = Math.min(...rollingSharpe);
const maxValue = Math.max(...rollingSharpe);
const valueRange = maxValue - minValue || 1;
// Generate path data
const pathData = rollingSharpe.map((value, index) => {
const x = margin.left + (index / (rollingSharpe.length - 1)) * chartWidth;
const y = margin.top + ((maxValue - value) / valueRange) * chartHeight;
return `${index === 0 ? 'M' : 'L'} ${x} ${y}`;
}).join(' ');
// Add time axis labels (show start, middle, end + key points)
const timeLabels = [];
if (dates && dates.length > window) {
const adjustedDates = dates.slice(window - 1); // Skip first 29 dates since rolling starts at day 30
const labelIndices = [
0, // Start
Math.floor(adjustedDates.length * 0.25), // 25%
Math.floor(adjustedDates.length * 0.5), // 50%
Math.floor(adjustedDates.length * 0.75), // 75%
adjustedDates.length - 1 // End
];
labelIndices.forEach(i => {
if (i < adjustedDates.length) {
const date = adjustedDates[i];
const x = margin.left + (i / (adjustedDates.length - 1)) * chartWidth;
const label = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
timeLabels.push(`<text x="${x}" y="${height - 15}" text-anchor="middle" font-size="11" fill="#555">${label}</text>`);
}
});
}
// Zero line
const zeroY = margin.top + ((maxValue - 0) / valueRange) * chartHeight;
return `<svg width="100%" height="400" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="max-width: 100%; height: auto;">
<!-- Background -->
<rect width="${width}" height="${height}" fill="white"/>
<!-- Chart area -->
<rect x="${margin.left}" y="${margin.top}" width="${chartWidth}" height="${chartHeight}"
fill="#fafafa" stroke="#e8e8e8" stroke-width="1"/>
<!-- Zero line -->
<line x1="${margin.left}" y1="${zeroY}" x2="${margin.left + chartWidth}" y2="${zeroY}"
stroke="#666" stroke-width="1" stroke-dasharray="2,3"/>
<!-- Rolling Sharpe line -->
<path d="${pathData}" fill="none" stroke="#2ca02c" stroke-width="2"/>
<!-- Title -->
<text x="${width/2}" y="25" text-anchor="middle" font-size="16" font-weight="600" fill="#333">${title}</text>
<!-- Time labels -->
${timeLabels.join('')}
<!-- Y-axis labels -->
<text x="15" y="${margin.top + 10}" font-size="11" fill="#666">${maxValue.toFixed(1)}</text>
<text x="15" y="${margin.top + chartHeight - 5}" font-size="11" fill="#666">${minValue.toFixed(1)}</text>
</svg>`;
}
/**
* Generate Rolling Sortino chart
*/
function generateRollingSortinoChart(returns, dates, title = 'Rolling Sortino (30 day)', rfRate = 0) {
const width = 800;
const height = 400;
const margin = { top: 40, right: 40, bottom: 80, left: 60 };
const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;
// Properly handle returns data format
const returnsData = returns.values ? returns.values : (Array.isArray(returns) ? returns : []);
if (!returnsData || returnsData.length < 30) {
return `<svg width="100%" height="400" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="max-width: 100%; height: auto;">
<rect width="${width}" height="${height}" fill="#f8f9fa" stroke="#dee2e6"/>
<text x="${width/2}" y="${height/2}" text-anchor="middle" fill="#6c757d">Insufficient data for rolling Sortino</text>
</svg>`;
}
// Calculate 30-day rolling Sortino ratio
const window = 30;
const rollingSortino = [];
for (let i = window - 1; i < returnsData.length; i++) {
const windowData = returnsData.slice(i - window + 1, i + 1);
try {
const sortinoValue = stats.sortino(windowData, rfRate, false);
// Handle NaN values
if (isNaN(sortinoValue) || !isFinite(sortinoValue)) {
rollingSortino.push(0);
} else {
rollingSortino.push(sortinoValue);
}
} catch (error) {
rollingSortino.push(0);
}
}
const minValue = Math.min(...rollingSortino);
const maxValue = Math.max(...rollingSortino);
const valueRange = maxValue - minValue || 1;
// Generate path data
const pathData = rollingSortino.map((value, index) => {
const x = margin.left + (index / (rollingSortino.length - 1)) * chartWidth;
const y = margin.top + ((maxValue - value) / valueRange) * chartHeight;
return `${index === 0 ? 'M' : 'L'} ${x} ${y}`;
}).join(' ');
// Add time axis labels (show start, middle, end + key points)
const timeLabels = [];
if (dates && dates.length > window) {
const adjustedDates = dates.slice(window - 1); // Skip first 29 dates since rolling starts at day 30
const labelIndices = [
0, // Start
Math.floor(adjustedDates.length * 0.25), // 25%
Math.floor(adjustedDates.length * 0.5), // 50%
Math.floor(adjustedDates.length * 0.75), // 75%
adjustedDates.length - 1 // End
];
labelIndices.forEach(i => {
if (i < adjustedDates.length) {
const date = adjustedDates[i];
const x = margin.left + (i / (adjustedDates.length - 1)) * chartWidth;
const label = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
timeLabels.push(`<text x="${x}" y="${height - 15}" text-anchor="middle" font-size="11" fill="#555">${label}</text>`);
}
});
}
// Zero line
const zeroY = margin.top + ((maxValue - 0) / valueRange) * chartHeight;
return `<svg width="100%" height="400" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="max-width: 100%; height: auto;">
<!-- Background -->
<rect width="${width}" height="${height}" fill="white"/>
<!-- Chart area -->
<rect x="${margin.left}" y="${margin.top}" width="${chartWidth}" height="${chartHeight}"
fill="#fafafa" stroke="#e8e8e8" stroke-width="1"/>
<!-- Zero line -->
<line x1="${margin.left}" y1="${zeroY}" x2="${margin.left + chartWidth}" y2="${zeroY}"
stroke="#666" stroke-width="1" stroke-dasharray="2,3"/>
<!-- Rolling Sortino line -->
<path d="${pathData}" fill="none" stroke="#9467bd" stroke-width="2"/>
<!-- Title -->
<text x="${width/2}" y="25" text-anchor="middle" font-size="16" font-weight="600" fill="#333">${title}</text>
<!-- Time labels -->
${timeLabels.join('')}
<!-- Y-axis labels -->
<text x="15" y="${margin.top + 10}" font-size="11" fill="#666">${maxValue.toFixed(1)}</text>
<text x="15" y="${margin.top + chartHeight - 5}" font-size="11" fill="#666">${minValue.toFixed(1)}</text>
</svg>`;
}
/**
* Generate Drawdown Periods chart
*/
function generateDrawdownPeriodsChart(returns, dates, title = 'Top 5 Drawdown Periods') {
const width = 800;
const height = 400;
const margin = { top: 40, right: 40, bottom: 80, left: 60 };
const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;
// Properly handle returns data format
const returnsData = returns.values ? returns.values : (Array.isArray(returns) ? returns : []);
if (!returnsData || returnsData.length === 0) {
return `<svg width="576" height="360" viewBox="0 0 576 360">
<rect width="576" height="360" fill="#f8f9fa" stroke="#dee2e6"/>
<text x="288" y="180" text-anchor="middle" fill="#6c757d">No data for drawdown periods</text>
</svg>`;
}
// Calculate cumulative returns and drawdowns manually
let cumulativeReturns = [1];
let peak = 1;
const drawdowns = [0];
for (let i = 0; i < returnsData.length; i++) {
const cumReturn = cumulativeReturns[i] * (1 + returnsData[i]);
cumulativeReturns.push(cumReturn);
if (cumReturn > peak) {
peak = cumReturn;
}
const drawdown = (cumReturn - peak) / peak;
drawdowns.push(drawdown);
}
// Find drawdown periods
const drawdownPeriods = [];
let inDrawdown = false;
let startIndex = 0;
let maxDD = 0;
for (let i = 1; i < drawdowns.length; i++) {
if (!inDrawdown && drawdowns[i] < 0) {
// Start of drawdown
inDrawdown = true;
startIndex = i;
maxDD = drawdowns[i];
} else if (inDrawdown) {
if (drawdowns[i] < maxDD) {
maxDD = drawdowns[i];
}
if (drawdowns[i] >= 0) {
// End of drawdown
inDrawdown = false;
drawdownPeriods.push({
startIndex,
endIndex: i - 1,
days: i - startIndex,
maxDrawdown: maxDD
});
}
}
}
// Handle case where drawdown continues to end
if (inDrawdown) {
drawdownPeriods.push({
startIndex,
endIndex: drawdowns.length - 1,
days: drawdowns.length - startIndex,
maxDrawdown: maxDD
});
}
if (drawdownPeriods.length === 0) {
return `<svg width="100%" height="400" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="max-width: 100%; height: auto;">
<rect width="${width}" height="${height}" fill="#f8f9fa" stroke="#dee2e6"/>
<text x="${width/2}" y="${height/2}" text-anchor="middle" fill="#6c757d">No significant drawdown periods found</text>
</svg>`;
}
// Get top 5 drawdowns by severity
const topDrawdowns = drawdownPeriods
.sort((a, b) => a.maxDrawdown - b.maxDrawdown) // Sort by severity (most negative first)
.slice(0, 5);
// Create bar chart of drawdown magnitudes
const barWidth = Math.max(40, (chartWidth - 40) / topDrawdowns.length);
const maxDrawdownAbs = Math.abs(Math.min(...topDrawdowns.map(dd => dd.maxDrawdown)));
const bars = topDrawdowns.map((dd, index) => {
const x = margin.left + index * (barWidth + 10) + 5;
const barHeight = Math.abs(dd.maxDrawdown) / maxDrawdownAbs * (chartHeight - 40);
const y = margin.top + chartHeight - barHeight;
return `<rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" fill="#d62728" opacity="0.8"/>
<text x="${x + barWidth/2}" y="${y - 5}" text-anchor="middle" font-size="10" fill="#333">${(dd.maxDrawdown * 100).toFixed(1)}%</text>
<text x="${x + barWidth/2}" y="${margin.top + chartHeight + 20}" text-anchor="middle" font-size="9" fill="#666">${dd.days}d</text>`;
}).join('');
return `<svg width="100%" height="400" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="max-width: 100%; height: auto;">
<!-- Background -->
<rect width="${width}" height="${height}" fill="white"/>
<!-- Chart area -->
<rect x="${margin.left}" y="${margin.top}" width="${chartWidth}" height="${chartHeight}"
fill="#fafafa" stroke="#e8e8e8" stroke-width="1"/>
<!-- Bars -->
${bars}
<!-- Title -->
<text x="${width/2}" y="25" text-anchor="middle" font-size="16" font-weight="600" fill="#333">${title}</text>
<!-- Labels -->
<text x="${width/2}" y="${height - 5}" text-anchor="middle" font-size="10" fill="#666">Duration (days)</text>
</svg>`;
}
/**
* Generate Underwater (Drawdown) chart
*/
function generateUnderwaterChart(returns, dates, title = 'Underwater Chart (Drawdowns)') {
const width = 800;
const height = 400;
const margin = { top: 50, right: 80, bottom: 70, left: 80 };
const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;
// Properly handle returns data format
const returnsData = returns.values ? returns.values : (Array.isArray(returns) ? returns : []);
const datesData = dates || (returns.index ? returns.index : null);
if (!returnsData || returnsData.length === 0) {
return `<svg width="100%" height="400" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="max-width: 100%; height: auto;">
<rect width="${width}" height="${height}" fill="#f8f9fa" stroke="#dee2e6"/>
<text x="${width/2}" y="${height/2}" text-anchor="middle" fill="#6c757d">No returns data for underwater chart</text>
</svg>`;
}
if (!datesData || datesData.length !== returnsData.length) {
return `<svg width="100%" height="400" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="max-width: 100%; height: auto;">
<rect width="${width}" height="${height}" fill="#f8f9fa" stroke="#dee2e6"/>
<text x="${width/2}" y="${height/2}" text-anchor="middle" fill="#6c757d">Underwater chart requires valid dates</text>
</svg>`;
}
// Calculate cumulative returns and drawdowns
let cumulativeValue = 1.0;
let peak = 1.0;
const drawdowns = [0]; // Start at 0% drawdown
for (let i = 0; i < returnsData.length; i++) {
cumulativeValue *= (1 + returnsData[i]);
// Update peak if we've reached a new high
if (cumulativeValue > peak) {
peak = cumulativeValue;
}
// Calculate drawdown as percentage from peak
const drawdown = ((cumulativeValue - peak) / peak) * 100; // Negative percentage
drawdowns.push(drawdown);
}
// Prepare data points for plotting
const dataPoints = [];
for (let i = 0; i < drawdowns.length; i++) {
const x = margin.left + (i / (drawdowns.length - 1)) * chartWidth;
const yPos = margin.top + chartHeight - ((drawdowns[i] - Math.min(...drawdowns)) / (0 - Math.min(...drawdowns))) * chartHeight;
dataPoints.push({ x, y: yPos, value: drawdowns[i] });
}
// Create the line path
const linePath = dataPoints.map((point, index) => {
return index === 0 ? `M ${point.x} ${point.y}` : `L ${point.x} ${point.y}`;
}).join(' ');
// Calculate the zero line position (0% drawdown)
const zeroLineY = margin.top;
// Create filled area path for underwater (from zero line down to drawdown line) - this should be blue
// Only fill the area between the zero line and the drawdown line when underwater
const underwaterAreaPoints = dataPoints.map(point => ({ x: point.x, y: Math.max(point.y, zeroLineY) }));
const underwaterLinePath = underwaterAreaPoints.map((point, index) => {
return index === 0 ? `M ${point.x} ${zeroLineY}` : `L ${point.x} ${zeroLineY}`;
}).join(' ') + ' ' + underwaterAreaPoints.map((point, index) => {
return index === 0 ? `M ${point.x} ${point.y}` : `L ${point.x} ${point.y}`;
}).reverse().join(' ') + ' Z';
// Simpler approach: create area from zero line to drawdown line
const underwaterAreaPath = `M ${margin.left} ${zeroLineY} ` +
dataPoints.map(point => `L ${point.x} ${zeroLineY}`).join(' ') +
` L ${dataPoints[dataPoints.length - 1].x} ${dataPoints[dataPoints.length - 1].y} ` +
dataPoints.slice().reverse().map(point => `L ${point.x} ${point.y}`).join(' ') +
` L ${margin.left} ${dataPoints[0].y} Z`;
// Calculate axis values
const minDrawdown = Math.min(...drawdowns);
const maxDrawdown = 0; // Always 0 at the top
// Generate Y-axis ticks
const yTicks = [];
const tickCount = 5;
for (let i = 0; i <= tickCount; i++) {
const value = maxDrawdown + (minDrawdown - maxDrawdown) * (i / tickCount);
const yPos = margin.top + (i / tickCount) * chartHeight;
yTicks.push({ value, yPos });
}
// Generate X-axis date labels
const xTicks = [];
const dateTickCount = 6;
for (let i = 0; i < dateTickCount; i++) {
const dataIndex = Math.floor((i / (dateTickCount - 1)) * (datesData.length - 1));
const xPos = margin.left + (dataIndex / (datesData.length - 1)) * chartWidth;
// Since we now require valid dates, we should always have them
const date = new Date(datesData[dataIndex]);
const label = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
xTicks.push({ label, xPos });
}
return `<svg width="100%" height="400" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="max-width: 100%; height: auto;">
<!-- Background -->
<rect width="${width}" height="${height}" fill="#f8f9fa"/>
<!-- Chart area background -->
<rect x="${margin.left}" y="${margin.top}" width="${chartWidth}" height="${chartHeight}" fill="#f8f9fa" stroke="#dee2e6"/>
<!-- Grid lines (Y-axis) -->
${yTicks.map(tick => `<line x1="${margin.left}" y1="${tick.yPos}" x2="${margin.left + chartWidth}" y2="${tick.yPos}" stroke="#e9ecef" stroke-width="1"/>`).join('')}
<!-- Grid lines (X-axis) -->
${xTicks.map(tick => `<line x1="${tick.xPos}" y1="${margin.top}" x2="${tick.xPos}" y2="${margin.top + chartHeight}" stroke="#e9ecef" stroke-width="1"/>`).join('')}
<!-- Underwater area (filled) - Blue for drawdown areas -->
<path d="${underwaterAreaPath}" fill="rgba(30, 144, 255, 0.4)" stroke="none"/>
<!-- Drawdown line -->
<path d="${linePath}" fill="none" stroke="#1e90ff" stroke-width="2"/>
<!-- Zero line (baseline) -->
<line x1="${margin.left}" y1="${zeroLineY}" x2="${margin.left + chartWidth}" y2="${zeroLineY}" stroke="#333" stroke-width="2" stroke-dasharray="5,5"/>
<!-- Axes -->
<line x1="${margin.left}" y1="${margin.top}" x2="${margin.left}" y2="${margin.top + chartHeight}" stroke="#333" stroke-width="1"/>
<line x1="${margin.left}" y1="${margin.top + chartHeight}" x2="${margin.left + chartWidth}" y2="${margin.top + chartHeight}" stroke="#333" stroke-width="1"/>
<!-- Y-axis labels -->
${yTicks.map(tick => `<text x="${margin.left - 10}" y="${tick.yPos + 4}" text-anchor="end" font-size="10" fill="#666">${tick.value.toFixed(1)}%</text>`).join('')}
<!-- X-axis labels -->
${xTicks.map(tick => `<text x="${tick.xPos}" y="${margin.top + chartHeight + 20}" text-anchor="middle" font-size="10" fill="#666">${tick.label}</text>`).join('')}
<!-- Title -->
<text x="${width/2}" y="25" text-anchor="middle" font-size="16" font-weight="600" fill="#333">${title}</text>
<!-- Axis labels -->
<text x="${width/2}" y="${height - 5}" text-anchor="middle" font-size="11" fill="#666">Time</text>
<text x="20" y="${height/2}" text-anchor="middle" font-size="11" fill="#666" transform="rotate(-90 20 ${height/2})">Drawdown %</text>
</svg>`;
}
/**
* Generate Log Returns chart (cumulative returns with logarithmic scale)
*/
function generateLogReturnsChart(returns, dates, title = 'Log Returns') {
// Use the existing cumulative returns chart but with log scale
return generateCumulativeReturnsChart(returns, dates, title, true);
}
/**
* Generate Vol/Returns chart
*/
function generateVolReturnsChart(returns, dates, title = 'Volatility vs Returns') {
const width = 800;
const height = 400;
const margin = { top: 40, right: 40, bottom: 80, left: 60 };
const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;
// Properly handle returns data format
const returnsData = returns.values ? returns.values : (Array.isArray(returns) ? returns : []);
if (!returnsData || returnsData.length < 252) {
return `<svg width="100%" height="400" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="max-width: 100%; height: auto;">
<rect width="${width}" height="${height}" fill="#f8f9fa" stroke="#dee2e6"/>
<text x="${width/2}" y="${height/2}" text-anchor="middle" fill="#6c757d">Insufficient data for vol/returns analysis</text>
</svg>`;
}
// Calculate annual vol and returns for rolling windows
const window = 252; // 1 year
const volReturnsData = [];
for (let i = window - 1; i < returnsData.length; i++) {
const windowData = returnsData.slice(i - window + 1, i + 1);
const annualReturn = stats.cagr(windowData, 252, false);
const annualVol = stats.volatility(windowData, 252, false);
volReturnsData.push({ vol: annualVol, returns: annualReturn });
}
const minVol = Math.min(...volReturnsData.map(d => d.vol));
const maxVol = Math.max(...volReturnsData.map(d => d.vol));
const minReturns = Math.min(...volReturnsData.map(d => d.returns));
const maxReturns = Math.max(...volReturnsData.map(d => d.returns));
const points = volReturnsData.map(d => {
const x = margin.left + ((d.vol - minVol) / (maxVol - minVol || 1)) * chartWidth;
const y = margin.top + chartHeight - ((d.returns - minReturns) / (maxReturns - minReturns || 1)) * chartHeight;
return `<circle cx="${x}" cy="${y}" r="3" fill="#ff7f0e" opacity="0.7"/>`;
}).join('');
return `<svg width="100%" height="400" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="max-width: 100%; height: auto;">
<!-- Background -->
<rect width="${width}" height="${height}" fill="white"/>
<!-- Chart area -->
<rect x="${margin.left}" y="${margin.top}" width="${chartWidth}" height="${chartHeight}"
fill="#fafafa" stroke="#e8e8e8" stroke-width="1"/>
<!-- Points -->
${points}
<!-- Title -->
<text x="${width/2}" y="25" text-anchor="middle" font-size="16" font-weight="600" fill="#333">${title}</text>
<!-- Axis labels -->
<text x="${width