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,209 lines (996 loc) 108 kB
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