@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.
Files changed (199) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +79 -0
  3. package/dist/allocation/assetClasses.d.ts +94 -0
  4. package/dist/allocation/assetClasses.js +215 -0
  5. package/dist/decisions/annuitization.d.ts +72 -0
  6. package/dist/decisions/annuitization.js +184 -0
  7. package/dist/decisions/decisionFixtures.d.ts +48 -0
  8. package/dist/decisions/decisionFixtures.js +253 -0
  9. package/dist/decisions/evaluateCandidate.d.ts +61 -0
  10. package/dist/decisions/evaluateCandidate.js +193 -0
  11. package/dist/decisions/generators.d.ts +110 -0
  12. package/dist/decisions/generators.js +667 -0
  13. package/dist/decisions/index.d.ts +17 -0
  14. package/dist/decisions/index.js +17 -0
  15. package/dist/decisions/insightsAdapter.d.ts +27 -0
  16. package/dist/decisions/insightsAdapter.js +51 -0
  17. package/dist/decisions/objectives.d.ts +96 -0
  18. package/dist/decisions/objectives.js +319 -0
  19. package/dist/decisions/pensionElection.d.ts +112 -0
  20. package/dist/decisions/pensionElection.js +239 -0
  21. package/dist/decisions/search.d.ts +51 -0
  22. package/dist/decisions/search.js +110 -0
  23. package/dist/decisions/spendingSolver.d.ts +70 -0
  24. package/dist/decisions/spendingSolver.js +180 -0
  25. package/dist/decisions/spiaQuotes.d.ts +21 -0
  26. package/dist/decisions/spiaQuotes.js +43 -0
  27. package/dist/decisions/stochastic.d.ts +11 -0
  28. package/dist/decisions/stochastic.js +66 -0
  29. package/dist/decisions/swrComparator.d.ts +54 -0
  30. package/dist/decisions/swrComparator.js +89 -0
  31. package/dist/decisions/tournament.d.ts +53 -0
  32. package/dist/decisions/tournament.js +104 -0
  33. package/dist/decisions/types.d.ts +125 -0
  34. package/dist/decisions/types.js +12 -0
  35. package/dist/index.d.ts +13 -0
  36. package/dist/index.js +12 -0
  37. package/dist/insights/detectors/annuitizationHeadroom.d.ts +11 -0
  38. package/dist/insights/detectors/annuitizationHeadroom.js +77 -0
  39. package/dist/insights/detectors/assetLocation.d.ts +9 -0
  40. package/dist/insights/detectors/assetLocation.js +86 -0
  41. package/dist/insights/detectors/hecmBufferCandidate.d.ts +12 -0
  42. package/dist/insights/detectors/hecmBufferCandidate.js +76 -0
  43. package/dist/insights/detectors/incomeFloorFunded.d.ts +8 -0
  44. package/dist/insights/detectors/incomeFloorFunded.js +52 -0
  45. package/dist/insights/detectors/irmaaTierEdge.d.ts +2 -0
  46. package/dist/insights/detectors/irmaaTierEdge.js +102 -0
  47. package/dist/insights/detectors/pensionElectionPending.d.ts +11 -0
  48. package/dist/insights/detectors/pensionElectionPending.js +62 -0
  49. package/dist/insights/detectors/qcdEfficiency.d.ts +2 -0
  50. package/dist/insights/detectors/qcdEfficiency.js +62 -0
  51. package/dist/insights/detectors/rothBridgeHeadroom.d.ts +2 -0
  52. package/dist/insights/detectors/rothBridgeHeadroom.js +77 -0
  53. package/dist/insights/detectors/spendingGuardrails.d.ts +2 -0
  54. package/dist/insights/detectors/spendingGuardrails.js +77 -0
  55. package/dist/insights/detectors/spendingHeadroom.d.ts +12 -0
  56. package/dist/insights/detectors/spendingHeadroom.js +92 -0
  57. package/dist/insights/detectors/ssBridgeGap.d.ts +9 -0
  58. package/dist/insights/detectors/ssBridgeGap.js +92 -0
  59. package/dist/insights/detectors/stateRelocation.d.ts +12 -0
  60. package/dist/insights/detectors/stateRelocation.js +99 -0
  61. package/dist/insights/detectors/widowsPenalty.d.ts +12 -0
  62. package/dist/insights/detectors/widowsPenalty.js +145 -0
  63. package/dist/insights/registry.d.ts +4 -0
  64. package/dist/insights/registry.js +62 -0
  65. package/dist/insights/runInsights.d.ts +6 -0
  66. package/dist/insights/runInsights.js +15 -0
  67. package/dist/insights/types.d.ts +70 -0
  68. package/dist/insights/types.js +1 -0
  69. package/dist/ladder/bridge.d.ts +72 -0
  70. package/dist/ladder/bridge.js +59 -0
  71. package/dist/ladder/fedInvest.d.ts +55 -0
  72. package/dist/ladder/fedInvest.js +77 -0
  73. package/dist/ladder/fundedRatio.d.ts +41 -0
  74. package/dist/ladder/fundedRatio.js +46 -0
  75. package/dist/ladder/ladderMath.d.ts +88 -0
  76. package/dist/ladder/ladderMath.js +140 -0
  77. package/dist/longevity/ssaPeriod2022.d.ts +10 -0
  78. package/dist/longevity/ssaPeriod2022.js +51 -0
  79. package/dist/longevity/types.d.ts +39 -0
  80. package/dist/longevity/types.js +1 -0
  81. package/dist/model/migrations.d.ts +20 -0
  82. package/dist/model/migrations.js +67 -0
  83. package/dist/model/plan.d.ts +3108 -0
  84. package/dist/model/plan.js +1514 -0
  85. package/dist/montecarlo/frontiers.d.ts +22 -0
  86. package/dist/montecarlo/frontiers.js +61 -0
  87. package/dist/montecarlo/historicalReturns.d.ts +27 -0
  88. package/dist/montecarlo/historicalReturns.js +124 -0
  89. package/dist/montecarlo/historicalSuites.d.ts +45 -0
  90. package/dist/montecarlo/historicalSuites.js +102 -0
  91. package/dist/montecarlo/ltcShock.d.ts +42 -0
  92. package/dist/montecarlo/ltcShock.js +62 -0
  93. package/dist/montecarlo/marketModels.d.ts +248 -0
  94. package/dist/montecarlo/marketModels.js +672 -0
  95. package/dist/montecarlo/mortality.d.ts +31 -0
  96. package/dist/montecarlo/mortality.js +61 -0
  97. package/dist/montecarlo/riskBasedGuardrails.d.ts +93 -0
  98. package/dist/montecarlo/riskBasedGuardrails.js +200 -0
  99. package/dist/montecarlo/rng.d.ts +18 -0
  100. package/dist/montecarlo/rng.js +44 -0
  101. package/dist/montecarlo/run.d.ts +210 -0
  102. package/dist/montecarlo/run.js +353 -0
  103. package/dist/montecarlo/sharedPaths.d.ts +43 -0
  104. package/dist/montecarlo/sharedPaths.js +28 -0
  105. package/dist/montecarlo/survival.d.ts +49 -0
  106. package/dist/montecarlo/survival.js +126 -0
  107. package/dist/params/data/realYieldCurve2026.d.ts +17 -0
  108. package/dist/params/data/realYieldCurve2026.js +26 -0
  109. package/dist/params/data/year2026.d.ts +14 -0
  110. package/dist/params/data/year2026.js +199 -0
  111. package/dist/params/index.d.ts +63 -0
  112. package/dist/params/index.js +134 -0
  113. package/dist/params/provenance.d.ts +29 -0
  114. package/dist/params/provenance.js +123 -0
  115. package/dist/params/state/data/year2026.d.ts +18 -0
  116. package/dist/params/state/data/year2026.js +581 -0
  117. package/dist/params/state/index.d.ts +13 -0
  118. package/dist/params/state/index.js +26 -0
  119. package/dist/params/state/types.d.ts +73 -0
  120. package/dist/params/state/types.js +10 -0
  121. package/dist/params/types.d.ts +201 -0
  122. package/dist/params/types.js +7 -0
  123. package/dist/projection/annuityForms.d.ts +62 -0
  124. package/dist/projection/annuityForms.js +87 -0
  125. package/dist/projection/compare.d.ts +96 -0
  126. package/dist/projection/compare.js +216 -0
  127. package/dist/projection/flatTax.d.ts +7 -0
  128. package/dist/projection/flatTax.js +14 -0
  129. package/dist/projection/optimizePlan.d.ts +328 -0
  130. package/dist/projection/optimizePlan.js +1039 -0
  131. package/dist/projection/relocation.d.ts +149 -0
  132. package/dist/projection/relocation.js +317 -0
  133. package/dist/projection/simulate.d.ts +61 -0
  134. package/dist/projection/simulate.js +2754 -0
  135. package/dist/projection/types.d.ts +381 -0
  136. package/dist/projection/types.js +9 -0
  137. package/dist/rmd/jointLifeTable.d.ts +8 -0
  138. package/dist/rmd/jointLifeTable.js +663 -0
  139. package/dist/rmd/rmd.d.ts +29 -0
  140. package/dist/rmd/rmd.js +34 -0
  141. package/dist/scenarios/scenarios.d.ts +70 -0
  142. package/dist/scenarios/scenarios.js +130 -0
  143. package/dist/socialSecurity/benefitFactor.d.ts +13 -0
  144. package/dist/socialSecurity/benefitFactor.js +40 -0
  145. package/dist/socialSecurity/claimFactor.d.ts +33 -0
  146. package/dist/socialSecurity/claimFactor.js +58 -0
  147. package/dist/socialSecurity/disability.d.ts +36 -0
  148. package/dist/socialSecurity/disability.js +42 -0
  149. package/dist/socialSecurity/familyMaximum.d.ts +38 -0
  150. package/dist/socialSecurity/familyMaximum.js +48 -0
  151. package/dist/socialSecurity/maritalBenefits.d.ts +57 -0
  152. package/dist/socialSecurity/maritalBenefits.js +87 -0
  153. package/dist/socialSecurity/nra.d.ts +30 -0
  154. package/dist/socialSecurity/nra.js +83 -0
  155. package/dist/socialSecurity/piaFromEarnings.d.ts +100 -0
  156. package/dist/socialSecurity/piaFromEarnings.js +202 -0
  157. package/dist/socialSecurity/ssaWageData.d.ts +68 -0
  158. package/dist/socialSecurity/ssaWageData.js +306 -0
  159. package/dist/socialSecurity/survivorBenefit.d.ts +61 -0
  160. package/dist/socialSecurity/survivorBenefit.js +61 -0
  161. package/dist/spending/abw.d.ts +57 -0
  162. package/dist/spending/abw.js +80 -0
  163. package/dist/spending/flexibleGoals.d.ts +62 -0
  164. package/dist/spending/flexibleGoals.js +136 -0
  165. package/dist/spending/guardrails.d.ts +78 -0
  166. package/dist/spending/guardrails.js +100 -0
  167. package/dist/spending/layers.d.ts +81 -0
  168. package/dist/spending/layers.js +67 -0
  169. package/dist/spending/shapePresets.d.ts +42 -0
  170. package/dist/spending/shapePresets.js +77 -0
  171. package/dist/strategies/accountEligibility.d.ts +81 -0
  172. package/dist/strategies/accountEligibility.js +98 -0
  173. package/dist/strategies/inheritedIra.d.ts +45 -0
  174. package/dist/strategies/inheritedIra.js +43 -0
  175. package/dist/strategies/iraBasis.d.ts +48 -0
  176. package/dist/strategies/iraBasis.js +46 -0
  177. package/dist/strategies/optimizer.d.ts +307 -0
  178. package/dist/strategies/optimizer.js +551 -0
  179. package/dist/strategies/rothBasis.d.ts +67 -0
  180. package/dist/strategies/rothBasis.js +83 -0
  181. package/dist/strategies/rothConversion.d.ts +55 -0
  182. package/dist/strategies/rothConversion.js +83 -0
  183. package/dist/strategies/sepp.d.ts +38 -0
  184. package/dist/strategies/sepp.js +53 -0
  185. package/dist/tax/aca.d.ts +40 -0
  186. package/dist/tax/aca.js +82 -0
  187. package/dist/tax/federalTax.d.ts +110 -0
  188. package/dist/tax/federalTax.js +249 -0
  189. package/dist/tax/medicare.d.ts +20 -0
  190. package/dist/tax/medicare.js +36 -0
  191. package/dist/tax/propertySale.d.ts +46 -0
  192. package/dist/tax/propertySale.js +35 -0
  193. package/dist/tax/stateTax.d.ts +66 -0
  194. package/dist/tax/stateTax.js +198 -0
  195. package/dist/testing/money.d.ts +7 -0
  196. package/dist/testing/money.js +17 -0
  197. package/dist/testing/planFixtures.d.ts +29 -0
  198. package/dist/testing/planFixtures.js +141 -0
  199. package/package.json +75 -0
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # @retiregolden/engine
2
+
3
+ Pure-TypeScript retirement-planning engine — the calculation core of
4
+ [RetireGolden](https://retiregolden.app/). It projects a household's finances
5
+ year by year and models federal + state taxes, Social Security (claiming,
6
+ spousal/survivor, PIA from earnings), RMDs, Roth conversions, withdrawal
7
+ strategies, insurance, Monte Carlo, and an LP-based optimizer.
8
+
9
+ Source of truth: [github.com/RetireGolden/RetireGolden](https://github.com/RetireGolden/RetireGolden)
10
+ (`packages/engine`). Engineering docs live in the repo's `DOCS/`.
11
+
12
+ ## Runtime contract
13
+
14
+ - **ESM, Node ≥ 20** (also bundles cleanly for browsers). No CommonJS build.
15
+ - **No browser globals, no ambient network, no persistence.** The engine never
16
+ touches `fetch`, `localStorage`, `indexedDB`, or the DOM — enforced by lint.
17
+ IO always crosses an injection seam owned by the consumer:
18
+ - anything stochastic takes an injected seedable RNG (`montecarlo/rng`);
19
+ - the optimizer loads the HiGHS wasm via an optional `locateFile` option
20
+ (`strategies/optimizer`);
21
+ - FedInvest TIPS prices: the engine only parses CSV text
22
+ (`ladder/fedInvest`); fetching and caching are the consumer's job.
23
+ - **Deterministic.** Same plan + same options ⇒ bit-identical results.
24
+ - Parameters (tax brackets, limits, SSA tables, Medicare/FPL) are versioned
25
+ data packs under `params/`, with provenance.
26
+
27
+ ## Usage
28
+
29
+ Deep subpath imports are the primary API; the root export covers the core
30
+ validate-and-project loop:
31
+
32
+ ```ts
33
+ import { planSchema, simulatePlan } from '@retiregolden/engine'
34
+
35
+ const plan = planSchema.parse(JSON.parse(planJson))
36
+ const result = simulatePlan(plan, { startYear: 2026 })
37
+ ```
38
+
39
+ ```ts
40
+ import { runMonteCarlo } from '@retiregolden/engine/montecarlo/run'
41
+ import { packForYear } from '@retiregolden/engine/params'
42
+ ```
43
+
44
+ Test fixtures used by the RetireGolden apps' own suites ship under
45
+ `@retiregolden/engine/testing/*` — framework-free (no vitest or other
46
+ test-runner dependency), but not part of the supported runtime API.
47
+
48
+ ## Layout
49
+
50
+ | Subpath | Contents |
51
+ |---------|----------|
52
+ | `model/` | Plan schema (Zod), types, migrations |
53
+ | `params/` | Annual parameter packs (tax brackets, limits, RMD, Medicare, SS, state) + typed accessors |
54
+ | `tax/` | Federal + state tax engine, ACA credit, Medicare/IRMAA |
55
+ | `rmd/` | Required minimum distributions (SECURE 2.0) |
56
+ | `socialSecurity/` | Claiming factors, NRA/FRA, PIA from earnings, spousal/survivor/family-maximum, disability |
57
+ | `longevity/` | SSA 2022 period life table + shared types |
58
+ | `strategies/` | Roth-conversion sizing (fill-to-target), withdrawal ordering, SEPP, inherited-IRA, the optimizer |
59
+ | `projection/` | Deterministic annual ledger + summaries/comparison |
60
+ | `montecarlo/` | Seedable RNG, market models (lognormal, historical bootstrap), path runner + aggregation, mortality/survival |
61
+ | `scenarios/` | Scenario patch apply/diff + side-by-side comparison |
62
+ | `decisions/`, `insights/` | Candidate evaluation, recommendation detectors |
63
+ | `ladder/` | TIPS ladder math, Social Security bridge, FedInvest CSV parsing |
64
+ | `allocation/`, `spending/` | Asset classes, spending shape presets |
65
+ | `testing/` | Plan fixtures and money matchers for consumer test suites |
66
+
67
+ ## License
68
+
69
+ **AGPL-3.0-only** (see [LICENSE](LICENSE)). The engine is free and un-gutted —
70
+ the full math ships in the free web app.
71
+
72
+ RetireGolden, LLC also ships a commercial desktop edition built from this same
73
+ engine under a separate commercial license, which funds the free one. That
74
+ dual-license arrangement is why contributions to the
75
+ [upstream repo](https://github.com/RetireGolden/RetireGolden) require a
76
+ one-time [Contributor License Agreement](https://github.com/RetireGolden/RetireGolden/blob/main/CLA.md)
77
+ — you keep your copyright; the CLA lets the LLC also ship your contribution in
78
+ the commercial edition. See
79
+ [CONTRIBUTING.md](https://github.com/RetireGolden/RetireGolden/blob/main/CONTRIBUTING.md).
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Asset classes, sourced default parameters, and allocation/glidepath math
3
+ * (asset-allocation-and-return-model-v2, steps 1–3).
4
+ *
5
+ * Pure helpers shared by the deterministic ledger, Monte Carlo class shocks,
6
+ * the decision engine's asset-location generator, and the UI. The schema for
7
+ * per-account allocation policies lives in `engine/model/plan.ts`; this module
8
+ * owns the numbers and the math.
9
+ *
10
+ * Allocation is opt-in per account: an account without an `allocation` keeps
11
+ * the single expected-return model unchanged (feature-off is byte-identical).
12
+ *
13
+ * @see DOCS/domain/domain-rules-reference.md §14 (class defaults + sources)
14
+ */
15
+ import type { AssetAllocationPolicy, AssetClassId, AssetClassParamOverrides, Assumptions, AllocationWeights, Account, Plan } from '../model/plan.js';
16
+ export interface AssetClassParams {
17
+ label: string;
18
+ /** Long-run expected annual nominal total return, percent. */
19
+ returnPct: number;
20
+ /** Annual return volatility, percentage points (drives Monte Carlo class shocks). */
21
+ volatilityPct: number;
22
+ /** Portion of the total return distributed as taxable interest, percent of balance. */
23
+ interestYieldPct: number;
24
+ /** Portion of the total return distributed as dividends, percent of balance. */
25
+ dividendYieldPct: number;
26
+ /** Share of dividends taxed as qualified dividends, percent. */
27
+ qualifiedRatioPct: number;
28
+ }
29
+ /**
30
+ * Sourced planning defaults, editable in Assumptions. Expected returns follow
31
+ * the app's long-run nominal conventions (the in-app return estimator uses
32
+ * stocks ≈ 7%, bonds ≈ 4%, cash ≈ 2.5%); volatilities and correlations are
33
+ * long-horizon historical values from the Damodaran (NYU Stern) annual
34
+ * dataset — the same source as the embedded Monte Carlo history — with
35
+ * international equity proxied by MSCI EAFE history. Yields are current-era
36
+ * index-fund figures. Sources + review cadence:
37
+ * DOCS/domain/domain-rules-reference.md §14.
38
+ */
39
+ export declare const DEFAULT_ASSET_CLASS_PARAMS: Record<AssetClassId, AssetClassParams>;
40
+ /**
41
+ * Default long-horizon correlation matrix over ASSET_CLASS_IDS order
42
+ * (usStocks, intlStocks, bonds, cash). Documented + sourced in domain rules
43
+ * §14; editable defaults can ship later without changing this seam.
44
+ */
45
+ export declare const DEFAULT_CLASS_CORRELATIONS: readonly (readonly number[])[];
46
+ /** Assumption overrides merged over the sourced defaults (absent = default). */
47
+ export declare function resolveAssetClassParams(overrides: AssetClassParamOverrides | undefined): Record<AssetClassId, AssetClassParams>;
48
+ /** Weights record → normalized fraction vector in ASSET_CLASS_IDS order. */
49
+ export declare function weightsToVector(weights: AllocationWeights): number[];
50
+ /**
51
+ * Compile a glidepath policy to this year's target weights (normalized
52
+ * fractions in ASSET_CLASS_IDS order). Linear interpolates between its
53
+ * endpoints and clamps outside them; staged is a step function holding each
54
+ * stage from its fromYear; custom interpolates between year targets and
55
+ * clamps outside the first/last target.
56
+ */
57
+ export declare function targetWeightsAt(policy: AssetAllocationPolicy, year: number): number[];
58
+ /** Blended expected nominal return for a weight vector, percent. */
59
+ export declare function blendedReturnPct(weights: number[], params: Record<AssetClassId, AssetClassParams>): number;
60
+ export interface BlendedTaxableYield {
61
+ interestYieldPct: number;
62
+ dividendYieldPct: number;
63
+ /** Dividend-weighted qualified share, 0–1 (0.85 fallback when no dividends). */
64
+ qualifiedRatio: number;
65
+ }
66
+ /** Class yields blended by weight — feeds the shipped taxable-drag fields. */
67
+ export declare function blendedTaxableYield(weights: number[], params: Record<AssetClassId, AssetClassParams>): BlendedTaxableYield;
68
+ /**
69
+ * Weights after one year in which class i returned ratesPct[i] percent (no
70
+ * rebalance). Total-return drift: distributions are treated as reinvested
71
+ * pro-rata for weight purposes, a planning-grade simplification.
72
+ */
73
+ export declare function driftWeights(weights: number[], ratesPct: number[]): number[];
74
+ /** Fraction of the account sold to move current → target (Σ overweight). */
75
+ export declare function rebalanceTurnoverFraction(current: number[], target: number[]): number;
76
+ /** Non-cash share of a weight vector (the market-shocked portion under the single-factor model). */
77
+ export declare function nonCashWeight(weights: number[]): number;
78
+ /** The account's allocation policy, when its type supports one and it is set. */
79
+ export declare function accountAllocation(account: Account): AssetAllocationPolicy | undefined;
80
+ /** Whether any account opts into class-level allocation (gates MC class shocks). */
81
+ export declare function planUsesAssetAllocation(plan: Plan): boolean;
82
+ /**
83
+ * Expected nominal return for an account this year: the allocation blend when
84
+ * the account opts in (its explicit annualReturnPct is superseded), else the
85
+ * account's own return, else the plan default.
86
+ */
87
+ export declare function expectedAccountReturnPct(account: Account, assumptions: Assumptions, year: number): number;
88
+ /**
89
+ * Cholesky factor L (lower-triangular, LLᵀ = matrix) for correlated class
90
+ * draws. For non-positive-definite input the diagonal term is clamped to a
91
+ * small epsilon (1e-12) rather than failing, so the factorization always
92
+ * returns (defensive: the shipped default matrix is PD).
93
+ */
94
+ export declare function choleskyDecompose(matrix: readonly (readonly number[])[]): number[][];
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Asset classes, sourced default parameters, and allocation/glidepath math
3
+ * (asset-allocation-and-return-model-v2, steps 1–3).
4
+ *
5
+ * Pure helpers shared by the deterministic ledger, Monte Carlo class shocks,
6
+ * the decision engine's asset-location generator, and the UI. The schema for
7
+ * per-account allocation policies lives in `engine/model/plan.ts`; this module
8
+ * owns the numbers and the math.
9
+ *
10
+ * Allocation is opt-in per account: an account without an `allocation` keeps
11
+ * the single expected-return model unchanged (feature-off is byte-identical).
12
+ *
13
+ * @see DOCS/domain/domain-rules-reference.md §14 (class defaults + sources)
14
+ */
15
+ import { ASSET_CLASS_IDS } from '../model/plan.js';
16
+ /**
17
+ * Sourced planning defaults, editable in Assumptions. Expected returns follow
18
+ * the app's long-run nominal conventions (the in-app return estimator uses
19
+ * stocks ≈ 7%, bonds ≈ 4%, cash ≈ 2.5%); volatilities and correlations are
20
+ * long-horizon historical values from the Damodaran (NYU Stern) annual
21
+ * dataset — the same source as the embedded Monte Carlo history — with
22
+ * international equity proxied by MSCI EAFE history. Yields are current-era
23
+ * index-fund figures. Sources + review cadence:
24
+ * DOCS/domain/domain-rules-reference.md §14.
25
+ */
26
+ export const DEFAULT_ASSET_CLASS_PARAMS = {
27
+ usStocks: { label: 'US stocks', returnPct: 7.0, volatilityPct: 19.6, interestYieldPct: 0, dividendYieldPct: 1.5, qualifiedRatioPct: 95 },
28
+ intlStocks: { label: 'International stocks', returnPct: 7.0, volatilityPct: 21.0, interestYieldPct: 0, dividendYieldPct: 3.0, qualifiedRatioPct: 70 },
29
+ bonds: { label: 'Bonds', returnPct: 4.0, volatilityPct: 7.7, interestYieldPct: 4.0, dividendYieldPct: 0, qualifiedRatioPct: 0 },
30
+ cash: { label: 'Cash', returnPct: 2.5, volatilityPct: 0.5, interestYieldPct: 2.5, dividendYieldPct: 0, qualifiedRatioPct: 0 },
31
+ };
32
+ /**
33
+ * Default long-horizon correlation matrix over ASSET_CLASS_IDS order
34
+ * (usStocks, intlStocks, bonds, cash). Documented + sourced in domain rules
35
+ * §14; editable defaults can ship later without changing this seam.
36
+ */
37
+ export const DEFAULT_CLASS_CORRELATIONS = [
38
+ [1.0, 0.75, 0.1, 0.0],
39
+ [0.75, 1.0, 0.1, 0.0],
40
+ [0.1, 0.1, 1.0, 0.2],
41
+ [0.0, 0.0, 0.2, 1.0],
42
+ ];
43
+ /** Assumption overrides merged over the sourced defaults (absent = default). */
44
+ export function resolveAssetClassParams(overrides) {
45
+ const resolved = {};
46
+ for (const id of ASSET_CLASS_IDS) {
47
+ const d = DEFAULT_ASSET_CLASS_PARAMS[id];
48
+ const o = overrides?.[id];
49
+ resolved[id] = {
50
+ label: d.label,
51
+ returnPct: o?.returnPct ?? d.returnPct,
52
+ volatilityPct: o?.volatilityPct ?? d.volatilityPct,
53
+ interestYieldPct: o?.interestYieldPct ?? d.interestYieldPct,
54
+ dividendYieldPct: o?.dividendYieldPct ?? d.dividendYieldPct,
55
+ qualifiedRatioPct: o?.qualifiedRatioPct ?? d.qualifiedRatioPct,
56
+ };
57
+ }
58
+ return resolved;
59
+ }
60
+ /** Weights record → normalized fraction vector in ASSET_CLASS_IDS order. */
61
+ export function weightsToVector(weights) {
62
+ const raw = ASSET_CLASS_IDS.map((id) => Math.max(0, weights[id]));
63
+ const total = raw.reduce((a, b) => a + b, 0);
64
+ if (total <= 0)
65
+ return ASSET_CLASS_IDS.map((id) => (id === 'cash' ? 1 : 0));
66
+ return raw.map((w) => w / total);
67
+ }
68
+ function lerpVectors(a, b, t) {
69
+ const mixed = a.map((v, i) => v + (b[i] - v) * t);
70
+ const total = mixed.reduce((x, y) => x + y, 0);
71
+ return total > 0 ? mixed.map((v) => v / total) : mixed;
72
+ }
73
+ /**
74
+ * Compile a glidepath policy to this year's target weights (normalized
75
+ * fractions in ASSET_CLASS_IDS order). Linear interpolates between its
76
+ * endpoints and clamps outside them; staged is a step function holding each
77
+ * stage from its fromYear; custom interpolates between year targets and
78
+ * clamps outside the first/last target.
79
+ */
80
+ export function targetWeightsAt(policy, year) {
81
+ switch (policy.mode) {
82
+ case 'static':
83
+ return weightsToVector(policy.weights);
84
+ case 'linear': {
85
+ const from = weightsToVector(policy.from);
86
+ const to = weightsToVector(policy.to);
87
+ if (year <= policy.startYear || policy.endYear <= policy.startYear)
88
+ return from;
89
+ if (year >= policy.endYear)
90
+ return to;
91
+ return lerpVectors(from, to, (year - policy.startYear) / (policy.endYear - policy.startYear));
92
+ }
93
+ case 'staged': {
94
+ const stages = [...policy.stages].sort((a, b) => a.fromYear - b.fromYear);
95
+ let current = stages[0];
96
+ for (const stage of stages) {
97
+ if (stage.fromYear <= year)
98
+ current = stage;
99
+ }
100
+ return weightsToVector(current.weights);
101
+ }
102
+ case 'custom': {
103
+ const targets = [...policy.targets].sort((a, b) => a.year - b.year);
104
+ if (year <= targets[0].year)
105
+ return weightsToVector(targets[0].weights);
106
+ const last = targets[targets.length - 1];
107
+ if (year >= last.year)
108
+ return weightsToVector(last.weights);
109
+ for (let i = 0; i < targets.length - 1; i++) {
110
+ const lo = targets[i];
111
+ const hi = targets[i + 1];
112
+ if (year >= lo.year && year <= hi.year) {
113
+ const t = (year - lo.year) / (hi.year - lo.year);
114
+ return lerpVectors(weightsToVector(lo.weights), weightsToVector(hi.weights), t);
115
+ }
116
+ }
117
+ return weightsToVector(last.weights);
118
+ }
119
+ }
120
+ }
121
+ /** Blended expected nominal return for a weight vector, percent. */
122
+ export function blendedReturnPct(weights, params) {
123
+ return ASSET_CLASS_IDS.reduce((sum, id, i) => sum + (weights[i] ?? 0) * params[id].returnPct, 0);
124
+ }
125
+ /** Class yields blended by weight — feeds the shipped taxable-drag fields. */
126
+ export function blendedTaxableYield(weights, params) {
127
+ let interest = 0;
128
+ let dividends = 0;
129
+ let qualified = 0;
130
+ ASSET_CLASS_IDS.forEach((id, i) => {
131
+ const w = weights[i] ?? 0;
132
+ interest += w * params[id].interestYieldPct;
133
+ dividends += w * params[id].dividendYieldPct;
134
+ qualified += w * params[id].dividendYieldPct * (params[id].qualifiedRatioPct / 100);
135
+ });
136
+ return {
137
+ interestYieldPct: interest,
138
+ dividendYieldPct: dividends,
139
+ qualifiedRatio: dividends > 0 ? qualified / dividends : 0.85,
140
+ };
141
+ }
142
+ /**
143
+ * Weights after one year in which class i returned ratesPct[i] percent (no
144
+ * rebalance). Total-return drift: distributions are treated as reinvested
145
+ * pro-rata for weight purposes, a planning-grade simplification.
146
+ */
147
+ export function driftWeights(weights, ratesPct) {
148
+ const grown = weights.map((w, i) => w * Math.max(0, 1 + (ratesPct[i] ?? 0) / 100));
149
+ const total = grown.reduce((a, b) => a + b, 0);
150
+ return total > 0 ? grown.map((v) => v / total) : weights;
151
+ }
152
+ /** Fraction of the account sold to move current → target (Σ overweight). */
153
+ export function rebalanceTurnoverFraction(current, target) {
154
+ let turnover = 0;
155
+ for (let i = 0; i < current.length; i++) {
156
+ turnover += Math.max(0, (current[i] ?? 0) - (target[i] ?? 0));
157
+ }
158
+ return turnover;
159
+ }
160
+ /** Non-cash share of a weight vector (the market-shocked portion under the single-factor model). */
161
+ export function nonCashWeight(weights) {
162
+ const cashIndex = ASSET_CLASS_IDS.indexOf('cash');
163
+ return Math.max(0, 1 - (weights[cashIndex] ?? 0));
164
+ }
165
+ /** The account's allocation policy, when its type supports one and it is set. */
166
+ export function accountAllocation(account) {
167
+ if (account.type === 'taxable' ||
168
+ account.type === 'traditional' ||
169
+ account.type === 'roth' ||
170
+ account.type === 'hsa') {
171
+ return account.allocation;
172
+ }
173
+ return undefined;
174
+ }
175
+ /** Whether any account opts into class-level allocation (gates MC class shocks). */
176
+ export function planUsesAssetAllocation(plan) {
177
+ return plan.accounts.some((a) => accountAllocation(a) !== undefined);
178
+ }
179
+ /**
180
+ * Expected nominal return for an account this year: the allocation blend when
181
+ * the account opts in (its explicit annualReturnPct is superseded), else the
182
+ * account's own return, else the plan default.
183
+ */
184
+ export function expectedAccountReturnPct(account, assumptions, year) {
185
+ const policy = accountAllocation(account);
186
+ if (policy) {
187
+ return blendedReturnPct(targetWeightsAt(policy, year), resolveAssetClassParams(assumptions.assetClassParams));
188
+ }
189
+ const own = 'annualReturnPct' in account ? account.annualReturnPct : null;
190
+ return own ?? assumptions.defaultReturnPct;
191
+ }
192
+ /**
193
+ * Cholesky factor L (lower-triangular, LLᵀ = matrix) for correlated class
194
+ * draws. For non-positive-definite input the diagonal term is clamped to a
195
+ * small epsilon (1e-12) rather than failing, so the factorization always
196
+ * returns (defensive: the shipped default matrix is PD).
197
+ */
198
+ export function choleskyDecompose(matrix) {
199
+ const n = matrix.length;
200
+ const L = Array.from({ length: n }, () => new Array(n).fill(0));
201
+ for (let i = 0; i < n; i++) {
202
+ for (let j = 0; j <= i; j++) {
203
+ let sum = 0;
204
+ for (let k = 0; k < j; k++)
205
+ sum += L[i][k] * L[j][k];
206
+ if (i === j) {
207
+ L[i][j] = Math.sqrt(Math.max(1e-12, matrix[i][i] - sum));
208
+ }
209
+ else {
210
+ L[i][j] = (matrix[i][j] - sum) / L[j][j];
211
+ }
212
+ }
213
+ }
214
+ return L;
215
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Annuitization sweep solver (annuity-pension-and-home-equity decisions,
3
+ * step 2): how much of the portfolio to trade for a SPIA?
4
+ *
5
+ * Sweeps a bounded grid of allocation percentages through the shared-path
6
+ * Monte Carlo primitive: each point adds a life-only SPIA purchase (funded
7
+ * from the largest liquid account, priced at the sourced default payout rate
8
+ * or a user-entered quote) and reports success-rate / estate metrics against
9
+ * the 0% point — a success-vs-legacy frontier on identical market paths.
10
+ *
11
+ * Kitces attribution: much of a SPIA's measured benefit is the implicit
12
+ * rising-equity glidepath — the bond-like premium leaves the portfolio, so
13
+ * the remaining dollars run at a higher equity share. To isolate it, each
14
+ * point (when the funding account carries a static allocation with enough
15
+ * bonds) also evaluates a control variant that shifts the same premium from
16
+ * bonds to US stocks WITHOUT buying the annuity: control − baseline is the
17
+ * glidepath share of the benefit, point − control is what annuitization adds
18
+ * beyond it (mortality credits and payout floor, net of lost liquidity).
19
+ * Everything is priced on the exact ledger inside the Monte Carlo runner —
20
+ * the sweep never decides, it measures.
21
+ */
22
+ import type { Plan } from '../model/plan.js';
23
+ import { type SharedPathComparisonOptions } from '../montecarlo/sharedPaths.js';
24
+ export interface AnnuitizationPointMetrics {
25
+ successRate: number;
26
+ requiredFloorSuccessRate: number;
27
+ targetLifestyleSuccessRate: number;
28
+ p10EndingAfterTaxEstate: number;
29
+ medianEndingAfterTaxEstate: number;
30
+ expectedShortfallDollars: number;
31
+ }
32
+ export interface AnnuitizationSweepPoint {
33
+ /** Requested grid percent of total investable assets (unique point key). */
34
+ allocationPct: number;
35
+ /**
36
+ * Share of total investable assets the premium actually represents
37
+ * (premium ÷ investable). Lower than `allocationPct` when the funding
38
+ * account caps the purchase — retirement-heavy plans can request 30% but
39
+ * only fund what the largest liquid account holds. Charts plot this.
40
+ */
41
+ effectiveAllocationPct: number;
42
+ /** SPIA premium this point trades away (nominal, at the start year). */
43
+ premium: number;
44
+ /** Annual annuity income the premium buys at the quoted rate. */
45
+ annualIncome: number;
46
+ metrics: AnnuitizationPointMetrics;
47
+ /** Allocation-matched control (glidepath without the annuity); absent when not constructible. */
48
+ glidepathControl?: AnnuitizationPointMetrics;
49
+ }
50
+ export interface AnnuitizationSweep {
51
+ points: AnnuitizationSweepPoint[];
52
+ /** Age payments start (max(current age, 65)) and the payout rate used. */
53
+ startAge: number;
54
+ payoutRatePct: number;
55
+ /** 'default-table' or 'user-quote' — where the payout rate came from. */
56
+ rateSource: 'default-table' | 'user-quote';
57
+ /**
58
+ * True when the glidepath controls could be built (static-allocation funding
59
+ * account with bond weight to shift); the Kitces attribution columns are
60
+ * only meaningful then.
61
+ */
62
+ attributionAvailable: boolean;
63
+ /** Why the sweep is empty or attribution is missing, for the UI. */
64
+ notes: string[];
65
+ }
66
+ export interface AnnuitizationSweepConfig {
67
+ /** Allocation grid (percent of investable). Bounded by MAX_FRONTIER_POINTS budget. */
68
+ allocationPcts?: readonly number[];
69
+ /** User-entered annual payout rate (percent of premium) overriding the default table. */
70
+ quotedPayoutRatePct?: number;
71
+ }
72
+ export declare function buildAnnuitizationSweep(plan: Plan, opts: SharedPathComparisonOptions, config?: AnnuitizationSweepConfig): AnnuitizationSweep;
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Annuitization sweep solver (annuity-pension-and-home-equity decisions,
3
+ * step 2): how much of the portfolio to trade for a SPIA?
4
+ *
5
+ * Sweeps a bounded grid of allocation percentages through the shared-path
6
+ * Monte Carlo primitive: each point adds a life-only SPIA purchase (funded
7
+ * from the largest liquid account, priced at the sourced default payout rate
8
+ * or a user-entered quote) and reports success-rate / estate metrics against
9
+ * the 0% point — a success-vs-legacy frontier on identical market paths.
10
+ *
11
+ * Kitces attribution: much of a SPIA's measured benefit is the implicit
12
+ * rising-equity glidepath — the bond-like premium leaves the portfolio, so
13
+ * the remaining dollars run at a higher equity share. To isolate it, each
14
+ * point (when the funding account carries a static allocation with enough
15
+ * bonds) also evaluates a control variant that shifts the same premium from
16
+ * bonds to US stocks WITHOUT buying the annuity: control − baseline is the
17
+ * glidepath share of the benefit, point − control is what annuitization adds
18
+ * beyond it (mortality credits and payout floor, net of lost liquidity).
19
+ * Everything is priced on the exact ledger inside the Monte Carlo runner —
20
+ * the sweep never decides, it measures.
21
+ */
22
+ import { comparePlansOnSharedMarketPaths } from '../montecarlo/sharedPaths.js';
23
+ import { MAX_FRONTIER_POINTS } from '../montecarlo/frontiers.js';
24
+ import { spiaPayoutRate } from './spiaQuotes.js';
25
+ const DEFAULT_GRID = [0, 5, 10, 15, 20, 25, 30];
26
+ function dobYear(dob) {
27
+ return Number(dob.slice(0, 4));
28
+ }
29
+ function accountBalance(account) {
30
+ return 'balance' in account ? account.balance : 0;
31
+ }
32
+ function metricsFromRow(row) {
33
+ return {
34
+ successRate: row.summary.successRate,
35
+ requiredFloorSuccessRate: row.summary.requiredFloorSuccessRate,
36
+ targetLifestyleSuccessRate: row.summary.targetLifestyleSuccessRate,
37
+ p10EndingAfterTaxEstate: row.summary.endingAfterTaxEstate.percentiles.p10,
38
+ medianEndingAfterTaxEstate: row.summary.endingAfterTaxEstate.percentiles.p50,
39
+ expectedShortfallDollars: row.summary.downsideRisk.expectedShortfallDollars,
40
+ };
41
+ }
42
+ /** Sum of liquid + retirement balances the sweep sizes premiums against. */
43
+ function investableTotal(plan) {
44
+ let total = 0;
45
+ for (const account of plan.accounts) {
46
+ if (account.type === 'cash' ||
47
+ account.type === 'taxable' ||
48
+ account.type === 'equityComp' ||
49
+ account.type === 'traditional' ||
50
+ account.type === 'roth' ||
51
+ account.type === 'hsa') {
52
+ total += accountBalance(account);
53
+ }
54
+ }
55
+ return total;
56
+ }
57
+ export function buildAnnuitizationSweep(plan, opts, config = {}) {
58
+ const notes = [];
59
+ const grid = [...(config.allocationPcts ?? DEFAULT_GRID)].sort((a, b) => a - b);
60
+ const primary = plan.household.people[0];
61
+ const funding = plan.accounts
62
+ .filter((a) => a.type === 'cash' || a.type === 'taxable')
63
+ .sort((a, b) => accountBalance(b) - accountBalance(a))[0];
64
+ const total = investableTotal(plan);
65
+ if (!primary || !funding || total <= 0) {
66
+ return {
67
+ points: [],
68
+ startAge: 65,
69
+ payoutRatePct: 0,
70
+ rateSource: 'default-table',
71
+ attributionAvailable: false,
72
+ notes: ['The sweep needs a household member and a cash or taxable account to fund purchases from.'],
73
+ };
74
+ }
75
+ const currentAge = opts.startYear - dobYear(primary.dob);
76
+ const startAge = Math.min(95, Math.max(currentAge, 65));
77
+ const rateSource = config.quotedPayoutRatePct !== undefined ? 'user-quote' : 'default-table';
78
+ const payoutRate = config.quotedPayoutRatePct !== undefined ? config.quotedPayoutRatePct / 100 : spiaPayoutRate(startAge);
79
+ // Premiums cap at what the funding account can actually pay (leaving 5% so
80
+ // the purchase never zeroes the household's most liquid account).
81
+ const fundingCap = accountBalance(funding) * 0.95;
82
+ const nonZeroPcts = grid.filter((pct) => pct > 0);
83
+ // Attribution controls need a static allocation on the funding account so
84
+ // "sell bonds for the SPIA" has a concrete meaning.
85
+ const fundingAllocation = 'allocation' in funding ? funding.allocation : undefined;
86
+ const staticWeights = fundingAllocation?.mode === 'static' ? fundingAllocation.weights : undefined;
87
+ if (!staticWeights) {
88
+ notes.push('Glidepath attribution needs a static asset allocation on the funding account (so shifting the premium out of bonds is well-defined); controls were skipped.');
89
+ }
90
+ // Budget: 1 baseline + a SPIA variant and (when constructible) a control per
91
+ // non-zero point, inside the shared-path frontier cap.
92
+ const budgetPerPoint = staticWeights ? 2 : 1;
93
+ const maxPoints = Math.floor((MAX_FRONTIER_POINTS - 1) / budgetPerPoint);
94
+ const pcts = nonZeroPcts.slice(0, maxPoints);
95
+ if (pcts.length < nonZeroPcts.length) {
96
+ notes.push(`The allocation grid was truncated to ${pcts.length} points to stay inside the bounded sweep budget.`);
97
+ }
98
+ const variants = [{ id: 'annuitize-0', label: '0% (no annuity)', plan }];
99
+ const pointMeta = [];
100
+ for (const pct of pcts) {
101
+ const premium = Math.min((pct / 100) * total, fundingCap);
102
+ if (premium < 5_000)
103
+ continue;
104
+ const annualIncome = premium * payoutRate;
105
+ const spia = {
106
+ id: `annuitize-sweep-${opts.startYear}-${funding.id}-${pct}`,
107
+ type: 'annuity',
108
+ name: `SPIA sweep ${pct}%`,
109
+ ownerPersonId: primary.id,
110
+ annualReturnPct: null,
111
+ startAge,
112
+ monthlyAmount: annualIncome / 12,
113
+ colaPct: 0,
114
+ taxablePct: 100,
115
+ purchase: { year: opts.startYear, premium, fundingAccountId: funding.id, taxQualification: 'nonQualified' },
116
+ };
117
+ variants.push({
118
+ id: `annuitize-${pct}`,
119
+ label: `${pct}% annuitized`,
120
+ plan: { ...plan, accounts: [...plan.accounts, spia] },
121
+ });
122
+ let controlId;
123
+ if (staticWeights) {
124
+ // Shift the premium's dollars from bonds to US stocks inside the funding
125
+ // account — the rising-equity glidepath a bond-funded SPIA buyer gets,
126
+ // without the annuity. Clamped to the bonds actually there.
127
+ const balance = accountBalance(funding);
128
+ const shiftPct = balance > 0 ? Math.min((premium / balance) * 100, staticWeights.bonds) : 0;
129
+ if (shiftPct > 0.5) {
130
+ controlId = `glidepath-${pct}`;
131
+ const shiftedFunding = {
132
+ ...funding,
133
+ allocation: {
134
+ ...fundingAllocation,
135
+ weights: {
136
+ ...staticWeights,
137
+ bonds: staticWeights.bonds - shiftPct,
138
+ usStocks: Math.min(100, staticWeights.usStocks + shiftPct),
139
+ },
140
+ },
141
+ };
142
+ variants.push({
143
+ id: controlId,
144
+ label: `${pct}% glidepath control`,
145
+ plan: { ...plan, accounts: plan.accounts.map((a) => (a.id === funding.id ? shiftedFunding : a)) },
146
+ });
147
+ }
148
+ }
149
+ pointMeta.push({ pct, premium, annualIncome, controlId });
150
+ }
151
+ const comparison = comparePlansOnSharedMarketPaths(variants, opts);
152
+ const rowById = new Map(comparison.rows.map((row) => [row.id, row]));
153
+ const baselineRow = rowById.get('annuitize-0');
154
+ if (!baselineRow)
155
+ return { points: [], startAge, payoutRatePct: payoutRate * 100, rateSource, attributionAvailable: false, notes };
156
+ const points = [
157
+ { allocationPct: 0, effectiveAllocationPct: 0, premium: 0, annualIncome: 0, metrics: metricsFromRow(baselineRow) },
158
+ ];
159
+ let anyControl = false;
160
+ let anyCapped = false;
161
+ for (const meta of pointMeta) {
162
+ const row = rowById.get(`annuitize-${meta.pct}`);
163
+ if (!row)
164
+ continue;
165
+ const controlRow = meta.controlId ? rowById.get(meta.controlId) : undefined;
166
+ if (controlRow)
167
+ anyControl = true;
168
+ const effectiveAllocationPct = total > 0 ? (meta.premium / total) * 100 : 0;
169
+ if (effectiveAllocationPct < meta.pct - 0.05)
170
+ anyCapped = true;
171
+ points.push({
172
+ allocationPct: meta.pct,
173
+ effectiveAllocationPct,
174
+ premium: meta.premium,
175
+ annualIncome: meta.annualIncome,
176
+ metrics: metricsFromRow(row),
177
+ glidepathControl: controlRow ? metricsFromRow(controlRow) : undefined,
178
+ });
179
+ }
180
+ if (anyCapped) {
181
+ notes.push('Some grid points were capped by the funding account (the largest cash/taxable balance): the reported share is the premium actually funded ÷ investable assets, not the requested grid value.');
182
+ }
183
+ return { points, startAge, payoutRatePct: payoutRate * 100, rateSource, attributionAvailable: anyControl, notes };
184
+ }