@retiregolden/engine 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 +79 -0
- package/dist/allocation/assetClasses.d.ts +94 -0
- package/dist/allocation/assetClasses.js +215 -0
- package/dist/decisions/annuitization.d.ts +72 -0
- package/dist/decisions/annuitization.js +184 -0
- package/dist/decisions/decisionFixtures.d.ts +48 -0
- package/dist/decisions/decisionFixtures.js +253 -0
- package/dist/decisions/evaluateCandidate.d.ts +61 -0
- package/dist/decisions/evaluateCandidate.js +193 -0
- package/dist/decisions/generators.d.ts +110 -0
- package/dist/decisions/generators.js +667 -0
- package/dist/decisions/index.d.ts +17 -0
- package/dist/decisions/index.js +17 -0
- package/dist/decisions/insightsAdapter.d.ts +27 -0
- package/dist/decisions/insightsAdapter.js +51 -0
- package/dist/decisions/objectives.d.ts +96 -0
- package/dist/decisions/objectives.js +319 -0
- package/dist/decisions/pensionElection.d.ts +112 -0
- package/dist/decisions/pensionElection.js +239 -0
- package/dist/decisions/search.d.ts +51 -0
- package/dist/decisions/search.js +110 -0
- package/dist/decisions/spendingSolver.d.ts +70 -0
- package/dist/decisions/spendingSolver.js +180 -0
- package/dist/decisions/spiaQuotes.d.ts +21 -0
- package/dist/decisions/spiaQuotes.js +43 -0
- package/dist/decisions/stochastic.d.ts +11 -0
- package/dist/decisions/stochastic.js +66 -0
- package/dist/decisions/swrComparator.d.ts +54 -0
- package/dist/decisions/swrComparator.js +89 -0
- package/dist/decisions/tournament.d.ts +53 -0
- package/dist/decisions/tournament.js +104 -0
- package/dist/decisions/types.d.ts +125 -0
- package/dist/decisions/types.js +12 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/insights/detectors/annuitizationHeadroom.d.ts +11 -0
- package/dist/insights/detectors/annuitizationHeadroom.js +77 -0
- package/dist/insights/detectors/assetLocation.d.ts +9 -0
- package/dist/insights/detectors/assetLocation.js +86 -0
- package/dist/insights/detectors/hecmBufferCandidate.d.ts +12 -0
- package/dist/insights/detectors/hecmBufferCandidate.js +76 -0
- package/dist/insights/detectors/incomeFloorFunded.d.ts +8 -0
- package/dist/insights/detectors/incomeFloorFunded.js +52 -0
- package/dist/insights/detectors/irmaaTierEdge.d.ts +2 -0
- package/dist/insights/detectors/irmaaTierEdge.js +102 -0
- package/dist/insights/detectors/pensionElectionPending.d.ts +11 -0
- package/dist/insights/detectors/pensionElectionPending.js +62 -0
- package/dist/insights/detectors/qcdEfficiency.d.ts +2 -0
- package/dist/insights/detectors/qcdEfficiency.js +62 -0
- package/dist/insights/detectors/rothBridgeHeadroom.d.ts +2 -0
- package/dist/insights/detectors/rothBridgeHeadroom.js +77 -0
- package/dist/insights/detectors/spendingGuardrails.d.ts +2 -0
- package/dist/insights/detectors/spendingGuardrails.js +77 -0
- package/dist/insights/detectors/spendingHeadroom.d.ts +12 -0
- package/dist/insights/detectors/spendingHeadroom.js +92 -0
- package/dist/insights/detectors/ssBridgeGap.d.ts +9 -0
- package/dist/insights/detectors/ssBridgeGap.js +92 -0
- package/dist/insights/detectors/stateRelocation.d.ts +12 -0
- package/dist/insights/detectors/stateRelocation.js +99 -0
- package/dist/insights/detectors/widowsPenalty.d.ts +12 -0
- package/dist/insights/detectors/widowsPenalty.js +145 -0
- package/dist/insights/registry.d.ts +4 -0
- package/dist/insights/registry.js +62 -0
- package/dist/insights/runInsights.d.ts +6 -0
- package/dist/insights/runInsights.js +15 -0
- package/dist/insights/types.d.ts +70 -0
- package/dist/insights/types.js +1 -0
- package/dist/ladder/bridge.d.ts +72 -0
- package/dist/ladder/bridge.js +59 -0
- package/dist/ladder/fedInvest.d.ts +55 -0
- package/dist/ladder/fedInvest.js +77 -0
- package/dist/ladder/fundedRatio.d.ts +41 -0
- package/dist/ladder/fundedRatio.js +46 -0
- package/dist/ladder/ladderMath.d.ts +88 -0
- package/dist/ladder/ladderMath.js +140 -0
- package/dist/longevity/ssaPeriod2022.d.ts +10 -0
- package/dist/longevity/ssaPeriod2022.js +51 -0
- package/dist/longevity/types.d.ts +39 -0
- package/dist/longevity/types.js +1 -0
- package/dist/model/migrations.d.ts +20 -0
- package/dist/model/migrations.js +67 -0
- package/dist/model/plan.d.ts +3108 -0
- package/dist/model/plan.js +1514 -0
- package/dist/montecarlo/frontiers.d.ts +22 -0
- package/dist/montecarlo/frontiers.js +61 -0
- package/dist/montecarlo/historicalReturns.d.ts +27 -0
- package/dist/montecarlo/historicalReturns.js +124 -0
- package/dist/montecarlo/historicalSuites.d.ts +45 -0
- package/dist/montecarlo/historicalSuites.js +102 -0
- package/dist/montecarlo/ltcShock.d.ts +42 -0
- package/dist/montecarlo/ltcShock.js +62 -0
- package/dist/montecarlo/marketModels.d.ts +248 -0
- package/dist/montecarlo/marketModels.js +672 -0
- package/dist/montecarlo/mortality.d.ts +31 -0
- package/dist/montecarlo/mortality.js +61 -0
- package/dist/montecarlo/riskBasedGuardrails.d.ts +93 -0
- package/dist/montecarlo/riskBasedGuardrails.js +200 -0
- package/dist/montecarlo/rng.d.ts +18 -0
- package/dist/montecarlo/rng.js +44 -0
- package/dist/montecarlo/run.d.ts +210 -0
- package/dist/montecarlo/run.js +353 -0
- package/dist/montecarlo/sharedPaths.d.ts +43 -0
- package/dist/montecarlo/sharedPaths.js +28 -0
- package/dist/montecarlo/survival.d.ts +49 -0
- package/dist/montecarlo/survival.js +126 -0
- package/dist/params/data/realYieldCurve2026.d.ts +17 -0
- package/dist/params/data/realYieldCurve2026.js +26 -0
- package/dist/params/data/year2026.d.ts +14 -0
- package/dist/params/data/year2026.js +199 -0
- package/dist/params/index.d.ts +63 -0
- package/dist/params/index.js +134 -0
- package/dist/params/provenance.d.ts +29 -0
- package/dist/params/provenance.js +123 -0
- package/dist/params/state/data/year2026.d.ts +18 -0
- package/dist/params/state/data/year2026.js +581 -0
- package/dist/params/state/index.d.ts +13 -0
- package/dist/params/state/index.js +26 -0
- package/dist/params/state/types.d.ts +73 -0
- package/dist/params/state/types.js +10 -0
- package/dist/params/types.d.ts +201 -0
- package/dist/params/types.js +7 -0
- package/dist/projection/annuityForms.d.ts +62 -0
- package/dist/projection/annuityForms.js +87 -0
- package/dist/projection/compare.d.ts +96 -0
- package/dist/projection/compare.js +216 -0
- package/dist/projection/flatTax.d.ts +7 -0
- package/dist/projection/flatTax.js +14 -0
- package/dist/projection/optimizePlan.d.ts +328 -0
- package/dist/projection/optimizePlan.js +1039 -0
- package/dist/projection/relocation.d.ts +149 -0
- package/dist/projection/relocation.js +317 -0
- package/dist/projection/simulate.d.ts +61 -0
- package/dist/projection/simulate.js +2754 -0
- package/dist/projection/types.d.ts +381 -0
- package/dist/projection/types.js +9 -0
- package/dist/rmd/jointLifeTable.d.ts +8 -0
- package/dist/rmd/jointLifeTable.js +663 -0
- package/dist/rmd/rmd.d.ts +29 -0
- package/dist/rmd/rmd.js +34 -0
- package/dist/scenarios/scenarios.d.ts +70 -0
- package/dist/scenarios/scenarios.js +130 -0
- package/dist/socialSecurity/benefitFactor.d.ts +13 -0
- package/dist/socialSecurity/benefitFactor.js +40 -0
- package/dist/socialSecurity/claimFactor.d.ts +33 -0
- package/dist/socialSecurity/claimFactor.js +58 -0
- package/dist/socialSecurity/disability.d.ts +36 -0
- package/dist/socialSecurity/disability.js +42 -0
- package/dist/socialSecurity/familyMaximum.d.ts +38 -0
- package/dist/socialSecurity/familyMaximum.js +48 -0
- package/dist/socialSecurity/maritalBenefits.d.ts +57 -0
- package/dist/socialSecurity/maritalBenefits.js +87 -0
- package/dist/socialSecurity/nra.d.ts +30 -0
- package/dist/socialSecurity/nra.js +83 -0
- package/dist/socialSecurity/piaFromEarnings.d.ts +100 -0
- package/dist/socialSecurity/piaFromEarnings.js +202 -0
- package/dist/socialSecurity/ssaWageData.d.ts +68 -0
- package/dist/socialSecurity/ssaWageData.js +306 -0
- package/dist/socialSecurity/survivorBenefit.d.ts +61 -0
- package/dist/socialSecurity/survivorBenefit.js +61 -0
- package/dist/spending/abw.d.ts +57 -0
- package/dist/spending/abw.js +80 -0
- package/dist/spending/flexibleGoals.d.ts +62 -0
- package/dist/spending/flexibleGoals.js +136 -0
- package/dist/spending/guardrails.d.ts +78 -0
- package/dist/spending/guardrails.js +100 -0
- package/dist/spending/layers.d.ts +81 -0
- package/dist/spending/layers.js +67 -0
- package/dist/spending/shapePresets.d.ts +42 -0
- package/dist/spending/shapePresets.js +77 -0
- package/dist/strategies/accountEligibility.d.ts +81 -0
- package/dist/strategies/accountEligibility.js +98 -0
- package/dist/strategies/inheritedIra.d.ts +45 -0
- package/dist/strategies/inheritedIra.js +43 -0
- package/dist/strategies/iraBasis.d.ts +48 -0
- package/dist/strategies/iraBasis.js +46 -0
- package/dist/strategies/optimizer.d.ts +307 -0
- package/dist/strategies/optimizer.js +551 -0
- package/dist/strategies/rothBasis.d.ts +67 -0
- package/dist/strategies/rothBasis.js +83 -0
- package/dist/strategies/rothConversion.d.ts +55 -0
- package/dist/strategies/rothConversion.js +83 -0
- package/dist/strategies/sepp.d.ts +38 -0
- package/dist/strategies/sepp.js +53 -0
- package/dist/tax/aca.d.ts +40 -0
- package/dist/tax/aca.js +82 -0
- package/dist/tax/federalTax.d.ts +110 -0
- package/dist/tax/federalTax.js +249 -0
- package/dist/tax/medicare.d.ts +20 -0
- package/dist/tax/medicare.js +36 -0
- package/dist/tax/propertySale.d.ts +46 -0
- package/dist/tax/propertySale.js +35 -0
- package/dist/tax/stateTax.d.ts +66 -0
- package/dist/tax/stateTax.js +198 -0
- package/dist/testing/money.d.ts +7 -0
- package/dist/testing/money.js +17 -0
- package/dist/testing/planFixtures.d.ts +29 -0
- package/dist/testing/planFixtures.js +141 -0
- package/package.json +75 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relocation compare ("where should I retire?" on your real plan) — see
|
|
3
|
+
* DOCS/enhancements/state-relocation-compare.md.
|
|
4
|
+
*
|
|
5
|
+
* Runs the user's actual plan once per candidate state and ranks the results.
|
|
6
|
+
* A candidate is expressed as a scenario patch over the existing split-year
|
|
7
|
+
* state-move fields (no new persistence), so a candidate row is byte-identical
|
|
8
|
+
* to manually editing the plan's state, and "Add as scenario" round-trips to
|
|
9
|
+
* exactly what the sweep ran.
|
|
10
|
+
*
|
|
11
|
+
* Scope is income tax only: property tax, sales tax, cost of living, and
|
|
12
|
+
* healthcare quality are out of the model. The optional local-rate and
|
|
13
|
+
* flat-spending-delta knobs are the user's blunt approximations for those,
|
|
14
|
+
* and both apply plan-wide (documented in the UI).
|
|
15
|
+
*/
|
|
16
|
+
import type { Plan } from '../model/plan.js';
|
|
17
|
+
import type { MarketModelConfig } from '../montecarlo/marketModels.js';
|
|
18
|
+
import { type StateRetirementExclusion } from '../params/state/index.js';
|
|
19
|
+
/** Hard cap on candidate states per sweep (each one is a full plan run + Monte Carlo). */
|
|
20
|
+
export declare const MAX_RELOCATION_CANDIDATES = 5;
|
|
21
|
+
export interface RelocationCandidate {
|
|
22
|
+
/** Two-letter destination state code. */
|
|
23
|
+
state: string;
|
|
24
|
+
/**
|
|
25
|
+
* Calendar year the move happens (July 1 by default, split-year taxed).
|
|
26
|
+
* Omitted ⇒ the plan is priced as if already resident in the candidate
|
|
27
|
+
* state from the start (the plan's base state is replaced).
|
|
28
|
+
*/
|
|
29
|
+
moveYear?: number;
|
|
30
|
+
/** Move month for the split year (1–12); defaults to July like the planner. */
|
|
31
|
+
moveMonth?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Flat local income-tax rate (percent) in the destination, replacing the
|
|
34
|
+
* plan's `assumptions.localIncomeTaxPct`. Applies plan-wide — exact for a
|
|
35
|
+
* from-the-start candidate, an approximation across a mid-plan move.
|
|
36
|
+
* **Omitted means 0**: the destination's local rate starts clean, so the
|
|
37
|
+
* origin's locality tax never silently follows the household. Pass the
|
|
38
|
+
* plan's own rate explicitly to keep it.
|
|
39
|
+
*/
|
|
40
|
+
localRatePct?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Flat cost-of-living knob: percent change applied to baseline lifestyle
|
|
43
|
+
* spending (`expenses.baseAnnual`), e.g. -10 for a cheaper metro. Applies
|
|
44
|
+
* plan-wide, not just after the move — a deliberate v1 simplification.
|
|
45
|
+
*/
|
|
46
|
+
spendingDeltaPct?: number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The scenario patch a candidate writes — only existing plan fields (household
|
|
50
|
+
* state/moves, the flat-override/local-rate assumptions, baseline spending).
|
|
51
|
+
* Applying it via `applyScenarioPatch` produces the exact plan the sweep runs,
|
|
52
|
+
* so "Add as scenario" and the compare table can never disagree.
|
|
53
|
+
*
|
|
54
|
+
* When the plan's flat state-rate override is set (> 0) it would mask modeled
|
|
55
|
+
* per-state packs entirely, so candidate patches clear it (the UI calls the
|
|
56
|
+
* masking out).
|
|
57
|
+
*/
|
|
58
|
+
export declare function relocationScenarioPatch(plan: Plan, candidate: RelocationCandidate, startYear: number): Record<string, unknown>;
|
|
59
|
+
/** Facts about a state's big levers, straight from the modeled pack. */
|
|
60
|
+
export interface RelocationDriverFacts {
|
|
61
|
+
state: string;
|
|
62
|
+
stateName: string;
|
|
63
|
+
hasIncomeTax: boolean;
|
|
64
|
+
taxesSocialSecurity: boolean;
|
|
65
|
+
retirementPrivate: StateRetirementExclusion;
|
|
66
|
+
retirementPublic: StateRetirementExclusion;
|
|
67
|
+
/** One shared all-retirement rule (no separate public-pension law). */
|
|
68
|
+
retirementRuleShared: boolean;
|
|
69
|
+
capitalGainsAsOrdinary: boolean;
|
|
70
|
+
/** Percent of net capital gain in the state base. */
|
|
71
|
+
capitalGainsTaxablePct: number;
|
|
72
|
+
capitalLossCarryforwardConformity: 'federal' | 'currentYearOnly';
|
|
73
|
+
/** Top marginal bracket rate (married filing jointly), percent. */
|
|
74
|
+
topRatePct: number;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Lifetime driver attribution for one row's destination state, computed by
|
|
78
|
+
* re-pricing every recorded ledger tax year with one state feature
|
|
79
|
+
* neutralized at a time (the identical code path the ledger used, so the
|
|
80
|
+
* unmodified recomputation reconciles with the ledger's state-tax lines
|
|
81
|
+
* exactly). Each "savings" is the extra lifetime state+local tax the plan
|
|
82
|
+
* would have paid without that feature — 0 when the feature doesn't apply.
|
|
83
|
+
*/
|
|
84
|
+
export interface RelocationDrivers {
|
|
85
|
+
/** Pack facts for the destination state; null when unmodeled or a flat override priced the row. */
|
|
86
|
+
facts: RelocationDriverFacts | null;
|
|
87
|
+
/** Lifetime state+local tax the ledger actually charged (sum of the per-year lines). */
|
|
88
|
+
totalStateLocalTax: number;
|
|
89
|
+
/** Saved because the state leaves the federally-taxable part of Social Security out of its base. */
|
|
90
|
+
ssTreatmentSavings: number;
|
|
91
|
+
/** Saved by the state's retirement-income exclusion(s), both buckets together. */
|
|
92
|
+
retirementExclusionSavings: number;
|
|
93
|
+
/** Portion attributable to a separate public-pension law; 0 when the state has one shared rule. */
|
|
94
|
+
publicPensionExclusionSavings: number;
|
|
95
|
+
/** Saved vs a benchmark state that taxes 100% of net gains as ordinary income (can be negative for nonconforming states). */
|
|
96
|
+
capitalGainsTreatmentSavings: number;
|
|
97
|
+
}
|
|
98
|
+
export interface RelocationCandidateRow {
|
|
99
|
+
/** 'baseline' for the stay-put row, else 'candidate-N'. */
|
|
100
|
+
id: string;
|
|
101
|
+
label: string;
|
|
102
|
+
/** null for the baseline row. */
|
|
103
|
+
candidate: RelocationCandidate | null;
|
|
104
|
+
/** Patch-application/validation failure; when set, all metrics are absent. */
|
|
105
|
+
error: string | null;
|
|
106
|
+
/** Destination state that drives the drill-down (final residence state for the baseline). */
|
|
107
|
+
destinationState: string;
|
|
108
|
+
/** False when any residence state in the row has no modeled pack (tax treated as $0) or a flat override priced it. */
|
|
109
|
+
modeled: boolean;
|
|
110
|
+
lifetimeStateLocalTax: number;
|
|
111
|
+
lifetimeTaxesAndPenalties: number;
|
|
112
|
+
endingAfterTaxEstate: number;
|
|
113
|
+
endingNetWorth: number;
|
|
114
|
+
depletionYear: number | null;
|
|
115
|
+
endYear: number;
|
|
116
|
+
/** Monte Carlo success on shared market paths; null when the sweep ran deterministic-only. */
|
|
117
|
+
successRate: number | null;
|
|
118
|
+
drivers: RelocationDrivers | null;
|
|
119
|
+
/** The ledger's per-year state+local tax lines (nominal). */
|
|
120
|
+
stateTaxByYear: Array<{
|
|
121
|
+
year: number;
|
|
122
|
+
tax: number;
|
|
123
|
+
}>;
|
|
124
|
+
warnings: string[];
|
|
125
|
+
}
|
|
126
|
+
export interface RelocationComparison {
|
|
127
|
+
startYear: number;
|
|
128
|
+
/** Baseline first, then candidates in input order (the UI ranks). */
|
|
129
|
+
rows: RelocationCandidateRow[];
|
|
130
|
+
monteCarlo: {
|
|
131
|
+
pathCount: number;
|
|
132
|
+
seed: number;
|
|
133
|
+
} | null;
|
|
134
|
+
}
|
|
135
|
+
export interface RelocationCompareOptions {
|
|
136
|
+
startYear: number;
|
|
137
|
+
/** When present, each row also gets a success rate on shared market paths. */
|
|
138
|
+
monteCarlo?: {
|
|
139
|
+
model: MarketModelConfig;
|
|
140
|
+
pathCount: number;
|
|
141
|
+
seed: number;
|
|
142
|
+
} | null;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Deterministic N-state sweep (plus optional shared-path Monte Carlo).
|
|
146
|
+
* Pure and synchronous — run it in a worker for the UI. Reproducible per
|
|
147
|
+
* (plan, candidates, startYear, monteCarlo config).
|
|
148
|
+
*/
|
|
149
|
+
export declare function compareRelocationCandidates(plan: Plan, candidates: RelocationCandidate[], opts: RelocationCompareOptions): RelocationComparison;
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relocation compare ("where should I retire?" on your real plan) — see
|
|
3
|
+
* DOCS/enhancements/state-relocation-compare.md.
|
|
4
|
+
*
|
|
5
|
+
* Runs the user's actual plan once per candidate state and ranks the results.
|
|
6
|
+
* A candidate is expressed as a scenario patch over the existing split-year
|
|
7
|
+
* state-move fields (no new persistence), so a candidate row is byte-identical
|
|
8
|
+
* to manually editing the plan's state, and "Add as scenario" round-trips to
|
|
9
|
+
* exactly what the sweep ran.
|
|
10
|
+
*
|
|
11
|
+
* Scope is income tax only: property tax, sales tax, cost of living, and
|
|
12
|
+
* healthcare quality are out of the model. The optional local-rate and
|
|
13
|
+
* flat-spending-delta knobs are the user's blunt approximations for those,
|
|
14
|
+
* and both apply plan-wide (documented in the UI).
|
|
15
|
+
*/
|
|
16
|
+
import { stateForYear } from '../model/plan.js';
|
|
17
|
+
import { applyScenarioPatch } from '../scenarios/scenarios.js';
|
|
18
|
+
import { comparePlansOnSharedMarketPaths } from '../montecarlo/sharedPaths.js';
|
|
19
|
+
import { stateParamsFor, } from '../params/state/index.js';
|
|
20
|
+
import { combineTaxCalculators, createFederalTaxCalculator } from '../tax/federalTax.js';
|
|
21
|
+
import { computeStateTaxYearTotal, createStateTaxCalculator } from '../tax/stateTax.js';
|
|
22
|
+
import { summarizeProjection } from './compare.js';
|
|
23
|
+
import { simulatePlan } from './simulate.js';
|
|
24
|
+
/** Hard cap on candidate states per sweep (each one is a full plan run + Monte Carlo). */
|
|
25
|
+
export const MAX_RELOCATION_CANDIDATES = 5;
|
|
26
|
+
/**
|
|
27
|
+
* The scenario patch a candidate writes — only existing plan fields (household
|
|
28
|
+
* state/moves, the flat-override/local-rate assumptions, baseline spending).
|
|
29
|
+
* Applying it via `applyScenarioPatch` produces the exact plan the sweep runs,
|
|
30
|
+
* so "Add as scenario" and the compare table can never disagree.
|
|
31
|
+
*
|
|
32
|
+
* When the plan's flat state-rate override is set (> 0) it would mask modeled
|
|
33
|
+
* per-state packs entirely, so candidate patches clear it (the UI calls the
|
|
34
|
+
* masking out).
|
|
35
|
+
*/
|
|
36
|
+
export function relocationScenarioPatch(plan, candidate, startYear) {
|
|
37
|
+
const state = candidate.state.toUpperCase();
|
|
38
|
+
const patch = {};
|
|
39
|
+
if (candidate.moveYear !== undefined && candidate.moveYear >= startYear) {
|
|
40
|
+
// Future (or current-year) move: keep the base state, replace any planned
|
|
41
|
+
// moves with this one split-year move. Arrays replace wholesale in
|
|
42
|
+
// scenario patches, so prior stateMoves cannot leak through.
|
|
43
|
+
patch.household = {
|
|
44
|
+
stateMoves: [{ fromYear: candidate.moveYear, fromMonth: candidate.moveMonth ?? 7, state }],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
// Resident from the start: swap the base state and drop planned moves.
|
|
49
|
+
patch.household = { state, stateMoves: [] };
|
|
50
|
+
}
|
|
51
|
+
const assumptions = {};
|
|
52
|
+
if (plan.assumptions.stateEffectiveTaxPct > 0)
|
|
53
|
+
assumptions.stateEffectiveTaxPct = 0;
|
|
54
|
+
// Destination local rate starts clean unless specified (omitted = 0); only
|
|
55
|
+
// written when it actually changes the plan, to keep scenario diffs tidy.
|
|
56
|
+
const localRatePct = Math.max(0, candidate.localRatePct ?? 0);
|
|
57
|
+
if (localRatePct !== plan.assumptions.localIncomeTaxPct)
|
|
58
|
+
assumptions.localIncomeTaxPct = localRatePct;
|
|
59
|
+
if (Object.keys(assumptions).length > 0)
|
|
60
|
+
patch.assumptions = assumptions;
|
|
61
|
+
if (candidate.spendingDeltaPct !== undefined && candidate.spendingDeltaPct !== 0) {
|
|
62
|
+
patch.expenses = {
|
|
63
|
+
baseAnnual: Math.max(0, Math.round(plan.expenses.baseAnnual * (1 + candidate.spendingDeltaPct / 100))),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return patch;
|
|
67
|
+
}
|
|
68
|
+
/** The app-standard per-plan tax stack, with the state side recorded per year. */
|
|
69
|
+
function recordingTaxStack(plan) {
|
|
70
|
+
const lines = new Map();
|
|
71
|
+
const stateCalculator = createStateTaxCalculator({
|
|
72
|
+
overridePct: plan.assumptions.stateEffectiveTaxPct,
|
|
73
|
+
localPct: plan.assumptions.localIncomeTaxPct,
|
|
74
|
+
});
|
|
75
|
+
const recordingState = {
|
|
76
|
+
compute(input) {
|
|
77
|
+
const tax = stateCalculator.compute(input);
|
|
78
|
+
// The ledger converges tax iteratively within each year and never
|
|
79
|
+
// revisits a finished year, so the last computation per year is the
|
|
80
|
+
// ledger's final state-tax line.
|
|
81
|
+
lines.set(input.year, { input, tax });
|
|
82
|
+
return tax;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
return { taxCalculator: combineTaxCalculators(createFederalTaxCalculator(), recordingState), lines };
|
|
86
|
+
}
|
|
87
|
+
function taxStackFor(plan) {
|
|
88
|
+
return combineTaxCalculators(createFederalTaxCalculator(), createStateTaxCalculator({
|
|
89
|
+
overridePct: plan.assumptions.stateEffectiveTaxPct,
|
|
90
|
+
localPct: plan.assumptions.localIncomeTaxPct,
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
function driverFacts(state, year) {
|
|
94
|
+
const params = stateParamsFor(state, year);
|
|
95
|
+
if (!params)
|
|
96
|
+
return null;
|
|
97
|
+
const brackets = params.brackets.marriedFilingJointly;
|
|
98
|
+
return {
|
|
99
|
+
state: params.code,
|
|
100
|
+
stateName: params.name,
|
|
101
|
+
hasIncomeTax: params.hasIncomeTax,
|
|
102
|
+
taxesSocialSecurity: params.taxesSocialSecurity,
|
|
103
|
+
retirementPrivate: params.retirementPrivate,
|
|
104
|
+
retirementPublic: params.retirementPublic,
|
|
105
|
+
retirementRuleShared: params.retirementRuleShared ?? false,
|
|
106
|
+
capitalGainsAsOrdinary: params.capitalGainsAsOrdinary,
|
|
107
|
+
capitalGainsTaxablePct: params.capitalGainsTaxablePct ?? (params.capitalGainsAsOrdinary ? 100 : 0),
|
|
108
|
+
capitalLossCarryforwardConformity: params.capitalLossCarryforwardConformity ?? 'federal',
|
|
109
|
+
topRatePct: brackets.length > 0 ? Math.max(...brackets.map((b) => b.ratePct)) : 0,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** Apply `map` to the destination state's params only, leaving other residence states untouched. */
|
|
113
|
+
function forState(state, map) {
|
|
114
|
+
return (p) => (p.code === state ? map(p) : p);
|
|
115
|
+
}
|
|
116
|
+
function computeDrivers(plan, destinationState, startYear, lines, warnings) {
|
|
117
|
+
const opts = { overridePct: plan.assumptions.stateEffectiveTaxPct, localPct: plan.assumptions.localIncomeTaxPct };
|
|
118
|
+
let total = 0;
|
|
119
|
+
for (const { tax } of lines.values())
|
|
120
|
+
total += tax;
|
|
121
|
+
// A flat override masks modeled packs; drivers can't be attributed.
|
|
122
|
+
if (plan.assumptions.stateEffectiveTaxPct > 0) {
|
|
123
|
+
return {
|
|
124
|
+
facts: null,
|
|
125
|
+
totalStateLocalTax: total,
|
|
126
|
+
ssTreatmentSavings: 0,
|
|
127
|
+
retirementExclusionSavings: 0,
|
|
128
|
+
publicPensionExclusionSavings: 0,
|
|
129
|
+
capitalGainsTreatmentSavings: 0,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const facts = driverFacts(destinationState, startYear);
|
|
133
|
+
const sumVariant = (map) => {
|
|
134
|
+
let sum = 0;
|
|
135
|
+
for (const { input } of lines.values()) {
|
|
136
|
+
sum += computeStateTaxYearTotal(input, map ? { ...opts, mapParams: forState(destinationState, map) } : opts);
|
|
137
|
+
}
|
|
138
|
+
return sum;
|
|
139
|
+
};
|
|
140
|
+
// Acceptance guard: re-pricing every recorded year unmodified must equal the
|
|
141
|
+
// ledger's state-tax lines — the drivers below are only meaningful if it does.
|
|
142
|
+
const reconciled = sumVariant(undefined);
|
|
143
|
+
if (Math.abs(reconciled - total) > 0.01) {
|
|
144
|
+
warnings.push('Driver attribution could not reconcile with the ledger state-tax lines; drill-down figures were suppressed.');
|
|
145
|
+
// facts: null keeps the UI on its "attribution unavailable" message
|
|
146
|
+
// instead of rendering a drivers table of misleading $0 rows.
|
|
147
|
+
return {
|
|
148
|
+
facts: null,
|
|
149
|
+
totalStateLocalTax: total,
|
|
150
|
+
ssTreatmentSavings: 0,
|
|
151
|
+
retirementExclusionSavings: 0,
|
|
152
|
+
publicPensionExclusionSavings: 0,
|
|
153
|
+
capitalGainsTreatmentSavings: 0,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (!facts) {
|
|
157
|
+
return {
|
|
158
|
+
facts: null,
|
|
159
|
+
totalStateLocalTax: total,
|
|
160
|
+
ssTreatmentSavings: 0,
|
|
161
|
+
retirementExclusionSavings: 0,
|
|
162
|
+
publicPensionExclusionSavings: 0,
|
|
163
|
+
capitalGainsTreatmentSavings: 0,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const noExclusion = { kind: 'none' };
|
|
167
|
+
// "Savings" = tax with the feature neutralized − tax as modeled.
|
|
168
|
+
const ssTreatmentSavings = facts.taxesSocialSecurity
|
|
169
|
+
? 0
|
|
170
|
+
: sumVariant((p) => ({ ...p, taxesSocialSecurity: true })) - total;
|
|
171
|
+
const retirementExclusionSavings = sumVariant((p) => ({ ...p, retirementPrivate: noExclusion, retirementPublic: noExclusion })) - total;
|
|
172
|
+
const publicPensionExclusionSavings = facts.retirementRuleShared
|
|
173
|
+
? 0
|
|
174
|
+
: sumVariant((p) => ({ ...p, retirementPublic: noExclusion })) - total;
|
|
175
|
+
const capitalGainsTreatmentSavings = sumVariant((p) => ({
|
|
176
|
+
...p,
|
|
177
|
+
capitalGainsAsOrdinary: true,
|
|
178
|
+
capitalGainsTaxablePct: 100,
|
|
179
|
+
capitalLossCarryforwardConformity: 'federal',
|
|
180
|
+
})) - total;
|
|
181
|
+
return {
|
|
182
|
+
facts,
|
|
183
|
+
totalStateLocalTax: total,
|
|
184
|
+
ssTreatmentSavings,
|
|
185
|
+
retirementExclusionSavings,
|
|
186
|
+
publicPensionExclusionSavings,
|
|
187
|
+
capitalGainsTreatmentSavings,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function candidateLabel(candidate) {
|
|
191
|
+
const state = candidate.state.toUpperCase();
|
|
192
|
+
return candidate.moveYear !== undefined ? `${state} (move ${candidate.moveYear})` : state;
|
|
193
|
+
}
|
|
194
|
+
function runRow(id, label, candidate, plan, startYear) {
|
|
195
|
+
const endYearFallback = startYear;
|
|
196
|
+
if (candidate) {
|
|
197
|
+
const applied = applyScenarioPatch(plan, relocationScenarioPatch(plan, candidate, startYear));
|
|
198
|
+
if (!applied.ok) {
|
|
199
|
+
return {
|
|
200
|
+
row: {
|
|
201
|
+
id,
|
|
202
|
+
label,
|
|
203
|
+
candidate,
|
|
204
|
+
error: `Candidate is invalid: ${applied.issues.join('; ')}`,
|
|
205
|
+
destinationState: candidate.state.toUpperCase(),
|
|
206
|
+
modeled: false,
|
|
207
|
+
lifetimeStateLocalTax: 0,
|
|
208
|
+
lifetimeTaxesAndPenalties: 0,
|
|
209
|
+
endingAfterTaxEstate: 0,
|
|
210
|
+
endingNetWorth: 0,
|
|
211
|
+
depletionYear: null,
|
|
212
|
+
endYear: endYearFallback,
|
|
213
|
+
successRate: null,
|
|
214
|
+
drivers: null,
|
|
215
|
+
stateTaxByYear: [],
|
|
216
|
+
warnings: [],
|
|
217
|
+
},
|
|
218
|
+
plan: null,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
plan = applied.plan;
|
|
222
|
+
}
|
|
223
|
+
const { taxCalculator, lines } = recordingTaxStack(plan);
|
|
224
|
+
const result = simulatePlan(plan, { startYear, taxCalculator });
|
|
225
|
+
const summary = summarizeProjection(plan, result);
|
|
226
|
+
// The state the drill-down attributes: the plan's ACTUAL final residence.
|
|
227
|
+
// For a candidate whose move year falls beyond the horizon the ledger never
|
|
228
|
+
// relocates, so labeling the requested state would misattribute a baseline
|
|
229
|
+
// run — attribute the real residence and say so.
|
|
230
|
+
const destinationState = stateForYear(plan.household, result.endYear);
|
|
231
|
+
const warnings = [];
|
|
232
|
+
if (candidate && destinationState !== candidate.state.toUpperCase()) {
|
|
233
|
+
warnings.push(`The planned move to ${candidate.state.toUpperCase()}${candidate.moveYear !== undefined ? ` in ${candidate.moveYear}` : ''} falls after the plan horizon (${result.endYear}), so this row never becomes a ${candidate.state.toUpperCase()} resident — it prices the unmoved plan.`);
|
|
234
|
+
}
|
|
235
|
+
const drivers = computeDrivers(plan, destinationState, startYear, lines, warnings);
|
|
236
|
+
// Modeled = every residence year priced through a modeled pack (or a flat
|
|
237
|
+
// override deliberately in charge). An unmodeled state contributes $0 and
|
|
238
|
+
// the ledger already warns about it; surface that as modeled:false.
|
|
239
|
+
const overrideActive = plan.assumptions.stateEffectiveTaxPct > 0;
|
|
240
|
+
let allModeled = true;
|
|
241
|
+
if (!overrideActive) {
|
|
242
|
+
for (const y of result.years) {
|
|
243
|
+
if (!stateParamsFor(stateForYear(plan.household, y.year), y.year)) {
|
|
244
|
+
allModeled = false;
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const stateTaxByYear = [...lines.entries()]
|
|
250
|
+
.filter(([year]) => year >= result.startYear && year <= result.endYear)
|
|
251
|
+
.sort((a, b) => a[0] - b[0])
|
|
252
|
+
.map(([year, { tax }]) => ({ year, tax }));
|
|
253
|
+
return {
|
|
254
|
+
row: {
|
|
255
|
+
id,
|
|
256
|
+
label,
|
|
257
|
+
candidate,
|
|
258
|
+
error: null,
|
|
259
|
+
destinationState,
|
|
260
|
+
modeled: allModeled && !overrideActive,
|
|
261
|
+
lifetimeStateLocalTax: drivers.totalStateLocalTax,
|
|
262
|
+
lifetimeTaxesAndPenalties: summary.lifetimeTaxesAndPenalties,
|
|
263
|
+
endingAfterTaxEstate: summary.endingAfterTaxEstate,
|
|
264
|
+
endingNetWorth: summary.endingNetWorth,
|
|
265
|
+
depletionYear: result.depletionYear,
|
|
266
|
+
endYear: result.endYear,
|
|
267
|
+
successRate: null,
|
|
268
|
+
drivers,
|
|
269
|
+
stateTaxByYear,
|
|
270
|
+
warnings: [...warnings, ...result.warnings],
|
|
271
|
+
},
|
|
272
|
+
plan,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Deterministic N-state sweep (plus optional shared-path Monte Carlo).
|
|
277
|
+
* Pure and synchronous — run it in a worker for the UI. Reproducible per
|
|
278
|
+
* (plan, candidates, startYear, monteCarlo config).
|
|
279
|
+
*/
|
|
280
|
+
export function compareRelocationCandidates(plan, candidates, opts) {
|
|
281
|
+
const capped = candidates.slice(0, MAX_RELOCATION_CANDIDATES);
|
|
282
|
+
// The baseline label names the plan's own residency path, including any
|
|
283
|
+
// planned moves ("CA → FL"), so "stay" never misdescribes a moving plan.
|
|
284
|
+
const finalState = stateForYear(plan.household, Number.MAX_SAFE_INTEGER);
|
|
285
|
+
const baselineLabel = finalState === plan.household.state
|
|
286
|
+
? `Stay in ${plan.household.state}`
|
|
287
|
+
: `Your plan (${plan.household.state} → ${finalState})`;
|
|
288
|
+
const runs = [
|
|
289
|
+
runRow('baseline', baselineLabel, null, plan, opts.startYear),
|
|
290
|
+
...capped.map((candidate, i) => runRow(`candidate-${i}`, candidateLabel(candidate), candidate, plan, opts.startYear)),
|
|
291
|
+
];
|
|
292
|
+
const mc = opts.monteCarlo ?? null;
|
|
293
|
+
if (mc) {
|
|
294
|
+
const entries = runs
|
|
295
|
+
.filter((r) => r.plan !== null)
|
|
296
|
+
.map((r) => ({ id: r.row.id, label: r.row.label, plan: r.plan, taxCalculator: taxStackFor(r.plan) }));
|
|
297
|
+
const comparison = comparePlansOnSharedMarketPaths(entries, {
|
|
298
|
+
startYear: opts.startYear,
|
|
299
|
+
// Unused fallback — every entry carries its own per-candidate stack.
|
|
300
|
+
taxCalculator: taxStackFor(plan),
|
|
301
|
+
model: mc.model,
|
|
302
|
+
pathCount: mc.pathCount,
|
|
303
|
+
seed: mc.seed,
|
|
304
|
+
});
|
|
305
|
+
const byId = new Map(comparison.rows.map((row) => [row.id, row.summary.successRate]));
|
|
306
|
+
for (const r of runs) {
|
|
307
|
+
const successRate = byId.get(r.row.id);
|
|
308
|
+
if (successRate !== undefined)
|
|
309
|
+
r.row.successRate = successRate;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
startYear: opts.startYear,
|
|
314
|
+
rows: runs.map((r) => r.row),
|
|
315
|
+
monteCarlo: mc ? { pathCount: mc.pathCount, seed: mc.seed } : null,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic annual-ledger simulation (roadmap V1).
|
|
3
|
+
*
|
|
4
|
+
* Year ordering: ages → income → expenses (incl. debt service) → capped
|
|
5
|
+
* contributions → fixed-point tax/withdrawal iteration → apply flows →
|
|
6
|
+
* property events → growth → snapshot. All amounts are nominal dollars;
|
|
7
|
+
* today's-dollar display is a render-time transform.
|
|
8
|
+
*
|
|
9
|
+
* V1 simplifications (each lifts in a later roadmap phase):
|
|
10
|
+
* - Wages, contributions, base spending, and goals inflate at the general rate;
|
|
11
|
+
* wages stop in the year the person attains retirement age.
|
|
12
|
+
* - SS COLA compounds from the projection start, and first-year benefits are
|
|
13
|
+
* prorated by claim months only (no birthday-month precision). PIA comes
|
|
14
|
+
* from the stream directly or from its earnings history (AIME → bend
|
|
15
|
+
* points). The earnings test withholds own, spousal, and survivor benefits
|
|
16
|
+
* annually ($1/$2 below FRA, $1/$3 in the FRA year) and credits the withheld
|
|
17
|
+
* months back at FRA (ARF, annual approximation). Spousal benefits apply the
|
|
18
|
+
* retirement/survivor family maximum; survivors step up to the deceased's
|
|
19
|
+
* benefit with the early-claim widow(er) reduction and RIB-LIM widow's-limit cap.
|
|
20
|
+
* - RMDs are forced from traditional accounts at SECURE 2.0 start ages
|
|
21
|
+
* (Uniform Lifetime Table; no April-1 first-year deferral). QCDs route
|
|
22
|
+
* charitable dollars out of the RMD (age 70½ ≈ age attained 71).
|
|
23
|
+
* Early-withdrawal penalties: 10% traditional pre-59½ (≈ age < 60), 20%
|
|
24
|
+
* HSA non-medical pre-65. Healthcare expenses: ACA-credited marketplace
|
|
25
|
+
* premiums pre-65 (credit vs prior-year MAGI; 400% FPL cliff), Medicare
|
|
26
|
+
* Part B + IRMAA (MAGI 2-year lookback) + Part D surcharge + extras from
|
|
27
|
+
* 65. Roth conversions run after RMDs (manual amounts or fill-to-target
|
|
28
|
+
* sized against the federal engine; conversion taxes ride the normal
|
|
29
|
+
* withdrawal flow, so they come from cash/taxable first and are never
|
|
30
|
+
* penalized). Annuities end at owner death; pensions pay survivorPct to a
|
|
31
|
+
* surviving spouse once payments have started.
|
|
32
|
+
* - Contribution limits beyond the latest parameter pack are indexed forward
|
|
33
|
+
* at the assumed inflation rate (statutory limits are inflation-indexed).
|
|
34
|
+
*/
|
|
35
|
+
import type { Plan } from '../model/plan.js';
|
|
36
|
+
import { type MarketSeries, type OptimizerYearProbe, type ProjectionResult, type TaxCalculator } from './types.js';
|
|
37
|
+
export interface SimulateOptions {
|
|
38
|
+
startYear: number;
|
|
39
|
+
taxCalculator: TaxCalculator;
|
|
40
|
+
/**
|
|
41
|
+
* Per-year stochastic returns/inflation (Monte Carlo, roadmap V4). Omitted =
|
|
42
|
+
* deterministic run using the plan's assumptions every year.
|
|
43
|
+
*/
|
|
44
|
+
market?: MarketSeries;
|
|
45
|
+
/**
|
|
46
|
+
* Per-person age at death (last full year alive), overriding longevity.planningAge.
|
|
47
|
+
* Used by stochastic-longevity Monte Carlo (roadmap V6); omitted = use planningAge.
|
|
48
|
+
*/
|
|
49
|
+
deathAgeByPersonId?: Record<string, number>;
|
|
50
|
+
/**
|
|
51
|
+
* Force the projection's end year, independent of lifespans, so every Monte
|
|
52
|
+
* Carlo path shares a year grid even when sampled death ages differ.
|
|
53
|
+
*/
|
|
54
|
+
horizonEndYear?: number;
|
|
55
|
+
/**
|
|
56
|
+
* Optional sink for the V8 optimizer's per-year linearization inputs. A no-op
|
|
57
|
+
* when omitted, so a normal projection is unaffected. @see OptimizerYearProbe
|
|
58
|
+
*/
|
|
59
|
+
captureOptimizerInputs?: (probe: OptimizerYearProbe) => void;
|
|
60
|
+
}
|
|
61
|
+
export declare function simulatePlan(plan: Plan, opts: SimulateOptions): ProjectionResult;
|