@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
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
|
+
}
|