@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,77 @@
|
|
|
1
|
+
import { spiaPayoutRate } from '../../decisions/spiaQuotes.js';
|
|
2
|
+
/**
|
|
3
|
+
* "You plan for a long life but hold no guaranteed income — annuitization
|
|
4
|
+
* headroom" (annuity-pension-and-home-equity, step 5). Fires for
|
|
5
|
+
* longevity-anxious plans (a planning age of 95+) with meaningful liquid
|
|
6
|
+
* savings, no annuity on the books, and no pension income covering the same
|
|
7
|
+
* ground. The action previews a cover-the-floor SPIA as a scenario so the
|
|
8
|
+
* ledger — not the card — prices the liquidity/estate tradeoff; the Monte
|
|
9
|
+
* Carlo annuitization sweep is the full solver view.
|
|
10
|
+
*/
|
|
11
|
+
export const annuitizationHeadroom = {
|
|
12
|
+
id: 'annuitization-headroom',
|
|
13
|
+
category: 'longevity-insurance-geography',
|
|
14
|
+
screen(ctx) {
|
|
15
|
+
const plan = ctx.plan;
|
|
16
|
+
const startYear = ctx.projection.startYear;
|
|
17
|
+
const primary = plan.household.people[0];
|
|
18
|
+
if (!primary)
|
|
19
|
+
return null;
|
|
20
|
+
// Longevity-anxious: someone plans to 95+.
|
|
21
|
+
const maxPlanningAge = Math.max(...plan.household.people.map((p) => p.longevity.planningAge));
|
|
22
|
+
if (maxPlanningAge < 95)
|
|
23
|
+
return null;
|
|
24
|
+
// Headroom is *unused*: any annuity (owned or purchased) or pension income
|
|
25
|
+
// stream means the household already holds lifetime income beyond SS.
|
|
26
|
+
if (plan.accounts.some((a) => a.type === 'annuity' || (a.type === 'pension' && !a.lumpSumElection)))
|
|
27
|
+
return null;
|
|
28
|
+
const liquid = plan.accounts
|
|
29
|
+
.filter((a) => a.type === 'cash' || a.type === 'taxable')
|
|
30
|
+
.sort((a, b) => ('balance' in b ? b.balance : 0) - ('balance' in a ? a.balance : 0))[0];
|
|
31
|
+
if (!liquid || !('balance' in liquid) || liquid.balance < 100_000)
|
|
32
|
+
return null;
|
|
33
|
+
const currentAge = startYear - Number(primary.dob.slice(0, 4));
|
|
34
|
+
const startAge = Math.min(95, Math.max(currentAge, 65));
|
|
35
|
+
const premium = Math.min(liquid.balance * 0.25, 250_000);
|
|
36
|
+
const monthly = (premium * spiaPayoutRate(startAge)) / 12;
|
|
37
|
+
const spia = {
|
|
38
|
+
id: `annuitization-headroom-preview-${startYear}-${liquid.id}`,
|
|
39
|
+
type: 'annuity',
|
|
40
|
+
name: 'SPIA (preview)',
|
|
41
|
+
ownerPersonId: primary.id,
|
|
42
|
+
annualReturnPct: null,
|
|
43
|
+
startAge,
|
|
44
|
+
monthlyAmount: monthly,
|
|
45
|
+
colaPct: 0,
|
|
46
|
+
taxablePct: 100,
|
|
47
|
+
purchase: { year: startYear, premium, fundingAccountId: liquid.id, taxQualification: 'nonQualified' },
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
id: 'annuitization-headroom',
|
|
51
|
+
category: 'longevity-insurance-geography',
|
|
52
|
+
title: 'Planning to 95+ with no lifetime income beyond Social Security',
|
|
53
|
+
rationale: `Your plan runs to ${maxPlanningAge} with no pension or annuity income. ` +
|
|
54
|
+
`Trading $${Math.round(premium).toLocaleString()} of liquid savings for a life annuity (~$${Math.round(monthly).toLocaleString()}/mo) ` +
|
|
55
|
+
'insures the years past life expectancy — the exact risk a long planning age worries about — at the cost of liquidity and estate. ' +
|
|
56
|
+
'The Monte Carlo page\'s annuitization sweep shows the full success-vs-legacy frontier.',
|
|
57
|
+
impact: {
|
|
58
|
+
qualitative: 'Preview the SPIA against your full plan: guaranteed lifetime income and steadier late-life funding, priced against the liquidity and estate it costs.',
|
|
59
|
+
},
|
|
60
|
+
exact: false,
|
|
61
|
+
confidence: 'medium',
|
|
62
|
+
learnSlug: 'pensions-and-annuities',
|
|
63
|
+
plannerRoute: 'monte-carlo',
|
|
64
|
+
action: {
|
|
65
|
+
kind: 'preview-scenario',
|
|
66
|
+
scenarioName: 'Buy a cover-the-floor SPIA',
|
|
67
|
+
patch: { accounts: [...plan.accounts, spia] },
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
evaluate(ctx) {
|
|
72
|
+
const card = this.screen(ctx);
|
|
73
|
+
if (!card)
|
|
74
|
+
throw new Error('Annuitization headroom not eligible');
|
|
75
|
+
return { action: card.action, impact: card.impact };
|
|
76
|
+
},
|
|
77
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asset-location detector (asset-allocation-and-return-model-v2, step 5).
|
|
3
|
+
*
|
|
4
|
+
* Surfaces the shared `assetLocationGenerator` when a plan opts into static
|
|
5
|
+
* allocation on multiple accounts. `screen()` is cheap and pure; `evaluate()`
|
|
6
|
+
* prices every bounded swap on the exact ledger and previews the winner.
|
|
7
|
+
*/
|
|
8
|
+
import type { Detector } from '../types.js';
|
|
9
|
+
export declare const assetLocation: Detector;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asset-location detector (asset-allocation-and-return-model-v2, step 5).
|
|
3
|
+
*
|
|
4
|
+
* Surfaces the shared `assetLocationGenerator` when a plan opts into static
|
|
5
|
+
* allocation on multiple accounts. `screen()` is cheap and pure; `evaluate()`
|
|
6
|
+
* prices every bounded swap on the exact ledger and previews the winner.
|
|
7
|
+
*/
|
|
8
|
+
import { planUsesAssetAllocation } from '../../allocation/assetClasses.js';
|
|
9
|
+
import { createDecisionContext, evaluateCandidate } from '../../decisions/evaluateCandidate.js';
|
|
10
|
+
import { assetLocationGenerator } from '../../decisions/generators.js';
|
|
11
|
+
import { combineTaxCalculators, createFederalTaxCalculator } from '../../tax/federalTax.js';
|
|
12
|
+
import { createStateTaxCalculator } from '../../tax/stateTax.js';
|
|
13
|
+
function decisionContextFromDetector(ctx) {
|
|
14
|
+
const taxCalculator = combineTaxCalculators(createFederalTaxCalculator(), createStateTaxCalculator({
|
|
15
|
+
overridePct: ctx.plan.assumptions.stateEffectiveTaxPct,
|
|
16
|
+
localPct: ctx.plan.assumptions.localIncomeTaxPct,
|
|
17
|
+
}));
|
|
18
|
+
return createDecisionContext(ctx.plan, { startYear: ctx.projection.startYear, taxCalculator }, { result: ctx.projection.result, summary: ctx.projection.summary });
|
|
19
|
+
}
|
|
20
|
+
function pickBestBeneficialCandidate(decisionCtx, candidates) {
|
|
21
|
+
let best = null;
|
|
22
|
+
for (const candidate of candidates) {
|
|
23
|
+
const evaluation = evaluateCandidate(decisionCtx, candidate);
|
|
24
|
+
if (evaluation.recommendationState !== 'beneficial')
|
|
25
|
+
continue;
|
|
26
|
+
const delta = evaluation.deltas.endingAfterTaxEstate;
|
|
27
|
+
if (best === null || delta > best.delta)
|
|
28
|
+
best = { candidate, delta };
|
|
29
|
+
}
|
|
30
|
+
return best;
|
|
31
|
+
}
|
|
32
|
+
export const assetLocation = {
|
|
33
|
+
id: 'asset-location',
|
|
34
|
+
category: 'accounts-contributions',
|
|
35
|
+
screen(ctx) {
|
|
36
|
+
if (!planUsesAssetAllocation(ctx.plan))
|
|
37
|
+
return null;
|
|
38
|
+
const candidates = assetLocationGenerator.generate({ plan: ctx.plan });
|
|
39
|
+
if (candidates.length === 0)
|
|
40
|
+
return null;
|
|
41
|
+
const preferred = candidates.find((candidate) => candidate.id === 'asset-location-bonds-to-traditional') ?? candidates[0];
|
|
42
|
+
const swapped = preferred.metadata?.swappedDollars ?? 0;
|
|
43
|
+
return {
|
|
44
|
+
id: 'asset-location',
|
|
45
|
+
category: 'accounts-contributions',
|
|
46
|
+
title: 'Improve asset location across accounts',
|
|
47
|
+
rationale: 'Your plan uses class-level allocation on multiple accounts. Preview moving bonds toward tax-deferred wrappers and stocks toward taxable or Roth accounts while keeping the household stock/bond mix unchanged.',
|
|
48
|
+
impact: {
|
|
49
|
+
qualitative: swapped > 0
|
|
50
|
+
? `Up to $${Math.round(swapped).toLocaleString()} of class exposure could be relocated; expand to preview the exact after-tax estate impact.`
|
|
51
|
+
: 'Preview bounded location swaps priced on your full projection.',
|
|
52
|
+
},
|
|
53
|
+
exact: false,
|
|
54
|
+
confidence: 'medium',
|
|
55
|
+
learnSlug: 'assumption-investment-returns',
|
|
56
|
+
plannerRoute: 'accounts',
|
|
57
|
+
action: {
|
|
58
|
+
kind: 'preview-scenario',
|
|
59
|
+
scenarioName: preferred.label,
|
|
60
|
+
patch: preferred.planPatch,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
evaluate(ctx) {
|
|
65
|
+
const card = this.screen(ctx);
|
|
66
|
+
if (!card || card.action.kind !== 'preview-scenario')
|
|
67
|
+
throw new Error('Asset location not eligible');
|
|
68
|
+
const decisionCtx = decisionContextFromDetector(ctx);
|
|
69
|
+
const candidates = assetLocationGenerator.generate(decisionCtx);
|
|
70
|
+
const best = pickBestBeneficialCandidate(decisionCtx, candidates);
|
|
71
|
+
if (!best) {
|
|
72
|
+
throw new Error('No beneficial asset-location swap was found once taxes, taxable drag, and rebalancing were priced in.');
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
action: {
|
|
76
|
+
kind: 'preview-scenario',
|
|
77
|
+
scenarioName: best.candidate.label,
|
|
78
|
+
patch: best.candidate.planPatch,
|
|
79
|
+
},
|
|
80
|
+
impact: {
|
|
81
|
+
qualitative: `Exact ledger: "${best.candidate.label}" improves after-tax estate by about $${Math.round(best.delta).toLocaleString()} (today's dollars).`,
|
|
82
|
+
endingAfterTaxEstateDelta: best.delta,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Detector } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* "House-rich, portfolio-thin: a HECM line of credit could buffer sequence
|
|
4
|
+
* risk" (annuity-pension-and-home-equity, step 5). Fires when a primary
|
|
5
|
+
* residence's value rivals the investable portfolio, the youngest borrower is
|
|
6
|
+
* 62+, and no HECM is modeled. Pfau's research: opening the line early and
|
|
7
|
+
* drawing after down years lets the portfolio recover — the line's unused
|
|
8
|
+
* credit grows regardless of home value. The action previews a coordinated
|
|
9
|
+
* line as a scenario; deterministic runs price only the last-resort backstop,
|
|
10
|
+
* so the Monte Carlo comparison is where the coordinated policy shows up.
|
|
11
|
+
*/
|
|
12
|
+
export declare const hecmBufferCandidate: Detector;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { hecmPrincipalLimitFactorPct } from '../../params/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* "House-rich, portfolio-thin: a HECM line of credit could buffer sequence
|
|
4
|
+
* risk" (annuity-pension-and-home-equity, step 5). Fires when a primary
|
|
5
|
+
* residence's value rivals the investable portfolio, the youngest borrower is
|
|
6
|
+
* 62+, and no HECM is modeled. Pfau's research: opening the line early and
|
|
7
|
+
* drawing after down years lets the portfolio recover — the line's unused
|
|
8
|
+
* credit grows regardless of home value. The action previews a coordinated
|
|
9
|
+
* line as a scenario; deterministic runs price only the last-resort backstop,
|
|
10
|
+
* so the Monte Carlo comparison is where the coordinated policy shows up.
|
|
11
|
+
*/
|
|
12
|
+
export const hecmBufferCandidate = {
|
|
13
|
+
id: 'hecm-buffer-candidate',
|
|
14
|
+
category: 'longevity-insurance-geography',
|
|
15
|
+
screen(ctx) {
|
|
16
|
+
const plan = ctx.plan;
|
|
17
|
+
const startYear = ctx.projection.startYear;
|
|
18
|
+
if (plan.household.people.length === 0)
|
|
19
|
+
return null;
|
|
20
|
+
const youngestAge = Math.min(...plan.household.people.map((p) => startYear - Number(p.dob.slice(0, 4))));
|
|
21
|
+
if (youngestAge < 62)
|
|
22
|
+
return null;
|
|
23
|
+
const home = plan.accounts.find((a) => a.type === 'property' && a.primaryResidence === true && a.hecm === undefined && a.value > 100_000 &&
|
|
24
|
+
// A planned sale makes the buffer moot (the line closes at sale).
|
|
25
|
+
(a.plannedSaleYear === null || a.plannedSaleYear === undefined));
|
|
26
|
+
if (!home)
|
|
27
|
+
return null;
|
|
28
|
+
let investable = 0;
|
|
29
|
+
for (const a of plan.accounts) {
|
|
30
|
+
if (a.type === 'cash' || a.type === 'taxable' || a.type === 'equityComp' ||
|
|
31
|
+
a.type === 'traditional' || a.type === 'roth' || a.type === 'hsa') {
|
|
32
|
+
investable += 'balance' in a ? a.balance : 0;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// House-rich / portfolio-thin: the home is a major share of net worth.
|
|
36
|
+
if (investable <= 0 || home.value < investable * 0.75)
|
|
37
|
+
return null;
|
|
38
|
+
const pack = ctx.params;
|
|
39
|
+
const plfPct = hecmPrincipalLimitFactorPct(pack, youngestAge);
|
|
40
|
+
const lineSize = (plfPct / 100) * home.value;
|
|
41
|
+
const patchedHome = {
|
|
42
|
+
...home,
|
|
43
|
+
hecm: {
|
|
44
|
+
openYear: startYear,
|
|
45
|
+
growthRatePct: pack.hecm.defaultGrowthRatePct,
|
|
46
|
+
drawPolicy: 'coordinated',
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
id: 'hecm-buffer-candidate',
|
|
51
|
+
category: 'longevity-insurance-geography',
|
|
52
|
+
title: 'Your home equity could backstop market downturns',
|
|
53
|
+
rationale: `${home.name} (~$${Math.round(home.value).toLocaleString()}) rivals your $${Math.round(investable).toLocaleString()} portfolio. ` +
|
|
54
|
+
`A HECM line of credit opened now would start near $${Math.round(lineSize).toLocaleString()} (${plfPct.toFixed(1)}% of value at age ${youngestAge}, published factor tables) ` +
|
|
55
|
+
`and grow ~${pack.hecm.defaultGrowthRatePct}%/yr regardless of home value. Drawing tax-free after down years — instead of selling depressed assets — is the coordinated buffer strategy; ` +
|
|
56
|
+
'costs are real (financed fees, a growing loan) and the loan is repaid from the home, non-recourse.',
|
|
57
|
+
impact: {
|
|
58
|
+
qualitative: 'Preview the line against your full plan (best seen on the Monte Carlo page, where down-year draws actually trigger): steadier bad-path spending at the cost of home equity in the estate.',
|
|
59
|
+
},
|
|
60
|
+
exact: false,
|
|
61
|
+
confidence: 'medium',
|
|
62
|
+
plannerRoute: 'accounts',
|
|
63
|
+
action: {
|
|
64
|
+
kind: 'preview-scenario',
|
|
65
|
+
scenarioName: 'Open a HECM line of credit (coordinated draws)',
|
|
66
|
+
patch: { accounts: plan.accounts.map((a) => (a.id === home.id ? patchedHome : a)) },
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
evaluate(ctx) {
|
|
71
|
+
const card = this.screen(ctx);
|
|
72
|
+
if (!card)
|
|
73
|
+
throw new Error('HECM buffer not eligible');
|
|
74
|
+
return { action: card.action, impact: card.impact };
|
|
75
|
+
},
|
|
76
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Detector } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* "Your floor is X% funded" (social-security-bridge-and-tips-ladder, step 4):
|
|
4
|
+
* the Pfau funded-ratio lens as an advisory card. Fires when the household has
|
|
5
|
+
* distinguished a required floor from lifestyle spending and guaranteed income
|
|
6
|
+
* covers less than ~90% of its present value on the TIPS curve.
|
|
7
|
+
*/
|
|
8
|
+
export declare const incomeFloorFunded: Detector;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { EMBEDDED_REAL_YIELD_CURVE } from '../../params/index.js';
|
|
2
|
+
import { computeFundedRatio } from '../../ladder/fundedRatio.js';
|
|
3
|
+
/**
|
|
4
|
+
* "Your floor is X% funded" (social-security-bridge-and-tips-ladder, step 4):
|
|
5
|
+
* the Pfau funded-ratio lens as an advisory card. Fires when the household has
|
|
6
|
+
* distinguished a required floor from lifestyle spending and guaranteed income
|
|
7
|
+
* covers less than ~90% of its present value on the TIPS curve.
|
|
8
|
+
*/
|
|
9
|
+
export const incomeFloorFunded = {
|
|
10
|
+
id: 'income-floor-funded',
|
|
11
|
+
category: 'longevity-insurance-geography',
|
|
12
|
+
screen(ctx) {
|
|
13
|
+
const plan = ctx.plan;
|
|
14
|
+
// Only meaningful once the user has said what "essential" means: without
|
|
15
|
+
// requiredAnnual the floor equals the whole lifestyle and the card would
|
|
16
|
+
// just restate the success rate.
|
|
17
|
+
if (plan.expenses.requiredAnnual === undefined)
|
|
18
|
+
return null;
|
|
19
|
+
const primary = plan.household.people[0];
|
|
20
|
+
if (!primary)
|
|
21
|
+
return null;
|
|
22
|
+
const retirementYear = primary.retirementAge !== null ? Number(primary.dob.slice(0, 4)) + primary.retirementAge : ctx.projection.startYear;
|
|
23
|
+
const fr = computeFundedRatio({
|
|
24
|
+
years: ctx.projection.result.years,
|
|
25
|
+
startYear: ctx.projection.startYear,
|
|
26
|
+
deflate: ctx.projection.deflate,
|
|
27
|
+
curve: EMBEDDED_REAL_YIELD_CURVE,
|
|
28
|
+
fromYear: Math.max(retirementYear, ctx.projection.startYear),
|
|
29
|
+
});
|
|
30
|
+
if (!fr || fr.fundedRatioPct >= 90)
|
|
31
|
+
return null;
|
|
32
|
+
const pct = Math.round(fr.fundedRatioPct);
|
|
33
|
+
return {
|
|
34
|
+
id: 'income-floor-funded',
|
|
35
|
+
category: 'longevity-insurance-geography',
|
|
36
|
+
title: `Your essential-spending floor is ${pct}% funded`,
|
|
37
|
+
rationale: `Discounted on today's TIPS curve, your essential retirement spending is worth ` +
|
|
38
|
+
`$${Math.round(fr.essentialSpendingPv).toLocaleString()} in today's dollars, and guaranteed income ` +
|
|
39
|
+
`(Social Security, pensions, annuities, TIPS ladders) covers $${Math.round(fr.guaranteedIncomePv).toLocaleString()} of it (${pct}%). ` +
|
|
40
|
+
`The $${Math.round(fr.unfundedPv).toLocaleString()} gap rides on the portfolio; a TIPS ladder can lock some of it in at ~` +
|
|
41
|
+
`${EMBEDDED_REAL_YIELD_CURVE.points[EMBEDDED_REAL_YIELD_CURVE.points.length - 1].realYieldPct}% real.`,
|
|
42
|
+
impact: {
|
|
43
|
+
qualitative: 'The funded ratio is a risk lens, not a verdict: a portfolio can fund the gap in most markets — the question is how much of the floor you want guaranteed regardless of markets.',
|
|
44
|
+
},
|
|
45
|
+
exact: false,
|
|
46
|
+
confidence: 'medium',
|
|
47
|
+
learnSlug: 'funded-ratio',
|
|
48
|
+
plannerRoute: 'income-floor',
|
|
49
|
+
action: { kind: 'advisory' },
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { irmaaTierForMagi } from '../../params/index.js';
|
|
2
|
+
import { medicareAnnualPremiumPerPerson } from '../../tax/medicare.js';
|
|
3
|
+
function inflationScaleFromPack(ctx, toYear) {
|
|
4
|
+
if (toYear <= ctx.params.year)
|
|
5
|
+
return 1;
|
|
6
|
+
return Math.pow(1 + ctx.plan.assumptions.inflationPct / 100, toYear - ctx.params.year);
|
|
7
|
+
}
|
|
8
|
+
function healthcarePremiumScaleFromPack(ctx, toYear) {
|
|
9
|
+
if (toYear <= ctx.params.year)
|
|
10
|
+
return 1;
|
|
11
|
+
const annualRate = 1 + (ctx.plan.assumptions.inflationPct + ctx.plan.assumptions.healthcareExtraInflationPct) / 100;
|
|
12
|
+
return Math.pow(annualRate, toYear - ctx.params.year);
|
|
13
|
+
}
|
|
14
|
+
function trimmedConversionPatch(ctx, year, trimAmount) {
|
|
15
|
+
const conversions = ctx.projection.result.years
|
|
16
|
+
.map((projectionYear) => ({
|
|
17
|
+
year: projectionYear.year,
|
|
18
|
+
amount: Math.max(0, projectionYear.rothConversion - (projectionYear.year === year ? trimAmount : 0)),
|
|
19
|
+
}))
|
|
20
|
+
.filter((conversion) => conversion.amount > 1);
|
|
21
|
+
return {
|
|
22
|
+
strategies: {
|
|
23
|
+
rothConversion: {
|
|
24
|
+
mode: 'manual',
|
|
25
|
+
conversions,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export const irmaaTierEdge = {
|
|
31
|
+
id: 'irmaa-tier-edge',
|
|
32
|
+
category: 'tax-brackets',
|
|
33
|
+
screen(ctx) {
|
|
34
|
+
const filingStatus = ctx.plan.household.filingStatus;
|
|
35
|
+
// Scan years for an IRMAA cliff proximity
|
|
36
|
+
for (const y of ctx.projection.result.years) {
|
|
37
|
+
const premiumYearNumber = y.year + 2;
|
|
38
|
+
const thresholdScale = inflationScaleFromPack(ctx, premiumYearNumber);
|
|
39
|
+
const tier = irmaaTierForMagi(ctx.params, y.magi, filingStatus, thresholdScale);
|
|
40
|
+
if (tier > 0 && tier <= ctx.params.medicare.irmaaTiers.length) {
|
|
41
|
+
const threshold = ctx.params.medicare.irmaaTiers[tier - 1].magiOver[filingStatus] * thresholdScale;
|
|
42
|
+
const diff = y.magi - threshold;
|
|
43
|
+
if (diff > 0 && diff <= 5000) {
|
|
44
|
+
const magiStr = '$' + Math.round(y.magi).toLocaleString();
|
|
45
|
+
const threshStr = '$' + Math.round(threshold).toLocaleString();
|
|
46
|
+
const premiumYear = ctx.projection.result.years.find((candidate) => candidate.year === premiumYearNumber);
|
|
47
|
+
if (!premiumYear)
|
|
48
|
+
continue;
|
|
49
|
+
const medicarePeople = premiumYear.people.filter((p) => p.alive && p.ageAttained >= 65).length;
|
|
50
|
+
if (medicarePeople === 0)
|
|
51
|
+
continue;
|
|
52
|
+
const premiumScale = healthcarePremiumScaleFromPack(ctx, premiumYearNumber);
|
|
53
|
+
const premiumAbove = medicareAnnualPremiumPerPerson(ctx.params, y.magi, filingStatus, thresholdScale, premiumScale);
|
|
54
|
+
const premiumBelow = medicareAnnualPremiumPerPerson(ctx.params, Math.max(0, threshold - 1), filingStatus, thresholdScale, premiumScale);
|
|
55
|
+
const annualPremiumCliff = medicarePeople *
|
|
56
|
+
Math.max(0, premiumAbove.partBAnnual +
|
|
57
|
+
premiumAbove.partDSurchargeAnnual -
|
|
58
|
+
premiumBelow.partBAnnual -
|
|
59
|
+
premiumBelow.partDSurchargeAnnual);
|
|
60
|
+
const trimAmount = Math.ceil(diff + 250);
|
|
61
|
+
const conversionDriven = y.rothConversion > trimAmount;
|
|
62
|
+
return {
|
|
63
|
+
id: 'irmaa-tier-edge',
|
|
64
|
+
category: 'tax-brackets',
|
|
65
|
+
title: 'IRMAA tier-edge proximity',
|
|
66
|
+
rationale: `Your nominal MAGI of ${magiStr} in ${y.year} is just over the ${premiumYearNumber} IRMAA tier threshold of ${threshStr}. This will trigger higher Medicare premiums in ${premiumYearNumber}.`,
|
|
67
|
+
impact: {
|
|
68
|
+
endingAfterTaxEstateDelta: annualPremiumCliff > 0 ? annualPremiumCliff : undefined,
|
|
69
|
+
qualitative: annualPremiumCliff > 0
|
|
70
|
+
? `Avoiding this tier could save roughly $${Math.round(annualPremiumCliff).toLocaleString()} of Medicare premiums in ${premiumYearNumber}.`
|
|
71
|
+
: 'Limit conversion-driven nominal MAGI to stay just under the IRMAA threshold.',
|
|
72
|
+
},
|
|
73
|
+
exact: false,
|
|
74
|
+
confidence: 'high',
|
|
75
|
+
learnSlug: 'irmaa-two-year-lookback',
|
|
76
|
+
plannerRoute: 'optimize',
|
|
77
|
+
action: conversionDriven
|
|
78
|
+
? {
|
|
79
|
+
kind: 'preview-scenario',
|
|
80
|
+
scenarioName: 'Trim conversion below IRMAA tier',
|
|
81
|
+
patch: trimmedConversionPatch(ctx, y.year, trimAmount),
|
|
82
|
+
}
|
|
83
|
+
: {
|
|
84
|
+
kind: 'advisory',
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
},
|
|
92
|
+
evaluate(ctx) {
|
|
93
|
+
const card = this.screen(ctx);
|
|
94
|
+
if (!card) {
|
|
95
|
+
throw new Error('IRMAA tier edge not eligible');
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
action: card.action,
|
|
99
|
+
impact: card.impact,
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Detector } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* "A pension lump-sum election is on record and undecided"
|
|
4
|
+
* (annuity-pension-and-home-equity, step 5). Fires when a pension carries a
|
|
5
|
+
* lump-sum offer with no election and the election year hasn't passed. The
|
|
6
|
+
* card quotes the PV comparison at the curve-anchored rate (deterministic
|
|
7
|
+
* math, no simulate calls) and previews the take-the-lump-sum scenario so the
|
|
8
|
+
* exact ledger prices taxes, survivor interplay, and sequence risk — the
|
|
9
|
+
* verdict stays a tradeoff, never advice.
|
|
10
|
+
*/
|
|
11
|
+
export declare const pensionElectionPending: Detector;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { analyzePensionElections, pensionTakeLumpSumPatch } from '../../decisions/pensionElection.js';
|
|
2
|
+
/**
|
|
3
|
+
* "A pension lump-sum election is on record and undecided"
|
|
4
|
+
* (annuity-pension-and-home-equity, step 5). Fires when a pension carries a
|
|
5
|
+
* lump-sum offer with no election and the election year hasn't passed. The
|
|
6
|
+
* card quotes the PV comparison at the curve-anchored rate (deterministic
|
|
7
|
+
* math, no simulate calls) and previews the take-the-lump-sum scenario so the
|
|
8
|
+
* exact ledger prices taxes, survivor interplay, and sequence risk — the
|
|
9
|
+
* verdict stays a tradeoff, never advice.
|
|
10
|
+
*/
|
|
11
|
+
export const pensionElectionPending = {
|
|
12
|
+
id: 'pension-election-pending',
|
|
13
|
+
category: 'longevity-insurance-geography',
|
|
14
|
+
screen(ctx) {
|
|
15
|
+
const plan = ctx.plan;
|
|
16
|
+
const startYear = ctx.projection.startYear;
|
|
17
|
+
const pending = plan.accounts.find((a) => a.type === 'pension' &&
|
|
18
|
+
a.lumpSumOffer !== undefined &&
|
|
19
|
+
a.lumpSumElection === undefined &&
|
|
20
|
+
a.lumpSumOffer.electionYear >= startYear);
|
|
21
|
+
if (!pending || pending.type !== 'pension')
|
|
22
|
+
return null;
|
|
23
|
+
const analysis = analyzePensionElections(plan, startYear).find((a) => a.pensionId === pending.id);
|
|
24
|
+
if (!analysis || analysis.lumpSum <= 0)
|
|
25
|
+
return null;
|
|
26
|
+
const pv = analysis.presentValueAtCurveRate;
|
|
27
|
+
const ratio = pv / analysis.lumpSum;
|
|
28
|
+
const direction = ratio > 1.05
|
|
29
|
+
? `the annuity's discounted value (~$${Math.round(pv).toLocaleString()}) is above the offer`
|
|
30
|
+
: ratio < 0.95
|
|
31
|
+
? `the offer is above the annuity's discounted value (~$${Math.round(pv).toLocaleString()})`
|
|
32
|
+
: `the offer and the annuity's discounted value (~$${Math.round(pv).toLocaleString()}) are close`;
|
|
33
|
+
// The concrete take-the-lump-sum patch comes from the shared builder so
|
|
34
|
+
// the insight and the decision engine can never disagree about mechanics.
|
|
35
|
+
const patch = pensionTakeLumpSumPatch(plan, pending, startYear);
|
|
36
|
+
return {
|
|
37
|
+
id: 'pension-election-pending',
|
|
38
|
+
category: 'longevity-insurance-geography',
|
|
39
|
+
title: `Pension election pending: $${Math.round(analysis.lumpSum).toLocaleString()} lump sum vs lifetime annuity`,
|
|
40
|
+
rationale: `${pending.name} offers a $${Math.round(analysis.lumpSum).toLocaleString()} lump sum in ${analysis.electionYear}. ` +
|
|
41
|
+
`At the ${analysis.curveRatePct.toFixed(1)}% curve-anchored discount rate to your planning age, ${direction}. ` +
|
|
42
|
+
'The Accounts section shows the full sensitivity table (discount rate × longevity, survivor option value); ' +
|
|
43
|
+
'the decision hinges on longevity, survivor needs, and what you would earn on the rollover — tradeoffs, not a verdict.',
|
|
44
|
+
impact: {
|
|
45
|
+
qualitative: 'Preview taking the lump sum (tax-free rollover to a traditional IRA) against keeping the annuity, priced on the exact ledger.',
|
|
46
|
+
},
|
|
47
|
+
exact: false,
|
|
48
|
+
confidence: 'high',
|
|
49
|
+
learnSlug: 'pensions-and-annuities',
|
|
50
|
+
plannerRoute: 'accounts',
|
|
51
|
+
action: patch
|
|
52
|
+
? { kind: 'preview-scenario', scenarioName: `Take the ${pending.name} lump sum`, patch }
|
|
53
|
+
: { kind: 'advisory' },
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
evaluate(ctx) {
|
|
57
|
+
const card = this.screen(ctx);
|
|
58
|
+
if (!card)
|
|
59
|
+
throw new Error('Pension election not eligible');
|
|
60
|
+
return { action: card.action, impact: card.impact };
|
|
61
|
+
},
|
|
62
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export const qcdEfficiency = {
|
|
2
|
+
id: 'qcd-efficiency',
|
|
3
|
+
category: 'withdrawals-charitable',
|
|
4
|
+
screen(ctx) {
|
|
5
|
+
const charitable = ctx.plan.strategies.itemizedDeductions?.charitable ?? 0;
|
|
6
|
+
if (charitable <= 0) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
if (ctx.plan.strategies.qcdAnnual >= charitable) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
const firstYear = ctx.projection.result.years[0];
|
|
13
|
+
if (!firstYear) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
// Check if anyone is age-eligible (age attained 71+ in the start year)
|
|
17
|
+
const hasAgeEligible = firstYear.people.some((p) => p.alive && p.ageAttained >= 71);
|
|
18
|
+
if (!hasAgeEligible) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
// Check if they have traditional assets to donate from
|
|
22
|
+
const hasTrad = ctx.plan.accounts.some((a) => a.type === 'traditional' && (firstYear.balances[a.id] ?? 0) > 0);
|
|
23
|
+
if (!hasTrad) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const charitableStr = '$' + Math.round(charitable).toLocaleString();
|
|
27
|
+
return {
|
|
28
|
+
id: 'qcd-efficiency',
|
|
29
|
+
category: 'withdrawals-charitable',
|
|
30
|
+
title: 'Use QCDs for charitable giving',
|
|
31
|
+
rationale: `You are donating ${charitableStr} per year but not utilizing Qualified Charitable Distributions (QCDs). Since you are over 70½, routing donations directly from your pre-tax IRA is more tax-efficient.`,
|
|
32
|
+
impact: { qualitative: 'Satisfy RMDs and lower your taxable income by donating pre-tax IRA assets directly to charity.' },
|
|
33
|
+
exact: false,
|
|
34
|
+
confidence: 'high',
|
|
35
|
+
learnSlug: 'qcds-qualified-charitable-distributions',
|
|
36
|
+
plannerRoute: 'strategy',
|
|
37
|
+
action: {
|
|
38
|
+
kind: 'preview-scenario',
|
|
39
|
+
scenarioName: 'Use QCDs for donations',
|
|
40
|
+
patch: {
|
|
41
|
+
strategies: {
|
|
42
|
+
qcdAnnual: charitable,
|
|
43
|
+
itemizedDeductions: {
|
|
44
|
+
...ctx.plan.strategies.itemizedDeductions,
|
|
45
|
+
charitable: 0,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
evaluate(ctx) {
|
|
53
|
+
const card = this.screen(ctx);
|
|
54
|
+
if (!card) {
|
|
55
|
+
throw new Error('QCD efficiency not eligible');
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
action: card.action,
|
|
59
|
+
impact: card.impact,
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export const rothBridgeHeadroom = {
|
|
2
|
+
id: 'roth-bridge-headroom',
|
|
3
|
+
category: 'tax-brackets',
|
|
4
|
+
screen(ctx) {
|
|
5
|
+
if (ctx.plan.strategies.rothConversion.mode !== 'none') {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
// Check if the plan has owner-convertible traditional balance. Inherited
|
|
9
|
+
// traditional accounts follow beneficiary distribution rules and cannot be
|
|
10
|
+
// converted into the beneficiary's Roth IRA.
|
|
11
|
+
const tradBalance = ctx.plan.accounts
|
|
12
|
+
.filter((a) => a.type === 'traditional' && !a.inherited)
|
|
13
|
+
.reduce((sum, a) => sum + (ctx.projection.result.years[0]?.balances[a.id] ?? 0), 0);
|
|
14
|
+
if (tradBalance < 10000) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
// Find bridge years
|
|
18
|
+
let firstYear = null;
|
|
19
|
+
let lastYear = null;
|
|
20
|
+
for (const y of ctx.projection.result.years) {
|
|
21
|
+
const allRetired = y.incomes.wages < 10000;
|
|
22
|
+
const anyPreRmd = y.people.some((p) => p.alive && p.ageAttained < 73);
|
|
23
|
+
const hasTradFunds = ctx.plan.accounts
|
|
24
|
+
.filter((a) => a.type === 'traditional' && !a.inherited)
|
|
25
|
+
.reduce((sum, a) => sum + (y.balances[a.id] ?? 0), 0) > 10000;
|
|
26
|
+
if (allRetired && anyPreRmd && hasTradFunds) {
|
|
27
|
+
if (firstYear === null) {
|
|
28
|
+
firstYear = y.year;
|
|
29
|
+
}
|
|
30
|
+
lastYear = y.year;
|
|
31
|
+
}
|
|
32
|
+
else if (firstYear !== null) {
|
|
33
|
+
// Contiguous block complete
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (firstYear === null || lastYear === null) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
id: 'roth-bridge-headroom',
|
|
42
|
+
category: 'tax-brackets',
|
|
43
|
+
title: 'Test Roth conversion bridge years',
|
|
44
|
+
rationale: `You have low-income bridge years from ${firstYear} to ${lastYear} before RMDs begin. A bracket-fill Roth conversion scenario is worth previewing against your full projection before treating it as a recommendation.`,
|
|
45
|
+
impact: { qualitative: 'Preview a lower-bracket conversion scenario; taxes, healthcare cliffs, and liquidity can still make it unattractive.' },
|
|
46
|
+
exact: false,
|
|
47
|
+
confidence: 'medium',
|
|
48
|
+
learnSlug: 'roth-conversion-basics',
|
|
49
|
+
plannerRoute: 'strategy',
|
|
50
|
+
action: {
|
|
51
|
+
kind: 'preview-scenario',
|
|
52
|
+
scenarioName: 'Convert to top of 12% bracket',
|
|
53
|
+
patch: {
|
|
54
|
+
strategies: {
|
|
55
|
+
rothConversion: {
|
|
56
|
+
mode: 'fillToTarget',
|
|
57
|
+
target: 'topOfBracket',
|
|
58
|
+
targetValue: 12,
|
|
59
|
+
startYear: firstYear,
|
|
60
|
+
endYear: lastYear,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
evaluate(ctx) {
|
|
68
|
+
const card = this.screen(ctx);
|
|
69
|
+
if (!card) {
|
|
70
|
+
throw new Error('Roth bridge headroom not eligible');
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
action: card.action,
|
|
74
|
+
impact: card.impact,
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
};
|