@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,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named fixture plans for the ledger-native decision engine test suite
|
|
3
|
+
* (enhancement doc "Testing Strategy"). Test-only helper — not exported from
|
|
4
|
+
* the module index, so it never reaches the app bundle.
|
|
5
|
+
*
|
|
6
|
+
* Fixtures deliberately use flat inflation/returns so tests can reason about
|
|
7
|
+
* bracket, taxability, and basis effects without market noise.
|
|
8
|
+
*/
|
|
9
|
+
import { type Plan } from '../model/plan.js';
|
|
10
|
+
import type { SimulateOptions } from '../projection/simulate.js';
|
|
11
|
+
export declare const testIds: () => string;
|
|
12
|
+
export declare const fixedNow: () => Date;
|
|
13
|
+
export declare function validate(plan: Plan): Plan;
|
|
14
|
+
export declare function simOptions(): SimulateOptions;
|
|
15
|
+
/** Mostly traditional with a cash/taxable bridge: ripe for bracket-fill conversions. */
|
|
16
|
+
export declare function tradHeavyPlan(): Plan;
|
|
17
|
+
/** No traditional at all — every conversion candidate should execute $0 and stay non-beneficial. */
|
|
18
|
+
export declare function noTraditionalPlan(): Plan;
|
|
19
|
+
/** Inherited-only traditional: distributable under the 10-year rule but never convertible. */
|
|
20
|
+
export declare function inheritedOnlyPlan(): Plan;
|
|
21
|
+
/** Own plus inherited traditional: only the own balance is convertible. */
|
|
22
|
+
export declare function mixedTraditionalPlan(): Plan;
|
|
23
|
+
/** Late-career accumulator with scheduled contributions and an employer match. */
|
|
24
|
+
export declare function accumulatorPlan(): Plan;
|
|
25
|
+
/**
|
|
26
|
+
* Early retiree bridging on a taxable brokerage: spending is funded by selling
|
|
27
|
+
* shares, so candidate evaluation must price the realized gains implied by the
|
|
28
|
+
* account's basis ratio.
|
|
29
|
+
*/
|
|
30
|
+
export declare function taxableBridgePlan(basis: 'high' | 'low'): Plan;
|
|
31
|
+
/**
|
|
32
|
+
* Social Security taxability edge: SS starts mid-horizon (claim at 70), so an
|
|
33
|
+
* identical conversion is priced differently before vs during benefit years —
|
|
34
|
+
* the provisional-income feedback only the exact ledger knows.
|
|
35
|
+
*/
|
|
36
|
+
export declare function ssTaxabilityPlan(): Plan;
|
|
37
|
+
/** Lumpy one-time ordinary income in 2028 that stacks under any conversion that year. */
|
|
38
|
+
export declare function oneTimeIncomePlan(): Plan;
|
|
39
|
+
/**
|
|
40
|
+
* Asset-location fixture (asset-allocation-and-return-model-v2, step 5): a
|
|
41
|
+
* bond-heavy allocated brokerage next to a stock-heavy-capable traditional and
|
|
42
|
+
* Roth, where *where* classes are held changes the after-tax estate — bonds
|
|
43
|
+
* throw ordinary interest in taxable and inflate the heir-taxed traditional
|
|
44
|
+
* balance when they grow there instead.
|
|
45
|
+
*/
|
|
46
|
+
export declare function assetLocationPlan(): Plan;
|
|
47
|
+
/** Married couple where the second spouse's earlier planning age creates survivor years. */
|
|
48
|
+
export declare function survivorPlan(): Plan;
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named fixture plans for the ledger-native decision engine test suite
|
|
3
|
+
* (enhancement doc "Testing Strategy"). Test-only helper — not exported from
|
|
4
|
+
* the module index, so it never reaches the app bundle.
|
|
5
|
+
*
|
|
6
|
+
* Fixtures deliberately use flat inflation/returns so tests can reason about
|
|
7
|
+
* bracket, taxability, and basis effects without market noise.
|
|
8
|
+
*/
|
|
9
|
+
import { createEmptyPlan, parsePlan } from '../model/plan.js';
|
|
10
|
+
import { createFederalTaxCalculator } from '../tax/federalTax.js';
|
|
11
|
+
let counter = 0;
|
|
12
|
+
export const testIds = () => `dec-${++counter}`;
|
|
13
|
+
export const fixedNow = () => new Date('2026-06-11T00:00:00.000Z');
|
|
14
|
+
export function validate(plan) {
|
|
15
|
+
const parsed = parsePlan(plan);
|
|
16
|
+
if (!parsed.ok)
|
|
17
|
+
throw new Error(parsed.issues.join('; '));
|
|
18
|
+
return parsed.plan;
|
|
19
|
+
}
|
|
20
|
+
export function simOptions() {
|
|
21
|
+
return { startYear: 2026, taxCalculator: createFederalTaxCalculator() };
|
|
22
|
+
}
|
|
23
|
+
function base(personOverrides = {}) {
|
|
24
|
+
const plan = createEmptyPlan({ newId: testIds, now: fixedNow });
|
|
25
|
+
plan.household.people[0] = {
|
|
26
|
+
id: 'p1',
|
|
27
|
+
name: 'Pat',
|
|
28
|
+
dob: '1958-06-15', // 68 in 2026; RMDs from 73
|
|
29
|
+
sex: 'average',
|
|
30
|
+
retirementAge: 65,
|
|
31
|
+
longevity: { planningAge: 85, source: 'manual' },
|
|
32
|
+
...personOverrides,
|
|
33
|
+
};
|
|
34
|
+
plan.assumptions.inflationPct = 0;
|
|
35
|
+
plan.assumptions.healthcareExtraInflationPct = 0;
|
|
36
|
+
plan.assumptions.defaultReturnPct = 0;
|
|
37
|
+
plan.assumptions.stateEffectiveTaxPct = 0;
|
|
38
|
+
plan.assumptions.heirTaxRatePct = 25;
|
|
39
|
+
plan.expenses.baseAnnual = 0;
|
|
40
|
+
plan.expenses.healthcare = { pre65MonthlyPremiumPerPerson: 0, applyAcaCredit: false, medicareExtrasMonthlyPerPerson: 0 };
|
|
41
|
+
return plan;
|
|
42
|
+
}
|
|
43
|
+
/** Mostly traditional with a cash/taxable bridge: ripe for bracket-fill conversions. */
|
|
44
|
+
export function tradHeavyPlan() {
|
|
45
|
+
const plan = base();
|
|
46
|
+
plan.assumptions.defaultReturnPct = 4;
|
|
47
|
+
plan.expenses.baseAnnual = 45_000;
|
|
48
|
+
plan.accounts = [
|
|
49
|
+
{ type: 'traditional', id: testIds(), name: '401k', ownerPersonId: 'p1', annualReturnPct: null, kind: 'ira', balance: 800_000, annualContribution: 0 },
|
|
50
|
+
{ type: 'roth', id: testIds(), name: 'Roth', ownerPersonId: 'p1', annualReturnPct: null, kind: 'ira', balance: 0, annualContribution: 0 },
|
|
51
|
+
{ type: 'taxable', id: testIds(), name: 'Brokerage', ownerPersonId: null, annualReturnPct: null, balance: 150_000, costBasis: 150_000, annualContribution: 0 },
|
|
52
|
+
{ type: 'cash', id: testIds(), name: 'Cash', ownerPersonId: null, annualReturnPct: null, balance: 60_000, annualContribution: 0 },
|
|
53
|
+
];
|
|
54
|
+
return validate(plan);
|
|
55
|
+
}
|
|
56
|
+
/** No traditional at all — every conversion candidate should execute $0 and stay non-beneficial. */
|
|
57
|
+
export function noTraditionalPlan() {
|
|
58
|
+
const plan = base();
|
|
59
|
+
plan.expenses.baseAnnual = 30_000;
|
|
60
|
+
plan.accounts = [
|
|
61
|
+
{ type: 'roth', id: testIds(), name: 'Roth', ownerPersonId: 'p1', annualReturnPct: 0, kind: 'ira', balance: 250_000, annualContribution: 0 },
|
|
62
|
+
{ type: 'taxable', id: testIds(), name: 'Brokerage', ownerPersonId: null, annualReturnPct: 0, balance: 400_000, costBasis: 400_000, annualContribution: 0 },
|
|
63
|
+
{ type: 'cash', id: testIds(), name: 'Cash', ownerPersonId: null, annualReturnPct: 0, balance: 50_000, annualContribution: 0 },
|
|
64
|
+
];
|
|
65
|
+
return validate(plan);
|
|
66
|
+
}
|
|
67
|
+
/** Inherited-only traditional: distributable under the 10-year rule but never convertible. */
|
|
68
|
+
export function inheritedOnlyPlan() {
|
|
69
|
+
const plan = base({ dob: '1960-01-01', longevity: { planningAge: 70, source: 'manual' } });
|
|
70
|
+
plan.accounts = [
|
|
71
|
+
{
|
|
72
|
+
type: 'traditional',
|
|
73
|
+
id: testIds(),
|
|
74
|
+
name: 'Inherited IRA',
|
|
75
|
+
ownerPersonId: 'p1',
|
|
76
|
+
annualReturnPct: 0,
|
|
77
|
+
kind: 'ira',
|
|
78
|
+
balance: 300_000,
|
|
79
|
+
annualContribution: 0,
|
|
80
|
+
inherited: { ownerDeathYear: 2024, decedentHadStartedRmds: false },
|
|
81
|
+
},
|
|
82
|
+
{ type: 'roth', id: testIds(), name: 'Roth', ownerPersonId: 'p1', annualReturnPct: 0, kind: 'ira', balance: 0, annualContribution: 0 },
|
|
83
|
+
{ type: 'cash', id: testIds(), name: 'Cash', ownerPersonId: null, annualReturnPct: 0, balance: 40_000, annualContribution: 0 },
|
|
84
|
+
];
|
|
85
|
+
return validate(plan);
|
|
86
|
+
}
|
|
87
|
+
/** Own plus inherited traditional: only the own balance is convertible. */
|
|
88
|
+
export function mixedTraditionalPlan() {
|
|
89
|
+
const plan = inheritedOnlyPlan();
|
|
90
|
+
return validate({
|
|
91
|
+
...plan,
|
|
92
|
+
accounts: [
|
|
93
|
+
{ type: 'traditional', id: testIds(), name: 'Own IRA', ownerPersonId: 'p1', annualReturnPct: 0, kind: 'ira', balance: 200_000, annualContribution: 0 },
|
|
94
|
+
...plan.accounts,
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/** Late-career accumulator with scheduled contributions and an employer match. */
|
|
99
|
+
export function accumulatorPlan() {
|
|
100
|
+
const plan = base({ dob: '1971-01-01', retirementAge: 62, longevity: { planningAge: 80, source: 'manual' } });
|
|
101
|
+
plan.expenses.baseAnnual = 50_000;
|
|
102
|
+
plan.accounts = [
|
|
103
|
+
{
|
|
104
|
+
type: 'traditional',
|
|
105
|
+
id: testIds(),
|
|
106
|
+
name: '401k',
|
|
107
|
+
ownerPersonId: 'p1',
|
|
108
|
+
annualReturnPct: 0,
|
|
109
|
+
kind: 'employer',
|
|
110
|
+
balance: 100_000,
|
|
111
|
+
annualContribution: 20_000,
|
|
112
|
+
employerMatch: { matchPct: 50, capPctOfPay: 6 },
|
|
113
|
+
},
|
|
114
|
+
{ type: 'roth', id: testIds(), name: 'Roth', ownerPersonId: 'p1', annualReturnPct: 0, kind: 'ira', balance: 0, annualContribution: 0 },
|
|
115
|
+
{ type: 'cash', id: testIds(), name: 'Cash', ownerPersonId: null, annualReturnPct: 0, balance: 50_000, annualContribution: 0 },
|
|
116
|
+
];
|
|
117
|
+
plan.incomes = [
|
|
118
|
+
{ type: 'wages', id: testIds(), personId: 'p1', annualGross: 150_000, endAge: null, realGrowthPct: 0 },
|
|
119
|
+
{ type: 'socialSecurity', id: testIds(), personId: 'p1', piaMonthly: 2_500, earnings: null, claimAge: { years: 67, months: 0 } },
|
|
120
|
+
];
|
|
121
|
+
return validate(plan);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Early retiree bridging on a taxable brokerage: spending is funded by selling
|
|
125
|
+
* shares, so candidate evaluation must price the realized gains implied by the
|
|
126
|
+
* account's basis ratio.
|
|
127
|
+
*/
|
|
128
|
+
export function taxableBridgePlan(basis) {
|
|
129
|
+
const plan = base({ dob: '1966-01-01', retirementAge: 60, longevity: { planningAge: 80, source: 'manual' } });
|
|
130
|
+
plan.expenses.baseAnnual = 60_000;
|
|
131
|
+
plan.accounts = [
|
|
132
|
+
{ type: 'traditional', id: testIds(), name: 'IRA', ownerPersonId: 'p1', annualReturnPct: 0, kind: 'ira', balance: 400_000, annualContribution: 0 },
|
|
133
|
+
{ type: 'roth', id: testIds(), name: 'Roth', ownerPersonId: 'p1', annualReturnPct: 0, kind: 'ira', balance: 0, annualContribution: 0 },
|
|
134
|
+
{
|
|
135
|
+
type: 'taxable',
|
|
136
|
+
id: testIds(),
|
|
137
|
+
name: 'Brokerage',
|
|
138
|
+
ownerPersonId: null,
|
|
139
|
+
annualReturnPct: 0,
|
|
140
|
+
balance: 600_000,
|
|
141
|
+
costBasis: basis === 'high' ? 580_000 : 120_000,
|
|
142
|
+
annualContribution: 0,
|
|
143
|
+
},
|
|
144
|
+
{ type: 'cash', id: testIds(), name: 'Cash', ownerPersonId: null, annualReturnPct: 0, balance: 5_000, annualContribution: 0 },
|
|
145
|
+
];
|
|
146
|
+
plan.incomes = [
|
|
147
|
+
{ type: 'socialSecurity', id: testIds(), personId: 'p1', piaMonthly: 2_800, earnings: null, claimAge: { years: 70, months: 0 } },
|
|
148
|
+
];
|
|
149
|
+
return validate(plan);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Social Security taxability edge: SS starts mid-horizon (claim at 70), so an
|
|
153
|
+
* identical conversion is priced differently before vs during benefit years —
|
|
154
|
+
* the provisional-income feedback only the exact ledger knows.
|
|
155
|
+
*/
|
|
156
|
+
export function ssTaxabilityPlan() {
|
|
157
|
+
const plan = base({ dob: '1964-01-01', retirementAge: 62, longevity: { planningAge: 85, source: 'manual' } });
|
|
158
|
+
plan.expenses.baseAnnual = 30_000;
|
|
159
|
+
plan.accounts = [
|
|
160
|
+
{ type: 'traditional', id: testIds(), name: 'IRA', ownerPersonId: 'p1', annualReturnPct: 0, kind: 'ira', balance: 400_000, annualContribution: 0 },
|
|
161
|
+
{ type: 'roth', id: testIds(), name: 'Roth', ownerPersonId: 'p1', annualReturnPct: 0, kind: 'ira', balance: 0, annualContribution: 0 },
|
|
162
|
+
{ type: 'cash', id: testIds(), name: 'Cash', ownerPersonId: null, annualReturnPct: 0, balance: 500_000, annualContribution: 0 },
|
|
163
|
+
];
|
|
164
|
+
plan.incomes = [
|
|
165
|
+
{ type: 'socialSecurity', id: testIds(), personId: 'p1', piaMonthly: 3_000, earnings: null, claimAge: { years: 70, months: 0 } },
|
|
166
|
+
];
|
|
167
|
+
return validate(plan);
|
|
168
|
+
}
|
|
169
|
+
/** Lumpy one-time ordinary income in 2028 that stacks under any conversion that year. */
|
|
170
|
+
export function oneTimeIncomePlan() {
|
|
171
|
+
const plan = base({ dob: '1960-01-01', longevity: { planningAge: 72, source: 'manual' } });
|
|
172
|
+
plan.accounts = [
|
|
173
|
+
{ type: 'traditional', id: testIds(), name: 'IRA', ownerPersonId: 'p1', annualReturnPct: 0, kind: 'ira', balance: 300_000, annualContribution: 0 },
|
|
174
|
+
{ type: 'roth', id: testIds(), name: 'Roth', ownerPersonId: 'p1', annualReturnPct: 0, kind: 'ira', balance: 0, annualContribution: 0 },
|
|
175
|
+
{ type: 'cash', id: testIds(), name: 'Cash', ownerPersonId: null, annualReturnPct: 0, balance: 250_000, annualContribution: 0 },
|
|
176
|
+
];
|
|
177
|
+
plan.incomes = [
|
|
178
|
+
{ type: 'oneTime', id: testIds(), label: 'Deferred comp payout', year: 2028, amount: 80_000, taxTreatment: 'ordinary' },
|
|
179
|
+
];
|
|
180
|
+
return validate(plan);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Asset-location fixture (asset-allocation-and-return-model-v2, step 5): a
|
|
184
|
+
* bond-heavy allocated brokerage next to a stock-heavy-capable traditional and
|
|
185
|
+
* Roth, where *where* classes are held changes the after-tax estate — bonds
|
|
186
|
+
* throw ordinary interest in taxable and inflate the heir-taxed traditional
|
|
187
|
+
* balance when they grow there instead.
|
|
188
|
+
*/
|
|
189
|
+
export function assetLocationPlan() {
|
|
190
|
+
const plan = base({ dob: '1961-06-15', longevity: { planningAge: 90, source: 'manual' } });
|
|
191
|
+
plan.expenses.baseAnnual = 40_000;
|
|
192
|
+
const weights5050 = { usStocks: 50, intlStocks: 0, bonds: 50, cash: 0 };
|
|
193
|
+
plan.accounts = [
|
|
194
|
+
{
|
|
195
|
+
type: 'taxable',
|
|
196
|
+
id: testIds(),
|
|
197
|
+
name: 'Brokerage',
|
|
198
|
+
ownerPersonId: null,
|
|
199
|
+
annualReturnPct: null,
|
|
200
|
+
balance: 500_000,
|
|
201
|
+
costBasis: 350_000,
|
|
202
|
+
annualContribution: 0,
|
|
203
|
+
allocation: { mode: 'static', rebalancing: 'annual', weights: weights5050 },
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
type: 'traditional',
|
|
207
|
+
id: testIds(),
|
|
208
|
+
name: 'IRA',
|
|
209
|
+
ownerPersonId: 'p1',
|
|
210
|
+
annualReturnPct: null,
|
|
211
|
+
kind: 'ira',
|
|
212
|
+
balance: 500_000,
|
|
213
|
+
annualContribution: 0,
|
|
214
|
+
allocation: { mode: 'static', rebalancing: 'annual', weights: weights5050 },
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
type: 'roth',
|
|
218
|
+
id: testIds(),
|
|
219
|
+
name: 'Roth',
|
|
220
|
+
ownerPersonId: 'p1',
|
|
221
|
+
annualReturnPct: null,
|
|
222
|
+
kind: 'ira',
|
|
223
|
+
balance: 200_000,
|
|
224
|
+
annualContribution: 0,
|
|
225
|
+
allocation: { mode: 'static', rebalancing: 'annual', weights: weights5050 },
|
|
226
|
+
},
|
|
227
|
+
{ type: 'cash', id: testIds(), name: 'Cash', ownerPersonId: null, annualReturnPct: 0, balance: 60_000, annualContribution: 0 },
|
|
228
|
+
];
|
|
229
|
+
plan.incomes = [
|
|
230
|
+
{ type: 'socialSecurity', id: testIds(), personId: 'p1', piaMonthly: 2_400, earnings: null, claimAge: { years: 67, months: 0 } },
|
|
231
|
+
];
|
|
232
|
+
return validate(plan);
|
|
233
|
+
}
|
|
234
|
+
/** Married couple where the second spouse's earlier planning age creates survivor years. */
|
|
235
|
+
export function survivorPlan() {
|
|
236
|
+
const plan = base({ dob: '1960-01-01', longevity: { planningAge: 90, source: 'manual' } });
|
|
237
|
+
plan.household.filingStatus = 'marriedFilingJointly';
|
|
238
|
+
plan.household.people.push({
|
|
239
|
+
id: 'p2',
|
|
240
|
+
name: 'Sam',
|
|
241
|
+
dob: '1958-01-01',
|
|
242
|
+
sex: 'average',
|
|
243
|
+
retirementAge: 65,
|
|
244
|
+
longevity: { planningAge: 78, source: 'manual' },
|
|
245
|
+
});
|
|
246
|
+
plan.expenses.baseAnnual = 40_000;
|
|
247
|
+
plan.accounts = [
|
|
248
|
+
{ type: 'traditional', id: testIds(), name: 'IRA', ownerPersonId: 'p1', annualReturnPct: 0, kind: 'ira', balance: 600_000, annualContribution: 0 },
|
|
249
|
+
{ type: 'roth', id: testIds(), name: 'Roth', ownerPersonId: 'p1', annualReturnPct: 0, kind: 'ira', balance: 0, annualContribution: 0 },
|
|
250
|
+
{ type: 'cash', id: testIds(), name: 'Cash', ownerPersonId: null, annualReturnPct: 0, balance: 700_000, annualContribution: 0 },
|
|
251
|
+
];
|
|
252
|
+
return validate(plan);
|
|
253
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared exact-ledger candidate evaluator (ledger-native decision engine,
|
|
3
|
+
* Phase 1).
|
|
4
|
+
*
|
|
5
|
+
* One evaluation = one deterministic `simulatePlan` run of the candidate plan,
|
|
6
|
+
* compared against a shared baseline run. All recommendation surfaces (Roth &
|
|
7
|
+
* Tax Optimizer validation, tournaments, Insights previews, local search) go
|
|
8
|
+
* through here so income, capital gains, Social Security taxability, ACA,
|
|
9
|
+
* IRMAA, RMD, and inherited-IRA effects are priced by the ledger exactly once
|
|
10
|
+
* — never re-derived in candidate logic.
|
|
11
|
+
*/
|
|
12
|
+
import type { Plan } from '../model/plan.js';
|
|
13
|
+
import { type ProjectionSummary } from '../projection/compare.js';
|
|
14
|
+
import type { ProjectionResult } from '../projection/types.js';
|
|
15
|
+
import type { DecisionCandidate, DecisionContext, ExactDecisionEvaluation } from './types.js';
|
|
16
|
+
export interface EvaluateCandidateOptions {
|
|
17
|
+
/** Dollars around zero treated as matching the baseline. */
|
|
18
|
+
neutralToleranceDollars?: number;
|
|
19
|
+
/** Minimum total requested conversions before execution-ratio diagnostics matter. */
|
|
20
|
+
minimumRequestedConversionDollars?: number;
|
|
21
|
+
/** Absolute shortfall before requested-vs-executed mismatch is material. */
|
|
22
|
+
materialConversionShortfallDollars?: number;
|
|
23
|
+
/** Percent shortfall before requested-vs-executed mismatch is material. */
|
|
24
|
+
materialConversionShortfallPct?: number;
|
|
25
|
+
/**
|
|
26
|
+
* Reuse an already-simulated candidate result instead of re-running the
|
|
27
|
+
* ledger (e.g. the optimizer post-processor simulates schedules itself).
|
|
28
|
+
* The caller is responsible for it matching the candidate exactly.
|
|
29
|
+
*/
|
|
30
|
+
candidateResult?: ProjectionResult;
|
|
31
|
+
}
|
|
32
|
+
export declare const DECISION_NEUTRAL_TOLERANCE_DOLLARS = 1;
|
|
33
|
+
export declare const DECISION_MINIMUM_REQUESTED_CONVERSION_DOLLARS = 1;
|
|
34
|
+
export declare const DECISION_MATERIAL_SHORTFALL_DOLLARS = 1000;
|
|
35
|
+
export declare const DECISION_MATERIAL_SHORTFALL_PCT = 0.05;
|
|
36
|
+
/** Years the money lasts: depletion year, or one past the horizon when it never depletes. */
|
|
37
|
+
export declare function lastsThroughYear(result: ProjectionResult): number;
|
|
38
|
+
/** Build a fresh decision context, running the shared baseline once (or reusing a caller's run). */
|
|
39
|
+
export declare function createDecisionContext(plan: Plan, simulateOptions: DecisionContext['simulateOptions'], baseline?: {
|
|
40
|
+
result: ProjectionResult;
|
|
41
|
+
summary?: ProjectionSummary;
|
|
42
|
+
}, taxCalculatorForPlan?: DecisionContext['taxCalculatorForPlan']): DecisionContext;
|
|
43
|
+
/**
|
|
44
|
+
* Materialize the concrete plan a candidate describes: scenario patch first
|
|
45
|
+
* (validated through the plan schema), then any explicit conversion schedule
|
|
46
|
+
* installed as an `optimized` Roth strategy.
|
|
47
|
+
*/
|
|
48
|
+
export declare function planForCandidate(plan: Plan, candidate: Pick<DecisionCandidate, 'planPatch' | 'conversions'>): {
|
|
49
|
+
ok: true;
|
|
50
|
+
plan: Plan;
|
|
51
|
+
} | {
|
|
52
|
+
ok: false;
|
|
53
|
+
error: string;
|
|
54
|
+
};
|
|
55
|
+
/** First year the plan's own (non-inherited) traditional balance is exhausted, or null. */
|
|
56
|
+
export declare function findTraditionalDepletionYear(plan: Plan, result: ProjectionResult, toleranceDollars: number): number | null;
|
|
57
|
+
/**
|
|
58
|
+
* Run one candidate through the exact ledger and compare it with the shared
|
|
59
|
+
* baseline. Deterministic: same plan + candidate + options ⇒ same evaluation.
|
|
60
|
+
*/
|
|
61
|
+
export declare function evaluateCandidate(ctx: DecisionContext, candidate: DecisionCandidate, options?: EvaluateCandidateOptions): ExactDecisionEvaluation;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared exact-ledger candidate evaluator (ledger-native decision engine,
|
|
3
|
+
* Phase 1).
|
|
4
|
+
*
|
|
5
|
+
* One evaluation = one deterministic `simulatePlan` run of the candidate plan,
|
|
6
|
+
* compared against a shared baseline run. All recommendation surfaces (Roth &
|
|
7
|
+
* Tax Optimizer validation, tournaments, Insights previews, local search) go
|
|
8
|
+
* through here so income, capital gains, Social Security taxability, ACA,
|
|
9
|
+
* IRMAA, RMD, and inherited-IRA effects are priced by the ledger exactly once
|
|
10
|
+
* — never re-derived in candidate logic.
|
|
11
|
+
*/
|
|
12
|
+
import { applyScenarioPatch } from '../scenarios/scenarios.js';
|
|
13
|
+
import { summarizeProjection } from '../projection/compare.js';
|
|
14
|
+
import { simulatePlan } from '../projection/simulate.js';
|
|
15
|
+
export const DECISION_NEUTRAL_TOLERANCE_DOLLARS = 1;
|
|
16
|
+
export const DECISION_MINIMUM_REQUESTED_CONVERSION_DOLLARS = 1;
|
|
17
|
+
export const DECISION_MATERIAL_SHORTFALL_DOLLARS = 1_000;
|
|
18
|
+
export const DECISION_MATERIAL_SHORTFALL_PCT = 0.05;
|
|
19
|
+
/** Years the money lasts: depletion year, or one past the horizon when it never depletes. */
|
|
20
|
+
export function lastsThroughYear(result) {
|
|
21
|
+
return result.depletionYear ?? result.endYear + 1;
|
|
22
|
+
}
|
|
23
|
+
/** Build a fresh decision context, running the shared baseline once (or reusing a caller's run). */
|
|
24
|
+
export function createDecisionContext(plan, simulateOptions, baseline, taxCalculatorForPlan) {
|
|
25
|
+
const baselineResult = baseline?.result ?? simulatePlan(plan, simulateOptions);
|
|
26
|
+
return {
|
|
27
|
+
plan,
|
|
28
|
+
baselineResult,
|
|
29
|
+
baselineSummary: baseline?.summary ?? summarizeProjection(plan, baselineResult),
|
|
30
|
+
simulateOptions,
|
|
31
|
+
taxCalculatorForPlan,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Materialize the concrete plan a candidate describes: scenario patch first
|
|
36
|
+
* (validated through the plan schema), then any explicit conversion schedule
|
|
37
|
+
* installed as an `optimized` Roth strategy.
|
|
38
|
+
*/
|
|
39
|
+
export function planForCandidate(plan, candidate) {
|
|
40
|
+
let candidatePlan = plan;
|
|
41
|
+
if (candidate.planPatch) {
|
|
42
|
+
const applied = applyScenarioPatch(plan, candidate.planPatch);
|
|
43
|
+
if (!applied.ok)
|
|
44
|
+
return { ok: false, error: `This candidate can't be applied to the plan: ${applied.issues.join('; ')}` };
|
|
45
|
+
candidatePlan = applied.plan;
|
|
46
|
+
}
|
|
47
|
+
if (candidate.conversions) {
|
|
48
|
+
candidatePlan = {
|
|
49
|
+
...candidatePlan,
|
|
50
|
+
strategies: {
|
|
51
|
+
...candidatePlan.strategies,
|
|
52
|
+
rothConversion: { mode: 'optimized', conversions: candidate.conversions },
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return { ok: true, plan: candidatePlan };
|
|
57
|
+
}
|
|
58
|
+
function aggregateByYear(conversions) {
|
|
59
|
+
const byYear = new Map();
|
|
60
|
+
for (const conversion of conversions) {
|
|
61
|
+
byYear.set(conversion.year, (byYear.get(conversion.year) ?? 0) + conversion.amount);
|
|
62
|
+
}
|
|
63
|
+
return byYear;
|
|
64
|
+
}
|
|
65
|
+
function buildConversionExecution(requested, candidateResult, options) {
|
|
66
|
+
const requestedByYear = aggregateByYear(requested);
|
|
67
|
+
const requestedTotal = requested.reduce((sum, conversion) => sum + conversion.amount, 0);
|
|
68
|
+
const executedTotal = candidateResult.years.reduce((sum, year) => sum + year.rothConversion, 0);
|
|
69
|
+
let firstMateriallyUnexecutedYear = null;
|
|
70
|
+
for (const year of [...requestedByYear.keys()].sort((a, b) => a - b)) {
|
|
71
|
+
const requestedAmount = requestedByYear.get(year) ?? 0;
|
|
72
|
+
const executedAmount = candidateResult.years.find((y) => y.year === year)?.rothConversion ?? 0;
|
|
73
|
+
const materialShortfall = Math.max(options.materialConversionShortfallDollars, requestedAmount * options.materialConversionShortfallPct);
|
|
74
|
+
if (requestedAmount - executedAmount > materialShortfall) {
|
|
75
|
+
firstMateriallyUnexecutedYear = year;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
requestedTotal,
|
|
81
|
+
executedTotal,
|
|
82
|
+
executedRatio: requestedTotal > 0 ? Math.min(1, executedTotal / requestedTotal) : 1,
|
|
83
|
+
firstMateriallyUnexecutedYear,
|
|
84
|
+
executedByYear: candidateResult.years
|
|
85
|
+
.filter((year) => year.rothConversion > 1)
|
|
86
|
+
.map((year) => ({ year: year.year, amount: Math.round(year.rothConversion * 100) / 100 })),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** First year the plan's own (non-inherited) traditional balance is exhausted, or null. */
|
|
90
|
+
export function findTraditionalDepletionYear(plan, result, toleranceDollars) {
|
|
91
|
+
const ownTraditionalIds = new Set(plan.accounts.filter((account) => account.type === 'traditional' && !account.inherited).map((account) => account.id));
|
|
92
|
+
if (ownTraditionalIds.size === 0)
|
|
93
|
+
return null;
|
|
94
|
+
for (const year of result.years) {
|
|
95
|
+
let balance = 0;
|
|
96
|
+
for (const accountId of ownTraditionalIds)
|
|
97
|
+
balance += year.balances[accountId] ?? 0;
|
|
98
|
+
if (balance <= toleranceDollars)
|
|
99
|
+
return year.year;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
function classifyRecommendationState(args) {
|
|
104
|
+
if (args.afterTaxEstateDelta > args.neutralToleranceDollars)
|
|
105
|
+
return 'beneficial';
|
|
106
|
+
if (args.conversionExecution) {
|
|
107
|
+
const { requestedTotal, executedTotal } = args.conversionExecution;
|
|
108
|
+
const materialShortfall = Math.max(args.materialConversionShortfallDollars, requestedTotal * args.materialConversionShortfallPct);
|
|
109
|
+
if (requestedTotal >= args.minimumRequestedConversionDollars && requestedTotal - executedTotal > materialShortfall) {
|
|
110
|
+
return 'diagnostic';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (args.afterTaxEstateDelta < -args.neutralToleranceDollars)
|
|
114
|
+
return 'rejected';
|
|
115
|
+
return 'neutral';
|
|
116
|
+
}
|
|
117
|
+
/** Diagnostic-only evaluation for a candidate whose patch failed plan validation. */
|
|
118
|
+
function invalidCandidateEvaluation(ctx, candidate, error) {
|
|
119
|
+
return {
|
|
120
|
+
candidate,
|
|
121
|
+
baselineSummary: ctx.baselineSummary,
|
|
122
|
+
candidateSummary: ctx.baselineSummary,
|
|
123
|
+
candidateResult: ctx.baselineResult,
|
|
124
|
+
deltas: { endingAfterTaxEstate: 0, endingNetWorth: 0, lifetimeTax: 0, moneyLastsYears: 0 },
|
|
125
|
+
conversionExecution: null,
|
|
126
|
+
traditionalDepletionYear: null,
|
|
127
|
+
diagnostics: [error],
|
|
128
|
+
recommendationState: 'diagnostic',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Run one candidate through the exact ledger and compare it with the shared
|
|
133
|
+
* baseline. Deterministic: same plan + candidate + options ⇒ same evaluation.
|
|
134
|
+
*/
|
|
135
|
+
export function evaluateCandidate(ctx, candidate, options = {}) {
|
|
136
|
+
const neutralToleranceDollars = options.neutralToleranceDollars ?? DECISION_NEUTRAL_TOLERANCE_DOLLARS;
|
|
137
|
+
const minimumRequestedConversionDollars = options.minimumRequestedConversionDollars ?? DECISION_MINIMUM_REQUESTED_CONVERSION_DOLLARS;
|
|
138
|
+
const materialConversionShortfallDollars = options.materialConversionShortfallDollars ?? DECISION_MATERIAL_SHORTFALL_DOLLARS;
|
|
139
|
+
const materialConversionShortfallPct = options.materialConversionShortfallPct ?? DECISION_MATERIAL_SHORTFALL_PCT;
|
|
140
|
+
const built = planForCandidate(ctx.plan, candidate);
|
|
141
|
+
if (!built.ok)
|
|
142
|
+
return invalidCandidateEvaluation(ctx, candidate, built.error);
|
|
143
|
+
// A patch may change tax assumptions (e.g. a relocation candidate clearing
|
|
144
|
+
// the flat state-rate override); when the context carries a per-plan tax
|
|
145
|
+
// stack factory, price the candidate with its own stack.
|
|
146
|
+
const candidateSimulateOptions = ctx.taxCalculatorForPlan
|
|
147
|
+
? { ...ctx.simulateOptions, taxCalculator: ctx.taxCalculatorForPlan(built.plan) }
|
|
148
|
+
: ctx.simulateOptions;
|
|
149
|
+
const candidateResult = options.candidateResult ?? simulatePlan(built.plan, candidateSimulateOptions);
|
|
150
|
+
const candidateSummary = summarizeProjection(built.plan, candidateResult);
|
|
151
|
+
const conversionExecution = candidate.conversions
|
|
152
|
+
? buildConversionExecution(candidate.conversions, candidateResult, {
|
|
153
|
+
materialConversionShortfallDollars,
|
|
154
|
+
materialConversionShortfallPct,
|
|
155
|
+
})
|
|
156
|
+
: null;
|
|
157
|
+
const deltas = {
|
|
158
|
+
endingAfterTaxEstate: candidateSummary.endingAfterTaxEstate - ctx.baselineSummary.endingAfterTaxEstate,
|
|
159
|
+
endingNetWorth: candidateSummary.endingNetWorth - ctx.baselineSummary.endingNetWorth,
|
|
160
|
+
lifetimeTax: candidateSummary.lifetimeTaxesAndPenalties - ctx.baselineSummary.lifetimeTaxesAndPenalties,
|
|
161
|
+
moneyLastsYears: lastsThroughYear(candidateResult) - lastsThroughYear(ctx.baselineResult),
|
|
162
|
+
};
|
|
163
|
+
const diagnostics = [];
|
|
164
|
+
if (conversionExecution && conversionExecution.firstMateriallyUnexecutedYear !== null) {
|
|
165
|
+
diagnostics.push(`Your plan could not execute the requested conversion in ${conversionExecution.firstMateriallyUnexecutedYear}: ` +
|
|
166
|
+
`requested $${Math.round(conversionExecution.requestedTotal).toLocaleString()} in total, ` +
|
|
167
|
+
`executed $${Math.round(conversionExecution.executedTotal).toLocaleString()}.`);
|
|
168
|
+
}
|
|
169
|
+
if (deltas.moneyLastsYears < 0) {
|
|
170
|
+
diagnostics.push(`Money lasts ${-deltas.moneyLastsYears} year(s) less than the baseline.`);
|
|
171
|
+
}
|
|
172
|
+
if (candidateResult.depletionYear !== null && ctx.baselineResult.depletionYear === null) {
|
|
173
|
+
diagnostics.push(`Introduces portfolio depletion in ${candidateResult.depletionYear}.`);
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
candidate,
|
|
177
|
+
baselineSummary: ctx.baselineSummary,
|
|
178
|
+
candidateSummary,
|
|
179
|
+
candidateResult,
|
|
180
|
+
deltas,
|
|
181
|
+
conversionExecution,
|
|
182
|
+
traditionalDepletionYear: findTraditionalDepletionYear(built.plan, candidateResult, neutralToleranceDollars),
|
|
183
|
+
diagnostics,
|
|
184
|
+
recommendationState: classifyRecommendationState({
|
|
185
|
+
afterTaxEstateDelta: deltas.endingAfterTaxEstate,
|
|
186
|
+
conversionExecution,
|
|
187
|
+
neutralToleranceDollars,
|
|
188
|
+
minimumRequestedConversionDollars,
|
|
189
|
+
materialConversionShortfallDollars,
|
|
190
|
+
materialConversionShortfallPct,
|
|
191
|
+
}),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
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 type { OptimizedSchedule } from '../strategies/optimizer.js';
|
|
10
|
+
import type { CandidateGenerator, DecisionCandidate } from './types.js';
|
|
11
|
+
/**
|
|
12
|
+
* The simple fill-to-target Roth strategies of the bounded tournament: bracket
|
|
13
|
+
* fills, the ACA cliff cap, and the first IRMAA tier cap over the whole
|
|
14
|
+
* horizon, plus *windowed* bracket fills that stop at income boundaries (SS
|
|
15
|
+
* claim / RMD start) — under a sequential withdrawal order, spending draws fill
|
|
16
|
+
* the cheap brackets themselves once cash/taxable deplete or income starts, so
|
|
17
|
+
* "fill high early, then stop" beats any whole-horizon fill on bridge-shaped
|
|
18
|
+
* plans. Generated unconditionally — a plan with no convertible traditional
|
|
19
|
+
* simply executes $0 and evaluates neutral.
|
|
20
|
+
*/
|
|
21
|
+
export declare const simpleRothConversionGenerator: CandidateGenerator;
|
|
22
|
+
/** The do-nothing alternative, only meaningful when the plan currently converts. */
|
|
23
|
+
export declare const noConversionGenerator: CandidateGenerator;
|
|
24
|
+
/** Withdrawal-order alternatives to whatever the plan currently uses. */
|
|
25
|
+
export declare const withdrawalOrderGenerator: CandidateGenerator;
|
|
26
|
+
export interface ProbabilityBandSpendingGuardrailOptions {
|
|
27
|
+
/** Floor as a percent of target annual spending when the plan has no floor yet. */
|
|
28
|
+
requiredFloorPct?: number;
|
|
29
|
+
/** Lower Monte Carlo success band the candidate is meant to defend. */
|
|
30
|
+
lowerSuccessPct?: number;
|
|
31
|
+
/** Upper Monte Carlo success band where raises/upside become available. */
|
|
32
|
+
upperSuccessPct?: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Probability-band safe-spend guardrail candidates. Generation is deliberately
|
|
36
|
+
* pure: the candidate carries the success band as metadata and a concrete
|
|
37
|
+
* ledger-native guardrail patch; exact/stochastic surfaces can then evaluate it
|
|
38
|
+
* through the shared decision and Monte Carlo runners without private models.
|
|
39
|
+
*/
|
|
40
|
+
export declare function probabilityBandSpendingGuardrailGenerator(options?: ProbabilityBandSpendingGuardrailOptions): CandidateGenerator;
|
|
41
|
+
/**
|
|
42
|
+
* Bounded Social Security claim-age candidates: for up to two SS streams, the
|
|
43
|
+
* three canonical claim ages (62 / FRA / 70) that differ from the current
|
|
44
|
+
* claim. Each candidate replaces the whole incomes array (scenario patches
|
|
45
|
+
* replace arrays wholesale, keeping the patch order-safe).
|
|
46
|
+
*/
|
|
47
|
+
export declare const socialSecurityClaimGenerator: CandidateGenerator;
|
|
48
|
+
/**
|
|
49
|
+
* Asset-location candidates (asset-allocation-and-return-model-v2, step 5).
|
|
50
|
+
*
|
|
51
|
+
* Proposes swapping *where* classes are held while leaving the household's
|
|
52
|
+
* total dollars in each class unchanged: bonds move toward traditional
|
|
53
|
+
* (sheltering their ordinary-income yield and slowing the RMD/heir-taxed
|
|
54
|
+
* balance), US stocks move toward taxable (qualified dividends + basis
|
|
55
|
+
* machinery) or Roth (sheltering the highest expected growth). Swaps pair the
|
|
56
|
+
* largest eligible static-allocation accounts and are expressed as plan
|
|
57
|
+
* patches over the accounts array; the exact ledger prices every candidate —
|
|
58
|
+
* generation never decides. Bounded: at most 3 candidates.
|
|
59
|
+
*/
|
|
60
|
+
export declare const assetLocationGenerator: CandidateGenerator;
|
|
61
|
+
/**
|
|
62
|
+
* Full Social Security claiming grid for the optimizer page: every whole-year
|
|
63
|
+
* claim combination from 62-70 (respecting current age) for up to two claiming
|
|
64
|
+
* streams. When a stream's current claim age is a whole year within range, that
|
|
65
|
+
* combination reproduces the current plan; a current claim age with months, below
|
|
66
|
+
* 62, above 70, or already past is not emitted here. Bounded: 9 single-person
|
|
67
|
+
* candidates or 81 couple candidates.
|
|
68
|
+
*/
|
|
69
|
+
export declare const socialSecurityClaimGridGenerator: CandidateGenerator;
|
|
70
|
+
/**
|
|
71
|
+
* Wrap MILP optimizer output (raw and post-processed schedules) as candidates.
|
|
72
|
+
* The solver is one candidate generator among several — never the authority.
|
|
73
|
+
*/
|
|
74
|
+
export declare function milpScheduleGenerator(schedules: {
|
|
75
|
+
raw?: OptimizedSchedule | null;
|
|
76
|
+
cleanedConversions?: Array<{
|
|
77
|
+
year: number;
|
|
78
|
+
amount: number;
|
|
79
|
+
}> | null;
|
|
80
|
+
}): CandidateGenerator;
|
|
81
|
+
/**
|
|
82
|
+
* Annuity purchase candidates (guaranteed-income-and-estate-depth, step 6;
|
|
83
|
+
* ladders added by annuity-pension-and-home-equity, step 1): no purchase /
|
|
84
|
+
* cover-the-floor SPIA / a laddered version of the same SPIA (three dated
|
|
85
|
+
* tranches, so rising age-payout rates and shorter rate lock-in are priced) /
|
|
86
|
+
* QLAC at the cap. Each is a concrete, bounded plan patch that adds (or
|
|
87
|
+
* removes) annuity purchases; the exact ledger prices the liquidity ↓ /
|
|
88
|
+
* durability ↑ / estate Δ tradeoff — generation never decides. Bounded: at
|
|
89
|
+
* most four candidates. Payout rates come from the sourced default table in
|
|
90
|
+
* ./spiaQuotes (user-entered purchases carry their own quotes).
|
|
91
|
+
*/
|
|
92
|
+
export declare const annuityPurchaseGenerator: CandidateGenerator;
|
|
93
|
+
/**
|
|
94
|
+
* Social Security bridge / TIPS-ladder candidates
|
|
95
|
+
* (social-security-bridge-and-tips-ladder, step 3): fund the gap years between
|
|
96
|
+
* retirement and each claimant's chosen claim age with a TIPS bridge ladder
|
|
97
|
+
* paying the forgone age-62 benefit, purchased from the largest liquid
|
|
98
|
+
* account; plus the remove-the-ladders alternative when the plan already has
|
|
99
|
+
* some. Bounded (≤ one bridge per person + one removal); the exact ledger
|
|
100
|
+
* prices the liquidity ↓ / durability ↑ tradeoff — generation never decides.
|
|
101
|
+
*/
|
|
102
|
+
export declare const bridgeLadderGenerator: CandidateGenerator;
|
|
103
|
+
/** Wrap a modelable Insights detector action as a decision candidate generator. */
|
|
104
|
+
export declare function insightActionGenerator(args: {
|
|
105
|
+
id: string;
|
|
106
|
+
category: DecisionCandidate['category'];
|
|
107
|
+
label: string;
|
|
108
|
+
explanation: string;
|
|
109
|
+
planPatch: Record<string, unknown>;
|
|
110
|
+
}): CandidateGenerator;
|