@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,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Monte Carlo path runner + aggregation (roadmap V4, feature catalog §11).
|
|
3
|
+
*
|
|
4
|
+
* Each path is the same deterministic annual ledger (engine/projection)
|
|
5
|
+
* driven by one stochastic MarketSeries — never a separate model. Per-path
|
|
6
|
+
* seeds derive from (seed, pathIndex), so a run is reproducible regardless
|
|
7
|
+
* of how paths are partitioned across workers. Aggregation produces the
|
|
8
|
+
* headline outputs: success probability, the per-year percentile fan,
|
|
9
|
+
* the ending-balance distribution, and the depletion-age histogram.
|
|
10
|
+
*/
|
|
11
|
+
import { summarizeProjection } from '../projection/compare.js';
|
|
12
|
+
import { simulatePlan } from '../projection/simulate.js';
|
|
13
|
+
import { sampleCareEvents } from './ltcShock.js';
|
|
14
|
+
import { MAX_AGE, sampleDeathAge } from './mortality.js';
|
|
15
|
+
import { createRng, derivePathSeed } from './rng.js';
|
|
16
|
+
/** Dollar threshold below which a shortfall is treated as rounding, not a miss. */
|
|
17
|
+
const SHORTFALL_EPSILON = 0.5;
|
|
18
|
+
export function runMonteCarloPaths(plan, opts) {
|
|
19
|
+
const first = opts.firstPathIndex ?? 0;
|
|
20
|
+
const paths = [];
|
|
21
|
+
let startYear = opts.startYear;
|
|
22
|
+
let endYear = opts.startYear;
|
|
23
|
+
// In stochastic-longevity mode, run every path to a fixed wide horizon (the
|
|
24
|
+
// youngest person reaching the table's max age) so the year grids align even
|
|
25
|
+
// though sampled death ages differ; otherwise the horizon is plan-determined.
|
|
26
|
+
const horizonEndYear = opts.stochasticLongevity
|
|
27
|
+
? Math.max(...plan.household.people.map((p) => Number(p.dob.slice(0, 4)) + MAX_AGE))
|
|
28
|
+
: undefined;
|
|
29
|
+
for (let i = 0; i < opts.pathCount; i++) {
|
|
30
|
+
const rng = createRng(derivePathSeed(opts.seed, first + i));
|
|
31
|
+
// 120 years comfortably covers any plan horizon; simulate clamps reads.
|
|
32
|
+
const market = opts.model.generatePath(rng, 120);
|
|
33
|
+
let pathPlan = plan;
|
|
34
|
+
let deathAgeByPersonId;
|
|
35
|
+
if (opts.stochasticLongevity) {
|
|
36
|
+
deathAgeByPersonId = {};
|
|
37
|
+
for (const p of plan.household.people) {
|
|
38
|
+
deathAgeByPersonId[p.id] = sampleDeathAge(rng, opts.startYear - Number(p.dob.slice(0, 4)), p.sex);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (opts.ltcShock) {
|
|
42
|
+
const sampled = sampleCareEvents(rng, plan.household.people, opts.startYear, opts.ltcShock);
|
|
43
|
+
if (sampled.length > 0)
|
|
44
|
+
pathPlan = { ...plan, careEvents: [...plan.careEvents, ...sampled] };
|
|
45
|
+
}
|
|
46
|
+
const result = simulatePlan(pathPlan, {
|
|
47
|
+
startYear: opts.startYear,
|
|
48
|
+
taxCalculator: opts.taxCalculator,
|
|
49
|
+
market,
|
|
50
|
+
deathAgeByPersonId,
|
|
51
|
+
horizonEndYear,
|
|
52
|
+
});
|
|
53
|
+
const projectionSummary = summarizeProjection(pathPlan, result);
|
|
54
|
+
startYear = result.startYear;
|
|
55
|
+
endYear = result.endYear;
|
|
56
|
+
const investableByYear = new Float64Array(result.years.length);
|
|
57
|
+
let requiredFloorMet = true;
|
|
58
|
+
let targetLifestyleMet = true;
|
|
59
|
+
let totalShortfall = 0;
|
|
60
|
+
let totalRequiredShortfall = 0;
|
|
61
|
+
let totalTargetShortfall = 0;
|
|
62
|
+
let targetIntended = 0;
|
|
63
|
+
let targetFunded = 0;
|
|
64
|
+
let targetShortfallTotal = 0;
|
|
65
|
+
let yearsBelowTarget = 0;
|
|
66
|
+
let idealIntended = 0;
|
|
67
|
+
let idealFunded = 0;
|
|
68
|
+
let excessIntended = 0;
|
|
69
|
+
let excessFunded = 0;
|
|
70
|
+
const flexibleGoals = { funded: 0, partiallyFunded: 0, deferred: 0, skipped: 0, fundedAmount: 0, unfundedAmount: 0 };
|
|
71
|
+
const guardrailActionCounts = { cut: 0, raise: 0, hold: 0 };
|
|
72
|
+
let guardrailCutYears = 0;
|
|
73
|
+
let longestGuardrailCutSpellYears = 0;
|
|
74
|
+
let currentCutSpellYears = 0;
|
|
75
|
+
let maxGuardrailCutDepth = 0;
|
|
76
|
+
for (let y = 0; y < result.years.length; y++) {
|
|
77
|
+
const yr = result.years[y];
|
|
78
|
+
investableByYear[y] = yr.investableTotal;
|
|
79
|
+
const guardrailFactor = yr.expenses.guardrailFactor;
|
|
80
|
+
if (guardrailFactor < 1 - 1e-9) {
|
|
81
|
+
guardrailCutYears++;
|
|
82
|
+
currentCutSpellYears++;
|
|
83
|
+
if (currentCutSpellYears > longestGuardrailCutSpellYears)
|
|
84
|
+
longestGuardrailCutSpellYears = currentCutSpellYears;
|
|
85
|
+
if (1 - guardrailFactor > maxGuardrailCutDepth)
|
|
86
|
+
maxGuardrailCutDepth = 1 - guardrailFactor;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
currentCutSpellYears = 0;
|
|
90
|
+
}
|
|
91
|
+
totalShortfall += yr.shortfall;
|
|
92
|
+
totalRequiredShortfall += yr.requiredShortfall;
|
|
93
|
+
totalTargetShortfall += yr.targetShortfall;
|
|
94
|
+
if (yr.requiredShortfall > SHORTFALL_EPSILON)
|
|
95
|
+
requiredFloorMet = false;
|
|
96
|
+
if (yr.targetShortfall > SHORTFALL_EPSILON)
|
|
97
|
+
targetLifestyleMet = false;
|
|
98
|
+
targetIntended += yr.expenses.targetSpending;
|
|
99
|
+
targetFunded += Math.max(0, yr.expenses.targetSpending - yr.targetShortfall);
|
|
100
|
+
targetShortfallTotal += yr.targetShortfall;
|
|
101
|
+
if (yr.targetShortfall > SHORTFALL_EPSILON)
|
|
102
|
+
yearsBelowTarget++;
|
|
103
|
+
idealIntended += yr.expenses.idealSpending;
|
|
104
|
+
idealFunded += Math.max(0, yr.expenses.idealSpending - yr.idealShortfall);
|
|
105
|
+
excessIntended += yr.expenses.excessSpending;
|
|
106
|
+
excessFunded += Math.max(0, yr.expenses.excessSpending - yr.excessShortfall);
|
|
107
|
+
flexibleGoals.funded += yr.flexibleGoals.funded;
|
|
108
|
+
flexibleGoals.partiallyFunded += yr.flexibleGoals.partiallyFunded;
|
|
109
|
+
flexibleGoals.deferred += yr.flexibleGoals.deferred;
|
|
110
|
+
flexibleGoals.skipped += yr.flexibleGoals.skipped;
|
|
111
|
+
flexibleGoals.fundedAmount += yr.flexibleGoals.fundedAmount;
|
|
112
|
+
flexibleGoals.unfundedAmount += yr.flexibleGoals.unfundedAmount;
|
|
113
|
+
guardrailActionCounts[yr.guardrailAction]++;
|
|
114
|
+
}
|
|
115
|
+
// Bequest target is entered in today's dollars; the ending estate is
|
|
116
|
+
// nominal at path end, so the target is inflated across the path horizon
|
|
117
|
+
// using this path's *realized* inflation series (mirroring the ledger's
|
|
118
|
+
// cumulative factor, with the plan assumption as the past-series fallback).
|
|
119
|
+
const bequestTarget = pathPlan.expenses.bequestTargetDollars ?? 0;
|
|
120
|
+
let pathInflationFactor = 1;
|
|
121
|
+
if (bequestTarget > 0) {
|
|
122
|
+
const series = market.inflationPct;
|
|
123
|
+
for (let y = 0; y < result.endYear - result.startYear; y++) {
|
|
124
|
+
const pct = series && series.length > 0
|
|
125
|
+
? (series[Math.min(y, series.length - 1)] ?? pathPlan.assumptions.inflationPct)
|
|
126
|
+
: pathPlan.assumptions.inflationPct;
|
|
127
|
+
pathInflationFactor *= 1 + pct / 100;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const endingAboveBequestTarget = bequestTarget > 0 ? projectionSummary.endingAfterTaxEstate >= bequestTarget * pathInflationFactor : null;
|
|
131
|
+
paths.push({
|
|
132
|
+
investableByYear,
|
|
133
|
+
endingInvestable: result.endingInvestable,
|
|
134
|
+
endingNetWorth: result.endingNetWorth,
|
|
135
|
+
endingAfterTaxEstate: projectionSummary.endingAfterTaxEstate,
|
|
136
|
+
depletionYear: result.depletionYear,
|
|
137
|
+
totalShortfall,
|
|
138
|
+
totalRequiredShortfall,
|
|
139
|
+
totalTargetShortfall,
|
|
140
|
+
requiredFloorMet,
|
|
141
|
+
targetLifestyleMet,
|
|
142
|
+
targetAttainmentPct: targetIntended > 0 ? Math.min(1, targetFunded / targetIntended) : 1,
|
|
143
|
+
averageAnnualTargetShortfall: result.years.length > 0 ? targetShortfallTotal / result.years.length : 0,
|
|
144
|
+
yearsBelowTarget,
|
|
145
|
+
idealIntended,
|
|
146
|
+
idealFunded,
|
|
147
|
+
excessIntended,
|
|
148
|
+
excessFunded,
|
|
149
|
+
flexibleGoals,
|
|
150
|
+
guardrailActionCounts,
|
|
151
|
+
guardrailCutYears,
|
|
152
|
+
longestGuardrailCutSpellYears,
|
|
153
|
+
maxGuardrailCutDepth,
|
|
154
|
+
endingAboveBequestTarget,
|
|
155
|
+
});
|
|
156
|
+
opts.onPathDone?.(i + 1);
|
|
157
|
+
}
|
|
158
|
+
return { startYear, endYear, paths };
|
|
159
|
+
}
|
|
160
|
+
function percentile(sorted, p) {
|
|
161
|
+
if (sorted.length === 0)
|
|
162
|
+
return 0;
|
|
163
|
+
const idx = (p / 100) * (sorted.length - 1);
|
|
164
|
+
const lo = Math.floor(idx);
|
|
165
|
+
const hi = Math.ceil(idx);
|
|
166
|
+
const frac = idx - lo;
|
|
167
|
+
return sorted[lo] * (1 - frac) + sorted[hi] * frac;
|
|
168
|
+
}
|
|
169
|
+
function percentileSet(sorted) {
|
|
170
|
+
return {
|
|
171
|
+
p10: percentile(sorted, 10),
|
|
172
|
+
p25: percentile(sorted, 25),
|
|
173
|
+
p50: percentile(sorted, 50),
|
|
174
|
+
p75: percentile(sorted, 75),
|
|
175
|
+
p90: percentile(sorted, 90),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function histogramFor(sorted, histogramBins) {
|
|
179
|
+
const min = sorted[0] ?? 0;
|
|
180
|
+
const max = sorted[sorted.length - 1] ?? 0;
|
|
181
|
+
const binWidth = max > min ? (max - min) / histogramBins : 1;
|
|
182
|
+
const counts = new Array(histogramBins).fill(0);
|
|
183
|
+
for (const v of sorted) {
|
|
184
|
+
counts[Math.min(histogramBins - 1, Math.floor((v - min) / binWidth))]++;
|
|
185
|
+
}
|
|
186
|
+
return { min, binWidth, counts };
|
|
187
|
+
}
|
|
188
|
+
export function aggregateMonteCarlo(result, histogramBins = 30) {
|
|
189
|
+
const { paths, startYear } = result;
|
|
190
|
+
const yearCount = paths[0]?.investableByYear.length ?? 0;
|
|
191
|
+
const fan = [];
|
|
192
|
+
const column = new Array(paths.length);
|
|
193
|
+
for (let y = 0; y < yearCount; y++) {
|
|
194
|
+
for (let i = 0; i < paths.length; i++)
|
|
195
|
+
column[i] = paths[i].investableByYear[y];
|
|
196
|
+
column.sort((a, b) => a - b);
|
|
197
|
+
fan.push({ year: startYear + y, ...percentileSet(column) });
|
|
198
|
+
}
|
|
199
|
+
const endings = paths.map((p) => p.endingInvestable).sort((a, b) => a - b);
|
|
200
|
+
const endingNetWorths = paths.map((p) => p.endingNetWorth).sort((a, b) => a - b);
|
|
201
|
+
const endingAfterTaxEstates = paths.map((p) => p.endingAfterTaxEstate).sort((a, b) => a - b);
|
|
202
|
+
const depletionMap = new Map();
|
|
203
|
+
let successes = 0;
|
|
204
|
+
let requiredFloorSuccesses = 0;
|
|
205
|
+
let targetLifestyleSuccesses = 0;
|
|
206
|
+
let averageAnnualTargetShortfallTotal = 0;
|
|
207
|
+
let yearsBelowTargetTotal = 0;
|
|
208
|
+
let idealIntendedTotal = 0;
|
|
209
|
+
let idealFundedTotal = 0;
|
|
210
|
+
let excessIntendedTotal = 0;
|
|
211
|
+
let excessFundedTotal = 0;
|
|
212
|
+
let totalShortfallTotal = 0;
|
|
213
|
+
let totalRequiredShortfallTotal = 0;
|
|
214
|
+
let totalTargetShortfallTotal = 0;
|
|
215
|
+
let failingPathShortfallTotal = 0;
|
|
216
|
+
let failingPathRequiredShortfallTotal = 0;
|
|
217
|
+
let failingPathTargetShortfallTotal = 0;
|
|
218
|
+
let failingPathCount = 0;
|
|
219
|
+
const totalShortfalls = [];
|
|
220
|
+
const targetAttainments = [];
|
|
221
|
+
const averageTargetShortfalls = [];
|
|
222
|
+
const flexibleGoals = { funded: 0, partiallyFunded: 0, deferred: 0, skipped: 0, fundedAmount: 0, unfundedAmount: 0 };
|
|
223
|
+
const guardrailActionCounts = { cut: 0, raise: 0, hold: 0 };
|
|
224
|
+
const cutDepths = [];
|
|
225
|
+
const cutYearCounts = [];
|
|
226
|
+
let cutSpellTotal = 0;
|
|
227
|
+
let pathsWithCut = 0;
|
|
228
|
+
let pathsWithRaise = 0;
|
|
229
|
+
let surplusPaths = 0;
|
|
230
|
+
let bequestTargetPaths = 0;
|
|
231
|
+
let aboveBequestTargetPaths = 0;
|
|
232
|
+
for (const p of paths) {
|
|
233
|
+
if (p.depletionYear === null)
|
|
234
|
+
successes++;
|
|
235
|
+
else {
|
|
236
|
+
depletionMap.set(p.depletionYear, (depletionMap.get(p.depletionYear) ?? 0) + 1);
|
|
237
|
+
failingPathCount++;
|
|
238
|
+
failingPathShortfallTotal += p.totalShortfall;
|
|
239
|
+
failingPathRequiredShortfallTotal += p.totalRequiredShortfall;
|
|
240
|
+
failingPathTargetShortfallTotal += p.totalTargetShortfall;
|
|
241
|
+
}
|
|
242
|
+
totalShortfalls.push(p.totalShortfall);
|
|
243
|
+
totalShortfallTotal += p.totalShortfall;
|
|
244
|
+
totalRequiredShortfallTotal += p.totalRequiredShortfall;
|
|
245
|
+
totalTargetShortfallTotal += p.totalTargetShortfall;
|
|
246
|
+
if (p.requiredFloorMet)
|
|
247
|
+
requiredFloorSuccesses++;
|
|
248
|
+
if (p.targetLifestyleMet)
|
|
249
|
+
targetLifestyleSuccesses++;
|
|
250
|
+
targetAttainments.push(p.targetAttainmentPct);
|
|
251
|
+
averageTargetShortfalls.push(p.averageAnnualTargetShortfall);
|
|
252
|
+
averageAnnualTargetShortfallTotal += p.averageAnnualTargetShortfall;
|
|
253
|
+
yearsBelowTargetTotal += p.yearsBelowTarget;
|
|
254
|
+
idealIntendedTotal += p.idealIntended;
|
|
255
|
+
idealFundedTotal += p.idealFunded;
|
|
256
|
+
excessIntendedTotal += p.excessIntended;
|
|
257
|
+
excessFundedTotal += p.excessFunded;
|
|
258
|
+
flexibleGoals.funded += p.flexibleGoals.funded;
|
|
259
|
+
flexibleGoals.partiallyFunded += p.flexibleGoals.partiallyFunded;
|
|
260
|
+
flexibleGoals.deferred += p.flexibleGoals.deferred;
|
|
261
|
+
flexibleGoals.skipped += p.flexibleGoals.skipped;
|
|
262
|
+
flexibleGoals.fundedAmount += p.flexibleGoals.fundedAmount;
|
|
263
|
+
flexibleGoals.unfundedAmount += p.flexibleGoals.unfundedAmount;
|
|
264
|
+
guardrailActionCounts.cut += p.guardrailActionCounts.cut;
|
|
265
|
+
guardrailActionCounts.raise += p.guardrailActionCounts.raise;
|
|
266
|
+
guardrailActionCounts.hold += p.guardrailActionCounts.hold;
|
|
267
|
+
if (p.guardrailCutYears > 0) {
|
|
268
|
+
pathsWithCut++;
|
|
269
|
+
cutDepths.push(p.maxGuardrailCutDepth);
|
|
270
|
+
cutYearCounts.push(p.guardrailCutYears);
|
|
271
|
+
cutSpellTotal += p.longestGuardrailCutSpellYears;
|
|
272
|
+
}
|
|
273
|
+
if (p.guardrailActionCounts.raise > 0)
|
|
274
|
+
pathsWithRaise++;
|
|
275
|
+
if (p.endingAfterTaxEstate > 0)
|
|
276
|
+
surplusPaths++;
|
|
277
|
+
if (p.endingAboveBequestTarget !== null) {
|
|
278
|
+
bequestTargetPaths++;
|
|
279
|
+
if (p.endingAboveBequestTarget)
|
|
280
|
+
aboveBequestTargetPaths++;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const depletionYearCounts = [...depletionMap.entries()]
|
|
284
|
+
.map(([year, count]) => ({ year, count }))
|
|
285
|
+
.sort((a, b) => a.year - b.year);
|
|
286
|
+
const share = (n) => (paths.length === 0 ? 0 : n / paths.length);
|
|
287
|
+
let cumulative = 0;
|
|
288
|
+
const depletionProbabilityByYear = depletionYearCounts.map(({ year, count }) => {
|
|
289
|
+
const probability = share(count);
|
|
290
|
+
cumulative += probability;
|
|
291
|
+
return { year, count, probability, cumulativeProbability: cumulative };
|
|
292
|
+
});
|
|
293
|
+
targetAttainments.sort((a, b) => a - b);
|
|
294
|
+
averageTargetShortfalls.sort((a, b) => a - b);
|
|
295
|
+
totalShortfalls.sort((a, b) => a - b);
|
|
296
|
+
cutDepths.sort((a, b) => a - b);
|
|
297
|
+
cutYearCounts.sort((a, b) => a - b);
|
|
298
|
+
const adjustments = {
|
|
299
|
+
pathsWithCut: share(pathsWithCut),
|
|
300
|
+
pathsWithRaise: share(pathsWithRaise),
|
|
301
|
+
medianMaxCutDepth: percentile(cutDepths, 50),
|
|
302
|
+
p90MaxCutDepth: percentile(cutDepths, 90),
|
|
303
|
+
averageCutYears: pathsWithCut === 0 ? 0 : cutYearCounts.reduce((a, b) => a + b, 0) / pathsWithCut,
|
|
304
|
+
p90CutYears: percentile(cutYearCounts, 90),
|
|
305
|
+
averageLongestCutSpellYears: pathsWithCut === 0 ? 0 : cutSpellTotal / pathsWithCut,
|
|
306
|
+
probEndingSurplus: share(surplusPaths),
|
|
307
|
+
probEndingAboveBequestTarget: bequestTargetPaths === 0 ? null : aboveBequestTargetPaths / bequestTargetPaths,
|
|
308
|
+
};
|
|
309
|
+
return {
|
|
310
|
+
pathCount: paths.length,
|
|
311
|
+
successRate: share(successes),
|
|
312
|
+
requiredFloorSuccessRate: share(requiredFloorSuccesses),
|
|
313
|
+
targetLifestyleSuccessRate: share(targetLifestyleSuccesses),
|
|
314
|
+
targetAttainmentPct: percentileSet(targetAttainments),
|
|
315
|
+
averageAnnualTargetShortfall: paths.length === 0 ? 0 : averageAnnualTargetShortfallTotal / paths.length,
|
|
316
|
+
p90AverageAnnualTargetShortfall: percentile(averageTargetShortfalls, 90),
|
|
317
|
+
averageYearsBelowTarget: paths.length === 0 ? 0 : yearsBelowTargetTotal / paths.length,
|
|
318
|
+
idealFundingRate: idealIntendedTotal > 0 ? idealFundedTotal / idealIntendedTotal : 1,
|
|
319
|
+
excessFundingRate: excessIntendedTotal > 0 ? excessFundedTotal / excessIntendedTotal : 1,
|
|
320
|
+
flexibleGoals,
|
|
321
|
+
guardrailActionCounts,
|
|
322
|
+
adjustments,
|
|
323
|
+
downsideRisk: {
|
|
324
|
+
failureRate: share(failingPathCount),
|
|
325
|
+
failingPathCount,
|
|
326
|
+
expectedShortfallDollars: failingPathCount === 0 ? 0 : failingPathShortfallTotal / failingPathCount,
|
|
327
|
+
expectedRequiredShortfallDollars: failingPathCount === 0 ? 0 : failingPathRequiredShortfallTotal / failingPathCount,
|
|
328
|
+
expectedTargetShortfallDollars: failingPathCount === 0 ? 0 : failingPathTargetShortfallTotal / failingPathCount,
|
|
329
|
+
p90TotalShortfallDollars: percentile(totalShortfalls, 90),
|
|
330
|
+
},
|
|
331
|
+
spendingShortfall: {
|
|
332
|
+
averageTotalShortfallDollars: paths.length === 0 ? 0 : totalShortfallTotal / paths.length,
|
|
333
|
+
averageRequiredShortfallDollars: paths.length === 0 ? 0 : totalRequiredShortfallTotal / paths.length,
|
|
334
|
+
averageTargetShortfallDollars: paths.length === 0 ? 0 : totalTargetShortfallTotal / paths.length,
|
|
335
|
+
p90TotalShortfallDollars: percentile(totalShortfalls, 90),
|
|
336
|
+
},
|
|
337
|
+
fan,
|
|
338
|
+
endingInvestable: { percentiles: percentileSet(endings), histogram: histogramFor(endings, histogramBins) },
|
|
339
|
+
endingNetWorth: { percentiles: percentileSet(endingNetWorths), histogram: histogramFor(endingNetWorths, histogramBins) },
|
|
340
|
+
endingAfterTaxEstate: {
|
|
341
|
+
percentiles: percentileSet(endingAfterTaxEstates),
|
|
342
|
+
histogram: histogramFor(endingAfterTaxEstates, histogramBins),
|
|
343
|
+
},
|
|
344
|
+
depletionYearCounts,
|
|
345
|
+
depletionProbabilityByYear,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
/** Merge per-worker partial results (path order does not affect aggregation). */
|
|
349
|
+
export function mergePathResults(parts) {
|
|
350
|
+
const nonEmpty = parts.filter((p) => p.paths.length > 0);
|
|
351
|
+
const first = nonEmpty[0] ?? parts[0] ?? { startYear: 0, endYear: 0, paths: [] };
|
|
352
|
+
return { startYear: first.startYear, endYear: first.endYear, paths: nonEmpty.flatMap((p) => p.paths) };
|
|
353
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared-path stochastic comparisons.
|
|
3
|
+
*
|
|
4
|
+
* Every plan variant is evaluated with the same seed, path count, and market
|
|
5
|
+
* model config, so path index N means the same market history for every row.
|
|
6
|
+
* This is the reusable primitive for scenario/candidate stochastic deltas.
|
|
7
|
+
*/
|
|
8
|
+
import type { Plan } from '../model/plan.js';
|
|
9
|
+
import type { TaxCalculator } from '../projection/types.js';
|
|
10
|
+
import type { LtcShockParams } from './ltcShock.js';
|
|
11
|
+
import type { MarketModelConfig } from './marketModels.js';
|
|
12
|
+
import { type MonteCarloSummary } from './run.js';
|
|
13
|
+
export interface SharedPathPlan {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
plan: Plan;
|
|
17
|
+
/**
|
|
18
|
+
* Per-entry tax stack, when variants need different calculators (e.g. the
|
|
19
|
+
* relocation compare's per-candidate local rates). Falls back to the shared
|
|
20
|
+
* `opts.taxCalculator`.
|
|
21
|
+
*/
|
|
22
|
+
taxCalculator?: TaxCalculator;
|
|
23
|
+
}
|
|
24
|
+
export interface SharedPathComparisonOptions {
|
|
25
|
+
startYear: number;
|
|
26
|
+
taxCalculator: TaxCalculator;
|
|
27
|
+
model: MarketModelConfig;
|
|
28
|
+
pathCount: number;
|
|
29
|
+
seed: number;
|
|
30
|
+
stochasticLongevity?: boolean;
|
|
31
|
+
ltcShock?: LtcShockParams | null;
|
|
32
|
+
}
|
|
33
|
+
export interface SharedPathComparisonRow {
|
|
34
|
+
id: string;
|
|
35
|
+
label: string;
|
|
36
|
+
summary: MonteCarloSummary;
|
|
37
|
+
}
|
|
38
|
+
export interface SharedPathComparison {
|
|
39
|
+
seed: number;
|
|
40
|
+
pathCount: number;
|
|
41
|
+
rows: SharedPathComparisonRow[];
|
|
42
|
+
}
|
|
43
|
+
export declare function comparePlansOnSharedMarketPaths(plans: SharedPathPlan[], opts: SharedPathComparisonOptions): SharedPathComparison;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared-path stochastic comparisons.
|
|
3
|
+
*
|
|
4
|
+
* Every plan variant is evaluated with the same seed, path count, and market
|
|
5
|
+
* model config, so path index N means the same market history for every row.
|
|
6
|
+
* This is the reusable primitive for scenario/candidate stochastic deltas.
|
|
7
|
+
*/
|
|
8
|
+
import { createMarketModel } from './marketModels.js';
|
|
9
|
+
import { aggregateMonteCarlo, runMonteCarloPaths } from './run.js';
|
|
10
|
+
export function comparePlansOnSharedMarketPaths(plans, opts) {
|
|
11
|
+
return {
|
|
12
|
+
seed: opts.seed,
|
|
13
|
+
pathCount: opts.pathCount,
|
|
14
|
+
rows: plans.map((entry) => ({
|
|
15
|
+
id: entry.id,
|
|
16
|
+
label: entry.label,
|
|
17
|
+
summary: aggregateMonteCarlo(runMonteCarloPaths(entry.plan, {
|
|
18
|
+
startYear: opts.startYear,
|
|
19
|
+
taxCalculator: entry.taxCalculator ?? opts.taxCalculator,
|
|
20
|
+
model: createMarketModel(opts.model),
|
|
21
|
+
seed: opts.seed,
|
|
22
|
+
pathCount: opts.pathCount,
|
|
23
|
+
stochasticLongevity: opts.stochasticLongevity,
|
|
24
|
+
ltcShock: opts.ltcShock,
|
|
25
|
+
})),
|
|
26
|
+
})),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Survival-percentile planning ages (spending-paths & SWR-lenses plan, Goal 4).
|
|
3
|
+
*
|
|
4
|
+
* "Plan to the age you have a 25% (or 10%) chance of reaching" is standard
|
|
5
|
+
* longevity-risk advice — the refreshed Actuaries Longevity Illustrator is the
|
|
6
|
+
* canonical tool that operationalizes it. These helpers compute that age from
|
|
7
|
+
* the same SSA 2022 q(x) derivation the stochastic-longevity Monte Carlo uses
|
|
8
|
+
* (mortality.ts), so every surface quotes one mortality table.
|
|
9
|
+
*
|
|
10
|
+
* The optional health adjustment is a proportional-hazards transform: annual
|
|
11
|
+
* survival p(x) is raised to a hazard power h (q' = 1 − (1−q)^h; h > 1 = worse
|
|
12
|
+
* health). `hazardForExpectancyMultiplier` converts the longevity wizard's
|
|
13
|
+
* remaining-years multiplier (its sourced smoking/health/activity factors,
|
|
14
|
+
* see ../../longevity/factors.ts) into that hazard power, so the percentile
|
|
15
|
+
* picker can reuse the questionnaire instead of a second unsourced factor set.
|
|
16
|
+
*/
|
|
17
|
+
import { type Sex } from './mortality.js';
|
|
18
|
+
/**
|
|
19
|
+
* Probability someone `currentAge` today is still alive at `targetAge`
|
|
20
|
+
* (product of one-year survivals over integer ages). 1 for targetAge ≤ current.
|
|
21
|
+
*/
|
|
22
|
+
export declare function survivalProbabilityTo(currentAge: number, sex: Sex, targetAge: number, hazard?: number): number;
|
|
23
|
+
/**
|
|
24
|
+
* The oldest integer age this person still reaches with probability ≥ pct/100 —
|
|
25
|
+
* "the age I have a `pct`% chance of reaching". Monotone: smaller pct ⇒ older
|
|
26
|
+
* age. Always ≥ the current age (you have already reached it) and ≤ MAX_AGE + 1
|
|
27
|
+
* (the table forces death by then).
|
|
28
|
+
*/
|
|
29
|
+
export declare function survivalPercentileAge(currentAge: number, sex: Sex, pct: number, hazard?: number): number;
|
|
30
|
+
export interface SurvivalPerson {
|
|
31
|
+
age: number;
|
|
32
|
+
sex: Sex;
|
|
33
|
+
hazard?: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Joint ("either of us") percentile age on the primary person's age clock: the
|
|
37
|
+
* oldest primary age A such that P(at least one member alive when the primary
|
|
38
|
+
* would be A) ≥ pct/100, assuming independent lifetimes —
|
|
39
|
+
* P = 1 − (1 − S_primary)(1 − S_partner). Always ≥ the single-life answer.
|
|
40
|
+
*/
|
|
41
|
+
export declare function jointSurvivalPercentileAge(primary: SurvivalPerson, partner: SurvivalPerson, pct: number): number;
|
|
42
|
+
/**
|
|
43
|
+
* Convert a remaining-years multiplier `m` (the longevity questionnaire's
|
|
44
|
+
* applied multiplier: m < 1 = shorter expectancy) into the proportional-hazards
|
|
45
|
+
* power whose adjusted expectancy at this age is m × the SSA baseline, solved
|
|
46
|
+
* by bisection. m = 1 returns ~1 (identity); results are clamped to a sane
|
|
47
|
+
* hazard range so extreme questionnaire answers cannot degenerate the curve.
|
|
48
|
+
*/
|
|
49
|
+
export declare function hazardForExpectancyMultiplier(age: number, sex: Sex, m: number): number;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Survival-percentile planning ages (spending-paths & SWR-lenses plan, Goal 4).
|
|
3
|
+
*
|
|
4
|
+
* "Plan to the age you have a 25% (or 10%) chance of reaching" is standard
|
|
5
|
+
* longevity-risk advice — the refreshed Actuaries Longevity Illustrator is the
|
|
6
|
+
* canonical tool that operationalizes it. These helpers compute that age from
|
|
7
|
+
* the same SSA 2022 q(x) derivation the stochastic-longevity Monte Carlo uses
|
|
8
|
+
* (mortality.ts), so every surface quotes one mortality table.
|
|
9
|
+
*
|
|
10
|
+
* The optional health adjustment is a proportional-hazards transform: annual
|
|
11
|
+
* survival p(x) is raised to a hazard power h (q' = 1 − (1−q)^h; h > 1 = worse
|
|
12
|
+
* health). `hazardForExpectancyMultiplier` converts the longevity wizard's
|
|
13
|
+
* remaining-years multiplier (its sourced smoking/health/activity factors,
|
|
14
|
+
* see ../../longevity/factors.ts) into that hazard power, so the percentile
|
|
15
|
+
* picker can reuse the questionnaire instead of a second unsourced factor set.
|
|
16
|
+
*/
|
|
17
|
+
import { baselineRemainingYears } from '../longevity/ssaPeriod2022.js';
|
|
18
|
+
import { annualMortality, MAX_AGE } from './mortality.js';
|
|
19
|
+
/** One-year survival probability at integer age, under a hazard power. */
|
|
20
|
+
function annualSurvival(age, sex, hazard) {
|
|
21
|
+
const q = annualMortality(age, sex);
|
|
22
|
+
if (q >= 1)
|
|
23
|
+
return 0;
|
|
24
|
+
return Math.pow(1 - q, hazard);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Probability someone `currentAge` today is still alive at `targetAge`
|
|
28
|
+
* (product of one-year survivals over integer ages). 1 for targetAge ≤ current.
|
|
29
|
+
*/
|
|
30
|
+
export function survivalProbabilityTo(currentAge, sex, targetAge, hazard = 1) {
|
|
31
|
+
const from = Math.floor(Math.max(currentAge, 0));
|
|
32
|
+
const to = Math.floor(targetAge);
|
|
33
|
+
let s = 1;
|
|
34
|
+
for (let age = from; age < to; age++) {
|
|
35
|
+
s *= annualSurvival(age, sex, hazard);
|
|
36
|
+
if (s <= 0)
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
return s;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The oldest integer age this person still reaches with probability ≥ pct/100 —
|
|
43
|
+
* "the age I have a `pct`% chance of reaching". Monotone: smaller pct ⇒ older
|
|
44
|
+
* age. Always ≥ the current age (you have already reached it) and ≤ MAX_AGE + 1
|
|
45
|
+
* (the table forces death by then).
|
|
46
|
+
*/
|
|
47
|
+
export function survivalPercentileAge(currentAge, sex, pct, hazard = 1) {
|
|
48
|
+
const threshold = Math.min(Math.max(pct, 0.1), 100) / 100;
|
|
49
|
+
const from = Math.floor(Math.max(currentAge, 0));
|
|
50
|
+
let s = 1;
|
|
51
|
+
let best = from;
|
|
52
|
+
for (let age = from; age <= MAX_AGE; age++) {
|
|
53
|
+
s *= annualSurvival(age, sex, hazard);
|
|
54
|
+
if (s >= threshold)
|
|
55
|
+
best = age + 1;
|
|
56
|
+
else
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
return best;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Joint ("either of us") percentile age on the primary person's age clock: the
|
|
63
|
+
* oldest primary age A such that P(at least one member alive when the primary
|
|
64
|
+
* would be A) ≥ pct/100, assuming independent lifetimes —
|
|
65
|
+
* P = 1 − (1 − S_primary)(1 − S_partner). Always ≥ the single-life answer.
|
|
66
|
+
*/
|
|
67
|
+
export function jointSurvivalPercentileAge(primary, partner, pct) {
|
|
68
|
+
const threshold = Math.min(Math.max(pct, 0.1), 100) / 100;
|
|
69
|
+
const from = Math.floor(Math.max(primary.age, 0));
|
|
70
|
+
const partnerFrom = Math.floor(Math.max(partner.age, 0));
|
|
71
|
+
let sPrimary = 1;
|
|
72
|
+
let sPartner = 1;
|
|
73
|
+
let best = from;
|
|
74
|
+
// Walk both survival curves on the primary's clock; the partner's own clock
|
|
75
|
+
// is offset by the age difference.
|
|
76
|
+
for (let t = 0; from + t <= MAX_AGE + 1; t++) {
|
|
77
|
+
const eitherAlive = 1 - (1 - sPrimary) * (1 - sPartner);
|
|
78
|
+
if (eitherAlive >= threshold)
|
|
79
|
+
best = from + t;
|
|
80
|
+
else
|
|
81
|
+
break;
|
|
82
|
+
sPrimary *= annualSurvival(from + t, primary.sex, primary.hazard ?? 1);
|
|
83
|
+
sPartner *= annualSurvival(partnerFrom + t, partner.sex, partner.hazard ?? 1);
|
|
84
|
+
if (sPrimary <= 0 && sPartner <= 0)
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
return best;
|
|
88
|
+
}
|
|
89
|
+
/** Curtate-style remaining life expectancy under a hazard power: 0.5 + Σ S(t). */
|
|
90
|
+
function expectancyUnderHazard(age, sex, hazard) {
|
|
91
|
+
const from = Math.floor(Math.max(age, 0));
|
|
92
|
+
let s = 1;
|
|
93
|
+
let e = 0.5;
|
|
94
|
+
for (let a = from; a <= MAX_AGE; a++) {
|
|
95
|
+
s *= annualSurvival(a, sex, hazard);
|
|
96
|
+
e += s;
|
|
97
|
+
if (s <= 1e-12)
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
return e;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Convert a remaining-years multiplier `m` (the longevity questionnaire's
|
|
104
|
+
* applied multiplier: m < 1 = shorter expectancy) into the proportional-hazards
|
|
105
|
+
* power whose adjusted expectancy at this age is m × the SSA baseline, solved
|
|
106
|
+
* by bisection. m = 1 returns ~1 (identity); results are clamped to a sane
|
|
107
|
+
* hazard range so extreme questionnaire answers cannot degenerate the curve.
|
|
108
|
+
*/
|
|
109
|
+
export function hazardForExpectancyMultiplier(age, sex, m) {
|
|
110
|
+
const target = Math.max(0.1, m) * baselineRemainingYears(age, sex);
|
|
111
|
+
let lo = 0.2; // far healthier than the table
|
|
112
|
+
let hi = 8; // far sicker than the table
|
|
113
|
+
// Expectancy is strictly decreasing in the hazard power.
|
|
114
|
+
if (expectancyUnderHazard(age, sex, lo) <= target)
|
|
115
|
+
return lo;
|
|
116
|
+
if (expectancyUnderHazard(age, sex, hi) >= target)
|
|
117
|
+
return hi;
|
|
118
|
+
for (let i = 0; i < 40; i++) {
|
|
119
|
+
const mid = (lo + hi) / 2;
|
|
120
|
+
if (expectancyUnderHazard(age, sex, mid) > target)
|
|
121
|
+
lo = mid;
|
|
122
|
+
else
|
|
123
|
+
hi = mid;
|
|
124
|
+
}
|
|
125
|
+
return (lo + hi) / 2;
|
|
126
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { RealYieldCurve } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Embedded TIPS real-yield curve snapshot (par real yields, percent per year).
|
|
4
|
+
*
|
|
5
|
+
* Source: U.S. Treasury "Daily Treasury Par Real Yield Curve Rates"
|
|
6
|
+
* (https://home.treasury.gov/resource-center/data-chart-center/interest-rates),
|
|
7
|
+
* end-of-June 2026 readings rounded to 5bp. The Treasury publishes 5-, 7-, 10-,
|
|
8
|
+
* 20-, and 30-year par real yields; the ladder engine interpolates between
|
|
9
|
+
* points and holds the endpoints flat outside them.
|
|
10
|
+
*
|
|
11
|
+
* This snapshot is the app's offline posture: every ladder quote and funded
|
|
12
|
+
* ratio works without a network call. Refresh cadence: annually with the
|
|
13
|
+
* parameter packs (see DOCS/maintenance-schedule.md), or opt-in per-session
|
|
14
|
+
* via the FedInvest live-price fetch (engine/ladder/fedInvest.ts), which
|
|
15
|
+
* never replaces this embedded default.
|
|
16
|
+
*/
|
|
17
|
+
export declare const REAL_YIELD_CURVE_2026: RealYieldCurve;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedded TIPS real-yield curve snapshot (par real yields, percent per year).
|
|
3
|
+
*
|
|
4
|
+
* Source: U.S. Treasury "Daily Treasury Par Real Yield Curve Rates"
|
|
5
|
+
* (https://home.treasury.gov/resource-center/data-chart-center/interest-rates),
|
|
6
|
+
* end-of-June 2026 readings rounded to 5bp. The Treasury publishes 5-, 7-, 10-,
|
|
7
|
+
* 20-, and 30-year par real yields; the ladder engine interpolates between
|
|
8
|
+
* points and holds the endpoints flat outside them.
|
|
9
|
+
*
|
|
10
|
+
* This snapshot is the app's offline posture: every ladder quote and funded
|
|
11
|
+
* ratio works without a network call. Refresh cadence: annually with the
|
|
12
|
+
* parameter packs (see DOCS/maintenance-schedule.md), or opt-in per-session
|
|
13
|
+
* via the FedInvest live-price fetch (engine/ladder/fedInvest.ts), which
|
|
14
|
+
* never replaces this embedded default.
|
|
15
|
+
*/
|
|
16
|
+
export const REAL_YIELD_CURVE_2026 = {
|
|
17
|
+
asOfIso: '2026-06-30',
|
|
18
|
+
source: 'U.S. Treasury Daily Par Real Yield Curve Rates',
|
|
19
|
+
points: [
|
|
20
|
+
{ maturityYears: 5, realYieldPct: 1.85 },
|
|
21
|
+
{ maturityYears: 7, realYieldPct: 2.05 },
|
|
22
|
+
{ maturityYears: 10, realYieldPct: 2.25 },
|
|
23
|
+
{ maturityYears: 20, realYieldPct: 2.55 },
|
|
24
|
+
{ maturityYears: 30, realYieldPct: 2.7 },
|
|
25
|
+
],
|
|
26
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tax year 2026 parameter pack (post-OBBBA current law).
|
|
3
|
+
*
|
|
4
|
+
* Sources (verified June 2026) — see DOCS/domain/domain-rules-reference.md:
|
|
5
|
+
* - Brackets/deductions/AMT: IRS inflation adjustments; Tax Foundation 2026 tables
|
|
6
|
+
* - Capital gains/NIIT: IRS; Kiplinger 2026 LTCG thresholds
|
|
7
|
+
* - Limits: IRS IR news release (401(k) $24,500; IRA $7,500)
|
|
8
|
+
* - RMD: IRS Pub 590-B Uniform Lifetime Table (2022+); QCD $111,000 (2026)
|
|
9
|
+
* - Medicare: CMS 2026 ($202.90 standard Part B); IRMAA tiers per statute
|
|
10
|
+
* - Social Security: SSA 2026 COLA fact sheet
|
|
11
|
+
* - FPL: HHS 2025 guidelines (apply to 2026 ACA coverage year)
|
|
12
|
+
*/
|
|
13
|
+
import type { ParameterPack } from '../types.js';
|
|
14
|
+
export declare const year2026: ParameterPack;
|