(function() { 'use strict'; // Box-Muller transform for normal distribution function boxMuller() { let u1, u2; do { u1 = Math.random(); u2 = Math.random(); } while (u1 === 0); return Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2); } // Convert annual rate to monthly function annualToMonthly(annualRate) { return Math.pow(1 + annualRate, 1/12) - 1; } // Run a single simulation function runSimulation(params, months) { const monthlyReturn = annualToMonthly(params.annualReturn); const monthlyVol = params.annualVol / Math.sqrt(12); const monthlyExpense = params.expenseRatio / 12; let value = 0; const values = [0]; for (let m = 1; m <= months; m++) { // Add monthly deposit at start of month value += params.monthlyDeposit; // Apply log-normal return const randomReturn = monthlyReturn + monthlyVol * boxMuller(); value *= Math.exp(randomReturn); // Deduct expense ratio value *= (1 - monthlyExpense); values.push(value); } // Track pre-tax value const preTaxValue = value; // Apply capital gains tax at withdrawal if specified const totalDeposits = params.monthlyDeposit * months; const gains = value - totalDeposits; let taxPaid = 0; if (params.capitalGainsTax > 0 && gains > 0) { taxPaid = gains * params.capitalGainsTax; value -= taxPaid; } return { values, finalValue: value, preTaxValue, taxPaid }; } // Run Monte Carlo simulation function runMonteCarlo(params, months, numSimulations) { const allFinalValues = []; const allPreTaxValues = []; const allTaxPaid = []; const allValuesByMonth = []; for (let i = 0; i <= months; i++) { allValuesByMonth.push([]); } for (let sim = 0; sim < numSimulations; sim++) { const result = runSimulation(params, months); allFinalValues.push(result.finalValue); allPreTaxValues.push(result.preTaxValue); allTaxPaid.push(result.taxPaid); for (let m = 0; m <= months; m++) { allValuesByMonth[m].push(result.values[m]); } } // Sort values at each month for percentile calculation for (let m = 0; m <= months; m++) { allValuesByMonth[m].sort((a, b) => a - b); } allFinalValues.sort((a, b) => a - b); allPreTaxValues.sort((a, b) => a - b); allTaxPaid.sort((a, b) => a - b); // Calculate percentiles function getPercentile(sortedArr, p) { const idx = Math.floor(sortedArr.length * p); return sortedArr[Math.min(idx, sortedArr.length - 1)]; } const percentiles = { p10: getPercentile(allFinalValues, 0.10), p25: getPercentile(allFinalValues, 0.25), p50: getPercentile(allFinalValues, 0.50), p75: getPercentile(allFinalValues, 0.75), p90: getPercentile(allFinalValues, 0.90) }; const preTaxPercentiles = { p50: getPercentile(allPreTaxValues, 0.50) }; const taxPaidPercentiles = { p50: getPercentile(allTaxPaid, 0.50) }; // Get percentile curves for chart const curves = { p10: [], p50: [], p90: [] }; for (let m = 0; m <= months; m++) { curves.p10.push(getPercentile(allValuesByMonth[m], 0.10)); curves.p50.push(getPercentile(allValuesByMonth[m], 0.50)); curves.p90.push(getPercentile(allValuesByMonth[m], 0.90)); } return { percentiles, preTaxPercentiles, taxPaidPercentiles, curves }; } // Run deterministic simulation (no volatility) function runDeterministic(params, months) { const monthlyReturn = annualToMonthly(params.annualReturn); const monthlyExpense = params.expenseRatio / 12; let value = 0; const values = [0]; for (let m = 1; m <= months; m++) { value += params.monthlyDeposit; value *= (1 + monthlyReturn); value *= (1 - monthlyExpense); values.push(value); } const preTaxValue = value; const totalDeposits = params.monthlyDeposit * months; const gains = value - totalDeposits; let taxPaid = 0; if (params.capitalGainsTax > 0 && gains > 0) { taxPaid = gains * params.capitalGainsTax; value -= taxPaid; } return { percentiles: { p10: value, p25: value, p50: value, p75: value, p90: value }, preTaxPercentiles: { p50: preTaxValue }, taxPaidPercentiles: { p50: taxPaid }, curves: { p10: values, p50: values, p90: values } }; } // Format currency function formatCurrency(value) { if (value >= 1000000) { return '$' + (value / 1000000).toFixed(2) + 'M'; } else if (value >= 1000) { return '$' + (value / 1000).toFixed(1) + 'K'; } return '$' + value.toFixed(0); } // Chart renderer const ChartRenderer = { canvas: null, ctx: null, init: function(canvasId) { this.canvas = document.getElementById(canvasId); this.ctx = this.canvas.getContext('2d'); this.resize(); }, resize: function() { const container = this.canvas.parentElement; const dpr = window.devicePixelRatio || 1; const rect = container.getBoundingClientRect(); this.canvas.width = rect.width * dpr; this.canvas.height = 400 * dpr; this.canvas.style.width = rect.width + 'px'; this.canvas.style.height = '400px'; this.ctx.scale(dpr, dpr); }, clear: function() { const width = this.canvas.width / (window.devicePixelRatio || 1); const height = this.canvas.height / (window.devicePixelRatio || 1); this.ctx.clearRect(0, 0, width, height); }, draw: function(sp500Result, comparedResult, years, monthlyDeposit, useMonteCarlo) { this.clear(); const width = this.canvas.width / (window.devicePixelRatio || 1); const height = this.canvas.height / (window.devicePixelRatio || 1); const padding = { top: 30, right: 100, bottom: 50, left: 80 }; const chartWidth = width - padding.left - padding.right; const chartHeight = height - padding.top - padding.bottom; const months = years * 12; const totalDeposits = []; for (let m = 0; m <= months; m++) { totalDeposits.push(monthlyDeposit * m); } // Find max value for Y axis let maxVal = 0; const allCurves = [ sp500Result.curves.p90, comparedResult.curves.p90, totalDeposits ]; for (const curve of allCurves) { for (const val of curve) { if (val > maxVal) maxVal = val; } } maxVal *= 1.1; // Add 10% padding // Get computed styles for colors const style = getComputedStyle(document.documentElement); const textColor = style.getPropertyValue('--text-color').trim() || '#D4D4D4'; const dividerColor = style.getPropertyValue('--divider-color').trim() || '#4a4a4a'; const primaryColor = style.getPropertyValue('--primary-color').trim() || '#91e0ee'; // Draw grid this.ctx.strokeStyle = dividerColor; this.ctx.lineWidth = 0.5; this.ctx.setLineDash([5, 5]); // Y-axis grid lines const numYLines = 5; for (let i = 0; i <= numYLines; i++) { const y = padding.top + (chartHeight / numYLines) * i; this.ctx.beginPath(); this.ctx.moveTo(padding.left, y); this.ctx.lineTo(width - padding.right, y); this.ctx.stroke(); } // X-axis grid lines for (let yr = 0; yr <= years; yr++) { const x = padding.left + (chartWidth / years) * yr; this.ctx.beginPath(); this.ctx.moveTo(x, padding.top); this.ctx.lineTo(x, height - padding.bottom); this.ctx.stroke(); } this.ctx.setLineDash([]); // Helper to map data to canvas coordinates const mapX = (month) => padding.left + (month / months) * chartWidth; const mapY = (value) => padding.top + chartHeight - (value / maxVal) * chartHeight; // Draw confidence bands if Monte Carlo if (useMonteCarlo) { // S&P 500 band (green) this.ctx.fillStyle = 'rgba(76, 175, 80, 0.15)'; this.ctx.beginPath(); this.ctx.moveTo(mapX(0), mapY(sp500Result.curves.p10[0])); for (let m = 0; m <= months; m++) { this.ctx.lineTo(mapX(m), mapY(sp500Result.curves.p90[m])); } for (let m = months; m >= 0; m--) { this.ctx.lineTo(mapX(m), mapY(sp500Result.curves.p10[m])); } this.ctx.closePath(); this.ctx.fill(); // Compared fund band (blue) this.ctx.fillStyle = 'rgba(33, 150, 243, 0.15)'; this.ctx.beginPath(); this.ctx.moveTo(mapX(0), mapY(comparedResult.curves.p10[0])); for (let m = 0; m <= months; m++) { this.ctx.lineTo(mapX(m), mapY(comparedResult.curves.p90[m])); } for (let m = months; m >= 0; m--) { this.ctx.lineTo(mapX(m), mapY(comparedResult.curves.p10[m])); } this.ctx.closePath(); this.ctx.fill(); } // Draw total deposits line (dashed gray) this.ctx.strokeStyle = '#888888'; this.ctx.lineWidth = 2; this.ctx.setLineDash([8, 4]); this.ctx.beginPath(); this.ctx.moveTo(mapX(0), mapY(totalDeposits[0])); for (let m = 1; m <= months; m++) { this.ctx.lineTo(mapX(m), mapY(totalDeposits[m])); } this.ctx.stroke(); this.ctx.setLineDash([]); // Draw S&P 500 median line (green) this.ctx.strokeStyle = '#4CAF50'; this.ctx.lineWidth = 2.5; this.ctx.beginPath(); this.ctx.moveTo(mapX(0), mapY(sp500Result.curves.p50[0])); for (let m = 1; m <= months; m++) { this.ctx.lineTo(mapX(m), mapY(sp500Result.curves.p50[m])); } this.ctx.stroke(); // Draw compared fund median line (blue) this.ctx.strokeStyle = '#2196F3'; this.ctx.lineWidth = 2.5; this.ctx.beginPath(); this.ctx.moveTo(mapX(0), mapY(comparedResult.curves.p50[0])); for (let m = 1; m <= months; m++) { this.ctx.lineTo(mapX(m), mapY(comparedResult.curves.p50[m])); } this.ctx.stroke(); // Draw capital gains tax loss on compared fund (red segment) const taxPaid = comparedResult.taxPaidPercentiles.p50; const preTaxValue = comparedResult.preTaxPercentiles.p50; const postTaxValue = comparedResult.percentiles.p50; const endX = mapX(months); if (taxPaid > 0) { const preTaxY = mapY(preTaxValue); const postTaxY = mapY(postTaxValue); // Draw red line from pre-tax to post-tax this.ctx.strokeStyle = '#F44336'; this.ctx.lineWidth = 3; this.ctx.beginPath(); this.ctx.moveTo(endX, preTaxY); this.ctx.lineTo(endX, postTaxY); this.ctx.stroke(); // Draw red circle at post-tax point this.ctx.fillStyle = '#F44336'; this.ctx.beginPath(); this.ctx.arc(endX, postTaxY, 4, 0, Math.PI * 2); this.ctx.fill(); // Label the tax loss this.ctx.fillStyle = '#F44336'; this.ctx.font = '10px monospace'; this.ctx.textAlign = 'left'; this.ctx.textBaseline = 'middle'; this.ctx.fillText('-' + formatCurrency(taxPaid) + ' tax', endX + 8, (preTaxY + postTaxY) / 2); } // Draw delta annotation between funds const sp500Final = sp500Result.percentiles.p50; const comparedFinal = comparedResult.percentiles.p50; const delta = sp500Final - comparedFinal; // Draw delta bracket/line const sp500Y = mapY(sp500Final); const comparedY = mapY(comparedFinal); const bracketX = endX + 6; this.ctx.strokeStyle = textColor; this.ctx.lineWidth = 1; this.ctx.setLineDash([]); this.ctx.beginPath(); // Horizontal ticks this.ctx.moveTo(endX + 2, sp500Y); this.ctx.lineTo(bracketX, sp500Y); this.ctx.moveTo(endX + 2, comparedY); this.ctx.lineTo(bracketX, comparedY); // Vertical line this.ctx.moveTo(bracketX, sp500Y); this.ctx.lineTo(bracketX, comparedY); this.ctx.stroke(); // Delta label this.ctx.fillStyle = delta >= 0 ? '#4CAF50' : '#F44336'; this.ctx.font = '11px monospace'; this.ctx.textAlign = 'left'; this.ctx.textBaseline = 'middle'; const deltaLabel = 'Δ ' + (delta >= 0 ? '+' : '') + formatCurrency(delta); this.ctx.fillText(deltaLabel, bracketX + 4, (sp500Y + comparedY) / 2); // Draw axes this.ctx.strokeStyle = textColor; this.ctx.lineWidth = 1; this.ctx.beginPath(); this.ctx.moveTo(padding.left, padding.top); this.ctx.lineTo(padding.left, height - padding.bottom); this.ctx.lineTo(width - padding.right, height - padding.bottom); this.ctx.stroke(); // Y-axis labels this.ctx.fillStyle = textColor; this.ctx.font = '12px monospace'; this.ctx.textAlign = 'right'; this.ctx.textBaseline = 'middle'; for (let i = 0; i <= numYLines; i++) { const value = maxVal * (1 - i / numYLines); const y = padding.top + (chartHeight / numYLines) * i; this.ctx.fillText(formatCurrency(value), padding.left - 10, y); } // X-axis labels this.ctx.textAlign = 'center'; this.ctx.textBaseline = 'top'; for (let yr = 0; yr <= years; yr++) { const x = padding.left + (chartWidth / years) * yr; this.ctx.fillText(yr + 'y', x, height - padding.bottom + 10); } // Legend const legendY = padding.top - 15; const legendItems = [ { color: '#4CAF50', label: 'S&P 500' }, { color: '#2196F3', label: 'Compared' }, { color: '#888888', label: 'Deposits', dashed: true }, { color: '#F44336', label: 'Tax Loss' } ]; let legendX = padding.left; this.ctx.font = '11px monospace'; this.ctx.textAlign = 'left'; this.ctx.textBaseline = 'middle'; for (const item of legendItems) { // Draw line sample this.ctx.strokeStyle = item.color; this.ctx.lineWidth = 2; if (item.dashed) { this.ctx.setLineDash([4, 2]); } this.ctx.beginPath(); this.ctx.moveTo(legendX, legendY); this.ctx.lineTo(legendX + 20, legendY); this.ctx.stroke(); this.ctx.setLineDash([]); // Draw label this.ctx.fillStyle = textColor; this.ctx.fillText(item.label, legendX + 25, legendY); legendX += this.ctx.measureText(item.label).width + 50; } } }; // Main calculator const Calculator = { form: null, chartRenderer: null, init: function() { this.form = document.getElementById('fund-comparison-form'); this.chartRenderer = Object.create(ChartRenderer); this.chartRenderer.init('fund-comparison-chart'); this.form.addEventListener('submit', (e) => { e.preventDefault(); this.calculate(); }); window.addEventListener('resize', () => { this.chartRenderer.resize(); this.calculate(); }); // Initial calculation this.calculate(); }, getInputs: function() { return { sp500: { annualReturn: parseFloat(document.getElementById('sp500-return').value) / 100, annualVol: parseFloat(document.getElementById('sp500-volatility').value) / 100, expenseRatio: parseFloat(document.getElementById('sp500-expense').value) / 100, capitalGainsTax: 0, monthlyDeposit: parseFloat(document.getElementById('monthly-deposit').value) }, compared: { annualReturn: parseFloat(document.getElementById('compared-return').value) / 100, annualVol: parseFloat(document.getElementById('compared-volatility').value) / 100, expenseRatio: parseFloat(document.getElementById('compared-admin-fee').value) / 100, capitalGainsTax: parseFloat(document.getElementById('compared-capital-gains').value) / 100, monthlyDeposit: parseFloat(document.getElementById('monthly-deposit').value) }, years: parseInt(document.getElementById('time-frame').value), useMonteCarlo: document.getElementById('use-monte-carlo').checked }; }, calculate: function() { const inputs = this.getInputs(); const months = inputs.years * 12; const numSimulations = 1000; let sp500Result, comparedResult; if (inputs.useMonteCarlo) { sp500Result = runMonteCarlo(inputs.sp500, months, numSimulations); comparedResult = runMonteCarlo(inputs.compared, months, numSimulations); } else { sp500Result = runDeterministic(inputs.sp500, months); comparedResult = runDeterministic(inputs.compared, months); } // Draw chart this.chartRenderer.draw( sp500Result, comparedResult, inputs.years, inputs.sp500.monthlyDeposit, inputs.useMonteCarlo ); // Update summary this.updateSummary(sp500Result, comparedResult, inputs); }, updateSummary: function(sp500Result, comparedResult, inputs) { const totalDeposits = inputs.sp500.monthlyDeposit * inputs.years * 12; // Summary table document.getElementById('total-deposits').textContent = formatCurrency(totalDeposits); document.getElementById('sp500-final').textContent = formatCurrency(sp500Result.percentiles.p50); const sp500Gain = sp500Result.percentiles.p50 - totalDeposits; document.getElementById('sp500-gain').textContent = formatCurrency(sp500Gain); document.getElementById('sp500-multiple').textContent = (sp500Result.percentiles.p50 / totalDeposits).toFixed(2) + 'x'; document.getElementById('compared-final').textContent = formatCurrency(comparedResult.percentiles.p50); const comparedGain = comparedResult.percentiles.p50 - totalDeposits; document.getElementById('compared-gain').textContent = formatCurrency(comparedGain); document.getElementById('compared-multiple').textContent = (comparedResult.percentiles.p50 / totalDeposits).toFixed(2) + 'x'; // Percentiles section const percentilesSection = document.getElementById('percentiles-section'); if (inputs.useMonteCarlo) { percentilesSection.style.display = 'block'; document.getElementById('sp500-p10').textContent = formatCurrency(sp500Result.percentiles.p10); document.getElementById('sp500-p25').textContent = formatCurrency(sp500Result.percentiles.p25); document.getElementById('sp500-p50').textContent = formatCurrency(sp500Result.percentiles.p50); document.getElementById('sp500-p75').textContent = formatCurrency(sp500Result.percentiles.p75); document.getElementById('sp500-p90').textContent = formatCurrency(sp500Result.percentiles.p90); document.getElementById('compared-p10').textContent = formatCurrency(comparedResult.percentiles.p10); document.getElementById('compared-p25').textContent = formatCurrency(comparedResult.percentiles.p25); document.getElementById('compared-p50').textContent = formatCurrency(comparedResult.percentiles.p50); document.getElementById('compared-p75').textContent = formatCurrency(comparedResult.percentiles.p75); document.getElementById('compared-p90').textContent = formatCurrency(comparedResult.percentiles.p90); } else { percentilesSection.style.display = 'none'; } } }; // Initialize on DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => Calculator.init()); } else { Calculator.init(); } })();