@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,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account eligibility service (account/HSA/fixed-asset depth plan, step 1).
|
|
3
|
+
*
|
|
4
|
+
* One pure module answering, per account (and where relevant, per year/owner):
|
|
5
|
+
* can it accept contributions? can it be converted to Roth? does it follow the
|
|
6
|
+
* owner's own RMDs? is its balance spendable this year? and what early-
|
|
7
|
+
* withdrawal penalty rate applies? The ledger (`projection/simulate.ts`), the
|
|
8
|
+
* optimizer input builder (`projection/optimizePlan.ts`), and the decision
|
|
9
|
+
* engine (`decisions/*`) all consume these helpers, so every recommended
|
|
10
|
+
* account movement is explainable by a rule that exists in exactly one place —
|
|
11
|
+
* the roadmap §7 acceptance criterion.
|
|
12
|
+
*
|
|
13
|
+
* Rules encoded here (each documented in DOCS/domain/domain-rules-reference.md):
|
|
14
|
+
* - Inherited traditional accounts (SECURE Act 10-year rule) cannot receive
|
|
15
|
+
* contributions, cannot be converted to Roth, are exempt from the owner's
|
|
16
|
+
* Uniform-Lifetime RMDs (they follow their own forced-distribution schedule),
|
|
17
|
+
* and are never subject to the 10% early-withdrawal penalty.
|
|
18
|
+
* - Cliff-vesting equity compensation is not spendable before its vest year.
|
|
19
|
+
* - Traditional withdrawals before 59½ (approximated as age < 60) carry a 10%
|
|
20
|
+
* penalty unless the Rule of 55 applies (employer plan, separation in/after
|
|
21
|
+
* the year the owner turned 55, approximated by the owner's retirement age).
|
|
22
|
+
* 72(t) SEPP and inherited distributions are taken outside the need-based
|
|
23
|
+
* flow and are penalty-free by construction.
|
|
24
|
+
* - HSA non-qualified withdrawals before 65 carry a 20% penalty; from 65 on
|
|
25
|
+
* they are penalty-free (ordinary income only).
|
|
26
|
+
*/
|
|
27
|
+
/** Traditional pre-59½ early-withdrawal penalty rate (age approximated annually). */
|
|
28
|
+
export const TRADITIONAL_EARLY_PENALTY_RATE = 0.1;
|
|
29
|
+
/** HSA non-qualified withdrawal penalty rate before age 65 (IRC §223(f)(4)). */
|
|
30
|
+
export const HSA_NON_QUALIFIED_PENALTY_RATE = 0.2;
|
|
31
|
+
/** Can this account receive new contributions? (Inherited accounts cannot.) */
|
|
32
|
+
export function acceptsContributions(account) {
|
|
33
|
+
return !(account.type === 'traditional' && account.inherited !== undefined);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Can this account's balance be converted to Roth? Only an owned (non-
|
|
37
|
+
* inherited) traditional account qualifies: inherited accounts follow the
|
|
38
|
+
* 10-year rule and are never convertible by a non-spouse beneficiary.
|
|
39
|
+
*/
|
|
40
|
+
export function isConvertibleToRoth(account) {
|
|
41
|
+
return account.type === 'traditional' && account.inherited === undefined;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Does this account follow the owner's own age-based RMDs (Uniform Lifetime /
|
|
45
|
+
* Joint Life)? Inherited accounts are exempt — they follow the beneficiary
|
|
46
|
+
* 10-year schedule in `strategies/inheritedIra.ts` instead.
|
|
47
|
+
*/
|
|
48
|
+
export function followsOwnerRmds(account) {
|
|
49
|
+
return account.type === 'traditional' && account.inherited === undefined;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Does this account participate in the owner's Form-8606 IRA aggregation for
|
|
53
|
+
* the nondeductible-basis pro-rata rule? Only the owner's own traditional
|
|
54
|
+
* IRAs aggregate: employer plans track after-tax money separately, and a
|
|
55
|
+
* beneficiary's inherited IRA has its own separate Form 8606.
|
|
56
|
+
*/
|
|
57
|
+
export function isAggregatedIra(account) {
|
|
58
|
+
return account.type === 'traditional' && account.kind === 'ira' && account.inherited === undefined;
|
|
59
|
+
}
|
|
60
|
+
/** Is a cliff-vesting equity-comp account vested (available for spending) in `year`? */
|
|
61
|
+
export function isEquityCompVested(account, year) {
|
|
62
|
+
if (account.vestingMode === 'final')
|
|
63
|
+
return true;
|
|
64
|
+
if (account.vestDate === null)
|
|
65
|
+
return false;
|
|
66
|
+
return Number(account.vestDate.slice(0, 4)) <= year;
|
|
67
|
+
}
|
|
68
|
+
/** Is this account's balance available for need-based spending withdrawals in `year`? */
|
|
69
|
+
export function isSpendableInYear(account, year) {
|
|
70
|
+
if (account.type === 'equityComp')
|
|
71
|
+
return isEquityCompVested(account, year);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Penalty rate on a need-based traditional withdrawal this year: 10% before
|
|
76
|
+
* 59½ (approximated as age < 60), waived for inherited accounts (never
|
|
77
|
+
* penalized), and waived by the Rule of 55 for an EMPLOYER plan the owner
|
|
78
|
+
* separated from in/after the year they turned 55 (IRAs never qualify).
|
|
79
|
+
*/
|
|
80
|
+
export function traditionalWithdrawalPenaltyRate(account, ctx) {
|
|
81
|
+
if (account.inherited !== undefined)
|
|
82
|
+
return 0;
|
|
83
|
+
if (ctx.ownerAgeAttained >= 60)
|
|
84
|
+
return 0;
|
|
85
|
+
const ruleOf55 = account.kind === 'employer' &&
|
|
86
|
+
ctx.ownerRetirementAge !== null &&
|
|
87
|
+
ctx.ownerRetirementAge >= 55 &&
|
|
88
|
+
ctx.ownerAgeAttained >= ctx.ownerRetirementAge;
|
|
89
|
+
return ruleOf55 ? 0 : TRADITIONAL_EARLY_PENALTY_RATE;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Penalty rate on the NON-QUALIFIED portion of an HSA withdrawal this year:
|
|
93
|
+
* 20% before age 65, none after. Qualified (medical) withdrawals are never
|
|
94
|
+
* penalized; the HSA subledger decides how much of a withdrawal is qualified.
|
|
95
|
+
*/
|
|
96
|
+
export function hsaNonQualifiedPenaltyRate(ownerAgeAttained) {
|
|
97
|
+
return ownerAgeAttained < 65 ? HSA_NON_QUALIFIED_PENALTY_RATE : 0;
|
|
98
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inherited (beneficiary) IRA 10-year rule (roadmap V8, §4 tax depth).
|
|
3
|
+
*
|
|
4
|
+
* Post-SECURE-Act, a non-spouse beneficiary must empty an inherited IRA by the
|
|
5
|
+
* end of the 10th year after the original owner's death. If the owner had
|
|
6
|
+
* already reached their required beginning date (RBD) — i.e. had started RMDs —
|
|
7
|
+
* the beneficiary must ALSO take an annual RMD in years 1–9, based on their own
|
|
8
|
+
* single life expectancy ("at least as rapidly", per the 2024 final regs). If
|
|
9
|
+
* the owner died before their RBD, no annual RMD is required during the window;
|
|
10
|
+
* the only requirement is to be empty by year 10.
|
|
11
|
+
*
|
|
12
|
+
* Inherited-IRA distributions are taxable ordinary income but are NEVER subject
|
|
13
|
+
* to the 10% early-withdrawal penalty, regardless of the beneficiary's age.
|
|
14
|
+
*
|
|
15
|
+
* The single-life divisor uses the repo's SSA period table
|
|
16
|
+
* (`baselineRemainingYears`) as a documented proxy for the IRS Single Life
|
|
17
|
+
* Table, and is recomputed each year rather than using the strict subtract-one
|
|
18
|
+
* method — a small, documented simplification consistent with the SEPP module.
|
|
19
|
+
*/
|
|
20
|
+
import type { Sex } from '../longevity/types.js';
|
|
21
|
+
/** The account must be fully distributed by the END of this year. */
|
|
22
|
+
export declare function inheritedTenYearDeadline(ownerDeathYear: number): number;
|
|
23
|
+
export interface InheritedForcedInput {
|
|
24
|
+
/** Current projection year. */
|
|
25
|
+
year: number;
|
|
26
|
+
/** Calendar year the original owner died. */
|
|
27
|
+
ownerDeathYear: number;
|
|
28
|
+
/** Did the decedent reach their RBD (had started RMDs)? Drives years 1–9. */
|
|
29
|
+
decedentHadStartedRmds: boolean;
|
|
30
|
+
/** Current account balance. */
|
|
31
|
+
balance: number;
|
|
32
|
+
/** Start-of-year balance, used for the annual-RMD divisor. */
|
|
33
|
+
startBalance: number;
|
|
34
|
+
/** Beneficiary's attained age this year. */
|
|
35
|
+
beneficiaryAge: number;
|
|
36
|
+
beneficiarySex: Sex;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The forced distribution required from an inherited IRA this year:
|
|
40
|
+
* - the full remaining balance in (and after) the 10th year — the final sweep;
|
|
41
|
+
* - an annual single-life RMD in years 1–9 when the decedent had started RMDs;
|
|
42
|
+
* - otherwise 0 (the beneficiary may still withdraw voluntarily).
|
|
43
|
+
* Never exceeds the current balance.
|
|
44
|
+
*/
|
|
45
|
+
export declare function inheritedForcedAmount(input: InheritedForcedInput): number;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inherited (beneficiary) IRA 10-year rule (roadmap V8, §4 tax depth).
|
|
3
|
+
*
|
|
4
|
+
* Post-SECURE-Act, a non-spouse beneficiary must empty an inherited IRA by the
|
|
5
|
+
* end of the 10th year after the original owner's death. If the owner had
|
|
6
|
+
* already reached their required beginning date (RBD) — i.e. had started RMDs —
|
|
7
|
+
* the beneficiary must ALSO take an annual RMD in years 1–9, based on their own
|
|
8
|
+
* single life expectancy ("at least as rapidly", per the 2024 final regs). If
|
|
9
|
+
* the owner died before their RBD, no annual RMD is required during the window;
|
|
10
|
+
* the only requirement is to be empty by year 10.
|
|
11
|
+
*
|
|
12
|
+
* Inherited-IRA distributions are taxable ordinary income but are NEVER subject
|
|
13
|
+
* to the 10% early-withdrawal penalty, regardless of the beneficiary's age.
|
|
14
|
+
*
|
|
15
|
+
* The single-life divisor uses the repo's SSA period table
|
|
16
|
+
* (`baselineRemainingYears`) as a documented proxy for the IRS Single Life
|
|
17
|
+
* Table, and is recomputed each year rather than using the strict subtract-one
|
|
18
|
+
* method — a small, documented simplification consistent with the SEPP module.
|
|
19
|
+
*/
|
|
20
|
+
import { baselineRemainingYears } from '../longevity/ssaPeriod2022.js';
|
|
21
|
+
/** The account must be fully distributed by the END of this year. */
|
|
22
|
+
export function inheritedTenYearDeadline(ownerDeathYear) {
|
|
23
|
+
return ownerDeathYear + 10;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The forced distribution required from an inherited IRA this year:
|
|
27
|
+
* - the full remaining balance in (and after) the 10th year — the final sweep;
|
|
28
|
+
* - an annual single-life RMD in years 1–9 when the decedent had started RMDs;
|
|
29
|
+
* - otherwise 0 (the beneficiary may still withdraw voluntarily).
|
|
30
|
+
* Never exceeds the current balance.
|
|
31
|
+
*/
|
|
32
|
+
export function inheritedForcedAmount(input) {
|
|
33
|
+
const { year, ownerDeathYear, balance } = input;
|
|
34
|
+
if (balance <= 0 || year <= ownerDeathYear)
|
|
35
|
+
return 0;
|
|
36
|
+
if (year >= inheritedTenYearDeadline(ownerDeathYear))
|
|
37
|
+
return balance; // empty it by year 10
|
|
38
|
+
if (input.decedentHadStartedRmds) {
|
|
39
|
+
const le = Math.max(1, baselineRemainingYears(input.beneficiaryAge, input.beneficiarySex));
|
|
40
|
+
return Math.min(input.startBalance / le, balance);
|
|
41
|
+
}
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nondeductible traditional-IRA basis and the Form 8606 pro-rata rule
|
|
3
|
+
* (account/HSA/fixed-asset depth plan, step 5).
|
|
4
|
+
*
|
|
5
|
+
* When an owner's traditional IRAs hold after-tax (nondeductible) basis, every
|
|
6
|
+
* distribution and Roth conversion from ANY of that owner's IRAs is part
|
|
7
|
+
* basis, part pre-tax — in the ratio of total basis to the total aggregated
|
|
8
|
+
* IRA balance. The taxpayer cannot choose to distribute "just the basis"
|
|
9
|
+
* (IRS Form 8606, line 6 aggregation; IRC §408(d)(2)).
|
|
10
|
+
*
|
|
11
|
+
* Planning-grade simplifications (documented in domain rules §15):
|
|
12
|
+
* - The nontaxable fraction uses basis ÷ (aggregate balance measured just
|
|
13
|
+
* before the year's distributions), which equals the 8606 method (year-end
|
|
14
|
+
* balance + distributions) when growth is credited after distributions —
|
|
15
|
+
* exactly the ledger's year ordering.
|
|
16
|
+
* - Employer plans are excluded from the aggregation (only IRAs aggregate),
|
|
17
|
+
* and inherited IRAs are excluded (a beneficiary files a separate 8606).
|
|
18
|
+
* - Basis is entered in dollars and never indexed (basis is historical cost).
|
|
19
|
+
*/
|
|
20
|
+
export interface IraProRataYear {
|
|
21
|
+
/** Remaining nondeductible basis in the owner's aggregated IRAs. */
|
|
22
|
+
basis: number;
|
|
23
|
+
/**
|
|
24
|
+
* Nontaxable fraction applied to this year's IRA distributions/conversions:
|
|
25
|
+
* basis ÷ aggregate pre-distribution IRA balance, in [0, 1].
|
|
26
|
+
*/
|
|
27
|
+
nontaxableFraction: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Fix the year's pro-rata fraction from the owner's aggregated pre-distribution
|
|
31
|
+
* IRA balance. Zero balance with positive basis returns fraction 1 (everything
|
|
32
|
+
* distributed is basis — the balance IS the basis or less, after losses).
|
|
33
|
+
*/
|
|
34
|
+
export declare function openIraProRataYear(basis: number, aggregateIraBalance: number): IraProRataYear;
|
|
35
|
+
export interface IraDistributionSplit {
|
|
36
|
+
/** Portion of the distribution that is return of basis (not taxed). */
|
|
37
|
+
nontaxable: number;
|
|
38
|
+
/** Portion taxed as ordinary income. */
|
|
39
|
+
taxable: number;
|
|
40
|
+
/** Year state after the distribution (basis depleted by the nontaxable part). */
|
|
41
|
+
next: IraProRataYear;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Split one distribution (or Roth conversion) from the owner's aggregated IRAs
|
|
45
|
+
* under the year's fixed pro-rata fraction. Nontaxable is capped at the
|
|
46
|
+
* remaining basis so repeated draws can never recover more basis than exists.
|
|
47
|
+
*/
|
|
48
|
+
export declare function splitIraDistribution(state: IraProRataYear, amount: number): IraDistributionSplit;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nondeductible traditional-IRA basis and the Form 8606 pro-rata rule
|
|
3
|
+
* (account/HSA/fixed-asset depth plan, step 5).
|
|
4
|
+
*
|
|
5
|
+
* When an owner's traditional IRAs hold after-tax (nondeductible) basis, every
|
|
6
|
+
* distribution and Roth conversion from ANY of that owner's IRAs is part
|
|
7
|
+
* basis, part pre-tax — in the ratio of total basis to the total aggregated
|
|
8
|
+
* IRA balance. The taxpayer cannot choose to distribute "just the basis"
|
|
9
|
+
* (IRS Form 8606, line 6 aggregation; IRC §408(d)(2)).
|
|
10
|
+
*
|
|
11
|
+
* Planning-grade simplifications (documented in domain rules §15):
|
|
12
|
+
* - The nontaxable fraction uses basis ÷ (aggregate balance measured just
|
|
13
|
+
* before the year's distributions), which equals the 8606 method (year-end
|
|
14
|
+
* balance + distributions) when growth is credited after distributions —
|
|
15
|
+
* exactly the ledger's year ordering.
|
|
16
|
+
* - Employer plans are excluded from the aggregation (only IRAs aggregate),
|
|
17
|
+
* and inherited IRAs are excluded (a beneficiary files a separate 8606).
|
|
18
|
+
* - Basis is entered in dollars and never indexed (basis is historical cost).
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Fix the year's pro-rata fraction from the owner's aggregated pre-distribution
|
|
22
|
+
* IRA balance. Zero balance with positive basis returns fraction 1 (everything
|
|
23
|
+
* distributed is basis — the balance IS the basis or less, after losses).
|
|
24
|
+
*/
|
|
25
|
+
export function openIraProRataYear(basis, aggregateIraBalance) {
|
|
26
|
+
if (basis <= 0)
|
|
27
|
+
return { basis: Math.max(0, basis), nontaxableFraction: 0 };
|
|
28
|
+
if (aggregateIraBalance <= 0)
|
|
29
|
+
return { basis, nontaxableFraction: 1 };
|
|
30
|
+
return { basis, nontaxableFraction: Math.min(1, basis / aggregateIraBalance) };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Split one distribution (or Roth conversion) from the owner's aggregated IRAs
|
|
34
|
+
* under the year's fixed pro-rata fraction. Nontaxable is capped at the
|
|
35
|
+
* remaining basis so repeated draws can never recover more basis than exists.
|
|
36
|
+
*/
|
|
37
|
+
export function splitIraDistribution(state, amount) {
|
|
38
|
+
if (amount <= 0)
|
|
39
|
+
return { nontaxable: 0, taxable: 0, next: state };
|
|
40
|
+
const nontaxable = Math.min(state.basis, amount * state.nontaxableFraction);
|
|
41
|
+
return {
|
|
42
|
+
nontaxable,
|
|
43
|
+
taxable: amount - nontaxable,
|
|
44
|
+
next: { basis: state.basis - nontaxable, nontaxableFraction: state.nontaxableFraction },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-year withdrawal / Roth-conversion optimizer (roadmap V8, PR 1).
|
|
3
|
+
*
|
|
4
|
+
* A true multi-year optimizer — unlike the greedy per-year `sizeRothConversion`
|
|
5
|
+
* sizer, this reasons over the whole horizon at once, which is the entire point
|
|
6
|
+
* of conversion planning: convert more now (paying tax in a low bracket) to
|
|
7
|
+
* shrink later RMDs and leave more in the never-haircut Roth. It is "just
|
|
8
|
+
* another strategy provider" (V8 spec §1.3): it emits a per-year schedule that
|
|
9
|
+
* the existing `simulate` ledger consumes via the Roth-conversion `optimized`
|
|
10
|
+
* mode — it does NOT run a parallel engine.
|
|
11
|
+
*
|
|
12
|
+
* Method: a mixed-integer linear program solved client-side by HiGHS-WASM (the
|
|
13
|
+
* `highs` package), proven viable by the PR 0 spike (measurements recorded in
|
|
14
|
+
* DOCS/features/optimizer.md §"Spike findings (historical)"). The LP is a *linearised* view of the
|
|
15
|
+
* ledger; `simulate` stays the source of truth, so callers re-run the real
|
|
16
|
+
* (nonlinear) ledger with the emitted schedule for exact numbers and surface
|
|
17
|
+
* any gap (V8 §3.1).
|
|
18
|
+
*
|
|
19
|
+
* Modeled exactly (the "big levers", V8 §6):
|
|
20
|
+
* - Graduated federal ordinary tax as a CONVEX piecewise-linear cost from the
|
|
21
|
+
* pack's real brackets — minimising tax fills the cheap band first with no
|
|
22
|
+
* integer variables.
|
|
23
|
+
* - IRMAA tier surcharges as binary step thresholds (the non-convex part that
|
|
24
|
+
* makes this a MILP, not an LP).
|
|
25
|
+
* - RMD floors as a linear lower bound on each year's taxable distribution
|
|
26
|
+
* (floor = start-of-year traditional balance ÷ divisor).
|
|
27
|
+
* - Three-bucket balances (owner traditional, inherited traditional, and
|
|
28
|
+
* "other" = Roth + taxable + cash) with per-year growth and scheduled
|
|
29
|
+
* contribution / employer-match inflows from the baseline probe, so a
|
|
30
|
+
* plan whose solvency depends on future deposits is not misread as
|
|
31
|
+
* infeasible.
|
|
32
|
+
*
|
|
33
|
+
* The optimizer-exact-ledger-convergence plan (Track 1) closed the
|
|
34
|
+
* original v1 simplifications, each opt-in via `OptimizerInput` so absent
|
|
35
|
+
* fields reproduce the v1 LP byte-for-byte:
|
|
36
|
+
* - Taxable-gain realization (Step 2): `openingTaxable`/`taxableInflow` split
|
|
37
|
+
* the lumped bucket into a taxable brokerage bucket — whose withdrawals
|
|
38
|
+
* realize LTCG at `ltcgRate` on the opening gain fraction and lift IRMAA
|
|
39
|
+
* MAGI — and the tax-free Roth/cash/HSA bucket. A single LTCG rate
|
|
40
|
+
* linearizes the 0/15/20% stack; the exact ledger refines it.
|
|
41
|
+
* - Bracketed state tax (Step 3): `stateBrackets` adds a convex state PWL
|
|
42
|
+
* over the same `ti` base as the federal PWL, so progressive
|
|
43
|
+
* (non-flat-override) states are priced with real brackets rather than
|
|
44
|
+
* zero; a flat `stateEffectiveTaxPct` override keeps the flat `stateRate`.
|
|
45
|
+
* - Taxable-SS phase-in (Step 3): `ssTaxability` swaps the fixed taxable-SS
|
|
46
|
+
* constant for an in-solve convex PWL over provisional income, so the
|
|
47
|
+
* solver sees the *marginal* tax torpedo (see `taxss` constraints below).
|
|
48
|
+
* - IRMAA 2-year lookback (Step 4): `irmaaLookback` drives each premium
|
|
49
|
+
* year's binaries off MAGI(year−2), matching the ledger's causality.
|
|
50
|
+
* - OBBBA senior deduction (ground-truth 2026 law sync, Step 2):
|
|
51
|
+
* `seniorDeduction` adds the per-person 65+ deduction plus a convex
|
|
52
|
+
* phase-out floor over MAGI, so conversions in the phase-out band are
|
|
53
|
+
* priced at bracket rate × (1 + phase-out rate) in-solve instead of the
|
|
54
|
+
* deduction being ignored entirely.
|
|
55
|
+
*
|
|
56
|
+
* Remaining simplifications (refined when the schedule re-runs through
|
|
57
|
+
* `simulate`, and by the exact-ledger convergence loop — Step 1, in
|
|
58
|
+
* projection/optimizePlan.ts — which recaptures the exogenous inputs at the
|
|
59
|
+
* incumbent schedule and re-solves to a fixed point): a single LTCG rate and
|
|
60
|
+
* opening basis ratio for the taxable stack, the omitted 85%-of-benefit
|
|
61
|
+
* taxable-SS cap (conservative), unscaled stand-in packs for future years
|
|
62
|
+
* (matching the ledger's convention), and state retirement-income exclusions.
|
|
63
|
+
*/
|
|
64
|
+
import type { FilingStatus, ParameterPack } from '../params/types.js';
|
|
65
|
+
/** One plan year of exogenous inputs the optimizer treats as given. */
|
|
66
|
+
export interface OptimizerYear {
|
|
67
|
+
year: number;
|
|
68
|
+
pack: ParameterPack;
|
|
69
|
+
filingStatus: FilingStatus;
|
|
70
|
+
/**
|
|
71
|
+
* Ordinary taxable income before any conversion or discretionary traditional
|
|
72
|
+
* withdrawal: wages − pre-tax contributions, pensions/annuities, the taxable
|
|
73
|
+
* portion of Social Security, etc. Nominal dollars.
|
|
74
|
+
*/
|
|
75
|
+
ordinaryIncomeBase: number;
|
|
76
|
+
/** After-tax cash needed this year beyond non-withdrawal inflows. Nominal. */
|
|
77
|
+
spendingNeed: number;
|
|
78
|
+
/** Net non-withdrawal cash already in hand (e.g. surplus SS/pension). Nominal. */
|
|
79
|
+
exogenousCash: number;
|
|
80
|
+
/** Uniform-Lifetime (or joint) RMD divisor when age-eligible; null = no RMD. */
|
|
81
|
+
rmdDivisor: number | null;
|
|
82
|
+
/** Forced inherited-traditional distribution from the baseline ledger. */
|
|
83
|
+
inheritedDistribution: number;
|
|
84
|
+
/** Inherited-IRA divisor recovered from the baseline forced distribution; null = no floor. */
|
|
85
|
+
inheritedDistributionDivisor: number | null;
|
|
86
|
+
/**
|
|
87
|
+
* Number of living people 65+. Drives the age-65 standard-deduction
|
|
88
|
+
* additions (`pack65Deduction`) and, when `seniorDeduction` is enabled, the
|
|
89
|
+
* OBBBA senior-deduction eligibility and amount (`amountPerPerson × this`).
|
|
90
|
+
*/
|
|
91
|
+
peopleAged65Plus: number;
|
|
92
|
+
/**
|
|
93
|
+
* Baseline forced owner-traditional distribution (the probe's RMD, nominal).
|
|
94
|
+
* Not a constraint input — the RMD floor stays `trad ÷ rmdDivisor` on the
|
|
95
|
+
* endogenous balance. Used only to locate the linearization point for
|
|
96
|
+
* saturation skips: a 65+ RMD year whose forced income already sits past the
|
|
97
|
+
* senior-deduction full phase-out must skip the phase-out PWL exactly, and
|
|
98
|
+
* `ordinaryIncomeBase` excludes forced withdrawals (the LP re-decides them
|
|
99
|
+
* as `wt`). Default 0.
|
|
100
|
+
*/
|
|
101
|
+
baselineRmd?: number;
|
|
102
|
+
/** Scales IRMAA thresholds for years beyond the published pack. */
|
|
103
|
+
inflationScale: number;
|
|
104
|
+
/** Nominal growth applied to end-of-year balances this year, e.g. 0.05. */
|
|
105
|
+
growth: number;
|
|
106
|
+
/**
|
|
107
|
+
* Flat state + local income-tax rate on taxable ordinary income (fraction).
|
|
108
|
+
* Carries the whole state tax under a flat `stateEffectiveTaxPct` override, or
|
|
109
|
+
* just the flat local rate when `stateBrackets` supplies the progressive state
|
|
110
|
+
* schedule (Step 3).
|
|
111
|
+
*/
|
|
112
|
+
stateRate: number;
|
|
113
|
+
/**
|
|
114
|
+
* Convex state income-tax bracket PWL (Step 3): ascending segments over the
|
|
115
|
+
* same taxable-ordinary base as the federal PWL, mirroring `bracketSegments`.
|
|
116
|
+
* Present only for progressive (non-flat-override) states with an income tax;
|
|
117
|
+
* absent means the flat `stateRate` is the whole story (byte-identical LP).
|
|
118
|
+
* Retirement-income exclusions are left to the exact ledger to refine.
|
|
119
|
+
*/
|
|
120
|
+
stateBrackets?: {
|
|
121
|
+
width: number | null;
|
|
122
|
+
rate: number;
|
|
123
|
+
}[];
|
|
124
|
+
/**
|
|
125
|
+
* Deposits landing in the owner-traditional bucket this year (scheduled
|
|
126
|
+
* employee contributions + employer match into traditional). The cash side of
|
|
127
|
+
* employee contributions is already inside `spendingNeed`; this is the asset
|
|
128
|
+
* side, so contribution-funded plans don't read as insolvent. Nominal.
|
|
129
|
+
*/
|
|
130
|
+
tradInflow: number;
|
|
131
|
+
/** Deposits landing in the lumped other bucket this year (Roth/taxable/cash/HSA contributions + Roth match). Nominal. */
|
|
132
|
+
otherInflow: number;
|
|
133
|
+
/**
|
|
134
|
+
* Deposits landing specifically in the taxable brokerage / equity-comp bucket
|
|
135
|
+
* this year (a subset of `otherInflow`). Only meaningful when the taxable
|
|
136
|
+
* bucket is split out (Step 2); default 0 keeps the lumped-bucket behavior.
|
|
137
|
+
*/
|
|
138
|
+
taxableInflow?: number;
|
|
139
|
+
/**
|
|
140
|
+
* In-solve taxable-SS PWL inputs (Step 3). When present with `ssBenefits > 0`,
|
|
141
|
+
* the LP replaces the fixed taxable-SS constant baked into
|
|
142
|
+
* `ordinaryIncomeBase` with a variable derived from provisional income, so
|
|
143
|
+
* the solver sees the *marginal* tax torpedo (each conversion dollar dragging
|
|
144
|
+
* 50–85¢ of SS into taxability) instead of the incumbent's average. Absent →
|
|
145
|
+
* the fixed-constant behavior (byte-identical LP).
|
|
146
|
+
*/
|
|
147
|
+
ssTaxability?: {
|
|
148
|
+
/** Gross Social Security benefits this year (nominal). */
|
|
149
|
+
ssBenefits: number;
|
|
150
|
+
/** Taxable-SS portion already folded into `ordinaryIncomeBase` (subtracted out when the PWL is active). */
|
|
151
|
+
taxableSsBase: number;
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* Baseline realized capital gains + qualified dividends this year, EXCLUDING
|
|
155
|
+
* gains from taxable-account withdrawals (the LP re-decides those as `wtax`,
|
|
156
|
+
* so including them would double-count). Feeds both the SS phase-in's
|
|
157
|
+
* provisional income and the IRMAA MAGI base — the ledger's MAGI counts
|
|
158
|
+
* gains/dividends, so omitting them would understate tier triggers. Nominal;
|
|
159
|
+
* default 0.
|
|
160
|
+
*/
|
|
161
|
+
capitalGainsBase?: number;
|
|
162
|
+
/**
|
|
163
|
+
* SSA-44 IRMAA redetermination applies to this premium year (the exact
|
|
164
|
+
* ledger prices its IRMAA on min(year t−2, year t−1 MAGI); see
|
|
165
|
+
* `healthcareConfigSchema.ssa44`). Under `irmaaLookback`, the year's IRMAA
|
|
166
|
+
* binaries are driven by year (t−1)'s MAGI instead of (t−2)'s — a
|
|
167
|
+
* single-source stand-in for the ledger's min that can only overstate the
|
|
168
|
+
* surcharge (conservative direction, refined by the exact-ledger
|
|
169
|
+
* tournament) while giving the solver the right marginal signal: a
|
|
170
|
+
* conversion in the estimate year now lifts this year's premium. Default
|
|
171
|
+
* false → the plain (t−2) lookback, byte-identical LP.
|
|
172
|
+
*/
|
|
173
|
+
ssa44Redetermination?: boolean;
|
|
174
|
+
}
|
|
175
|
+
export type OptimizerSolveResult = {
|
|
176
|
+
Status: string;
|
|
177
|
+
ObjectiveValue?: number;
|
|
178
|
+
Columns?: Record<string, {
|
|
179
|
+
Primal?: number;
|
|
180
|
+
}>;
|
|
181
|
+
};
|
|
182
|
+
export type OptimizerSolver = (lp: string, opts?: Record<string, unknown>) => OptimizerSolveResult;
|
|
183
|
+
export interface OptimizerInput {
|
|
184
|
+
years: OptimizerYear[];
|
|
185
|
+
/** Opening balances at the first year's start. Nominal. */
|
|
186
|
+
openingTrad: number;
|
|
187
|
+
/** Opening inherited traditional balance. Taxable and liquid, but never convertible. */
|
|
188
|
+
openingInheritedTrad: number;
|
|
189
|
+
/**
|
|
190
|
+
* Opening tax-free "other" bucket: Roth + cash + HSA (kept whole in the
|
|
191
|
+
* objective; withdrawals are tax-free). When the taxable bucket is split out
|
|
192
|
+
* (Step 2) this excludes taxable/equity-comp — those go in `openingTaxable`.
|
|
193
|
+
* When `openingTaxable` is 0/absent this is the original lumped bucket.
|
|
194
|
+
*/
|
|
195
|
+
openingOther: number;
|
|
196
|
+
/**
|
|
197
|
+
* Opening taxable brokerage + equity-comp balance (Step 2, taxable-gain
|
|
198
|
+
* realization). Withdrawals realize LTCG on the gain fraction at `ltcgRate`,
|
|
199
|
+
* so the solver no longer treats taxable draws as free. Default 0 (no taxable
|
|
200
|
+
* bucket) reproduces the lumped-`openingOther` behavior exactly.
|
|
201
|
+
*/
|
|
202
|
+
openingTaxable?: number;
|
|
203
|
+
/**
|
|
204
|
+
* Aggregate cost-basis fraction of the opening taxable balance (basis ÷
|
|
205
|
+
* balance, in [0,1]); `1 − this` is the gain fraction realized per taxable
|
|
206
|
+
* withdrawal dollar. A single opening ratio is a fair v1 linearization (the
|
|
207
|
+
* exact re-run prices true per-year basis depletion). Default 1 → no gain.
|
|
208
|
+
*/
|
|
209
|
+
taxableBasisRatio?: number;
|
|
210
|
+
/**
|
|
211
|
+
* Single preferential LTCG rate applied to realized taxable gains inside the
|
|
212
|
+
* solve (fraction, e.g. 0.15). A single rate linearizes the 0/15/20% stack;
|
|
213
|
+
* the exact ledger refines it. Default 0.
|
|
214
|
+
*/
|
|
215
|
+
ltcgRate?: number;
|
|
216
|
+
/**
|
|
217
|
+
* Model IRMAA on the 2-year MAGI lookback (Step 4): year t's Medicare
|
|
218
|
+
* surcharge binaries are driven by year (t−2)'s MAGI, matching the exact
|
|
219
|
+
* ledger — so a conversion at 63 anticipates the 65 premium in-solve.
|
|
220
|
+
* `buildOptimizerInput` always enables this in production; the field stays
|
|
221
|
+
* optional so direct `OptimizerInput` construction (golden tests,
|
|
222
|
+
* back-compat callers) defaults to the v1 same-year approximation with a
|
|
223
|
+
* byte-identical LP. Premium years without an in-horizon source (the first
|
|
224
|
+
* two years under lookback) carry exogenous premiums the ledger prices.
|
|
225
|
+
*/
|
|
226
|
+
irmaaLookback?: boolean;
|
|
227
|
+
/**
|
|
228
|
+
* Price the OBBBA senior deduction in-solve (ground-truth 2026 law sync,
|
|
229
|
+
* Step 2). When enabled, eligible years (people 65+, pack carries the rule,
|
|
230
|
+
* year ≤ `lastApplicableYear`) get the per-person deduction added to the
|
|
231
|
+
* year's deduction constant plus a convex phase-out floor over MAGI, so the
|
|
232
|
+
* solver sees the phase-out band's marginal-rate spike (each conversion
|
|
233
|
+
* dollar clawing back 6¢ of deduction ⇒ bracket rate × 1.06) instead of
|
|
234
|
+
* ignoring the deduction entirely. The concave full-phase-out cap is omitted
|
|
235
|
+
* (same treatment and direction as the taxable-SS 85% cap: overstates the
|
|
236
|
+
* cost of mega-conversions, which the exact ledger refines), and years whose
|
|
237
|
+
* baseline MAGI is already past full phase-out skip the deduction exactly.
|
|
238
|
+
* `buildOptimizerInput` always enables this in production; absent, the v1
|
|
239
|
+
* LP is byte-identical.
|
|
240
|
+
*/
|
|
241
|
+
seniorDeduction?: boolean;
|
|
242
|
+
/** Haircut on leftover traditional in the objective (V8 §1.4; ~0.22–0.24). */
|
|
243
|
+
liquidationRate: number;
|
|
244
|
+
/** Deflator to express the objective in today's dollars; argmax is unchanged. */
|
|
245
|
+
realDollarFactor?: number;
|
|
246
|
+
options?: {
|
|
247
|
+
/** HiGHS time limit in seconds (default 10). */
|
|
248
|
+
timeLimitSec?: number;
|
|
249
|
+
/** Optional per-year conversion ceiling, nominal. */
|
|
250
|
+
maxConversionPerYear?: number;
|
|
251
|
+
/**
|
|
252
|
+
* Resolve the wasm asset URL in a browser worker. Omit in Node (tests),
|
|
253
|
+
* where `highs` loads the wasm from disk.
|
|
254
|
+
*/
|
|
255
|
+
locateFile?: (file: string) => string;
|
|
256
|
+
/** Test seam for deterministic solver-status coverage. */
|
|
257
|
+
solve?: OptimizerSolver;
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
export interface OptimizedYear {
|
|
261
|
+
year: number;
|
|
262
|
+
conversion: number;
|
|
263
|
+
/** Discretionary + RMD traditional withdrawal (taxable). */
|
|
264
|
+
withdrawTraditional: number;
|
|
265
|
+
/** Inherited traditional withdrawal (taxable, never convertible). */
|
|
266
|
+
withdrawInheritedTraditional: number;
|
|
267
|
+
/** Withdrawal from the tax-free Roth/cash/HSA bucket (tax-free in the LP). */
|
|
268
|
+
withdrawOther: number;
|
|
269
|
+
/** Withdrawal from the taxable brokerage bucket (realizes LTCG on the gain fraction). */
|
|
270
|
+
withdrawTaxable: number;
|
|
271
|
+
/** Realized long-term gains from this year's taxable withdrawal (gain fraction × withdrawTaxable). */
|
|
272
|
+
taxableGainRealized: number;
|
|
273
|
+
taxableOrdinary: number;
|
|
274
|
+
irmaaTier: number;
|
|
275
|
+
endTrad: number;
|
|
276
|
+
endInheritedTrad: number;
|
|
277
|
+
endOther: number;
|
|
278
|
+
/** End-of-year taxable brokerage balance (0 when the taxable bucket isn't split out). */
|
|
279
|
+
endTaxable: number;
|
|
280
|
+
}
|
|
281
|
+
export interface OptimizedSchedule {
|
|
282
|
+
status: 'optimal' | 'feasible' | 'infeasible' | 'timeout';
|
|
283
|
+
/** Ending after-tax wealth in today's dollars (the objective). */
|
|
284
|
+
endingAfterTax: number;
|
|
285
|
+
/** Cumulative modeled tax over the horizon (secondary readout, V8 §1.4). */
|
|
286
|
+
lifetimeTax: number;
|
|
287
|
+
schedule: OptimizedYear[];
|
|
288
|
+
/** Per-year conversions, ready to drop into the `optimized`/`manual` strategy. */
|
|
289
|
+
conversions: {
|
|
290
|
+
year: number;
|
|
291
|
+
amount: number;
|
|
292
|
+
}[];
|
|
293
|
+
solveMs: number;
|
|
294
|
+
}
|
|
295
|
+
interface BuiltModel {
|
|
296
|
+
lp: string;
|
|
297
|
+
binaryCount: number;
|
|
298
|
+
bigM: number;
|
|
299
|
+
}
|
|
300
|
+
/** Build the multi-year MILP in CPLEX LP format. Exported for golden tests. */
|
|
301
|
+
export declare function buildOptimizerModel(input: OptimizerInput): BuiltModel;
|
|
302
|
+
/**
|
|
303
|
+
* Solve the multi-year conversion/withdrawal MILP. Returns a per-year schedule
|
|
304
|
+
* plus a conversions array ready to feed the `optimized` Roth strategy.
|
|
305
|
+
*/
|
|
306
|
+
export declare function optimizeSchedule(input: OptimizerInput): Promise<OptimizedSchedule>;
|
|
307
|
+
export {};
|