@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,611 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optimize tab (roadmap V8): runs the multi-year withdrawal/Roth optimizer in a
|
|
3
|
+
* Web Worker, then shows the proposed conversion schedule and its effect on the
|
|
4
|
+
* after-tax estate versus the plan's current strategy — measured on the EXACT
|
|
5
|
+
* ledger (the worker's MILP is a linearisation; these headline numbers come from
|
|
6
|
+
* re-running `simulate` with the proposed schedule, per V8 §3.1). The result is
|
|
7
|
+
* advisory: "Apply" installs it as an optimized strategy, "Accept as manual"
|
|
8
|
+
* rewrites it as an editable manual schedule.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
12
|
+
import { Link } from 'react-router-dom'
|
|
13
|
+
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
|
14
|
+
|
|
15
|
+
import type { OptimizedSchedule } from '@retiregolden/engine/strategies/optimizer'
|
|
16
|
+
import { objectivePolicies, type ObjectivePolicyId } from '@retiregolden/engine/decisions'
|
|
17
|
+
import {
|
|
18
|
+
type ExactLedgerRecommendationState,
|
|
19
|
+
type ExactLedgerValidation,
|
|
20
|
+
withOptimizedConversions,
|
|
21
|
+
} from '@retiregolden/engine/projection/optimizePlan'
|
|
22
|
+
import { DEFAULT_PATH_COUNT, runMonteCarlo } from '../mc/pool'
|
|
23
|
+
import type { OptimizeResult } from '../optimize/messages'
|
|
24
|
+
import { runOptimize } from '../optimize/runner'
|
|
25
|
+
import { downloadStandaloneReport } from '../report/downloadReport'
|
|
26
|
+
import { useReportBranding } from '../report/brandingContext'
|
|
27
|
+
import { reportEvidenceFromOptimizeResult } from '../report/reportHtml'
|
|
28
|
+
import { usePlan } from './planContextCore'
|
|
29
|
+
import { WhyRecommendationPanel } from './explainPanels'
|
|
30
|
+
import { CheckboxField, HelpTip, SelectField } from './fields'
|
|
31
|
+
import { LearnAboutScreen } from '../learn/LearnAboutScreen'
|
|
32
|
+
import { fmtMoney, fmtMoneyCompact } from './format'
|
|
33
|
+
import { LEARN } from './learnLinks'
|
|
34
|
+
import { buildOptimizeChartRows, shouldShowRecommendedScheduleBars } from './optimizePageChart'
|
|
35
|
+
import { applyOptimizeRecommendation, claimEstateGain, planWithWinningClaim } from './optimizePageClaim'
|
|
36
|
+
import { currentStartYear, projectPlan, seedFromPlanId } from './useProjection'
|
|
37
|
+
import { chartTooltipStyle } from './chartStyle'
|
|
38
|
+
|
|
39
|
+
function DeltaStat({
|
|
40
|
+
label,
|
|
41
|
+
value,
|
|
42
|
+
tone,
|
|
43
|
+
help,
|
|
44
|
+
}: {
|
|
45
|
+
label: string
|
|
46
|
+
value: string
|
|
47
|
+
tone: 'good' | 'bad' | 'neutral'
|
|
48
|
+
help: string
|
|
49
|
+
}) {
|
|
50
|
+
return (
|
|
51
|
+
<div className="card">
|
|
52
|
+
<span className="field-label-row">
|
|
53
|
+
<span className="field-label">{label}</span>
|
|
54
|
+
<HelpTip text={help} />
|
|
55
|
+
</span>
|
|
56
|
+
<div className={`stat-value stat-value--${tone}`}>{value}</div>
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function recommendationHeading(validation: ExactLedgerValidation): string {
|
|
62
|
+
switch (validation.recommendationState) {
|
|
63
|
+
case 'beneficial':
|
|
64
|
+
return `Up to ${fmtMoney(validation.afterTaxEstateDelta)} more for your heirs.`
|
|
65
|
+
case 'neutral':
|
|
66
|
+
return 'The optimizer matches your current strategy.'
|
|
67
|
+
case 'rejected':
|
|
68
|
+
return 'This lower-tax schedule is not recommended.'
|
|
69
|
+
case 'unexecutable':
|
|
70
|
+
return 'This conversion schedule is mostly theoretical.'
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function recommendationBody(validation: ExactLedgerValidation): string {
|
|
75
|
+
const requested = fmtMoney(validation.requestedConversionTotal)
|
|
76
|
+
const executed = fmtMoney(validation.executedConversionTotal)
|
|
77
|
+
const from = fmtMoneyCompact(validation.baseline.endingAfterTaxEstate)
|
|
78
|
+
const to = fmtMoneyCompact(validation.candidate.endingAfterTaxEstate)
|
|
79
|
+
const taxPhrase =
|
|
80
|
+
validation.lifetimeTaxDelta < 0
|
|
81
|
+
? `lowers lifetime tax by ${fmtMoney(Math.abs(validation.lifetimeTaxDelta))}`
|
|
82
|
+
: validation.lifetimeTaxDelta > 0
|
|
83
|
+
? `raises lifetime tax by ${fmtMoney(validation.lifetimeTaxDelta)}`
|
|
84
|
+
: 'leaves lifetime tax unchanged'
|
|
85
|
+
|
|
86
|
+
switch (validation.recommendationState) {
|
|
87
|
+
case 'beneficial':
|
|
88
|
+
return `Converting ${requested} raises your projected after-tax estate from ${from} to ${to}.`
|
|
89
|
+
case 'neutral':
|
|
90
|
+
return `Converting ${requested} leaves your projected after-tax estate essentially unchanged at ${to}.`
|
|
91
|
+
case 'rejected':
|
|
92
|
+
return `Converting ${requested} ${taxPhrase}, but your projected after-tax estate moves from ${from} to ${to}.`
|
|
93
|
+
case 'unexecutable':
|
|
94
|
+
return `The optimizer proposed converting ${requested}, but only ${executed} could actually be converted — the traditional balance it counted on is not available in the plan years shown.`
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function stateColor(state: ExactLedgerRecommendationState): string {
|
|
99
|
+
if (state === 'beneficial') return 'var(--good)'
|
|
100
|
+
if (state === 'rejected' || state === 'unexecutable') return 'var(--bad)'
|
|
101
|
+
return 'var(--fg)'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatPct(value: number): string {
|
|
105
|
+
return `${Math.round(value * 100)}%`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function totalScheduleConversions(schedule: OptimizedSchedule | null): number {
|
|
109
|
+
return schedule?.conversions.reduce((a, c) => a + c.amount, 0) ?? 0
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Objectives offered on this surface. `max-sustainable-spending` is excluded:
|
|
114
|
+
* conversion candidates never change base spending, so it cannot separate them
|
|
115
|
+
* — that objective lives on the "How much can I spend?" page instead.
|
|
116
|
+
*/
|
|
117
|
+
const OBJECTIVE_CHOICES: ReadonlyArray<{ value: ObjectivePolicyId; label: string }> = [
|
|
118
|
+
'max-after-tax-estate',
|
|
119
|
+
'max-spending-durability',
|
|
120
|
+
'min-lifetime-tax-estate-floor',
|
|
121
|
+
'protect-survivor-liquidity',
|
|
122
|
+
'bridge-durability',
|
|
123
|
+
'max-downside-resilience',
|
|
124
|
+
].map((id) => ({ value: id as ObjectivePolicyId, label: objectivePolicies[id as ObjectivePolicyId].label }))
|
|
125
|
+
|
|
126
|
+
export function OptimizePage() {
|
|
127
|
+
const { plan, update } = usePlan()
|
|
128
|
+
const reportBranding = useReportBranding()
|
|
129
|
+
const startYear = currentStartYear()
|
|
130
|
+
|
|
131
|
+
const [optimizeResult, setOptimizeResult] = useState<OptimizeResult | null>(null)
|
|
132
|
+
const [running, setRunning] = useState(false)
|
|
133
|
+
const [error, setError] = useState<string | null>(null)
|
|
134
|
+
const [mcRate, setMcRate] = useState<number | null>(null)
|
|
135
|
+
const [objectiveId, setObjectiveId] = useState<ObjectivePolicyId>('max-after-tax-estate')
|
|
136
|
+
// Default-off: the claim grid re-runs the full optimizer for every claim
|
|
137
|
+
// combination (up to ~7×), so the user opts into the extra runtime per session.
|
|
138
|
+
const [coOptimizeClaim, setCoOptimizeClaim] = useState(false)
|
|
139
|
+
const runToken = useRef(0)
|
|
140
|
+
|
|
141
|
+
const hasSocialSecurityIncome = plan.incomes.some((s) => s.type === 'socialSecurity')
|
|
142
|
+
const coOptimizeRequested = coOptimizeClaim && hasSocialSecurityIncome
|
|
143
|
+
|
|
144
|
+
// Derived-state-during-render (same pattern as fields.tsx useLocalText): if
|
|
145
|
+
// every SS stream is removed the checkbox unmounts but its state would
|
|
146
|
+
// survive, so re-adding a stream would silently resurrect the expensive
|
|
147
|
+
// joint-grid run. Clear it — the toggle stays a deliberate opt-in.
|
|
148
|
+
const [prevHasSsIncome, setPrevHasSsIncome] = useState(hasSocialSecurityIncome)
|
|
149
|
+
if (prevHasSsIncome !== hasSocialSecurityIncome) {
|
|
150
|
+
setPrevHasSsIncome(hasSocialSecurityIncome)
|
|
151
|
+
if (!hasSocialSecurityIncome) setCoOptimizeClaim(false)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const schedule = optimizeResult?.schedule ?? null
|
|
155
|
+
const postProcessed = optimizeResult?.postProcessed ?? null
|
|
156
|
+
const tournament = optimizeResult?.tournament ?? null
|
|
157
|
+
// The exact-ledger tournament arbitrates the recommendation: when a simple
|
|
158
|
+
// candidate strategy beats the post-processed MILP schedule on the exact
|
|
159
|
+
// after-tax estate, its schedule is what the page shows and applies.
|
|
160
|
+
const candidateWins = tournament?.winnerSource === 'candidate'
|
|
161
|
+
// Nothing evaluated beat the plan's already-installed conversion strategy —
|
|
162
|
+
// the usual state right after applying a winning schedule and re-running.
|
|
163
|
+
// Rendered as a calm "no change recommended" card, not a rejected-schedule
|
|
164
|
+
// diagnostic (the deltas on this page are always vs the current plan, so a
|
|
165
|
+
// fresh solver proposal that loses to the incumbent shows scary negatives).
|
|
166
|
+
const incumbentHolds = tournament?.winnerSource === 'incumbent'
|
|
167
|
+
const candidateReplacedMilp = candidateWins && postProcessed?.recommendationSchedule === 'cleaned'
|
|
168
|
+
const displaySchedule = postProcessed?.cleanedSchedule ?? schedule
|
|
169
|
+
const recommendedConversions = useMemo(
|
|
170
|
+
() =>
|
|
171
|
+
incumbentHolds
|
|
172
|
+
? [] // the plan already holds the best schedule — nothing to apply or Monte-Carlo
|
|
173
|
+
: candidateWins
|
|
174
|
+
? tournament!.winnerConversions
|
|
175
|
+
: (displaySchedule?.conversions ?? []),
|
|
176
|
+
[incumbentHolds, candidateWins, tournament, displaySchedule],
|
|
177
|
+
)
|
|
178
|
+
// Step 5 claim-age co-optimization: when a claim change won, the schedule and
|
|
179
|
+
// every validation delta on this page were computed against the claim-patched
|
|
180
|
+
// plan, so Monte Carlo, the report, and Apply must all start from it.
|
|
181
|
+
const claimAge = optimizeResult?.claimAge ?? null
|
|
182
|
+
const claimChangeRecommended = claimAge?.winningClaimPatch != null
|
|
183
|
+
const planForRecommendation = useMemo(() => planWithWinningClaim(plan, claimAge), [plan, claimAge])
|
|
184
|
+
const optimizedPlan = useMemo(() => {
|
|
185
|
+
if (recommendedConversions.length > 0) return withOptimizedConversions(planForRecommendation, recommendedConversions)
|
|
186
|
+
// A claim change with no conversion change (the incumbent schedule holds
|
|
187
|
+
// under the new claim) is still a recommendation worth pricing and reporting.
|
|
188
|
+
return claimChangeRecommended ? planForRecommendation : null
|
|
189
|
+
}, [planForRecommendation, recommendedConversions, claimChangeRecommended])
|
|
190
|
+
const validation = candidateWins ? (tournament?.winnerValidation ?? null) : (postProcessed?.cleanedValidation ?? null)
|
|
191
|
+
|
|
192
|
+
const run = useCallback(() => {
|
|
193
|
+
const token = ++runToken.current
|
|
194
|
+
setRunning(true)
|
|
195
|
+
setError(null)
|
|
196
|
+
setMcRate(null)
|
|
197
|
+
runOptimize({ plan, startYear, objectivePolicyId: objectiveId, coOptimizeClaimAge: coOptimizeRequested })
|
|
198
|
+
.then((s) => {
|
|
199
|
+
if (token === runToken.current) setOptimizeResult(s)
|
|
200
|
+
})
|
|
201
|
+
.catch((e: unknown) => {
|
|
202
|
+
if (token === runToken.current) {
|
|
203
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
204
|
+
// Drop any prior result so a stale chart/Apply can't render against
|
|
205
|
+
// inputs the optimizer just failed on.
|
|
206
|
+
setOptimizeResult(null)
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
.finally(() => {
|
|
210
|
+
if (token === runToken.current) setRunning(false)
|
|
211
|
+
})
|
|
212
|
+
}, [plan, startYear, objectiveId, coOptimizeRequested])
|
|
213
|
+
|
|
214
|
+
// Auto-run on plan / rate change (debounced).
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
const t = window.setTimeout(run, 300)
|
|
217
|
+
return () => window.clearTimeout(t)
|
|
218
|
+
}, [run])
|
|
219
|
+
|
|
220
|
+
// Auto Monte Carlo success-% for the proposed schedule (V8 §1.6).
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
if (!optimizedPlan) return
|
|
223
|
+
let cancelled = false
|
|
224
|
+
void runMonteCarlo(optimizedPlan, {
|
|
225
|
+
startYear,
|
|
226
|
+
pathCount: DEFAULT_PATH_COUNT,
|
|
227
|
+
seed: seedFromPlanId(plan.id),
|
|
228
|
+
model: { type: 'lognormal', inflationMeanPct: plan.assumptions.inflationPct, returnVolPct: 12 },
|
|
229
|
+
}).then((s) => {
|
|
230
|
+
if (!cancelled) setMcRate(s.successRate)
|
|
231
|
+
})
|
|
232
|
+
return () => {
|
|
233
|
+
cancelled = true
|
|
234
|
+
}
|
|
235
|
+
}, [optimizedPlan, startYear, plan.id, plan.assumptions.inflationPct])
|
|
236
|
+
|
|
237
|
+
const estateDelta = validation?.afterTaxEstateDelta ?? 0
|
|
238
|
+
const taxDelta = validation?.lifetimeTaxDelta ?? 0
|
|
239
|
+
const totalConversions = recommendedConversions.reduce((sum, c) => sum + c.amount, 0)
|
|
240
|
+
const rawConversions = totalScheduleConversions(schedule)
|
|
241
|
+
const executedConversions = validation?.executedConversionTotal ?? 0
|
|
242
|
+
const hasPostProcessingAdjustments = (postProcessed?.adjustments.length ?? 0) > 0
|
|
243
|
+
const hasExecutionMismatch =
|
|
244
|
+
!candidateWins &&
|
|
245
|
+
(hasPostProcessingAdjustments ||
|
|
246
|
+
(validation?.firstMateriallyUnexecutedYear !== null && validation?.firstMateriallyUnexecutedYear !== undefined))
|
|
247
|
+
const showRecommendedBars = shouldShowRecommendedScheduleBars(candidateWins, hasExecutionMismatch)
|
|
248
|
+
const recommendationState = validation?.recommendationState ?? 'neutral'
|
|
249
|
+
const blocksApply = candidateWins
|
|
250
|
+
? false
|
|
251
|
+
: recommendationState === 'rejected' ||
|
|
252
|
+
recommendationState === 'unexecutable' ||
|
|
253
|
+
postProcessed?.recommendationSchedule === 'none'
|
|
254
|
+
// A claim change can win on estate alone while the conversion side is
|
|
255
|
+
// unappliable (incumbent holds, empty/infeasible schedule, or diagnostic-only
|
|
256
|
+
// result). The claim card then carries its own apply control so the winning
|
|
257
|
+
// claim change is never advertised without a way to install it.
|
|
258
|
+
const scheduleApplyAvailable = recommendedConversions.length > 0 && !blocksApply
|
|
259
|
+
|
|
260
|
+
const chartRows = useMemo(
|
|
261
|
+
() => buildOptimizeChartRows({ schedule, recommendedConversions, postProcessed, candidateWins }),
|
|
262
|
+
[schedule, recommendedConversions, postProcessed, candidateWins],
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
const apply = (mode: 'optimized' | 'manual') => {
|
|
266
|
+
if (recommendedConversions.length === 0 || blocksApply) return
|
|
267
|
+
// Claim change and schedule install together: the schedule was computed
|
|
268
|
+
// against the claim-patched plan, so conversions alone would be wrong.
|
|
269
|
+
update((d) => applyOptimizeRecommendation(d, { claimAge, conversions: recommendedConversions, mode }))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// The incumbent-holds path has no schedule to install, but a winning claim
|
|
273
|
+
// change is still an actionable recommendation on its own (the current
|
|
274
|
+
// conversion strategy already holds under the new claim ages).
|
|
275
|
+
const applyClaimChangeOnly = () => {
|
|
276
|
+
if (!claimChangeRecommended) return
|
|
277
|
+
update((d) => applyOptimizeRecommendation(d, { claimAge, conversions: [], mode: 'optimized' }))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const rerunButton = (label = 'Re-run optimizer') => (
|
|
281
|
+
<button type="button" className="btn btn-secondary btn-small" disabled={running} onClick={run}>
|
|
282
|
+
{running ? 'Optimizing...' : label}
|
|
283
|
+
</button>
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
const downloadRecommendationReport = () => {
|
|
287
|
+
if (!optimizeResult) return
|
|
288
|
+
// Report the plan the evidence section describes: when the optimizer recommends
|
|
289
|
+
// a schedule, project that recommended plan so the headline results and ledger
|
|
290
|
+
// appendix match the recommendation. When nothing beats the incumbent (no
|
|
291
|
+
// optimizedPlan), the recommendation is "no change", so the current plan is correct.
|
|
292
|
+
const reportPlan = optimizedPlan ?? plan
|
|
293
|
+
const view = projectPlan(reportPlan, startYear)
|
|
294
|
+
downloadStandaloneReport({
|
|
295
|
+
plan: reportPlan,
|
|
296
|
+
result: view.result,
|
|
297
|
+
summary: view.summary,
|
|
298
|
+
startYear,
|
|
299
|
+
recommendationEvidence: reportEvidenceFromOptimizeResult(optimizeResult),
|
|
300
|
+
branding: reportBranding,
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return (
|
|
305
|
+
<section>
|
|
306
|
+
<div className="card">
|
|
307
|
+
<h2>Roth & Tax Optimizer</h2>
|
|
308
|
+
<p className="card-hint">
|
|
309
|
+
Search for a Roth-conversion & withdrawal schedule, then rank the candidates by the objective you choose.
|
|
310
|
+
The default leaves the most after-tax wealth to your heirs; other modes can favor spending durability,
|
|
311
|
+
lifetime tax with an estate floor, survivor liquidity, or bridge-year resilience. RetireGolden generates
|
|
312
|
+
candidate schedules (a multi-year math program over federal brackets, IRMAA thresholds, and RMDs, plus
|
|
313
|
+
simple bracket-fill and cliff-cap strategies), compares every candidate on your full year-by-year
|
|
314
|
+
projection, and recommends the best result it found within the search limits.
|
|
315
|
+
</p>
|
|
316
|
+
<p className="field-hint">
|
|
317
|
+
Using the {formatPct(plan.assumptions.heirTaxRatePct / 100)} heir tax rate from{' '}
|
|
318
|
+
<Link to={`/plan/${plan.id}/assumptions`}>Assumptions</Link>.
|
|
319
|
+
</p>
|
|
320
|
+
<div className="form-grid" style={{ marginTop: '0.5rem' }}>
|
|
321
|
+
<SelectField
|
|
322
|
+
label="Optimize for"
|
|
323
|
+
help="What 'better' means when candidate schedules are ranked on your full year-by-year projection. The default maximizes the after-tax estate; other objectives re-rank the same evaluations — money lasting longer, lowest lifetime tax without breaking the estate floor, the worst-case balance in survivor years, or the worst-case balance across pre-Social-Security bridge years. Every objective still hard-rejects candidates that shorten how long the money lasts."
|
|
324
|
+
hint={objectivePolicies[objectiveId].description}
|
|
325
|
+
learn={LEARN.optimizerObjectives}
|
|
326
|
+
value={objectiveId}
|
|
327
|
+
options={OBJECTIVE_CHOICES}
|
|
328
|
+
onCommit={setObjectiveId}
|
|
329
|
+
/>
|
|
330
|
+
{hasSocialSecurityIncome ? (
|
|
331
|
+
<CheckboxField
|
|
332
|
+
label="Also optimize Social Security claim age"
|
|
333
|
+
help="Re-runs the full conversion optimizer at each canonical claim age (62 / full retirement age / 70, for up to two Social Security streams) and recommends the claim-age + conversion pair with the best projected after-tax estate. A claim change must beat the current-claim optimum by a clear margin to be recommended."
|
|
334
|
+
hint="Re-runs the full optimizer once per claim combination — expect several times longer than a standard run."
|
|
335
|
+
value={coOptimizeClaim}
|
|
336
|
+
onCommit={setCoOptimizeClaim}
|
|
337
|
+
/>
|
|
338
|
+
) : null}
|
|
339
|
+
</div>
|
|
340
|
+
{running ? (
|
|
341
|
+
<>
|
|
342
|
+
<div className="skeleton" style={{ height: '2rem', marginTop: '0.75rem' }} aria-label="Optimizing" />
|
|
343
|
+
{coOptimizeRequested ? (
|
|
344
|
+
<p className="field-hint" style={{ margin: '0.5rem 0 0' }} role="status">
|
|
345
|
+
Also optimizing Social Security claim age: the optimizer is re-run in full for every claim
|
|
346
|
+
combination, so this takes several times longer than a standard optimize.
|
|
347
|
+
</p>
|
|
348
|
+
) : null}
|
|
349
|
+
</>
|
|
350
|
+
) : null}
|
|
351
|
+
{error ? <p style={{ color: 'var(--bad)' }}>Optimizer error: {error}</p> : null}
|
|
352
|
+
<div style={{ marginTop: '0.75rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
|
353
|
+
{!schedule && !running ? rerunButton(error ? 'Try again' : 'Run optimizer') : null}
|
|
354
|
+
{/* Also disabled while re-running: the held result (and any claim
|
|
355
|
+
patch in it) describes the pre-edit plan, so a report downloaded
|
|
356
|
+
mid-run would mix live plan fields with stale recommendations. */}
|
|
357
|
+
<button
|
|
358
|
+
type="button"
|
|
359
|
+
className="btn btn-secondary btn-small"
|
|
360
|
+
disabled={!optimizeResult || running}
|
|
361
|
+
onClick={downloadRecommendationReport}
|
|
362
|
+
>
|
|
363
|
+
Download recommendation report
|
|
364
|
+
</button>
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
|
|
368
|
+
{schedule && !running && claimAge?.enabled ? (
|
|
369
|
+
claimChangeRecommended ? (
|
|
370
|
+
<div className="card" style={{ borderColor: 'var(--good)' }}>
|
|
371
|
+
<h2 style={{ margin: '0 0 0.35rem', color: 'var(--good)' }}>
|
|
372
|
+
Recommended claim change: {claimAge.winningClaimLabel}
|
|
373
|
+
</h2>
|
|
374
|
+
<p className="muted" style={{ margin: 0 }}>
|
|
375
|
+
Changing the claim age and re-optimizing conversions is worth{' '}
|
|
376
|
+
<strong>{fmtMoney(claimEstateGain(claimAge))}</strong> more projected after-tax estate than the best
|
|
377
|
+
result at your current claim ages ({fmtMoneyCompact(claimAge.currentClaimExactEstate)} →{' '}
|
|
378
|
+
{fmtMoneyCompact(claimAge.jointExactEstate)}), across {claimAge.combinationsEvaluated} claim combinations
|
|
379
|
+
each fully re-optimized.
|
|
380
|
+
</p>
|
|
381
|
+
<p className="field-hint" style={{ margin: '0.6rem 0 0' }}>
|
|
382
|
+
{incumbentHolds
|
|
383
|
+
? 'Your current conversion strategy already holds under the new claim age, so applying changes only the Social Security claim age.'
|
|
384
|
+
: scheduleApplyAvailable
|
|
385
|
+
? 'Everything below — the schedule, the estate and tax deltas, and the success rate — was computed assuming this claim change. Apply installs the new claim age and the conversion schedule together; the schedule alone would not be correct for your current claim ages.'
|
|
386
|
+
: recommendedConversions.length === 0
|
|
387
|
+
? 'No conversion change comes with this recommendation, so the button here changes just the Social Security claim age — the estate gain above comes from the claim change itself.'
|
|
388
|
+
: 'The conversion schedule from this run is diagnostic-only and cannot be applied, so the button here changes just the Social Security claim age. The estate gain above was measured with that schedule included, so the claim change alone may capture only part of it.'}
|
|
389
|
+
</p>
|
|
390
|
+
{/* Claim-only recommendations show their success rate here (the
|
|
391
|
+
stats row below never renders for them); when a schedule comes
|
|
392
|
+
along, the normal stats row already shows the rate computed
|
|
393
|
+
against the same claim-patched plan via optimizedPlan. */}
|
|
394
|
+
{recommendedConversions.length === 0 && mcRate !== null ? (
|
|
395
|
+
<p className="field-hint" style={{ margin: '0.45rem 0 0' }}>
|
|
396
|
+
Monte Carlo success rate with this claim change: {Math.round(mcRate * 100)}%.
|
|
397
|
+
</p>
|
|
398
|
+
) : null}
|
|
399
|
+
{!scheduleApplyAvailable ? (
|
|
400
|
+
<div style={{ marginTop: '0.75rem' }}>
|
|
401
|
+
<button type="button" className="btn btn-primary btn-small" onClick={applyClaimChangeOnly}>
|
|
402
|
+
Apply claim change
|
|
403
|
+
</button>
|
|
404
|
+
</div>
|
|
405
|
+
) : null}
|
|
406
|
+
</div>
|
|
407
|
+
) : (
|
|
408
|
+
<div className="card">
|
|
409
|
+
<p className="field-hint" style={{ margin: 0 }}>
|
|
410
|
+
Social Security claim age co-optimized: {claimAge.combinationsEvaluated} claim combinations were each
|
|
411
|
+
fully re-optimized; none beat your current claim ages by a meaningful margin, so the recommendation
|
|
412
|
+
below keeps them.
|
|
413
|
+
</p>
|
|
414
|
+
</div>
|
|
415
|
+
)
|
|
416
|
+
) : null}
|
|
417
|
+
|
|
418
|
+
{schedule && !running ? (
|
|
419
|
+
incumbentHolds && tournament ? (
|
|
420
|
+
<div className="card">
|
|
421
|
+
<h2 style={{ margin: '0 0 0.35rem', color: 'var(--good)' }}>
|
|
422
|
+
No change recommended — {tournament.winnerLabel} is still the best result found.
|
|
423
|
+
</h2>
|
|
424
|
+
<p className="muted" style={{ margin: 0 }}>
|
|
425
|
+
RetireGolden compared {tournament.candidates.length} simple candidate strategies and a fresh solver
|
|
426
|
+
schedule against your current plan on your full year-by-year projection; none improved it. Your current schedule (
|
|
427
|
+
{fmtMoney(tournament.winnerConversions.reduce((sum, c) => sum + c.amount, 0))} of conversions across{' '}
|
|
428
|
+
{tournament.winnerConversions.length} year{tournament.winnerConversions.length === 1 ? '' : 's'}) stays
|
|
429
|
+
in place{claimChangeRecommended ? ' — only the claim change above is left to apply.' : ' — nothing to apply.'}
|
|
430
|
+
</p>
|
|
431
|
+
{postProcessed?.cleanedValidation ? (
|
|
432
|
+
<p className="field-hint" style={{ margin: '0.6rem 0 0' }}>
|
|
433
|
+
Diagnostic: the solver's latest cleaned schedule would move the projected after-tax estate by{' '}
|
|
434
|
+
{fmtMoney(postProcessed.cleanedValidation.afterTaxEstateDelta)} versus your current plan, so it is not
|
|
435
|
+
offered as a recommendation. To explore alternatives from scratch, remove or edit the conversion
|
|
436
|
+
schedule under Strategy and re-run.
|
|
437
|
+
</p>
|
|
438
|
+
) : (
|
|
439
|
+
<p className="field-hint" style={{ margin: '0.6rem 0 0' }}>
|
|
440
|
+
To explore alternatives from scratch, remove or edit the conversion schedule under Strategy and re-run.
|
|
441
|
+
</p>
|
|
442
|
+
)}
|
|
443
|
+
<div style={{ marginTop: '0.75rem' }}>{rerunButton()}</div>
|
|
444
|
+
</div>
|
|
445
|
+
) : schedule.status === 'infeasible' && !candidateWins ? (
|
|
446
|
+
<div className="card">
|
|
447
|
+
<h2>Couldn't optimize this plan</h2>
|
|
448
|
+
<p className="muted">
|
|
449
|
+
The optimizer couldn't find a feasible schedule — usually because the plan runs out of money before the end
|
|
450
|
+
(spending exceeds what the portfolio can cover), so there's no conversion strategy to weigh. Resolve the
|
|
451
|
+
shortfall in Results or Monte Carlo, then try again.
|
|
452
|
+
</p>
|
|
453
|
+
<div style={{ marginTop: '0.75rem' }}>{rerunButton()}</div>
|
|
454
|
+
</div>
|
|
455
|
+
) : rawConversions < 1 && !candidateWins ? (
|
|
456
|
+
<div className="card">
|
|
457
|
+
<h2>No beneficial conversions found</h2>
|
|
458
|
+
<p className="muted">
|
|
459
|
+
For this plan the optimizer didn't find conversions that improve the after-tax estate (often because there
|
|
460
|
+
is little pre-tax balance to convert, or the current strategy already captures the opportunity).
|
|
461
|
+
</p>
|
|
462
|
+
<div style={{ marginTop: '0.75rem' }}>{rerunButton()}</div>
|
|
463
|
+
</div>
|
|
464
|
+
) : (
|
|
465
|
+
<>
|
|
466
|
+
<div className="mc-hero">
|
|
467
|
+
<div>
|
|
468
|
+
<h2 style={{ margin: '0 0 0.35rem', color: stateColor(recommendationState) }}>
|
|
469
|
+
{validation ? recommendationHeading(validation) : 'The optimizer matches your current strategy.'}
|
|
470
|
+
</h2>
|
|
471
|
+
<p className="muted" style={{ margin: 0 }}>
|
|
472
|
+
{validation
|
|
473
|
+
? recommendationBody(validation)
|
|
474
|
+
: `${fmtMoney(totalConversions)} of conversions across ${recommendedConversions.length} year(s).`}
|
|
475
|
+
</p>
|
|
476
|
+
{candidateWins && tournament ? (
|
|
477
|
+
<p className="field-hint" style={{ margin: '0.45rem 0 0' }}>
|
|
478
|
+
{candidateReplacedMilp ? (
|
|
479
|
+
<>
|
|
480
|
+
A simple strategy wins on your projection: <strong>{tournament.winnerLabel}</strong> beats the
|
|
481
|
+
solver's schedule by {fmtMoney(tournament.marginOverMilpDollars)} of after-tax estate. The
|
|
482
|
+
schedule shown and applied below follows it; the "Requested" bars show what the solver had
|
|
483
|
+
proposed.
|
|
484
|
+
</>
|
|
485
|
+
) : (
|
|
486
|
+
<>
|
|
487
|
+
A simple strategy wins on your projection: <strong>{tournament.winnerLabel}</strong>. The
|
|
488
|
+
schedule shown and applied below follows it.
|
|
489
|
+
</>
|
|
490
|
+
)}
|
|
491
|
+
{tournament.searchRefined
|
|
492
|
+
? ' A bounded search on your full projection then fine-tuned the winning schedule’s per-year amounts.'
|
|
493
|
+
: ''}
|
|
494
|
+
</p>
|
|
495
|
+
) : null}
|
|
496
|
+
{tournament && tournament.policyId !== 'max-after-tax-estate' ? (
|
|
497
|
+
<p className="field-hint" style={{ margin: '0.45rem 0 0' }}>
|
|
498
|
+
Candidates ranked by <strong>{objectivePolicies[tournament.policyId].label}</strong> — the estate
|
|
499
|
+
and tax deltas below are context, not the ranking metric.
|
|
500
|
+
</p>
|
|
501
|
+
) : null}
|
|
502
|
+
{hasExecutionMismatch && validation ? (
|
|
503
|
+
<p className="field-hint" style={{ margin: '0.45rem 0 0' }}>
|
|
504
|
+
Raw optimizer request: {fmtMoney(rawConversions)}. Cleaned executable schedule:{' '}
|
|
505
|
+
{fmtMoney(totalConversions)}. Executed after cleaning: {fmtMoney(executedConversions)} (
|
|
506
|
+
{formatPct(validation.executedConversionRatio)}).
|
|
507
|
+
{postProcessed?.rawValidation.firstMateriallyUnexecutedYear
|
|
508
|
+
? ` First raw shortfall: ${postProcessed.rawValidation.firstMateriallyUnexecutedYear}.`
|
|
509
|
+
: ''}
|
|
510
|
+
{postProcessed?.adjustments.some((a) => a.reason === 'estate-pruned')
|
|
511
|
+
? ' Later conversion years that lowered the projected after-tax estate were pruned.'
|
|
512
|
+
: ''}
|
|
513
|
+
</p>
|
|
514
|
+
) : null}
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
|
|
518
|
+
<div className="stat-grid">
|
|
519
|
+
<DeltaStat
|
|
520
|
+
label="After-tax estate"
|
|
521
|
+
value={`${estateDelta >= 0 ? '+' : ''}${fmtMoney(estateDelta)}`}
|
|
522
|
+
tone={estateDelta > 0 ? 'good' : estateDelta < 0 ? 'bad' : 'neutral'}
|
|
523
|
+
help="Change in ending net worth, net of the heir tax on inherited pre-tax balances, versus your current strategy. Measured on your full year-by-year projection, not the optimizer's simplified model."
|
|
524
|
+
/>
|
|
525
|
+
<DeltaStat
|
|
526
|
+
label="Lifetime tax"
|
|
527
|
+
value={`${taxDelta <= 0 ? '' : '+'}${fmtMoney(taxDelta)}`}
|
|
528
|
+
tone={taxDelta < 0 ? 'good' : taxDelta > 0 ? 'bad' : 'neutral'}
|
|
529
|
+
help="Change in total taxes and penalties paid over the whole plan. Conversions usually raise lifetime tax (you pay sooner) even as they raise the after-tax estate — the estate figure is the goal, this is the cost."
|
|
530
|
+
/>
|
|
531
|
+
<DeltaStat
|
|
532
|
+
label="Success rate"
|
|
533
|
+
value={mcRate === null ? '…' : `${Math.round(mcRate * 100)}%`}
|
|
534
|
+
tone={mcRate !== null && mcRate >= 0.9 ? 'good' : 'neutral'}
|
|
535
|
+
help="Monte Carlo success probability for the proposed schedule (1,000 paths, lognormal markets), so you can confirm the conversions don't materially raise the risk of running out."
|
|
536
|
+
/>
|
|
537
|
+
</div>
|
|
538
|
+
|
|
539
|
+
<div className="chart-card">
|
|
540
|
+
<h2>Proposed conversions by year</h2>
|
|
541
|
+
<p className="card-hint">
|
|
542
|
+
{candidateWins
|
|
543
|
+
? candidateReplacedMilp
|
|
544
|
+
? 'Raw optimizer request, winning candidate schedule, and what your projection actually executed (nominal dollars).'
|
|
545
|
+
: 'Winning candidate schedule and what your projection actually executed (nominal dollars).'
|
|
546
|
+
: 'Raw optimizer requests, cleaned schedule, and what your projection actually executed (nominal dollars).'}
|
|
547
|
+
</p>
|
|
548
|
+
<div style={{ width: '100%', height: 280 }}>
|
|
549
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
550
|
+
<BarChart data={chartRows} margin={{ left: 12, right: 8, top: 8 }}>
|
|
551
|
+
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
|
552
|
+
<XAxis dataKey="year" tick={{ fill: 'var(--muted)', fontSize: 12 }} />
|
|
553
|
+
<YAxis tickFormatter={fmtMoneyCompact} tick={{ fill: 'var(--muted)', fontSize: 12 }} width={70} />
|
|
554
|
+
<Tooltip formatter={(v: unknown) => fmtMoney(Number(v))} contentStyle={chartTooltipStyle} />
|
|
555
|
+
<Bar dataKey="requested" name="Requested" fill="var(--chart-1)" />
|
|
556
|
+
{showRecommendedBars ? (
|
|
557
|
+
<Bar
|
|
558
|
+
dataKey="cleaned"
|
|
559
|
+
name={candidateWins ? 'Recommended schedule' : 'Cleaned schedule'}
|
|
560
|
+
fill="var(--chart-2)"
|
|
561
|
+
/>
|
|
562
|
+
) : null}
|
|
563
|
+
{showRecommendedBars ? (
|
|
564
|
+
<Bar
|
|
565
|
+
dataKey="executed"
|
|
566
|
+
name={candidateWins ? 'Executed recommendation' : 'Executed after cleaning'}
|
|
567
|
+
fill="var(--chart-3)"
|
|
568
|
+
/>
|
|
569
|
+
) : null}
|
|
570
|
+
</BarChart>
|
|
571
|
+
</ResponsiveContainer>
|
|
572
|
+
</div>
|
|
573
|
+
<p className="field-hint">
|
|
574
|
+
Optimizer status: {schedule.status} · solved in {schedule.solveMs.toFixed(0)} ms. The optimizer reasons
|
|
575
|
+
over a simplified plan; the headline figures above come from re-running your full projection with the cleaned schedule.
|
|
576
|
+
</p>
|
|
577
|
+
</div>
|
|
578
|
+
|
|
579
|
+
<div className="card">
|
|
580
|
+
<h2>Use this schedule</h2>
|
|
581
|
+
<p className="card-hint">
|
|
582
|
+
{blocksApply
|
|
583
|
+
? 'This result is shown as a diagnostic; it cannot be applied to your plan as a recommended schedule.'
|
|
584
|
+
: `Apply keeps it labeled as optimizer output; Accept as manual copies the same amounts into an editable manual conversion schedule you can adjust under Strategy.${
|
|
585
|
+
claimChangeRecommended
|
|
586
|
+
? ' Both buttons also install the recommended Social Security claim change — the schedule was computed assuming it.'
|
|
587
|
+
: ''
|
|
588
|
+
}`}
|
|
589
|
+
</p>
|
|
590
|
+
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
|
591
|
+
<button type="button" className="btn btn-primary btn-small" disabled={blocksApply} onClick={() => apply('optimized')}>
|
|
592
|
+
Apply optimized schedule
|
|
593
|
+
</button>
|
|
594
|
+
<button type="button" className="btn btn-secondary btn-small" disabled={blocksApply} onClick={() => apply('manual')}>
|
|
595
|
+
Accept as manual
|
|
596
|
+
</button>
|
|
597
|
+
{rerunButton()}
|
|
598
|
+
</div>
|
|
599
|
+
</div>
|
|
600
|
+
</>
|
|
601
|
+
)
|
|
602
|
+
) : null}
|
|
603
|
+
|
|
604
|
+
{tournament && schedule && !running ? (
|
|
605
|
+
<WhyRecommendationPanel tournament={tournament} objectiveLabel={objectivePolicies[tournament.policyId].label} />
|
|
606
|
+
) : null}
|
|
607
|
+
|
|
608
|
+
<LearnAboutScreen route="/plan/:planId/optimize" />
|
|
609
|
+
</section>
|
|
610
|
+
)
|
|
611
|
+
}
|