@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,551 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-year withdrawal / Roth-conversion optimizer (roadmap V8, PR 1).
|
|
3
|
+
*
|
|
4
|
+
* A true multi-year optimizer — unlike the greedy per-year `sizeRothConversion`
|
|
5
|
+
* sizer, this reasons over the whole horizon at once, which is the entire point
|
|
6
|
+
* of conversion planning: convert more now (paying tax in a low bracket) to
|
|
7
|
+
* shrink later RMDs and leave more in the never-haircut Roth. It is "just
|
|
8
|
+
* another strategy provider" (V8 spec §1.3): it emits a per-year schedule that
|
|
9
|
+
* the existing `simulate` ledger consumes via the Roth-conversion `optimized`
|
|
10
|
+
* mode — it does NOT run a parallel engine.
|
|
11
|
+
*
|
|
12
|
+
* Method: a mixed-integer linear program solved client-side by HiGHS-WASM (the
|
|
13
|
+
* `highs` package), proven viable by the PR 0 spike (measurements recorded in
|
|
14
|
+
* DOCS/features/optimizer.md §"Spike findings (historical)"). The LP is a *linearised* view of the
|
|
15
|
+
* ledger; `simulate` stays the source of truth, so callers re-run the real
|
|
16
|
+
* (nonlinear) ledger with the emitted schedule for exact numbers and surface
|
|
17
|
+
* any gap (V8 §3.1).
|
|
18
|
+
*
|
|
19
|
+
* Modeled exactly (the "big levers", V8 §6):
|
|
20
|
+
* - Graduated federal ordinary tax as a CONVEX piecewise-linear cost from the
|
|
21
|
+
* pack's real brackets — minimising tax fills the cheap band first with no
|
|
22
|
+
* integer variables.
|
|
23
|
+
* - IRMAA tier surcharges as binary step thresholds (the non-convex part that
|
|
24
|
+
* makes this a MILP, not an LP).
|
|
25
|
+
* - RMD floors as a linear lower bound on each year's taxable distribution
|
|
26
|
+
* (floor = start-of-year traditional balance ÷ divisor).
|
|
27
|
+
* - Three-bucket balances (owner traditional, inherited traditional, and
|
|
28
|
+
* "other" = Roth + taxable + cash) with per-year growth and scheduled
|
|
29
|
+
* contribution / employer-match inflows from the baseline probe, so a
|
|
30
|
+
* plan whose solvency depends on future deposits is not misread as
|
|
31
|
+
* infeasible.
|
|
32
|
+
*
|
|
33
|
+
* The optimizer-exact-ledger-convergence plan (Track 1) closed the
|
|
34
|
+
* original v1 simplifications, each opt-in via `OptimizerInput` so absent
|
|
35
|
+
* fields reproduce the v1 LP byte-for-byte:
|
|
36
|
+
* - Taxable-gain realization (Step 2): `openingTaxable`/`taxableInflow` split
|
|
37
|
+
* the lumped bucket into a taxable brokerage bucket — whose withdrawals
|
|
38
|
+
* realize LTCG at `ltcgRate` on the opening gain fraction and lift IRMAA
|
|
39
|
+
* MAGI — and the tax-free Roth/cash/HSA bucket. A single LTCG rate
|
|
40
|
+
* linearizes the 0/15/20% stack; the exact ledger refines it.
|
|
41
|
+
* - Bracketed state tax (Step 3): `stateBrackets` adds a convex state PWL
|
|
42
|
+
* over the same `ti` base as the federal PWL, so progressive
|
|
43
|
+
* (non-flat-override) states are priced with real brackets rather than
|
|
44
|
+
* zero; a flat `stateEffectiveTaxPct` override keeps the flat `stateRate`.
|
|
45
|
+
* - Taxable-SS phase-in (Step 3): `ssTaxability` swaps the fixed taxable-SS
|
|
46
|
+
* constant for an in-solve convex PWL over provisional income, so the
|
|
47
|
+
* solver sees the *marginal* tax torpedo (see `taxss` constraints below).
|
|
48
|
+
* - IRMAA 2-year lookback (Step 4): `irmaaLookback` drives each premium
|
|
49
|
+
* year's binaries off MAGI(year−2), matching the ledger's causality.
|
|
50
|
+
* - OBBBA senior deduction (ground-truth 2026 law sync, Step 2):
|
|
51
|
+
* `seniorDeduction` adds the per-person 65+ deduction plus a convex
|
|
52
|
+
* phase-out floor over MAGI, so conversions in the phase-out band are
|
|
53
|
+
* priced at bracket rate × (1 + phase-out rate) in-solve instead of the
|
|
54
|
+
* deduction being ignored entirely.
|
|
55
|
+
*
|
|
56
|
+
* Remaining simplifications (refined when the schedule re-runs through
|
|
57
|
+
* `simulate`, and by the exact-ledger convergence loop — Step 1, in
|
|
58
|
+
* projection/optimizePlan.ts — which recaptures the exogenous inputs at the
|
|
59
|
+
* incumbent schedule and re-solves to a fixed point): a single LTCG rate and
|
|
60
|
+
* opening basis ratio for the taxable stack, the omitted 85%-of-benefit
|
|
61
|
+
* taxable-SS cap (conservative), unscaled stand-in packs for future years
|
|
62
|
+
* (matching the ledger's convention), and state retirement-income exclusions.
|
|
63
|
+
*/
|
|
64
|
+
function fmt(coef) {
|
|
65
|
+
// Plain decimal (HiGHS LP reader dislikes exponential notation); trim noise.
|
|
66
|
+
return Number.isInteger(coef) ? String(coef) : coef.toFixed(8).replace(/\.?0+$/, '');
|
|
67
|
+
}
|
|
68
|
+
/** Render { x: 1.05, y: -2 } as a signed LP linear expression. */
|
|
69
|
+
function expr(terms) {
|
|
70
|
+
const parts = [];
|
|
71
|
+
for (const [name, coef] of Object.entries(terms)) {
|
|
72
|
+
if (coef === 0)
|
|
73
|
+
continue;
|
|
74
|
+
parts.push(`${coef < 0 ? '-' : '+'} ${fmt(Math.abs(coef))} ${name}`);
|
|
75
|
+
}
|
|
76
|
+
return parts.join(' ') || '0';
|
|
77
|
+
}
|
|
78
|
+
/** Convex piecewise-linear federal tax from a pack's ascending brackets. */
|
|
79
|
+
function bracketSegments(pack, filing) {
|
|
80
|
+
const brackets = pack.federalTax.brackets[filing];
|
|
81
|
+
return brackets.map((b, i) => ({
|
|
82
|
+
width: i + 1 < brackets.length ? brackets[i + 1].lowerBound - b.lowerBound : null, // last band open-ended
|
|
83
|
+
rate: b.ratePct / 100,
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
/** Annual Part B + Part D IRMAA surcharge increments per tier (nominal). */
|
|
87
|
+
function irmaaIncrements(pack) {
|
|
88
|
+
const std = pack.medicare.partBStandardMonthly;
|
|
89
|
+
const tiers = pack.medicare.irmaaTiers;
|
|
90
|
+
return tiers.map((tier, i) => {
|
|
91
|
+
const prevPct = i === 0 ? 25 : tiers[i - 1].applicablePct;
|
|
92
|
+
// Part B increment over the previous tier + this tier's Part D surcharge.
|
|
93
|
+
const partB = std * ((tier.applicablePct - prevPct) / 25) * 12;
|
|
94
|
+
const partD = (tier.partDSurchargeMonthly ?? 0) * 12 - (i === 0 ? 0 : (tiers[i - 1].partDSurchargeMonthly ?? 0) * 12);
|
|
95
|
+
return { threshold: (f) => tier.magiOver[f], surcharge: Math.max(0, partB + partD) };
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/** Build the multi-year MILP in CPLEX LP format. Exported for golden tests. */
|
|
99
|
+
export function buildOptimizerModel(input) {
|
|
100
|
+
const { years, liquidationRate } = input;
|
|
101
|
+
const n = years.length;
|
|
102
|
+
const maxConv = input.options?.maxConversionPerYear;
|
|
103
|
+
// Step 2 — taxable-gain realization. Split the lumped "other" bucket into a
|
|
104
|
+
// taxable bucket (withdrawals realize LTCG on the gain fraction) and the
|
|
105
|
+
// tax-free bucket (`openingOther`). Only materialized when there is a taxable
|
|
106
|
+
// balance or inflow, so plans without a taxable account emit the identical LP.
|
|
107
|
+
const openingTaxable = input.openingTaxable ?? 0;
|
|
108
|
+
const gainFraction = Math.min(1, Math.max(0, 1 - (input.taxableBasisRatio ?? 1)));
|
|
109
|
+
const ltcgRate = Number.isFinite(input.ltcgRate) ? Math.max(0, input.ltcgRate ?? 0) : 0;
|
|
110
|
+
const hasTaxable = openingTaxable > 0 || years.some((y) => (y.taxableInflow ?? 0) > 0);
|
|
111
|
+
// Net cash a taxable withdrawal dollar yields after its LTCG cost, and the
|
|
112
|
+
// MAGI weight of its realized gain (capital gains lift MAGI for IRMAA).
|
|
113
|
+
const taxableNetCash = 1 - ltcgRate * gainFraction;
|
|
114
|
+
const taxableGainWeight = gainFraction;
|
|
115
|
+
// Step 4 — drive each premium year's IRMAA binaries off year (t−2)'s MAGI.
|
|
116
|
+
const irmaaLookback = input.irmaaLookback ?? false;
|
|
117
|
+
// Safe big-M: no year's taxable income can exceed all assets plus all income.
|
|
118
|
+
// Scale by the PEAK cumulative growth factor, floored at 1 — with negative
|
|
119
|
+
// returns the end-of-horizon factor can be < 1, which would shrink big-M
|
|
120
|
+
// below early-year balances and let a relaxed IRMAA constraint wrongly bind.
|
|
121
|
+
let cumGrowth = 1;
|
|
122
|
+
let peakGrowth = 1;
|
|
123
|
+
for (const y of years) {
|
|
124
|
+
cumGrowth *= 1 + y.growth;
|
|
125
|
+
if (cumGrowth > peakGrowth)
|
|
126
|
+
peakGrowth = cumGrowth;
|
|
127
|
+
}
|
|
128
|
+
const incomeSum = years.reduce((a, y) => a + y.ordinaryIncomeBase, 0);
|
|
129
|
+
const inflowSum = years.reduce((a, y) => a + y.tradInflow + y.otherInflow, 0);
|
|
130
|
+
const bigM = (input.openingTrad + input.openingInheritedTrad + input.openingOther + (input.openingTaxable ?? 0) + inflowSum) *
|
|
131
|
+
peakGrowth +
|
|
132
|
+
incomeSum +
|
|
133
|
+
1_000_000;
|
|
134
|
+
const constraints = [];
|
|
135
|
+
const binaries = [];
|
|
136
|
+
const segBounds = [];
|
|
137
|
+
// Per-year MAGI decomposition for the IRMAA constraints: a constant base plus
|
|
138
|
+
// variable terms (conversions/withdrawals, taxable gains, and — when the
|
|
139
|
+
// in-solve taxable-SS PWL is active — the taxable-SS variable). Stored so the
|
|
140
|
+
// 2-year lookback (Step 4) can reference an earlier year's MAGI.
|
|
141
|
+
const magiBase = [];
|
|
142
|
+
const magiTerms = [];
|
|
143
|
+
for (let t = 0; t < n; t++) {
|
|
144
|
+
const y = years[t];
|
|
145
|
+
const g = 1 + y.growth;
|
|
146
|
+
const conv = `conv${t}`;
|
|
147
|
+
const wt = `wt${t}`; // taxable traditional withdrawal
|
|
148
|
+
const wi = `wi${t}`; // taxable inherited-traditional withdrawal
|
|
149
|
+
const wo = `wo${t}`; // tax-free other-bucket withdrawal
|
|
150
|
+
const wtax = `wtax${t}`; // taxable-brokerage withdrawal (realizes LTCG on the gain fraction)
|
|
151
|
+
const save = `save${t}`; // surplus cash routed back to the tax-free bucket
|
|
152
|
+
const ti = `ti${t}`; // taxable ordinary income (post standard deduction, ≥0)
|
|
153
|
+
// OBBBA senior deduction in-solve (ground-truth 2026 law sync, Step 2).
|
|
154
|
+
// Eligible years add the full per-person deduction to the constant and a
|
|
155
|
+
// convex phase-out floor `srd ≥ rate·(MAGI − start)` (srd ≥ 0) that claws
|
|
156
|
+
// it back inside the solve — the marginal-rate spike the exact ledger
|
|
157
|
+
// prices via `seniorDeductionAmount`. Years already past full phase-out at
|
|
158
|
+
// baseline skip it exactly (conversions only raise MAGI, so the deduction
|
|
159
|
+
// stays zero); the baseline MAGI for that test must include the year's
|
|
160
|
+
// forced distributions (owner RMD + inherited) — they are re-decided as
|
|
161
|
+
// `wt`/`wi` so `ordinaryIncomeBase` excludes them, but the ledger's MAGI
|
|
162
|
+
// counts them, and a high-RMD year can be fully phased out on forced
|
|
163
|
+
// income alone. The concave full-phase-out cap is otherwise omitted — a
|
|
164
|
+
// conservative overstatement for cap-crossing conversions, like the SS 85%
|
|
165
|
+
// cap (a hard `srd ≤ base` bound would instead make cap-crossing MAGI
|
|
166
|
+
// infeasible against the floor constraint, and an exact cap needs a binary).
|
|
167
|
+
const srdRule = input.seniorDeduction ? y.pack.federalTax.seniorDeduction : null;
|
|
168
|
+
const srdEligible = srdRule !== null && y.peopleAged65Plus > 0 && y.year <= srdRule.lastApplicableYear;
|
|
169
|
+
const srdRate = srdRule && srdEligible ? srdRule.phaseOutRatePct / 100 : 0;
|
|
170
|
+
const srdStart = srdRule && srdEligible ? srdRule.magiPhaseOutStart[y.filingStatus] : 0;
|
|
171
|
+
const baselineMagi = y.ordinaryIncomeBase + (y.capitalGainsBase ?? 0) + (y.baselineRmd ?? 0) + y.inheritedDistribution;
|
|
172
|
+
const srdBaseRaw = srdRule && srdEligible ? srdRule.amountPerPerson * y.peopleAged65Plus : 0;
|
|
173
|
+
const srdFullyPhasedOut = srdRate > 0 && baselineMagi >= srdStart + srdBaseRaw / srdRate;
|
|
174
|
+
const srdBase = srdFullyPhasedOut ? 0 : srdBaseRaw;
|
|
175
|
+
const srdPwlActive = srdBase > 0 && srdRate > 0;
|
|
176
|
+
const srd = `srd${t}`;
|
|
177
|
+
const ded = pack65Deduction(y) + srdBase;
|
|
178
|
+
// Tax pieces: federal PWL segments + IRMAA binaries + flat state on `ti`.
|
|
179
|
+
const segs = bracketSegments(y.pack, y.filingStatus);
|
|
180
|
+
const segVars = segs.map((_, i) => `fseg${t}_${i}`);
|
|
181
|
+
const irmaa = irmaaIncrements(y.pack);
|
|
182
|
+
const irmaaVars = irmaa.map((_, k) => `irmaa${t}_${k}`);
|
|
183
|
+
// In-solve taxable-SS PWL (Step 3): active when the year carries SS inputs
|
|
184
|
+
// and the incumbent is not already saturated at the 85% cap (a saturated
|
|
185
|
+
// year's marginal effect is zero, so the fixed constant is exactly right).
|
|
186
|
+
// Convex max-affine underestimate of the 0/50/85% phase-in over provisional
|
|
187
|
+
// income PI = PI0 + x (x = conv + wt + wi + gain-weighted wtax); the 0.85·B
|
|
188
|
+
// cap is omitted (concave), so past the cap the LP overestimates the
|
|
189
|
+
// marginal cost — a conservative direction the exact ledger refines.
|
|
190
|
+
const ssB = y.ssTaxability;
|
|
191
|
+
const ssPwlActive = ssB !== undefined && ssB.ssBenefits > 1 && ssB.taxableSsBase < 0.845 * ssB.ssBenefits;
|
|
192
|
+
const taxSs = `taxss${t}`;
|
|
193
|
+
// Ordinary base with the probe's taxable-SS constant swapped out for the
|
|
194
|
+
// PWL variable when active.
|
|
195
|
+
const ordinaryBase = ssPwlActive ? y.ordinaryIncomeBase - ssB.taxableSsBase : y.ordinaryIncomeBase;
|
|
196
|
+
// (taxable ordinary) ti ≥ grossOrdinary − deduction, ti ≥ 0. Minimising tax
|
|
197
|
+
// pins ti to max(0, gross−ded) since every tax term is increasing in ti.
|
|
198
|
+
// gross = ordinaryBase + conv + wt + wi (+ taxSS variable when PWL active)
|
|
199
|
+
const tiFloor = { [ti]: 1, [conv]: -1, [wt]: -1, [wi]: -1 };
|
|
200
|
+
if (ssPwlActive)
|
|
201
|
+
tiFloor[taxSs] = -1;
|
|
202
|
+
// The phase-out variable raises the floor: ti ≥ gross − ded + srd.
|
|
203
|
+
if (srdPwlActive)
|
|
204
|
+
tiFloor[srd] = -1;
|
|
205
|
+
constraints.push(` tifloor${t}: ${expr(tiFloor)} >= ${fmt(ordinaryBase - ded)}`);
|
|
206
|
+
if (ssPwlActive) {
|
|
207
|
+
const B = ssB.ssBenefits;
|
|
208
|
+
const t1 = y.pack.ssBenefitTaxation.tier50Start[y.filingStatus];
|
|
209
|
+
const t2 = y.pack.ssBenefitTaxation.tier85Start[y.filingStatus];
|
|
210
|
+
// Provisional income: ordinary (excl. taxable SS) + gains + half the benefit.
|
|
211
|
+
const pi0 = ordinaryBase + (y.capitalGainsBase ?? 0) + 0.5 * B;
|
|
212
|
+
const piVars = { [conv]: 1, [wt]: 1, [wi]: 1 };
|
|
213
|
+
if (hasTaxable && taxableGainWeight > 0)
|
|
214
|
+
piVars[wtax] = taxableGainWeight;
|
|
215
|
+
// taxSS ≥ max(0, 0.5·(PI−T1), tier1Cap + 0.85·(PI−T2)) — the convex
|
|
216
|
+
// phase-in mirroring the ledger's `taxableSocialSecurity`: above T2 the
|
|
217
|
+
// 50% tier's contribution is capped at min(0.5·B, 0.5·(T2−T1)) and each
|
|
218
|
+
// marginal dollar adds 0.85 (never 0.5+0.85 — the tiers do not stack at
|
|
219
|
+
// the margin). Expressed as ≥-constraints (the tax terms are increasing
|
|
220
|
+
// in taxSS, so minimization pins taxSS to the max). The concave 0.85·B
|
|
221
|
+
// cap is deliberately NOT modeled: a binary per SS year makes the MILP
|
|
222
|
+
// intractably slow (measured), and omitting the cap only OVERSTATES the
|
|
223
|
+
// tax on conversions large enough to blow past it — a conservative
|
|
224
|
+
// direction. Mega-conversion shapes still reach the recommendation
|
|
225
|
+
// through the tournament candidates and local search, all priced on the
|
|
226
|
+
// exact ledger, and saturated-at-the-incumbent years skip the PWL
|
|
227
|
+
// entirely (their marginal effect is zero, so the constant is exact).
|
|
228
|
+
const tier1Cap = Math.min(0.5 * B, 0.5 * (t2 - t1));
|
|
229
|
+
const pieces = [
|
|
230
|
+
{ slope: 0.5, constant: 0.5 * (pi0 - t1), name: 'a' },
|
|
231
|
+
{ slope: 0.85, constant: tier1Cap + 0.85 * (pi0 - t2), name: 'b' },
|
|
232
|
+
];
|
|
233
|
+
for (const piece of pieces) {
|
|
234
|
+
const terms = { [taxSs]: 1 };
|
|
235
|
+
for (const [v, c] of Object.entries(piVars))
|
|
236
|
+
terms[v] = -piece.slope * c;
|
|
237
|
+
constraints.push(` taxss${t}${piece.name}: ${expr(terms)} >= ${fmt(piece.constant)}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// MAGI decomposition for IRMAA: constant base + variable add-ons. The base
|
|
241
|
+
// includes the year's baseline capital gains / qualified dividends — the
|
|
242
|
+
// ledger's MAGI counts them, and the SS PWL above already does; omitting
|
|
243
|
+
// them here would let a conversion look tier-safe in-solve while the exact
|
|
244
|
+
// ledger prices the surcharge. Matches the pre-PWL behavior exactly when
|
|
245
|
+
// both are absent (constant taxable SS rides inside ordinaryIncomeBase).
|
|
246
|
+
magiBase.push(ordinaryBase + (y.capitalGainsBase ?? 0));
|
|
247
|
+
const myMagiTerms = { [conv]: 1, [wt]: 1, [wi]: 1 };
|
|
248
|
+
if (hasTaxable && taxableGainWeight > 0)
|
|
249
|
+
myMagiTerms[wtax] = taxableGainWeight;
|
|
250
|
+
if (ssPwlActive)
|
|
251
|
+
myMagiTerms[taxSs] = 1;
|
|
252
|
+
magiTerms.push(myMagiTerms);
|
|
253
|
+
// Senior-deduction phase-out floor: srd ≥ rate·(MAGI − start), srd ≥ 0.
|
|
254
|
+
// Tax is increasing in ti (hence in srd) and drains cash from the
|
|
255
|
+
// maximized terminal estate, so the optimum pins srd to
|
|
256
|
+
// max(0, rate·(MAGI − start)) — the ledger's claw-back, sans the cap.
|
|
257
|
+
if (srdPwlActive) {
|
|
258
|
+
const srdTerms = { [srd]: 1 };
|
|
259
|
+
for (const [v, c] of Object.entries(myMagiTerms))
|
|
260
|
+
srdTerms[v] = -srdRate * c;
|
|
261
|
+
constraints.push(` srd${t}: ${expr(srdTerms)} >= ${fmt(srdRate * (magiBase[t] - srdStart))}`);
|
|
262
|
+
}
|
|
263
|
+
// ti split across federal segments (convex; low bands fill first).
|
|
264
|
+
const split = { [ti]: 1 };
|
|
265
|
+
segVars.forEach((s) => (split[s] = -1));
|
|
266
|
+
constraints.push(` fsplit${t}: ${expr(split)} = 0`);
|
|
267
|
+
segs.forEach((seg, i) => {
|
|
268
|
+
if (seg.width !== null)
|
|
269
|
+
segBounds.push(` 0 <= ${segVars[i]} <= ${fmt(seg.width)}`);
|
|
270
|
+
});
|
|
271
|
+
// State PWL (Step 3): a second convex partition of the same `ti` into the
|
|
272
|
+
// state's brackets. Present only for progressive (non-flat-override) states;
|
|
273
|
+
// widths are UNSCALED, matching the ledger's stand-in convention (state
|
|
274
|
+
// packs apply at face value for future years, like the federal deduction
|
|
275
|
+
// and brackets). When absent the flat `stateRate` term below is the whole
|
|
276
|
+
// state tax (byte-identical LP).
|
|
277
|
+
const stateSegs = y.stateBrackets;
|
|
278
|
+
const stateSegVars = stateSegs?.map((_, i) => `sseg${t}_${i}`) ?? [];
|
|
279
|
+
if (stateSegs && stateSegs.length > 0) {
|
|
280
|
+
const ssplit = { [ti]: 1 };
|
|
281
|
+
stateSegVars.forEach((s) => (ssplit[s] = -1));
|
|
282
|
+
constraints.push(` ssplit${t}: ${expr(ssplit)} = 0`);
|
|
283
|
+
stateSegs.forEach((seg, i) => {
|
|
284
|
+
if (seg.width !== null)
|
|
285
|
+
segBounds.push(` 0 <= ${stateSegVars[i]} <= ${fmt(seg.width)}`);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
// IRMAA: MAGI (≈ gross ordinary + realized taxable gains) over a tier
|
|
289
|
+
// threshold in the *premium* year forces its binary on. Same-year MAGI by
|
|
290
|
+
// default; under the two-year lookback (Step 4) the trigger is year (t−2)'s
|
|
291
|
+
// MAGI, matching the exact ledger's causality (a conversion at 63 raises the
|
|
292
|
+
// 65 premium) — shifted to year (t−1)'s MAGI when SSA-44 redetermination
|
|
293
|
+
// applies (see OptimizerYear.ssa44Redetermination). Premium years with no
|
|
294
|
+
// in-horizon source (t<2 under lookback) have exogenous premiums the ledger
|
|
295
|
+
// prices, so no binary is modeled.
|
|
296
|
+
const irmaaSrc = irmaaLookback ? (y.ssa44Redetermination ? t - 1 : t - 2) : t;
|
|
297
|
+
const hasIrmaaBinaries = irmaaSrc >= 0;
|
|
298
|
+
if (hasIrmaaBinaries) {
|
|
299
|
+
const srcTerms = magiTerms[irmaaSrc];
|
|
300
|
+
const srcBase = magiBase[irmaaSrc];
|
|
301
|
+
irmaa.forEach((tier, k) => {
|
|
302
|
+
const thr = tier.threshold(y.filingStatus) * y.inflationScale;
|
|
303
|
+
const step = { ...srcTerms, [irmaaVars[k]]: -bigM };
|
|
304
|
+
constraints.push(` irmaa${t}_${k}: ${expr(step)} <= ${fmt(thr - srcBase)}`);
|
|
305
|
+
binaries.push(irmaaVars[k]);
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
// tax_t = Σ rate·fseg + stateRate·ti + Σ surcharge·irmaaBinary (defined inline
|
|
309
|
+
// in the cash-flow constraint to avoid a separate variable).
|
|
310
|
+
const taxTerms = {};
|
|
311
|
+
segs.forEach((seg, i) => (taxTerms[segVars[i]] = seg.rate));
|
|
312
|
+
// Flat state/local rate on `ti` (override + local), plus the progressive
|
|
313
|
+
// state bracket PWL when supplied.
|
|
314
|
+
taxTerms[ti] = (taxTerms[ti] ?? 0) + y.stateRate;
|
|
315
|
+
if (stateSegs && stateSegs.length > 0)
|
|
316
|
+
stateSegs.forEach((seg, i) => (taxTerms[stateSegVars[i]] = seg.rate));
|
|
317
|
+
// The surcharge is a cash cost in the premium year t; its binary is whatever
|
|
318
|
+
// the trigger constraint above bound (same-year or the t−2 lookback source).
|
|
319
|
+
if (hasIrmaaBinaries)
|
|
320
|
+
irmaa.forEach((tier, k) => (taxTerms[irmaaVars[k]] = tier.surcharge));
|
|
321
|
+
// Cash flow: withdrawals + exogenous cash − tax = spendingNeed + save.
|
|
322
|
+
// wt + wi + wo + wtax·(1 − ltcgCost) + exoCash − tax = spendingNeed + save
|
|
323
|
+
// A taxable withdrawal yields less net cash than a tax-free one because its
|
|
324
|
+
// gain fraction is taxed at the LTCG rate — so the solver stops treating
|
|
325
|
+
// taxable draws as free and prefers the tax-free bucket where it can.
|
|
326
|
+
const cash = { [wt]: 1, [wi]: 1, [wo]: 1, [save]: -1 };
|
|
327
|
+
if (hasTaxable)
|
|
328
|
+
cash[wtax] = taxableNetCash;
|
|
329
|
+
for (const [v, c] of Object.entries(taxTerms))
|
|
330
|
+
cash[v] = (cash[v] ?? 0) - c;
|
|
331
|
+
constraints.push(` cash${t}: ${expr(cash)} = ${fmt(y.spendingNeed - y.exogenousCash)}`);
|
|
332
|
+
// RMD floor: discretionary+forced taxable distribution ≥ balance ÷ divisor.
|
|
333
|
+
if (y.rmdDivisor && y.rmdDivisor > 0) {
|
|
334
|
+
const rmd = { [wt]: 1, [`trad${t}`]: -1 / y.rmdDivisor };
|
|
335
|
+
constraints.push(` rmd${t}: ${expr(rmd)} >= 0`);
|
|
336
|
+
}
|
|
337
|
+
if (y.inheritedDistributionDivisor && y.inheritedDistributionDivisor > 0) {
|
|
338
|
+
const inheritedFloor = { [wi]: 1, [`inh${t}`]: -1 / y.inheritedDistributionDivisor };
|
|
339
|
+
constraints.push(` inhrmd${t}: ${expr(inheritedFloor)} >= 0`);
|
|
340
|
+
}
|
|
341
|
+
else if (y.inheritedDistribution > 0) {
|
|
342
|
+
constraints.push(` inhrmd${t}: + 1 ${wi} >= ${fmt(y.inheritedDistribution)}`);
|
|
343
|
+
}
|
|
344
|
+
if (maxConv !== undefined)
|
|
345
|
+
segBounds.push(` 0 <= ${conv} <= ${fmt(maxConv)}`);
|
|
346
|
+
// Balance recursions (next start = growth × (this start ± activity)).
|
|
347
|
+
// Scheduled contribution / employer-match inflows land in their bucket the
|
|
348
|
+
// same year and grow like the exact ledger's deposits (which are applied
|
|
349
|
+
// before end-of-year growth); their cash cost is already in spendingNeed.
|
|
350
|
+
const trad = { [`trad${t + 1}`]: 1, [`trad${t}`]: -g, [conv]: g, [wt]: g };
|
|
351
|
+
constraints.push(` trad${t}: ${expr(trad)} = ${fmt(g * y.tradInflow)}`);
|
|
352
|
+
const inherited = { [`inh${t + 1}`]: 1, [`inh${t}`]: -g, [wi]: g };
|
|
353
|
+
constraints.push(` inh${t}: ${expr(inherited)} = 0`);
|
|
354
|
+
// Tax-free bucket: `otherInflow` net of the taxable share lands here; the
|
|
355
|
+
// taxable share seeds the taxable bucket below. Surplus `save` and Roth
|
|
356
|
+
// conversions accrue to this tax-free bucket.
|
|
357
|
+
const taxFreeInflow = hasTaxable ? Math.max(0, y.otherInflow - (y.taxableInflow ?? 0)) : y.otherInflow;
|
|
358
|
+
const other = { [`other${t + 1}`]: 1, [`other${t}`]: -g, [conv]: -g, [wo]: g, [save]: -g };
|
|
359
|
+
constraints.push(` other${t}: ${expr(other)} = ${fmt(g * taxFreeInflow)}`);
|
|
360
|
+
if (hasTaxable) {
|
|
361
|
+
const taxable = { [`taxable${t + 1}`]: 1, [`taxable${t}`]: -g, [wtax]: g };
|
|
362
|
+
constraints.push(` taxable${t}: ${expr(taxable)} = ${fmt(g * (y.taxableInflow ?? 0))}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
// Objective: ending other (whole) + ending traditional net of the haircut,
|
|
366
|
+
// expressed in today's dollars (a constant deflator; argmax unchanged). Plus a
|
|
367
|
+
// tiny per-conversion reward so that discretionary traditional-drainage — which
|
|
368
|
+
// a conversion and a taxable withdrawal-to-savings model identically here — is
|
|
369
|
+
// canonically reported as a CONVERSION (the only lever `simulate` can act on
|
|
370
|
+
// via the schedule). ε ≪ any bracket-rate difference, so it breaks genuine
|
|
371
|
+
// ties only and never drives an uneconomic conversion.
|
|
372
|
+
const deflate = input.realDollarFactor ?? 1;
|
|
373
|
+
const CONVERSION_TIE_BREAK = 1e-6;
|
|
374
|
+
const objTerms = {
|
|
375
|
+
[`other${n}`]: deflate,
|
|
376
|
+
[`trad${n}`]: deflate * (1 - liquidationRate),
|
|
377
|
+
[`inh${n}`]: deflate * (1 - liquidationRate),
|
|
378
|
+
};
|
|
379
|
+
// Ending taxable counts at full value (heirs get a basis step-up; the exact
|
|
380
|
+
// ledger prices the true estate treatment). Only present when split out.
|
|
381
|
+
if (hasTaxable)
|
|
382
|
+
objTerms[`taxable${n}`] = deflate;
|
|
383
|
+
for (let t = 0; t < n; t++)
|
|
384
|
+
objTerms[`conv${t}`] = CONVERSION_TIE_BREAK;
|
|
385
|
+
const objective = expr(objTerms);
|
|
386
|
+
const bounds = [
|
|
387
|
+
` trad0 = ${fmt(input.openingTrad)}`,
|
|
388
|
+
` inh0 = ${fmt(input.openingInheritedTrad)}`,
|
|
389
|
+
` other0 = ${fmt(input.openingOther)}`,
|
|
390
|
+
...(hasTaxable ? [` taxable0 = ${fmt(openingTaxable)}`] : []),
|
|
391
|
+
...segBounds,
|
|
392
|
+
];
|
|
393
|
+
const lp = [
|
|
394
|
+
'Maximize',
|
|
395
|
+
` obj: ${objective}`,
|
|
396
|
+
'Subject To',
|
|
397
|
+
...constraints,
|
|
398
|
+
'Bounds',
|
|
399
|
+
...bounds,
|
|
400
|
+
'Binaries',
|
|
401
|
+
` ${binaries.join(' ')}`,
|
|
402
|
+
'End',
|
|
403
|
+
].join('\n');
|
|
404
|
+
return { lp, binaryCount: binaries.length, bigM };
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Standard deduction (+ age-65 addition) for the year — UNSCALED, matching the
|
|
408
|
+
* exact ledger's stand-in convention: `computeFederalTax` applies the latest
|
|
409
|
+
* pack's deduction (and brackets) at face value for future years, so the LP
|
|
410
|
+
* must too. Scaling it by inflation (as v1 did) overstated future deductions by
|
|
411
|
+
* up to ~2× at long horizons, under-taxing late-year conversions in-solve and
|
|
412
|
+
* driving systematic over-conversion the exact ledger then priced as harmful.
|
|
413
|
+
* IRMAA thresholds ARE inflation-scaled in both engines (`inflationScale`).
|
|
414
|
+
* The OBBBA senior deduction is handled separately in the model builder (it
|
|
415
|
+
* needs the MAGI-dependent phase-out, not a constant).
|
|
416
|
+
*/
|
|
417
|
+
function pack65Deduction(y) {
|
|
418
|
+
const f = y.filingStatus;
|
|
419
|
+
const base = y.pack.federalTax.standardDeduction[f];
|
|
420
|
+
const age65 = y.peopleAged65Plus > 0 ? y.pack.federalTax.age65Addition[f] * y.peopleAged65Plus : 0;
|
|
421
|
+
return base + age65;
|
|
422
|
+
}
|
|
423
|
+
let highsPromise = null;
|
|
424
|
+
async function getHighs(locateFile) {
|
|
425
|
+
if (!highsPromise) {
|
|
426
|
+
// highs ships CJS with an `export default` in its types; under Node16
|
|
427
|
+
// resolution the namespace type isn't callable, but at runtime `.default`
|
|
428
|
+
// is the loader function in both Node ESM and bundler interop.
|
|
429
|
+
const loader = (await import('highs')).default;
|
|
430
|
+
highsPromise = loader(locateFile ? { locateFile } : undefined);
|
|
431
|
+
}
|
|
432
|
+
return highsPromise;
|
|
433
|
+
}
|
|
434
|
+
const STATUS_MAP = {
|
|
435
|
+
Optimal: 'optimal',
|
|
436
|
+
'Time limit reached': 'timeout',
|
|
437
|
+
'Iteration limit reached': 'timeout',
|
|
438
|
+
Infeasible: 'infeasible',
|
|
439
|
+
'Primal infeasible or unbounded': 'infeasible',
|
|
440
|
+
};
|
|
441
|
+
/**
|
|
442
|
+
* Solve the multi-year conversion/withdrawal MILP. Returns a per-year schedule
|
|
443
|
+
* plus a conversions array ready to feed the `optimized` Roth strategy.
|
|
444
|
+
*/
|
|
445
|
+
export async function optimizeSchedule(input) {
|
|
446
|
+
const model = buildOptimizerModel(input);
|
|
447
|
+
const solve = input.options?.solve ?? (await getHighs(input.options?.locateFile)).solve;
|
|
448
|
+
const t0 = globalThis.performance?.now?.() ?? Date.now();
|
|
449
|
+
const sol = solve(model.lp, {
|
|
450
|
+
output_flag: false,
|
|
451
|
+
time_limit: input.options?.timeLimitSec ?? 10,
|
|
452
|
+
});
|
|
453
|
+
const solveMs = (globalThis.performance?.now?.() ?? Date.now()) - t0;
|
|
454
|
+
const objectiveValue = typeof sol.ObjectiveValue === 'number' ? sol.ObjectiveValue : 0;
|
|
455
|
+
const columns = sol.Columns ?? {};
|
|
456
|
+
const status = STATUS_MAP[sol.Status] ?? (objectiveValue ? 'feasible' : 'infeasible');
|
|
457
|
+
const col = (name) => {
|
|
458
|
+
const c = columns[name];
|
|
459
|
+
return c && typeof c.Primal === 'number' ? c.Primal : 0;
|
|
460
|
+
};
|
|
461
|
+
// Gain fraction of a taxable withdrawal, for the LTCG readout (mirrors the model).
|
|
462
|
+
const gainFraction = Math.min(1, Math.max(0, 1 - (input.taxableBasisRatio ?? 1)));
|
|
463
|
+
const ltcgRate = Number.isFinite(input.ltcgRate) ? Math.max(0, input.ltcgRate ?? 0) : 0;
|
|
464
|
+
const schedule = [];
|
|
465
|
+
const conversions = [];
|
|
466
|
+
let lifetimeTax = 0;
|
|
467
|
+
for (let t = 0; t < input.years.length; t++) {
|
|
468
|
+
const y = input.years[t];
|
|
469
|
+
const conversion = round(col(`conv${t}`));
|
|
470
|
+
const withdrawTraditional = round(col(`wt${t}`));
|
|
471
|
+
const withdrawInheritedTraditional = round(col(`wi${t}`));
|
|
472
|
+
const withdrawTaxable = round(col(`wtax${t}`));
|
|
473
|
+
const taxableGainRealized = round(withdrawTaxable * gainFraction);
|
|
474
|
+
const taxableOrdinary = round(col(`ti${t}`));
|
|
475
|
+
// Reconstruct modeled tax for the readout (federal PWL + state + IRMAA + LTCG).
|
|
476
|
+
let tier = 0;
|
|
477
|
+
const irmaa = irmaaIncrements(y.pack);
|
|
478
|
+
irmaa.forEach((_, k) => {
|
|
479
|
+
if (col(`irmaa${t}_${k}`) > 0.5)
|
|
480
|
+
tier = k + 1;
|
|
481
|
+
});
|
|
482
|
+
lifetimeTax +=
|
|
483
|
+
federalTaxOn(taxableOrdinary, y) +
|
|
484
|
+
taxableOrdinary * y.stateRate +
|
|
485
|
+
stateBracketTaxOn(taxableOrdinary, y) +
|
|
486
|
+
irmaaSurchargeFor(y, tier) +
|
|
487
|
+
taxableGainRealized * ltcgRate;
|
|
488
|
+
if (conversion > 0.5)
|
|
489
|
+
conversions.push({ year: y.year, amount: conversion });
|
|
490
|
+
schedule.push({
|
|
491
|
+
year: y.year,
|
|
492
|
+
conversion,
|
|
493
|
+
withdrawTraditional,
|
|
494
|
+
withdrawInheritedTraditional,
|
|
495
|
+
withdrawOther: round(col(`wo${t}`)),
|
|
496
|
+
withdrawTaxable,
|
|
497
|
+
taxableGainRealized,
|
|
498
|
+
taxableOrdinary,
|
|
499
|
+
irmaaTier: tier,
|
|
500
|
+
endTrad: round(col(`trad${t + 1}`)),
|
|
501
|
+
endInheritedTrad: round(col(`inh${t + 1}`)),
|
|
502
|
+
endOther: round(col(`other${t + 1}`)),
|
|
503
|
+
endTaxable: round(col(`taxable${t + 1}`)),
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
return {
|
|
507
|
+
status,
|
|
508
|
+
endingAfterTax: round(objectiveValue),
|
|
509
|
+
lifetimeTax: round(lifetimeTax),
|
|
510
|
+
schedule,
|
|
511
|
+
conversions,
|
|
512
|
+
solveMs,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
function round(x) {
|
|
516
|
+
return Math.round(x * 100) / 100;
|
|
517
|
+
}
|
|
518
|
+
function federalTaxOn(taxableOrdinary, y) {
|
|
519
|
+
let remaining = taxableOrdinary;
|
|
520
|
+
let tax = 0;
|
|
521
|
+
for (const seg of bracketSegments(y.pack, y.filingStatus)) {
|
|
522
|
+
if (remaining <= 0)
|
|
523
|
+
break;
|
|
524
|
+
const take = seg.width === null ? remaining : Math.min(remaining, seg.width);
|
|
525
|
+
tax += take * seg.rate;
|
|
526
|
+
remaining -= take;
|
|
527
|
+
}
|
|
528
|
+
return tax;
|
|
529
|
+
}
|
|
530
|
+
/** State bracket PWL tax on taxable ordinary income (0 when the year is flat-only). */
|
|
531
|
+
function stateBracketTaxOn(taxableOrdinary, y) {
|
|
532
|
+
if (!y.stateBrackets || y.stateBrackets.length === 0)
|
|
533
|
+
return 0;
|
|
534
|
+
let remaining = taxableOrdinary;
|
|
535
|
+
let tax = 0;
|
|
536
|
+
for (const seg of y.stateBrackets) {
|
|
537
|
+
if (remaining <= 0)
|
|
538
|
+
break;
|
|
539
|
+
const width = seg.width === null ? remaining : Math.min(remaining, seg.width);
|
|
540
|
+
tax += width * seg.rate;
|
|
541
|
+
remaining -= width;
|
|
542
|
+
}
|
|
543
|
+
return tax;
|
|
544
|
+
}
|
|
545
|
+
function irmaaSurchargeFor(y, tier) {
|
|
546
|
+
if (tier <= 0)
|
|
547
|
+
return 0;
|
|
548
|
+
return irmaaIncrements(y.pack)
|
|
549
|
+
.slice(0, tier)
|
|
550
|
+
.reduce((a, t) => a + t.surcharge, 0);
|
|
551
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Roth ordering + 5-year rules (roadmap V8, §4 tax depth).
|
|
3
|
+
*
|
|
4
|
+
* A Roth distribution comes out in a fixed IRS order, and each layer is taxed
|
|
5
|
+
* differently before the account is "qualified" (owner 59½+ and the account 5+
|
|
6
|
+
* years old):
|
|
7
|
+
* 1. Contributions — always tax- AND penalty-free, at any age.
|
|
8
|
+
* 2. Conversions — oldest first; already taxed at conversion, so never
|
|
9
|
+
* income-taxed again, BUT a 10% recapture penalty applies
|
|
10
|
+
* to any layer tapped within 5 years while under 59½.
|
|
11
|
+
* 3. Earnings — last out; before 59½ they are ordinary income PLUS the
|
|
12
|
+
* 10% penalty (a non-qualified distribution).
|
|
13
|
+
*
|
|
14
|
+
* The engine approximates the 59½ boundary as "age 60 attained," matching the
|
|
15
|
+
* traditional early-withdrawal penalty elsewhere in the simulation, and folds
|
|
16
|
+
* the account's own 5-year clock into that age proxy (an existing Roth is
|
|
17
|
+
* almost always open >5 years by 59½). Conversions performed during the
|
|
18
|
+
* projection each carry their own explicit 5-year clock, which is the case that
|
|
19
|
+
* actually matters for the early-retirement "conversion ladder."
|
|
20
|
+
*/
|
|
21
|
+
/** The engine's 59½ proxy: no early-withdrawal penalty once age 60 is attained. */
|
|
22
|
+
export declare const ROTH_QUALIFIED_AGE = 60;
|
|
23
|
+
/** A conversion is "seasoned" (no recapture penalty) once this many years pass. */
|
|
24
|
+
export declare const ROTH_SEASONING_YEARS = 5;
|
|
25
|
+
export interface RothConversionLayer {
|
|
26
|
+
/** Calendar year the conversion occurred (starts its 5-year clock). */
|
|
27
|
+
year: number;
|
|
28
|
+
/** Remaining un-withdrawn converted principal in this layer (nominal $). */
|
|
29
|
+
amount: number;
|
|
30
|
+
/**
|
|
31
|
+
* Portion of `amount` that was included in income at conversion. The 10%
|
|
32
|
+
* recapture penalty on an unseasoned tap applies only to this taxable share —
|
|
33
|
+
* nondeductible IRA basis rolled in was never taxed, so it recaptures nothing
|
|
34
|
+
* (IRS Pub 590-B). The full `amount` still returns tax-free before earnings.
|
|
35
|
+
* Absent (or equal to `amount`) for a fully-taxable conversion.
|
|
36
|
+
*/
|
|
37
|
+
taxableAmount: number;
|
|
38
|
+
}
|
|
39
|
+
export interface RothBasisState {
|
|
40
|
+
/** Direct-contribution basis remaining (nominal $); withdrawn first, always free. */
|
|
41
|
+
contributionBasis: number;
|
|
42
|
+
/** Conversion principal layers, oldest first; penalized if tapped <5y while pre-59½. */
|
|
43
|
+
conversionLayers: RothConversionLayer[];
|
|
44
|
+
}
|
|
45
|
+
export interface RothWithdrawalSplit {
|
|
46
|
+
contributions: number;
|
|
47
|
+
conversions: number;
|
|
48
|
+
earnings: number;
|
|
49
|
+
/** 10% early-withdrawal penalty on unseasoned conversions + pre-59½ earnings. */
|
|
50
|
+
penalty: number;
|
|
51
|
+
/** Earnings portion taxed as ordinary income (pre-59½ non-qualified distribution). */
|
|
52
|
+
taxableOrdinary: number;
|
|
53
|
+
/** Basis state after the withdrawal; the caller commits this only for the final plan. */
|
|
54
|
+
next: RothBasisState;
|
|
55
|
+
}
|
|
56
|
+
/** An empty (fresh) basis state, e.g. for a brand-new Roth account. */
|
|
57
|
+
export declare function emptyRothBasis(contributionBasis?: number): RothBasisState;
|
|
58
|
+
/**
|
|
59
|
+
* Split a Roth withdrawal into contribution / conversion / earnings buckets and
|
|
60
|
+
* the early-distribution tax + penalty it incurs. Pure: returns the post-
|
|
61
|
+
* withdrawal `next` state rather than mutating, so callers can probe a candidate
|
|
62
|
+
* withdrawal during the tax fixed-point and only commit once it converges.
|
|
63
|
+
*
|
|
64
|
+
* `amount` is assumed ≤ the account balance (the caller drains against available
|
|
65
|
+
* balance), so the remainder after contributions and conversions is earnings.
|
|
66
|
+
*/
|
|
67
|
+
export declare function splitRothWithdrawal(state: RothBasisState, amount: number, year: number, age: number): RothWithdrawalSplit;
|