@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,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pension lump-sum vs annuity decision module (annuity-pension-and-home-equity
|
|
3
|
+
* decisions, step 3).
|
|
4
|
+
*
|
|
5
|
+
* Two complementary surfaces, verdicts framed as tradeoffs — never advice:
|
|
6
|
+
* - deterministic PV math: the pension annuity's present value at a chosen
|
|
7
|
+
* discount rate, its survivor-option value, and a sensitivity table over
|
|
8
|
+
* discount rate × longevity — the classic "what does the lump sum have to
|
|
9
|
+
* beat" view, golden-tested by hand;
|
|
10
|
+
* - a scenario pair for the exact ledger / shared-path Monte Carlo: keep the
|
|
11
|
+
* annuity vs take the lump sum (a tax-free direct rollover into a
|
|
12
|
+
* traditional account in the election year — see the ledger's
|
|
13
|
+
* pension-lump-sum block), so taxes, survivor interplay, and sequence risk
|
|
14
|
+
* are priced by the engine rather than a side model.
|
|
15
|
+
*/
|
|
16
|
+
import { EMBEDDED_REAL_YIELD_CURVE } from '../params/index.js';
|
|
17
|
+
/**
|
|
18
|
+
* Present value (valuation-year dollars) of the pension's payment stream:
|
|
19
|
+
* the full benefit while the owner lives (from startAge), then survivorPct of
|
|
20
|
+
* it while the survivor lives — the same continuation rule the ledger applies
|
|
21
|
+
* (survivor benefits require the owner to have reached the start age; a
|
|
22
|
+
* survivor election on a pension that never started pays nothing here too).
|
|
23
|
+
* Payments are nominal with COLA compounding from the start age; discounting
|
|
24
|
+
* is annual at year offsets from the valuation year.
|
|
25
|
+
*/
|
|
26
|
+
export function pensionAnnuityPresentValue(inputs) {
|
|
27
|
+
const annual = inputs.monthlyAmount * 12;
|
|
28
|
+
if (annual <= 0)
|
|
29
|
+
return 0;
|
|
30
|
+
const discount = 1 + inputs.discountRatePct / 100;
|
|
31
|
+
const horizonAge = Math.max(inputs.ownerDeathAge, inputs.survivor ? inputs.ownerCurrentAge + (inputs.survivor.deathAge - inputs.survivor.currentAge) : 0);
|
|
32
|
+
let pv = 0;
|
|
33
|
+
for (let ownerAge = Math.max(inputs.startAge, inputs.ownerCurrentAge); ownerAge <= horizonAge; ownerAge++) {
|
|
34
|
+
const t = ownerAge - inputs.ownerCurrentAge;
|
|
35
|
+
const grown = annual * Math.pow(1 + inputs.colaPct / 100, ownerAge - inputs.startAge);
|
|
36
|
+
const ownerAlive = ownerAge <= inputs.ownerDeathAge;
|
|
37
|
+
let paid = 0;
|
|
38
|
+
if (ownerAlive) {
|
|
39
|
+
paid = grown;
|
|
40
|
+
}
|
|
41
|
+
else if (inputs.survivor && inputs.ownerDeathAge >= inputs.startAge) {
|
|
42
|
+
const survivorAge = inputs.survivor.currentAge + t;
|
|
43
|
+
if (survivorAge <= inputs.survivor.deathAge)
|
|
44
|
+
paid = grown * (inputs.survivorPct / 100);
|
|
45
|
+
}
|
|
46
|
+
if (paid <= 0)
|
|
47
|
+
continue;
|
|
48
|
+
pv += paid / Math.pow(discount, t);
|
|
49
|
+
}
|
|
50
|
+
return pv;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Sensitivity of the annuity's PV to discount rate × owner longevity, against
|
|
54
|
+
* the lump-sum offer. Bounded: at most 5 × 5 cells.
|
|
55
|
+
*/
|
|
56
|
+
export function pensionLumpSumSensitivity(base, lumpSum, discountRatesPct, ownerDeathAges) {
|
|
57
|
+
const rates = discountRatesPct.slice(0, 5);
|
|
58
|
+
const ages = ownerDeathAges.slice(0, 5);
|
|
59
|
+
const rows = ages.map((ownerDeathAge) => ({
|
|
60
|
+
ownerDeathAge,
|
|
61
|
+
cells: rates.map((discountRatePct) => {
|
|
62
|
+
const presentValue = pensionAnnuityPresentValue({ ...base, ownerDeathAge, discountRatePct });
|
|
63
|
+
return {
|
|
64
|
+
discountRatePct,
|
|
65
|
+
presentValue,
|
|
66
|
+
ratioToLumpSum: lumpSum > 0 ? presentValue / lumpSum : 0,
|
|
67
|
+
};
|
|
68
|
+
}),
|
|
69
|
+
}));
|
|
70
|
+
return { rows, discountRatesPct: [...rates], lumpSum };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Curve-anchored nominal discount rate (percent): the embedded TIPS par real
|
|
74
|
+
* yield at the given horizon plus assumed inflation — the "market rate" column
|
|
75
|
+
* of the sensitivity table (a corporate-bond-style spread is the user's call).
|
|
76
|
+
*/
|
|
77
|
+
export function curveNominalDiscountRatePct(horizonYears, inflationPct) {
|
|
78
|
+
const points = EMBEDDED_REAL_YIELD_CURVE.points;
|
|
79
|
+
if (points.length === 0)
|
|
80
|
+
return inflationPct;
|
|
81
|
+
let real = points[points.length - 1].realYieldPct;
|
|
82
|
+
if (horizonYears <= points[0].maturityYears) {
|
|
83
|
+
real = points[0].realYieldPct;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
87
|
+
const a = points[i];
|
|
88
|
+
const b = points[i + 1];
|
|
89
|
+
if (horizonYears >= a.maturityYears && horizonYears <= b.maturityYears) {
|
|
90
|
+
real =
|
|
91
|
+
a.realYieldPct +
|
|
92
|
+
((b.realYieldPct - a.realYieldPct) * (horizonYears - a.maturityYears)) / (b.maturityYears - a.maturityYears);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return real + inflationPct;
|
|
98
|
+
}
|
|
99
|
+
function dobYear(dob) {
|
|
100
|
+
return Number(dob.slice(0, 4));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The concrete "take the lump sum" plan patch for a pension with an offer:
|
|
104
|
+
* installs the election, rolling into the largest owner-matched traditional
|
|
105
|
+
* account — or a new zero-balance rollover IRA when the plan has none. Shared
|
|
106
|
+
* by the candidate generator and the pension-election insight so both surfaces
|
|
107
|
+
* model the identical mechanics. Null when the pension carries no offer.
|
|
108
|
+
*/
|
|
109
|
+
export function pensionTakeLumpSumPatch(plan, pension, startYear) {
|
|
110
|
+
if (!pension.lumpSumOffer)
|
|
111
|
+
return null;
|
|
112
|
+
const ownerId = pension.ownerPersonId ?? plan.household.people[0]?.id ?? null;
|
|
113
|
+
const existing = plan.accounts
|
|
114
|
+
.filter((a) => a.type === 'traditional' && !a.inherited && (a.ownerPersonId === ownerId || a.ownerPersonId === null))
|
|
115
|
+
.sort((a, b) => b.balance - a.balance)[0];
|
|
116
|
+
// Namespaced by pension id so the synthetic rollover IRA cannot collide
|
|
117
|
+
// with a real user account id.
|
|
118
|
+
const rollover = existing
|
|
119
|
+
? undefined
|
|
120
|
+
: {
|
|
121
|
+
id: `pension-rollover-${startYear}-${pension.id}`,
|
|
122
|
+
type: 'traditional',
|
|
123
|
+
kind: 'ira',
|
|
124
|
+
name: `${pension.name} rollover IRA`,
|
|
125
|
+
ownerPersonId: ownerId,
|
|
126
|
+
annualReturnPct: null,
|
|
127
|
+
balance: 0,
|
|
128
|
+
annualContribution: 0,
|
|
129
|
+
};
|
|
130
|
+
const rolloverAccountId = existing?.id ?? rollover.id;
|
|
131
|
+
const accounts = plan.accounts.map((a) => a.id === pension.id ? { ...pension, lumpSumElection: { rolloverAccountId } } : a);
|
|
132
|
+
return { accounts: rollover ? [...accounts, rollover] : accounts };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Scenario-pair candidates for pensions with a lump-sum offer on record:
|
|
136
|
+
* "take the lump sum" (via pensionTakeLumpSumPatch) and, when already
|
|
137
|
+
* elected, "keep the annuity" (removes the election). Bounded: ≤ 2 pensions,
|
|
138
|
+
* one candidate each; the exact ledger prices both.
|
|
139
|
+
*/
|
|
140
|
+
export const pensionLumpSumGenerator = {
|
|
141
|
+
id: 'pension-lump-sum',
|
|
142
|
+
generate(ctx) {
|
|
143
|
+
const plan = ctx.plan;
|
|
144
|
+
const startYear = ctx.simulateOptions.startYear;
|
|
145
|
+
const candidates = [];
|
|
146
|
+
const pensions = plan.accounts.filter((a) => a.type === 'pension' && a.lumpSumOffer !== undefined);
|
|
147
|
+
for (const pension of pensions.slice(0, 2)) {
|
|
148
|
+
const offer = pension.lumpSumOffer;
|
|
149
|
+
if (offer.electionYear < startYear)
|
|
150
|
+
continue; // election already past; nothing to model
|
|
151
|
+
if (pension.lumpSumElection) {
|
|
152
|
+
candidates.push({
|
|
153
|
+
id: `pension-keep-annuity-${pension.id}`,
|
|
154
|
+
source: 'heuristic',
|
|
155
|
+
category: 'guaranteed-income',
|
|
156
|
+
label: `Keep the ${pension.name} annuity`,
|
|
157
|
+
explanation: 'Runs the plan with the lump-sum election removed, so the pension pays its lifetime annuity and the exact ledger prices what the lump sum gives up.',
|
|
158
|
+
planPatch: {
|
|
159
|
+
accounts: plan.accounts.map((a) => (a.id === pension.id ? { ...pension, lumpSumElection: undefined } : a)),
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const patch = pensionTakeLumpSumPatch(plan, pension, startYear);
|
|
165
|
+
if (!patch)
|
|
166
|
+
continue;
|
|
167
|
+
candidates.push({
|
|
168
|
+
id: `pension-lump-sum-${pension.id}`,
|
|
169
|
+
source: 'heuristic',
|
|
170
|
+
category: 'guaranteed-income',
|
|
171
|
+
label: `Take the ${pension.name} lump sum`,
|
|
172
|
+
explanation: `Rolls the $${Math.round(offer.amount).toLocaleString()} offer into a traditional IRA in ${offer.electionYear} (tax-free direct rollover) instead of the lifetime annuity, and reprices the whole plan on the exact ledger.`,
|
|
173
|
+
planPatch: patch,
|
|
174
|
+
metadata: { pensionId: pension.id, lumpSum: Math.round(offer.amount), electionYear: offer.electionYear },
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return candidates;
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* The decision view's deterministic analysis for every pension carrying a
|
|
182
|
+
* lump-sum offer. Discount rates: the curve-anchored rate ± 1% and two fixed
|
|
183
|
+
* planning rates; longevity: planning age and ±3 years.
|
|
184
|
+
*/
|
|
185
|
+
export function analyzePensionElections(plan, startYear) {
|
|
186
|
+
const analyses = [];
|
|
187
|
+
for (const account of plan.accounts) {
|
|
188
|
+
if (account.type !== 'pension' || !account.lumpSumOffer)
|
|
189
|
+
continue;
|
|
190
|
+
const ownerId = account.ownerPersonId ?? plan.household.people[0]?.id;
|
|
191
|
+
const owner = plan.household.people.find((p) => p.id === ownerId) ?? plan.household.people[0];
|
|
192
|
+
if (!owner)
|
|
193
|
+
continue;
|
|
194
|
+
const other = plan.household.people.find((p) => p.id !== owner.id);
|
|
195
|
+
const ownerCurrentAge = startYear - dobYear(owner.dob);
|
|
196
|
+
const ownerPlanningAge = owner.longevity.planningAge;
|
|
197
|
+
const survivor = other !== undefined
|
|
198
|
+
? { currentAge: startYear - dobYear(other.dob), deathAge: other.longevity.planningAge }
|
|
199
|
+
: undefined;
|
|
200
|
+
const base = {
|
|
201
|
+
monthlyAmount: account.monthlyAmount,
|
|
202
|
+
colaPct: account.colaPct,
|
|
203
|
+
survivorPct: account.survivorPct,
|
|
204
|
+
startAge: account.startAge,
|
|
205
|
+
ownerCurrentAge,
|
|
206
|
+
survivor,
|
|
207
|
+
};
|
|
208
|
+
const horizonYears = Math.max(5, ownerPlanningAge - ownerCurrentAge);
|
|
209
|
+
const curveRatePct = curveNominalDiscountRatePct(horizonYears, plan.assumptions.inflationPct);
|
|
210
|
+
const rates = [
|
|
211
|
+
Math.max(0.5, Math.round((curveRatePct - 1) * 10) / 10),
|
|
212
|
+
Math.round(curveRatePct * 10) / 10,
|
|
213
|
+
Math.round((curveRatePct + 1) * 10) / 10,
|
|
214
|
+
6,
|
|
215
|
+
7,
|
|
216
|
+
].filter((r, i, arr) => arr.indexOf(r) === i);
|
|
217
|
+
const deathAges = [ownerPlanningAge - 3, ownerPlanningAge, ownerPlanningAge + 3].filter((a) => a > ownerCurrentAge);
|
|
218
|
+
analyses.push({
|
|
219
|
+
pensionId: account.id,
|
|
220
|
+
pensionName: account.name,
|
|
221
|
+
lumpSum: account.lumpSumOffer.amount,
|
|
222
|
+
electionYear: account.lumpSumOffer.electionYear,
|
|
223
|
+
presentValueAtCurveRate: pensionAnnuityPresentValue({
|
|
224
|
+
...base,
|
|
225
|
+
ownerDeathAge: ownerPlanningAge,
|
|
226
|
+
discountRatePct: curveRatePct,
|
|
227
|
+
}),
|
|
228
|
+
curveRatePct,
|
|
229
|
+
presentValueSingleLife: pensionAnnuityPresentValue({
|
|
230
|
+
...base,
|
|
231
|
+
survivorPct: 0,
|
|
232
|
+
ownerDeathAge: ownerPlanningAge,
|
|
233
|
+
discountRatePct: curveRatePct,
|
|
234
|
+
}),
|
|
235
|
+
sensitivity: pensionLumpSumSensitivity(base, account.lumpSumOffer.amount, rates, deathAges),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return analyses;
|
|
239
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local search over the exact ledger (ledger-native decision engine, Phase 4).
|
|
3
|
+
*
|
|
4
|
+
* Coordinate descent on a Roth conversion schedule: one decision variable per
|
|
5
|
+
* schedule year (the conversion amount), coarse dollar steps first, then a
|
|
6
|
+
* smaller refinement pass. Every move is scored by running the mutated
|
|
7
|
+
* schedule through the exact ledger and applying the objective policy — cheap
|
|
8
|
+
* screens are never the final score. Deterministic under a fixed simulation
|
|
9
|
+
* budget: fixed year order, fixed step order, and a hard cap on evaluations.
|
|
10
|
+
*/
|
|
11
|
+
import { type EvaluateCandidateOptions } from './evaluateCandidate.js';
|
|
12
|
+
import { type ObjectivePolicy } from './objectives.js';
|
|
13
|
+
import type { DecisionContext, ExactDecisionEvaluation } from './types.js';
|
|
14
|
+
export interface CoordinateDescentOptions {
|
|
15
|
+
/** Hard cap on exact-ledger simulations (including the seed evaluation). */
|
|
16
|
+
maxSimulations?: number;
|
|
17
|
+
/** First-pass step size in dollars. */
|
|
18
|
+
coarseStepDollars?: number;
|
|
19
|
+
/** Refinement-pass step size in dollars. */
|
|
20
|
+
fineStepDollars?: number;
|
|
21
|
+
/** Max full sweeps per step size. */
|
|
22
|
+
maxSweepsPerStep?: number;
|
|
23
|
+
/** A move must beat the incumbent primary metric by more than this. */
|
|
24
|
+
minimumImprovement?: number;
|
|
25
|
+
policy?: ObjectivePolicy;
|
|
26
|
+
evaluation?: EvaluateCandidateOptions;
|
|
27
|
+
/** Base plan patch every mutated schedule is applied on top of. */
|
|
28
|
+
basePatch?: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
export interface CoordinateDescentResult {
|
|
31
|
+
bestConversions: Array<{
|
|
32
|
+
year: number;
|
|
33
|
+
amount: number;
|
|
34
|
+
}>;
|
|
35
|
+
bestEvaluation: ExactDecisionEvaluation;
|
|
36
|
+
/** True when search found a strictly better schedule than the seed. */
|
|
37
|
+
improved: boolean;
|
|
38
|
+
simulationCount: number;
|
|
39
|
+
/** Full sweeps performed across both step sizes. */
|
|
40
|
+
sweepCount: number;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Refine a conversion schedule by coordinate descent, keeping only moves the
|
|
44
|
+
* exact ledger prices as improvements under the policy's primary metric and
|
|
45
|
+
* hard constraints. Returns the seed evaluation unchanged when nothing beats it
|
|
46
|
+
* within the budget.
|
|
47
|
+
*/
|
|
48
|
+
export declare function refineConversionSchedule(ctx: DecisionContext, seedConversions: Array<{
|
|
49
|
+
year: number;
|
|
50
|
+
amount: number;
|
|
51
|
+
}>, options?: CoordinateDescentOptions): CoordinateDescentResult;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local search over the exact ledger (ledger-native decision engine, Phase 4).
|
|
3
|
+
*
|
|
4
|
+
* Coordinate descent on a Roth conversion schedule: one decision variable per
|
|
5
|
+
* schedule year (the conversion amount), coarse dollar steps first, then a
|
|
6
|
+
* smaller refinement pass. Every move is scored by running the mutated
|
|
7
|
+
* schedule through the exact ledger and applying the objective policy — cheap
|
|
8
|
+
* screens are never the final score. Deterministic under a fixed simulation
|
|
9
|
+
* budget: fixed year order, fixed step order, and a hard cap on evaluations.
|
|
10
|
+
*/
|
|
11
|
+
import { evaluateCandidate } from './evaluateCandidate.js';
|
|
12
|
+
import { maximizeAfterTaxEstate } from './objectives.js';
|
|
13
|
+
const DEFAULT_MAX_SIMULATIONS = 48;
|
|
14
|
+
const DEFAULT_COARSE_STEP = 10_000;
|
|
15
|
+
const DEFAULT_FINE_STEP = 2_500;
|
|
16
|
+
const DEFAULT_MAX_SWEEPS_PER_STEP = 3;
|
|
17
|
+
const DEFAULT_MINIMUM_IMPROVEMENT = 1;
|
|
18
|
+
/** Extra coordinate years past the seed's last conversion (taper discovery). */
|
|
19
|
+
const TAPER_EXTENSION_YEARS = 8;
|
|
20
|
+
function normalizeSchedule(conversions) {
|
|
21
|
+
return conversions
|
|
22
|
+
.map((c) => ({ year: c.year, amount: Math.round(c.amount * 100) / 100 }))
|
|
23
|
+
.filter((c) => c.amount > 0)
|
|
24
|
+
.sort((a, b) => a.year - b.year);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Refine a conversion schedule by coordinate descent, keeping only moves the
|
|
28
|
+
* exact ledger prices as improvements under the policy's primary metric and
|
|
29
|
+
* hard constraints. Returns the seed evaluation unchanged when nothing beats it
|
|
30
|
+
* within the budget.
|
|
31
|
+
*/
|
|
32
|
+
export function refineConversionSchedule(ctx, seedConversions, options = {}) {
|
|
33
|
+
const policy = options.policy ?? maximizeAfterTaxEstate;
|
|
34
|
+
const maxSimulations = options.maxSimulations ?? DEFAULT_MAX_SIMULATIONS;
|
|
35
|
+
const coarseStep = options.coarseStepDollars ?? DEFAULT_COARSE_STEP;
|
|
36
|
+
const fineStep = options.fineStepDollars ?? DEFAULT_FINE_STEP;
|
|
37
|
+
const maxSweepsPerStep = options.maxSweepsPerStep ?? DEFAULT_MAX_SWEEPS_PER_STEP;
|
|
38
|
+
const minimumImprovement = options.minimumImprovement ?? DEFAULT_MINIMUM_IMPROVEMENT;
|
|
39
|
+
let simulationCount = 0;
|
|
40
|
+
let sweepCount = 0;
|
|
41
|
+
const makeCandidate = (conversions, moveId) => ({
|
|
42
|
+
id: `search-${moveId}`,
|
|
43
|
+
source: 'search',
|
|
44
|
+
category: 'roth',
|
|
45
|
+
label: 'Locally refined conversion schedule',
|
|
46
|
+
explanation: 'Coordinate-descent mutation of the incumbent schedule, priced on the exact ledger.',
|
|
47
|
+
planPatch: options.basePatch,
|
|
48
|
+
conversions,
|
|
49
|
+
});
|
|
50
|
+
const evaluate = (conversions, moveId) => {
|
|
51
|
+
simulationCount++;
|
|
52
|
+
return evaluateCandidate(ctx, makeCandidate(conversions, moveId), options.evaluation);
|
|
53
|
+
};
|
|
54
|
+
let bestConversions = normalizeSchedule(seedConversions);
|
|
55
|
+
let bestEvaluation = evaluate(bestConversions, 'seed');
|
|
56
|
+
let bestScore = policy.primaryMetric(bestEvaluation, ctx);
|
|
57
|
+
let improved = false;
|
|
58
|
+
// Coordinate set: the seed's years plus a bounded extension past the last
|
|
59
|
+
// seed year. Mutating only seed years can never ADD a conversion year, which
|
|
60
|
+
// locks out taper shapes ("fill hard until RMDs, then convert smaller
|
|
61
|
+
// amounts") — the years after a windowed fill ends are exactly where those
|
|
62
|
+
// live. Bounded and deterministic: at most `TAPER_EXTENSION_YEARS` extra
|
|
63
|
+
// coordinates, in ascending year order.
|
|
64
|
+
const seedYears = bestConversions.map((c) => c.year);
|
|
65
|
+
const lastSeedYear = seedYears.length > 0 ? seedYears[seedYears.length - 1] : ctx.baselineResult.startYear - 1;
|
|
66
|
+
const extensionYears = ctx.baselineResult.years
|
|
67
|
+
.map((year) => year.year)
|
|
68
|
+
.filter((year) => year > lastSeedYear)
|
|
69
|
+
.slice(0, TAPER_EXTENSION_YEARS);
|
|
70
|
+
const years = [...seedYears, ...extensionYears];
|
|
71
|
+
const steps = coarseStep === fineStep ? [coarseStep] : [coarseStep, fineStep];
|
|
72
|
+
for (const step of steps) {
|
|
73
|
+
for (let sweep = 0; sweep < maxSweepsPerStep; sweep++) {
|
|
74
|
+
if (simulationCount >= maxSimulations)
|
|
75
|
+
break;
|
|
76
|
+
sweepCount++;
|
|
77
|
+
let sweepImproved = false;
|
|
78
|
+
for (const year of years) {
|
|
79
|
+
for (const direction of [1, -1]) {
|
|
80
|
+
if (simulationCount >= maxSimulations)
|
|
81
|
+
break;
|
|
82
|
+
const current = bestConversions.find((c) => c.year === year)?.amount ?? 0;
|
|
83
|
+
const mutated = Math.max(0, current + direction * step);
|
|
84
|
+
if (mutated === current)
|
|
85
|
+
continue;
|
|
86
|
+
const candidateConversions = normalizeSchedule([
|
|
87
|
+
...bestConversions.filter((c) => c.year !== year),
|
|
88
|
+
{ year, amount: mutated },
|
|
89
|
+
]);
|
|
90
|
+
if (candidateConversions.length === 0)
|
|
91
|
+
continue;
|
|
92
|
+
const evaluation = evaluate(candidateConversions, `${year}-${direction > 0 ? 'up' : 'down'}-${step}`);
|
|
93
|
+
if (policy.constraintViolations(evaluation, ctx).length > 0)
|
|
94
|
+
continue;
|
|
95
|
+
const score = policy.primaryMetric(evaluation, ctx);
|
|
96
|
+
if (score > bestScore + minimumImprovement) {
|
|
97
|
+
bestConversions = candidateConversions;
|
|
98
|
+
bestEvaluation = evaluation;
|
|
99
|
+
bestScore = score;
|
|
100
|
+
sweepImproved = true;
|
|
101
|
+
improved = true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (!sweepImproved)
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { bestConversions, bestEvaluation, improved, simulationCount, sweepCount };
|
|
110
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sustainable-spending solver (planning-depth roadmap §4 / Phase 3).
|
|
3
|
+
*
|
|
4
|
+
* Answers "how much can this plan spend every year?" with the exact ledger:
|
|
5
|
+
* bisection over `expenses.baseAnnual` (today's dollars), where a spending
|
|
6
|
+
* level is feasible only when the full `simulatePlan` run never depletes and
|
|
7
|
+
* the ending after-tax estate stays at or above the requested floor (the
|
|
8
|
+
* bequest target, entered in today's dollars and inflated to nominal
|
|
9
|
+
* end-of-plan dollars). Spending phases, one-time goals, healthcare, taxes, and
|
|
10
|
+
* every other ledger cross-effect apply unchanged — the solver never builds
|
|
11
|
+
* its own cash-flow approximation. Deterministic under a hard simulation cap:
|
|
12
|
+
* fixed probe sequence, integer-dollar midpoints, no randomness.
|
|
13
|
+
*/
|
|
14
|
+
import { type EvaluateCandidateOptions } from './evaluateCandidate.js';
|
|
15
|
+
import type { DecisionContext, ExactDecisionEvaluation } from './types.js';
|
|
16
|
+
export interface SustainableSpendingOptions {
|
|
17
|
+
/** Hard cap on exact-ledger simulations (bracketing probes + bisection). */
|
|
18
|
+
maxSimulations?: number;
|
|
19
|
+
/** Stop when the feasible/infeasible bracket is at most this wide. */
|
|
20
|
+
resolutionDollars?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Ending after-tax estate must stay at or above this (bequest target), in
|
|
23
|
+
* today's dollars; the solver inflates it to nominal end-of-plan dollars
|
|
24
|
+
* before comparing against the nominal ending estate.
|
|
25
|
+
*/
|
|
26
|
+
estateFloorTodayDollars?: number;
|
|
27
|
+
/** Base plan patch applied under every probed spending level. */
|
|
28
|
+
basePatch?: Record<string, unknown>;
|
|
29
|
+
/**
|
|
30
|
+
* `candidateResult` is excluded: every probe must simulate its own spending
|
|
31
|
+
* level, so a cached projection would poison the feasibility test.
|
|
32
|
+
*/
|
|
33
|
+
evaluation?: Omit<EvaluateCandidateOptions, 'candidateResult'>;
|
|
34
|
+
}
|
|
35
|
+
export interface SustainableSpendingResult {
|
|
36
|
+
/**
|
|
37
|
+
* Highest feasible annual base spending found (today's dollars), or null
|
|
38
|
+
* when even zero base spending depletes or breaks the estate floor.
|
|
39
|
+
*/
|
|
40
|
+
maxBaseAnnual: number | null;
|
|
41
|
+
/**
|
|
42
|
+
* maxBaseAnnual minus the current base spending — the basePatch's override
|
|
43
|
+
* when present, else the plan's own (negative ⇒ overspending today).
|
|
44
|
+
*/
|
|
45
|
+
spendingSlackDollars: number | null;
|
|
46
|
+
/** Exact-ledger evaluation of the plan at `maxBaseAnnual`. */
|
|
47
|
+
bestEvaluation: ExactDecisionEvaluation | null;
|
|
48
|
+
/** True when the bracket converged to `resolutionDollars` within the budget. */
|
|
49
|
+
converged: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Why the next-higher probed spending level failed — the constraint that
|
|
52
|
+
* binds the answer. Null when no infeasible level was probed (unbounded
|
|
53
|
+
* spending) or the solve bailed out on a diagnostic evaluation.
|
|
54
|
+
*/
|
|
55
|
+
limitingConstraint: 'depletion' | 'estate-floor' | null;
|
|
56
|
+
simulationCount: number;
|
|
57
|
+
diagnostics: string[];
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Shared interactive-surface budget: the "How much can I spend?" page and the
|
|
61
|
+
* Insights spending-headroom detector both solve with this bound so their
|
|
62
|
+
* answers agree exactly (the solver is deterministic under a fixed budget).
|
|
63
|
+
*/
|
|
64
|
+
export declare const SPENDING_SOLVER_UI_BUDGET = 25;
|
|
65
|
+
/**
|
|
66
|
+
* Find the maximum sustainable annual base spending by exact-ledger bisection.
|
|
67
|
+
* Returns a lower-bound answer (with `converged: false` and a diagnostic) when
|
|
68
|
+
* the simulation budget runs out before the bracket tightens.
|
|
69
|
+
*/
|
|
70
|
+
export declare function solveMaxSustainableSpending(ctx: DecisionContext, options?: SustainableSpendingOptions): SustainableSpendingResult;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sustainable-spending solver (planning-depth roadmap §4 / Phase 3).
|
|
3
|
+
*
|
|
4
|
+
* Answers "how much can this plan spend every year?" with the exact ledger:
|
|
5
|
+
* bisection over `expenses.baseAnnual` (today's dollars), where a spending
|
|
6
|
+
* level is feasible only when the full `simulatePlan` run never depletes and
|
|
7
|
+
* the ending after-tax estate stays at or above the requested floor (the
|
|
8
|
+
* bequest target, entered in today's dollars and inflated to nominal
|
|
9
|
+
* end-of-plan dollars). Spending phases, one-time goals, healthcare, taxes, and
|
|
10
|
+
* every other ledger cross-effect apply unchanged — the solver never builds
|
|
11
|
+
* its own cash-flow approximation. Deterministic under a hard simulation cap:
|
|
12
|
+
* fixed probe sequence, integer-dollar midpoints, no randomness.
|
|
13
|
+
*/
|
|
14
|
+
import { evaluateCandidate, planForCandidate } from './evaluateCandidate.js';
|
|
15
|
+
import { nominalDollarsAtPlanEnd } from './objectives.js';
|
|
16
|
+
const DEFAULT_MAX_SIMULATIONS = 24;
|
|
17
|
+
const DEFAULT_RESOLUTION_DOLLARS = 500;
|
|
18
|
+
/**
|
|
19
|
+
* Shared interactive-surface budget: the "How much can I spend?" page and the
|
|
20
|
+
* Insights spending-headroom detector both solve with this bound so their
|
|
21
|
+
* answers agree exactly (the solver is deterministic under a fixed budget).
|
|
22
|
+
*/
|
|
23
|
+
export const SPENDING_SOLVER_UI_BUDGET = 25;
|
|
24
|
+
/** First doubling probe when the plan's own base spending is (near) zero. */
|
|
25
|
+
const MINIMUM_BRACKET_PROBE_DOLLARS = 20_000;
|
|
26
|
+
/** Doubling stops here; past this the plan's income plainly outruns spending. */
|
|
27
|
+
const UNBOUNDED_SPENDING_DOLLARS = 100_000_000;
|
|
28
|
+
function spendingCandidate(basePatch, baseAnnual) {
|
|
29
|
+
const baseExpenses = basePatch?.['expenses'];
|
|
30
|
+
return {
|
|
31
|
+
id: `sustainable-spending-${baseAnnual}`,
|
|
32
|
+
source: 'search',
|
|
33
|
+
category: 'spending',
|
|
34
|
+
label: `Base spending $${Math.round(baseAnnual).toLocaleString()}/yr`,
|
|
35
|
+
explanation: 'Sustainable-spending probe: the exact ledger runs the whole plan at this base spending level.',
|
|
36
|
+
planPatch: {
|
|
37
|
+
...basePatch,
|
|
38
|
+
expenses: {
|
|
39
|
+
...(baseExpenses && typeof baseExpenses === 'object' ? baseExpenses : {}),
|
|
40
|
+
baseAnnual,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Find the maximum sustainable annual base spending by exact-ledger bisection.
|
|
47
|
+
* Returns a lower-bound answer (with `converged: false` and a diagnostic) when
|
|
48
|
+
* the simulation budget runs out before the bracket tightens.
|
|
49
|
+
*/
|
|
50
|
+
export function solveMaxSustainableSpending(ctx, options = {}) {
|
|
51
|
+
const maxSimulations = options.maxSimulations ?? DEFAULT_MAX_SIMULATIONS;
|
|
52
|
+
const resolutionDollars = options.resolutionDollars ?? DEFAULT_RESOLUTION_DOLLARS;
|
|
53
|
+
const estateFloorTodayDollars = options.estateFloorTodayDollars ?? 0;
|
|
54
|
+
// Runtime guard behind the Omit: a cached candidateResult from a JS caller
|
|
55
|
+
// would make every probe reuse one projection instead of simulating its own.
|
|
56
|
+
const evaluationOptions = { ...options.evaluation };
|
|
57
|
+
delete evaluationOptions.candidateResult;
|
|
58
|
+
// Every probe runs on the basePatch-applied plan, so both the "current"
|
|
59
|
+
// spending reference (bracketing seed + slack basis) and the inflation used
|
|
60
|
+
// to inflate the estate floor must come from that patched plan, not the
|
|
61
|
+
// unpatched baseline. An invalid patch falls back to the plan; the first
|
|
62
|
+
// probe then returns a diagnostic evaluation and the solve bails out.
|
|
63
|
+
const patchedBase = options.basePatch ? planForCandidate(ctx.plan, { planPatch: options.basePatch }) : null;
|
|
64
|
+
const effectivePlan = patchedBase?.ok ? patchedBase.plan : ctx.plan;
|
|
65
|
+
const currentBaseAnnual = effectivePlan.expenses.baseAnnual;
|
|
66
|
+
// Amortized spending (ABW) computes the year's lifestyle target from the
|
|
67
|
+
// portfolio itself and ignores `baseAnnual`, so bisecting baseAnnual would
|
|
68
|
+
// simulate the identical plan at every probe — the "max fixed spending
|
|
69
|
+
// level" question has no answer there. Bail out with a diagnostic instead
|
|
70
|
+
// of burning the budget on indistinguishable probes.
|
|
71
|
+
if (effectivePlan.expenses.spendingPolicy?.mode === 'abw') {
|
|
72
|
+
return {
|
|
73
|
+
maxBaseAnnual: null,
|
|
74
|
+
spendingSlackDollars: null,
|
|
75
|
+
bestEvaluation: null,
|
|
76
|
+
converged: false,
|
|
77
|
+
limitingConstraint: null,
|
|
78
|
+
simulationCount: 0,
|
|
79
|
+
diagnostics: [
|
|
80
|
+
'This plan uses amortized spending (ABW), which recomputes annual spending from the portfolio each year — there is no fixed base-spending level to solve for. Switch the spending policy to fixed target or guardrails to use this solver.',
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const diagnostics = [];
|
|
85
|
+
let simulationCount = 0;
|
|
86
|
+
let bestFeasible = null;
|
|
87
|
+
// Reason the current upper (infeasible) bracket bound failed; tightening the
|
|
88
|
+
// bracket keeps this in sync with the bound the answer finally rests against.
|
|
89
|
+
let limitingConstraint = null;
|
|
90
|
+
const probe = (baseAnnual) => {
|
|
91
|
+
simulationCount++;
|
|
92
|
+
const evaluation = evaluateCandidate(ctx, spendingCandidate(options.basePatch, baseAnnual), evaluationOptions);
|
|
93
|
+
const depleted = evaluation.candidateResult.depletionYear !== null;
|
|
94
|
+
const breaksFloor = evaluation.candidateSummary.endingAfterTaxEstate <
|
|
95
|
+
nominalDollarsAtPlanEnd(estateFloorTodayDollars, effectivePlan, evaluation.candidateResult);
|
|
96
|
+
const feasible = evaluation.recommendationState !== 'diagnostic' && !depleted && !breaksFloor;
|
|
97
|
+
if (feasible && (bestFeasible === null || baseAnnual > bestFeasible.amount)) {
|
|
98
|
+
bestFeasible = { amount: baseAnnual, evaluation };
|
|
99
|
+
}
|
|
100
|
+
if (!feasible && evaluation.recommendationState !== 'diagnostic') {
|
|
101
|
+
limitingConstraint = depleted ? 'depletion' : 'estate-floor';
|
|
102
|
+
}
|
|
103
|
+
return { feasible, evaluation };
|
|
104
|
+
};
|
|
105
|
+
const finish = (lower, upper) => {
|
|
106
|
+
const converged = lower !== null && upper !== null && upper - lower <= resolutionDollars;
|
|
107
|
+
if (!converged && lower !== null) {
|
|
108
|
+
diagnostics.push(`Stopped before converging to $${resolutionDollars.toLocaleString()}; the result is a feasible lower bound.`);
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
maxBaseAnnual: bestFeasible?.amount ?? null,
|
|
112
|
+
spendingSlackDollars: bestFeasible ? bestFeasible.amount - currentBaseAnnual : null,
|
|
113
|
+
bestEvaluation: bestFeasible?.evaluation ?? null,
|
|
114
|
+
converged,
|
|
115
|
+
limitingConstraint,
|
|
116
|
+
simulationCount,
|
|
117
|
+
diagnostics,
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
// Bracket the answer starting from the current base spending.
|
|
121
|
+
const seedAmount = Math.max(0, Math.round(currentBaseAnnual));
|
|
122
|
+
const seed = probe(seedAmount);
|
|
123
|
+
if (seed.evaluation.recommendationState === 'diagnostic') {
|
|
124
|
+
diagnostics.push(...seed.evaluation.diagnostics);
|
|
125
|
+
return finish(null, null);
|
|
126
|
+
}
|
|
127
|
+
let lower;
|
|
128
|
+
let upper;
|
|
129
|
+
if (seed.feasible) {
|
|
130
|
+
lower = seedAmount;
|
|
131
|
+
upper = null;
|
|
132
|
+
let next = Math.max(seedAmount * 2, MINIMUM_BRACKET_PROBE_DOLLARS);
|
|
133
|
+
while (simulationCount < maxSimulations) {
|
|
134
|
+
if (next > UNBOUNDED_SPENDING_DOLLARS) {
|
|
135
|
+
diagnostics.push('Spending appears unbounded at the probed range — guaranteed income outruns spending at every tested level.');
|
|
136
|
+
return finish(lower, upper);
|
|
137
|
+
}
|
|
138
|
+
if (probe(next).feasible) {
|
|
139
|
+
lower = next;
|
|
140
|
+
next *= 2;
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
upper = next;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
upper = seedAmount;
|
|
150
|
+
if (upper === 0) {
|
|
151
|
+
diagnostics.push('Even zero base spending depletes the portfolio or breaks the estate floor.');
|
|
152
|
+
return finish(null, upper);
|
|
153
|
+
}
|
|
154
|
+
if (simulationCount >= maxSimulations) {
|
|
155
|
+
diagnostics.push('Simulation budget exhausted before any feasible spending level was found.');
|
|
156
|
+
return finish(null, upper);
|
|
157
|
+
}
|
|
158
|
+
if (probe(0).feasible) {
|
|
159
|
+
lower = 0;
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
diagnostics.push('Even zero base spending depletes the portfolio or breaks the estate floor.');
|
|
163
|
+
return finish(null, upper);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Bisect the bracket down to the requested resolution.
|
|
167
|
+
while (lower !== null &&
|
|
168
|
+
upper !== null &&
|
|
169
|
+
upper - lower > resolutionDollars &&
|
|
170
|
+
simulationCount < maxSimulations) {
|
|
171
|
+
const mid = Math.round((lower + upper) / 2);
|
|
172
|
+
if (mid === lower || mid === upper)
|
|
173
|
+
break;
|
|
174
|
+
if (probe(mid).feasible)
|
|
175
|
+
lower = mid;
|
|
176
|
+
else
|
|
177
|
+
upper = mid;
|
|
178
|
+
}
|
|
179
|
+
return finish(lower, upper);
|
|
180
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sourced default SPIA payout-rate table (annuity-pension-and-home-equity
|
|
3
|
+
* decisions, step 2).
|
|
4
|
+
*
|
|
5
|
+
* Approximate single-life immediate-annuity annual payout rates (fraction of
|
|
6
|
+
* premium per year, life-only) by start age — compiled from public quote
|
|
7
|
+
* aggregators (Blueprint-Income-style annuity marketplaces and insurer
|
|
8
|
+
* calculators, mid-2026 rates). Planning defaults only: RetireGolden never
|
|
9
|
+
* fetches live quotes; a user-entered quote always wins. Linear interpolation
|
|
10
|
+
* between anchors; clamped outside the table.
|
|
11
|
+
*
|
|
12
|
+
* Refresh with the annual parameter packs (DOCS/maintenance-schedule.md).
|
|
13
|
+
*/
|
|
14
|
+
/** Annual life-only SPIA payout rate (fraction of premium) at `startAge`. */
|
|
15
|
+
export declare function spiaPayoutRate(startAge: number): number;
|
|
16
|
+
/**
|
|
17
|
+
* Approximate deferred-QLAC annual payout rate (fraction of premium) for a
|
|
18
|
+
* purchase today starting at 80–85: deferral compounds mortality credits well
|
|
19
|
+
* above immediate rates. Same public-quote provenance as the SPIA table.
|
|
20
|
+
*/
|
|
21
|
+
export declare const QLAC_DEFERRED_PAYOUT_RATE = 0.16;
|