@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 { probabilityBandSpendingGuardrailGenerator } from '../../decisions/generators.js';
|
|
2
|
+
function guardrailPatchFromGenerator(plan) {
|
|
3
|
+
const ctx = { plan };
|
|
4
|
+
const candidates = probabilityBandSpendingGuardrailGenerator().generate(ctx);
|
|
5
|
+
if (candidates.length === 0)
|
|
6
|
+
return null;
|
|
7
|
+
const candidate = candidates[0];
|
|
8
|
+
const patch = candidate.planPatch;
|
|
9
|
+
if (!patch || typeof patch !== 'object')
|
|
10
|
+
return null;
|
|
11
|
+
const expenses = patch.expenses;
|
|
12
|
+
if (!expenses || typeof expenses !== 'object')
|
|
13
|
+
return null;
|
|
14
|
+
const requiredAnnual = expenses.requiredAnnual;
|
|
15
|
+
if (typeof requiredAnnual !== 'number' || !Number.isFinite(requiredAnnual))
|
|
16
|
+
return null;
|
|
17
|
+
const spendingPolicy = expenses.spendingPolicy;
|
|
18
|
+
if (!spendingPolicy ||
|
|
19
|
+
typeof spendingPolicy !== 'object' ||
|
|
20
|
+
spendingPolicy.mode !== 'withdrawalRateGuardrails') {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return { requiredAnnual, patch: patch };
|
|
24
|
+
}
|
|
25
|
+
export const spendingGuardrails = {
|
|
26
|
+
id: 'spending-guardrails',
|
|
27
|
+
category: 'sequence-risk',
|
|
28
|
+
screen(ctx) {
|
|
29
|
+
const firstYear = ctx.projection.result.years[0];
|
|
30
|
+
if (!firstYear)
|
|
31
|
+
return null;
|
|
32
|
+
// Any active guardrail policy (withdrawal-rate or risk-based) means the
|
|
33
|
+
// plan already has dynamic spending rules — nothing to recommend.
|
|
34
|
+
const mode = ctx.plan.expenses.spendingPolicy?.mode;
|
|
35
|
+
if (mode !== undefined && mode !== 'fixedTarget')
|
|
36
|
+
return null;
|
|
37
|
+
const hasDepletion = ctx.projection.summary.depletionYear !== null;
|
|
38
|
+
const hasAssets = firstYear.investableTotal > 100_000;
|
|
39
|
+
if (!hasDepletion && !hasAssets)
|
|
40
|
+
return null;
|
|
41
|
+
const generated = guardrailPatchFromGenerator(ctx.plan);
|
|
42
|
+
if (!generated)
|
|
43
|
+
return null;
|
|
44
|
+
const { requiredAnnual, patch } = generated;
|
|
45
|
+
return {
|
|
46
|
+
id: 'spending-guardrails',
|
|
47
|
+
category: 'sequence-risk',
|
|
48
|
+
title: 'Use dynamic spending guardrails',
|
|
49
|
+
rationale: `Your plan currently assumes fixed inflation-adjusted spending. Preview a rules-based guardrail scenario with a $${Math.round(requiredAnnual).toLocaleString()} required floor and 10% spending adjustments when the withdrawal-rate band is crossed.`,
|
|
50
|
+
impact: {
|
|
51
|
+
qualitative: 'Preview to compare the projected and Monte Carlo impact of flexible spending rules.',
|
|
52
|
+
successRateDeltaPct: 12,
|
|
53
|
+
},
|
|
54
|
+
exact: false,
|
|
55
|
+
confidence: 'medium',
|
|
56
|
+
learnSlug: 'dynamic-spending-guardrails',
|
|
57
|
+
plannerRoute: 'spending',
|
|
58
|
+
action: {
|
|
59
|
+
kind: 'preview-scenario',
|
|
60
|
+
scenarioName: 'Dynamic spending guardrails',
|
|
61
|
+
patch,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
evaluate(ctx) {
|
|
66
|
+
const card = this.screen(ctx);
|
|
67
|
+
if (!card || card.action.kind !== 'preview-scenario')
|
|
68
|
+
throw new Error('Spending guardrails not eligible');
|
|
69
|
+
return {
|
|
70
|
+
action: card.action,
|
|
71
|
+
impact: {
|
|
72
|
+
qualitative: 'Exact preview applies the guardrail policy inside the same annual ledger used by Results and Monte Carlo.',
|
|
73
|
+
successRateDeltaPct: card.impact.successRateDeltaPct,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spending-headroom detector (sustainable-spending plan, Step 6).
|
|
3
|
+
*
|
|
4
|
+
* Two-phase per the detector contract: `screen()` is cheap and pure — it only
|
|
5
|
+
* reads the baseline projection (never depleting + a large ending estate above
|
|
6
|
+
* the bequest target ⇒ the plan is leaving lifestyle on the table) and offers
|
|
7
|
+
* a ROUGH estimate. `evaluate()` runs the exact-ledger sustainable-spending
|
|
8
|
+
* solver under the same fixed budget the "How much can I spend?" page uses,
|
|
9
|
+
* so the card and that surface report the same solved level.
|
|
10
|
+
*/
|
|
11
|
+
import type { Detector } from '../types.js';
|
|
12
|
+
export declare const spendingHeadroom: Detector;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spending-headroom detector (sustainable-spending plan, Step 6).
|
|
3
|
+
*
|
|
4
|
+
* Two-phase per the detector contract: `screen()` is cheap and pure — it only
|
|
5
|
+
* reads the baseline projection (never depleting + a large ending estate above
|
|
6
|
+
* the bequest target ⇒ the plan is leaving lifestyle on the table) and offers
|
|
7
|
+
* a ROUGH estimate. `evaluate()` runs the exact-ledger sustainable-spending
|
|
8
|
+
* solver under the same fixed budget the "How much can I spend?" page uses,
|
|
9
|
+
* so the card and that surface report the same solved level.
|
|
10
|
+
*/
|
|
11
|
+
import { createDecisionContext, solveMaxSustainableSpending, SPENDING_SOLVER_UI_BUDGET, } from '../../decisions/index.js';
|
|
12
|
+
import { combineTaxCalculators, createFederalTaxCalculator } from '../../tax/federalTax.js';
|
|
13
|
+
import { createStateTaxCalculator } from '../../tax/stateTax.js';
|
|
14
|
+
/** Screen only when the excess estate could fund a meaningful lifestyle bump. */
|
|
15
|
+
const MIN_EXCESS_ESTATE_TODAY_DOLLARS = 250_000;
|
|
16
|
+
const MIN_ROUGH_HEADROOM_PER_YEAR = 2_000;
|
|
17
|
+
export const spendingHeadroom = {
|
|
18
|
+
id: 'spending-headroom',
|
|
19
|
+
category: 'sequence-risk',
|
|
20
|
+
screen(ctx) {
|
|
21
|
+
const summary = ctx.projection.summary;
|
|
22
|
+
if (summary.depletionYear !== null)
|
|
23
|
+
return null;
|
|
24
|
+
// Amortized spending (ABW) already spends the portfolio down by design; a
|
|
25
|
+
// "raise your fixed baseline" headroom card (and the base-spending solver
|
|
26
|
+
// behind it) doesn't apply — the solver refuses ABW plans with a diagnostic.
|
|
27
|
+
if (ctx.plan.expenses.spendingPolicy?.mode === 'abw')
|
|
28
|
+
return null;
|
|
29
|
+
const endYear = ctx.projection.result.endYear;
|
|
30
|
+
const yearsRemaining = Math.max(1, endYear - ctx.projection.startYear);
|
|
31
|
+
const bequestTarget = ctx.plan.expenses.bequestTargetDollars ?? 0;
|
|
32
|
+
const endingEstateToday = ctx.projection.deflate(endYear, summary.endingAfterTaxEstate);
|
|
33
|
+
const excessEstateToday = endingEstateToday - bequestTarget;
|
|
34
|
+
if (excessEstateToday < MIN_EXCESS_ESTATE_TODAY_DOLLARS)
|
|
35
|
+
return null;
|
|
36
|
+
// Rough (pre-tax, straight-line) reading of the excess: what spreading it
|
|
37
|
+
// evenly over the remaining years would add to annual spending. The exact
|
|
38
|
+
// answer needs the solver — taxes, cliffs, and sequencing always shrink it.
|
|
39
|
+
const roughHeadroomPerYear = excessEstateToday / yearsRemaining;
|
|
40
|
+
if (roughHeadroomPerYear < MIN_ROUGH_HEADROOM_PER_YEAR)
|
|
41
|
+
return null;
|
|
42
|
+
const baseAnnual = ctx.plan.expenses.baseAnnual;
|
|
43
|
+
return {
|
|
44
|
+
id: 'spending-headroom',
|
|
45
|
+
category: 'sequence-risk',
|
|
46
|
+
title: 'Your plan may support more spending',
|
|
47
|
+
rationale: `Your plan is projected to end with roughly $${Math.round(endingEstateToday).toLocaleString()} of after-tax estate in today's dollars${bequestTarget > 0 ? ` — well above your $${Math.round(bequestTarget).toLocaleString()} bequest target` : ' with no bequest target set'}. Previewing the max-sustainable spending level shows how much lifestyle that margin could fund.`,
|
|
48
|
+
impact: {
|
|
49
|
+
qualitative: `≈ $${Math.round(roughHeadroomPerYear).toLocaleString()}/yr of rough headroom before taxes; preview for the precise answer.`,
|
|
50
|
+
},
|
|
51
|
+
exact: false,
|
|
52
|
+
confidence: 'medium',
|
|
53
|
+
learnSlug: 'building-a-retirement-spending-budget',
|
|
54
|
+
plannerRoute: 'spending-solver',
|
|
55
|
+
action: {
|
|
56
|
+
kind: 'preview-scenario',
|
|
57
|
+
scenarioName: 'Spend closer to plan capacity',
|
|
58
|
+
// Conservative placeholder — evaluate() replaces it with the solved level.
|
|
59
|
+
patch: { expenses: { baseAnnual: Math.round(baseAnnual + roughHeadroomPerYear / 2) } },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
evaluate(ctx) {
|
|
64
|
+
const card = this.screen(ctx);
|
|
65
|
+
if (!card)
|
|
66
|
+
throw new Error('Spending headroom not eligible');
|
|
67
|
+
const taxCalculator = combineTaxCalculators(createFederalTaxCalculator(), createStateTaxCalculator({
|
|
68
|
+
overridePct: ctx.plan.assumptions.stateEffectiveTaxPct,
|
|
69
|
+
localPct: ctx.plan.assumptions.localIncomeTaxPct,
|
|
70
|
+
}));
|
|
71
|
+
const decisionCtx = createDecisionContext(ctx.plan, { startYear: ctx.projection.startYear, taxCalculator }, { result: ctx.projection.result, summary: ctx.projection.summary });
|
|
72
|
+
const solved = solveMaxSustainableSpending(decisionCtx, {
|
|
73
|
+
maxSimulations: SPENDING_SOLVER_UI_BUDGET,
|
|
74
|
+
estateFloorTodayDollars: ctx.plan.expenses.bequestTargetDollars ?? 0,
|
|
75
|
+
});
|
|
76
|
+
const maxBaseAnnual = solved.maxBaseAnnual;
|
|
77
|
+
const slack = solved.spendingSlackDollars ?? 0;
|
|
78
|
+
if (maxBaseAnnual === null || slack < 1_000) {
|
|
79
|
+
throw new Error('The spending solver found no meaningful headroom once taxes, healthcare cliffs, and sequencing were priced in.');
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
action: {
|
|
83
|
+
kind: 'preview-scenario',
|
|
84
|
+
scenarioName: `Spend $${Math.round(maxBaseAnnual).toLocaleString()}/yr (max sustainable)`,
|
|
85
|
+
patch: { expenses: { baseAnnual: maxBaseAnnual } },
|
|
86
|
+
},
|
|
87
|
+
impact: {
|
|
88
|
+
qualitative: `Exact ledger sustains about $${Math.round(maxBaseAnnual).toLocaleString()}/yr of baseline spending — $${Math.round(slack).toLocaleString()}/yr above your current level (today's dollars).`,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Detector } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* "Your gap years are unfunded — consider a bridge"
|
|
4
|
+
* (social-security-bridge-and-tips-ladder, step 4). Fires when someone delays
|
|
5
|
+
* Social Security past retirement with no TIPS ladder covering the gap years,
|
|
6
|
+
* and liquid savings could fund one. The action previews the sized bridge as
|
|
7
|
+
* a scenario, so the ledger (not the card) prices it.
|
|
8
|
+
*/
|
|
9
|
+
export declare const ssBridgeGap: Detector;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { EMBEDDED_REAL_YIELD_CURVE } from '../../params/index.js';
|
|
2
|
+
import { BRIDGE_FUNDING_MIN_FRACTION, sizeBridge } from '../../ladder/bridge.js';
|
|
3
|
+
/**
|
|
4
|
+
* "Your gap years are unfunded — consider a bridge"
|
|
5
|
+
* (social-security-bridge-and-tips-ladder, step 4). Fires when someone delays
|
|
6
|
+
* Social Security past retirement with no TIPS ladder covering the gap years,
|
|
7
|
+
* and liquid savings could fund one. The action previews the sized bridge as
|
|
8
|
+
* a scenario, so the ledger (not the card) prices it.
|
|
9
|
+
*/
|
|
10
|
+
export const ssBridgeGap = {
|
|
11
|
+
id: 'ss-bridge-gap',
|
|
12
|
+
category: 'social-security',
|
|
13
|
+
screen(ctx) {
|
|
14
|
+
const plan = ctx.plan;
|
|
15
|
+
const startYear = ctx.projection.startYear;
|
|
16
|
+
const liquid = plan.accounts
|
|
17
|
+
.filter((a) => a.type === 'cash' || a.type === 'taxable')
|
|
18
|
+
.sort((a, b) => ('balance' in b ? b.balance : 0) - ('balance' in a ? a.balance : 0))[0];
|
|
19
|
+
if (!liquid || !('balance' in liquid))
|
|
20
|
+
return null;
|
|
21
|
+
const ladders = [];
|
|
22
|
+
let totalCost = 0;
|
|
23
|
+
let firstYear = Number.POSITIVE_INFINITY;
|
|
24
|
+
let lastYear = 0;
|
|
25
|
+
let annualTotal = 0;
|
|
26
|
+
for (const stream of plan.incomes) {
|
|
27
|
+
if (stream.type !== 'socialSecurity' || stream.piaMonthly === null || stream.piaMonthly <= 0)
|
|
28
|
+
continue;
|
|
29
|
+
const person = plan.household.people.find((p) => p.id === stream.personId);
|
|
30
|
+
if (!person)
|
|
31
|
+
continue;
|
|
32
|
+
const dobYear = Number(person.dob.slice(0, 4));
|
|
33
|
+
const sized = sizeBridge({
|
|
34
|
+
piaMonthly: stream.piaMonthly,
|
|
35
|
+
dob: { year: dobYear, month: Number(person.dob.slice(5, 7)), day: Number(person.dob.slice(8, 10)) },
|
|
36
|
+
claimAge: stream.claimAge,
|
|
37
|
+
currentYear: startYear,
|
|
38
|
+
retirementYear: person.retirementAge !== null ? dobYear + person.retirementAge : startYear,
|
|
39
|
+
curve: EMBEDDED_REAL_YIELD_CURVE,
|
|
40
|
+
});
|
|
41
|
+
if (!sized)
|
|
42
|
+
continue;
|
|
43
|
+
const covered = plan.incomeFloor?.ladders.some((l) => l.startYear <= sized.startYear && l.endYear >= sized.endYear);
|
|
44
|
+
if (covered)
|
|
45
|
+
continue;
|
|
46
|
+
totalCost += sized.ladderCost;
|
|
47
|
+
firstYear = Math.min(firstYear, sized.startYear);
|
|
48
|
+
lastYear = Math.max(lastYear, sized.endYear);
|
|
49
|
+
annualTotal += sized.annualRealAmount;
|
|
50
|
+
ladders.push({
|
|
51
|
+
id: `ss-bridge-preview-${stream.personId}`,
|
|
52
|
+
name: `SS bridge (${person.name})`,
|
|
53
|
+
purpose: 'bridge',
|
|
54
|
+
startYear: sized.startYear,
|
|
55
|
+
endYear: sized.endYear,
|
|
56
|
+
annualRealAmount: sized.annualRealAmount,
|
|
57
|
+
purchase: { year: startYear, fundingAccountId: liquid.id },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (ladders.length === 0)
|
|
61
|
+
return null;
|
|
62
|
+
// Don't suggest a bridge the plan clearly cannot buy (shared threshold).
|
|
63
|
+
if (liquid.balance < totalCost * BRIDGE_FUNDING_MIN_FRACTION)
|
|
64
|
+
return null;
|
|
65
|
+
return {
|
|
66
|
+
id: 'ss-bridge-gap',
|
|
67
|
+
category: 'social-security',
|
|
68
|
+
title: 'Fund your Social Security gap years with a bridge',
|
|
69
|
+
rationale: `You delay Social Security but retire earlier, leaving ${firstYear}–${lastYear} without your benefit. ` +
|
|
70
|
+
`A TIPS bridge ladder (≈$${Math.round(totalCost).toLocaleString()} today) pays you the forgone age-62 benefit ` +
|
|
71
|
+
`(~$${Math.round(annualTotal).toLocaleString()}/yr, inflation-protected) until your claim starts — the delayed claim's longevity insurance without the lifestyle gap.`,
|
|
72
|
+
impact: {
|
|
73
|
+
qualitative: 'Preview the bridge against your full plan: guaranteed gap-year income and steadier withdrawals, at the cost of locking up liquid savings.',
|
|
74
|
+
},
|
|
75
|
+
exact: false,
|
|
76
|
+
confidence: 'medium',
|
|
77
|
+
learnSlug: 'social-security-bridge',
|
|
78
|
+
plannerRoute: 'income-floor',
|
|
79
|
+
action: {
|
|
80
|
+
kind: 'preview-scenario',
|
|
81
|
+
scenarioName: 'Add a Social Security bridge ladder',
|
|
82
|
+
patch: { incomeFloor: { ladders: [...(plan.incomeFloor?.ladders ?? []), ...ladders] } },
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
evaluate(ctx) {
|
|
87
|
+
const card = this.screen(ctx);
|
|
88
|
+
if (!card)
|
|
89
|
+
throw new Error('SS bridge gap not eligible');
|
|
90
|
+
return { action: card.action, impact: card.impact };
|
|
91
|
+
},
|
|
92
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State-relocation detector (extended by the relocation-compare plan —
|
|
3
|
+
* DOCS/enhancements/state-relocation-compare.md, step 3).
|
|
4
|
+
*
|
|
5
|
+
* `screen()` keeps its original cheap conditions: a taxed state, no planned
|
|
6
|
+
* moves. `evaluate()` now quantifies the lifetime tax drag by running the
|
|
7
|
+
* relocation-compare sweep over the modeled zero-income-tax shortlist and
|
|
8
|
+
* previews the top candidate as a scenario. Copy stays neutral — income tax
|
|
9
|
+
* is one relocation factor, so the card says "worth a look", never "move".
|
|
10
|
+
*/
|
|
11
|
+
import type { Detector } from '../types.js';
|
|
12
|
+
export declare const stateRelocation: Detector;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State-relocation detector (extended by the relocation-compare plan —
|
|
3
|
+
* DOCS/enhancements/state-relocation-compare.md, step 3).
|
|
4
|
+
*
|
|
5
|
+
* `screen()` keeps its original cheap conditions: a taxed state, no planned
|
|
6
|
+
* moves. `evaluate()` now quantifies the lifetime tax drag by running the
|
|
7
|
+
* relocation-compare sweep over the modeled zero-income-tax shortlist and
|
|
8
|
+
* previews the top candidate as a scenario. Copy stays neutral — income tax
|
|
9
|
+
* is one relocation factor, so the card says "worth a look", never "move".
|
|
10
|
+
*/
|
|
11
|
+
import { stateParamsFor } from '../../params/state/index.js';
|
|
12
|
+
import { compareRelocationCandidates, relocationScenarioPatch, } from '../../projection/relocation.js';
|
|
13
|
+
/** Modeled zero-income-tax candidates the evaluate() sweep prices. */
|
|
14
|
+
const ZERO_TAX_SHORTLIST = ['FL', 'TX', 'WA'];
|
|
15
|
+
export const stateRelocation = {
|
|
16
|
+
id: 'state-relocation',
|
|
17
|
+
category: 'longevity-insurance-geography',
|
|
18
|
+
screen(ctx) {
|
|
19
|
+
const startYear = ctx.projection.startYear;
|
|
20
|
+
const currentState = ctx.plan.household.state;
|
|
21
|
+
// If already in a tax-free state and no flat override, it's not applicable
|
|
22
|
+
const params = stateParamsFor(currentState, startYear);
|
|
23
|
+
const currentHasIncomeTax = ctx.plan.assumptions.stateEffectiveTaxPct > 0 || (params ? params.hasIncomeTax : true);
|
|
24
|
+
if (!currentHasIncomeTax) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (currentState === 'FL') {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
// Only suggest if they haven't planned any moves yet
|
|
31
|
+
if (ctx.plan.household.stateMoves && ctx.plan.household.stateMoves.length > 0) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
id: 'state-relocation',
|
|
36
|
+
category: 'longevity-insurance-geography',
|
|
37
|
+
title: 'State residence is a tax lever worth a look',
|
|
38
|
+
rationale: `Your plan is based in ${currentState}, which levies an income tax, with no relocation modeled. If a move is ever on the table for other reasons, the state-tax difference is worth quantifying — the Relocation Compare page prices candidate states on your actual plan. Income tax is one factor among many; nothing here says you should move.`,
|
|
39
|
+
impact: { qualitative: 'Preview to quantify the lifetime state-tax drag vs modeled zero-income-tax states on your own plan.' },
|
|
40
|
+
exact: false,
|
|
41
|
+
confidence: 'medium',
|
|
42
|
+
learnSlug: 'state-income-taxes-in-retirement',
|
|
43
|
+
plannerRoute: 'relocation',
|
|
44
|
+
action: {
|
|
45
|
+
kind: 'preview-scenario',
|
|
46
|
+
scenarioName: 'Relocate to a tax-free state',
|
|
47
|
+
// Rough placeholder — evaluate() replaces it with the sweep's top candidate.
|
|
48
|
+
patch: relocationScenarioPatch(ctx.plan, { state: 'FL', moveYear: startYear }, startYear),
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
evaluate(ctx) {
|
|
53
|
+
const card = this.screen(ctx);
|
|
54
|
+
if (!card) {
|
|
55
|
+
throw new Error('State relocation not eligible');
|
|
56
|
+
}
|
|
57
|
+
const startYear = ctx.projection.startYear;
|
|
58
|
+
try {
|
|
59
|
+
// Deterministic sweep (no Monte Carlo) over the zero-income-tax
|
|
60
|
+
// shortlist, each as a split-year move this July — the same engine the
|
|
61
|
+
// Relocation Compare page runs, so the card and the page can never
|
|
62
|
+
// disagree. Four ledger runs, on par with other detectors' evaluate().
|
|
63
|
+
const comparison = compareRelocationCandidates(ctx.plan, ZERO_TAX_SHORTLIST.map((state) => ({ state, moveYear: startYear })), { startYear });
|
|
64
|
+
const baseline = comparison.rows.find((r) => r.id === 'baseline');
|
|
65
|
+
const candidates = comparison.rows.filter((r) => r.id !== 'baseline' && r.error === null);
|
|
66
|
+
if (!baseline || candidates.length === 0)
|
|
67
|
+
throw new Error('Relocation sweep produced no valid candidates');
|
|
68
|
+
const best = candidates.reduce((a, b) => (b.lifetimeTaxesAndPenalties < a.lifetimeTaxesAndPenalties ? b : a));
|
|
69
|
+
// Lifetime state-tax drag in today's dollars: deflate the per-year
|
|
70
|
+
// state+local tax delta between staying and the best candidate. This is
|
|
71
|
+
// deliberately qualitative-only — the preview grid's numeric deltas come
|
|
72
|
+
// from the shared exact-ledger evaluator on a different basis (total
|
|
73
|
+
// taxes, nominal), and two numbers on two bases would collide.
|
|
74
|
+
const baseByYear = new Map(baseline.stateTaxByYear.map((l) => [l.year, l.tax]));
|
|
75
|
+
const bestByYear = new Map(best.stateTaxByYear.map((l) => [l.year, l.tax]));
|
|
76
|
+
const years = new Set([...baseByYear.keys(), ...bestByYear.keys()]);
|
|
77
|
+
let lifetimeStateTaxDeltaToday = 0;
|
|
78
|
+
for (const year of years) {
|
|
79
|
+
lifetimeStateTaxDeltaToday += ctx.projection.deflate(year, (bestByYear.get(year) ?? 0) - (baseByYear.get(year) ?? 0));
|
|
80
|
+
}
|
|
81
|
+
const savings = Math.max(0, -lifetimeStateTaxDeltaToday);
|
|
82
|
+
return {
|
|
83
|
+
action: {
|
|
84
|
+
kind: 'preview-scenario',
|
|
85
|
+
scenarioName: `Relocate to ${best.destinationState} (illustrative)`,
|
|
86
|
+
patch: relocationScenarioPatch(ctx.plan, { state: best.destinationState, moveYear: startYear }, startYear),
|
|
87
|
+
},
|
|
88
|
+
impact: {
|
|
89
|
+
qualitative: `Exact ledger: staying in ${ctx.plan.household.state} costs about $${Math.round(savings).toLocaleString()} of lifetime state+local income tax (today's dollars) vs ${best.destinationState}, the best of ${ZERO_TAX_SHORTLIST.join('/')} on your plan. Income tax is one relocation factor — compare your own shortlist on the Relocation Compare page.`,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Degrade to the rough screen-level card instead of erroring the
|
|
95
|
+
// preview; the Relocation Compare page remains the exact surface.
|
|
96
|
+
return { action: card.action, impact: card.impact };
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Detector } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The widow's-penalty lever (extended by survivor-widowhood-and-irmaa-relief,
|
|
4
|
+
* step 3): screens trad-heavy MFJ couples without conversions — the original
|
|
5
|
+
* gate is unchanged — and now quantifies the survivor bracket jump on the
|
|
6
|
+
* plan's own first single-FILED survivor year (same MAGI priced single vs
|
|
7
|
+
* joint, a cheap pack calculation, exact:false; QSS interlude years keep the
|
|
8
|
+
* joint tables and are narrated, not priced), flags survivor-year IRMAA tiers
|
|
9
|
+
* with an SSA-44 pointer when the plan isn't modeling the relief, and previews
|
|
10
|
+
* the conversion-acceleration scenario the caller prices on the exact ledger.
|
|
11
|
+
*/
|
|
12
|
+
export declare const widowsPenalty: Detector;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { computeFederalTax } from '../../tax/federalTax.js';
|
|
2
|
+
/**
|
|
3
|
+
* The widow's-penalty lever (extended by survivor-widowhood-and-irmaa-relief,
|
|
4
|
+
* step 3): screens trad-heavy MFJ couples without conversions — the original
|
|
5
|
+
* gate is unchanged — and now quantifies the survivor bracket jump on the
|
|
6
|
+
* plan's own first single-FILED survivor year (same MAGI priced single vs
|
|
7
|
+
* joint, a cheap pack calculation, exact:false; QSS interlude years keep the
|
|
8
|
+
* joint tables and are narrated, not priced), flags survivor-year IRMAA tiers
|
|
9
|
+
* with an SSA-44 pointer when the plan isn't modeling the relief, and previews
|
|
10
|
+
* the conversion-acceleration scenario the caller prices on the exact ledger.
|
|
11
|
+
*/
|
|
12
|
+
export const widowsPenalty = {
|
|
13
|
+
id: 'widows-penalty-roth',
|
|
14
|
+
category: 'social-security',
|
|
15
|
+
screen(ctx) {
|
|
16
|
+
if (ctx.plan.household.filingStatus !== 'marriedFilingJointly') {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
if (ctx.plan.strategies.rothConversion.mode !== 'none') {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
// Check if there are traditional assets
|
|
23
|
+
const firstYear = ctx.projection.result.years[0];
|
|
24
|
+
if (!firstYear) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const tradBalance = ctx.plan.accounts
|
|
28
|
+
.filter((a) => a.type === 'traditional')
|
|
29
|
+
.reduce((sum, a) => sum + (firstYear.balances[a.id] ?? 0), 0);
|
|
30
|
+
if (tradBalance < 50000) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
// Find the first year where only one spouse remains alive
|
|
34
|
+
const singleYearObj = ctx.projection.result.years.find((y) => {
|
|
35
|
+
const living = y.people.filter((p) => p.alive);
|
|
36
|
+
return living.length === 1;
|
|
37
|
+
});
|
|
38
|
+
if (!singleYearObj) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const firstSingleYear = singleYearObj.year;
|
|
42
|
+
const lastJointYear = firstSingleYear - 1;
|
|
43
|
+
if (lastJointYear < ctx.projection.startYear) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
// Survivor bracket jump, quantified on the plan's own numbers: the first
|
|
47
|
+
// survivor year that truly files SINGLE (a qualifying surviving spouse
|
|
48
|
+
// keeps the joint tables for up to two years, so QSS years have no bracket
|
|
49
|
+
// jump to price), with that year's MAGI compared single vs joint. Rough by
|
|
50
|
+
// design (taxable SS itself shifts with filing status), so the card stays
|
|
51
|
+
// exact:false; the preview scenario is what the exact ledger prices.
|
|
52
|
+
const singleFiledYearObj = ctx.projection.result.years.find((y) => y.year >= firstSingleYear && y.filingStatus === 'single');
|
|
53
|
+
let bracketJumpToday = 0;
|
|
54
|
+
if (singleFiledYearObj) {
|
|
55
|
+
const jumpYear = singleFiledYearObj.year;
|
|
56
|
+
const survivorMagi = singleFiledYearObj.magi;
|
|
57
|
+
const jointAges65Plus = ctx.plan.household.people.filter((p) => jumpYear - Number(p.dob.slice(0, 4)) >= 65).length;
|
|
58
|
+
const bracketJump = Math.max(0, computeFederalTax({
|
|
59
|
+
year: jumpYear,
|
|
60
|
+
filingStatus: 'single',
|
|
61
|
+
ordinaryIncome: survivorMagi,
|
|
62
|
+
capitalGains: 0,
|
|
63
|
+
ssBenefits: 0,
|
|
64
|
+
peopleAged65Plus: singleFiledYearObj.people.filter((p) => p.alive && p.ageAttained >= 65).length,
|
|
65
|
+
}).totalTax -
|
|
66
|
+
computeFederalTax({
|
|
67
|
+
year: jumpYear,
|
|
68
|
+
filingStatus: 'marriedFilingJointly',
|
|
69
|
+
ordinaryIncome: survivorMagi,
|
|
70
|
+
capitalGains: 0,
|
|
71
|
+
ssBenefits: 0,
|
|
72
|
+
peopleAged65Plus: jointAges65Plus,
|
|
73
|
+
}).totalTax);
|
|
74
|
+
bracketJumpToday = Math.round(ctx.projection.deflate(jumpYear, bracketJump));
|
|
75
|
+
}
|
|
76
|
+
// SSA-44 awareness: the two years after the death are redeterminable when
|
|
77
|
+
// their premiums land in an IRMAA tier the plan isn't already relieving
|
|
78
|
+
// (QSS years already price IRMAA on the single table, so a flagged tier is
|
|
79
|
+
// real regardless of the income-tax filing status).
|
|
80
|
+
const ssa44Window = ctx.projection.result.years.filter((y) => y.year >= firstSingleYear && y.year <= firstSingleYear + 1 && y.irmaaTier > 0);
|
|
81
|
+
const modelsSsa44 = ctx.plan.expenses.healthcare.ssa44?.survivorYears === true;
|
|
82
|
+
const ssa44Note = ssa44Window.length > 0 && !modelsSsa44
|
|
83
|
+
? ` The survivor's Medicare premiums also land in an IRMAA surcharge tier in ${ssa44Window
|
|
84
|
+
.map((y) => y.year)
|
|
85
|
+
.join(' and ')}: Form SSA-44 can re-price those years on the survivor's own income (model it under Spending → Healthcare).`
|
|
86
|
+
: '';
|
|
87
|
+
// The death is projected in the last joint year (the death year still
|
|
88
|
+
// files MFJ); the survivor's own filing starts the year after — as
|
|
89
|
+
// qualifying surviving spouse first when the plan opts into it.
|
|
90
|
+
const isQss = singleYearObj.filingStatus === 'qualifyingSurvivingSpouse';
|
|
91
|
+
const filingStory = isQss
|
|
92
|
+
? singleFiledYearObj
|
|
93
|
+
? `the survivor files as qualifying surviving spouse (joint tables) through ${singleFiledYearObj.year - 1}, then as Single from ${singleFiledYearObj.year}`
|
|
94
|
+
: `the survivor files as qualifying surviving spouse (joint tables) for up to two years, then as Single`
|
|
95
|
+
: `the survivor files as Single from ${firstSingleYear}`;
|
|
96
|
+
const rationale = `When one spouse passes away (projected in ${lastJointYear}), ${filingStory}. ` +
|
|
97
|
+
(bracketJumpToday > 100
|
|
98
|
+
? `On the survivor's projected income in ${singleFiledYearObj.year}, single brackets and the smaller deduction cost about $${bracketJumpToday.toLocaleString('en-US')} more (today's $) than the same income filed jointly. `
|
|
99
|
+
: `Single filing cuts tax bracket ceilings in half, raising their tax rate. `) +
|
|
100
|
+
`Converting traditional assets to Roth before ${firstSingleYear} utilizes joint brackets.` +
|
|
101
|
+
ssa44Note;
|
|
102
|
+
const card = {
|
|
103
|
+
id: 'widows-penalty-roth',
|
|
104
|
+
category: 'social-security',
|
|
105
|
+
title: "Avoid the 'widow's penalty' tax cliff",
|
|
106
|
+
rationale,
|
|
107
|
+
impact: {
|
|
108
|
+
// The bracket jump is a size-of-problem number, not this action's
|
|
109
|
+
// delta, so it lives in the rationale; the preview scenario supplies
|
|
110
|
+
// the exact deltas when evaluated.
|
|
111
|
+
qualitative: 'Proactively convert assets while filing Married Jointly to protect the survivor from higher tax brackets.',
|
|
112
|
+
},
|
|
113
|
+
exact: false,
|
|
114
|
+
confidence: 'high',
|
|
115
|
+
learnSlug: 'widows-penalty-and-survivor-brackets',
|
|
116
|
+
plannerRoute: 'strategy',
|
|
117
|
+
action: {
|
|
118
|
+
kind: 'preview-scenario',
|
|
119
|
+
scenarioName: 'Pre-emptive conversions (MFJ)',
|
|
120
|
+
patch: {
|
|
121
|
+
strategies: {
|
|
122
|
+
rothConversion: {
|
|
123
|
+
mode: 'fillToTarget',
|
|
124
|
+
target: 'topOfBracket',
|
|
125
|
+
targetValue: 12,
|
|
126
|
+
startYear: ctx.projection.startYear,
|
|
127
|
+
endYear: lastJointYear,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
return card;
|
|
134
|
+
},
|
|
135
|
+
evaluate(ctx) {
|
|
136
|
+
const card = this.screen(ctx);
|
|
137
|
+
if (!card) {
|
|
138
|
+
throw new Error('Widows penalty not eligible');
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
action: card.action,
|
|
142
|
+
impact: card.impact,
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { annuitizationHeadroom } from './detectors/annuitizationHeadroom.js';
|
|
2
|
+
import { assetLocation } from './detectors/assetLocation.js';
|
|
3
|
+
import { hecmBufferCandidate } from './detectors/hecmBufferCandidate.js';
|
|
4
|
+
import { incomeFloorFunded } from './detectors/incomeFloorFunded.js';
|
|
5
|
+
import { irmaaTierEdge } from './detectors/irmaaTierEdge.js';
|
|
6
|
+
import { pensionElectionPending } from './detectors/pensionElectionPending.js';
|
|
7
|
+
import { qcdEfficiency } from './detectors/qcdEfficiency.js';
|
|
8
|
+
import { rothBridgeHeadroom } from './detectors/rothBridgeHeadroom.js';
|
|
9
|
+
import { spendingGuardrails } from './detectors/spendingGuardrails.js';
|
|
10
|
+
import { spendingHeadroom } from './detectors/spendingHeadroom.js';
|
|
11
|
+
import { ssBridgeGap } from './detectors/ssBridgeGap.js';
|
|
12
|
+
import { stateRelocation } from './detectors/stateRelocation.js';
|
|
13
|
+
import { widowsPenalty } from './detectors/widowsPenalty.js';
|
|
14
|
+
export const registry = [
|
|
15
|
+
annuitizationHeadroom,
|
|
16
|
+
assetLocation,
|
|
17
|
+
hecmBufferCandidate,
|
|
18
|
+
incomeFloorFunded,
|
|
19
|
+
irmaaTierEdge,
|
|
20
|
+
pensionElectionPending,
|
|
21
|
+
qcdEfficiency,
|
|
22
|
+
rothBridgeHeadroom,
|
|
23
|
+
spendingGuardrails,
|
|
24
|
+
spendingHeadroom,
|
|
25
|
+
ssBridgeGap,
|
|
26
|
+
stateRelocation,
|
|
27
|
+
widowsPenalty,
|
|
28
|
+
];
|
|
29
|
+
export function computeCardScore(card) {
|
|
30
|
+
const hasQuantified = card.impact.endingAfterTaxEstateDelta !== undefined ||
|
|
31
|
+
card.impact.successRateDeltaPct !== undefined ||
|
|
32
|
+
card.impact.lifetimeTaxDelta !== undefined;
|
|
33
|
+
if (!hasQuantified) {
|
|
34
|
+
return -1;
|
|
35
|
+
}
|
|
36
|
+
let metricValue = 0;
|
|
37
|
+
if (card.impact.endingAfterTaxEstateDelta !== undefined) {
|
|
38
|
+
metricValue = Math.abs(card.impact.endingAfterTaxEstateDelta);
|
|
39
|
+
}
|
|
40
|
+
else if (card.impact.successRateDeltaPct !== undefined) {
|
|
41
|
+
// Heuristic: scale 1% Monte Carlo success rate to $10,000
|
|
42
|
+
metricValue = Math.abs(card.impact.successRateDeltaPct) * 10000;
|
|
43
|
+
}
|
|
44
|
+
else if (card.impact.lifetimeTaxDelta !== undefined) {
|
|
45
|
+
metricValue = Math.abs(card.impact.lifetimeTaxDelta);
|
|
46
|
+
}
|
|
47
|
+
const confidenceWeight = { high: 1.0, medium: 0.7, low: 0.4 }[card.confidence];
|
|
48
|
+
return metricValue * confidenceWeight;
|
|
49
|
+
}
|
|
50
|
+
export function sortCards(cards) {
|
|
51
|
+
return [...cards].sort((a, b) => {
|
|
52
|
+
const scoreA = computeCardScore(a);
|
|
53
|
+
const scoreB = computeCardScore(b);
|
|
54
|
+
if (scoreA !== scoreB) {
|
|
55
|
+
return scoreB - scoreA; // descending
|
|
56
|
+
}
|
|
57
|
+
const catComp = a.category.localeCompare(b.category);
|
|
58
|
+
if (catComp !== 0)
|
|
59
|
+
return catComp;
|
|
60
|
+
return a.title.localeCompare(b.title);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Detector, DetectorContext, InsightCard } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Runs the synchronous screening pass of all registered detectors.
|
|
4
|
+
* Filters out nulls and ranks the resulting cards.
|
|
5
|
+
*/
|
|
6
|
+
export declare function runScreen(ctx: DetectorContext, detectors?: Detector[]): InsightCard[];
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { registry, sortCards } from './registry.js';
|
|
2
|
+
/**
|
|
3
|
+
* Runs the synchronous screening pass of all registered detectors.
|
|
4
|
+
* Filters out nulls and ranks the resulting cards.
|
|
5
|
+
*/
|
|
6
|
+
export function runScreen(ctx, detectors = registry) {
|
|
7
|
+
const cards = [];
|
|
8
|
+
for (const detector of detectors) {
|
|
9
|
+
const card = detector.screen(ctx);
|
|
10
|
+
if (card) {
|
|
11
|
+
cards.push(card);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return sortCards(cards);
|
|
15
|
+
}
|