Compare commits

...

3 commits

10 changed files with 1291 additions and 4 deletions

View file

@ -10,9 +10,12 @@ Personal homepage built with Zola.
- `content/` — Markdown content
- `blog/` — Blog posts
- `lib/` — Library/reference articles
- `triforce_strategies/` — Triforce Strategies LLC content
- `triforce-strategies/` — Triforce Strategies LLC content
- `tools/` — Interactive calculators/tools
- `templates/shortcodes/` — Custom shortcodes (override theme)
- `static/js/` — JavaScript files
- `static/css/` — CSS files
- `themes/tabi-lean/` — Theme (git subtree, do not edit directly)
- `static/` — Static assets
- `config.toml` — Site configuration
## Content Conventions
@ -20,5 +23,29 @@ Personal homepage built with Zola.
- Section indices: `_index.md`
- Internal links: `@/path/to/file.md`
## Adding Interactive Tools
To add a new tool under `triforce-strategies/tools/`:
1. **Create shortcode**: `templates/shortcodes/tool_name.html`
- HTML structure (form, canvas, results container)
- Load JS via `{{ get_url(path='js/toolName.js') | safe }}`
- Include `<noscript>` fallback
2. **Create JS**: `static/js/toolName.js`
- Wrap in IIFE: `(function() { 'use strict'; ... })();`
- Use theme CSS variables for colors (--text-color, --primary-color, etc.)
- Scale canvas for retina: `window.devicePixelRatio`
3. **Create CSS**: `static/css/tool-name.css`
- Use theme CSS variables from `themes/tabi-lean/sass/main.scss`
- Responsive layout with media queries
4. **Create page**: `content/triforce-strategies/tools/tool_name.md`
- Frontmatter: `[extra] stylesheets = ["css/tool-name.css"]`
- Invoke shortcode: `{{ tool_name() }}`
Example: `snp500_vs_anything` (fund comparison calculator with Monte Carlo simulation)
## Commit Style
`type: description` (e.g., `blog: add post title`)

View file

@ -10,6 +10,10 @@ author = "Fabian Montero"
build_search_index = true
generate_sitemap = true
taxonomies = [
{ name = "tags" },
]
[markdown]
highlight_code = true
@ -65,9 +69,9 @@ feed_icon = true
menu = [
{name = "lib", url = "lib"},
{name = "blog", url = "blog"},
{name = "leaderboard", url = "leaderboard" },
{name = "git", url = "https://git.posixlycorrect.com"},
{name = "recipes", url = "https://food.posixlycorrect.com"},
{name = "triforce strategies", url = "triforce-strategies"},
{name = "leaderboard", url = "leaderboard" },
]
footer_menu = [
@ -76,6 +80,7 @@ footer_menu = [
{name = "wiki", url = "https://wiki.posixlycorrect.com"},
{name = "notes", url = "https://notes.posixlycorrect.com"},
{name = "ebooks", url = "https://calibre.posixlycorrect.com"},
{name = "recipes", url = "https://food.posixlycorrect.com"},
{name = "news", url = "https://rss.posixlycorrect.com"},
{name = "photos", url = "https://photos.posixlycorrect.com"},
{name = "sitemap", url = "sitemap.xml", trailing_slash = false},

View file

@ -0,0 +1,5 @@
+++
title = "triforce strategies archive"
sort_by = "date"
paginate_by = 10
+++

View file

@ -0,0 +1,161 @@
+++
title = "Fondos de Inversión en Costa Rica: BN ETF 500 vs Millennium BAC"
date = 2026-03-25
description = "Comparación entre dos fondos de inversión disponibles en Costa Rica para acceder al S&P 500 y mercados internacionales."
[taxonomies]
tags = ["inversiones", "etfs", "costa-rica", "sp500", "bac", "bn"]
[extra]
author = "fabian"
isso = true
+++
## ¿Qué es un fondo de inversión?
Uno le da la plata al banco y ellos la invierten en algo que hopefully genera ganancias. En este caso, es SPY.
- **Fondo abierto:** Puede entrar y salir cuando quiera. El número de participaciones es variable.
- **Valor de participación:** Se calcula diariamente con base en el valor neto del fondo (NAV). Tu ganancia o pérdida es la diferencia entre el valor al entrar y al salir.
- **Crecimiento:** Las utilidades no se distribuyen; se reinvierten, aumentando el valor de la participación.
- **Plazo de liquidación:** Al solicitar un retiro, el reembolso tarda T+5.
- **SAFI:** Sociedad Administradora de Fondos de Inversión. La entidad que gestiona el fondo, regulada por SUGEVAL.
## Marco regulatorio e impuestos
### Regulación
SUGEVAL supervisa todas las SAFIs y fondos de inversión en Costa Rica. Los fondos deben estar inscritos en el Registro Nacional de Valores e Intermediarios.
**Ojo:** Autorización de oferta pública NO implica opinión sobre el fondo ni sobre la SAFI. La gestión financiera del fondo es independiente de la entidad bancaria del grupo.
### Impuestos
La Ley 9635 (Fortalecimiento de las Finanzas Públicas) reformó el régimen fiscal desde julio 2019. Los fondos pagan impuesto sobre rentas de capital y ganancias/pérdidas de capital. Para activos extranjeros aplica el régimen fiscal del país donde se invierte.
Es su responsabilidad verificar tu tratamiento tributario particular.
## Gestión pasiva vs. gestión activa
| | Gestión pasiva | Gestión activa |
|---|---|---|
| Estrategia | Replica un índice (ej. S&P 500) | Gestor toma decisiones de compra/venta |
| Objetivo | No intenta superar al mercado | Busca retornos con base en análisis de mercado |
| Comisiones | Más bajas | Más altas (pagan al gestor) |
| Transparencia | Total: sabés exactamente qué comprás | Diversificación geográfica gestionada |
| En caídas | El fondo cae igual | Puede proteger la cartera (o no) |
| **Ejemplo** | **BN ETF 500** | **Millennium BAC** |
## BN ETF 500
*Banco Nacional · BN Fondos*
| | |
|---|---|
| Inversión mínima | $100 |
| Comisión anual | 1% |
| Estrategia de gestión | Pasiva |
| Perfil de riesgo | Agresivo |
| Autorizado | 2025-01 |
### Objetivo
Replicar el comportamiento del Índice S&P 500 por medio de ETFs (VOO, SPY). No busca generar retornos más allá del seguimiento del índice.
**Composición:** Hasta 98% de la cartera es pasiva; un 10% puede invertirse en valores de deuda para manejo de liquidez.
### Composición de cartera (2026-01-31)
| Instrumento | Monto (USD) | % |
|---|---:|---:|
| Vanguard S&P 500 ETF (VOO) | $5,794,090 | 46.3% |
| SPDR S&P 500 ETF Trust (SPY) | $5,056,660 | 40.4% |
| SPDR S&P 500 ETF Trust UCIT | $831,252 | 6.6% |
| Caja y Bancos | $821,903 | 6.6% |
| **Total** | **$12,504,506** | **100%** |
## Millennium BAC
*BAC Credomatic · BAC San José SFI*
| | |
|---|---|
| Inversión mínima | $250 |
| Comisión anual | 1.25% |
| Estrategia de gestión | Activa |
| Perfil de riesgo | Moderado |
| Operando desde | 2006-01 |
### Modelo de negocio
Fondo de crecimiento con gestión activa. Invierte en participaciones de otros fondos de inversión y ETFs internacionales (fund-of-funds).
- **Gestor:** Ronald Rojas — toma decisiones de compra y venta buscando retornos
- **Calificación:** AArv.cr por Moody's
- **Activo neto:** ~$35.98M USD
### Distribución geográfica
| Región | % |
|---|---:|
| Estados Unidos | 62% |
| Europa | 14% |
| Efectivo | 9% |
| Asia | 8% |
| Mercados Emergentes | 7% |
**Rendimiento 12 meses: 15.35%** (2026-01)
### Rendimientos históricos
*Rendimientos netos anuales (%). Datos del prospecto y ficha comercial.*
| Mes | 2024 | 2025 |
|---|---:|---:|
| Ene | 10.03 | 13.67 |
| Feb | 16.63 | 9.84 |
| Mar | 17.41 | 4.51 |
| Abr | 12.63 | 7.09 |
| May | 16.20 | 9.30 |
| Jun | 14.34 | 11.88 |
| Jul | 13.08 | 9.89 |
| Ago | 17.46 | 10.54 |
| Sep | 24.51 | 11.65 |
| Oct | 25.02 | 15.41 |
| Nov | 20.83 | 12.45 |
| Dic | 11.54 | 17.05 |
## Comparación directa
| Dimensión | BN ETF 500 | Millennium BAC |
|---|---|---|
| Gestor | BN Fondos (Banco Nacional) | BAC San José SFI |
| Estrategia | Pasiva (replica S&P 500) | Activa (fund-of-funds) |
| Comisión anual | **1.00%** | 1.25% |
| Inversión mínima | **$100** | $250 |
| Diversificación geográfica | Solo EE.UU. (S&P 500) | **EE.UU., Europa, Asia, EM** |
| Track record | ~1 año (desde 2025-01) | **20 años (desde 2006)** |
| Activo neto | ~$12.5M | **~$36M** |
| Calificación | Pendiente (< 12 meses) | **AArv.cr (Moody's)** |
| Transparencia de cartera | **100% visible (3 ETFs + cash)** | Holdings detallados no públicos |
## Riesgos y consideraciones
- **No diversificados:** Ambos fondos están clasificados como 'no diversificados' por SUGEVAL. Una situación adversa concentrada puede afectar más el fondo.
- **Riesgo de gestión pasiva (BN ETF 500):** En una caída generalizada del S&P 500, el fondo cae igual. No se toman acciones defensivas.
- **Riesgo cambiario:** Los fondos operan en USD. Movimientos en el tipo de cambio CRC/USD afectan su rendimiento real.
- **Liquidación del fondo:** Si el número de inversionistas o el activo neto caen bajo el mínimo por más de 6 meses, el fondo podría liquidarse. [BN ETF 500 Prospecto (página 22 de 43), Millennium BAC Prospecto (página 11 de 24)]
- **Comisiones sobre comisiones (Millennium):** Al ser fund-of-funds, se paga la comisión del fondo local (1.25%) más las comisiones internas de los fondos subyacentes.
## Preguntas abiertas
1. **Holdings exactos de Millennium BAC:** Solicité la composición detallada a BAC; respuesta pendiente.
2. **Overlap entre fondos:** Si Millennium tiene alta concentración en S&P 500, ¿vale la pena pagar 0.25% más?
### Es altamente probable que sea mejor simplemente invertir en el S&P500 a través de un broker.
---
> **Disclaimer:** This content is for informational and educational purposes only. The author is not a financial advisor, and none of this constitutes financial advice.
>
> **Descargo de responsabilidad:** Este contenido es únicamente con fines informativos y educativos. El autor no es asesor financiero, y nada de esto constituiste asesoría financiera.

View file

@ -0,0 +1,149 @@
+++
title = "Ley de Protección al Trabajador: Apuntes básicos"
date = 2026-03-25
description = "Apuntes sobre el FCL, ROP, pensiones voluntarias y cómo funciona el sistema de ahorro laboral en Costa Rica."
[taxonomies]
tags = ["costa-rica", "ley-7983", "rop", "fcl"]
[extra]
author = "chema"
isso = true
+++
Costa Rica tiene un sistema de ahorro laboral obligatorio que la mayoría de trabajadores no entiende completamente. Esta ley cubre tres pilares: el Fondo de Capitalización Laboral (FCL), el sistema de pensiones, y la gestión de esos recursos.
## Fondo de Capitalización Laboral (FCL)
El FCL es ahorro laboral que viene de contribuciones del patrono más los rendimientos de inversión — que dependen de la operadora que elijás.
**¿Cuánto?** 1.5% de tu salario mensual, durante toda la relación laboral.
### Consideraciones importantes
- **No se toca:** No puede cederse ni ser embargado (excepto 1/2 por pensiones alimenticias lmao)
- **No se interrumpe** por enfermedad, vacaciones u otras situaciones de este tipo
- **No taxes** o cargas sociales — es solo para el worker
- **Solo los puede gestionar** ciertos entes autorizados
- **Empresas públicas** aportan 15% de utilidades netas para ayudar (COMMUNISM)
- **Ente regulador:** La Superintendencia de Pensiones. Establece reglamentos, distribución de competencias e impone medidas/sanciones
### Cuándo podés retirar
- **Fin de relación laboral:** Se otorga todo el dinero acumulado
- **Death:** Se reclama por los parientes con interés
- **Cada 5 años:** Get that bread
- **Suspensión temporal** o reducción de jornada
Los fondos se entregan 15 días luego si no hay irregularidades, por transferencia.
## Régimen Obligatorio de Pensión Complementaria (ROP)
La base de cálculo es la misma base salarial para el Régimen de Invalidez Vejez y Muerte — los mejores 300 salarios luego de ajustar por inflación.
El usuario puede escoger operadora y cambiar sin costo, pero solo una uWu.
### Financiamiento
| Fuente | Porcentaje |
|---|---|
| Trabajador | 1% mensual |
| Patrono (después de 18 meses, cuz primero va al Banco Popular) | 0.25% mensual |
| Patrono | 3% mensual |
| Aportes de convenios | Variable |
| Aportes extraordinarios | Variable |
## Pensión Voluntaria
Aportes periódicos adicionales a las cuentas de pensión complementaria. Por escrito y con copia a la Superintendencia. Se registran a nombre del trabajador, por separado a los aportes obligatorios.
### Afiliación
Te podés afiliar aunque no estés en el Régimen Obligatorio (why tho). Los planes son de contribución definida, autorizados por el Superintendente de Pensiones.
### Ahorro Voluntario
Aportes administrados por las operadoras en un "megafondo" — basically para inversiones. Los afiliados pueden retirar según el contrato. Aplica el 5% de la ley sobre el Mercado de Valores.
### Retiro anticipado
Está permitido aunque no cumplas lo de 57 años or dying. **Requisito:** Haber cotizado al menos 66 meses + cancelar al Estado los beneficios fiscales (pay the taxes).
**Escala de devolución:** Entre más lejos de los 57, más % de taxes tenés que devolver. Menor de 48 devuelve el 100%, cada año adicional es 10% menos que devuelve.
## Beneficios
Accedés cuando cumplís con los requisitos del Régimen de Invalidez, Vejez y Muerte o régimen público equivalente. Si expirás, alguien más puede acceder a los $$$.
Los operadores tienen **60 días** para entregar el dinero. La edad mínima depende del contrato, pero mínimo 57 years (o invalidez/death).
### Formas de prestación
- **Renta vitalicia:** Se "compra" con el operador un derecho a recibir cierta cantidad de $$$ hasta morirse
- **Renta permanente:** Se entrega producto de los rendimientos de la inversión, el saldo es para sus beneficiarios when die
- **Retiro programado:** Renta periódica calculada anualmente según tu saldo y el valor de la unidad de pensión
- **Expectativa de vida condicionada:** Se divide el capital para la pensión entre los años que te quedan como expectativa
**Excepción:** Podés sacar todo de un solo si tenés enfermedad terminal o condición grave de salud (death likely).
Si los beneficios son menores a 20%, se te otorga el 20% hasta que tu saldo se agote **(COMMUNISM)**.
**Importante:** Dejá beneficiarios para evitar problemas tbh.
## Operadoras
Básicamente gestionan el fondo de pensión. Son cringe pero tocan.
- **Junta directiva:** Tienen restricciones woke en la composición
- **Elección:** Escogés una, no pueden negarse si cumplís requisitos
- **Responsabilidad:** Responden por pérdidas en las aportaciones o rendimientos. Liquidan para pagar si la cosa se pone fea lmao
- **Portabilidad:** TIENEN que pasar la $$$ a otras operadoras si el trabajador lo quiere hacer
### Fondos
Los fondos son patrimonios autónomos, propiedad de los afiliados, administrados por la operadora. Los rendimientos se acreditan en cuentas individualizadas. Se pagan y depositan los aportes al mismo tiempo que el ROP.
### Inversiones
- **No sujetas** a regulación del BCCR
- **Objetivo:** Para el provecho de los afiliados
- **Restricción local:** Solo se invierten en valores inscritos en el Registro Nacional de Valores e Intermediarios, o valores emitidos por empresas supervisadas por SUGEF (o sea cringe, solo varas de CR)
- **Límites:** Pueden ser limitados por la Superintendencia según el riesgo
- **Mínimo ROP:** Las operadoras deben invertir **al menos** 15% de los fondos depositados por concepto del ROP
- **Inversión extranjera:** Hasta 25%. Si el rendimiento nacional <= que el extranjero, se puede autorizar hasta 50%
- **Conflicto de interés:** NO se pueden invertir en valores emitidos/garantizados por gente de la junta o parientes si tienen más de 5% de participación accionaria o control efectivo
### Taxes
- **Voluntario:** Los aportes al régimen voluntario no pagan impuestos hasta un 10% del ingreso bruto. Se aplica cuando el patrono deduce lo correspondiente al trabajador **ANTES** de hacer la planilla de pago
- **ROP y RVP:** Los beneficios no pagan taxes
- **Valores de operadoras:** Los beneficios originados no pagan ciertos impuestos
### Sanciones
Categorías: muy graves, graves y leves. Sancionan diferente a los operadores. Para administrar y comercializar planes de pensiones y fondos de capitalización necesitás autorización de la Superintendencia.
## La Caja
La Caja otorga la pensión correspondiente en caso de irregularidades de pago y tiene que arreglarse con el patrono. Cierra temporalmente centros si existe mora de más de 2 meses y no hay arreglo de pago.
### El patrono cannot
- **Empadronamiento:** Iniciar luego de 8 días hábiles del inicio de la actividad el proceso con la Caja
- **Rebajos:** Cubrir con rebajos de salario o remuneración la cuota del patrono
- **Cuotas:** Evitar deducir la cuota obrera y no pagar la cuota patronal
- **Falsificación:** Falsificar salarios o cantidad de trabajadores
- **Represalias:** Tomar represalias contra trabajadores que hagan snitch con autoridades
### Info que nos pueden dar
- Info general de su situación económica, programa de inversiones y proyecciones
- Evolución probable de la situación financiera de la Caja
- Medidas implementadas para su saneamiento y mejoramiento económico
- Info estadística para fundamentar lo anterior
---
> **Disclaimer:** This content is for informational and educational purposes only. The author is not a financial advisor, and none of this constitutes financial advice.
>
> **Descargo de responsabilidad:** Este contenido es únicamente con fines informativos y educativos. El autor no es asesor financiero, y nada de esto constituye asesoría financiera.

View file

@ -0,0 +1,5 @@
+++
title = "tools"
sort_by = "title"
paginate_by = 10
+++

View file

@ -0,0 +1,41 @@
+++
title = "S&P 500 vs lo que sea"
date = 2026-03-25
description = "Compara el crecimiento del S&P500 contra cualquier otro fondo hipotético usando una simulación Monte Carlo sencilla."
[extra]
stylesheets = ["css/fund-comparison.css"]
+++
Esta calculadora compara el crecimiento proyectado del S&P500 contra cualquier otro fondo hipotético. Usa una simulación Monte Carlo sencilla para modelar la volatilidad del mercado.
Este tipo de simulación es muy sencilla y solo sirve para ilustrar la diferencia entre el rendimiento del S&P500 en comparación a otros fondos disponibles en Costa Rica.
## Cómo usar la calculadora
0. **Idioma**: Perdón, todo está en spanglish, pero es que es el restulado de una investigación que es en inglés y español.
1. **Fondo S&P 500**: Valores por defecto representan un índice de bajo costo. Puede buscar el rendimiento real del S&P 500 y poner los valores aquí.
2. **Fondo a comparar**: Introduzca los parámetros del fondo que quiere comprarar.
3. **Parámetros de inversión**: Seleccione el tiempo de la inversión y la contribución mensual.
4. **Monte Carlo**: Active la simulación para un modelado rudimentario de volatilidad.
## Cómo leer el gráfico generado
- **Medianas**: Verde (S&P 500) y azul (fondo comparado) muestran los resultados esperados
- **Bandas sombreadas**: Rango del percentil 10 al 90 cuando la simulación Monte Carlo está activada
- **Δ (Delta)**: Diferencia entre los valores finales de ambos fondos
- **Tax loss**: (pérdida fiscal) Segmento rojo que muestra el impuesto sobre ganancias de capital deducido al retirar (cuando aplica)
### Nota acerca de impuestos
- Costarricenses no tienen que pagar impuestos a ganancias de capital obtenidas a través de inversinoes directas en USA. Más info: [taxation of NRAs](https://www.irs.gov/businesses/taxation-of-nonresident-aliens-international-tax-gap-series).
- En Costa Rica no hay renta global (aún lol)
- Por eso la calculadora no calcula una pérdida fiscal al invertir directamente en el S&P500 pero sí al invertir en un fondo de CR.
{{ fund_comparison() }}
## Notas generales
- Los rendimientos se modelan usando distribución log-normal
- Los gastos de administración y comisiones se deducen mensualmente
- El impuesto sobre ganancias de capital se aplica únicamente sobre las ganancias al momento del retiro final y se muestra como una caída roja en el gráfico
- Los rendimientos históricos del S&P 500 promedian ~10% anual con ~15% de desviación estándar
- Rendimientos pasados no garantizan resultados futuros

View file

@ -0,0 +1,181 @@
.fund-comparison-container {
width: 100%;
}
.fund-comparison-form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-bottom: 2rem;
}
.form-section {
background: var(--bg-0);
padding: 1rem;
border-radius: 4px;
}
.form-section h3 {
margin-top: 0;
margin-bottom: 1rem;
color: var(--text-color-high-contrast);
font-size: 1rem;
}
.form-group {
margin-bottom: 0.75rem;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.85rem;
color: var(--meta-color);
}
.form-group input {
width: 100%;
padding: 0.5rem;
background: var(--bg-2);
border: 1px solid var(--divider-color);
border-radius: 3px;
color: var(--text-color);
font-family: var(--code-font);
font-size: 0.9rem;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: var(--primary-color);
}
.form-section-full {
grid-column: 1 / -1;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
}
.checkbox-group input[type="checkbox"] {
width: auto;
}
.checkbox-group label {
margin-bottom: 0;
}
.calculate-btn {
display: block;
width: 100%;
padding: 0.75rem;
margin-top: 1rem;
background: var(--primary-color);
color: var(--background-color);
border: none;
border-radius: 4px;
font-family: var(--sans-serif-font);
font-size: 1rem;
font-weight: 550;
cursor: pointer;
transition: opacity 0.2s;
}
.calculate-btn:hover {
opacity: 0.9;
}
.chart-container {
margin-bottom: 2rem;
background: var(--bg-2);
border-radius: 4px;
padding: 1rem;
}
.chart-container canvas {
width: 100%;
display: block;
}
.summary-section {
margin-bottom: 1.5rem;
}
.summary-section h3 {
margin-top: 0;
margin-bottom: 1rem;
}
.summary-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.summary-table th,
.summary-table td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--divider-color);
}
.summary-table th {
background: var(--bg-0);
font-weight: 550;
}
.summary-table td:not(:first-child) {
text-align: right;
font-family: var(--code-font);
}
.percentiles-details {
margin-top: 1rem;
}
.percentiles-details summary {
cursor: pointer;
color: var(--primary-color);
font-weight: 550;
}
.percentiles-table {
margin-top: 0.75rem;
}
.noscript-warning {
background: #ff9800;
color: #000;
padding: 1rem;
border-radius: 4px;
text-align: center;
}
@media (max-width: 600px) {
.fund-comparison-form {
grid-template-columns: 1fr;
}
.form-row {
grid-template-columns: 1fr;
}
.summary-table {
font-size: 0.8rem;
}
.summary-table th,
.summary-table td {
padding: 0.4rem 0.5rem;
}
}

567
static/js/fundComparison.js Normal file
View file

@ -0,0 +1,567 @@
(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();
}
})();

View file

@ -0,0 +1,146 @@
<div class="fund-comparison-container">
<noscript>
<div class="noscript-warning">
This calculator requires JavaScript to function.
</div>
</noscript>
<form id="fund-comparison-form" class="fund-comparison-form">
<div class="form-section">
<h3>S&P 500 Index Fund</h3>
<div class="form-group">
<label for="sp500-return">Expected yearly return (%)</label>
<input type="number" id="sp500-return" value="10" step="0.1" required>
</div>
<div class="form-group">
<label for="sp500-volatility">Volatility / std dev (%)</label>
<input type="number" id="sp500-volatility" value="15" step="0.1" required>
</div>
<div class="form-group">
<label for="sp500-expense">Expense ratio (%)</label>
<input type="number" id="sp500-expense" value="0.03" step="0.01" required>
</div>
</div>
<div class="form-section">
<h3>Compared Fund</h3>
<div class="form-group">
<label for="compared-return">Expected yearly return (%)</label>
<input type="number" id="compared-return" value="7" step="0.1" required>
</div>
<div class="form-group">
<label for="compared-volatility">Volatility / std dev (%)</label>
<input type="number" id="compared-volatility" value="10" step="0.1" required>
</div>
<div class="form-group">
<label for="compared-admin-fee">Admin fee (% yearly)</label>
<input type="number" id="compared-admin-fee" value="1" step="0.01">
</div>
<div class="form-group">
<label for="compared-capital-gains">Capital gains tax at withdrawal (%)</label>
<input type="number" id="compared-capital-gains" value="0" step="0.1">
</div>
</div>
<div class="form-section form-section-full">
<h3>Investment Parameters</h3>
<div class="form-row">
<div class="form-group">
<label for="time-frame">Time frame (years)</label>
<input type="number" id="time-frame" value="20" min="1" max="50" required>
</div>
<div class="form-group">
<label for="monthly-deposit">Monthly deposit ($)</label>
<input type="number" id="monthly-deposit" value="500" min="1" required>
</div>
</div>
<div class="checkbox-group">
<input type="checkbox" id="use-monte-carlo" checked>
<label for="use-monte-carlo">Use Monte Carlo simulation (1000 runs)</label>
</div>
<button type="submit" class="calculate-btn">Calculate</button>
</div>
</form>
<div class="chart-container">
<canvas id="fund-comparison-chart"></canvas>
</div>
<div class="summary-section">
<h3>Summary</h3>
<table class="summary-table">
<thead>
<tr>
<th></th>
<th>S&P 500 Fund</th>
<th>Compared Fund</th>
</tr>
</thead>
<tbody>
<tr>
<td>Total Deposits</td>
<td colspan="2" id="total-deposits">-</td>
</tr>
<tr>
<td>Final Value (median)</td>
<td id="sp500-final">-</td>
<td id="compared-final">-</td>
</tr>
<tr>
<td>Total Gain</td>
<td id="sp500-gain">-</td>
<td id="compared-gain">-</td>
</tr>
<tr>
<td>Return Multiple</td>
<td id="sp500-multiple">-</td>
<td id="compared-multiple">-</td>
</tr>
</tbody>
</table>
</div>
<div id="percentiles-section" class="summary-section">
<details class="percentiles-details">
<summary>Percentile Breakdown</summary>
<table class="summary-table percentiles-table">
<thead>
<tr>
<th>Percentile</th>
<th>S&P 500 Fund</th>
<th>Compared Fund</th>
</tr>
</thead>
<tbody>
<tr>
<td>10th (pessimistic)</td>
<td id="sp500-p10">-</td>
<td id="compared-p10">-</td>
</tr>
<tr>
<td>25th</td>
<td id="sp500-p25">-</td>
<td id="compared-p25">-</td>
</tr>
<tr>
<td>50th (median)</td>
<td id="sp500-p50">-</td>
<td id="compared-p50">-</td>
</tr>
<tr>
<td>75th</td>
<td id="sp500-p75">-</td>
<td id="compared-p75">-</td>
</tr>
<tr>
<td>90th (optimistic)</td>
<td id="sp500-p90">-</td>
<td id="compared-p90">-</td>
</tr>
</tbody>
</table>
</details>
</div>
</div>
<script src="{{ get_url(path='js/fundComparison.js') | safe }}"></script>