@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,70 @@
|
|
|
1
|
+
import type { Plan } from '../model/plan.js';
|
|
2
|
+
import type { ProjectionResult } from '../projection/types.js';
|
|
3
|
+
import type { ProjectionSummary } from '../projection/compare.js';
|
|
4
|
+
import type { ParameterPack } from '../params/types.js';
|
|
5
|
+
export type InsightCategory = 'tax-brackets' | 'accounts-contributions' | 'withdrawals-charitable' | 'sequence-risk' | 'social-security' | 'longevity-insurance-geography';
|
|
6
|
+
export type InsightActionKind = 'advisory' | 'preview-scenario' | 'apply-toggle';
|
|
7
|
+
export interface InsightImpact {
|
|
8
|
+
/** Rough (screen) or exact (evaluate) change in ending after-tax estate, today's $. */
|
|
9
|
+
endingAfterTaxEstateDelta?: number;
|
|
10
|
+
/** Change in lifetime taxes & penalties, today's $ (negative = savings). */
|
|
11
|
+
lifetimeTaxDelta?: number;
|
|
12
|
+
/** Change in Monte Carlo success rate, percentage points. */
|
|
13
|
+
successRateDeltaPct?: number;
|
|
14
|
+
/** Free-form for advisory levers that can't produce a clean delta yet. */
|
|
15
|
+
qualitative?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface InsightCard {
|
|
18
|
+
id: string;
|
|
19
|
+
category: InsightCategory;
|
|
20
|
+
title: string;
|
|
21
|
+
rationale: string;
|
|
22
|
+
impact: InsightImpact;
|
|
23
|
+
exact: boolean;
|
|
24
|
+
confidence: 'high' | 'medium' | 'low';
|
|
25
|
+
learnSlug?: string;
|
|
26
|
+
plannerRoute?: string;
|
|
27
|
+
action: InsightAction;
|
|
28
|
+
}
|
|
29
|
+
export type InsightAction = {
|
|
30
|
+
kind: 'advisory';
|
|
31
|
+
} | {
|
|
32
|
+
kind: 'preview-scenario';
|
|
33
|
+
scenarioName: string;
|
|
34
|
+
patch: Record<string, unknown>;
|
|
35
|
+
} | {
|
|
36
|
+
kind: 'apply-toggle';
|
|
37
|
+
patch: Record<string, unknown>;
|
|
38
|
+
};
|
|
39
|
+
export interface DetectorProjection {
|
|
40
|
+
result: ProjectionResult;
|
|
41
|
+
summary: ProjectionSummary;
|
|
42
|
+
startYear: number;
|
|
43
|
+
deflate: (year: number, amount: number) => number;
|
|
44
|
+
}
|
|
45
|
+
export interface DetectorContext {
|
|
46
|
+
plan: Plan;
|
|
47
|
+
projection: DetectorProjection;
|
|
48
|
+
params: ParameterPack;
|
|
49
|
+
}
|
|
50
|
+
export interface Detector {
|
|
51
|
+
id: string;
|
|
52
|
+
category: InsightCategory;
|
|
53
|
+
/**
|
|
54
|
+
* Cheap, synchronous, pure. Reads the baseline projection only — NO new
|
|
55
|
+
* simulate() calls. Returns null when the lever doesn't apply to this plan,
|
|
56
|
+
* or a card with a ROUGH impact estimate (exact:false). Must be fast enough
|
|
57
|
+
* to run all detectors on every committed edit.
|
|
58
|
+
*/
|
|
59
|
+
screen(ctx: DetectorContext): InsightCard | null;
|
|
60
|
+
/**
|
|
61
|
+
* On-demand exact evaluation (only when a card is expanded or previewed).
|
|
62
|
+
* For preview-scenario detectors this builds the patch and the caller runs
|
|
63
|
+
* compareScenarios; this returns the patch + scenario name. Advisory
|
|
64
|
+
* detectors may omit evaluate() entirely.
|
|
65
|
+
*/
|
|
66
|
+
evaluate?(ctx: DetectorContext): {
|
|
67
|
+
action: InsightAction;
|
|
68
|
+
impact?: InsightImpact;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Social Security bridge sizing (social-security-bridge-and-tips-ladder,
|
|
3
|
+
* step 3). The canonical fix for the early-claiming problem (Bipartisan
|
|
4
|
+
* Policy Center, July 2025): instead of claiming at 62, pay yourself the
|
|
5
|
+
* forgone age-62 benefit from a dedicated bridge account until the chosen
|
|
6
|
+
* (usually later, often 70) claim age — buying the delayed benefit's
|
|
7
|
+
* longevity insurance without a lifestyle gap.
|
|
8
|
+
*
|
|
9
|
+
* Pure sizing math: the plan artifact itself is a TIPS ladder
|
|
10
|
+
* (plan.incomeFloor) covering the bridge window; the ledger prices its cash
|
|
11
|
+
* flows and taxes like any other ladder.
|
|
12
|
+
*/
|
|
13
|
+
import type { RealYieldCurve } from '../params/types.js';
|
|
14
|
+
import { type ClaimAge } from '../socialSecurity/claimFactor.js';
|
|
15
|
+
/**
|
|
16
|
+
* Minimum share of the quoted ladder cost the funding account must hold
|
|
17
|
+
* before a bridge is proposed. One threshold shared by the `ss-bridge-gap`
|
|
18
|
+
* Insights detector and `bridgeLadderGenerator`, so a household never gets a
|
|
19
|
+
* decision candidate without the matching Insights card (or vice versa) —
|
|
20
|
+
* the "can you fund this?" story stays consistent across surfaces.
|
|
21
|
+
*/
|
|
22
|
+
export declare const BRIDGE_FUNDING_MIN_FRACTION = 0.5;
|
|
23
|
+
export interface BridgeSizingInput {
|
|
24
|
+
/** Worker's PIA, monthly, today's dollars. */
|
|
25
|
+
piaMonthly: number;
|
|
26
|
+
dob: {
|
|
27
|
+
year: number;
|
|
28
|
+
month: number;
|
|
29
|
+
day: number;
|
|
30
|
+
};
|
|
31
|
+
/** The chosen (or optimized) claim age the bridge funds the wait for. */
|
|
32
|
+
claimAge: ClaimAge;
|
|
33
|
+
/** Calendar year the projection starts ("today"). */
|
|
34
|
+
currentYear: number;
|
|
35
|
+
/**
|
|
36
|
+
* Calendar year retirement-phase spending begins (wages stop). The bridge
|
|
37
|
+
* never starts before age 62 — before that there is no forgone benefit to
|
|
38
|
+
* replace — nor before next year (this year's income is already set).
|
|
39
|
+
*/
|
|
40
|
+
retirementYear: number;
|
|
41
|
+
curve: RealYieldCurve;
|
|
42
|
+
}
|
|
43
|
+
export interface BridgeSizing {
|
|
44
|
+
/** Forgone age-62 benefit: monthly, today's dollars. */
|
|
45
|
+
monthlyAge62Benefit: number;
|
|
46
|
+
/** The bridge's level real payout — the annualized age-62 benefit. */
|
|
47
|
+
annualRealAmount: number;
|
|
48
|
+
/** First calendar year the bridge pays. */
|
|
49
|
+
startYear: number;
|
|
50
|
+
/**
|
|
51
|
+
* Last calendar year the bridge pays: the year before the claim starts, or
|
|
52
|
+
* the claim year itself for a mid-year claim (months > 0) — the ledger pays
|
|
53
|
+
* only the post-claim months of SS that year, so ending the bridge a year
|
|
54
|
+
* earlier would leave the pre-claim months unfunded. Bridging the whole
|
|
55
|
+
* claim year is the conservative planning-grade resolution (the bridge and
|
|
56
|
+
* the new benefit overlap for the post-claim months).
|
|
57
|
+
*/
|
|
58
|
+
endYear: number;
|
|
59
|
+
years: number;
|
|
60
|
+
/**
|
|
61
|
+
* Quoted TIPS-ladder cost (today's $) on the curve, anchored to a purchase
|
|
62
|
+
* THIS year — matching how the SS Optimizer panel, the detector, and the
|
|
63
|
+
* generator all buy the ladder (purchase.year = current year), which is the
|
|
64
|
+
* ledger's anchor for the same rungs.
|
|
65
|
+
*/
|
|
66
|
+
ladderCost: number;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Size the bridge for one claimant, or null when there is no gap to fund
|
|
70
|
+
* (claiming at/before 62, or the claim starts before any bridge year could).
|
|
71
|
+
*/
|
|
72
|
+
export declare function sizeBridge(input: BridgeSizingInput): BridgeSizing | null;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Social Security bridge sizing (social-security-bridge-and-tips-ladder,
|
|
3
|
+
* step 3). The canonical fix for the early-claiming problem (Bipartisan
|
|
4
|
+
* Policy Center, July 2025): instead of claiming at 62, pay yourself the
|
|
5
|
+
* forgone age-62 benefit from a dedicated bridge account until the chosen
|
|
6
|
+
* (usually later, often 70) claim age — buying the delayed benefit's
|
|
7
|
+
* longevity insurance without a lifestyle gap.
|
|
8
|
+
*
|
|
9
|
+
* Pure sizing math: the plan artifact itself is a TIPS ladder
|
|
10
|
+
* (plan.incomeFloor) covering the bridge window; the ledger prices its cash
|
|
11
|
+
* flows and taxes like any other ladder.
|
|
12
|
+
*/
|
|
13
|
+
import { claimFactor } from '../socialSecurity/claimFactor.js';
|
|
14
|
+
import { buildLadder } from './ladderMath.js';
|
|
15
|
+
/**
|
|
16
|
+
* Minimum share of the quoted ladder cost the funding account must hold
|
|
17
|
+
* before a bridge is proposed. One threshold shared by the `ss-bridge-gap`
|
|
18
|
+
* Insights detector and `bridgeLadderGenerator`, so a household never gets a
|
|
19
|
+
* decision candidate without the matching Insights card (or vice versa) —
|
|
20
|
+
* the "can you fund this?" story stays consistent across surfaces.
|
|
21
|
+
*/
|
|
22
|
+
export const BRIDGE_FUNDING_MIN_FRACTION = 0.5;
|
|
23
|
+
/**
|
|
24
|
+
* Size the bridge for one claimant, or null when there is no gap to fund
|
|
25
|
+
* (claiming at/before 62, or the claim starts before any bridge year could).
|
|
26
|
+
*/
|
|
27
|
+
export function sizeBridge(input) {
|
|
28
|
+
const { piaMonthly, dob, claimAge, currentYear, curve } = input;
|
|
29
|
+
if (piaMonthly <= 0)
|
|
30
|
+
return null;
|
|
31
|
+
const claimYears = claimAge.years + claimAge.months / 12;
|
|
32
|
+
if (claimYears <= 62)
|
|
33
|
+
return null;
|
|
34
|
+
const age62Year = dob.year + 62;
|
|
35
|
+
const claimYear = dob.year + Math.floor(claimYears);
|
|
36
|
+
const startYear = Math.max(input.retirementYear, age62Year, currentYear + 1);
|
|
37
|
+
// A mid-year claim (months > 0) pays SS only from the claim month on, so the
|
|
38
|
+
// claim year itself is bridged too (see BridgeSizing.endYear).
|
|
39
|
+
const endYear = claimAge.months > 0 ? claimYear : claimYear - 1;
|
|
40
|
+
if (endYear < startYear)
|
|
41
|
+
return null;
|
|
42
|
+
const monthlyAge62Benefit = piaMonthly * claimFactor(dob.year, dob.month, dob.day, { years: 62, months: 0 });
|
|
43
|
+
const annualRealAmount = monthlyAge62Benefit * 12;
|
|
44
|
+
const years = endYear - startYear + 1;
|
|
45
|
+
const build = buildLadder({
|
|
46
|
+
annualRealIncome: annualRealAmount,
|
|
47
|
+
firstPayoutOffset: startYear - currentYear,
|
|
48
|
+
payoutYears: years,
|
|
49
|
+
curve,
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
monthlyAge62Benefit,
|
|
53
|
+
annualRealAmount,
|
|
54
|
+
startYear,
|
|
55
|
+
endYear,
|
|
56
|
+
years,
|
|
57
|
+
ladderCost: build.totalCost,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FedInvest TIPS price data — the pure half (CSV parsing, price-date math,
|
|
3
|
+
* rung matching). See social-security-bridge-and-tips-ladder, step 5.
|
|
4
|
+
*
|
|
5
|
+
* The engine performs no IO: the actual fetch against the FedInvest service
|
|
6
|
+
* and the per-day localStorage cache live in the consuming app (the web
|
|
7
|
+
* app's `src/data/fedInvestClient.ts`), which hands CSV text to
|
|
8
|
+
* {@link parseFedInvestCsv} and assembles {@link FedInvestSnapshot} values.
|
|
9
|
+
* The embedded real-yield curve always remains the planning source of truth
|
|
10
|
+
* — FedInvest prices are an opt-in, user-triggered reference only.
|
|
11
|
+
*
|
|
12
|
+
* Data: FedInvest publishes end-of-day prices per $100 face for every
|
|
13
|
+
* marketable Treasury, including TIPS, keyed by CUSIP (CSV columns: cusip,
|
|
14
|
+
* security type, rate as a decimal fraction, maturity date, call date, buy,
|
|
15
|
+
* sell, end-of-day). We surface the TIPS rows as a reference against the
|
|
16
|
+
* embedded-curve quote. (Planning note: TIPS trade with a separate inflation
|
|
17
|
+
* index ratio FedInvest does not include, so these prices sanity-check the
|
|
18
|
+
* quote rather than replace it.)
|
|
19
|
+
*/
|
|
20
|
+
export interface FedInvestTips {
|
|
21
|
+
cusip: string;
|
|
22
|
+
/** Coupon rate, percent. */
|
|
23
|
+
ratePct: number;
|
|
24
|
+
/** Maturity date, ISO YYYY-MM-DD. */
|
|
25
|
+
maturityIso: string;
|
|
26
|
+
/** End-of-day price per $100 face. */
|
|
27
|
+
endOfDayPrice: number;
|
|
28
|
+
}
|
|
29
|
+
export interface FedInvestSnapshot {
|
|
30
|
+
/**
|
|
31
|
+
* Price date the quotes are for (ISO). null for imported files: the
|
|
32
|
+
* FedInvest CSV does not carry its own date, so we never guess one — the
|
|
33
|
+
* prices are "as of whenever the user downloaded the file".
|
|
34
|
+
*/
|
|
35
|
+
priceDateIso: string | null;
|
|
36
|
+
/** When the fetch/import happened (ISO timestamp). */
|
|
37
|
+
fetchedAtIso: string;
|
|
38
|
+
/** 'fetch' = direct FedInvest request; 'import' = user-supplied CSV file. */
|
|
39
|
+
source: 'fetch' | 'import';
|
|
40
|
+
tips: FedInvestTips[];
|
|
41
|
+
}
|
|
42
|
+
export declare const FEDINVEST_PAGE_URL = "https://www.treasurydirect.gov/GA-FI/FedInvest/selectSecurityPriceDate";
|
|
43
|
+
export declare const FEDINVEST_CSV_URL = "https://www.treasurydirect.gov/GA-FI/FedInvest/securityPriceDetail";
|
|
44
|
+
/** Most recent likely-published business day (prices publish evenings, US). */
|
|
45
|
+
export declare function latestPriceDate(now?: Date): Date;
|
|
46
|
+
/** Same as {@link latestPriceDate}, as the ISO string used by snapshots and staleness checks. */
|
|
47
|
+
export declare function latestPriceDateIso(now?: Date): string;
|
|
48
|
+
/**
|
|
49
|
+
* Parse the FedInvest `securityprice.csv` payload: one row per security,
|
|
50
|
+
* `cusip,type,rate,maturity(MM/DD/YYYY),call,buy,sell,endOfDay`, no header.
|
|
51
|
+
* Rates arrive as decimal fractions (0.00125 = 0.125%).
|
|
52
|
+
*/
|
|
53
|
+
export declare function parseFedInvestCsv(text: string): FedInvestTips[];
|
|
54
|
+
/** The TIPS whose maturity falls nearest a target calendar year (for rung reference rows). */
|
|
55
|
+
export declare function nearestTipsForYear(tips: FedInvestTips[], year: number): FedInvestTips | null;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FedInvest TIPS price data — the pure half (CSV parsing, price-date math,
|
|
3
|
+
* rung matching). See social-security-bridge-and-tips-ladder, step 5.
|
|
4
|
+
*
|
|
5
|
+
* The engine performs no IO: the actual fetch against the FedInvest service
|
|
6
|
+
* and the per-day localStorage cache live in the consuming app (the web
|
|
7
|
+
* app's `src/data/fedInvestClient.ts`), which hands CSV text to
|
|
8
|
+
* {@link parseFedInvestCsv} and assembles {@link FedInvestSnapshot} values.
|
|
9
|
+
* The embedded real-yield curve always remains the planning source of truth
|
|
10
|
+
* — FedInvest prices are an opt-in, user-triggered reference only.
|
|
11
|
+
*
|
|
12
|
+
* Data: FedInvest publishes end-of-day prices per $100 face for every
|
|
13
|
+
* marketable Treasury, including TIPS, keyed by CUSIP (CSV columns: cusip,
|
|
14
|
+
* security type, rate as a decimal fraction, maturity date, call date, buy,
|
|
15
|
+
* sell, end-of-day). We surface the TIPS rows as a reference against the
|
|
16
|
+
* embedded-curve quote. (Planning note: TIPS trade with a separate inflation
|
|
17
|
+
* index ratio FedInvest does not include, so these prices sanity-check the
|
|
18
|
+
* quote rather than replace it.)
|
|
19
|
+
*/
|
|
20
|
+
export const FEDINVEST_PAGE_URL = 'https://www.treasurydirect.gov/GA-FI/FedInvest/selectSecurityPriceDate';
|
|
21
|
+
export const FEDINVEST_CSV_URL = 'https://www.treasurydirect.gov/GA-FI/FedInvest/securityPriceDetail';
|
|
22
|
+
/** Most recent likely-published business day (prices publish evenings, US). */
|
|
23
|
+
export function latestPriceDate(now = new Date()) {
|
|
24
|
+
const d = new Date(now);
|
|
25
|
+
d.setDate(d.getDate() - 1); // yesterday: today's prices aren't out yet
|
|
26
|
+
while (d.getDay() === 0 || d.getDay() === 6)
|
|
27
|
+
d.setDate(d.getDate() - 1);
|
|
28
|
+
return d;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Local calendar date as YYYY-MM-DD. The business-day walk and the FedInvest
|
|
32
|
+
* form body both use LOCAL date components, so the snapshot/cache key must
|
|
33
|
+
* too — `toISOString()` is UTC and disagrees near timezone boundaries.
|
|
34
|
+
*/
|
|
35
|
+
function localIsoDate(d) {
|
|
36
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
37
|
+
}
|
|
38
|
+
/** Same as {@link latestPriceDate}, as the ISO string used by snapshots and staleness checks. */
|
|
39
|
+
export function latestPriceDateIso(now = new Date()) {
|
|
40
|
+
return localIsoDate(latestPriceDate(now));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Parse the FedInvest `securityprice.csv` payload: one row per security,
|
|
44
|
+
* `cusip,type,rate,maturity(MM/DD/YYYY),call,buy,sell,endOfDay`, no header.
|
|
45
|
+
* Rates arrive as decimal fractions (0.00125 = 0.125%).
|
|
46
|
+
*/
|
|
47
|
+
export function parseFedInvestCsv(text) {
|
|
48
|
+
const tips = [];
|
|
49
|
+
for (const line of text.split(/\r?\n/)) {
|
|
50
|
+
const cells = line.split(',').map((c) => c.trim().replace(/^"|"$/g, ''));
|
|
51
|
+
if (cells.length < 8)
|
|
52
|
+
continue;
|
|
53
|
+
const [cusip, type, rate, maturity, , , , endOfDay] = cells;
|
|
54
|
+
if (!cusip || type !== 'TIPS')
|
|
55
|
+
continue;
|
|
56
|
+
const rateFraction = Number(rate);
|
|
57
|
+
const endOfDayPrice = Number(endOfDay);
|
|
58
|
+
const m = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(maturity ?? '');
|
|
59
|
+
if (!m || !Number.isFinite(rateFraction) || !Number.isFinite(endOfDayPrice) || endOfDayPrice <= 0)
|
|
60
|
+
continue;
|
|
61
|
+
tips.push({ cusip, ratePct: rateFraction * 100, maturityIso: `${m[3]}-${m[1]}-${m[2]}`, endOfDayPrice });
|
|
62
|
+
}
|
|
63
|
+
return tips.sort((a, b) => a.maturityIso.localeCompare(b.maturityIso));
|
|
64
|
+
}
|
|
65
|
+
/** The TIPS whose maturity falls nearest a target calendar year (for rung reference rows). */
|
|
66
|
+
export function nearestTipsForYear(tips, year) {
|
|
67
|
+
let best = null;
|
|
68
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
69
|
+
for (const t of tips) {
|
|
70
|
+
const distance = Math.abs(Number(t.maturityIso.slice(0, 4)) - year);
|
|
71
|
+
if (distance < bestDistance) {
|
|
72
|
+
best = t;
|
|
73
|
+
bestDistance = distance;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return best && bestDistance <= 1 ? best : null;
|
|
77
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Funded ratio (social-security-bridge-and-tips-ladder, step 4) — Pfau's
|
|
3
|
+
* "pension accounting for households" lens: the present value of essential
|
|
4
|
+
* (required-floor) spending discounted on the TIPS curve, against the present
|
|
5
|
+
* value of the guaranteed real income dedicated to it (Social Security,
|
|
6
|
+
* pensions, annuities, TIPS-ladder flows).
|
|
7
|
+
*
|
|
8
|
+
* Both sides are read from the SAME deterministic ledger years and deflated to
|
|
9
|
+
* today's dollars, so the ratio is consistent with the projection by
|
|
10
|
+
* construction: every tax, COLA, survivor, and haircut effect the ledger
|
|
11
|
+
* models is already inside the cash flows being discounted.
|
|
12
|
+
*/
|
|
13
|
+
import type { RealYieldCurve } from '../params/types.js';
|
|
14
|
+
import type { YearResult } from '../projection/types.js';
|
|
15
|
+
export interface FundedRatioInput {
|
|
16
|
+
years: YearResult[];
|
|
17
|
+
startYear: number;
|
|
18
|
+
/** Deflator from the projection (nominal in `year` → today's $). */
|
|
19
|
+
deflate: (year: number, amount: number) => number;
|
|
20
|
+
curve: RealYieldCurve;
|
|
21
|
+
/**
|
|
22
|
+
* Count essential spending only from this year on (typically the retirement
|
|
23
|
+
* year): pre-retirement spending is funded by wages, not the floor.
|
|
24
|
+
*/
|
|
25
|
+
fromYear?: number;
|
|
26
|
+
}
|
|
27
|
+
export interface FundedRatioResult {
|
|
28
|
+
/** PV (today's $) of required-floor spending over the horizon, on the TIPS curve. */
|
|
29
|
+
essentialSpendingPv: number;
|
|
30
|
+
/** PV (today's $) of guaranteed income: SS + pensions + annuities + ladder flows. */
|
|
31
|
+
guaranteedIncomePv: number;
|
|
32
|
+
/** guaranteedIncomePv / essentialSpendingPv × 100. */
|
|
33
|
+
fundedRatioPct: number;
|
|
34
|
+
/** PV of the floor left for the portfolio to cover: max(0, essential − guaranteed). */
|
|
35
|
+
unfundedPv: number;
|
|
36
|
+
/** First and last calendar years counted. */
|
|
37
|
+
fromYear: number;
|
|
38
|
+
toYear: number;
|
|
39
|
+
}
|
|
40
|
+
/** Null when there is nothing to measure (no years, or no essential spending in the window). */
|
|
41
|
+
export declare function computeFundedRatio(input: FundedRatioInput): FundedRatioResult | null;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Funded ratio (social-security-bridge-and-tips-ladder, step 4) — Pfau's
|
|
3
|
+
* "pension accounting for households" lens: the present value of essential
|
|
4
|
+
* (required-floor) spending discounted on the TIPS curve, against the present
|
|
5
|
+
* value of the guaranteed real income dedicated to it (Social Security,
|
|
6
|
+
* pensions, annuities, TIPS-ladder flows).
|
|
7
|
+
*
|
|
8
|
+
* Both sides are read from the SAME deterministic ledger years and deflated to
|
|
9
|
+
* today's dollars, so the ratio is consistent with the projection by
|
|
10
|
+
* construction: every tax, COLA, survivor, and haircut effect the ledger
|
|
11
|
+
* models is already inside the cash flows being discounted.
|
|
12
|
+
*/
|
|
13
|
+
import { realPresentValue } from './ladderMath.js';
|
|
14
|
+
/** Null when there is nothing to measure (no years, or no essential spending in the window). */
|
|
15
|
+
export function computeFundedRatio(input) {
|
|
16
|
+
const { years, startYear, deflate, curve } = input;
|
|
17
|
+
if (years.length === 0)
|
|
18
|
+
return null;
|
|
19
|
+
const fromYear = input.fromYear ?? startYear;
|
|
20
|
+
const essentialFlows = [];
|
|
21
|
+
const guaranteedFlows = [];
|
|
22
|
+
let toYear = fromYear;
|
|
23
|
+
for (const y of years) {
|
|
24
|
+
if (y.year < fromYear)
|
|
25
|
+
continue;
|
|
26
|
+
toYear = y.year;
|
|
27
|
+
const yearsFromNow = y.year - startYear;
|
|
28
|
+
essentialFlows.push({ yearsFromNow, realAmount: deflate(y.year, y.expenses.requiredSpending) });
|
|
29
|
+
guaranteedFlows.push({
|
|
30
|
+
yearsFromNow,
|
|
31
|
+
realAmount: deflate(y.year, y.incomes.socialSecurity + y.incomes.pension + y.incomes.annuity + y.incomes.tipsLadder),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
const essentialSpendingPv = realPresentValue(essentialFlows, curve);
|
|
35
|
+
if (essentialSpendingPv <= 0)
|
|
36
|
+
return null;
|
|
37
|
+
const guaranteedIncomePv = realPresentValue(guaranteedFlows, curve);
|
|
38
|
+
return {
|
|
39
|
+
essentialSpendingPv,
|
|
40
|
+
guaranteedIncomePv,
|
|
41
|
+
fundedRatioPct: (guaranteedIncomePv / essentialSpendingPv) * 100,
|
|
42
|
+
unfundedPv: Math.max(0, essentialSpendingPv - guaranteedIncomePv),
|
|
43
|
+
fromYear,
|
|
44
|
+
toYear,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TIPS ladder math (social-security-bridge-and-tips-ladder, step 1).
|
|
3
|
+
*
|
|
4
|
+
* Pure functions: a target real income + payout window + real-yield curve
|
|
5
|
+
* produce a rung schedule (face amounts, coupons, cost) and per-year cash
|
|
6
|
+
* flows. Everything here is in REAL (today's) dollars — the ledger multiplies
|
|
7
|
+
* by its own inflation path, which is exactly the TIPS inflation adjustment
|
|
8
|
+
* (principal and coupons both index to CPI).
|
|
9
|
+
*
|
|
10
|
+
* Planning-grade model (documented simplifications):
|
|
11
|
+
* - Each rung is a par TIPS: coupon rate = the interpolated par real yield at
|
|
12
|
+
* its maturity (floored at the 0.125% statutory minimum TIPS coupon), so a
|
|
13
|
+
* rung on a flat curve prices exactly at face.
|
|
14
|
+
* - Rungs are priced by discounting their real cash flows on the same par
|
|
15
|
+
* curve (par-yields-as-spot approximation, fine at planning grade).
|
|
16
|
+
* - Coupons pay annually; real-world semiannual timing is ignored.
|
|
17
|
+
* - No CUSIP-level lot rounding in core mode ($1 granularity); the opt-in
|
|
18
|
+
* FedInvest mode (step 5) exists for users who want real security prices.
|
|
19
|
+
*/
|
|
20
|
+
import type { RealYieldCurve } from '../params/types.js';
|
|
21
|
+
/** Statutory minimum TIPS coupon rate (percent). */
|
|
22
|
+
export declare const MIN_TIPS_COUPON_PCT = 0.125;
|
|
23
|
+
/** Interpolated par real yield (percent) at a maturity; endpoints held flat. */
|
|
24
|
+
export declare function realYieldAt(curve: RealYieldCurve, maturityYears: number): number;
|
|
25
|
+
export interface LadderRung {
|
|
26
|
+
/** Years from the anchor (purchase) year to maturity; >= 1. */
|
|
27
|
+
maturityOffset: number;
|
|
28
|
+
/** Real principal (today's $) returned at maturity. */
|
|
29
|
+
face: number;
|
|
30
|
+
/** Annual coupon rate (percent of inflation-adjusted principal). */
|
|
31
|
+
couponRatePct: number;
|
|
32
|
+
/** Real (today's $) purchase price of the rung. */
|
|
33
|
+
cost: number;
|
|
34
|
+
}
|
|
35
|
+
export interface LadderBuildInput {
|
|
36
|
+
/** Level real income (today's $) the ladder should pay each payout year. */
|
|
37
|
+
annualRealIncome: number;
|
|
38
|
+
/** Years from the anchor year to the first payout; >= 1 (1 = next year). */
|
|
39
|
+
firstPayoutOffset: number;
|
|
40
|
+
/** Number of consecutive payout years; >= 1. */
|
|
41
|
+
payoutYears: number;
|
|
42
|
+
curve: RealYieldCurve;
|
|
43
|
+
}
|
|
44
|
+
export interface LadderBuild {
|
|
45
|
+
rungs: LadderRung[];
|
|
46
|
+
/** Total real (today's $) purchase cost of all rungs. */
|
|
47
|
+
totalCost: number;
|
|
48
|
+
/** The input target, echoed for display. */
|
|
49
|
+
targetAnnualRealIncome: number;
|
|
50
|
+
/**
|
|
51
|
+
* Real income received in each year offset 1..lastMaturityOffset: coupons
|
|
52
|
+
* from outstanding rungs plus any maturing principal. During a deferral
|
|
53
|
+
* window (offsets before firstPayoutOffset) this is coupon income only.
|
|
54
|
+
*/
|
|
55
|
+
annualRealIncomeByOffset: number[];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Construct a level-real-income ladder: one rung per payout year, faces solved
|
|
59
|
+
* back-to-front so that each payout year's maturing principal plus the coupons
|
|
60
|
+
* of all still-outstanding rungs equals the target income (the standard
|
|
61
|
+
* DIY/tipsladder.com construction).
|
|
62
|
+
*/
|
|
63
|
+
export declare function buildLadder(input: LadderBuildInput): LadderBuild;
|
|
64
|
+
export interface LadderRealFlows {
|
|
65
|
+
/** Coupon income (real $) from all rungs outstanding during this year. */
|
|
66
|
+
coupons: number;
|
|
67
|
+
/** Principal (real $) of the rung maturing this year (0 in non-payout years). */
|
|
68
|
+
maturingPrincipal: number;
|
|
69
|
+
/**
|
|
70
|
+
* Total face (real $) outstanding during this year — the maturing rung
|
|
71
|
+
* included. Inflation accretion (taxable phantom income) applies to this.
|
|
72
|
+
*/
|
|
73
|
+
outstandingFace: number;
|
|
74
|
+
}
|
|
75
|
+
/** Real cash flows of a rung set in the year at `offset` (>= 1) from the anchor. */
|
|
76
|
+
export declare function ladderRealFlowsAtOffset(rungs: LadderRung[], offset: number): LadderRealFlows;
|
|
77
|
+
/** Total face (real $) still outstanding AFTER the year at `offset` completes. */
|
|
78
|
+
export declare function ladderRemainingFace(rungs: LadderRung[], offset: number): number;
|
|
79
|
+
/**
|
|
80
|
+
* Present value (real, today's $) of a stream of real cash flows discounted on
|
|
81
|
+
* the TIPS curve — the "pension accounting" discounting behind the funded
|
|
82
|
+
* ratio: what it would cost today to defease each future real dollar with a
|
|
83
|
+
* Treasury of matching maturity.
|
|
84
|
+
*/
|
|
85
|
+
export declare function realPresentValue(flows: Array<{
|
|
86
|
+
yearsFromNow: number;
|
|
87
|
+
realAmount: number;
|
|
88
|
+
}>, curve: RealYieldCurve): number;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TIPS ladder math (social-security-bridge-and-tips-ladder, step 1).
|
|
3
|
+
*
|
|
4
|
+
* Pure functions: a target real income + payout window + real-yield curve
|
|
5
|
+
* produce a rung schedule (face amounts, coupons, cost) and per-year cash
|
|
6
|
+
* flows. Everything here is in REAL (today's) dollars — the ledger multiplies
|
|
7
|
+
* by its own inflation path, which is exactly the TIPS inflation adjustment
|
|
8
|
+
* (principal and coupons both index to CPI).
|
|
9
|
+
*
|
|
10
|
+
* Planning-grade model (documented simplifications):
|
|
11
|
+
* - Each rung is a par TIPS: coupon rate = the interpolated par real yield at
|
|
12
|
+
* its maturity (floored at the 0.125% statutory minimum TIPS coupon), so a
|
|
13
|
+
* rung on a flat curve prices exactly at face.
|
|
14
|
+
* - Rungs are priced by discounting their real cash flows on the same par
|
|
15
|
+
* curve (par-yields-as-spot approximation, fine at planning grade).
|
|
16
|
+
* - Coupons pay annually; real-world semiannual timing is ignored.
|
|
17
|
+
* - No CUSIP-level lot rounding in core mode ($1 granularity); the opt-in
|
|
18
|
+
* FedInvest mode (step 5) exists for users who want real security prices.
|
|
19
|
+
*/
|
|
20
|
+
/** Statutory minimum TIPS coupon rate (percent). */
|
|
21
|
+
export const MIN_TIPS_COUPON_PCT = 0.125;
|
|
22
|
+
/** Interpolated par real yield (percent) at a maturity; endpoints held flat. */
|
|
23
|
+
export function realYieldAt(curve, maturityYears) {
|
|
24
|
+
const points = curve.points;
|
|
25
|
+
if (points.length === 0)
|
|
26
|
+
return 0;
|
|
27
|
+
const first = points[0];
|
|
28
|
+
const last = points[points.length - 1];
|
|
29
|
+
if (maturityYears <= first.maturityYears)
|
|
30
|
+
return first.realYieldPct;
|
|
31
|
+
if (maturityYears >= last.maturityYears)
|
|
32
|
+
return last.realYieldPct;
|
|
33
|
+
for (let i = 1; i < points.length; i++) {
|
|
34
|
+
const lo = points[i - 1];
|
|
35
|
+
const hi = points[i];
|
|
36
|
+
if (maturityYears <= hi.maturityYears) {
|
|
37
|
+
const t = (maturityYears - lo.maturityYears) / (hi.maturityYears - lo.maturityYears);
|
|
38
|
+
return lo.realYieldPct + t * (hi.realYieldPct - lo.realYieldPct);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return last.realYieldPct;
|
|
42
|
+
}
|
|
43
|
+
/** Real price of one rung: its coupons + principal discounted on the curve. */
|
|
44
|
+
function priceRung(face, couponRatePct, maturityOffset, curve) {
|
|
45
|
+
let price = 0;
|
|
46
|
+
for (let k = 1; k <= maturityOffset; k++) {
|
|
47
|
+
const y = realYieldAt(curve, k) / 100;
|
|
48
|
+
const coupon = face * (couponRatePct / 100);
|
|
49
|
+
const principal = k === maturityOffset ? face : 0;
|
|
50
|
+
price += (coupon + principal) / Math.pow(1 + y, k);
|
|
51
|
+
}
|
|
52
|
+
return price;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Construct a level-real-income ladder: one rung per payout year, faces solved
|
|
56
|
+
* back-to-front so that each payout year's maturing principal plus the coupons
|
|
57
|
+
* of all still-outstanding rungs equals the target income (the standard
|
|
58
|
+
* DIY/tipsladder.com construction).
|
|
59
|
+
*/
|
|
60
|
+
export function buildLadder(input) {
|
|
61
|
+
const { annualRealIncome, curve } = input;
|
|
62
|
+
const firstPayoutOffset = Math.max(1, Math.round(input.firstPayoutOffset));
|
|
63
|
+
const payoutYears = Math.max(1, Math.round(input.payoutYears));
|
|
64
|
+
const lastOffset = firstPayoutOffset + payoutYears - 1;
|
|
65
|
+
const offsets = [];
|
|
66
|
+
for (let m = firstPayoutOffset; m <= lastOffset; m++)
|
|
67
|
+
offsets.push(m);
|
|
68
|
+
const couponRate = (m) => Math.max(MIN_TIPS_COUPON_PCT, realYieldAt(curve, m)) / 100;
|
|
69
|
+
// Back-substitution: the last rung funds its year alone; earlier rungs fund
|
|
70
|
+
// the target net of the coupons every later (still-outstanding) rung pays.
|
|
71
|
+
const faces = new Map();
|
|
72
|
+
for (let i = offsets.length - 1; i >= 0; i--) {
|
|
73
|
+
const m = offsets[i];
|
|
74
|
+
let laterCoupons = 0;
|
|
75
|
+
for (let j = i + 1; j < offsets.length; j++) {
|
|
76
|
+
const later = offsets[j];
|
|
77
|
+
laterCoupons += (faces.get(later) ?? 0) * couponRate(later);
|
|
78
|
+
}
|
|
79
|
+
faces.set(m, Math.max(0, (annualRealIncome - laterCoupons) / (1 + couponRate(m))));
|
|
80
|
+
}
|
|
81
|
+
const rungs = offsets.map((m) => {
|
|
82
|
+
const face = faces.get(m) ?? 0;
|
|
83
|
+
const couponRatePct = Math.max(MIN_TIPS_COUPON_PCT, realYieldAt(curve, m));
|
|
84
|
+
return { maturityOffset: m, face, couponRatePct, cost: priceRung(face, couponRatePct, m, curve) };
|
|
85
|
+
});
|
|
86
|
+
const annualRealIncomeByOffset = [];
|
|
87
|
+
for (let offset = 1; offset <= lastOffset; offset++) {
|
|
88
|
+
const flow = ladderRealFlowsAtOffset(rungs, offset);
|
|
89
|
+
annualRealIncomeByOffset.push(flow.coupons + flow.maturingPrincipal);
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
rungs,
|
|
93
|
+
totalCost: rungs.reduce((sum, r) => sum + r.cost, 0),
|
|
94
|
+
targetAnnualRealIncome: annualRealIncome,
|
|
95
|
+
annualRealIncomeByOffset,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** Real cash flows of a rung set in the year at `offset` (>= 1) from the anchor. */
|
|
99
|
+
export function ladderRealFlowsAtOffset(rungs, offset) {
|
|
100
|
+
let coupons = 0;
|
|
101
|
+
let maturingPrincipal = 0;
|
|
102
|
+
let outstandingFace = 0;
|
|
103
|
+
for (const rung of rungs) {
|
|
104
|
+
if (rung.maturityOffset < offset)
|
|
105
|
+
continue;
|
|
106
|
+
outstandingFace += rung.face;
|
|
107
|
+
coupons += rung.face * (rung.couponRatePct / 100);
|
|
108
|
+
if (rung.maturityOffset === offset)
|
|
109
|
+
maturingPrincipal += rung.face;
|
|
110
|
+
}
|
|
111
|
+
return { coupons, maturingPrincipal, outstandingFace };
|
|
112
|
+
}
|
|
113
|
+
/** Total face (real $) still outstanding AFTER the year at `offset` completes. */
|
|
114
|
+
export function ladderRemainingFace(rungs, offset) {
|
|
115
|
+
let face = 0;
|
|
116
|
+
for (const rung of rungs)
|
|
117
|
+
if (rung.maturityOffset > offset)
|
|
118
|
+
face += rung.face;
|
|
119
|
+
return face;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Present value (real, today's $) of a stream of real cash flows discounted on
|
|
123
|
+
* the TIPS curve — the "pension accounting" discounting behind the funded
|
|
124
|
+
* ratio: what it would cost today to defease each future real dollar with a
|
|
125
|
+
* Treasury of matching maturity.
|
|
126
|
+
*/
|
|
127
|
+
export function realPresentValue(flows, curve) {
|
|
128
|
+
let pv = 0;
|
|
129
|
+
for (const flow of flows) {
|
|
130
|
+
if (flow.yearsFromNow < 0 || flow.realAmount === 0)
|
|
131
|
+
continue;
|
|
132
|
+
if (flow.yearsFromNow === 0) {
|
|
133
|
+
pv += flow.realAmount;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const y = realYieldAt(curve, flow.yearsFromNow) / 100;
|
|
137
|
+
pv += flow.realAmount / Math.pow(1 + y, flow.yearsFromNow);
|
|
138
|
+
}
|
|
139
|
+
return pv;
|
|
140
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Sex } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Period life expectancy e(x) — **remaining years** at exact integer age x.
|
|
4
|
+
* Source: SSA Table 4C6, 2022 (2025 TR), male/female columns.
|
|
5
|
+
* @see https://www.ssa.gov/oact/STATS/table4c6.html
|
|
6
|
+
*/
|
|
7
|
+
export declare const MALE: readonly number[];
|
|
8
|
+
export declare const FEMALE: readonly number[];
|
|
9
|
+
/** SSA baseline: remaining years at integer age (linear between ages if fractional). */
|
|
10
|
+
export declare function baselineRemainingYears(age: number, sex: Sex): number;
|