@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,667 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalized candidate generators (ledger-native decision engine, Phase 2).
|
|
3
|
+
*
|
|
4
|
+
* Every recommendation source is a `CandidateGenerator`: pure, bounded, and
|
|
5
|
+
* fast — no simulate() calls, just concrete plan patches or conversion
|
|
6
|
+
* schedules. Generation never decides whether something is recommended; the
|
|
7
|
+
* exact-ledger evaluation does.
|
|
8
|
+
*/
|
|
9
|
+
import { packForYear, rmdStartAgeForBirthYear, LATEST_PACK_YEAR, EMBEDDED_REAL_YIELD_CURVE } from '../params/index.js';
|
|
10
|
+
import { BRIDGE_FUNDING_MIN_FRACTION, sizeBridge } from '../ladder/bridge.js';
|
|
11
|
+
import { QLAC_DEFERRED_PAYOUT_RATE, spiaPayoutRate } from './spiaQuotes.js';
|
|
12
|
+
function fillToTargetPatch(target, targetValue, startYear, endYear) {
|
|
13
|
+
return {
|
|
14
|
+
strategies: {
|
|
15
|
+
rothConversion: { mode: 'fillToTarget', target, targetValue, startYear, endYear },
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Income-boundary years where the cheap-conversion regime typically ends: each
|
|
21
|
+
* person's Social-Security claim year and RMD start year. A whole-horizon
|
|
22
|
+
* bracket fill keeps converting past these boundaries even when the marginal
|
|
23
|
+
* dollars stop being cheap; windowed fills stop at the boundary instead —
|
|
24
|
+
* the classic "convert during the bridge, then stop" shape a sequential
|
|
25
|
+
* withdrawal order makes optimal. Bounded and generation-pure (no simulate).
|
|
26
|
+
*/
|
|
27
|
+
function conversionWindowBoundaries(ctx, startYear, endYear) {
|
|
28
|
+
const plan = ctx.plan;
|
|
29
|
+
const boundaries = new Map();
|
|
30
|
+
// Liquid-reserve depletion: the first baseline year where spending taps the
|
|
31
|
+
// traditional balance. Up to then, spending rides on cash/taxable and every
|
|
32
|
+
// converted dollar starts at the bottom of the brackets; afterwards the
|
|
33
|
+
// spending withdrawals fill the cheap bands themselves (sequential order), so
|
|
34
|
+
// the classic optimal shape is "convert hard while reserves last, then stop."
|
|
35
|
+
// Reads the already-computed baseline result — no new simulation.
|
|
36
|
+
const firstTraditionalDrawYear = ctx.baselineResult.years.find((year) => year.withdrawals.traditional - year.rmd - year.inheritedDistribution > 1)?.year;
|
|
37
|
+
if (firstTraditionalDrawYear !== undefined && firstTraditionalDrawYear > startYear && firstTraditionalDrawYear <= endYear) {
|
|
38
|
+
boundaries.set(firstTraditionalDrawYear, `while cash and taxable cover spending (through ${firstTraditionalDrawYear - 1})`);
|
|
39
|
+
}
|
|
40
|
+
for (const income of plan.incomes) {
|
|
41
|
+
if (income.type !== 'socialSecurity')
|
|
42
|
+
continue;
|
|
43
|
+
const person = plan.household.people.find((p) => p.id === income.personId);
|
|
44
|
+
if (!person)
|
|
45
|
+
continue;
|
|
46
|
+
const claimYear = dobYear(person.dob) + income.claimAge.years;
|
|
47
|
+
if (claimYear > startYear && claimYear <= endYear && !boundaries.has(claimYear)) {
|
|
48
|
+
boundaries.set(claimYear, `until Social Security starts (${claimYear})`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const hasTraditional = plan.accounts.some((a) => a.type === 'traditional' && !a.inherited);
|
|
52
|
+
if (hasTraditional) {
|
|
53
|
+
for (const person of plan.household.people) {
|
|
54
|
+
const birthYear = dobYear(person.dob);
|
|
55
|
+
const rmdYear = birthYear + rmdStartAgeForBirthYear(birthYear);
|
|
56
|
+
if (rmdYear > startYear && rmdYear <= endYear && !boundaries.has(rmdYear)) {
|
|
57
|
+
boundaries.set(rmdYear, `until RMDs begin (${rmdYear})`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return [...boundaries.entries()]
|
|
62
|
+
.sort(([a], [b]) => a - b)
|
|
63
|
+
// Hard bound on the candidate fan-out. A married couple legitimately has 5
|
|
64
|
+
// distinct boundaries (reserve depletion + 2 SS claims + 2 RMD starts);
|
|
65
|
+
// truncating below that silently drops the latest — typically the younger
|
|
66
|
+
// spouse's RMD wall, exactly the window shape these candidates exist for.
|
|
67
|
+
.slice(0, 5)
|
|
68
|
+
.map(([year, suffix]) => ({ year, suffix }));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The simple fill-to-target Roth strategies of the bounded tournament: bracket
|
|
72
|
+
* fills, the ACA cliff cap, and the first IRMAA tier cap over the whole
|
|
73
|
+
* horizon, plus *windowed* bracket fills that stop at income boundaries (SS
|
|
74
|
+
* claim / RMD start) — under a sequential withdrawal order, spending draws fill
|
|
75
|
+
* the cheap brackets themselves once cash/taxable deplete or income starts, so
|
|
76
|
+
* "fill high early, then stop" beats any whole-horizon fill on bridge-shaped
|
|
77
|
+
* plans. Generated unconditionally — a plan with no convertible traditional
|
|
78
|
+
* simply executes $0 and evaluates neutral.
|
|
79
|
+
*/
|
|
80
|
+
export const simpleRothConversionGenerator = {
|
|
81
|
+
id: 'roth-fill-to-target',
|
|
82
|
+
generate(ctx) {
|
|
83
|
+
const { startYear, endYear } = ctx.baselineResult;
|
|
84
|
+
const make = (id, label, target, targetValue, category, window) => ({
|
|
85
|
+
id,
|
|
86
|
+
source: 'heuristic',
|
|
87
|
+
category,
|
|
88
|
+
label,
|
|
89
|
+
// Windowed candidates are always bracket fills, so the phrase comes
|
|
90
|
+
// straight from targetValue — no scraping the human label back apart.
|
|
91
|
+
explanation: window
|
|
92
|
+
? `Roth conversions up to the ${targetValue}% bracket in each year before ${window.year}, evaluated on the exact ledger.`
|
|
93
|
+
: `Roth conversions each year up to ${label.toLowerCase().replace(/^fill |^convert up to /, '')}, evaluated on the exact ledger.`,
|
|
94
|
+
planPatch: fillToTargetPatch(target, targetValue, startYear, window ? window.year - 1 : endYear),
|
|
95
|
+
});
|
|
96
|
+
const candidates = [
|
|
97
|
+
make('bracket-10', 'Fill the 10% bracket', 'topOfBracket', 10, 'roth'),
|
|
98
|
+
make('bracket-12', 'Fill the 12% bracket', 'topOfBracket', 12, 'roth'),
|
|
99
|
+
make('bracket-22', 'Fill the 22% bracket', 'topOfBracket', 22, 'roth'),
|
|
100
|
+
make('bracket-24', 'Fill the 24% bracket', 'topOfBracket', 24, 'roth'),
|
|
101
|
+
make('aca-cliff-cap', 'Convert up to the ACA cliff', 'acaCliff', null, 'tax-cliff'),
|
|
102
|
+
make('irmaa-tier-1-cap', 'Convert up to the first IRMAA tier', 'irmaaTier', 1, 'tax-cliff'),
|
|
103
|
+
];
|
|
104
|
+
for (const window of conversionWindowBoundaries(ctx, startYear, endYear)) {
|
|
105
|
+
for (const bracket of [12, 22, 24]) {
|
|
106
|
+
candidates.push(make(`bracket-${bracket}-until-${window.year}`, `Fill the ${bracket}% bracket ${window.suffix}`, 'topOfBracket', bracket, 'roth', window));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return candidates;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
/** The do-nothing alternative, only meaningful when the plan currently converts. */
|
|
113
|
+
export const noConversionGenerator = {
|
|
114
|
+
id: 'roth-no-conversion',
|
|
115
|
+
generate(ctx) {
|
|
116
|
+
if (ctx.plan.strategies.rothConversion.mode === 'none')
|
|
117
|
+
return [];
|
|
118
|
+
return [
|
|
119
|
+
{
|
|
120
|
+
id: 'no-conversion',
|
|
121
|
+
source: 'heuristic',
|
|
122
|
+
category: 'roth',
|
|
123
|
+
label: 'Stop Roth conversions',
|
|
124
|
+
explanation: 'Runs the plan with no Roth conversions, in case the current schedule costs more than it saves.',
|
|
125
|
+
planPatch: { strategies: { rothConversion: { mode: 'none' } } },
|
|
126
|
+
},
|
|
127
|
+
];
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
/** Withdrawal-order alternatives to whatever the plan currently uses. */
|
|
131
|
+
export const withdrawalOrderGenerator = {
|
|
132
|
+
id: 'withdrawal-order',
|
|
133
|
+
generate(ctx) {
|
|
134
|
+
const current = ctx.plan.strategies.withdrawalOrder;
|
|
135
|
+
const alternatives = [
|
|
136
|
+
{ id: 'withdrawal-sequential', label: 'Sequential withdrawal order', strategy: { mode: 'sequential' } },
|
|
137
|
+
{ id: 'withdrawal-proportional', label: 'Proportional withdrawal order', strategy: { mode: 'proportional' } },
|
|
138
|
+
{ id: 'withdrawal-bracket-12', label: 'Bracket-targeted withdrawals (12%)', strategy: { mode: 'bracketTargeted', bracketPct: 12 } },
|
|
139
|
+
{ id: 'withdrawal-bracket-22', label: 'Bracket-targeted withdrawals (22%)', strategy: { mode: 'bracketTargeted', bracketPct: 22 } },
|
|
140
|
+
];
|
|
141
|
+
return alternatives
|
|
142
|
+
.filter(({ strategy }) => {
|
|
143
|
+
if (strategy.mode !== current.mode)
|
|
144
|
+
return true;
|
|
145
|
+
return strategy.mode === 'bracketTargeted' && current.mode === 'bracketTargeted'
|
|
146
|
+
? strategy.bracketPct !== current.bracketPct
|
|
147
|
+
: false;
|
|
148
|
+
})
|
|
149
|
+
.map(({ id, label, strategy }) => ({
|
|
150
|
+
id,
|
|
151
|
+
source: 'heuristic',
|
|
152
|
+
category: 'withdrawal',
|
|
153
|
+
label,
|
|
154
|
+
explanation: `Funds spending with a ${label.toLowerCase()} instead of the current strategy.`,
|
|
155
|
+
planPatch: { strategies: { withdrawalOrder: strategy } },
|
|
156
|
+
}));
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* Probability-band safe-spend guardrail candidates. Generation is deliberately
|
|
161
|
+
* pure: the candidate carries the success band as metadata and a concrete
|
|
162
|
+
* ledger-native guardrail patch; exact/stochastic surfaces can then evaluate it
|
|
163
|
+
* through the shared decision and Monte Carlo runners without private models.
|
|
164
|
+
*/
|
|
165
|
+
export function probabilityBandSpendingGuardrailGenerator(options = {}) {
|
|
166
|
+
const requiredFloorPct = options.requiredFloorPct ?? 80;
|
|
167
|
+
const lowerSuccessPct = options.lowerSuccessPct ?? 70;
|
|
168
|
+
const upperSuccessPct = options.upperSuccessPct ?? 95;
|
|
169
|
+
return {
|
|
170
|
+
id: 'probability-band-spending-guardrails',
|
|
171
|
+
generate(ctx) {
|
|
172
|
+
// Skip plans that already run any guardrail policy (withdrawal-rate or
|
|
173
|
+
// risk-based) — the candidate would just re-add what is already there.
|
|
174
|
+
const mode = ctx.plan.expenses.spendingPolicy?.mode;
|
|
175
|
+
if (mode !== undefined && mode !== 'fixedTarget')
|
|
176
|
+
return [];
|
|
177
|
+
const baseAnnual = ctx.plan.expenses.baseAnnual;
|
|
178
|
+
if (baseAnnual <= 0)
|
|
179
|
+
return [];
|
|
180
|
+
const requiredAnnual = Math.min(ctx.plan.expenses.requiredAnnual ?? Math.round(baseAnnual * requiredFloorPct / 100), baseAnnual);
|
|
181
|
+
return [
|
|
182
|
+
{
|
|
183
|
+
id: `safe-spend-band-${lowerSuccessPct}-${upperSuccessPct}`,
|
|
184
|
+
source: 'heuristic',
|
|
185
|
+
category: 'spending',
|
|
186
|
+
label: `Safe-spend guardrails (${lowerSuccessPct}-${upperSuccessPct}% band)`,
|
|
187
|
+
explanation: 'Adds a required spending floor and ledger-native withdrawal-rate guardrails as the modelable candidate for probability-band safe-spend comparisons.',
|
|
188
|
+
planPatch: {
|
|
189
|
+
expenses: {
|
|
190
|
+
requiredAnnual,
|
|
191
|
+
spendingPolicy: {
|
|
192
|
+
mode: 'withdrawalRateGuardrails',
|
|
193
|
+
upperGuardrailPct: 120,
|
|
194
|
+
lowerGuardrailPct: 80,
|
|
195
|
+
adjustmentPct: 10,
|
|
196
|
+
allowRaisesAboveTarget: true,
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
metadata: {
|
|
201
|
+
decisionRule: 'probabilityBandSafeSpend',
|
|
202
|
+
lowerSuccessPct,
|
|
203
|
+
upperSuccessPct,
|
|
204
|
+
requiredFloorPct,
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
];
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const SS_CLAIM_AGES = [
|
|
212
|
+
{ years: 62, months: 0, suffix: 'at 62' },
|
|
213
|
+
{ years: 67, months: 0, suffix: 'at 67 (FRA)' },
|
|
214
|
+
{ years: 70, months: 0, suffix: 'at 70' },
|
|
215
|
+
];
|
|
216
|
+
const SS_GRID_CLAIM_AGES = [62, 63, 64, 65, 66, 67, 68, 69, 70];
|
|
217
|
+
function dobYear(dob) {
|
|
218
|
+
return Number(dob.slice(0, 4));
|
|
219
|
+
}
|
|
220
|
+
function gridClaimAgesForPerson(person, startYear) {
|
|
221
|
+
const currentAge = startYear - dobYear(person.dob);
|
|
222
|
+
const ages = SS_GRID_CLAIM_AGES.filter((age) => age >= currentAge);
|
|
223
|
+
return ages.length > 0 ? [...ages] : [70];
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Bounded Social Security claim-age candidates: for up to two SS streams, the
|
|
227
|
+
* three canonical claim ages (62 / FRA / 70) that differ from the current
|
|
228
|
+
* claim. Each candidate replaces the whole incomes array (scenario patches
|
|
229
|
+
* replace arrays wholesale, keeping the patch order-safe).
|
|
230
|
+
*/
|
|
231
|
+
export const socialSecurityClaimGenerator = {
|
|
232
|
+
id: 'social-security-claim',
|
|
233
|
+
generate(ctx) {
|
|
234
|
+
const candidates = [];
|
|
235
|
+
const ssStreams = ctx.plan.incomes.filter((income) => income.type === 'socialSecurity');
|
|
236
|
+
for (const stream of ssStreams.slice(0, 2)) {
|
|
237
|
+
const person = ctx.plan.household.people.find((p) => p.id === stream.personId);
|
|
238
|
+
const personLabel = person?.name ?? 'household member';
|
|
239
|
+
for (const claim of SS_CLAIM_AGES) {
|
|
240
|
+
if (stream.claimAge.years === claim.years && stream.claimAge.months === claim.months)
|
|
241
|
+
continue;
|
|
242
|
+
const incomes = ctx.plan.incomes.map((income) => income === stream ? { ...stream, claimAge: { years: claim.years, months: claim.months } } : income);
|
|
243
|
+
candidates.push({
|
|
244
|
+
id: `ss-claim-${stream.id}-${claim.years}-${claim.months}`,
|
|
245
|
+
source: 'heuristic',
|
|
246
|
+
category: 'social-security',
|
|
247
|
+
label: `${personLabel} claims Social Security ${claim.suffix}`,
|
|
248
|
+
explanation: `Moves ${personLabel}'s claim from ${stream.claimAge.years}y${stream.claimAge.months}m to ${claim.years}y${claim.months}m and reprices the whole plan on the exact ledger.`,
|
|
249
|
+
planPatch: { incomes },
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return candidates;
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
function largestStaticAllocated(plan, type) {
|
|
257
|
+
const eligible = plan.accounts.filter((a) => a.type === type && a.allocation?.mode === 'static' && a.balance > 0);
|
|
258
|
+
return eligible.sort((a, b) => b.balance - a.balance)[0];
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Asset-location candidates (asset-allocation-and-return-model-v2, step 5).
|
|
262
|
+
*
|
|
263
|
+
* Proposes swapping *where* classes are held while leaving the household's
|
|
264
|
+
* total dollars in each class unchanged: bonds move toward traditional
|
|
265
|
+
* (sheltering their ordinary-income yield and slowing the RMD/heir-taxed
|
|
266
|
+
* balance), US stocks move toward taxable (qualified dividends + basis
|
|
267
|
+
* machinery) or Roth (sheltering the highest expected growth). Swaps pair the
|
|
268
|
+
* largest eligible static-allocation accounts and are expressed as plan
|
|
269
|
+
* patches over the accounts array; the exact ledger prices every candidate —
|
|
270
|
+
* generation never decides. Bounded: at most 3 candidates.
|
|
271
|
+
*/
|
|
272
|
+
export const assetLocationGenerator = {
|
|
273
|
+
id: 'asset-location',
|
|
274
|
+
generate(ctx) {
|
|
275
|
+
const plan = ctx.plan;
|
|
276
|
+
const taxable = largestStaticAllocated(plan, 'taxable');
|
|
277
|
+
const traditional = largestStaticAllocated(plan, 'traditional');
|
|
278
|
+
const roth = largestStaticAllocated(plan, 'roth');
|
|
279
|
+
const candidates = [];
|
|
280
|
+
const patchWith = (replacements) => ({
|
|
281
|
+
accounts: plan.accounts.map((a) => replacements.get(a.id) ?? a),
|
|
282
|
+
});
|
|
283
|
+
const shifted = (weights, from, to, pct) => {
|
|
284
|
+
// Clamp to the schema's 0–100 bounds; move exactly what the source can
|
|
285
|
+
// give so the pair of shifts stays sum-preserving under float rounding.
|
|
286
|
+
const moved = Math.min(pct, weights[from]);
|
|
287
|
+
return { ...weights, [from]: weights[from] - moved, [to]: Math.min(100, weights[to] + moved) };
|
|
288
|
+
};
|
|
289
|
+
/** Swap `dollars` of `sellClass` in a for `sellClass` exposure in b (b gives up buyClass). */
|
|
290
|
+
const swap = (id, label, explanation, a, b, aGivesUp, bGivesUp, dollars) => {
|
|
291
|
+
const aPct = (dollars / a.balance) * 100;
|
|
292
|
+
const bPct = (dollars / b.balance) * 100;
|
|
293
|
+
const replacements = new Map([
|
|
294
|
+
[a.id, { ...a, allocation: { ...a.allocation, weights: shifted(a.allocation.weights, aGivesUp, bGivesUp, aPct) } }],
|
|
295
|
+
[b.id, { ...b, allocation: { ...b.allocation, weights: shifted(b.allocation.weights, bGivesUp, aGivesUp, bPct) } }],
|
|
296
|
+
]);
|
|
297
|
+
return {
|
|
298
|
+
id,
|
|
299
|
+
source: 'heuristic',
|
|
300
|
+
category: 'asset-location',
|
|
301
|
+
label,
|
|
302
|
+
explanation,
|
|
303
|
+
planPatch: patchWith(replacements),
|
|
304
|
+
metadata: { swappedDollars: Math.round(dollars) },
|
|
305
|
+
};
|
|
306
|
+
};
|
|
307
|
+
// Bonds → traditional, stocks → taxable: swap the taxable account's bond
|
|
308
|
+
// dollars for the traditional account's US-stock dollars (full and half).
|
|
309
|
+
if (taxable && traditional) {
|
|
310
|
+
const dollars = Math.min((taxable.allocation.weights.bonds / 100) * taxable.balance, (traditional.allocation.weights.usStocks / 100) * traditional.balance);
|
|
311
|
+
if (dollars > 1000) {
|
|
312
|
+
const explanation = 'Holds the same household mix, but moves bond exposure into the traditional account (sheltering its ordinary-income yield) and US stocks into taxable (qualified dividends, basis step-up at death). Priced on the exact ledger.';
|
|
313
|
+
candidates.push(swap('asset-location-bonds-to-traditional', 'Hold bonds in traditional, stocks in taxable', explanation, taxable, traditional, 'bonds', 'usStocks', dollars), swap('asset-location-bonds-to-traditional-half', 'Shift half the taxable bonds into traditional', explanation, taxable, traditional, 'bonds', 'usStocks', dollars / 2));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// Bonds → traditional, stocks → Roth: both tax-advantaged (no taxable
|
|
317
|
+
// trade), concentrating the highest expected growth where it is never
|
|
318
|
+
// taxed again.
|
|
319
|
+
if (roth && traditional) {
|
|
320
|
+
const dollars = Math.min((roth.allocation.weights.bonds / 100) * roth.balance, (traditional.allocation.weights.usStocks / 100) * traditional.balance);
|
|
321
|
+
if (dollars > 1000) {
|
|
322
|
+
candidates.push(swap('asset-location-stocks-to-roth', 'Hold stocks in Roth, bonds in traditional', 'Holds the same household mix, but concentrates US stocks in the Roth (tax-free growth for you or heirs) and bonds in the traditional account. Priced on the exact ledger.', roth, traditional, 'bonds', 'usStocks', dollars));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return candidates;
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
/**
|
|
329
|
+
* Full Social Security claiming grid for the optimizer page: every whole-year
|
|
330
|
+
* claim combination from 62-70 (respecting current age) for up to two claiming
|
|
331
|
+
* streams. When a stream's current claim age is a whole year within range, that
|
|
332
|
+
* combination reproduces the current plan; a current claim age with months, below
|
|
333
|
+
* 62, above 70, or already past is not emitted here. Bounded: 9 single-person
|
|
334
|
+
* candidates or 81 couple candidates.
|
|
335
|
+
*/
|
|
336
|
+
export const socialSecurityClaimGridGenerator = {
|
|
337
|
+
id: 'social-security-claim-grid',
|
|
338
|
+
generate(ctx) {
|
|
339
|
+
const entries = ctx.plan.incomes
|
|
340
|
+
.filter((income) => income.type === 'socialSecurity')
|
|
341
|
+
// Match the planner's claiming-person logic: a default record carries
|
|
342
|
+
// piaMonthly 0 (not null), which resolves to no benefit, so treat only a
|
|
343
|
+
// positive entered PIA or an earnings history as a real claiming stream.
|
|
344
|
+
// Otherwise a zero-PIA spouse inflates the grid with duplicate finalists.
|
|
345
|
+
.filter((income) => (income.piaMonthly !== null && income.piaMonthly > 0) || (income.earnings?.length ?? 0) > 0)
|
|
346
|
+
.slice(0, 2)
|
|
347
|
+
.map((stream) => ({ stream, person: ctx.plan.household.people.find((p) => p.id === stream.personId) }))
|
|
348
|
+
.filter((entry) => entry.person !== undefined);
|
|
349
|
+
if (entries.length === 0)
|
|
350
|
+
return [];
|
|
351
|
+
const combos = [{}];
|
|
352
|
+
for (const { person } of entries) {
|
|
353
|
+
const ages = gridClaimAgesForPerson(person, ctx.simulateOptions.startYear);
|
|
354
|
+
const next = [];
|
|
355
|
+
for (const partial of combos) {
|
|
356
|
+
for (const age of ages)
|
|
357
|
+
next.push({ ...partial, [person.id]: age });
|
|
358
|
+
}
|
|
359
|
+
combos.length = 0;
|
|
360
|
+
combos.push(...next);
|
|
361
|
+
}
|
|
362
|
+
return combos.map((claimByPersonId) => {
|
|
363
|
+
const incomes = ctx.plan.incomes.map((income) => income.type === 'socialSecurity' && claimByPersonId[income.personId] !== undefined
|
|
364
|
+
? { ...income, claimAge: { years: claimByPersonId[income.personId], months: 0 } }
|
|
365
|
+
: income);
|
|
366
|
+
const label = entries.map(({ person }) => `${person.name} ${claimByPersonId[person.id]}`).join(' / ');
|
|
367
|
+
const id = `ss-claim-grid-${entries.map(({ person }) => `${person.id}-${claimByPersonId[person.id]}`).join('-')}`;
|
|
368
|
+
return {
|
|
369
|
+
id,
|
|
370
|
+
source: 'scenario-sweep',
|
|
371
|
+
category: 'social-security',
|
|
372
|
+
label: `Social Security claim ages: ${label}`,
|
|
373
|
+
explanation: 'Runs this whole-year Social Security claim-age combination through the shared exact-ledger decision engine.',
|
|
374
|
+
planPatch: { incomes },
|
|
375
|
+
metadata: {
|
|
376
|
+
decisionRule: 'socialSecurityClaimGrid',
|
|
377
|
+
claimByPersonId,
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
});
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
/**
|
|
384
|
+
* Wrap MILP optimizer output (raw and post-processed schedules) as candidates.
|
|
385
|
+
* The solver is one candidate generator among several — never the authority.
|
|
386
|
+
*/
|
|
387
|
+
export function milpScheduleGenerator(schedules) {
|
|
388
|
+
return {
|
|
389
|
+
id: 'milp-schedules',
|
|
390
|
+
generate() {
|
|
391
|
+
const candidates = [];
|
|
392
|
+
if (schedules.raw && schedules.raw.conversions.length > 0) {
|
|
393
|
+
candidates.push({
|
|
394
|
+
id: 'milp-raw',
|
|
395
|
+
source: 'milp',
|
|
396
|
+
category: 'roth',
|
|
397
|
+
label: 'Optimizer schedule (raw)',
|
|
398
|
+
explanation: 'The raw MILP conversion schedule, priced on the exact ledger.',
|
|
399
|
+
conversions: schedules.raw.conversions,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
if (schedules.cleanedConversions && schedules.cleanedConversions.length > 0) {
|
|
403
|
+
candidates.push({
|
|
404
|
+
id: 'milp-cleaned',
|
|
405
|
+
source: 'milp',
|
|
406
|
+
category: 'roth',
|
|
407
|
+
label: 'Optimizer schedule (exact-ledger cleaned)',
|
|
408
|
+
explanation: 'The post-processed MILP schedule the exact ledger can execute in full.',
|
|
409
|
+
conversions: schedules.cleanedConversions,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
return candidates;
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
/** Ending balance of an account, 0 for the types that don't carry one. */
|
|
417
|
+
function accountBalance(account) {
|
|
418
|
+
return 'balance' in account ? account.balance : 0;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Annuity purchase candidates (guaranteed-income-and-estate-depth, step 6;
|
|
422
|
+
* ladders added by annuity-pension-and-home-equity, step 1): no purchase /
|
|
423
|
+
* cover-the-floor SPIA / a laddered version of the same SPIA (three dated
|
|
424
|
+
* tranches, so rising age-payout rates and shorter rate lock-in are priced) /
|
|
425
|
+
* QLAC at the cap. Each is a concrete, bounded plan patch that adds (or
|
|
426
|
+
* removes) annuity purchases; the exact ledger prices the liquidity ↓ /
|
|
427
|
+
* durability ↑ / estate Δ tradeoff — generation never decides. Bounded: at
|
|
428
|
+
* most four candidates. Payout rates come from the sourced default table in
|
|
429
|
+
* ./spiaQuotes (user-entered purchases carry their own quotes).
|
|
430
|
+
*/
|
|
431
|
+
export const annuityPurchaseGenerator = {
|
|
432
|
+
id: 'annuity-purchase',
|
|
433
|
+
generate(ctx) {
|
|
434
|
+
const plan = ctx.plan;
|
|
435
|
+
const startYear = ctx.simulateOptions.startYear;
|
|
436
|
+
const primary = plan.household.people[0];
|
|
437
|
+
if (!primary)
|
|
438
|
+
return [];
|
|
439
|
+
const currentAge = startYear - dobYear(primary.dob);
|
|
440
|
+
const candidates = [];
|
|
441
|
+
// No-purchase alternative, only meaningful when the plan already buys one.
|
|
442
|
+
if (plan.accounts.some((a) => a.type === 'annuity' && a.purchase)) {
|
|
443
|
+
candidates.push({
|
|
444
|
+
id: 'annuity-none',
|
|
445
|
+
source: 'heuristic',
|
|
446
|
+
category: 'guaranteed-income',
|
|
447
|
+
label: 'Buy no annuity',
|
|
448
|
+
explanation: 'Runs the plan with the annuity purchase removed, so the liquidity and estate cost of buying guaranteed income is priced on the exact ledger.',
|
|
449
|
+
planPatch: { accounts: plan.accounts.filter((a) => !(a.type === 'annuity' && a.purchase)) },
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
// Cover-the-floor SPIA: an immediate non-qualified annuity funded from the
|
|
453
|
+
// largest liquid account, premium bounded at a quarter of that balance.
|
|
454
|
+
const liquid = plan.accounts
|
|
455
|
+
.filter((a) => a.type === 'cash' || a.type === 'taxable')
|
|
456
|
+
.sort((a, b) => accountBalance(b) - accountBalance(a))[0];
|
|
457
|
+
if (liquid && accountBalance(liquid) > 25_000) {
|
|
458
|
+
const startAge = Math.min(95, Math.max(currentAge, 65));
|
|
459
|
+
const premium = Math.min(accountBalance(liquid) * 0.25, 250_000);
|
|
460
|
+
const monthly = (premium * spiaPayoutRate(startAge)) / 12;
|
|
461
|
+
const annuity = {
|
|
462
|
+
// Namespaced by year + funding account so the synthetic candidate account
|
|
463
|
+
// cannot collide with (and silently overwrite) a real user account id.
|
|
464
|
+
id: `annuity-spia-candidate-${startYear}-${liquid.id}`,
|
|
465
|
+
type: 'annuity',
|
|
466
|
+
name: 'SPIA (candidate)',
|
|
467
|
+
ownerPersonId: primary.id,
|
|
468
|
+
annualReturnPct: null,
|
|
469
|
+
startAge,
|
|
470
|
+
monthlyAmount: monthly,
|
|
471
|
+
colaPct: 0,
|
|
472
|
+
taxablePct: 100,
|
|
473
|
+
purchase: { year: startYear, premium, fundingAccountId: liquid.id, taxQualification: 'nonQualified' },
|
|
474
|
+
};
|
|
475
|
+
candidates.push({
|
|
476
|
+
id: 'annuity-spia',
|
|
477
|
+
source: 'heuristic',
|
|
478
|
+
category: 'guaranteed-income',
|
|
479
|
+
label: 'Buy a cover-the-floor SPIA',
|
|
480
|
+
explanation: `Trades $${Math.round(premium).toLocaleString()} of liquid savings for an immediate life annuity (~$${Math.round(monthly).toLocaleString()}/mo), taxed by exclusion ratio and priced on the exact ledger.`,
|
|
481
|
+
planPatch: { accounts: [...plan.accounts, annuity] },
|
|
482
|
+
metadata: { premium: Math.round(premium), monthly: Math.round(monthly) },
|
|
483
|
+
});
|
|
484
|
+
// Laddered alternative: the same premium in three dated tranches (now,
|
|
485
|
+
// +3y, +6y). Later tranches buy at older-age (higher) payout rates and
|
|
486
|
+
// keep the deferred dollars invested meanwhile; the exact ledger prices
|
|
487
|
+
// whether that beats the single purchase.
|
|
488
|
+
const trancheYears = [0, 3, 6];
|
|
489
|
+
const tranchePremium = premium / trancheYears.length;
|
|
490
|
+
const ladderAccounts = trancheYears.map((offset) => {
|
|
491
|
+
const trancheStartAge = Math.min(95, startAge + offset);
|
|
492
|
+
return {
|
|
493
|
+
id: `annuity-spia-ladder-candidate-${startYear + offset}-${liquid.id}`,
|
|
494
|
+
type: 'annuity',
|
|
495
|
+
name: `SPIA ladder tranche ${startYear + offset} (candidate)`,
|
|
496
|
+
ownerPersonId: primary.id,
|
|
497
|
+
annualReturnPct: null,
|
|
498
|
+
startAge: trancheStartAge,
|
|
499
|
+
monthlyAmount: (tranchePremium * spiaPayoutRate(trancheStartAge)) / 12,
|
|
500
|
+
colaPct: 0,
|
|
501
|
+
taxablePct: 100,
|
|
502
|
+
purchase: {
|
|
503
|
+
year: startYear + offset,
|
|
504
|
+
premium: tranchePremium,
|
|
505
|
+
fundingAccountId: liquid.id,
|
|
506
|
+
taxQualification: 'nonQualified',
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
});
|
|
510
|
+
candidates.push({
|
|
511
|
+
id: 'annuity-spia-ladder',
|
|
512
|
+
source: 'heuristic',
|
|
513
|
+
category: 'guaranteed-income',
|
|
514
|
+
label: 'Ladder the SPIA over three purchases',
|
|
515
|
+
explanation: `Splits the same $${Math.round(premium).toLocaleString()} into three SPIA purchases (now, +3y, +6y) at each age's payout rate, keeping deferred dollars invested meanwhile; priced on the exact ledger.`,
|
|
516
|
+
planPatch: { accounts: [...plan.accounts, ...ladderAccounts] },
|
|
517
|
+
metadata: { premium: Math.round(premium), tranches: trancheYears.length },
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
// QLAC at the cap: a deferred qualified annuity funded from the largest
|
|
521
|
+
// owner-controlled traditional account, premium held to the statutory cap.
|
|
522
|
+
const traditional = plan.accounts
|
|
523
|
+
.filter((a) => a.type === 'traditional' && !a.inherited)
|
|
524
|
+
.sort((a, b) => b.balance - a.balance)[0];
|
|
525
|
+
if (traditional && traditional.balance > 50_000 && currentAge < 83) {
|
|
526
|
+
// Match the projection's statutory-limit indexing: for a start year past the
|
|
527
|
+
// latest pack the QLAC cap is inflation-projected, so an un-indexed cap would
|
|
528
|
+
// systematically under-shoot "at the cap" (and mis-price the candidate).
|
|
529
|
+
const { pack: qlacPack, isStandIn } = packForYear(startYear);
|
|
530
|
+
const inflation = plan.assumptions.inflationPct / 100;
|
|
531
|
+
const capGrowth = isStandIn && startYear > LATEST_PACK_YEAR ? Math.pow(1 + inflation, startYear - qlacPack.year) : 1;
|
|
532
|
+
const cap = qlacPack.annuities.qlacPremiumCap * capGrowth;
|
|
533
|
+
const premium = Math.min(cap, traditional.balance * 0.25);
|
|
534
|
+
const startAge = Math.min(85, Math.max(currentAge + 1, 80));
|
|
535
|
+
// A QLAC bought years before it starts pays a much higher deferred rate.
|
|
536
|
+
const monthly = (premium * QLAC_DEFERRED_PAYOUT_RATE) / 12;
|
|
537
|
+
const annuity = {
|
|
538
|
+
// Namespaced by year + funding account so the synthetic candidate account
|
|
539
|
+
// cannot collide with (and silently overwrite) a real user account id.
|
|
540
|
+
id: `annuity-qlac-candidate-${startYear}-${traditional.id}`,
|
|
541
|
+
type: 'annuity',
|
|
542
|
+
name: 'QLAC (candidate)',
|
|
543
|
+
ownerPersonId: traditional.ownerPersonId ?? primary.id,
|
|
544
|
+
annualReturnPct: null,
|
|
545
|
+
startAge,
|
|
546
|
+
monthlyAmount: monthly,
|
|
547
|
+
colaPct: 0,
|
|
548
|
+
taxablePct: 100,
|
|
549
|
+
purchase: { year: startYear, premium, fundingAccountId: traditional.id, taxQualification: 'qualified', qlac: true },
|
|
550
|
+
};
|
|
551
|
+
candidates.push({
|
|
552
|
+
id: 'annuity-qlac',
|
|
553
|
+
source: 'heuristic',
|
|
554
|
+
category: 'guaranteed-income',
|
|
555
|
+
label: 'Buy a QLAC at the cap',
|
|
556
|
+
explanation: `Moves $${Math.round(premium).toLocaleString()} of traditional savings into a deferred longevity annuity starting at ${startAge} (fully taxable payouts, premium out of the RMD base), priced on the exact ledger.`,
|
|
557
|
+
planPatch: { accounts: [...plan.accounts, annuity] },
|
|
558
|
+
metadata: { premium: Math.round(premium), monthly: Math.round(monthly), startAge },
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
return candidates;
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
/**
|
|
565
|
+
* Social Security bridge / TIPS-ladder candidates
|
|
566
|
+
* (social-security-bridge-and-tips-ladder, step 3): fund the gap years between
|
|
567
|
+
* retirement and each claimant's chosen claim age with a TIPS bridge ladder
|
|
568
|
+
* paying the forgone age-62 benefit, purchased from the largest liquid
|
|
569
|
+
* account; plus the remove-the-ladders alternative when the plan already has
|
|
570
|
+
* some. Bounded (≤ one bridge per person + one removal); the exact ledger
|
|
571
|
+
* prices the liquidity ↓ / durability ↑ tradeoff — generation never decides.
|
|
572
|
+
*/
|
|
573
|
+
export const bridgeLadderGenerator = {
|
|
574
|
+
id: 'bridge-ladder',
|
|
575
|
+
generate(ctx) {
|
|
576
|
+
const plan = ctx.plan;
|
|
577
|
+
const startYear = ctx.simulateOptions.startYear;
|
|
578
|
+
const candidates = [];
|
|
579
|
+
if ((plan.incomeFloor?.ladders.length ?? 0) > 0) {
|
|
580
|
+
candidates.push({
|
|
581
|
+
id: 'income-floor-none',
|
|
582
|
+
source: 'heuristic',
|
|
583
|
+
category: 'guaranteed-income',
|
|
584
|
+
label: 'Hold no TIPS ladders',
|
|
585
|
+
explanation: 'Runs the plan with every TIPS ladder removed, so the cost of the guaranteed floor is priced on the exact ledger.',
|
|
586
|
+
planPatch: { incomeFloor: { ladders: [] } },
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
const liquid = plan.accounts
|
|
590
|
+
.filter((a) => a.type === 'cash' || a.type === 'taxable')
|
|
591
|
+
.sort((a, b) => accountBalance(b) - accountBalance(a))[0];
|
|
592
|
+
if (!liquid)
|
|
593
|
+
return candidates;
|
|
594
|
+
const newLadders = [];
|
|
595
|
+
let totalCost = 0;
|
|
596
|
+
for (const stream of plan.incomes) {
|
|
597
|
+
if (stream.type !== 'socialSecurity' || stream.piaMonthly === null || stream.piaMonthly <= 0)
|
|
598
|
+
continue;
|
|
599
|
+
const person = plan.household.people.find((p) => p.id === stream.personId);
|
|
600
|
+
if (!person)
|
|
601
|
+
continue;
|
|
602
|
+
const dobYearNum = dobYear(person.dob);
|
|
603
|
+
const dobMonth = Number(person.dob.slice(5, 7));
|
|
604
|
+
const dobDay = Number(person.dob.slice(8, 10));
|
|
605
|
+
const retirementYear = person.retirementAge !== null ? dobYearNum + person.retirementAge : startYear;
|
|
606
|
+
const sized = sizeBridge({
|
|
607
|
+
piaMonthly: stream.piaMonthly,
|
|
608
|
+
dob: { year: dobYearNum, month: dobMonth, day: dobDay },
|
|
609
|
+
claimAge: stream.claimAge,
|
|
610
|
+
currentYear: startYear,
|
|
611
|
+
retirementYear,
|
|
612
|
+
curve: EMBEDDED_REAL_YIELD_CURVE,
|
|
613
|
+
});
|
|
614
|
+
if (!sized)
|
|
615
|
+
continue;
|
|
616
|
+
// Skip when a plan ladder already covers the whole window (a ladder
|
|
617
|
+
// ending even one year short leaves a real unfunded gap year).
|
|
618
|
+
const covered = plan.incomeFloor?.ladders.some((l) => l.startYear <= sized.startYear && l.endYear >= sized.endYear);
|
|
619
|
+
if (covered)
|
|
620
|
+
continue;
|
|
621
|
+
totalCost += sized.ladderCost;
|
|
622
|
+
newLadders.push({
|
|
623
|
+
// Namespaced by person so the synthetic candidate id cannot collide
|
|
624
|
+
// with a real ladder id.
|
|
625
|
+
id: `bridge-candidate-${startYear}-${stream.personId}`,
|
|
626
|
+
name: `SS bridge (${person.name})`,
|
|
627
|
+
purpose: 'bridge',
|
|
628
|
+
startYear: sized.startYear,
|
|
629
|
+
endYear: sized.endYear,
|
|
630
|
+
annualRealAmount: sized.annualRealAmount,
|
|
631
|
+
purchase: { year: startYear, fundingAccountId: liquid.id },
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
// Same affordability gate as the ss-bridge-gap detector, so both surfaces
|
|
635
|
+
// agree on whether a bridge is fundable.
|
|
636
|
+
if (newLadders.length === 0 || accountBalance(liquid) < totalCost * BRIDGE_FUNDING_MIN_FRACTION)
|
|
637
|
+
return candidates;
|
|
638
|
+
candidates.push({
|
|
639
|
+
id: 'bridge-ladder',
|
|
640
|
+
source: 'heuristic',
|
|
641
|
+
category: 'guaranteed-income',
|
|
642
|
+
label: 'Fund the Social Security gap years with a TIPS bridge ladder',
|
|
643
|
+
explanation: `Buys ~$${Math.round(totalCost).toLocaleString()} of TIPS maturing across the years before the chosen claim age, paying the forgone age-62 benefit so delaying Social Security never cuts lifestyle; priced on the exact ledger.`,
|
|
644
|
+
planPatch: { incomeFloor: { ladders: [...(plan.incomeFloor?.ladders ?? []), ...newLadders] } },
|
|
645
|
+
metadata: { totalCost: Math.round(totalCost), ladders: newLadders.length },
|
|
646
|
+
});
|
|
647
|
+
return candidates;
|
|
648
|
+
},
|
|
649
|
+
};
|
|
650
|
+
/** Wrap a modelable Insights detector action as a decision candidate generator. */
|
|
651
|
+
export function insightActionGenerator(args) {
|
|
652
|
+
return {
|
|
653
|
+
id: `insight-${args.id}`,
|
|
654
|
+
generate() {
|
|
655
|
+
return [
|
|
656
|
+
{
|
|
657
|
+
id: args.id,
|
|
658
|
+
source: 'detector',
|
|
659
|
+
category: args.category,
|
|
660
|
+
label: args.label,
|
|
661
|
+
explanation: args.explanation,
|
|
662
|
+
planPatch: args.planPatch,
|
|
663
|
+
},
|
|
664
|
+
];
|
|
665
|
+
},
|
|
666
|
+
};
|
|
667
|
+
}
|