@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,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mortality sampling for stochastic-longevity Monte Carlo (roadmap V6).
|
|
3
|
+
*
|
|
4
|
+
* Annual death probability q(x) is derived from the SSA 2022 period life
|
|
5
|
+
* expectancy table already in the repo, via the standard identity
|
|
6
|
+
* E(x) = e(x) − 0.5 = p(x)·(1 + E(x+1)) ⇒ q(x) = 1 − (e(x)−0.5)/(e(x+1)+0.5)
|
|
7
|
+
* where E is the curtate expectation and p = 1−q is one-year survival. This
|
|
8
|
+
* grounds the model in the same cited SSA data the deterministic life-
|
|
9
|
+
* expectancy estimate uses — no separate q(x) dataset to maintain.
|
|
10
|
+
*
|
|
11
|
+
* @see ../../longevity/ssaPeriod2022 (SSA Table 4C6, 2022)
|
|
12
|
+
*/
|
|
13
|
+
import type { Rng } from './rng.js';
|
|
14
|
+
export type Sex = 'male' | 'female' | 'average';
|
|
15
|
+
/** Oldest integer age in the table; everyone is forced to die by here. */
|
|
16
|
+
export declare const MAX_AGE: number;
|
|
17
|
+
/** One-year probability of death at integer age `age` for the given sex. */
|
|
18
|
+
export declare function annualMortality(age: number, sex: Sex): number;
|
|
19
|
+
/**
|
|
20
|
+
* Sample the age (last full year alive) at death for someone currently `currentAge`,
|
|
21
|
+
* walking the survival curve year by year with the path RNG. Returns an age that
|
|
22
|
+
* plays the same role as `planningAge` (alive through it, dead the next year).
|
|
23
|
+
* Deterministic given the RNG stream, so paths stay reproducible.
|
|
24
|
+
*/
|
|
25
|
+
export declare function sampleDeathAge(rng: Rng, currentAge: number, sex: Sex): number;
|
|
26
|
+
/**
|
|
27
|
+
* Joint last-survivor life expectancy at the two ages — the years until *both*
|
|
28
|
+
* are dead, assuming independent lifetimes: e = 0.5 + Σ_t [1 − (1−tp_a)(1−tp_b)].
|
|
29
|
+
* Used for stochastic-longevity analysis; IRS RMD Table II lives in the RMD module.
|
|
30
|
+
*/
|
|
31
|
+
export declare function jointLastSurvivorExpectancy(ageA: number, sexA: Sex, ageB: number, sexB: Sex): number;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mortality sampling for stochastic-longevity Monte Carlo (roadmap V6).
|
|
3
|
+
*
|
|
4
|
+
* Annual death probability q(x) is derived from the SSA 2022 period life
|
|
5
|
+
* expectancy table already in the repo, via the standard identity
|
|
6
|
+
* E(x) = e(x) − 0.5 = p(x)·(1 + E(x+1)) ⇒ q(x) = 1 − (e(x)−0.5)/(e(x+1)+0.5)
|
|
7
|
+
* where E is the curtate expectation and p = 1−q is one-year survival. This
|
|
8
|
+
* grounds the model in the same cited SSA data the deterministic life-
|
|
9
|
+
* expectancy estimate uses — no separate q(x) dataset to maintain.
|
|
10
|
+
*
|
|
11
|
+
* @see ../../longevity/ssaPeriod2022 (SSA Table 4C6, 2022)
|
|
12
|
+
*/
|
|
13
|
+
import { FEMALE, MALE } from '../longevity/ssaPeriod2022.js';
|
|
14
|
+
const AVERAGE = MALE.map((m, i) => (m + (FEMALE[i] ?? m)) / 2);
|
|
15
|
+
/** Oldest integer age in the table; everyone is forced to die by here. */
|
|
16
|
+
export const MAX_AGE = MALE.length - 1;
|
|
17
|
+
function expectancyTable(sex) {
|
|
18
|
+
return sex === 'male' ? MALE : sex === 'female' ? FEMALE : AVERAGE;
|
|
19
|
+
}
|
|
20
|
+
/** One-year probability of death at integer age `age` for the given sex. */
|
|
21
|
+
export function annualMortality(age, sex) {
|
|
22
|
+
const t = expectancyTable(sex);
|
|
23
|
+
const x = Math.floor(age);
|
|
24
|
+
if (x < 0)
|
|
25
|
+
return 0;
|
|
26
|
+
if (x >= t.length - 1)
|
|
27
|
+
return 1;
|
|
28
|
+
const survival = (t[x] - 0.5) / (t[x + 1] + 0.5);
|
|
29
|
+
return Math.min(1, Math.max(0, 1 - survival));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Sample the age (last full year alive) at death for someone currently `currentAge`,
|
|
33
|
+
* walking the survival curve year by year with the path RNG. Returns an age that
|
|
34
|
+
* plays the same role as `planningAge` (alive through it, dead the next year).
|
|
35
|
+
* Deterministic given the RNG stream, so paths stay reproducible.
|
|
36
|
+
*/
|
|
37
|
+
export function sampleDeathAge(rng, currentAge, sex) {
|
|
38
|
+
let age = Math.floor(Math.max(currentAge, 0));
|
|
39
|
+
while (age < MAX_AGE) {
|
|
40
|
+
if (rng.next() < annualMortality(age, sex))
|
|
41
|
+
return age;
|
|
42
|
+
age++;
|
|
43
|
+
}
|
|
44
|
+
return MAX_AGE;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Joint last-survivor life expectancy at the two ages — the years until *both*
|
|
48
|
+
* are dead, assuming independent lifetimes: e = 0.5 + Σ_t [1 − (1−tp_a)(1−tp_b)].
|
|
49
|
+
* Used for stochastic-longevity analysis; IRS RMD Table II lives in the RMD module.
|
|
50
|
+
*/
|
|
51
|
+
export function jointLastSurvivorExpectancy(ageA, sexA, ageB, sexB) {
|
|
52
|
+
let expectancy = 0.5;
|
|
53
|
+
let survivalA = 1;
|
|
54
|
+
let survivalB = 1;
|
|
55
|
+
for (let t = 1; t <= MAX_AGE + 1; t++) {
|
|
56
|
+
survivalA *= 1 - annualMortality(ageA + t - 1, sexA);
|
|
57
|
+
survivalB *= 1 - annualMortality(ageB + t - 1, sexB);
|
|
58
|
+
expectancy += 1 - (1 - survivalA) * (1 - survivalB);
|
|
59
|
+
}
|
|
60
|
+
return expectancy;
|
|
61
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Risk-based guardrail threshold solver (risk-based-guardrails plan, step 2).
|
|
3
|
+
*
|
|
4
|
+
* Translates a target probability-of-success band into dollar-denominated
|
|
5
|
+
* portfolio thresholds: "if the portfolio falls below $X, cut spending; above
|
|
6
|
+
* $Z, raise it." For a candidate balance level the solver re-evaluates the
|
|
7
|
+
* plan's Monte Carlo success probability with every investable balance scaled
|
|
8
|
+
* to that level — always on the same seeded market paths, so each probe sees
|
|
9
|
+
* identical market histories and the search is deterministic. Bisection over
|
|
10
|
+
* the balance scale finds where success crosses each band edge; a second
|
|
11
|
+
* bisection over the spending level sizes the adjustment that would bring
|
|
12
|
+
* success back to the middle of the band.
|
|
13
|
+
*
|
|
14
|
+
* The evaluated variant is the plan's *fixed-target* spending (any guardrail
|
|
15
|
+
* policy stripped): the question each threshold answers is "what is the chance
|
|
16
|
+
* the current spending plan succeeds with no future adjustments" — the public
|
|
17
|
+
* risk-based guardrail methodology (Kitces 2025; Guyton–Klinger critiques by
|
|
18
|
+
* Pfau/Jeske motivate the balance-based trigger). Nested full re-simulation
|
|
19
|
+
* per probe is the budgeted cost; callers keep pathCount modest and run this
|
|
20
|
+
* in a worker.
|
|
21
|
+
*/
|
|
22
|
+
import type { Plan } from '../model/plan.js';
|
|
23
|
+
import type { TaxCalculator } from '../projection/types.js';
|
|
24
|
+
import type { LtcShockParams } from './ltcShock.js';
|
|
25
|
+
import type { MarketModelConfig } from './marketModels.js';
|
|
26
|
+
export declare const DEFAULT_TARGET_SUCCESS_LOWER_PCT = 70;
|
|
27
|
+
export declare const DEFAULT_TARGET_SUCCESS_UPPER_PCT = 95;
|
|
28
|
+
export interface RiskBasedGuardrailSolveOptions {
|
|
29
|
+
startYear: number;
|
|
30
|
+
taxCalculator: TaxCalculator;
|
|
31
|
+
model: MarketModelConfig;
|
|
32
|
+
pathCount: number;
|
|
33
|
+
seed: number;
|
|
34
|
+
stochasticLongevity?: boolean;
|
|
35
|
+
ltcShock?: LtcShockParams | null;
|
|
36
|
+
/** Success-band edges as percents; default to the policy on the plan, then 70/95. */
|
|
37
|
+
lowerBandPct?: number;
|
|
38
|
+
upperBandPct?: number;
|
|
39
|
+
/** Called after each completed Monte Carlo probe (drives progress UI). */
|
|
40
|
+
onProbeDone?: (completed: number, total: number) => void;
|
|
41
|
+
}
|
|
42
|
+
export interface RiskBasedThreshold {
|
|
43
|
+
/** Balance level as a fraction of today's investable portfolio. */
|
|
44
|
+
balanceFrac: number;
|
|
45
|
+
/** The same level in dollars (today's dollars). */
|
|
46
|
+
balanceDollars: number;
|
|
47
|
+
/** Success probability re-evaluated at the threshold (0..1). */
|
|
48
|
+
successAtThreshold: number;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Why a band edge has no threshold. 'always-above-band': success stays at or
|
|
52
|
+
* above the edge even at the bottom of the search bracket — the plan is safe
|
|
53
|
+
* at any realistic balance and needs no trigger. 'never-reaches-band': success
|
|
54
|
+
* stays below the edge even at the top of the bracket — the plan is underfunded
|
|
55
|
+
* for this band at any realistic balance, which is the opposite of safe.
|
|
56
|
+
*/
|
|
57
|
+
export type RiskBasedThresholdOutcome = 'solved' | 'always-above-band' | 'never-reaches-band';
|
|
58
|
+
export interface RiskBasedAdjustment {
|
|
59
|
+
/** Spending multiplier that restores success to the recovery target at the threshold. */
|
|
60
|
+
spendingMultiplier: number;
|
|
61
|
+
/** Adjustment size in today's dollars per year (positive for both cut and raise). */
|
|
62
|
+
annualDollars: number;
|
|
63
|
+
/** The same per month. */
|
|
64
|
+
monthlyDollars: number;
|
|
65
|
+
/** Success probability after the adjustment, evaluated at the threshold balance (0..1). */
|
|
66
|
+
successAfter: number;
|
|
67
|
+
}
|
|
68
|
+
export interface RiskBasedGuardrailSolution {
|
|
69
|
+
/** Sum of investable balances today — the base the fractions apply to. */
|
|
70
|
+
startingInvestable: number;
|
|
71
|
+
/** Success probability of the fixed-target plan at today's balances (0..1). */
|
|
72
|
+
successAtCurrent: number;
|
|
73
|
+
lowerBandPct: number;
|
|
74
|
+
upperBandPct: number;
|
|
75
|
+
/**
|
|
76
|
+
* Balance below which success falls under the lower band edge. Null when no
|
|
77
|
+
* crossing exists inside the search bracket; `lowerOutcome` says which way —
|
|
78
|
+
* always safe vs underfunded (the two must not be conflated).
|
|
79
|
+
*/
|
|
80
|
+
lower: RiskBasedThreshold | null;
|
|
81
|
+
lowerOutcome: RiskBasedThresholdOutcome;
|
|
82
|
+
/** Balance above which success clears the upper band edge; null when no crossing exists. */
|
|
83
|
+
upper: RiskBasedThreshold | null;
|
|
84
|
+
upperOutcome: RiskBasedThresholdOutcome;
|
|
85
|
+
/** Spending cut at the lower threshold that restores success to the band midpoint. */
|
|
86
|
+
suggestedCut: RiskBasedAdjustment | null;
|
|
87
|
+
/** Spending raise at the upper threshold that brings success back down to the band midpoint. */
|
|
88
|
+
suggestedRaise: RiskBasedAdjustment | null;
|
|
89
|
+
pathCount: number;
|
|
90
|
+
seed: number;
|
|
91
|
+
}
|
|
92
|
+
export declare function startingInvestableOf(plan: Plan): number;
|
|
93
|
+
export declare function solveRiskBasedGuardrails(plan: Plan, opts: RiskBasedGuardrailSolveOptions): RiskBasedGuardrailSolution;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Risk-based guardrail threshold solver (risk-based-guardrails plan, step 2).
|
|
3
|
+
*
|
|
4
|
+
* Translates a target probability-of-success band into dollar-denominated
|
|
5
|
+
* portfolio thresholds: "if the portfolio falls below $X, cut spending; above
|
|
6
|
+
* $Z, raise it." For a candidate balance level the solver re-evaluates the
|
|
7
|
+
* plan's Monte Carlo success probability with every investable balance scaled
|
|
8
|
+
* to that level — always on the same seeded market paths, so each probe sees
|
|
9
|
+
* identical market histories and the search is deterministic. Bisection over
|
|
10
|
+
* the balance scale finds where success crosses each band edge; a second
|
|
11
|
+
* bisection over the spending level sizes the adjustment that would bring
|
|
12
|
+
* success back to the middle of the band.
|
|
13
|
+
*
|
|
14
|
+
* The evaluated variant is the plan's *fixed-target* spending (any guardrail
|
|
15
|
+
* policy stripped): the question each threshold answers is "what is the chance
|
|
16
|
+
* the current spending plan succeeds with no future adjustments" — the public
|
|
17
|
+
* risk-based guardrail methodology (Kitces 2025; Guyton–Klinger critiques by
|
|
18
|
+
* Pfau/Jeske motivate the balance-based trigger). Nested full re-simulation
|
|
19
|
+
* per probe is the budgeted cost; callers keep pathCount modest and run this
|
|
20
|
+
* in a worker.
|
|
21
|
+
*/
|
|
22
|
+
import { createMarketModel } from './marketModels.js';
|
|
23
|
+
import { runMonteCarloPaths } from './run.js';
|
|
24
|
+
export const DEFAULT_TARGET_SUCCESS_LOWER_PCT = 70;
|
|
25
|
+
export const DEFAULT_TARGET_SUCCESS_UPPER_PCT = 95;
|
|
26
|
+
/** Balance-scale search bracket: 2% to 400% of today's portfolio. */
|
|
27
|
+
const MIN_BALANCE_FRAC = 0.02;
|
|
28
|
+
const MAX_BALANCE_FRAC = 4;
|
|
29
|
+
/** Bisection iterations: balance resolution ~0.4% of the bracket, spending ~0.3%. */
|
|
30
|
+
const BALANCE_BISECTION_ITERATIONS = 10;
|
|
31
|
+
const SPENDING_BISECTION_ITERATIONS = 8;
|
|
32
|
+
const INVESTABLE_ACCOUNT_TYPES = new Set(['taxable', 'equityComp', 'traditional', 'roth', 'hsa', 'cash']);
|
|
33
|
+
function isInvestable(account) {
|
|
34
|
+
return INVESTABLE_ACCOUNT_TYPES.has(account.type);
|
|
35
|
+
}
|
|
36
|
+
export function startingInvestableOf(plan) {
|
|
37
|
+
let total = 0;
|
|
38
|
+
for (const account of plan.accounts) {
|
|
39
|
+
if (isInvestable(account) && 'balance' in account)
|
|
40
|
+
total += account.balance;
|
|
41
|
+
}
|
|
42
|
+
return total;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Scale every investable balance (and its basis fields proportionally, so the
|
|
46
|
+
* tax character of the portfolio is unchanged) to `frac` of today's level.
|
|
47
|
+
*/
|
|
48
|
+
function scaleInvestableBalances(plan, frac) {
|
|
49
|
+
return {
|
|
50
|
+
...plan,
|
|
51
|
+
accounts: plan.accounts.map((account) => {
|
|
52
|
+
if (!isInvestable(account) || !('balance' in account))
|
|
53
|
+
return account;
|
|
54
|
+
const scaled = { ...account, balance: account.balance * frac };
|
|
55
|
+
if ('costBasis' in scaled)
|
|
56
|
+
scaled.costBasis = scaled.costBasis * frac;
|
|
57
|
+
if ('contributionBasis' in scaled && scaled.contributionBasis !== undefined) {
|
|
58
|
+
scaled.contributionBasis = scaled.contributionBasis * frac;
|
|
59
|
+
}
|
|
60
|
+
if ('nondeductibleBasis' in scaled && scaled.nondeductibleBasis !== undefined) {
|
|
61
|
+
scaled.nondeductibleBasis = scaled.nondeductibleBasis * frac;
|
|
62
|
+
}
|
|
63
|
+
return scaled;
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function scaleTargetSpending(plan, multiplier) {
|
|
68
|
+
return {
|
|
69
|
+
...plan,
|
|
70
|
+
expenses: { ...plan.expenses, baseAnnual: Math.max(0, plan.expenses.baseAnnual * multiplier) },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function solveRiskBasedGuardrails(plan, opts) {
|
|
74
|
+
const policy = plan.expenses.spendingPolicy;
|
|
75
|
+
const lowerBandPct = opts.lowerBandPct ?? policy?.targetSuccessLowerPct ?? DEFAULT_TARGET_SUCCESS_LOWER_PCT;
|
|
76
|
+
const upperBandPct = opts.upperBandPct ?? policy?.targetSuccessUpperPct ?? DEFAULT_TARGET_SUCCESS_UPPER_PCT;
|
|
77
|
+
if (!(lowerBandPct < upperBandPct)) {
|
|
78
|
+
throw new Error(`The success band is inverted (${lowerBandPct}–${upperBandPct}%): the cut edge must be below the raise edge.`);
|
|
79
|
+
}
|
|
80
|
+
const recoveryTarget = (lowerBandPct + upperBandPct) / 2 / 100;
|
|
81
|
+
// The evaluated variant keeps spending fixed: thresholds describe the chance
|
|
82
|
+
// the *current* plan succeeds without future adjustments.
|
|
83
|
+
const fixedTargetPlan = { ...plan, expenses: { ...plan.expenses, spendingPolicy: undefined } };
|
|
84
|
+
const startingInvestable = startingInvestableOf(plan);
|
|
85
|
+
const totalProbes = 1 + 2 * BALANCE_BISECTION_ITERATIONS + 2 + 2 * (SPENDING_BISECTION_ITERATIONS + 1);
|
|
86
|
+
let completedProbes = 0;
|
|
87
|
+
const successOf = (variant) => {
|
|
88
|
+
const result = runMonteCarloPaths(variant, {
|
|
89
|
+
startYear: opts.startYear,
|
|
90
|
+
taxCalculator: opts.taxCalculator,
|
|
91
|
+
model: createMarketModel(opts.model),
|
|
92
|
+
seed: opts.seed,
|
|
93
|
+
pathCount: opts.pathCount,
|
|
94
|
+
stochasticLongevity: opts.stochasticLongevity,
|
|
95
|
+
ltcShock: opts.ltcShock,
|
|
96
|
+
});
|
|
97
|
+
completedProbes++;
|
|
98
|
+
opts.onProbeDone?.(completedProbes, totalProbes);
|
|
99
|
+
if (result.paths.length === 0)
|
|
100
|
+
return 0;
|
|
101
|
+
let successes = 0;
|
|
102
|
+
for (const p of result.paths)
|
|
103
|
+
if (p.depletionYear === null)
|
|
104
|
+
successes++;
|
|
105
|
+
return successes / result.paths.length;
|
|
106
|
+
};
|
|
107
|
+
const successByFrac = new Map();
|
|
108
|
+
const successAtFrac = (frac) => {
|
|
109
|
+
const key = Math.round(frac * 1e6);
|
|
110
|
+
const cached = successByFrac.get(key);
|
|
111
|
+
if (cached !== undefined)
|
|
112
|
+
return cached;
|
|
113
|
+
const success = successOf(scaleInvestableBalances(fixedTargetPlan, frac));
|
|
114
|
+
successByFrac.set(key, success);
|
|
115
|
+
return success;
|
|
116
|
+
};
|
|
117
|
+
const successAtCurrent = successAtFrac(1);
|
|
118
|
+
// Success is monotone nondecreasing in the balance scale on shared paths, so
|
|
119
|
+
// each band edge is a single crossing findable by bisection. The solved frac
|
|
120
|
+
// is the *lowest* balance still inside the band for the lower edge, and the
|
|
121
|
+
// lowest balance above the band for the upper edge. A missing crossing keeps
|
|
122
|
+
// its reason: staying above the edge everywhere (safe) is the opposite of
|
|
123
|
+
// never reaching it (underfunded), and callers must not conflate them.
|
|
124
|
+
const solveCrossing = (targetPct) => {
|
|
125
|
+
const target = targetPct / 100;
|
|
126
|
+
if (successAtFrac(MIN_BALANCE_FRAC) >= target)
|
|
127
|
+
return { threshold: null, outcome: 'always-above-band' };
|
|
128
|
+
if (successAtFrac(MAX_BALANCE_FRAC) < target)
|
|
129
|
+
return { threshold: null, outcome: 'never-reaches-band' };
|
|
130
|
+
let lo = MIN_BALANCE_FRAC;
|
|
131
|
+
let hi = MAX_BALANCE_FRAC;
|
|
132
|
+
for (let i = 0; i < BALANCE_BISECTION_ITERATIONS; i++) {
|
|
133
|
+
const mid = (lo + hi) / 2;
|
|
134
|
+
if (successAtFrac(mid) >= target)
|
|
135
|
+
hi = mid;
|
|
136
|
+
else
|
|
137
|
+
lo = mid;
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
threshold: { balanceFrac: hi, balanceDollars: hi * startingInvestable, successAtThreshold: successAtFrac(hi) },
|
|
141
|
+
outcome: 'solved',
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
const lowerCrossing = solveCrossing(lowerBandPct);
|
|
145
|
+
const upperCrossing = solveCrossing(upperBandPct);
|
|
146
|
+
const lower = lowerCrossing.threshold;
|
|
147
|
+
const upper = upperCrossing.threshold;
|
|
148
|
+
// Size the adjustment: at the threshold balance, bisect the target-spending
|
|
149
|
+
// multiplier until success returns to the middle of the band. The floor of
|
|
150
|
+
// the cut search respects the plan's required spending floor.
|
|
151
|
+
const solveAdjustment = (threshold, direction) => {
|
|
152
|
+
if (!threshold || plan.expenses.baseAnnual <= 0)
|
|
153
|
+
return null;
|
|
154
|
+
const atThreshold = scaleInvestableBalances(fixedTargetPlan, threshold.balanceFrac);
|
|
155
|
+
const requiredFloorMultiplier = Math.min(1, (plan.expenses.requiredAnnual ?? 0) / plan.expenses.baseAnnual);
|
|
156
|
+
const minMultiplier = direction === 'cut' ? Math.max(0.3, requiredFloorMultiplier) : 1;
|
|
157
|
+
const maxMultiplier = direction === 'cut' ? 1 : 2;
|
|
158
|
+
const successAtSpend = (m) => successOf(scaleTargetSpending(atThreshold, m));
|
|
159
|
+
// Success falls as spending rises: check the target is reachable inside the bracket.
|
|
160
|
+
const bestCase = successAtSpend(direction === 'cut' ? minMultiplier : maxMultiplier);
|
|
161
|
+
if (direction === 'cut' && bestCase < recoveryTarget)
|
|
162
|
+
return null;
|
|
163
|
+
if (direction === 'raise' && bestCase > recoveryTarget) {
|
|
164
|
+
// Even a 2× raise keeps success above the midpoint; report the bracket max.
|
|
165
|
+
const annual = plan.expenses.baseAnnual * (maxMultiplier - 1);
|
|
166
|
+
return { spendingMultiplier: maxMultiplier, annualDollars: annual, monthlyDollars: annual / 12, successAfter: bestCase };
|
|
167
|
+
}
|
|
168
|
+
let lo = minMultiplier;
|
|
169
|
+
let hi = maxMultiplier;
|
|
170
|
+
for (let i = 0; i < SPENDING_BISECTION_ITERATIONS; i++) {
|
|
171
|
+
const mid = (lo + hi) / 2;
|
|
172
|
+
if (successAtSpend(mid) >= recoveryTarget)
|
|
173
|
+
lo = mid;
|
|
174
|
+
else
|
|
175
|
+
hi = mid;
|
|
176
|
+
}
|
|
177
|
+
const multiplier = lo;
|
|
178
|
+
const annual = Math.abs(1 - multiplier) * plan.expenses.baseAnnual;
|
|
179
|
+
return {
|
|
180
|
+
spendingMultiplier: multiplier,
|
|
181
|
+
annualDollars: annual,
|
|
182
|
+
monthlyDollars: annual / 12,
|
|
183
|
+
successAfter: successAtSpend(multiplier),
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
return {
|
|
187
|
+
startingInvestable,
|
|
188
|
+
successAtCurrent,
|
|
189
|
+
lowerBandPct,
|
|
190
|
+
upperBandPct,
|
|
191
|
+
lower,
|
|
192
|
+
lowerOutcome: lowerCrossing.outcome,
|
|
193
|
+
upper,
|
|
194
|
+
upperOutcome: upperCrossing.outcome,
|
|
195
|
+
suggestedCut: solveAdjustment(lower, 'cut'),
|
|
196
|
+
suggestedRaise: solveAdjustment(upper, 'raise'),
|
|
197
|
+
pathCount: opts.pathCount,
|
|
198
|
+
seed: opts.seed,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seedable RNG for Monte Carlo (roadmap V4). Engine purity requires injected
|
|
3
|
+
* randomness; mulberry32 is fast, tiny, and good enough for simulation
|
|
4
|
+
* sampling (not cryptography). Per-path seeds are derived from (seed, index)
|
|
5
|
+
* so results are reproducible regardless of how paths are split across
|
|
6
|
+
* workers.
|
|
7
|
+
*/
|
|
8
|
+
export interface Rng {
|
|
9
|
+
/** Uniform in [0, 1). */
|
|
10
|
+
next(): number;
|
|
11
|
+
/** Standard normal (Box–Muller). */
|
|
12
|
+
nextNormal(): number;
|
|
13
|
+
/** Uniform integer in [0, n). */
|
|
14
|
+
nextInt(n: number): number;
|
|
15
|
+
}
|
|
16
|
+
export declare function createRng(seed: number): Rng;
|
|
17
|
+
/** SplitMix32-style hash so path i's stream is independent of path i−1's length. */
|
|
18
|
+
export declare function derivePathSeed(seed: number, pathIndex: number): number;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seedable RNG for Monte Carlo (roadmap V4). Engine purity requires injected
|
|
3
|
+
* randomness; mulberry32 is fast, tiny, and good enough for simulation
|
|
4
|
+
* sampling (not cryptography). Per-path seeds are derived from (seed, index)
|
|
5
|
+
* so results are reproducible regardless of how paths are split across
|
|
6
|
+
* workers.
|
|
7
|
+
*/
|
|
8
|
+
export function createRng(seed) {
|
|
9
|
+
let a = seed >>> 0;
|
|
10
|
+
const next = () => {
|
|
11
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
12
|
+
let t = a;
|
|
13
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
14
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
15
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
16
|
+
};
|
|
17
|
+
let spareNormal = null;
|
|
18
|
+
return {
|
|
19
|
+
next,
|
|
20
|
+
nextNormal() {
|
|
21
|
+
if (spareNormal !== null) {
|
|
22
|
+
const v = spareNormal;
|
|
23
|
+
spareNormal = null;
|
|
24
|
+
return v;
|
|
25
|
+
}
|
|
26
|
+
// Box–Muller; u clamped away from 0 so log() stays finite.
|
|
27
|
+
const u = Math.max(next(), 1e-12);
|
|
28
|
+
const v = next();
|
|
29
|
+
const r = Math.sqrt(-2 * Math.log(u));
|
|
30
|
+
spareNormal = r * Math.sin(2 * Math.PI * v);
|
|
31
|
+
return r * Math.cos(2 * Math.PI * v);
|
|
32
|
+
},
|
|
33
|
+
nextInt(n) {
|
|
34
|
+
return Math.min(n - 1, Math.floor(next() * n));
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** SplitMix32-style hash so path i's stream is independent of path i−1's length. */
|
|
39
|
+
export function derivePathSeed(seed, pathIndex) {
|
|
40
|
+
let h = (seed ^ Math.imul(pathIndex + 1, 0x9e3779b9)) >>> 0;
|
|
41
|
+
h = Math.imul(h ^ (h >>> 16), 0x21f0aaad);
|
|
42
|
+
h = Math.imul(h ^ (h >>> 15), 0x735a2d97);
|
|
43
|
+
return (h ^ (h >>> 15)) >>> 0;
|
|
44
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
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 type { Plan } from '../model/plan.js';
|
|
12
|
+
import type { TaxCalculator } from '../projection/types.js';
|
|
13
|
+
import type { LtcShockParams } from './ltcShock.js';
|
|
14
|
+
import type { MarketModel } from './marketModels.js';
|
|
15
|
+
export interface MonteCarloPathOptions {
|
|
16
|
+
startYear: number;
|
|
17
|
+
taxCalculator: TaxCalculator;
|
|
18
|
+
model: MarketModel;
|
|
19
|
+
seed: number;
|
|
20
|
+
/** Number of paths this call runs. */
|
|
21
|
+
pathCount: number;
|
|
22
|
+
/** Global index of the first path (worker partitioning); default 0. */
|
|
23
|
+
firstPathIndex?: number;
|
|
24
|
+
/** Sample each person's lifespan from the mortality table per path (roadmap V6). */
|
|
25
|
+
stochasticLongevity?: boolean;
|
|
26
|
+
/** Inject a probabilistic LTC care episode per path (roadmap V6); null = off. */
|
|
27
|
+
ltcShock?: LtcShockParams | null;
|
|
28
|
+
/** Called after each completed path (drives progress UI). */
|
|
29
|
+
onPathDone?: (completed: number) => void;
|
|
30
|
+
}
|
|
31
|
+
/** One path, reduced to what aggregation needs (full ledgers stay worker-local). */
|
|
32
|
+
export interface MonteCarloPath {
|
|
33
|
+
/** End-of-year investable balance per projection year. */
|
|
34
|
+
investableByYear: Float64Array;
|
|
35
|
+
endingInvestable: number;
|
|
36
|
+
endingNetWorth: number;
|
|
37
|
+
endingAfterTaxEstate: number;
|
|
38
|
+
depletionYear: number | null;
|
|
39
|
+
/** Sum of unfunded spending across the path. */
|
|
40
|
+
totalShortfall: number;
|
|
41
|
+
/** Sum of unfunded required-floor dollars across the path. */
|
|
42
|
+
totalRequiredShortfall: number;
|
|
43
|
+
/** Sum of missed target-lifestyle dollars across the path. */
|
|
44
|
+
totalTargetShortfall: number;
|
|
45
|
+
/**
|
|
46
|
+
* No year left the required spending floor unfunded (planning-depth roadmap
|
|
47
|
+
* §4). Distinct from `depletionYear`: a guardrail plan can deliberately cut
|
|
48
|
+
* discretionary spending yet still fund every essential.
|
|
49
|
+
*/
|
|
50
|
+
requiredFloorMet: boolean;
|
|
51
|
+
/** No year missed target-lifestyle spending (a superset failure of the floor). */
|
|
52
|
+
targetLifestyleMet: boolean;
|
|
53
|
+
/** Share of target spending funded across the path. */
|
|
54
|
+
targetAttainmentPct: number;
|
|
55
|
+
/** Average annual target shortfall across the path. */
|
|
56
|
+
averageAnnualTargetShortfall: number;
|
|
57
|
+
/** Number of years with any target shortfall. */
|
|
58
|
+
yearsBelowTarget: number;
|
|
59
|
+
idealIntended: number;
|
|
60
|
+
idealFunded: number;
|
|
61
|
+
excessIntended: number;
|
|
62
|
+
excessFunded: number;
|
|
63
|
+
flexibleGoals: {
|
|
64
|
+
funded: number;
|
|
65
|
+
partiallyFunded: number;
|
|
66
|
+
deferred: number;
|
|
67
|
+
skipped: number;
|
|
68
|
+
fundedAmount: number;
|
|
69
|
+
unfundedAmount: number;
|
|
70
|
+
};
|
|
71
|
+
guardrailActionCounts: {
|
|
72
|
+
cut: number;
|
|
73
|
+
raise: number;
|
|
74
|
+
hold: number;
|
|
75
|
+
};
|
|
76
|
+
/** Years this path spent with the discretionary layer cut below full (guardrail plans only). */
|
|
77
|
+
guardrailCutYears: number;
|
|
78
|
+
/** Longest consecutive run of cut years on this path. */
|
|
79
|
+
longestGuardrailCutSpellYears: number;
|
|
80
|
+
/** Deepest cut reached on this path, as a fraction of the discretionary layer (0..1). */
|
|
81
|
+
maxGuardrailCutDepth: number;
|
|
82
|
+
/** Ending after-tax estate cleared the plan's bequest target (today's $, inflated to path end); null = no target set. */
|
|
83
|
+
endingAboveBequestTarget: boolean | null;
|
|
84
|
+
}
|
|
85
|
+
export interface MonteCarloPathsResult {
|
|
86
|
+
startYear: number;
|
|
87
|
+
endYear: number;
|
|
88
|
+
paths: MonteCarloPath[];
|
|
89
|
+
}
|
|
90
|
+
export declare function runMonteCarloPaths(plan: Plan, opts: MonteCarloPathOptions): MonteCarloPathsResult;
|
|
91
|
+
export interface YearPercentiles {
|
|
92
|
+
year: number;
|
|
93
|
+
p10: number;
|
|
94
|
+
p25: number;
|
|
95
|
+
p50: number;
|
|
96
|
+
p75: number;
|
|
97
|
+
p90: number;
|
|
98
|
+
}
|
|
99
|
+
export interface Histogram {
|
|
100
|
+
min: number;
|
|
101
|
+
binWidth: number;
|
|
102
|
+
/** counts[i] covers [min + i·binWidth, min + (i+1)·binWidth). */
|
|
103
|
+
counts: number[];
|
|
104
|
+
}
|
|
105
|
+
export interface MonteCarloSummary {
|
|
106
|
+
pathCount: number;
|
|
107
|
+
/** Share of paths whose investable assets never deplete. */
|
|
108
|
+
successRate: number;
|
|
109
|
+
/**
|
|
110
|
+
* Share of paths that funded the required spending floor every year
|
|
111
|
+
* (planning-depth roadmap §4). Equals `successRate` unless a guardrail policy
|
|
112
|
+
* or required-floor layer is in play; then it is the more meaningful "never
|
|
113
|
+
* ran short on essentials" number.
|
|
114
|
+
*/
|
|
115
|
+
requiredFloorSuccessRate: number;
|
|
116
|
+
/** Share of paths that funded full target-lifestyle spending every year. */
|
|
117
|
+
targetLifestyleSuccessRate: number;
|
|
118
|
+
/** Path-level target-attainment distribution (1 = every target dollar funded). */
|
|
119
|
+
targetAttainmentPct: Omit<YearPercentiles, 'year'>;
|
|
120
|
+
averageAnnualTargetShortfall: number;
|
|
121
|
+
p90AverageAnnualTargetShortfall: number;
|
|
122
|
+
averageYearsBelowTarget: number;
|
|
123
|
+
idealFundingRate: number;
|
|
124
|
+
excessFundingRate: number;
|
|
125
|
+
flexibleGoals: {
|
|
126
|
+
funded: number;
|
|
127
|
+
partiallyFunded: number;
|
|
128
|
+
deferred: number;
|
|
129
|
+
skipped: number;
|
|
130
|
+
fundedAmount: number;
|
|
131
|
+
unfundedAmount: number;
|
|
132
|
+
};
|
|
133
|
+
guardrailActionCounts: {
|
|
134
|
+
cut: number;
|
|
135
|
+
raise: number;
|
|
136
|
+
hold: number;
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* Probability-and-magnitude-of-adjustment reporting (risk-based guardrails
|
|
140
|
+
* plan, Kitces 2025 framing): instead of a bare success %, report how likely
|
|
141
|
+
* a mid-course spending adjustment is, how big it tends to be, how long it
|
|
142
|
+
* lasts, and how likely the plan ends with a surplus. Cut metrics are
|
|
143
|
+
* conditional on paths that cut at least once; they are all zero for plans
|
|
144
|
+
* without a guardrail policy.
|
|
145
|
+
*/
|
|
146
|
+
adjustments: {
|
|
147
|
+
/** Share of paths with at least one cut year. */
|
|
148
|
+
pathsWithCut: number;
|
|
149
|
+
/** Share of paths with at least one raise action. */
|
|
150
|
+
pathsWithRaise: number;
|
|
151
|
+
/** Among cut paths: median deepest cut as a fraction of the discretionary layer. */
|
|
152
|
+
medianMaxCutDepth: number;
|
|
153
|
+
/** Among cut paths: 90th-percentile deepest cut. */
|
|
154
|
+
p90MaxCutDepth: number;
|
|
155
|
+
/** Among cut paths: average number of cut years. */
|
|
156
|
+
averageCutYears: number;
|
|
157
|
+
/** Among cut paths: 90th-percentile number of cut years. */
|
|
158
|
+
p90CutYears: number;
|
|
159
|
+
/** Among cut paths: average longest consecutive cut spell, in years. */
|
|
160
|
+
averageLongestCutSpellYears: number;
|
|
161
|
+
/** Share of paths ending with any after-tax estate left. */
|
|
162
|
+
probEndingSurplus: number;
|
|
163
|
+
/** Share of paths whose ending estate clears the bequest target; null = no target set. */
|
|
164
|
+
probEndingAboveBequestTarget: number | null;
|
|
165
|
+
};
|
|
166
|
+
downsideRisk: {
|
|
167
|
+
failureRate: number;
|
|
168
|
+
failingPathCount: number;
|
|
169
|
+
/** Average total unfunded spending across failing paths. */
|
|
170
|
+
expectedShortfallDollars: number;
|
|
171
|
+
expectedRequiredShortfallDollars: number;
|
|
172
|
+
expectedTargetShortfallDollars: number;
|
|
173
|
+
p90TotalShortfallDollars: number;
|
|
174
|
+
};
|
|
175
|
+
spendingShortfall: {
|
|
176
|
+
averageTotalShortfallDollars: number;
|
|
177
|
+
averageRequiredShortfallDollars: number;
|
|
178
|
+
averageTargetShortfallDollars: number;
|
|
179
|
+
p90TotalShortfallDollars: number;
|
|
180
|
+
};
|
|
181
|
+
/** Per-year investable-balance fan (10/25/50/75/90). */
|
|
182
|
+
fan: YearPercentiles[];
|
|
183
|
+
endingInvestable: {
|
|
184
|
+
percentiles: Omit<YearPercentiles, 'year'>;
|
|
185
|
+
histogram: Histogram;
|
|
186
|
+
};
|
|
187
|
+
endingNetWorth: {
|
|
188
|
+
percentiles: Omit<YearPercentiles, 'year'>;
|
|
189
|
+
histogram: Histogram;
|
|
190
|
+
};
|
|
191
|
+
endingAfterTaxEstate: {
|
|
192
|
+
percentiles: Omit<YearPercentiles, 'year'>;
|
|
193
|
+
histogram: Histogram;
|
|
194
|
+
};
|
|
195
|
+
/** Depletion year → number of paths first depleting that year (successes excluded). */
|
|
196
|
+
depletionYearCounts: {
|
|
197
|
+
year: number;
|
|
198
|
+
count: number;
|
|
199
|
+
}[];
|
|
200
|
+
/** Cumulative probability that assets have depleted by each depletion year. */
|
|
201
|
+
depletionProbabilityByYear: {
|
|
202
|
+
year: number;
|
|
203
|
+
count: number;
|
|
204
|
+
probability: number;
|
|
205
|
+
cumulativeProbability: number;
|
|
206
|
+
}[];
|
|
207
|
+
}
|
|
208
|
+
export declare function aggregateMonteCarlo(result: MonteCarloPathsResult, histogramBins?: number): MonteCarloSummary;
|
|
209
|
+
/** Merge per-worker partial results (path order does not affect aggregation). */
|
|
210
|
+
export declare function mergePathResults(parts: MonteCarloPathsResult[]): MonteCarloPathsResult;
|