@retiregolden/planner-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +181 -0
- package/package.json +77 -0
- package/src/App.tsx +246 -0
- package/src/RouteErrorBoundary.tsx +45 -0
- package/src/assets/hero.png +0 -0
- package/src/assets/react.svg +1 -0
- package/src/assets/vite.svg +1 -0
- package/src/data/fedInvestClient.ts +113 -0
- package/src/data/localStore.ts +42 -0
- package/src/data/planOrigin.ts +24 -0
- package/src/data/planStore.ts +165 -0
- package/src/data/v2Backup.ts +101 -0
- package/src/import/ImportPage.tsx +347 -0
- package/src/import/ReviewChecklistView.tsx +38 -0
- package/src/import/brokerCsv.ts +395 -0
- package/src/import/csv.ts +133 -0
- package/src/import/genericCsv.ts +224 -0
- package/src/import/projectionLab.ts +350 -0
- package/src/import/reviewChecklist.ts +33 -0
- package/src/import/tenForty.ts +275 -0
- package/src/index.css +630 -0
- package/src/index.ts +16 -0
- package/src/learn/ArticleBody.tsx +78 -0
- package/src/learn/ArticlePage.tsx +57 -0
- package/src/learn/GlossaryPage.tsx +33 -0
- package/src/learn/LearnAboutScreen.tsx +41 -0
- package/src/learn/LearnCards.tsx +41 -0
- package/src/learn/LearnLink.tsx +91 -0
- package/src/learn/LearningCenterPage.tsx +114 -0
- package/src/learn/SourcesPage.tsx +98 -0
- package/src/learn/components/ArticleFigure.tsx +34 -0
- package/src/learn/components/ArticleShell.tsx +86 -0
- package/src/learn/components/ComparisonTable.tsx +42 -0
- package/src/learn/components/FormulaBlock.tsx +34 -0
- package/src/learn/components/PurchasingPowerChart.tsx +41 -0
- package/src/learn/components/RelatedArticles.tsx +27 -0
- package/src/learn/components/ScenarioCard.tsx +24 -0
- package/src/learn/components/SourceList.tsx +23 -0
- package/src/learn/components/charts.tsx +21 -0
- package/src/learn/content/about-retiregolden.ts +100 -0
- package/src/learn/content/aca-premium-tax-credits-and-magi.ts +103 -0
- package/src/learn/content/account-types-overview.ts +106 -0
- package/src/learn/content/after-tax-estate.ts +111 -0
- package/src/learn/content/agi-magi-and-taxable-income.ts +112 -0
- package/src/learn/content/appealing-irmaa-ssa-44.ts +95 -0
- package/src/learn/content/assumption-general-inflation.ts +82 -0
- package/src/learn/content/assumption-healthcare-inflation.ts +85 -0
- package/src/learn/content/assumption-heir-tax-rate.ts +79 -0
- package/src/learn/content/assumption-investment-returns.ts +90 -0
- package/src/learn/content/assumption-longevity-planning-age.ts +78 -0
- package/src/learn/content/assumption-recent-magi.ts +83 -0
- package/src/learn/content/assumption-social-security-cola.ts +89 -0
- package/src/learn/content/assumption-social-security-trust-fund.ts +83 -0
- package/src/learn/content/assumption-state-tax-override.ts +79 -0
- package/src/learn/content/beneficiaries-and-account-titling.ts +99 -0
- package/src/learn/content/break-even-useful-lens.ts +94 -0
- package/src/learn/content/building-a-retirement-spending-budget.ts +100 -0
- package/src/learn/content/cola-and-inflation-protection.ts +102 -0
- package/src/learn/content/divorced-spousal-and-survivor-records.ts +104 -0
- package/src/learn/content/dynamic-spending-guardrails.ts +90 -0
- package/src/learn/content/earnings-test-before-fra.ts +100 -0
- package/src/learn/content/employer-match-and-contribution-order.ts +104 -0
- package/src/learn/content/examplePlanArticles.ts +525 -0
- package/src/learn/content/fees-expense-ratios-and-compounding-drag.ts +98 -0
- package/src/learn/content/fi-number-and-four-percent-rule.ts +64 -0
- package/src/learn/content/filling-a-tax-bracket-with-roth-conversions.ts +98 -0
- package/src/learn/content/funded-ratio.ts +70 -0
- package/src/learn/content/healthcare-after-65.ts +103 -0
- package/src/learn/content/healthcare-before-65.ts +104 -0
- package/src/learn/content/historical-vs-random-return-models.ts +101 -0
- package/src/learn/content/how-assumptions-change-the-answer.ts +105 -0
- package/src/learn/content/how-much-can-i-spend.ts +105 -0
- package/src/learn/content/how-social-security-is-taxed.ts +95 -0
- package/src/learn/content/how-the-optimizer-thinks.ts +102 -0
- package/src/learn/content/how-the-optimizer-values-after-tax-estate.ts +97 -0
- package/src/learn/content/how-to-model-accumulation.ts +67 -0
- package/src/learn/content/how-to-read-a-retirement-projection.ts +115 -0
- package/src/learn/content/hsas-and-qualified-medical-expenses.ts +108 -0
- package/src/learn/content/hsas-as-retirement-accounts.ts +101 -0
- package/src/learn/content/inflation-risk.ts +98 -0
- package/src/learn/content/inherited-ira-10-year-rule.ts +105 -0
- package/src/learn/content/insurance-in-your-retirement-plan.ts +103 -0
- package/src/learn/content/irmaa-two-year-lookback.ts +99 -0
- package/src/learn/content/long-term-care-costs-and-insurance.ts +103 -0
- package/src/learn/content/long-term-care-insurance-as-risk-transfer.ts +98 -0
- package/src/learn/content/longevity-risk.ts +99 -0
- package/src/learn/content/marginal-vs-effective-tax-rate.ts +98 -0
- package/src/learn/content/medicare-part-b-vs-part-d-irmaa.ts +102 -0
- package/src/learn/content/mortality-weighted-social-security.ts +113 -0
- package/src/learn/content/moving-to-retiregolden.ts +86 -0
- package/src/learn/content/niit-high-income-investment-tax.ts +98 -0
- package/src/learn/content/ordinary-income-vs-capital-gains.ts +103 -0
- package/src/learn/content/paying-conversion-taxes-taxable-vs-ira.ts +102 -0
- package/src/learn/content/pensions-and-annuities.ts +101 -0
- package/src/learn/content/permanent-life-insurance-in-a-plan.ts +106 -0
- package/src/learn/content/pia-aime-and-bend-points.ts +103 -0
- package/src/learn/content/planner-overview.ts +106 -0
- package/src/learn/content/planning-for-couples-and-survivor-years.ts +108 -0
- package/src/learn/content/privacy-what-stays-in-your-browser.ts +99 -0
- package/src/learn/content/qcds-qualified-charitable-distributions.ts +101 -0
- package/src/learn/content/reading-the-results-page.ts +96 -0
- package/src/learn/content/reading-the-social-security-analysis-page.ts +106 -0
- package/src/learn/content/real-estate-home-equity-and-debt.ts +100 -0
- package/src/learn/content/reports-csv-exports-and-sharing.ts +101 -0
- package/src/learn/content/risk-based-guardrails.ts +100 -0
- package/src/learn/content/rmds-required-minimum-distributions.ts +100 -0
- package/src/learn/content/roth-conversion-basics.ts +104 -0
- package/src/learn/content/rsus-and-espp.ts +101 -0
- package/src/learn/content/rule-of-55-and-72t.ts +107 -0
- package/src/learn/content/savings-rate-biggest-lever.ts +66 -0
- package/src/learn/content/seed-your-plan-from-your-tax-return.ts +93 -0
- package/src/learn/content/sensitivity-testing-what-changes-the-answer.ts +104 -0
- package/src/learn/content/sequence-of-returns-risk.ts +98 -0
- package/src/learn/content/social-security-bridge.ts +67 -0
- package/src/learn/content/social-security-claiming-age-basics.ts +113 -0
- package/src/learn/content/social-security-taxes-vs-benefits.ts +76 -0
- package/src/learn/content/spending-profiles-and-the-retirement-smile.ts +92 -0
- package/src/learn/content/spousal-and-survivor-benefits.ts +120 -0
- package/src/learn/content/ssdi-and-retirement-planning.ts +72 -0
- package/src/learn/content/standard-deduction-senior-deduction-and-itemizing.ts +97 -0
- package/src/learn/content/state-income-taxes-in-retirement.ts +97 -0
- package/src/learn/content/step-up-in-basis.ts +102 -0
- package/src/learn/content/survivor-planning-for-couples.ts +110 -0
- package/src/learn/content/survivor-spending-in-couple-plans.ts +98 -0
- package/src/learn/content/tax-cliffs-and-bracket-edges.ts +105 -0
- package/src/learn/content/tax-loss-and-gain-harvesting.ts +99 -0
- package/src/learn/content/taxable-brokerage-basis-and-capital-gains.ts +99 -0
- package/src/learn/content/three-big-questions-spending-time-risk.ts +103 -0
- package/src/learn/content/tips-ladders.ts +92 -0
- package/src/learn/content/todays-dollars-vs-future-dollars.ts +107 -0
- package/src/learn/content/traditional-vs-roth-contributions.ts +113 -0
- package/src/learn/content/troubleshooting-surprising-results.ts +105 -0
- package/src/learn/content/trust-fund-haircut-scenarios.ts +101 -0
- package/src/learn/content/understanding-monte-carlo-success-rate.ts +118 -0
- package/src/learn/content/understanding-your-plan-assumptions.ts +134 -0
- package/src/learn/content/using-assumptions-and-provenance.ts +98 -0
- package/src/learn/content/using-scenarios-to-compare-choices.ts +99 -0
- package/src/learn/content/what-changes-when-you-move-states.ts +141 -0
- package/src/learn/content/what-is-fire.ts +65 -0
- package/src/learn/content/what-monte-carlo-proves.ts +98 -0
- package/src/learn/content/what-retiregolden-models.ts +103 -0
- package/src/learn/content/what-retirement-healthcare-really-costs.ts +117 -0
- package/src/learn/content/why-95-percent-is-not-a-guarantee.ts +98 -0
- package/src/learn/content/why-roth-conversions-raise-other-costs.ts +106 -0
- package/src/learn/content/why-small-tax-cliffs-can-matter.ts +109 -0
- package/src/learn/content/widows-penalty-and-survivor-brackets.ts +106 -0
- package/src/learn/content/withdrawal-order-basics.ts +105 -0
- package/src/learn/glossary.ts +191 -0
- package/src/learn/inlineMarkdown.tsx +54 -0
- package/src/learn/learn.css +537 -0
- package/src/learn/learningRegistry.ts +502 -0
- package/src/longevity/LongevityResults.tsx +85 -0
- package/src/longevity/LongevityWizard.tsx +305 -0
- package/src/longevity/constants.ts +15 -0
- package/src/longevity/factors.ts +125 -0
- package/src/longevity/model.ts +31 -0
- package/src/longevity/persistedGuard.ts +129 -0
- package/src/longevity/storage.ts +40 -0
- package/src/mc/messages.ts +118 -0
- package/src/mc/monteCarlo.worker.ts +44 -0
- package/src/mc/pool.ts +267 -0
- package/src/mc/runRequest.ts +125 -0
- package/src/optimize/messages.ts +84 -0
- package/src/optimize/optimize.worker.ts +29 -0
- package/src/optimize/runOptimize.ts +92 -0
- package/src/optimize/runSpendingSolve.ts +47 -0
- package/src/optimize/runner.ts +21 -0
- package/src/optimize/spendingMessages.ts +44 -0
- package/src/optimize/spendingRunner.ts +21 -0
- package/src/optimize/spendingSolve.worker.ts +18 -0
- package/src/planner/AssumptionsCardPage.tsx +136 -0
- package/src/planner/BucketLensCard.tsx +114 -0
- package/src/planner/ComparePlansPage.tsx +219 -0
- package/src/planner/DisclaimerPage.tsx +88 -0
- package/src/planner/HowTestedPage.tsx +159 -0
- package/src/planner/LiveStatus.tsx +15 -0
- package/src/planner/LongevityModal.tsx +55 -0
- package/src/planner/Modal.tsx +97 -0
- package/src/planner/MonteCarloPage.tsx +907 -0
- package/src/planner/OptimizePage.tsx +611 -0
- package/src/planner/PlanContext.tsx +198 -0
- package/src/planner/PlanPickerPage.tsx +124 -0
- package/src/planner/PlanWorkspace.tsx +290 -0
- package/src/planner/ProvenancePanel.tsx +45 -0
- package/src/planner/RelocationComparePage.tsx +485 -0
- package/src/planner/ReportPage.tsx +375 -0
- package/src/planner/ResultsPage.tsx +817 -0
- package/src/planner/ScenariosPage.tsx +285 -0
- package/src/planner/SocialSecuritySection.tsx +556 -0
- package/src/planner/SpendingSolverPage.tsx +512 -0
- package/src/planner/SsAnalysisPage.tsx +1134 -0
- package/src/planner/SurvivalPercentileModal.tsx +161 -0
- package/src/planner/SurvivorTransitionPage.tsx +286 -0
- package/src/planner/assumptionsExport.ts +371 -0
- package/src/planner/bucketLens.ts +89 -0
- package/src/planner/chartFrame.ts +8 -0
- package/src/planner/chartStyle.ts +11 -0
- package/src/planner/dialogViews.tsx +184 -0
- package/src/planner/dialogs.tsx +133 -0
- package/src/planner/examples/ExampleLibrary.tsx +189 -0
- package/src/planner/examples/ExamplePreviewBanner.tsx +55 -0
- package/src/planner/examples/ExamplesPage.tsx +25 -0
- package/src/planner/examples/OpenExampleButton.tsx +61 -0
- package/src/planner/examples/buildAggressiveSaver.ts +102 -0
- package/src/planner/examples/buildAnnuityEstate.ts +137 -0
- package/src/planner/examples/buildBaristaFire.ts +115 -0
- package/src/planner/examples/buildBracketFillRoth.ts +65 -0
- package/src/planner/examples/buildBridgeEarlyRetirement.ts +94 -0
- package/src/planner/examples/buildBrokerageNoHsa.ts +109 -0
- package/src/planner/examples/buildCoastFire.ts +88 -0
- package/src/planner/examples/buildContext.ts +20 -0
- package/src/planner/examples/buildEarlyCareerMatch.ts +93 -0
- package/src/planner/examples/buildEarlyRetireeAca.ts +61 -0
- package/src/planner/examples/buildExampleCouple.ts +103 -0
- package/src/planner/examples/buildFixedTargetSpending.ts +74 -0
- package/src/planner/examples/buildGlidepathAllocation.ts +131 -0
- package/src/planner/examples/buildGuardrailsFlex.ts +120 -0
- package/src/planner/examples/buildHsaPropertyDepth.ts +109 -0
- package/src/planner/examples/buildHsaStealthRetirement.ts +97 -0
- package/src/planner/examples/buildLeanFatFire.ts +109 -0
- package/src/planner/examples/buildLtcShock.ts +62 -0
- package/src/planner/examples/buildMovingStateTax.ts +53 -0
- package/src/planner/examples/buildNoAnnuityBrokerage.ts +92 -0
- package/src/planner/examples/buildRmdIrmaa.ts +55 -0
- package/src/planner/examples/buildSalaryGrowthEscalation.ts +96 -0
- package/src/planner/examples/buildStaticAllocationControl.ts +96 -0
- package/src/planner/examples/buildSurvivorYears.ts +62 -0
- package/src/planner/examples/buildUnderSavedSingle.ts +51 -0
- package/src/planner/examples/exampleCopy.ts +23 -0
- package/src/planner/examples/loadExample.ts +90 -0
- package/src/planner/examples/registry.ts +313 -0
- package/src/planner/explainPanels.tsx +233 -0
- package/src/planner/fields.tsx +381 -0
- package/src/planner/format.ts +33 -0
- package/src/planner/home/DataAndPrivacyCard.tsx +56 -0
- package/src/planner/home/GettingStartedPaths.tsx +46 -0
- package/src/planner/home/GettingStartedReopener.tsx +32 -0
- package/src/planner/home/StartHereLinks.tsx +22 -0
- package/src/planner/home/WelcomeHero.tsx +39 -0
- package/src/planner/home/YourPlans.tsx +72 -0
- package/src/planner/home/importErrorMessage.ts +22 -0
- package/src/planner/home/startHereSlugs.ts +7 -0
- package/src/planner/home/useHomeData.ts +190 -0
- package/src/planner/home/useHomeMode.ts +47 -0
- package/src/planner/householdActions.ts +22 -0
- package/src/planner/insights/InsightCardView.tsx +340 -0
- package/src/planner/insights/InsightsPage.tsx +204 -0
- package/src/planner/insights/categoryLabels.ts +11 -0
- package/src/planner/learnLinks.ts +85 -0
- package/src/planner/marketModelPicker.ts +172 -0
- package/src/planner/optimizePageChart.ts +40 -0
- package/src/planner/optimizePageClaim.ts +64 -0
- package/src/planner/planCompleteness.ts +27 -0
- package/src/planner/planContextCore.ts +26 -0
- package/src/planner/planner.css +2304 -0
- package/src/planner/provenanceLinks.ts +25 -0
- package/src/planner/sections/AccountFields.tsx +872 -0
- package/src/planner/sections/AccountsSection.tsx +89 -0
- package/src/planner/sections/AllocationPanel.tsx +261 -0
- package/src/planner/sections/AssumptionsSection.tsx +256 -0
- package/src/planner/sections/HouseholdSection.tsx +243 -0
- package/src/planner/sections/IncomeFloorSection.tsx +418 -0
- package/src/planner/sections/IncomeSection.tsx +170 -0
- package/src/planner/sections/InsuranceSection.tsx +362 -0
- package/src/planner/sections/SpendingSection.tsx +904 -0
- package/src/planner/sections/StrategySection.tsx +349 -0
- package/src/planner/sections/UpdateBalancesPanel.tsx +182 -0
- package/src/planner/sections/sectionHelpers.ts +48 -0
- package/src/planner/sections/shared.tsx +15 -0
- package/src/planner/sections.tsx +15 -0
- package/src/planner/ssAnalysis.ts +325 -0
- package/src/planner/successBand.ts +20 -0
- package/src/planner/survivorAnalysis.ts +277 -0
- package/src/planner/usStates.ts +19 -0
- package/src/planner/useMcSuccessRate.ts +77 -0
- package/src/planner/useProjection.ts +63 -0
- package/src/relocation/messages.ts +21 -0
- package/src/relocation/relocation.worker.ts +18 -0
- package/src/relocation/runRelocation.ts +17 -0
- package/src/relocation/runner.ts +22 -0
- package/src/report/brandingContext.ts +15 -0
- package/src/report/downloadReport.ts +34 -0
- package/src/report/reportHtml.ts +547 -0
- package/src/routes/LearnRoutes.tsx +46 -0
- package/src/routes/PlanRoutes.tsx +55 -0
- package/src/routes/RouteFallback.tsx +9 -0
- package/src/socialSecurity/breakEven.ts +107 -0
- package/src/socialSecurity/expectedPv.ts +164 -0
- package/src/socialSecurity/explain.ts +92 -0
- package/src/socialSecurity/ficaReturn.ts +81 -0
- package/src/socialSecurity/persistedSsGuard.ts +138 -0
- package/src/socialSecurity/ssFormUtils.ts +48 -0
- package/src/socialSecurity/ssaStatementXml.ts +156 -0
- package/src/socialSecurity/storage.ts +69 -0
- package/src/socialSecurity/survivorSwitching.ts +153 -0
- package/src/testSupport/samplePlan.ts +2 -0
- package/src/workers/run.ts +45 -0
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Monte Carlo: success gauge, percentile fan, ending-balance and depletion
|
|
3
|
+
* distributions. Runs 1,000 paths automatically on the worker pool (sync
|
|
4
|
+
* fallback in tests) and 10,000 on demand; the seed is stable per plan and
|
|
5
|
+
* re-rollable.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
9
|
+
import { Link } from 'react-router-dom'
|
|
10
|
+
import {
|
|
11
|
+
Area,
|
|
12
|
+
Bar,
|
|
13
|
+
BarChart,
|
|
14
|
+
CartesianGrid,
|
|
15
|
+
Line,
|
|
16
|
+
ComposedChart,
|
|
17
|
+
ResponsiveContainer,
|
|
18
|
+
Tooltip,
|
|
19
|
+
XAxis,
|
|
20
|
+
YAxis,
|
|
21
|
+
} from 'recharts'
|
|
22
|
+
|
|
23
|
+
import { planUsesAssetAllocation } from '@retiregolden/engine/allocation/assetClasses'
|
|
24
|
+
import type { Plan } from '@retiregolden/engine/model/plan'
|
|
25
|
+
import { DEFAULT_LTC_SHOCK } from '@retiregolden/engine/montecarlo/ltcShock'
|
|
26
|
+
import { startingInvestableOf } from '@retiregolden/engine/montecarlo/riskBasedGuardrails'
|
|
27
|
+
import type { MarketModelConfig } from '@retiregolden/engine/montecarlo/marketModels'
|
|
28
|
+
import type { AnnuitizationSweep } from '@retiregolden/engine/decisions/annuitization'
|
|
29
|
+
import type { StochasticFrontierPoint } from '@retiregolden/engine/montecarlo/frontiers'
|
|
30
|
+
import type { MonteCarloSummary } from '@retiregolden/engine/montecarlo/run'
|
|
31
|
+
import {
|
|
32
|
+
DEFAULT_PATH_COUNT,
|
|
33
|
+
ON_DEMAND_PATH_COUNT,
|
|
34
|
+
runHistoricalStressSuiteViews,
|
|
35
|
+
runMonteCarlo,
|
|
36
|
+
runStochasticFrontiers,
|
|
37
|
+
type HistoricalStressSuiteViewResult,
|
|
38
|
+
} from '../mc/pool'
|
|
39
|
+
import { usePlan } from './planContextCore'
|
|
40
|
+
import { WhySuccessPanel } from './explainPanels'
|
|
41
|
+
import { LiveStatus } from './LiveStatus'
|
|
42
|
+
import { HelpTip } from './fields'
|
|
43
|
+
import { LearnAboutScreen } from '../learn/LearnAboutScreen'
|
|
44
|
+
import { fmtMoney, fmtMoneyCompact } from './format'
|
|
45
|
+
import {
|
|
46
|
+
buildModel,
|
|
47
|
+
catalogLabelOf,
|
|
48
|
+
MODEL_CATALOG,
|
|
49
|
+
MODEL_PRESETS,
|
|
50
|
+
PRESET_FAMILY_LABELS,
|
|
51
|
+
presetFamilyOf,
|
|
52
|
+
type ModelKind,
|
|
53
|
+
} from './marketModelPicker'
|
|
54
|
+
import { currentStartYear, seedFromPlanId } from './useProjection'
|
|
55
|
+
import { chartTooltipStyle } from './chartStyle'
|
|
56
|
+
import { successBand } from './successBand'
|
|
57
|
+
import { frameH } from './chartFrame'
|
|
58
|
+
|
|
59
|
+
function SuccessGauge({ rate, pathCount }: { rate: number; pathCount: number }) {
|
|
60
|
+
const pct = Math.round(rate * 100)
|
|
61
|
+
const { color: tone, severity } = successBand(rate)
|
|
62
|
+
const r = 64
|
|
63
|
+
const c = 2 * Math.PI * r
|
|
64
|
+
return (
|
|
65
|
+
<div
|
|
66
|
+
className="success-gauge"
|
|
67
|
+
role="img"
|
|
68
|
+
aria-label={`Success probability ${pct} percent — ${severity} — over ${pathCount} paths`}
|
|
69
|
+
>
|
|
70
|
+
<svg width="176" height="176" viewBox="0 0 176 176">
|
|
71
|
+
<circle cx="88" cy="88" r={r} fill="none" stroke="var(--surface-2)" strokeWidth="16" />
|
|
72
|
+
<circle
|
|
73
|
+
cx="88"
|
|
74
|
+
cy="88"
|
|
75
|
+
r={r}
|
|
76
|
+
fill="none"
|
|
77
|
+
stroke={tone}
|
|
78
|
+
strokeWidth="16"
|
|
79
|
+
strokeLinecap="round"
|
|
80
|
+
strokeDasharray={`${c * rate} ${c}`}
|
|
81
|
+
/>
|
|
82
|
+
</svg>
|
|
83
|
+
<div className="success-gauge-label">
|
|
84
|
+
<span className="success-gauge-value" style={{ color: tone }}>
|
|
85
|
+
{pct}%
|
|
86
|
+
</span>
|
|
87
|
+
<span className="success-gauge-caption">{pathCount.toLocaleString()} paths</span>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function MonteCarloPage() {
|
|
94
|
+
const { plan } = usePlan()
|
|
95
|
+
const [modelKind, setModelKind] = useState<ModelKind>('lognormal')
|
|
96
|
+
const [returnVolPct, setReturnVolPct] = useState(12)
|
|
97
|
+
const [equityWeightPct, setEquityWeightPct] = useState(60)
|
|
98
|
+
const [seed, setSeed] = useState(() => seedFromPlanId(plan.id))
|
|
99
|
+
const [stochasticLongevity, setStochasticLongevity] = useState(false)
|
|
100
|
+
const [ltcShock, setLtcShock] = useState(false)
|
|
101
|
+
const [summary, setSummary] = useState<MonteCarloSummary | null>(null)
|
|
102
|
+
const [frontier, setFrontier] = useState<{
|
|
103
|
+
plan: Plan
|
|
104
|
+
model: MarketModelConfig
|
|
105
|
+
seed: number
|
|
106
|
+
stochasticLongevity: boolean
|
|
107
|
+
ltcShock: boolean
|
|
108
|
+
spending: StochasticFrontierPoint[]
|
|
109
|
+
retirement: StochasticFrontierPoint[]
|
|
110
|
+
annuitization: AnnuitizationSweep
|
|
111
|
+
} | null>(null)
|
|
112
|
+
const [frontierRunning, setFrontierRunning] = useState(false)
|
|
113
|
+
const [historical, setHistorical] = useState<{
|
|
114
|
+
plan: Plan
|
|
115
|
+
equityWeightPct: number
|
|
116
|
+
result: HistoricalStressSuiteViewResult
|
|
117
|
+
} | null>(null)
|
|
118
|
+
const [historicalRunning, setHistoricalRunning] = useState(false)
|
|
119
|
+
const [progress, setProgress] = useState(0)
|
|
120
|
+
const [running, setRunning] = useState(false)
|
|
121
|
+
const [statusMessage, setStatusMessage] = useState('')
|
|
122
|
+
const [error, setError] = useState<string | null>(null)
|
|
123
|
+
const [frontierError, setFrontierError] = useState<string | null>(null)
|
|
124
|
+
const [historicalError, setHistoricalError] = useState<string | null>(null)
|
|
125
|
+
const runToken = useRef(0)
|
|
126
|
+
|
|
127
|
+
const model = useMemo(
|
|
128
|
+
() => buildModel(modelKind, plan.assumptions.inflationPct, returnVolPct, equityWeightPct, plan),
|
|
129
|
+
[modelKind, plan, returnVolPct, equityWeightPct],
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
const run = useCallback(
|
|
133
|
+
(paths: number) => {
|
|
134
|
+
const token = ++runToken.current
|
|
135
|
+
setRunning(true)
|
|
136
|
+
setProgress(0)
|
|
137
|
+
setError(null)
|
|
138
|
+
setStatusMessage(`Simulating ${paths.toLocaleString()} market paths…`)
|
|
139
|
+
void runMonteCarlo(plan, {
|
|
140
|
+
startYear: currentStartYear(),
|
|
141
|
+
pathCount: paths,
|
|
142
|
+
seed,
|
|
143
|
+
model,
|
|
144
|
+
stochasticLongevity,
|
|
145
|
+
ltcShock: ltcShock ? DEFAULT_LTC_SHOCK : null,
|
|
146
|
+
onProgress: (done, total) => {
|
|
147
|
+
if (token === runToken.current) setProgress(done / total)
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
.then((s) => {
|
|
151
|
+
if (token === runToken.current) {
|
|
152
|
+
setSummary(s)
|
|
153
|
+
setStatusMessage(
|
|
154
|
+
`Simulation complete. ${Math.round(s.successRate * 100)} percent of markets sustain the plan.`,
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
.catch((e: unknown) => {
|
|
159
|
+
if (token === runToken.current) {
|
|
160
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
161
|
+
setStatusMessage('')
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
.finally(() => {
|
|
165
|
+
if (token === runToken.current) setRunning(false)
|
|
166
|
+
})
|
|
167
|
+
},
|
|
168
|
+
[plan, seed, model, stochasticLongevity, ltcShock],
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
const runFrontiers = useCallback(() => {
|
|
172
|
+
setFrontierRunning(true)
|
|
173
|
+
setFrontierError(null)
|
|
174
|
+
void runStochasticFrontiers(plan, {
|
|
175
|
+
startYear: currentStartYear(),
|
|
176
|
+
pathCount: 200,
|
|
177
|
+
seed,
|
|
178
|
+
model,
|
|
179
|
+
stochasticLongevity,
|
|
180
|
+
ltcShock: ltcShock ? DEFAULT_LTC_SHOCK : null,
|
|
181
|
+
})
|
|
182
|
+
.then((result) => {
|
|
183
|
+
setFrontier({
|
|
184
|
+
plan,
|
|
185
|
+
model,
|
|
186
|
+
seed,
|
|
187
|
+
stochasticLongevity,
|
|
188
|
+
ltcShock,
|
|
189
|
+
spending: result.spending,
|
|
190
|
+
retirement: result.retirement,
|
|
191
|
+
annuitization: result.annuitization,
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
.catch((e: unknown) => setFrontierError(e instanceof Error ? e.message : String(e)))
|
|
195
|
+
.finally(() => setFrontierRunning(false))
|
|
196
|
+
}, [plan, model, seed, stochasticLongevity, ltcShock])
|
|
197
|
+
|
|
198
|
+
const runHistoricalSuites = useCallback(() => {
|
|
199
|
+
setHistoricalRunning(true)
|
|
200
|
+
setHistoricalError(null)
|
|
201
|
+
void runHistoricalStressSuiteViews(plan, {
|
|
202
|
+
startYear: currentStartYear(),
|
|
203
|
+
equityWeightPct,
|
|
204
|
+
classShocks: planUsesAssetAllocation(plan),
|
|
205
|
+
worstWindowCount: 5,
|
|
206
|
+
})
|
|
207
|
+
.then((result) => {
|
|
208
|
+
setHistorical({ plan, equityWeightPct, result })
|
|
209
|
+
})
|
|
210
|
+
.catch((e: unknown) => setHistoricalError(e instanceof Error ? e.message : String(e)))
|
|
211
|
+
.finally(() => setHistoricalRunning(false))
|
|
212
|
+
}, [plan, equityWeightPct])
|
|
213
|
+
|
|
214
|
+
// Auto-run 1k paths whenever the plan, seed, or model changes (debounced).
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
const t = window.setTimeout(() => run(DEFAULT_PATH_COUNT), 250)
|
|
217
|
+
return () => window.clearTimeout(t)
|
|
218
|
+
}, [run])
|
|
219
|
+
|
|
220
|
+
const fanRows = useMemo(() => summary?.fan ?? [], [summary])
|
|
221
|
+
const histRows = useMemo(() => {
|
|
222
|
+
if (!summary) return []
|
|
223
|
+
const { min, binWidth, counts } = summary.endingInvestable.histogram
|
|
224
|
+
return counts.map((count, i) => ({ label: fmtMoneyCompact(min + (i + 0.5) * binWidth), count }))
|
|
225
|
+
}, [summary])
|
|
226
|
+
const depletionRows = useMemo(
|
|
227
|
+
() =>
|
|
228
|
+
summary?.depletionProbabilityByYear.map((row) => ({
|
|
229
|
+
year: row.year,
|
|
230
|
+
probability: row.probability * 100,
|
|
231
|
+
cumulativeProbability: row.cumulativeProbability * 100,
|
|
232
|
+
})) ?? [],
|
|
233
|
+
[summary],
|
|
234
|
+
)
|
|
235
|
+
const visibleFrontier =
|
|
236
|
+
frontier &&
|
|
237
|
+
frontier.plan === plan &&
|
|
238
|
+
frontier.model === model &&
|
|
239
|
+
frontier.seed === seed &&
|
|
240
|
+
frontier.stochasticLongevity === stochasticLongevity &&
|
|
241
|
+
frontier.ltcShock === ltcShock
|
|
242
|
+
? frontier
|
|
243
|
+
: null
|
|
244
|
+
const visibleHistorical =
|
|
245
|
+
historical && historical.plan === plan && historical.equityWeightPct === equityWeightPct ? historical.result : null
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<section>
|
|
249
|
+
<LiveStatus message={statusMessage} />
|
|
250
|
+
<div className="card">
|
|
251
|
+
<h2>Market model</h2>
|
|
252
|
+
<p className="card-hint">
|
|
253
|
+
Your deterministic projection assumes the same return and inflation every year. Monte Carlo replays the exact
|
|
254
|
+
same plan a thousand times with markets that vary year to year, then reports how often the money lasts. The
|
|
255
|
+
model below controls <em>how</em> those markets are generated — your expected returns and inflation from
|
|
256
|
+
Assumptions stay the center of the distribution either way.
|
|
257
|
+
</p>
|
|
258
|
+
<div className="form-grid">
|
|
259
|
+
<div className="field field-span-full">
|
|
260
|
+
<span className="field-label-row">
|
|
261
|
+
<span className="field-label">Model</span>
|
|
262
|
+
<HelpTip text="Monte Carlo needs a rule for generating each year's market. Smooth randomness draws each year's return from a bell-shaped curve around your expected return, with inflation statistically linked to it. Replay real history starts your retirement in a random year since 1928 and lets actual market history unfold from there. Stress test forces a 25% market drop in the first year, then returns to normal randomness. The advanced catalog offers more ways to shape the markets — fat tails, volatility clustering, bull/bear regimes, and more. Every model keeps your expected returns and inflation from Assumptions at the center of the distribution, and the same seed always reproduces the same markets." />
|
|
263
|
+
</span>
|
|
264
|
+
<div className="model-preset-row" role="group" aria-label="Market model presets">
|
|
265
|
+
{MODEL_PRESETS.map((preset) => (
|
|
266
|
+
<button
|
|
267
|
+
key={preset.id}
|
|
268
|
+
type="button"
|
|
269
|
+
className="model-preset"
|
|
270
|
+
aria-pressed={modelKind === preset.kind}
|
|
271
|
+
onClick={() => setModelKind(preset.kind)}
|
|
272
|
+
>
|
|
273
|
+
<span className="model-preset-label">{preset.label}</span>
|
|
274
|
+
<span className="model-preset-desc">{preset.description}</span>
|
|
275
|
+
</button>
|
|
276
|
+
))}
|
|
277
|
+
</div>
|
|
278
|
+
<details className="ss-explainer model-advanced">
|
|
279
|
+
<summary>Advanced models</summary>
|
|
280
|
+
<div className="field model-advanced-field">
|
|
281
|
+
<label className="field-label" htmlFor="mc-advanced-model">
|
|
282
|
+
Full model catalog
|
|
283
|
+
</label>
|
|
284
|
+
<select id="mc-advanced-model" value={modelKind} onChange={(e) => setModelKind(e.target.value as ModelKind)}>
|
|
285
|
+
{MODEL_CATALOG.map((entry) => (
|
|
286
|
+
<option key={entry.kind} value={entry.kind}>
|
|
287
|
+
{entry.label}
|
|
288
|
+
</option>
|
|
289
|
+
))}
|
|
290
|
+
</select>
|
|
291
|
+
<p className="field-hint">
|
|
292
|
+
{catalogLabelOf(modelKind)} is part of the “{PRESET_FAMILY_LABELS[presetFamilyOf(modelKind)]}” family.
|
|
293
|
+
</p>
|
|
294
|
+
<p className="field-hint">Current market seed: {seed} — “Re-roll markets” draws a new one.</p>
|
|
295
|
+
</div>
|
|
296
|
+
</details>
|
|
297
|
+
</div>
|
|
298
|
+
{modelKind === 'lognormal' ? (
|
|
299
|
+
<div className="field">
|
|
300
|
+
<span className="field-label-row">
|
|
301
|
+
<span className="field-label">Return volatility: {returnVolPct}%</span>
|
|
302
|
+
<HelpTip text="How wildly returns swing year to year (one standard deviation). ~10% resembles a balanced 60/40 portfolio, ~15% a mostly-stock portfolio, ~20% all stocks. Higher volatility means more sequence-of-returns risk: the same average return with bigger swings fails more often." />
|
|
303
|
+
</span>
|
|
304
|
+
<input type="range" min={5} max={25} step={1} value={returnVolPct} onChange={(e) => setReturnVolPct(Number(e.target.value))} />
|
|
305
|
+
</div>
|
|
306
|
+
) : (
|
|
307
|
+
<div className="field">
|
|
308
|
+
<span className="field-label-row">
|
|
309
|
+
<span className="field-label">Equity weight: {equityWeightPct}/{100 - equityWeightPct}</span>
|
|
310
|
+
<HelpTip text="The stock/bond mix used when sampling historical years (e.g. 60/40 = 60% S&P 500, 40% 10-year Treasuries). It shapes how volatile the resampled history is; your accounts' expected returns still come from the Accounts/Assumptions forms." />
|
|
311
|
+
</span>
|
|
312
|
+
<input type="range" min={0} max={100} step={5} value={equityWeightPct} onChange={(e) => setEquityWeightPct(Number(e.target.value))} />
|
|
313
|
+
</div>
|
|
314
|
+
)}
|
|
315
|
+
<div className="field">
|
|
316
|
+
<span className="field-label-row">
|
|
317
|
+
<span className="field-label">Market draw</span>
|
|
318
|
+
<HelpTip text="The random sequence is reproducible: the same draw always produces the same thousand markets, so results don't jump around as you edit the plan. Re-roll to check the conclusion holds under a different draw — if success swings more than a point or two, run 10,000 paths. The exact seed number is under Advanced models." />
|
|
319
|
+
</span>
|
|
320
|
+
<button type="button" className="btn btn-secondary btn-small" onClick={() => setSeed((Math.random() * 0xffffffff) >>> 0)}>
|
|
321
|
+
Re-roll markets
|
|
322
|
+
</button>
|
|
323
|
+
</div>
|
|
324
|
+
<div className="field">
|
|
325
|
+
<span className="field-label-row">
|
|
326
|
+
<span className="field-label">Precision</span>
|
|
327
|
+
<HelpTip text="1,000 paths run automatically and put the success rate within roughly ±1.5 points. 10,000 paths tighten that to about ±0.5 — useful when comparing strategies that differ by a hair." />
|
|
328
|
+
</span>
|
|
329
|
+
<button type="button" className="btn btn-secondary btn-small" disabled={running} onClick={() => run(ON_DEMAND_PATH_COUNT)}>
|
|
330
|
+
Run {ON_DEMAND_PATH_COUNT.toLocaleString()} paths
|
|
331
|
+
</button>
|
|
332
|
+
</div>
|
|
333
|
+
<div className="field">
|
|
334
|
+
<label className="radio-option">
|
|
335
|
+
<input type="checkbox" checked={stochasticLongevity} onChange={(e) => setStochasticLongevity(e.target.checked)} />
|
|
336
|
+
<span>
|
|
337
|
+
<span className="field-label-row">
|
|
338
|
+
<span className="field-label">Model longevity</span>
|
|
339
|
+
<HelpTip text="Instead of everyone living to their fixed planning age, each path draws a lifespan from SSA mortality tables (by age and sex). Outcomes are then weighted by how long people actually live — dying earlier frees the plan, living longer stresses it. Couples use a joint-life RMD divisor when one spouse is much younger." />
|
|
340
|
+
</span>
|
|
341
|
+
<p className="field-hint">Draw lifespans from mortality tables instead of the fixed planning age.</p>
|
|
342
|
+
</span>
|
|
343
|
+
</label>
|
|
344
|
+
</div>
|
|
345
|
+
<div className="field">
|
|
346
|
+
<label className="radio-option">
|
|
347
|
+
<input type="checkbox" checked={ltcShock} onChange={(e) => setLtcShock(e.target.checked)} />
|
|
348
|
+
<span>
|
|
349
|
+
<span className="field-label-row">
|
|
350
|
+
<span className="field-label">Model an LTC shock</span>
|
|
351
|
+
<HelpTip text="Each path may draw a paid long-term-care episode (incidence, onset age, and duration from published LTC-risk research), adding a late-life cost spike. Any LTC policy you've entered offsets it — so the success rate with vs. without coverage shows what the policy buys you across the whole distribution." />
|
|
352
|
+
</span>
|
|
353
|
+
<p className="field-hint">Add a probabilistic care episode; your LTC policy offsets it.</p>
|
|
354
|
+
</span>
|
|
355
|
+
</label>
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
{running ? (
|
|
359
|
+
<div className="progress-track" role="progressbar" aria-valuenow={Math.round(progress * 100)} aria-valuemin={0} aria-valuemax={100}>
|
|
360
|
+
<div className="progress-fill" style={{ transform: `scaleX(${Math.max(0.04, progress)})` }} />
|
|
361
|
+
</div>
|
|
362
|
+
) : null}
|
|
363
|
+
{error ? (
|
|
364
|
+
<div className="error-recovery" role="alert">
|
|
365
|
+
<p className="error-text">Simulation error: {error}</p>
|
|
366
|
+
<button type="button" className="btn btn-secondary btn-small" onClick={() => run(DEFAULT_PATH_COUNT)}>
|
|
367
|
+
Run again
|
|
368
|
+
</button>
|
|
369
|
+
</div>
|
|
370
|
+
) : null}
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
{summary === null ? (
|
|
374
|
+
error ? null : <div className="skeleton" style={{ height: '22rem' }} aria-label="Simulating" />
|
|
375
|
+
) : (
|
|
376
|
+
<>
|
|
377
|
+
<div className="mc-hero">
|
|
378
|
+
<SuccessGauge rate={summary.successRate} pathCount={summary.pathCount} />
|
|
379
|
+
<div>
|
|
380
|
+
<h2>{successBand(summary.successRate).verdict}</h2>
|
|
381
|
+
<p className="muted">
|
|
382
|
+
Median ending estate {fmtMoneyCompact(summary.endingAfterTaxEstate.percentiles.p50)} · worst 10%{' '}
|
|
383
|
+
{fmtMoneyCompact(summary.endingAfterTaxEstate.percentiles.p10)} · best 10%{' '}
|
|
384
|
+
{fmtMoneyCompact(summary.endingAfterTaxEstate.percentiles.p90)}. Success means investable assets never run
|
|
385
|
+
out before the end of the plan.
|
|
386
|
+
</p>
|
|
387
|
+
{summary.successRate < 0.9 ? (
|
|
388
|
+
<p className="mc-handrail">
|
|
389
|
+
A result like this is usually movable — small changes to spending, retirement age, or claiming
|
|
390
|
+
strategy shift it the most. See what would change this:{' '}
|
|
391
|
+
<Link to={`/plan/${plan.id}/insights`}>Insights</Link> ·{' '}
|
|
392
|
+
<Link to={`/plan/${plan.id}/spending-solver`}>How much can I spend?</Link>
|
|
393
|
+
</p>
|
|
394
|
+
) : null}
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
|
|
398
|
+
<WhySuccessPanel summary={summary} modelLabel={catalogLabelOf(modelKind)} seed={seed} planId={plan.id} />
|
|
399
|
+
|
|
400
|
+
<div className="chart-card">
|
|
401
|
+
<h2>Downside risk</h2>
|
|
402
|
+
<p className="card-hint">
|
|
403
|
+
Success rate answers "did the plan last?" These metrics show how deep the misses are when it does not.
|
|
404
|
+
</p>
|
|
405
|
+
<div className="stat-grid">
|
|
406
|
+
<div>
|
|
407
|
+
<div className="stat-value">{fmtMoneyCompact(summary.endingAfterTaxEstate.percentiles.p10)}</div>
|
|
408
|
+
<div className="muted">
|
|
409
|
+
In the worst 10% of markets, you'd end with{' '}
|
|
410
|
+
<HelpTip
|
|
411
|
+
text="The 10th-percentile (p10) ending estate: 9 in 10 simulated markets leave you with more than this, 1 in 10 with less."
|
|
412
|
+
learn={{ slug: 'understanding-monte-carlo-success-rate', label: 'Understanding success rate' }}
|
|
413
|
+
/>
|
|
414
|
+
</div>
|
|
415
|
+
</div>
|
|
416
|
+
<div>
|
|
417
|
+
<div className="stat-value">{fmtMoneyCompact(summary.downsideRisk.expectedShortfallDollars)}</div>
|
|
418
|
+
<div className="muted">
|
|
419
|
+
Typical shortfall when the plan falls short{' '}
|
|
420
|
+
<HelpTip
|
|
421
|
+
text="Expected shortfall: the average unmet spending across only the markets where the money runs out — a sense of how bad a bad outcome tends to be."
|
|
422
|
+
learn={{ slug: 'sequence-of-returns-risk', label: 'Sequence-of-returns risk' }}
|
|
423
|
+
/>
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
<div>
|
|
427
|
+
<div className="stat-value">{fmtMoneyCompact(summary.spendingShortfall.p90TotalShortfallDollars)}</div>
|
|
428
|
+
<div className="muted">
|
|
429
|
+
Worst-case total spending miss{' '}
|
|
430
|
+
<HelpTip text="The 90th-percentile (p90) cumulative shortfall: in the worst 10% of markets, total unmet spending across the whole retirement reaches at least this much." />
|
|
431
|
+
</div>
|
|
432
|
+
</div>
|
|
433
|
+
<div>
|
|
434
|
+
<div className="stat-value">{Math.round(summary.downsideRisk.failureRate * 100)}%</div>
|
|
435
|
+
<div className="muted">
|
|
436
|
+
Chance the money runs out{' '}
|
|
437
|
+
<HelpTip text="The share of simulated markets in which investable assets are depleted before the end of the plan — the complement of the success rate." />
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
</div>
|
|
441
|
+
{depletionRows.length > 0 ? (
|
|
442
|
+
<div
|
|
443
|
+
className="chart-frame chart-frame--spaced"
|
|
444
|
+
style={frameH(220)}
|
|
445
|
+
role="img"
|
|
446
|
+
aria-label={`Depletion probability by year: by ${depletionRows[depletionRows.length - 1]!.year}, ${depletionRows[depletionRows.length - 1]!.cumulativeProbability.toFixed(1)} percent of paths have depleted.`}
|
|
447
|
+
>
|
|
448
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
449
|
+
<ComposedChart data={depletionRows} margin={{ left: 12, right: 8, top: 8 }}>
|
|
450
|
+
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
|
451
|
+
<XAxis dataKey="year" tick={{ fill: 'var(--muted)', fontSize: 12 }} />
|
|
452
|
+
<YAxis tickFormatter={(v) => `${Math.round(Number(v))}%`} tick={{ fill: 'var(--muted)', fontSize: 12 }} width={56} />
|
|
453
|
+
<Tooltip formatter={(v: unknown) => `${Number(v).toFixed(1)}%`} contentStyle={chartTooltipStyle} />
|
|
454
|
+
<Bar dataKey="probability" name="First depletion" fill="var(--chart-3)" />
|
|
455
|
+
{/* Rising depletion odds must not wear the brand/good green. */}
|
|
456
|
+
<Line dataKey="cumulativeProbability" name="Depleted by year" stroke="var(--bad)" strokeWidth={2.5} dot={false} />
|
|
457
|
+
</ComposedChart>
|
|
458
|
+
</ResponsiveContainer>
|
|
459
|
+
</div>
|
|
460
|
+
) : (
|
|
461
|
+
<p className="muted">No path depleted, so there is no depletion-by-year curve to show.</p>
|
|
462
|
+
)}
|
|
463
|
+
</div>
|
|
464
|
+
|
|
465
|
+
{summary.requiredFloorSuccessRate !== summary.successRate ||
|
|
466
|
+
summary.targetLifestyleSuccessRate !== summary.successRate ||
|
|
467
|
+
summary.idealFundingRate < 1 ||
|
|
468
|
+
summary.excessFundingRate < 1 ||
|
|
469
|
+
summary.flexibleGoals.funded + summary.flexibleGoals.partiallyFunded + summary.flexibleGoals.deferred + summary.flexibleGoals.skipped > 0 ||
|
|
470
|
+
summary.guardrailActionCounts.cut + summary.guardrailActionCounts.raise > 0 ? (
|
|
471
|
+
<div className="chart-card">
|
|
472
|
+
<h2>Spending resilience</h2>
|
|
473
|
+
<p className="card-hint">
|
|
474
|
+
With a required floor or spending guardrails, a single success number is too blunt. These separate
|
|
475
|
+
funding the essential floor every year from funding the full target lifestyle.
|
|
476
|
+
</p>
|
|
477
|
+
{/* Two headline numbers; the qualifying metrics live one click away
|
|
478
|
+
so they stop taxing the pair that matters (round-4 critique). */}
|
|
479
|
+
<div className="stat-grid">
|
|
480
|
+
<div>
|
|
481
|
+
<div className="stat-value">{Math.round(summary.requiredFloorSuccessRate * 100)}%</div>
|
|
482
|
+
<div className="muted">funded the required floor every year</div>
|
|
483
|
+
</div>
|
|
484
|
+
<div>
|
|
485
|
+
<div className="stat-value">{Math.round(summary.targetLifestyleSuccessRate * 100)}%</div>
|
|
486
|
+
<div className="muted">funded the full target lifestyle every year</div>
|
|
487
|
+
</div>
|
|
488
|
+
</div>
|
|
489
|
+
<details className="ss-explainer">
|
|
490
|
+
<summary>All resilience metrics</summary>
|
|
491
|
+
<div className="stat-grid">
|
|
492
|
+
<div>
|
|
493
|
+
<div className="stat-value">{Math.round(summary.successRate * 100)}%</div>
|
|
494
|
+
<div className="muted">never depleted investable assets</div>
|
|
495
|
+
</div>
|
|
496
|
+
<div>
|
|
497
|
+
<div className="stat-value">{Math.round(summary.targetAttainmentPct.p50 * 100)}%</div>
|
|
498
|
+
<div className="muted">median target dollars funded</div>
|
|
499
|
+
</div>
|
|
500
|
+
<div>
|
|
501
|
+
<div className="stat-value">{fmtMoneyCompact(summary.p90AverageAnnualTargetShortfall)}</div>
|
|
502
|
+
<div className="muted">
|
|
503
|
+
Worst-case average annual target miss{' '}
|
|
504
|
+
<HelpTip text="The 90th-percentile (p90) average annual shortfall against the target lifestyle: only 1 in 10 markets miss by more." />
|
|
505
|
+
</div>
|
|
506
|
+
</div>
|
|
507
|
+
<div>
|
|
508
|
+
<div className="stat-value">{summary.averageYearsBelowTarget.toFixed(1)}</div>
|
|
509
|
+
<div className="muted">average years below target</div>
|
|
510
|
+
</div>
|
|
511
|
+
{summary.idealFundingRate < 1 || summary.excessFundingRate < 1 ? (
|
|
512
|
+
<div>
|
|
513
|
+
<div className="stat-value">
|
|
514
|
+
{Math.round(summary.idealFundingRate * 100)}% / {Math.round(summary.excessFundingRate * 100)}%
|
|
515
|
+
</div>
|
|
516
|
+
<div className="muted">ideal / excess upside funded</div>
|
|
517
|
+
</div>
|
|
518
|
+
) : null}
|
|
519
|
+
{summary.flexibleGoals.funded + summary.flexibleGoals.partiallyFunded + summary.flexibleGoals.deferred + summary.flexibleGoals.skipped > 0 ? (
|
|
520
|
+
<div>
|
|
521
|
+
<div className="stat-value stat-value--sm">
|
|
522
|
+
{summary.flexibleGoals.funded} funded · {summary.flexibleGoals.partiallyFunded} partial ·{' '}
|
|
523
|
+
{summary.flexibleGoals.deferred} deferred · {summary.flexibleGoals.skipped} skipped
|
|
524
|
+
</div>
|
|
525
|
+
<div className="muted">flexible goal outcomes across paths</div>
|
|
526
|
+
</div>
|
|
527
|
+
) : null}
|
|
528
|
+
{summary.guardrailActionCounts.cut + summary.guardrailActionCounts.raise > 0 ? (
|
|
529
|
+
<div>
|
|
530
|
+
<div className="stat-value stat-value--sm">
|
|
531
|
+
{summary.guardrailActionCounts.cut} cuts · {summary.guardrailActionCounts.raise} raises
|
|
532
|
+
</div>
|
|
533
|
+
<div className="muted">guardrail actions across path-years</div>
|
|
534
|
+
</div>
|
|
535
|
+
) : null}
|
|
536
|
+
</div>
|
|
537
|
+
</details>
|
|
538
|
+
</div>
|
|
539
|
+
) : null}
|
|
540
|
+
|
|
541
|
+
{/* Guardrail modes only: ABW re-amortizes instead of cutting, so the
|
|
542
|
+
cut/raise adjustment framing would just read as zeros there. */}
|
|
543
|
+
{plan.expenses.spendingPolicy &&
|
|
544
|
+
(plan.expenses.spendingPolicy.mode === 'withdrawalRateGuardrails' ||
|
|
545
|
+
plan.expenses.spendingPolicy.mode === 'riskBasedGuardrails') ? (
|
|
546
|
+
<div className="chart-card">
|
|
547
|
+
<h2>Adjustment outlook</h2>
|
|
548
|
+
<p className="card-hint">
|
|
549
|
+
A more useful headline than one success number: how likely a mid-course spending adjustment is, how
|
|
550
|
+
deep it tends to go, how long it lasts — and how likely the plan ends with money left over.
|
|
551
|
+
</p>
|
|
552
|
+
{/* Headline pair: how likely a cut is, how likely it ends well. */}
|
|
553
|
+
<div className="stat-grid">
|
|
554
|
+
<div>
|
|
555
|
+
<div className="stat-value">{Math.round(summary.adjustments.pathsWithCut * 100)}%</div>
|
|
556
|
+
<div className="muted">of paths needed at least one spending cut</div>
|
|
557
|
+
</div>
|
|
558
|
+
<div>
|
|
559
|
+
<div className="stat-value">{Math.round(summary.adjustments.probEndingSurplus * 100)}%</div>
|
|
560
|
+
<div className="muted">chance the plan ends with a surplus</div>
|
|
561
|
+
</div>
|
|
562
|
+
</div>
|
|
563
|
+
<details className="ss-explainer">
|
|
564
|
+
<summary>Cut depth, duration, and raises</summary>
|
|
565
|
+
<div className="stat-grid">
|
|
566
|
+
<div>
|
|
567
|
+
<div className="stat-value">
|
|
568
|
+
{Math.round(summary.adjustments.medianMaxCutDepth * 100)}% / {Math.round(summary.adjustments.p90MaxCutDepth * 100)}%
|
|
569
|
+
</div>
|
|
570
|
+
<div className="muted">
|
|
571
|
+
median / worst-case deepest cut, as a share of flexible spending{' '}
|
|
572
|
+
<HelpTip text="Worst-case here is the 90th percentile (p90): only 1 in 10 cut paths dig deeper than this." />
|
|
573
|
+
</div>
|
|
574
|
+
</div>
|
|
575
|
+
<div>
|
|
576
|
+
<div className="stat-value">
|
|
577
|
+
{summary.adjustments.averageCutYears.toFixed(1)} / {summary.adjustments.p90CutYears.toFixed(0)}
|
|
578
|
+
</div>
|
|
579
|
+
<div className="muted">
|
|
580
|
+
average / worst-case years spent below target (cut paths){' '}
|
|
581
|
+
<HelpTip text="Worst-case here is the 90th percentile (p90) across cut paths." />
|
|
582
|
+
</div>
|
|
583
|
+
</div>
|
|
584
|
+
<div>
|
|
585
|
+
<div className="stat-value">{summary.adjustments.averageLongestCutSpellYears.toFixed(1)}</div>
|
|
586
|
+
<div className="muted">average longest consecutive cut spell, years</div>
|
|
587
|
+
</div>
|
|
588
|
+
<div>
|
|
589
|
+
<div className="stat-value">{Math.round(summary.adjustments.pathsWithRaise * 100)}%</div>
|
|
590
|
+
<div className="muted">of paths earned at least one raise</div>
|
|
591
|
+
</div>
|
|
592
|
+
{summary.adjustments.probEndingAboveBequestTarget !== null ? (
|
|
593
|
+
<div>
|
|
594
|
+
<div className="stat-value">{Math.round(summary.adjustments.probEndingAboveBequestTarget * 100)}%</div>
|
|
595
|
+
<div className="muted">chance the estate clears the bequest target</div>
|
|
596
|
+
</div>
|
|
597
|
+
) : null}
|
|
598
|
+
</div>
|
|
599
|
+
</details>
|
|
600
|
+
{plan.expenses.spendingPolicy.mode === 'riskBasedGuardrails' ? (
|
|
601
|
+
plan.expenses.spendingPolicy.lowerBalanceThresholdPct !== undefined ||
|
|
602
|
+
plan.expenses.spendingPolicy.upperBalanceThresholdPct !== undefined ? (
|
|
603
|
+
<p className="card-hint">
|
|
604
|
+
Risk-based dollar guardrails ({plan.expenses.spendingPolicy.targetSuccessLowerPct ?? 70}–
|
|
605
|
+
{plan.expenses.spendingPolicy.targetSuccessUpperPct ?? 95}% success band):{' '}
|
|
606
|
+
{plan.expenses.spendingPolicy.lowerBalanceThresholdPct !== undefined
|
|
607
|
+
? `cut below ${fmtMoney((plan.expenses.spendingPolicy.lowerBalanceThresholdPct / 100) * startingInvestableOf(plan))}`
|
|
608
|
+
: 'no cut threshold'}
|
|
609
|
+
{' · '}
|
|
610
|
+
{plan.expenses.spendingPolicy.upperBalanceThresholdPct !== undefined
|
|
611
|
+
? `raise above ${fmtMoney((plan.expenses.spendingPolicy.upperBalanceThresholdPct / 100) * startingInvestableOf(plan))}`
|
|
612
|
+
: 'no raise threshold'}
|
|
613
|
+
{' '}(today's dollars, solved on Spending).
|
|
614
|
+
</p>
|
|
615
|
+
) : (
|
|
616
|
+
<p className="card-hint">
|
|
617
|
+
Risk-based guardrails are selected but the dollar thresholds have not been solved yet — solve them
|
|
618
|
+
on the Spending screen to activate adjustments.
|
|
619
|
+
</p>
|
|
620
|
+
)
|
|
621
|
+
) : null}
|
|
622
|
+
</div>
|
|
623
|
+
) : null}
|
|
624
|
+
|
|
625
|
+
<div className="chart-card">
|
|
626
|
+
<div className="item-row-head">
|
|
627
|
+
<h2 style={{ margin: 0 }}>Frontier views</h2>
|
|
628
|
+
<button type="button" className="btn btn-secondary btn-small" disabled={frontierRunning} onClick={runFrontiers}>
|
|
629
|
+
{frontierRunning ? 'Running...' : 'Run 200-path frontiers'}
|
|
630
|
+
</button>
|
|
631
|
+
</div>
|
|
632
|
+
<p className="card-hint">
|
|
633
|
+
These sweeps reuse the same seeded market paths for every point, so the curve shows the effect of the
|
|
634
|
+
changed spending or retirement age instead of fresh random noise.
|
|
635
|
+
</p>
|
|
636
|
+
{frontierError ? (
|
|
637
|
+
<div className="error-recovery" role="alert">
|
|
638
|
+
<p className="error-text">Frontier run error: {frontierError}</p>
|
|
639
|
+
<button type="button" className="btn btn-secondary btn-small" disabled={frontierRunning} onClick={runFrontiers}>
|
|
640
|
+
Run again
|
|
641
|
+
</button>
|
|
642
|
+
</div>
|
|
643
|
+
) : null}
|
|
644
|
+
{visibleFrontier ? (
|
|
645
|
+
<div className="chart-grid">
|
|
646
|
+
<div>
|
|
647
|
+
<h3 style={{ marginTop: 0 }}>Spending vs. success</h3>
|
|
648
|
+
<div className="chart-frame" style={frameH(240)}>
|
|
649
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
650
|
+
<ComposedChart data={visibleFrontier.spending} margin={{ left: 12, right: 8, top: 8 }}>
|
|
651
|
+
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
|
652
|
+
<XAxis dataKey="x" tickFormatter={fmtMoneyCompact} tick={{ fill: 'var(--muted)', fontSize: 12 }} />
|
|
653
|
+
<YAxis tickFormatter={(v) => `${Math.round(Number(v) * 100)}%`} tick={{ fill: 'var(--muted)', fontSize: 12 }} width={56} />
|
|
654
|
+
<Tooltip
|
|
655
|
+
formatter={(v: unknown, name: unknown) =>
|
|
656
|
+
name === 'Success rate' ? `${Math.round(Number(v) * 100)}%` : fmtMoneyCompact(Number(v))
|
|
657
|
+
}
|
|
658
|
+
labelFormatter={(v) => `Spending ${fmtMoneyCompact(Number(v))}`}
|
|
659
|
+
contentStyle={chartTooltipStyle}
|
|
660
|
+
/>
|
|
661
|
+
<Line dataKey="successRate" name="Success rate" stroke="var(--chart-1)" strokeWidth={2.5} />
|
|
662
|
+
</ComposedChart>
|
|
663
|
+
</ResponsiveContainer>
|
|
664
|
+
</div>
|
|
665
|
+
</div>
|
|
666
|
+
<div>
|
|
667
|
+
<h3 style={{ marginTop: 0 }}>Retirement age vs. success</h3>
|
|
668
|
+
<div className="chart-frame" style={frameH(240)}>
|
|
669
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
670
|
+
<ComposedChart data={visibleFrontier.retirement} margin={{ left: 12, right: 8, top: 8 }}>
|
|
671
|
+
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
|
672
|
+
<XAxis dataKey="x" tick={{ fill: 'var(--muted)', fontSize: 12 }} />
|
|
673
|
+
<YAxis tickFormatter={(v) => `${Math.round(Number(v) * 100)}%`} tick={{ fill: 'var(--muted)', fontSize: 12 }} width={56} />
|
|
674
|
+
<Tooltip
|
|
675
|
+
formatter={(v: unknown, name: unknown) =>
|
|
676
|
+
name === 'Success rate' ? `${Math.round(Number(v) * 100)}%` : fmtMoneyCompact(Number(v))
|
|
677
|
+
}
|
|
678
|
+
labelFormatter={(v) => `Retirement age ${v}`}
|
|
679
|
+
contentStyle={chartTooltipStyle}
|
|
680
|
+
/>
|
|
681
|
+
<Line dataKey="successRate" name="Success rate" stroke="var(--chart-2)" strokeWidth={2.5} />
|
|
682
|
+
</ComposedChart>
|
|
683
|
+
</ResponsiveContainer>
|
|
684
|
+
</div>
|
|
685
|
+
</div>
|
|
686
|
+
</div>
|
|
687
|
+
) : (
|
|
688
|
+
<p className="muted">Run the frontier sweep to compare bounded variants on the same market paths.</p>
|
|
689
|
+
)}
|
|
690
|
+
{visibleFrontier && visibleFrontier.annuitization.points.length > 1 ? (
|
|
691
|
+
<div style={{ marginTop: '1rem' }}>
|
|
692
|
+
<h3 style={{ marginTop: 0 }}>How much to annuitize?</h3>
|
|
693
|
+
<p className="card-hint">
|
|
694
|
+
Each point trades that share of your investable assets for an immediate life annuity (payout rate{' '}
|
|
695
|
+
{visibleFrontier.annuitization.payoutRatePct.toFixed(1)}% at age {visibleFrontier.annuitization.startAge},{' '}
|
|
696
|
+
{visibleFrontier.annuitization.rateSource === 'default-table'
|
|
697
|
+
? 'from a published-quote planning table — check a marketplace quote before acting'
|
|
698
|
+
: 'from your entered quote'}
|
|
699
|
+
), priced on the exact ledger over the same market paths.
|
|
700
|
+
{visibleFrontier.annuitization.attributionAvailable
|
|
701
|
+
? ' The dashed line holds the same dollars but shifts them from bonds to stocks instead of buying the annuity — the implicit rising-equity glidepath that research attributes much of a SPIA\'s benefit to. The gap between the lines is what annuitization adds beyond it (mortality credits, spending floor), net of lost liquidity.'
|
|
702
|
+
: ''}
|
|
703
|
+
</p>
|
|
704
|
+
<div className="chart-grid">
|
|
705
|
+
<div>
|
|
706
|
+
<div className="chart-frame" style={frameH(240)}>
|
|
707
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
708
|
+
<ComposedChart
|
|
709
|
+
data={visibleFrontier.annuitization.points.map((p) => ({
|
|
710
|
+
pct: Math.round(p.effectiveAllocationPct * 10) / 10,
|
|
711
|
+
success: p.metrics.successRate,
|
|
712
|
+
control: p.glidepathControl?.successRate,
|
|
713
|
+
}))}
|
|
714
|
+
margin={{ left: 12, right: 8, top: 8 }}
|
|
715
|
+
>
|
|
716
|
+
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
|
717
|
+
<XAxis dataKey="pct" tickFormatter={(v) => `${v}%`} tick={{ fill: 'var(--muted)', fontSize: 12 }} />
|
|
718
|
+
<YAxis tickFormatter={(v) => `${Math.round(Number(v) * 100)}%`} tick={{ fill: 'var(--muted)', fontSize: 12 }} width={56} />
|
|
719
|
+
<Tooltip
|
|
720
|
+
formatter={(v: unknown) =>
|
|
721
|
+
Number.isFinite(Number(v)) ? `${(Number(v) * 100).toFixed(1)}%` : '—'
|
|
722
|
+
}
|
|
723
|
+
labelFormatter={(v) => `${v}% annuitized`}
|
|
724
|
+
contentStyle={chartTooltipStyle}
|
|
725
|
+
/>
|
|
726
|
+
<Line dataKey="success" name="Success rate (annuitized)" stroke="var(--chart-1)" strokeWidth={2.5} />
|
|
727
|
+
{visibleFrontier.annuitization.attributionAvailable ? (
|
|
728
|
+
<Line
|
|
729
|
+
dataKey="control"
|
|
730
|
+
name="Glidepath only (no annuity)"
|
|
731
|
+
stroke="var(--chart-3)"
|
|
732
|
+
strokeWidth={2}
|
|
733
|
+
strokeDasharray="6 4"
|
|
734
|
+
/>
|
|
735
|
+
) : null}
|
|
736
|
+
</ComposedChart>
|
|
737
|
+
</ResponsiveContainer>
|
|
738
|
+
</div>
|
|
739
|
+
</div>
|
|
740
|
+
<div>
|
|
741
|
+
<div className="year-table-wrap" style={{ border: 'none' }}>
|
|
742
|
+
<table className="compare-table">
|
|
743
|
+
<thead>
|
|
744
|
+
<tr>
|
|
745
|
+
<th>Annuitized</th>
|
|
746
|
+
<th>Premium</th>
|
|
747
|
+
<th>Income/yr</th>
|
|
748
|
+
<th>Median estate</th>
|
|
749
|
+
</tr>
|
|
750
|
+
</thead>
|
|
751
|
+
<tbody>
|
|
752
|
+
{visibleFrontier.annuitization.points.map((p) => (
|
|
753
|
+
<tr key={p.allocationPct}>
|
|
754
|
+
<td>{Math.round(p.effectiveAllocationPct * 10) / 10}%</td>
|
|
755
|
+
<td>{fmtMoneyCompact(p.premium)}</td>
|
|
756
|
+
<td>{fmtMoneyCompact(p.annualIncome)}</td>
|
|
757
|
+
<td>{fmtMoneyCompact(p.metrics.medianEndingAfterTaxEstate)}</td>
|
|
758
|
+
</tr>
|
|
759
|
+
))}
|
|
760
|
+
</tbody>
|
|
761
|
+
</table>
|
|
762
|
+
</div>
|
|
763
|
+
</div>
|
|
764
|
+
</div>
|
|
765
|
+
{visibleFrontier.annuitization.notes.map((note) => (
|
|
766
|
+
<p key={note} className="muted" style={{ marginBottom: 0 }}>
|
|
767
|
+
{note}
|
|
768
|
+
</p>
|
|
769
|
+
))}
|
|
770
|
+
</div>
|
|
771
|
+
) : null}
|
|
772
|
+
</div>
|
|
773
|
+
|
|
774
|
+
<div className="chart-card">
|
|
775
|
+
<div className="item-row-head">
|
|
776
|
+
<h2 style={{ margin: 0 }}>Historical stress windows</h2>
|
|
777
|
+
<button type="button" className="btn btn-secondary btn-small" disabled={historicalRunning} onClick={runHistoricalSuites}>
|
|
778
|
+
{historicalRunning ? 'Running...' : 'Run rolling/reversed suite'}
|
|
779
|
+
</button>
|
|
780
|
+
</div>
|
|
781
|
+
<p className="card-hint">
|
|
782
|
+
Replay every rolling historical window, then replay those windows in reverse order, to find the named
|
|
783
|
+
sequences that hurt this plan most.
|
|
784
|
+
</p>
|
|
785
|
+
{historicalError ? (
|
|
786
|
+
<div className="error-recovery" role="alert">
|
|
787
|
+
<p className="error-text">Stress suite error: {historicalError}</p>
|
|
788
|
+
<button type="button" className="btn btn-secondary btn-small" disabled={historicalRunning} onClick={runHistoricalSuites}>
|
|
789
|
+
Run again
|
|
790
|
+
</button>
|
|
791
|
+
</div>
|
|
792
|
+
) : null}
|
|
793
|
+
{visibleHistorical ? (
|
|
794
|
+
<div className="chart-grid">
|
|
795
|
+
{visibleHistorical.suites.map((suite) => (
|
|
796
|
+
<div key={suite.kind}>
|
|
797
|
+
<h3 style={{ marginTop: 0 }}>{suite.name}</h3>
|
|
798
|
+
<div className="year-table-wrap" style={{ border: 'none' }}>
|
|
799
|
+
<table className="compare-table">
|
|
800
|
+
<thead>
|
|
801
|
+
<tr>
|
|
802
|
+
<th>Window</th>
|
|
803
|
+
<th>Estate</th>
|
|
804
|
+
<th>Depletes</th>
|
|
805
|
+
<th>Shortfall</th>
|
|
806
|
+
</tr>
|
|
807
|
+
</thead>
|
|
808
|
+
<tbody>
|
|
809
|
+
{suite.worstByEndingAfterTaxEstate.map((window) => (
|
|
810
|
+
<tr key={window.label}>
|
|
811
|
+
<td>{window.label}</td>
|
|
812
|
+
<td>{fmtMoneyCompact(window.summary.endingAfterTaxEstate)}</td>
|
|
813
|
+
<td>{window.projection.depletionYear ?? 'never'}</td>
|
|
814
|
+
<td>{fmtMoneyCompact(window.totalShortfall)}</td>
|
|
815
|
+
</tr>
|
|
816
|
+
))}
|
|
817
|
+
</tbody>
|
|
818
|
+
</table>
|
|
819
|
+
</div>
|
|
820
|
+
</div>
|
|
821
|
+
))}
|
|
822
|
+
</div>
|
|
823
|
+
) : (
|
|
824
|
+
<p className="muted">Run the suite to list the worst rolling and reversed historical windows.</p>
|
|
825
|
+
)}
|
|
826
|
+
</div>
|
|
827
|
+
|
|
828
|
+
<div className="chart-card">
|
|
829
|
+
<h2>Range of outcomes</h2>
|
|
830
|
+
<p className="card-hint">Investable assets per year (nominal). Bands: 10–90th and 25–75th percentiles; line: median.</p>
|
|
831
|
+
<div
|
|
832
|
+
className="chart-frame"
|
|
833
|
+
style={frameH(340)}
|
|
834
|
+
role="img"
|
|
835
|
+
aria-label={
|
|
836
|
+
fanRows.length > 0
|
|
837
|
+
? `Fan chart of investable assets by year. In ${fanRows[fanRows.length - 1]!.year}, the median is ${fmtMoneyCompact(fanRows[fanRows.length - 1]!.p50)}, with the 10th to 90th percentile range spanning ${fmtMoneyCompact(fanRows[fanRows.length - 1]!.p10)} to ${fmtMoneyCompact(fanRows[fanRows.length - 1]!.p90)}.`
|
|
838
|
+
: 'Fan chart of investable assets by year.'
|
|
839
|
+
}
|
|
840
|
+
>
|
|
841
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
842
|
+
<ComposedChart data={fanRows} margin={{ left: 12, right: 8, top: 8 }}>
|
|
843
|
+
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
|
844
|
+
<XAxis dataKey="year" tick={{ fill: 'var(--muted)', fontSize: 12 }} />
|
|
845
|
+
<YAxis tickFormatter={fmtMoneyCompact} tick={{ fill: 'var(--muted)', fontSize: 12 }} width={70} />
|
|
846
|
+
<Tooltip formatter={(v: unknown) => fmtMoney(Number(v))} contentStyle={chartTooltipStyle} />
|
|
847
|
+
<Area dataKey="p10" stackId="outer" stroke="none" fill="transparent" name="p10" />
|
|
848
|
+
<Area dataKey={(d: { p10: number; p90: number }) => d.p90 - d.p10} stackId="outer" stroke="none" fill="var(--chart-1)" fillOpacity={0.18} name="10–90%" />
|
|
849
|
+
<Area dataKey="p25" stackId="inner" stroke="none" fill="transparent" name="p25" legendType="none" />
|
|
850
|
+
<Area dataKey={(d: { p25: number; p75: number }) => d.p75 - d.p25} stackId="inner" stroke="none" fill="var(--chart-1)" fillOpacity={0.3} name="25–75%" />
|
|
851
|
+
<Line dataKey="p50" stroke="var(--chart-1)" strokeWidth={2.5} dot={false} name="Median" />
|
|
852
|
+
</ComposedChart>
|
|
853
|
+
</ResponsiveContainer>
|
|
854
|
+
</div>
|
|
855
|
+
</div>
|
|
856
|
+
|
|
857
|
+
<div className="chart-grid">
|
|
858
|
+
<div className="chart-card">
|
|
859
|
+
<h2>Ending balance distribution</h2>
|
|
860
|
+
<div
|
|
861
|
+
className="chart-frame"
|
|
862
|
+
style={frameH(240)}
|
|
863
|
+
role="img"
|
|
864
|
+
aria-label={`Histogram of ending investable balances across ${summary.pathCount.toLocaleString()} simulated paths. Median ending estate ${fmtMoneyCompact(summary.endingAfterTaxEstate.percentiles.p50)}.`}
|
|
865
|
+
>
|
|
866
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
867
|
+
<BarChart data={histRows} margin={{ left: 4, right: 8, top: 8 }}>
|
|
868
|
+
<XAxis dataKey="label" tick={{ fill: 'var(--muted)', fontSize: 10 }} interval="preserveStartEnd" />
|
|
869
|
+
<YAxis tick={{ fill: 'var(--muted)', fontSize: 11 }} width={40} />
|
|
870
|
+
<Tooltip contentStyle={chartTooltipStyle} />
|
|
871
|
+
<Bar dataKey="count" name="Paths" fill="var(--chart-2)" />
|
|
872
|
+
</BarChart>
|
|
873
|
+
</ResponsiveContainer>
|
|
874
|
+
</div>
|
|
875
|
+
</div>
|
|
876
|
+
<div className="chart-card">
|
|
877
|
+
<h2>When depleting plans run out</h2>
|
|
878
|
+
{summary.depletionYearCounts.length === 0 ? (
|
|
879
|
+
<p className="muted">No path depleted — every simulated market carried the plan to the end.</p>
|
|
880
|
+
) : (
|
|
881
|
+
<div
|
|
882
|
+
className="chart-frame"
|
|
883
|
+
style={frameH(240)}
|
|
884
|
+
role="img"
|
|
885
|
+
aria-label={`Histogram of first-depletion years for the ${summary.depletionYearCounts.reduce((a, r) => a + r.count, 0)} paths that ran out of money.`}
|
|
886
|
+
>
|
|
887
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
888
|
+
<BarChart data={summary.depletionYearCounts} margin={{ left: 4, right: 8, top: 8 }}>
|
|
889
|
+
<XAxis dataKey="year" tick={{ fill: 'var(--muted)', fontSize: 11 }} />
|
|
890
|
+
<YAxis tick={{ fill: 'var(--muted)', fontSize: 11 }} width={40} />
|
|
891
|
+
<Tooltip contentStyle={chartTooltipStyle} />
|
|
892
|
+
<Bar dataKey="count" name="Paths" fill="var(--chart-3)" />
|
|
893
|
+
</BarChart>
|
|
894
|
+
</ResponsiveContainer>
|
|
895
|
+
</div>
|
|
896
|
+
)}
|
|
897
|
+
</div>
|
|
898
|
+
</div>
|
|
899
|
+
|
|
900
|
+
</>
|
|
901
|
+
)}
|
|
902
|
+
|
|
903
|
+
<LearnAboutScreen route="/plan/:planId/monte-carlo" />
|
|
904
|
+
</section>
|
|
905
|
+
)
|
|
906
|
+
}
|
|
907
|
+
|