diff --git a/.gitignore b/.gitignore index a19705f..0403203 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,7 @@ gcpdeploy.md .env* gcpdeploy.md deploy.sh - +.agent/* # local install dist-electron diff --git a/README.md b/README.md index dd4aac7..62bbbfb 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,11 @@ The tool prioritizes tax-efficient withdrawals by: - Contextual recommendations based on your specific situation - Withdrawal rate analysis against the 4-5% rule of thumb +### 📈 Portfolio Modeler & Monte Carlo Simulator +- **Visualizer**: see the probability of success for different portfolio allocations +- **Customization**: Build your own portfolio or use preset strategies (Conservative, Moderate, Aggressive) tailored for Accumulation vs. Retirement. +- **Monte Carlo Engine**: Runs 10,000 simulations to project likely (median), unlucky (10th percentile), and lucky (90th percentile) return scenarios based on historical asset data (VTI, VXUS, BND, BNDX). + ### 🎨 Modern UI/UX - Responsive design works on desktop and mobile - Dark/Light mode toggle @@ -236,6 +241,12 @@ The Longevity tab projects assets up to age 100. * **Depletion Order:** For the longevity visualizer, assets are burned down sequentially: Brokerage → Traditional IRA → Roth IRA → HSA. * **Income Composition:** Tracks the shifting mix of fixed income (SS, Pension) vs. Portfolio Withdrawals over time to show how the "Paycheck" is constructed. +### 5. Monte Carlo Simulation +The optional Portfolio Modeler uses Geometric Brownian Motion to simulate 10,000 possible market paths for your customized portfolio. +* **Asset Classes:** Uses historical return and volatility data for US Stock (VTI), Int'l Stock (VXUS), US Bond (BND), and Int'l Bond (BNDX). +* **Correlations:** Implements a correlation matrix to account for how assets move in relation to one another (diversification benefit). +* **Outputs:** Proves a statistical "range of outcomes" rather than a single static guess, helping you choose a more robust Annual Return assumption. + ## 🤝 Contributing Contributions are welcome! Here's how you can help: diff --git a/src/components/features/InputSection.tsx b/src/components/features/InputSection.tsx index 31f3a9f..3db5034 100644 --- a/src/components/features/InputSection.tsx +++ b/src/components/features/InputSection.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import { UserProfile, FilingStatus } from '../../types'; -import { HelpCircle, DollarSign, Briefcase, Activity, TrendingUp, PiggyBank, RotateCcw, PlusCircle, AlertTriangle } from 'lucide-react'; +import { HelpCircle, DollarSign, Briefcase, Activity, TrendingUp, PiggyBank, RotateCcw, PlusCircle, AlertTriangle, Calculator } from 'lucide-react'; +import PortfolioSelectorModal from '../modals/PortfolioSelectorModal'; interface InputSectionProps { profile: UserProfile; @@ -88,6 +89,10 @@ const InputSection: React.FC = ({ profile, setProfile, onRest const headerClass = "text-xl font-bold text-slate-800 dark:text-white flex items-center gap-2 mb-4"; // const isFutureScenario = (Number(profile.age) || 0) !== profile.baseAge; + const [activeModal, setActiveModal] = useState<'accumulation' | 'retirement' | null>(null); + + const accumulationYears = Math.max(5, (profile.age || 65) - (profile.baseAge || 30)); // Min 5 years to show meaningful data + const retirementLongevityYears = Math.max(30, 95 - (profile.age || 65)); // Plan for at least 30 years or until age 95 return (
@@ -319,7 +324,7 @@ const InputSection: React.FC = ({ profile, setProfile, onRest Market Assumptions (Accumulation)
-
+
= ({ profile, setProfile, onRest onChange={(val) => handleAssumptionChange('rateOfReturn', val)} className={inputClass} /> +
@@ -347,7 +359,7 @@ const InputSection: React.FC = ({ profile, setProfile, onRest Market Assumptions (Retirement)
-
+
= ({ profile, setProfile, onRest onChange={(val) => handleAssumptionChange('rateOfReturnInRetirement', val)} className={inputClass} /> +
@@ -367,6 +386,26 @@ const InputSection: React.FC = ({ profile, setProfile, onRest
+ {activeModal && ( + setActiveModal(null)} + onConfirm={(rate) => { + if (activeModal === 'accumulation') { + handleAssumptionChange('rateOfReturn', rate); + } else { + handleAssumptionChange('rateOfReturnInRetirement', rate); + } + setActiveModal(null); + }} + simulationDurationYears={ + activeModal === 'accumulation' + ? accumulationYears + : retirementLongevityYears + } + scenario={activeModal} + /> + )}
); }; diff --git a/src/components/features/TaxReference.tsx b/src/components/features/TaxReference.tsx index 87e240a..98778de 100644 --- a/src/components/features/TaxReference.tsx +++ b/src/components/features/TaxReference.tsx @@ -25,7 +25,8 @@ import { Linkedin, Mail, Heart, - Sparkles + Sparkles, + Activity } from 'lucide-react'; interface AccordionSectionProps { @@ -688,6 +689,99 @@ const TaxReference: React.FC = () => {
+ {/* Portfolio Modeler & Monte Carlo Logic */} + } + accentColor="green" + > +
+

+ The Portfolio Modeler helps you move beyond guessing a single "Annual Return" number. By simulating thousands of possible market futures, it provides a statistical range of outcomes for your specific asset allocation. +

+ + {/* How It Works */} +
+
+ +

How probability works

+
+

+ Markets are volatile. A portfolio averaging 7% long-term might have years of -20% and +30%. This volatility "drag" means your compound annual growth rate (CAGR) is often lower than the simple average. +

+

+ Our engine runs 10,000 simulations using Geometric Brownian Motion to model this volatility. It outputs a bell curve of results: +

+
    +
  • + Unlucky (10th Percentile) + A conservative estimate. 90% of the simulated features performed better than this. Use this if you want to be safe. +
  • +
  • + Median (50th Percentile) + The most likely outcome. Half the simulations were better, half were worse. +
  • +
  • + Lucky (90th Percentile) + An optimistic outcome. Only 10% of simulations performed this well. +
  • +
+
+ + {/* Asset Assumptions */} +
+
+ +

Asset Assumptions

+ +

+ The simulation uses historical risk/return profiles for four core index fund types. +

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Asset ClassRepresentative FundModeled ReturnVolatility (Std Dev)
US Total Stock MarketVTI10.5%15.5%
Total International StockVXUS8.5%18.0%
US Total Bond MarketBND4.5%5.0%
Total International BondBNDX4.0%4.5%
+
+

+ * Correlation Matrix assumed: Stocks correlate highly (0.7), Bonds correlate moderately (0.5), and Stock/Bond correlation is low (0.1) for diversification benefits. +

+
+
+
+ {/* About the Creator */} void; + onConfirm: (annualReturn: number) => void; + simulationDurationYears: number; + scenario: 'accumulation' | 'retirement'; +} + +const PRESETS = { + accumulation: { + Conservative: { VTI: 20, VXUS: 10, BND: 50, BNDX: 20 }, + Moderate: { VTI: 45, VXUS: 15, BND: 25, BNDX: 15 }, + Aggressive: { VTI: 60, VXUS: 30, BND: 7, BNDX: 3 }, + }, + retirement: { + Conservative: { VTI: 15, VXUS: 5, BND: 60, BNDX: 20 }, // 20/80 - Capital Preservation + Moderate: { VTI: 30, VXUS: 10, BND: 45, BNDX: 15 }, // 40/60 - Balanced Income + Aggressive: { VTI: 45, VXUS: 15, BND: 30, BNDX: 10 }, // 60/40 - Growth + } +}; + +const PortfolioSelectorModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + simulationDurationYears, + scenario +}) => { + const [mode, setMode] = useState<'preset' | 'custom'>('preset'); + const [allocation, setAllocation] = useState(PRESETS[scenario].Moderate); + const [isLocked, setIsLocked] = useState(true); // Locks total to 100% (simple validation mode) + const [simResult, setSimResult] = useState(null); + const [selectedPercentile, setSelectedPercentile] = useState<'p10' | 'p50' | 'p90'>('p50'); + + useEffect(() => { + if (isOpen) { + setAllocation(PRESETS[scenario].Moderate); + setMode('preset'); + setSimResult(null); + } + }, [isOpen, scenario]); + + const handlePresetSelect = (presetName: keyof typeof PRESETS['accumulation']) => { + setAllocation(PRESETS[scenario][presetName]); + setMode('preset'); + setSimResult(null); // Clear previous result to force standard flow + }; + + const handleSliderChange = (asset: keyof PortfolioAllocation, value: number) => { + setMode('custom'); + setSimResult(null); + + // Simple logic: If locked, we can't easily auto-balance 4 sliders without complex logic. + // For this MVP, we will allow free sliding and show a warning if != 100%. + const newAllocation = { ...allocation, [asset]: value }; + setAllocation(newAllocation); + }; + + const totalAllocation = (Object.values(allocation) as number[]).reduce((sum, val) => sum + val, 0); + const isValid = Math.abs(totalAllocation - 100) < 0.1; + + const handleRunSimulation = () => { + if (!isValid) return; + const result = runMonteCarloSimulation(allocation, simulationDurationYears); + setSimResult(result); + setSelectedPercentile('p50'); // Default to Median + }; + + if (!isOpen) return null; + + return ( +
+
+ + {/* Header */} +
+
+

+ + Portfolio Modeler & Monte Carlo Simulator +

+

+ Simulating {simulationDurationYears} years of random market paths based on volatility & correlation. +

+
+ +
+ +
+ + {/* LEFT COLUMN: Inputs */} +
+ + {/* Presets */} +
+ +
+ {(Object.keys(PRESETS[scenario]) as Array).map((name) => { + const isSelected = mode === 'preset' && JSON.stringify(allocation) === JSON.stringify(PRESETS[scenario][name]); + return ( + + ); + })} +
+
+ + {/* Sliders */} +
+
+ + + Total: {totalAllocation.toFixed(0)}% + +
+ + {Object.keys(allocation).map((key) => ( +
+
+ {key} + {allocation[key as keyof PortfolioAllocation]}% +
+ handleSliderChange(key as keyof PortfolioAllocation, parseInt(e.target.value))} + className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-600" + /> +
+ {key === 'VTI' && 'US Total Stock Market'} + {key === 'VXUS' && 'Total International Stock'} + {key === 'BND' && 'US Total Bond Market'} + {key === 'BNDX' && 'Total International Bond'} +
+
+ ))} +
+ + + +
+ + {/* RIGHT COLUMN: Results */} +
+ + {!simResult ? ( +
+ +

Select an allocation and run the simulation to see probable outcomes.

+
+ ) : ( +
+ + {/* Stats Cards */} +
+ {[ + { id: 'p10', label: 'Unlucky (10%)', val: simResult.p10, color: 'text-orange-500' }, + { id: 'p50', label: 'Median (50%)', val: simResult.p50, color: 'text-blue-600' }, + { id: 'p90', label: 'Lucky (90%)', val: simResult.p90, color: 'text-green-500' }, + ].map((stat) => ( + + ))} +
+ + {/* Chart */} +
+ + + + + + + + + `${val}%`} + /> + + [val, 'Occurrences']} + labelFormatter={(label) => `CAGR: ${label}%`} + /> + + {/* Reference line for selected */} + + + +
+ + {/* Explanation */} +
+ +

+ Based on 10,000 Monte Carlo simulations over {simulationDurationYears} years. + The Median result indicates that 50% of simulated market paths performed better than this, and 50% performed worse. +

+
+ +
+ )} +
+
+ + {/* Footer */} +
+ + +
+ +
+
+ ); +}; + +export default PortfolioSelectorModal; diff --git a/src/services/MonteCarloEngine.ts b/src/services/MonteCarloEngine.ts new file mode 100644 index 0000000..24e9716 --- /dev/null +++ b/src/services/MonteCarloEngine.ts @@ -0,0 +1,190 @@ +/** + * Monte Carlo Simulation Engine + * + * Calculates expected portfolio returns and runs a Monte Carlo simulation + * to generate a distribution of possible future outcomes. + */ + +// ============================================================================ +// Constants & Asset Definitions +// ============================================================================ + +export type AssetType = 'VTI' | 'VXUS' | 'BND' | 'BNDX'; + +export interface PortfolioAllocation { + VTI: number; // US Stocks + VXUS: number; // International Stocks + BND: number; // US Bonds + BNDX: number; // International Bonds +} + +interface AssetMetrics { + meanReturn: number; // Geometric Mean (approx) or Arithmetic Mean + volatility: number; // Standard Deviation +} + +export const ASSET_METRICS: Record = { + VTI: { meanReturn: 0.105, volatility: 0.155 }, + VXUS: { meanReturn: 0.085, volatility: 0.180 }, + BND: { meanReturn: 0.045, volatility: 0.050 }, + BNDX: { meanReturn: 0.040, volatility: 0.045 }, +}; + +// Simple Correlation Matrix (Approximation) +// Order: VTI, VXUS, BND, BNDX +// 1.0 for self +// 0.7 for Stock/Stock +// 0.1 for Stock/Bond +// 0.5 for Bond/Bond +const CORRELATIONS: Record> = { + VTI: { VTI: 1.0, VXUS: 0.7, BND: 0.1, BNDX: 0.1 }, + VXUS: { VTI: 0.7, VXUS: 1.0, BND: 0.1, BNDX: 0.1 }, + BND: { VTI: 0.1, VXUS: 0.1, BND: 1.0, BNDX: 0.5 }, + BNDX: { VTI: 0.1, VXUS: 0.1, BND: 0.5, BNDX: 1.0 }, +}; + +export interface SimulationResult { + p10: number; // 10th percentile (Bad Case) + p50: number; // 50th percentile (Likely Case) + p90: number; // 90th percentile (Great Case) + bellCurveData: { returnFreq: number; frequency: number }[]; // For visualization +} + +// ============================================================================ +// Math Helpers +// ============================================================================ + +/** + * Generates a random number with a standard normal distribution (mean=0, std=1) + * utilizing the Box-Muller transform. + */ +function randn_bm(): number { + let u = 0, v = 0; + while (u === 0) u = Math.random(); + while (v === 0) v = Math.random(); + return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); +} + +/** + * Calculates portfolio expected return (weighted average). + */ +function calculatePortfolioReturn(allocation: PortfolioAllocation): number { + let weightedReturn = 0; + const totalWeight = allocation.VTI + allocation.VXUS + allocation.BND + allocation.BNDX; + + if (totalWeight === 0) return 0; + + (Object.keys(allocation) as AssetType[]).forEach(asset => { + const weight = allocation[asset] / 100; // Assuming allocation inputs are 0-100 + weightedReturn += weight * ASSET_METRICS[asset].meanReturn; + }); + + return weightedReturn; +} + +/** + * Calculates portfolio volatility (standard deviation) using the covariance matrix. + * Sigma_p = sqrt(w^T * Sigma * w) + */ +function calculatePortfolioVolatility(allocation: PortfolioAllocation): number { + const assets: AssetType[] = ['VTI', 'VXUS', 'BND', 'BNDX']; + let variance = 0; + + // Double loop sum: w_i * w_j * sigma_i * sigma_j * rho_ij + for (const i of assets) { + for (const j of assets) { + const w_i = allocation[i] / 100; + const w_j = allocation[j] / 100; + const sigma_i = ASSET_METRICS[i].volatility; + const sigma_j = ASSET_METRICS[j].volatility; + const correlation = CORRELATIONS[i][j]; + + variance += w_i * w_j * sigma_i * sigma_j * correlation; + } + } + + return Math.sqrt(variance); +} + +// ============================================================================ +// Core Simulation Logic +// ============================================================================ + +/** + * Runs the Monte Carlo simulation. + * @param allocation Portfolio weights (must sum to 100) + * @param years Duration of simulation (default 30, but uses user timeline) + * @returns SimulationResult with p10, p50, p90 and chart data + */ +export const runMonteCarloSimulation = ( + allocation: PortfolioAllocation, + years: number = 30 +): SimulationResult => { + const iterations = 10000; + const mu = calculatePortfolioReturn(allocation); // Expected Arithmetic Return + const sigma = calculatePortfolioVolatility(allocation); // Expected Volatility + + // Ensure reasonable duration (at least 30 years for stats, or user horizon) + const T = Math.max(30, years); + + const finalCAGRs: number[] = []; + + // Simulate price paths using Geometric Brownian Motion + // S_t = S_0 * exp((mu - 0.5*sigma^2)*t + sigma*W_t) + // We want the CAGR: (S_T / S_0)^(1/T) - 1 + // This simplifies to: exp((mu - 0.5*sigma^2) + sigma * (W_T / T)) - 1 ?? + // Actually, easier: Simulate year-by-year or just final distribution directly since log-returns are normal? + // User requested "path" logic typically, but for CAGR distribution of terminal wealth we can use the direct formula property. + // Ln(S_T/S_0) ~ Normal((mu - 0.5*sigma^2)*T, sigma^2 * T) + + // Let's do the loop version to be explicit and allow for future annual logic if needed. + // Actually, direct sampling is faster and equivalent for standard GBM. + + const drift = mu - 0.5 * Math.pow(sigma, 2); + const rootT = Math.sqrt(T); + + for (let i = 0; i < iterations; i++) { + const Z = randn_bm(); // Standard Normal Random Variable implies W_T = Z * sqrt(T) + + // Log Return over T years + const logReturnTotal = drift * T + sigma * Z * rootT; + + // Convert to CAGR: (exp(total_log_return))^(1/T) - 1 + // = exp(total_log_return / T) - 1 + const cagr = Math.exp(logReturnTotal / T) - 1; + + finalCAGRs.push(cagr); + } + + // Sort results + finalCAGRs.sort((a, b) => a - b); + + const p10Index = Math.floor(iterations * 0.1); + const p50Index = Math.floor(iterations * 0.5); + const p90Index = Math.floor(iterations * 0.9); + + // Generate Frequency Data for Bell Curve + // Create histogram buckets + const bucketCount = 40; + const minVal = finalCAGRs[0]; + const maxVal = finalCAGRs[finalCAGRs.length - 1]; + const range = maxVal - minVal; + const bucketSize = range / bucketCount; + + const bellCurveData = new Array(bucketCount).fill(0).map((_, i) => { + const bucketStart = minVal + i * bucketSize; + const bucketEnd = bucketStart + bucketSize; + const count = finalCAGRs.filter(v => v >= bucketStart && v < bucketEnd).length; + return { + returnFreq: parseFloat(((bucketStart + bucketSize / 2) * 100).toFixed(1)), // Midpoint as x-axis label (%) + frequency: count + }; + }); + + return { + p10: finalCAGRs[p10Index], + p50: finalCAGRs[p50Index], + p90: finalCAGRs[p90Index], + bellCurveData + }; +};