homepage/static/js/fundComparison.js
2026-03-25 21:10:37 -06:00

567 lines
22 KiB
JavaScript

(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();
}
})();