@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,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ledger-native decision engine (DOCS/enhancements/ledger-native-decision-engine.md).
|
|
3
|
+
*
|
|
4
|
+
* Shared foundation for Insights, the Roth & Tax Optimizer, Social Security
|
|
5
|
+
* candidates, and future decision tools: candidates are generated cheaply,
|
|
6
|
+
* evaluated exactly through `simulatePlan`, ranked by objective policies, and
|
|
7
|
+
* only exact-ledger winners become recommendations.
|
|
8
|
+
*/
|
|
9
|
+
export * from './types.js';
|
|
10
|
+
export * from './evaluateCandidate.js';
|
|
11
|
+
export * from './objectives.js';
|
|
12
|
+
export * from './generators.js';
|
|
13
|
+
export * from './tournament.js';
|
|
14
|
+
export * from './search.js';
|
|
15
|
+
export * from './stochastic.js';
|
|
16
|
+
export * from './spendingSolver.js';
|
|
17
|
+
export * from './insightsAdapter.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ledger-native decision engine (DOCS/enhancements/ledger-native-decision-engine.md).
|
|
3
|
+
*
|
|
4
|
+
* Shared foundation for Insights, the Roth & Tax Optimizer, Social Security
|
|
5
|
+
* candidates, and future decision tools: candidates are generated cheaply,
|
|
6
|
+
* evaluated exactly through `simulatePlan`, ranked by objective policies, and
|
|
7
|
+
* only exact-ledger winners become recommendations.
|
|
8
|
+
*/
|
|
9
|
+
export * from './types.js';
|
|
10
|
+
export * from './evaluateCandidate.js';
|
|
11
|
+
export * from './objectives.js';
|
|
12
|
+
export * from './generators.js';
|
|
13
|
+
export * from './tournament.js';
|
|
14
|
+
export * from './search.js';
|
|
15
|
+
export * from './stochastic.js';
|
|
16
|
+
export * from './spendingSolver.js';
|
|
17
|
+
export * from './insightsAdapter.js';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin adapter between Insights detector actions and the shared exact-ledger
|
|
3
|
+
* evaluator (ledger-native decision engine, Phases 1 & 6).
|
|
4
|
+
*
|
|
5
|
+
* Preview-scenario and apply-toggle actions are already scenario-style plan
|
|
6
|
+
* patches, so an InsightCard maps 1:1 onto a `DecisionCandidate`. Evaluating
|
|
7
|
+
* it here — instead of through a separate compare path — keeps an Insights
|
|
8
|
+
* card's exact numbers and recommendation state identical to what the Roth &
|
|
9
|
+
* Tax Optimizer (or any other tournament surface) would report for the same
|
|
10
|
+
* change.
|
|
11
|
+
*/
|
|
12
|
+
import type { InsightAction, InsightCard, InsightImpact } from '../insights/types.js';
|
|
13
|
+
import { type EvaluateCandidateOptions } from './evaluateCandidate.js';
|
|
14
|
+
import type { DecisionCandidate, DecisionContext, ExactDecisionEvaluation } from './types.js';
|
|
15
|
+
/** Convert a modelable insight action into a decision candidate; null for advisory actions. */
|
|
16
|
+
export declare function candidateFromInsight(card: Pick<InsightCard, 'id' | 'category' | 'title' | 'rationale'>, action: InsightAction): DecisionCandidate | null;
|
|
17
|
+
export interface InsightExactEvaluation {
|
|
18
|
+
evaluation: ExactDecisionEvaluation;
|
|
19
|
+
/** Exact impact in the shape InsightCard UI already renders. */
|
|
20
|
+
impact: InsightImpact;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Evaluate an insight action on the exact ledger. Throws for advisory actions
|
|
24
|
+
* (nothing to model) and surfaces invalid patches as a diagnostic evaluation,
|
|
25
|
+
* mirroring every other decision surface.
|
|
26
|
+
*/
|
|
27
|
+
export declare function evaluateInsightAction(ctx: DecisionContext, card: Pick<InsightCard, 'id' | 'category' | 'title' | 'rationale'>, action: InsightAction, options?: EvaluateCandidateOptions): InsightExactEvaluation;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin adapter between Insights detector actions and the shared exact-ledger
|
|
3
|
+
* evaluator (ledger-native decision engine, Phases 1 & 6).
|
|
4
|
+
*
|
|
5
|
+
* Preview-scenario and apply-toggle actions are already scenario-style plan
|
|
6
|
+
* patches, so an InsightCard maps 1:1 onto a `DecisionCandidate`. Evaluating
|
|
7
|
+
* it here — instead of through a separate compare path — keeps an Insights
|
|
8
|
+
* card's exact numbers and recommendation state identical to what the Roth &
|
|
9
|
+
* Tax Optimizer (or any other tournament surface) would report for the same
|
|
10
|
+
* change.
|
|
11
|
+
*/
|
|
12
|
+
import { evaluateCandidate } from './evaluateCandidate.js';
|
|
13
|
+
const CATEGORY_BY_INSIGHT = {
|
|
14
|
+
'tax-brackets': 'roth',
|
|
15
|
+
'accounts-contributions': 'spending',
|
|
16
|
+
'withdrawals-charitable': 'withdrawal',
|
|
17
|
+
'sequence-risk': 'spending',
|
|
18
|
+
'social-security': 'social-security',
|
|
19
|
+
'longevity-insurance-geography': 'insurance',
|
|
20
|
+
};
|
|
21
|
+
/** Convert a modelable insight action into a decision candidate; null for advisory actions. */
|
|
22
|
+
export function candidateFromInsight(card, action) {
|
|
23
|
+
if (action.kind === 'advisory')
|
|
24
|
+
return null;
|
|
25
|
+
return {
|
|
26
|
+
id: `insight-${card.id}`,
|
|
27
|
+
source: 'detector',
|
|
28
|
+
category: CATEGORY_BY_INSIGHT[card.category],
|
|
29
|
+
label: action.kind === 'preview-scenario' ? action.scenarioName : card.title,
|
|
30
|
+
explanation: card.rationale,
|
|
31
|
+
planPatch: action.patch,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Evaluate an insight action on the exact ledger. Throws for advisory actions
|
|
36
|
+
* (nothing to model) and surfaces invalid patches as a diagnostic evaluation,
|
|
37
|
+
* mirroring every other decision surface.
|
|
38
|
+
*/
|
|
39
|
+
export function evaluateInsightAction(ctx, card, action, options) {
|
|
40
|
+
const candidate = candidateFromInsight(card, action);
|
|
41
|
+
if (!candidate)
|
|
42
|
+
throw new Error('Advisory insights have no modelable action to evaluate.');
|
|
43
|
+
const evaluation = evaluateCandidate(ctx, candidate, options);
|
|
44
|
+
return {
|
|
45
|
+
evaluation,
|
|
46
|
+
impact: {
|
|
47
|
+
endingAfterTaxEstateDelta: evaluation.deltas.endingAfterTaxEstate,
|
|
48
|
+
lifetimeTaxDelta: evaluation.deltas.lifetimeTax,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Objective and constraint policies (ledger-native decision engine, Phase 1.5).
|
|
3
|
+
*
|
|
4
|
+
* A policy defines what "better" means for ranking exact-ledger evaluations:
|
|
5
|
+
* one primary metric (higher is better), hard constraints that disqualify a
|
|
6
|
+
* candidate outright, and secondary context metrics. Policies never simulate;
|
|
7
|
+
* they only read evaluations the exact ledger already produced, so no surface
|
|
8
|
+
* can pick an objective that bypasses exact evaluation.
|
|
9
|
+
*/
|
|
10
|
+
import type { Plan } from '../model/plan.js';
|
|
11
|
+
import type { ProjectionResult } from '../projection/types.js';
|
|
12
|
+
import type { DecisionContext, ExactDecisionEvaluation } from './types.js';
|
|
13
|
+
export type ObjectivePolicyId = 'max-after-tax-estate' | 'max-spending-durability' | 'min-lifetime-tax-estate-floor' | 'protect-survivor-liquidity' | 'bridge-durability' | 'max-sustainable-spending' | 'max-downside-resilience';
|
|
14
|
+
export interface ObjectivePolicy {
|
|
15
|
+
id: ObjectivePolicyId;
|
|
16
|
+
label: string;
|
|
17
|
+
description: string;
|
|
18
|
+
primaryMetricLabel: string;
|
|
19
|
+
/** Higher is better. Deterministic function of the evaluation (and baseline via ctx). */
|
|
20
|
+
primaryMetric(evaluation: ExactDecisionEvaluation, ctx: DecisionContext): number;
|
|
21
|
+
/** Ties on the primary metric break on this (higher is better); defaults to estate delta. */
|
|
22
|
+
tieBreaker?(evaluation: ExactDecisionEvaluation, ctx: DecisionContext): number;
|
|
23
|
+
/** Human-readable hard-constraint violations; non-empty ⇒ candidate is ineligible. */
|
|
24
|
+
constraintViolations(evaluation: ExactDecisionEvaluation, ctx: DecisionContext): string[];
|
|
25
|
+
/** Secondary metrics shown as context, never used for ranking. */
|
|
26
|
+
secondaryMetrics(evaluation: ExactDecisionEvaluation): Array<{
|
|
27
|
+
label: string;
|
|
28
|
+
value: number;
|
|
29
|
+
}>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Primary policy: maximize the ending after-tax estate, never materially
|
|
33
|
+
* worsening depletion. This is the conservative default the first shared
|
|
34
|
+
* tournament ships with.
|
|
35
|
+
*/
|
|
36
|
+
export declare const maximizeAfterTaxEstate: ObjectivePolicy;
|
|
37
|
+
/**
|
|
38
|
+
* Maximize sustainable spending durability: money must last at least as long,
|
|
39
|
+
* ranked by how much longer it lasts, estate as tie-break.
|
|
40
|
+
*/
|
|
41
|
+
export declare const maximizeSpendingDurability: ObjectivePolicy;
|
|
42
|
+
/**
|
|
43
|
+
* Minimize lifetime taxes and penalties, subject to an after-tax-estate floor.
|
|
44
|
+
* With no bequest target the floor is "the estate must not worsen" — the
|
|
45
|
+
* classic "tax savings that aren't estate-negative" screen. With a bequest
|
|
46
|
+
* target (today's dollars, e.g. `plan.expenses.bequestTargetDollars`) the
|
|
47
|
+
* floor is absolute instead: the candidate's ending after-tax estate must
|
|
48
|
+
* clear the target inflated to nominal end-of-plan dollars.
|
|
49
|
+
*/
|
|
50
|
+
export declare function makeMinimizeLifetimeTaxWithEstateFloor(bequestTargetTodayDollars?: number): ObjectivePolicy;
|
|
51
|
+
export declare const minimizeLifetimeTaxWithEstateFloor: ObjectivePolicy;
|
|
52
|
+
/**
|
|
53
|
+
* Protect survivor-year liquidity: rank by the minimum investable balance in
|
|
54
|
+
* years where exactly one spouse survives. Falls back to the estate delta for
|
|
55
|
+
* plans with no survivor years (single household or joint survival throughout).
|
|
56
|
+
* With a survivor reserve target (today's dollars) the policy also enforces a
|
|
57
|
+
* hard floor: a candidate whose worst survivor-year investable balance falls
|
|
58
|
+
* below the target is disqualified with a readable loss reason.
|
|
59
|
+
*/
|
|
60
|
+
export declare function makeProtectSurvivorLiquidity(reserveTargetTodayDollars?: number): ObjectivePolicy;
|
|
61
|
+
export declare const protectSurvivorLiquidity: ObjectivePolicy;
|
|
62
|
+
/**
|
|
63
|
+
* Improve early-retirement bridge durability: rank by the minimum investable
|
|
64
|
+
* balance across bridge years (retired, pre-Social-Security, pre-RMD). Falls
|
|
65
|
+
* back to the estate delta when the plan has no bridge years.
|
|
66
|
+
*/
|
|
67
|
+
export declare const improveBridgeDurability: ObjectivePolicy;
|
|
68
|
+
/**
|
|
69
|
+
* Convert a today's-dollars amount to nominal dollars at the plan's end year
|
|
70
|
+
* under the deterministic inflation assumption. Projection balances (and
|
|
71
|
+
* therefore `endingAfterTaxEstate`) are nominal, while user inputs like
|
|
72
|
+
* spending and estate floors are today's dollars — comparisons must bring one
|
|
73
|
+
* side to the other. Pass the plan the projection actually ran (the candidate
|
|
74
|
+
* plan when the candidate's patch could change assumptions).
|
|
75
|
+
*/
|
|
76
|
+
export declare function nominalDollarsAtPlanEnd(todayDollars: number, plan: Plan, result: ProjectionResult): number;
|
|
77
|
+
/**
|
|
78
|
+
* Maximize sustainable annual spending: rank by the base spending level a
|
|
79
|
+
* candidate sets, disqualifying any candidate whose exact-ledger run depletes
|
|
80
|
+
* or ends below the required after-tax estate floor. Unlike the money-lasts
|
|
81
|
+
* delta guard, the depletion constraint here is absolute — "sustainable" means
|
|
82
|
+
* the money lasts through the horizon, full stop. The floor is in today's
|
|
83
|
+
* dollars (like `expenses.baseAnnual`) and is inflated to nominal end-of-plan
|
|
84
|
+
* dollars before comparing against the nominal ending estate.
|
|
85
|
+
*/
|
|
86
|
+
export declare function makeMaximizeSustainableSpending(estateFloorTodayDollars?: number): ObjectivePolicy;
|
|
87
|
+
export declare const maximizeSustainableSpending: ObjectivePolicy;
|
|
88
|
+
export declare function makeMaximizeDownsideResilience(successTarget?: number): ObjectivePolicy;
|
|
89
|
+
export declare const maximizeDownsideResilience: ObjectivePolicy;
|
|
90
|
+
export declare const objectivePolicies: Record<ObjectivePolicyId, ObjectivePolicy>;
|
|
91
|
+
/**
|
|
92
|
+
* Resolve a policy id against a concrete plan: floor-style policies pick up
|
|
93
|
+
* the plan's bequest target (`expenses.bequestTargetDollars`) so every surface
|
|
94
|
+
* prices the same floor without re-plumbing it call site by call site.
|
|
95
|
+
*/
|
|
96
|
+
export declare function objectivePolicyForPlan(id: ObjectivePolicyId, plan: Plan): ObjectivePolicy;
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Objective and constraint policies (ledger-native decision engine, Phase 1.5).
|
|
3
|
+
*
|
|
4
|
+
* A policy defines what "better" means for ranking exact-ledger evaluations:
|
|
5
|
+
* one primary metric (higher is better), hard constraints that disqualify a
|
|
6
|
+
* candidate outright, and secondary context metrics. Policies never simulate;
|
|
7
|
+
* they only read evaluations the exact ledger already produced, so no surface
|
|
8
|
+
* can pick an objective that bypasses exact evaluation.
|
|
9
|
+
*/
|
|
10
|
+
import { planForCandidate } from './evaluateCandidate.js';
|
|
11
|
+
const centRound = (value) => Math.round(value * 100) / 100;
|
|
12
|
+
function standardSecondaryMetrics(evaluation) {
|
|
13
|
+
return [
|
|
14
|
+
{ label: 'Lifetime tax delta', value: centRound(evaluation.deltas.lifetimeTax) },
|
|
15
|
+
{ label: 'Ending net worth delta', value: centRound(evaluation.deltas.endingNetWorth) },
|
|
16
|
+
{ label: 'Money-lasts delta (years)', value: evaluation.deltas.moneyLastsYears },
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
function moneyLastsViolations(evaluation) {
|
|
20
|
+
if (evaluation.deltas.moneyLastsYears < 0) {
|
|
21
|
+
return [`shortens money-lasts by ${-evaluation.deltas.moneyLastsYears} year(s)`];
|
|
22
|
+
}
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
function structuralViolations(evaluation) {
|
|
26
|
+
if (evaluation.recommendationState === 'diagnostic') {
|
|
27
|
+
return ['diagnostic-only evaluation (invalid patch or materially unexecuted schedule)'];
|
|
28
|
+
}
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Minimum investable balance over the years `selectYears` picks, deflated by
|
|
33
|
+
* nothing (nominal — both sides deflate identically so deltas are comparable).
|
|
34
|
+
* Returns null when no year matches.
|
|
35
|
+
*/
|
|
36
|
+
function minInvestableOver(result, selectYears) {
|
|
37
|
+
let min = null;
|
|
38
|
+
for (const year of result.years) {
|
|
39
|
+
if (!selectYears(year))
|
|
40
|
+
continue;
|
|
41
|
+
if (min === null || year.investableTotal < min)
|
|
42
|
+
min = year.investableTotal;
|
|
43
|
+
}
|
|
44
|
+
return min;
|
|
45
|
+
}
|
|
46
|
+
function survivorYearFilter(year) {
|
|
47
|
+
const alive = year.people.filter((person) => person.alive).length;
|
|
48
|
+
return year.people.length > 1 && alive === 1;
|
|
49
|
+
}
|
|
50
|
+
/** Pre-RMD, post-wage years with no Social Security yet — the early-retirement bridge. */
|
|
51
|
+
function bridgeYearFilter(year) {
|
|
52
|
+
const anyPreRmd = year.people.some((person) => person.alive && person.ageAttained < 73);
|
|
53
|
+
return year.incomes.wages < 10_000 && year.incomes.socialSecurity <= 0 && anyPreRmd;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Primary policy: maximize the ending after-tax estate, never materially
|
|
57
|
+
* worsening depletion. This is the conservative default the first shared
|
|
58
|
+
* tournament ships with.
|
|
59
|
+
*/
|
|
60
|
+
export const maximizeAfterTaxEstate = {
|
|
61
|
+
id: 'max-after-tax-estate',
|
|
62
|
+
label: 'Maximize after-tax estate',
|
|
63
|
+
description: 'Largest projected ending estate net of heir tax, with money-lasts as a hard guardrail.',
|
|
64
|
+
primaryMetricLabel: 'Ending after-tax estate delta',
|
|
65
|
+
primaryMetric: (evaluation) => evaluation.deltas.endingAfterTaxEstate,
|
|
66
|
+
constraintViolations: (evaluation) => [...structuralViolations(evaluation), ...moneyLastsViolations(evaluation)],
|
|
67
|
+
secondaryMetrics: standardSecondaryMetrics,
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Maximize sustainable spending durability: money must last at least as long,
|
|
71
|
+
* ranked by how much longer it lasts, estate as tie-break.
|
|
72
|
+
*/
|
|
73
|
+
export const maximizeSpendingDurability = {
|
|
74
|
+
id: 'max-spending-durability',
|
|
75
|
+
label: 'Maximize spending durability',
|
|
76
|
+
description: 'Money lasting longer beats a bigger estate; depletion must never arrive sooner.',
|
|
77
|
+
primaryMetricLabel: 'Money-lasts delta (years)',
|
|
78
|
+
primaryMetric: (evaluation) => evaluation.deltas.moneyLastsYears,
|
|
79
|
+
tieBreaker: (evaluation) => evaluation.deltas.endingAfterTaxEstate,
|
|
80
|
+
constraintViolations: (evaluation) => [...structuralViolations(evaluation), ...moneyLastsViolations(evaluation)],
|
|
81
|
+
secondaryMetrics: standardSecondaryMetrics,
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Minimize lifetime taxes and penalties, subject to an after-tax-estate floor.
|
|
85
|
+
* With no bequest target the floor is "the estate must not worsen" — the
|
|
86
|
+
* classic "tax savings that aren't estate-negative" screen. With a bequest
|
|
87
|
+
* target (today's dollars, e.g. `plan.expenses.bequestTargetDollars`) the
|
|
88
|
+
* floor is absolute instead: the candidate's ending after-tax estate must
|
|
89
|
+
* clear the target inflated to nominal end-of-plan dollars.
|
|
90
|
+
*/
|
|
91
|
+
export function makeMinimizeLifetimeTaxWithEstateFloor(bequestTargetTodayDollars = 0) {
|
|
92
|
+
return {
|
|
93
|
+
id: 'min-lifetime-tax-estate-floor',
|
|
94
|
+
label: 'Minimize lifetime tax (estate floor)',
|
|
95
|
+
description: bequestTargetTodayDollars > 0
|
|
96
|
+
? `Lowest lifetime taxes and penalties among candidates that keep the after-tax estate at or above the $${Math.round(bequestTargetTodayDollars).toLocaleString()} bequest target (today's dollars).`
|
|
97
|
+
: 'Lowest lifetime taxes and penalties among candidates that do not reduce the after-tax estate.',
|
|
98
|
+
primaryMetricLabel: 'Lifetime tax savings',
|
|
99
|
+
primaryMetric: (evaluation) => -evaluation.deltas.lifetimeTax,
|
|
100
|
+
tieBreaker: (evaluation) => evaluation.deltas.endingAfterTaxEstate,
|
|
101
|
+
constraintViolations: (evaluation, ctx) => {
|
|
102
|
+
const violations = [...structuralViolations(evaluation), ...moneyLastsViolations(evaluation)];
|
|
103
|
+
if (bequestTargetTodayDollars > 0) {
|
|
104
|
+
// Inflate under the candidate plan's own assumptions — its patch may
|
|
105
|
+
// change inflation, and the estate it produced is nominal.
|
|
106
|
+
const built = planForCandidate(ctx.plan, evaluation.candidate);
|
|
107
|
+
const candidatePlan = built.ok ? built.plan : ctx.plan;
|
|
108
|
+
const nominalFloor = nominalDollarsAtPlanEnd(bequestTargetTodayDollars, candidatePlan, evaluation.candidateResult);
|
|
109
|
+
if (evaluation.candidateSummary.endingAfterTaxEstate < nominalFloor) {
|
|
110
|
+
violations.push(`ending after-tax estate falls below the $${Math.round(bequestTargetTodayDollars).toLocaleString()} bequest target (today's dollars)`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else if (evaluation.deltas.endingAfterTaxEstate < -1) {
|
|
114
|
+
violations.push(`reduces the after-tax estate by $${Math.round(-evaluation.deltas.endingAfterTaxEstate).toLocaleString()}`);
|
|
115
|
+
}
|
|
116
|
+
return violations;
|
|
117
|
+
},
|
|
118
|
+
secondaryMetrics: standardSecondaryMetrics,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export const minimizeLifetimeTaxWithEstateFloor = makeMinimizeLifetimeTaxWithEstateFloor();
|
|
122
|
+
/** Worst survivor-year investable balance, deflated to today's dollars. */
|
|
123
|
+
function minDeflatedSurvivorInvestable(result, plan) {
|
|
124
|
+
const startYear = result.years[0]?.year ?? result.endYear;
|
|
125
|
+
const inflationRate = plan.assumptions.inflationPct / 100;
|
|
126
|
+
let min = null;
|
|
127
|
+
for (const year of result.years) {
|
|
128
|
+
if (!survivorYearFilter(year))
|
|
129
|
+
continue;
|
|
130
|
+
const deflated = year.investableTotal / Math.pow(1 + inflationRate, year.year - startYear);
|
|
131
|
+
if (min === null || deflated < min)
|
|
132
|
+
min = deflated;
|
|
133
|
+
}
|
|
134
|
+
return min;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Protect survivor-year liquidity: rank by the minimum investable balance in
|
|
138
|
+
* years where exactly one spouse survives. Falls back to the estate delta for
|
|
139
|
+
* plans with no survivor years (single household or joint survival throughout).
|
|
140
|
+
* With a survivor reserve target (today's dollars) the policy also enforces a
|
|
141
|
+
* hard floor: a candidate whose worst survivor-year investable balance falls
|
|
142
|
+
* below the target is disqualified with a readable loss reason.
|
|
143
|
+
*/
|
|
144
|
+
export function makeProtectSurvivorLiquidity(reserveTargetTodayDollars = 0) {
|
|
145
|
+
return {
|
|
146
|
+
id: 'protect-survivor-liquidity',
|
|
147
|
+
label: 'Protect survivor liquidity',
|
|
148
|
+
description: reserveTargetTodayDollars > 0
|
|
149
|
+
? `Raise the worst-case survivor-year investable balance while keeping it at or above the $${Math.round(reserveTargetTodayDollars).toLocaleString()} reserve target (today's dollars); never shorten money-lasts.`
|
|
150
|
+
: 'Raise the worst-case investable balance during survivor years; never shorten money-lasts.',
|
|
151
|
+
primaryMetricLabel: 'Minimum survivor-year investable delta',
|
|
152
|
+
primaryMetric: (evaluation, ctx) => {
|
|
153
|
+
const candidateMin = minInvestableOver(evaluation.candidateResult, survivorYearFilter);
|
|
154
|
+
const baselineMin = minInvestableOver(ctx.baselineResult, survivorYearFilter);
|
|
155
|
+
if (candidateMin === null || baselineMin === null)
|
|
156
|
+
return evaluation.deltas.endingAfterTaxEstate;
|
|
157
|
+
return candidateMin - baselineMin;
|
|
158
|
+
},
|
|
159
|
+
tieBreaker: (evaluation) => evaluation.deltas.endingAfterTaxEstate,
|
|
160
|
+
constraintViolations: (evaluation, ctx) => {
|
|
161
|
+
const violations = [...structuralViolations(evaluation), ...moneyLastsViolations(evaluation)];
|
|
162
|
+
if (reserveTargetTodayDollars > 0) {
|
|
163
|
+
const built = planForCandidate(ctx.plan, evaluation.candidate);
|
|
164
|
+
const candidatePlan = built.ok ? built.plan : ctx.plan;
|
|
165
|
+
const worst = minDeflatedSurvivorInvestable(evaluation.candidateResult, candidatePlan);
|
|
166
|
+
if (worst !== null && worst < reserveTargetTodayDollars - 1) {
|
|
167
|
+
violations.push(`survivor-year investable balance falls to $${Math.round(worst).toLocaleString()} (today's dollars), below the $${Math.round(reserveTargetTodayDollars).toLocaleString()} reserve target`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return violations;
|
|
171
|
+
},
|
|
172
|
+
secondaryMetrics: standardSecondaryMetrics,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
export const protectSurvivorLiquidity = makeProtectSurvivorLiquidity();
|
|
176
|
+
/**
|
|
177
|
+
* Improve early-retirement bridge durability: rank by the minimum investable
|
|
178
|
+
* balance across bridge years (retired, pre-Social-Security, pre-RMD). Falls
|
|
179
|
+
* back to the estate delta when the plan has no bridge years.
|
|
180
|
+
*/
|
|
181
|
+
export const improveBridgeDurability = {
|
|
182
|
+
id: 'bridge-durability',
|
|
183
|
+
label: 'Improve bridge durability',
|
|
184
|
+
description: 'Raise the worst-case investable balance across pre-Social-Security bridge years.',
|
|
185
|
+
primaryMetricLabel: 'Minimum bridge-year investable delta',
|
|
186
|
+
primaryMetric: (evaluation, ctx) => {
|
|
187
|
+
const candidateMin = minInvestableOver(evaluation.candidateResult, bridgeYearFilter);
|
|
188
|
+
const baselineMin = minInvestableOver(ctx.baselineResult, bridgeYearFilter);
|
|
189
|
+
if (candidateMin === null || baselineMin === null)
|
|
190
|
+
return evaluation.deltas.endingAfterTaxEstate;
|
|
191
|
+
return candidateMin - baselineMin;
|
|
192
|
+
},
|
|
193
|
+
tieBreaker: (evaluation) => evaluation.deltas.endingAfterTaxEstate,
|
|
194
|
+
constraintViolations: (evaluation) => [...structuralViolations(evaluation), ...moneyLastsViolations(evaluation)],
|
|
195
|
+
secondaryMetrics: standardSecondaryMetrics,
|
|
196
|
+
};
|
|
197
|
+
/**
|
|
198
|
+
* Convert a today's-dollars amount to nominal dollars at the plan's end year
|
|
199
|
+
* under the deterministic inflation assumption. Projection balances (and
|
|
200
|
+
* therefore `endingAfterTaxEstate`) are nominal, while user inputs like
|
|
201
|
+
* spending and estate floors are today's dollars — comparisons must bring one
|
|
202
|
+
* side to the other. Pass the plan the projection actually ran (the candidate
|
|
203
|
+
* plan when the candidate's patch could change assumptions).
|
|
204
|
+
*/
|
|
205
|
+
export function nominalDollarsAtPlanEnd(todayDollars, plan, result) {
|
|
206
|
+
const inflationRate = plan.assumptions.inflationPct / 100;
|
|
207
|
+
const startYear = result.years[0]?.year ?? result.endYear;
|
|
208
|
+
return todayDollars * Math.pow(1 + inflationRate, result.endYear - startYear);
|
|
209
|
+
}
|
|
210
|
+
/** The annual base spending a candidate's patch sets, falling back to the plan's own. */
|
|
211
|
+
function candidateBaseAnnual(evaluation, ctx) {
|
|
212
|
+
const expenses = evaluation.candidate.planPatch?.['expenses'];
|
|
213
|
+
if (expenses && typeof expenses === 'object') {
|
|
214
|
+
const baseAnnual = expenses['baseAnnual'];
|
|
215
|
+
if (typeof baseAnnual === 'number' && Number.isFinite(baseAnnual))
|
|
216
|
+
return baseAnnual;
|
|
217
|
+
}
|
|
218
|
+
return ctx.plan.expenses.baseAnnual;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Maximize sustainable annual spending: rank by the base spending level a
|
|
222
|
+
* candidate sets, disqualifying any candidate whose exact-ledger run depletes
|
|
223
|
+
* or ends below the required after-tax estate floor. Unlike the money-lasts
|
|
224
|
+
* delta guard, the depletion constraint here is absolute — "sustainable" means
|
|
225
|
+
* the money lasts through the horizon, full stop. The floor is in today's
|
|
226
|
+
* dollars (like `expenses.baseAnnual`) and is inflated to nominal end-of-plan
|
|
227
|
+
* dollars before comparing against the nominal ending estate.
|
|
228
|
+
*/
|
|
229
|
+
export function makeMaximizeSustainableSpending(estateFloorTodayDollars = 0) {
|
|
230
|
+
return {
|
|
231
|
+
id: 'max-sustainable-spending',
|
|
232
|
+
label: 'Maximize sustainable spending',
|
|
233
|
+
description: 'Highest annual base spending whose full projection never depletes and keeps the after-tax estate at or above the floor.',
|
|
234
|
+
primaryMetricLabel: 'Annual base spending (today’s dollars)',
|
|
235
|
+
primaryMetric: candidateBaseAnnual,
|
|
236
|
+
tieBreaker: (evaluation) => evaluation.deltas.endingAfterTaxEstate,
|
|
237
|
+
constraintViolations: (evaluation, ctx) => {
|
|
238
|
+
const violations = structuralViolations(evaluation);
|
|
239
|
+
const depletionYear = evaluation.candidateResult.depletionYear;
|
|
240
|
+
if (depletionYear !== null)
|
|
241
|
+
violations.push(`portfolio depletes in ${depletionYear}`);
|
|
242
|
+
// Inflate the floor under the candidate plan's own assumptions — its
|
|
243
|
+
// patch may change inflation, and the estate it produced is nominal.
|
|
244
|
+
// A zero floor needs no inflating (or plan rebuild): it still rejects
|
|
245
|
+
// a negative ending estate.
|
|
246
|
+
let nominalFloor = 0;
|
|
247
|
+
if (estateFloorTodayDollars > 0) {
|
|
248
|
+
const built = planForCandidate(ctx.plan, evaluation.candidate);
|
|
249
|
+
const candidatePlan = built.ok ? built.plan : ctx.plan;
|
|
250
|
+
nominalFloor = nominalDollarsAtPlanEnd(estateFloorTodayDollars, candidatePlan, evaluation.candidateResult);
|
|
251
|
+
}
|
|
252
|
+
if (evaluation.candidateSummary.endingAfterTaxEstate < nominalFloor) {
|
|
253
|
+
violations.push(`ending after-tax estate falls below the $${Math.round(estateFloorTodayDollars).toLocaleString()} floor (today's dollars)`);
|
|
254
|
+
}
|
|
255
|
+
return violations;
|
|
256
|
+
},
|
|
257
|
+
secondaryMetrics: standardSecondaryMetrics,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
export const maximizeSustainableSpending = makeMaximizeSustainableSpending();
|
|
261
|
+
export function makeMaximizeDownsideResilience(successTarget = 0.85) {
|
|
262
|
+
return {
|
|
263
|
+
id: 'max-downside-resilience',
|
|
264
|
+
label: 'Maximize downside resilience',
|
|
265
|
+
description: `Opt-in robust ranking: highest 10th-percentile after-tax estate among candidates that keep Monte Carlo success at or above ${Math.round(successTarget * 100)}%.`,
|
|
266
|
+
primaryMetricLabel: '10th-percentile after-tax estate delta',
|
|
267
|
+
primaryMetric: (evaluation) => evaluation.stochastic?.deltas.p10EndingAfterTaxEstate ?? Number.NEGATIVE_INFINITY,
|
|
268
|
+
tieBreaker: (evaluation) => evaluation.stochastic?.deltas.successRate ?? evaluation.deltas.endingAfterTaxEstate,
|
|
269
|
+
constraintViolations: (evaluation) => {
|
|
270
|
+
const violations = [...structuralViolations(evaluation), ...moneyLastsViolations(evaluation)];
|
|
271
|
+
const stochastic = evaluation.stochastic?.candidate;
|
|
272
|
+
if (!stochastic) {
|
|
273
|
+
violations.push('stochastic metrics unavailable for robust ranking');
|
|
274
|
+
return violations;
|
|
275
|
+
}
|
|
276
|
+
if (stochastic.successRate + 1e-9 < successTarget) {
|
|
277
|
+
violations.push(`Monte Carlo success ${Math.round(stochastic.successRate * 100)}% is below the ${Math.round(successTarget * 100)}% robust target`);
|
|
278
|
+
}
|
|
279
|
+
return violations;
|
|
280
|
+
},
|
|
281
|
+
secondaryMetrics: (evaluation) => [
|
|
282
|
+
...standardSecondaryMetrics(evaluation),
|
|
283
|
+
{ label: 'Monte Carlo success delta (pct pts)', value: centRound((evaluation.stochastic?.deltas.successRate ?? 0) * 100) },
|
|
284
|
+
{
|
|
285
|
+
label: 'Expected shortfall delta',
|
|
286
|
+
value: centRound(evaluation.stochastic?.deltas.expectedShortfallDollars ?? 0),
|
|
287
|
+
},
|
|
288
|
+
],
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
export const maximizeDownsideResilience = makeMaximizeDownsideResilience();
|
|
292
|
+
export const objectivePolicies = {
|
|
293
|
+
'max-after-tax-estate': maximizeAfterTaxEstate,
|
|
294
|
+
'max-spending-durability': maximizeSpendingDurability,
|
|
295
|
+
'min-lifetime-tax-estate-floor': minimizeLifetimeTaxWithEstateFloor,
|
|
296
|
+
'protect-survivor-liquidity': protectSurvivorLiquidity,
|
|
297
|
+
'bridge-durability': improveBridgeDurability,
|
|
298
|
+
'max-sustainable-spending': maximizeSustainableSpending,
|
|
299
|
+
'max-downside-resilience': maximizeDownsideResilience,
|
|
300
|
+
};
|
|
301
|
+
/**
|
|
302
|
+
* Resolve a policy id against a concrete plan: floor-style policies pick up
|
|
303
|
+
* the plan's bequest target (`expenses.bequestTargetDollars`) so every surface
|
|
304
|
+
* prices the same floor without re-plumbing it call site by call site.
|
|
305
|
+
*/
|
|
306
|
+
export function objectivePolicyForPlan(id, plan) {
|
|
307
|
+
const bequestTarget = plan.expenses.bequestTargetDollars ?? 0;
|
|
308
|
+
if (bequestTarget > 0 && id === 'min-lifetime-tax-estate-floor') {
|
|
309
|
+
return makeMinimizeLifetimeTaxWithEstateFloor(bequestTarget);
|
|
310
|
+
}
|
|
311
|
+
if (bequestTarget > 0 && id === 'max-sustainable-spending') {
|
|
312
|
+
return makeMaximizeSustainableSpending(bequestTarget);
|
|
313
|
+
}
|
|
314
|
+
const survivorReserve = plan.strategies.survivorReserveTarget ?? 0;
|
|
315
|
+
if (survivorReserve > 0 && id === 'protect-survivor-liquidity') {
|
|
316
|
+
return makeProtectSurvivorLiquidity(survivorReserve);
|
|
317
|
+
}
|
|
318
|
+
return objectivePolicies[id];
|
|
319
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pension lump-sum vs annuity decision module (annuity-pension-and-home-equity
|
|
3
|
+
* decisions, step 3).
|
|
4
|
+
*
|
|
5
|
+
* Two complementary surfaces, verdicts framed as tradeoffs — never advice:
|
|
6
|
+
* - deterministic PV math: the pension annuity's present value at a chosen
|
|
7
|
+
* discount rate, its survivor-option value, and a sensitivity table over
|
|
8
|
+
* discount rate × longevity — the classic "what does the lump sum have to
|
|
9
|
+
* beat" view, golden-tested by hand;
|
|
10
|
+
* - a scenario pair for the exact ledger / shared-path Monte Carlo: keep the
|
|
11
|
+
* annuity vs take the lump sum (a tax-free direct rollover into a
|
|
12
|
+
* traditional account in the election year — see the ledger's
|
|
13
|
+
* pension-lump-sum block), so taxes, survivor interplay, and sequence risk
|
|
14
|
+
* are priced by the engine rather than a side model.
|
|
15
|
+
*/
|
|
16
|
+
import type { Account, Plan } from '../model/plan.js';
|
|
17
|
+
import type { CandidateGenerator } from './types.js';
|
|
18
|
+
type PensionAccount = Extract<Account, {
|
|
19
|
+
type: 'pension';
|
|
20
|
+
}>;
|
|
21
|
+
export interface PensionPvInputs {
|
|
22
|
+
monthlyAmount: number;
|
|
23
|
+
/** Annual COLA (percent); 0 = fixed nominal. */
|
|
24
|
+
colaPct: number;
|
|
25
|
+
/** Percent of the benefit continuing to the survivor after the owner's death. */
|
|
26
|
+
survivorPct: number;
|
|
27
|
+
/** Owner's age when payments start. */
|
|
28
|
+
startAge: number;
|
|
29
|
+
/** Owner's age attained in the valuation year. */
|
|
30
|
+
ownerCurrentAge: number;
|
|
31
|
+
/** Owner's last full year alive (planning age or a sensitivity override). */
|
|
32
|
+
ownerDeathAge: number;
|
|
33
|
+
/** Survivor ages in the valuation year / at their planning horizon; omit for a single household. */
|
|
34
|
+
survivor?: {
|
|
35
|
+
currentAge: number;
|
|
36
|
+
deathAge: number;
|
|
37
|
+
};
|
|
38
|
+
/** Nominal annual discount rate (percent). */
|
|
39
|
+
discountRatePct: number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Present value (valuation-year dollars) of the pension's payment stream:
|
|
43
|
+
* the full benefit while the owner lives (from startAge), then survivorPct of
|
|
44
|
+
* it while the survivor lives — the same continuation rule the ledger applies
|
|
45
|
+
* (survivor benefits require the owner to have reached the start age; a
|
|
46
|
+
* survivor election on a pension that never started pays nothing here too).
|
|
47
|
+
* Payments are nominal with COLA compounding from the start age; discounting
|
|
48
|
+
* is annual at year offsets from the valuation year.
|
|
49
|
+
*/
|
|
50
|
+
export declare function pensionAnnuityPresentValue(inputs: PensionPvInputs): number;
|
|
51
|
+
export interface PensionSensitivityCell {
|
|
52
|
+
discountRatePct: number;
|
|
53
|
+
presentValue: number;
|
|
54
|
+
/** PV ÷ lump sum: > 1 means the annuity is worth more than the offer at this rate/longevity. */
|
|
55
|
+
ratioToLumpSum: number;
|
|
56
|
+
}
|
|
57
|
+
export interface PensionSensitivityRow {
|
|
58
|
+
ownerDeathAge: number;
|
|
59
|
+
cells: PensionSensitivityCell[];
|
|
60
|
+
}
|
|
61
|
+
export interface PensionSensitivityTable {
|
|
62
|
+
rows: PensionSensitivityRow[];
|
|
63
|
+
discountRatesPct: number[];
|
|
64
|
+
lumpSum: number;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Sensitivity of the annuity's PV to discount rate × owner longevity, against
|
|
68
|
+
* the lump-sum offer. Bounded: at most 5 × 5 cells.
|
|
69
|
+
*/
|
|
70
|
+
export declare function pensionLumpSumSensitivity(base: Omit<PensionPvInputs, 'discountRatePct' | 'ownerDeathAge'>, lumpSum: number, discountRatesPct: readonly number[], ownerDeathAges: readonly number[]): PensionSensitivityTable;
|
|
71
|
+
/**
|
|
72
|
+
* Curve-anchored nominal discount rate (percent): the embedded TIPS par real
|
|
73
|
+
* yield at the given horizon plus assumed inflation — the "market rate" column
|
|
74
|
+
* of the sensitivity table (a corporate-bond-style spread is the user's call).
|
|
75
|
+
*/
|
|
76
|
+
export declare function curveNominalDiscountRatePct(horizonYears: number, inflationPct: number): number;
|
|
77
|
+
/**
|
|
78
|
+
* The concrete "take the lump sum" plan patch for a pension with an offer:
|
|
79
|
+
* installs the election, rolling into the largest owner-matched traditional
|
|
80
|
+
* account — or a new zero-balance rollover IRA when the plan has none. Shared
|
|
81
|
+
* by the candidate generator and the pension-election insight so both surfaces
|
|
82
|
+
* model the identical mechanics. Null when the pension carries no offer.
|
|
83
|
+
*/
|
|
84
|
+
export declare function pensionTakeLumpSumPatch(plan: Plan, pension: PensionAccount, startYear: number): {
|
|
85
|
+
accounts: Account[];
|
|
86
|
+
} | null;
|
|
87
|
+
/**
|
|
88
|
+
* Scenario-pair candidates for pensions with a lump-sum offer on record:
|
|
89
|
+
* "take the lump sum" (via pensionTakeLumpSumPatch) and, when already
|
|
90
|
+
* elected, "keep the annuity" (removes the election). Bounded: ≤ 2 pensions,
|
|
91
|
+
* one candidate each; the exact ledger prices both.
|
|
92
|
+
*/
|
|
93
|
+
export declare const pensionLumpSumGenerator: CandidateGenerator;
|
|
94
|
+
export interface PensionDecisionAnalysis {
|
|
95
|
+
pensionId: string;
|
|
96
|
+
pensionName: string;
|
|
97
|
+
lumpSum: number;
|
|
98
|
+
electionYear: number;
|
|
99
|
+
/** PV at the curve-anchored rate, full survivor continuation as configured. */
|
|
100
|
+
presentValueAtCurveRate: number;
|
|
101
|
+
curveRatePct: number;
|
|
102
|
+
/** PV with survivor continuation zeroed — the survivor option's PV value is the difference. */
|
|
103
|
+
presentValueSingleLife: number;
|
|
104
|
+
sensitivity: PensionSensitivityTable;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The decision view's deterministic analysis for every pension carrying a
|
|
108
|
+
* lump-sum offer. Discount rates: the curve-anchored rate ± 1% and two fixed
|
|
109
|
+
* planning rates; longevity: planning age and ±3 years.
|
|
110
|
+
*/
|
|
111
|
+
export declare function analyzePensionElections(plan: Plan, startYear: number): PensionDecisionAnalysis[];
|
|
112
|
+
export {};
|