@retiregolden/engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +79 -0
  3. package/dist/allocation/assetClasses.d.ts +94 -0
  4. package/dist/allocation/assetClasses.js +215 -0
  5. package/dist/decisions/annuitization.d.ts +72 -0
  6. package/dist/decisions/annuitization.js +184 -0
  7. package/dist/decisions/decisionFixtures.d.ts +48 -0
  8. package/dist/decisions/decisionFixtures.js +253 -0
  9. package/dist/decisions/evaluateCandidate.d.ts +61 -0
  10. package/dist/decisions/evaluateCandidate.js +193 -0
  11. package/dist/decisions/generators.d.ts +110 -0
  12. package/dist/decisions/generators.js +667 -0
  13. package/dist/decisions/index.d.ts +17 -0
  14. package/dist/decisions/index.js +17 -0
  15. package/dist/decisions/insightsAdapter.d.ts +27 -0
  16. package/dist/decisions/insightsAdapter.js +51 -0
  17. package/dist/decisions/objectives.d.ts +96 -0
  18. package/dist/decisions/objectives.js +319 -0
  19. package/dist/decisions/pensionElection.d.ts +112 -0
  20. package/dist/decisions/pensionElection.js +239 -0
  21. package/dist/decisions/search.d.ts +51 -0
  22. package/dist/decisions/search.js +110 -0
  23. package/dist/decisions/spendingSolver.d.ts +70 -0
  24. package/dist/decisions/spendingSolver.js +180 -0
  25. package/dist/decisions/spiaQuotes.d.ts +21 -0
  26. package/dist/decisions/spiaQuotes.js +43 -0
  27. package/dist/decisions/stochastic.d.ts +11 -0
  28. package/dist/decisions/stochastic.js +66 -0
  29. package/dist/decisions/swrComparator.d.ts +54 -0
  30. package/dist/decisions/swrComparator.js +89 -0
  31. package/dist/decisions/tournament.d.ts +53 -0
  32. package/dist/decisions/tournament.js +104 -0
  33. package/dist/decisions/types.d.ts +125 -0
  34. package/dist/decisions/types.js +12 -0
  35. package/dist/index.d.ts +13 -0
  36. package/dist/index.js +12 -0
  37. package/dist/insights/detectors/annuitizationHeadroom.d.ts +11 -0
  38. package/dist/insights/detectors/annuitizationHeadroom.js +77 -0
  39. package/dist/insights/detectors/assetLocation.d.ts +9 -0
  40. package/dist/insights/detectors/assetLocation.js +86 -0
  41. package/dist/insights/detectors/hecmBufferCandidate.d.ts +12 -0
  42. package/dist/insights/detectors/hecmBufferCandidate.js +76 -0
  43. package/dist/insights/detectors/incomeFloorFunded.d.ts +8 -0
  44. package/dist/insights/detectors/incomeFloorFunded.js +52 -0
  45. package/dist/insights/detectors/irmaaTierEdge.d.ts +2 -0
  46. package/dist/insights/detectors/irmaaTierEdge.js +102 -0
  47. package/dist/insights/detectors/pensionElectionPending.d.ts +11 -0
  48. package/dist/insights/detectors/pensionElectionPending.js +62 -0
  49. package/dist/insights/detectors/qcdEfficiency.d.ts +2 -0
  50. package/dist/insights/detectors/qcdEfficiency.js +62 -0
  51. package/dist/insights/detectors/rothBridgeHeadroom.d.ts +2 -0
  52. package/dist/insights/detectors/rothBridgeHeadroom.js +77 -0
  53. package/dist/insights/detectors/spendingGuardrails.d.ts +2 -0
  54. package/dist/insights/detectors/spendingGuardrails.js +77 -0
  55. package/dist/insights/detectors/spendingHeadroom.d.ts +12 -0
  56. package/dist/insights/detectors/spendingHeadroom.js +92 -0
  57. package/dist/insights/detectors/ssBridgeGap.d.ts +9 -0
  58. package/dist/insights/detectors/ssBridgeGap.js +92 -0
  59. package/dist/insights/detectors/stateRelocation.d.ts +12 -0
  60. package/dist/insights/detectors/stateRelocation.js +99 -0
  61. package/dist/insights/detectors/widowsPenalty.d.ts +12 -0
  62. package/dist/insights/detectors/widowsPenalty.js +145 -0
  63. package/dist/insights/registry.d.ts +4 -0
  64. package/dist/insights/registry.js +62 -0
  65. package/dist/insights/runInsights.d.ts +6 -0
  66. package/dist/insights/runInsights.js +15 -0
  67. package/dist/insights/types.d.ts +70 -0
  68. package/dist/insights/types.js +1 -0
  69. package/dist/ladder/bridge.d.ts +72 -0
  70. package/dist/ladder/bridge.js +59 -0
  71. package/dist/ladder/fedInvest.d.ts +55 -0
  72. package/dist/ladder/fedInvest.js +77 -0
  73. package/dist/ladder/fundedRatio.d.ts +41 -0
  74. package/dist/ladder/fundedRatio.js +46 -0
  75. package/dist/ladder/ladderMath.d.ts +88 -0
  76. package/dist/ladder/ladderMath.js +140 -0
  77. package/dist/longevity/ssaPeriod2022.d.ts +10 -0
  78. package/dist/longevity/ssaPeriod2022.js +51 -0
  79. package/dist/longevity/types.d.ts +39 -0
  80. package/dist/longevity/types.js +1 -0
  81. package/dist/model/migrations.d.ts +20 -0
  82. package/dist/model/migrations.js +67 -0
  83. package/dist/model/plan.d.ts +3108 -0
  84. package/dist/model/plan.js +1514 -0
  85. package/dist/montecarlo/frontiers.d.ts +22 -0
  86. package/dist/montecarlo/frontiers.js +61 -0
  87. package/dist/montecarlo/historicalReturns.d.ts +27 -0
  88. package/dist/montecarlo/historicalReturns.js +124 -0
  89. package/dist/montecarlo/historicalSuites.d.ts +45 -0
  90. package/dist/montecarlo/historicalSuites.js +102 -0
  91. package/dist/montecarlo/ltcShock.d.ts +42 -0
  92. package/dist/montecarlo/ltcShock.js +62 -0
  93. package/dist/montecarlo/marketModels.d.ts +248 -0
  94. package/dist/montecarlo/marketModels.js +672 -0
  95. package/dist/montecarlo/mortality.d.ts +31 -0
  96. package/dist/montecarlo/mortality.js +61 -0
  97. package/dist/montecarlo/riskBasedGuardrails.d.ts +93 -0
  98. package/dist/montecarlo/riskBasedGuardrails.js +200 -0
  99. package/dist/montecarlo/rng.d.ts +18 -0
  100. package/dist/montecarlo/rng.js +44 -0
  101. package/dist/montecarlo/run.d.ts +210 -0
  102. package/dist/montecarlo/run.js +353 -0
  103. package/dist/montecarlo/sharedPaths.d.ts +43 -0
  104. package/dist/montecarlo/sharedPaths.js +28 -0
  105. package/dist/montecarlo/survival.d.ts +49 -0
  106. package/dist/montecarlo/survival.js +126 -0
  107. package/dist/params/data/realYieldCurve2026.d.ts +17 -0
  108. package/dist/params/data/realYieldCurve2026.js +26 -0
  109. package/dist/params/data/year2026.d.ts +14 -0
  110. package/dist/params/data/year2026.js +199 -0
  111. package/dist/params/index.d.ts +63 -0
  112. package/dist/params/index.js +134 -0
  113. package/dist/params/provenance.d.ts +29 -0
  114. package/dist/params/provenance.js +123 -0
  115. package/dist/params/state/data/year2026.d.ts +18 -0
  116. package/dist/params/state/data/year2026.js +581 -0
  117. package/dist/params/state/index.d.ts +13 -0
  118. package/dist/params/state/index.js +26 -0
  119. package/dist/params/state/types.d.ts +73 -0
  120. package/dist/params/state/types.js +10 -0
  121. package/dist/params/types.d.ts +201 -0
  122. package/dist/params/types.js +7 -0
  123. package/dist/projection/annuityForms.d.ts +62 -0
  124. package/dist/projection/annuityForms.js +87 -0
  125. package/dist/projection/compare.d.ts +96 -0
  126. package/dist/projection/compare.js +216 -0
  127. package/dist/projection/flatTax.d.ts +7 -0
  128. package/dist/projection/flatTax.js +14 -0
  129. package/dist/projection/optimizePlan.d.ts +328 -0
  130. package/dist/projection/optimizePlan.js +1039 -0
  131. package/dist/projection/relocation.d.ts +149 -0
  132. package/dist/projection/relocation.js +317 -0
  133. package/dist/projection/simulate.d.ts +61 -0
  134. package/dist/projection/simulate.js +2754 -0
  135. package/dist/projection/types.d.ts +381 -0
  136. package/dist/projection/types.js +9 -0
  137. package/dist/rmd/jointLifeTable.d.ts +8 -0
  138. package/dist/rmd/jointLifeTable.js +663 -0
  139. package/dist/rmd/rmd.d.ts +29 -0
  140. package/dist/rmd/rmd.js +34 -0
  141. package/dist/scenarios/scenarios.d.ts +70 -0
  142. package/dist/scenarios/scenarios.js +130 -0
  143. package/dist/socialSecurity/benefitFactor.d.ts +13 -0
  144. package/dist/socialSecurity/benefitFactor.js +40 -0
  145. package/dist/socialSecurity/claimFactor.d.ts +33 -0
  146. package/dist/socialSecurity/claimFactor.js +58 -0
  147. package/dist/socialSecurity/disability.d.ts +36 -0
  148. package/dist/socialSecurity/disability.js +42 -0
  149. package/dist/socialSecurity/familyMaximum.d.ts +38 -0
  150. package/dist/socialSecurity/familyMaximum.js +48 -0
  151. package/dist/socialSecurity/maritalBenefits.d.ts +57 -0
  152. package/dist/socialSecurity/maritalBenefits.js +87 -0
  153. package/dist/socialSecurity/nra.d.ts +30 -0
  154. package/dist/socialSecurity/nra.js +83 -0
  155. package/dist/socialSecurity/piaFromEarnings.d.ts +100 -0
  156. package/dist/socialSecurity/piaFromEarnings.js +202 -0
  157. package/dist/socialSecurity/ssaWageData.d.ts +68 -0
  158. package/dist/socialSecurity/ssaWageData.js +306 -0
  159. package/dist/socialSecurity/survivorBenefit.d.ts +61 -0
  160. package/dist/socialSecurity/survivorBenefit.js +61 -0
  161. package/dist/spending/abw.d.ts +57 -0
  162. package/dist/spending/abw.js +80 -0
  163. package/dist/spending/flexibleGoals.d.ts +62 -0
  164. package/dist/spending/flexibleGoals.js +136 -0
  165. package/dist/spending/guardrails.d.ts +78 -0
  166. package/dist/spending/guardrails.js +100 -0
  167. package/dist/spending/layers.d.ts +81 -0
  168. package/dist/spending/layers.js +67 -0
  169. package/dist/spending/shapePresets.d.ts +42 -0
  170. package/dist/spending/shapePresets.js +77 -0
  171. package/dist/strategies/accountEligibility.d.ts +81 -0
  172. package/dist/strategies/accountEligibility.js +98 -0
  173. package/dist/strategies/inheritedIra.d.ts +45 -0
  174. package/dist/strategies/inheritedIra.js +43 -0
  175. package/dist/strategies/iraBasis.d.ts +48 -0
  176. package/dist/strategies/iraBasis.js +46 -0
  177. package/dist/strategies/optimizer.d.ts +307 -0
  178. package/dist/strategies/optimizer.js +551 -0
  179. package/dist/strategies/rothBasis.d.ts +67 -0
  180. package/dist/strategies/rothBasis.js +83 -0
  181. package/dist/strategies/rothConversion.d.ts +55 -0
  182. package/dist/strategies/rothConversion.js +83 -0
  183. package/dist/strategies/sepp.d.ts +38 -0
  184. package/dist/strategies/sepp.js +53 -0
  185. package/dist/tax/aca.d.ts +40 -0
  186. package/dist/tax/aca.js +82 -0
  187. package/dist/tax/federalTax.d.ts +110 -0
  188. package/dist/tax/federalTax.js +249 -0
  189. package/dist/tax/medicare.d.ts +20 -0
  190. package/dist/tax/medicare.js +36 -0
  191. package/dist/tax/propertySale.d.ts +46 -0
  192. package/dist/tax/propertySale.js +35 -0
  193. package/dist/tax/stateTax.d.ts +66 -0
  194. package/dist/tax/stateTax.js +198 -0
  195. package/dist/testing/money.d.ts +7 -0
  196. package/dist/testing/money.js +17 -0
  197. package/dist/testing/planFixtures.d.ts +29 -0
  198. package/dist/testing/planFixtures.js +141 -0
  199. package/package.json +75 -0
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Roth ordering + 5-year rules (roadmap V8, §4 tax depth).
3
+ *
4
+ * A Roth distribution comes out in a fixed IRS order, and each layer is taxed
5
+ * differently before the account is "qualified" (owner 59½+ and the account 5+
6
+ * years old):
7
+ * 1. Contributions — always tax- AND penalty-free, at any age.
8
+ * 2. Conversions — oldest first; already taxed at conversion, so never
9
+ * income-taxed again, BUT a 10% recapture penalty applies
10
+ * to any layer tapped within 5 years while under 59½.
11
+ * 3. Earnings — last out; before 59½ they are ordinary income PLUS the
12
+ * 10% penalty (a non-qualified distribution).
13
+ *
14
+ * The engine approximates the 59½ boundary as "age 60 attained," matching the
15
+ * traditional early-withdrawal penalty elsewhere in the simulation, and folds
16
+ * the account's own 5-year clock into that age proxy (an existing Roth is
17
+ * almost always open >5 years by 59½). Conversions performed during the
18
+ * projection each carry their own explicit 5-year clock, which is the case that
19
+ * actually matters for the early-retirement "conversion ladder."
20
+ */
21
+ /** The engine's 59½ proxy: no early-withdrawal penalty once age 60 is attained. */
22
+ export const ROTH_QUALIFIED_AGE = 60;
23
+ /** A conversion is "seasoned" (no recapture penalty) once this many years pass. */
24
+ export const ROTH_SEASONING_YEARS = 5;
25
+ /** An empty (fresh) basis state, e.g. for a brand-new Roth account. */
26
+ export function emptyRothBasis(contributionBasis = 0) {
27
+ return { contributionBasis, conversionLayers: [] };
28
+ }
29
+ /**
30
+ * Split a Roth withdrawal into contribution / conversion / earnings buckets and
31
+ * the early-distribution tax + penalty it incurs. Pure: returns the post-
32
+ * withdrawal `next` state rather than mutating, so callers can probe a candidate
33
+ * withdrawal during the tax fixed-point and only commit once it converges.
34
+ *
35
+ * `amount` is assumed ≤ the account balance (the caller drains against available
36
+ * balance), so the remainder after contributions and conversions is earnings.
37
+ */
38
+ export function splitRothWithdrawal(state, amount, year, age) {
39
+ const qualified = age >= ROTH_QUALIFIED_AGE;
40
+ let remaining = Math.max(0, amount);
41
+ // 1) Contributions — always tax- and penalty-free.
42
+ const contributions = Math.min(remaining, state.contributionBasis);
43
+ remaining -= contributions;
44
+ const contributionBasis = state.contributionBasis - contributions;
45
+ // 2) Conversions, oldest first. Tax-free (already taxed at conversion); a 10%
46
+ // penalty applies to a layer tapped within 5 years while under 59½.
47
+ let conversions = 0;
48
+ let penalty = 0;
49
+ const conversionLayers = [];
50
+ for (const layer of state.conversionLayers) {
51
+ if (remaining <= 0) {
52
+ conversionLayers.push(layer);
53
+ continue;
54
+ }
55
+ const take = Math.min(remaining, layer.amount);
56
+ conversions += take;
57
+ remaining -= take;
58
+ // The recapture penalty applies only to the taxable share of the principal
59
+ // tapped; nondeductible basis that was converted recaptures nothing.
60
+ const taxableTake = layer.amount > 0 ? take * (layer.taxableAmount / layer.amount) : 0;
61
+ if (year - layer.year < ROTH_SEASONING_YEARS && !qualified)
62
+ penalty += taxableTake * 0.1;
63
+ const left = layer.amount - take;
64
+ if (left > 0)
65
+ conversionLayers.push({ year: layer.year, amount: left, taxableAmount: layer.taxableAmount - taxableTake });
66
+ }
67
+ // 3) Earnings — last out. Non-qualified (pre-59½) earnings are ordinary income
68
+ // plus the 10% penalty; qualified earnings are tax- and penalty-free.
69
+ const earnings = remaining;
70
+ let taxableOrdinary = 0;
71
+ if (earnings > 0 && !qualified) {
72
+ taxableOrdinary = earnings;
73
+ penalty += earnings * 0.1;
74
+ }
75
+ return {
76
+ contributions,
77
+ conversions,
78
+ earnings,
79
+ penalty,
80
+ taxableOrdinary,
81
+ next: { contributionBasis, conversionLayers },
82
+ };
83
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Roth-conversion sizing (roadmap V3).
3
+ *
4
+ * Fill-to-target strategies size each year's conversion so a chosen metric
5
+ * lands on a ceiling:
6
+ * - topOfBracket: federal taxable income fills the chosen rate bracket
7
+ * - irmaaTier: MAGI stays just under a chosen IRMAA tier threshold
8
+ * - acaCliff: MAGI stays just under 400% FPL (pre-65 credit cliff)
9
+ * - fixedMagi: MAGI lands on a user-set nominal ceiling
10
+ *
11
+ * Sizing is solved by bisection against the federal tax engine, because the
12
+ * metrics are monotone in the conversion amount but kinked (taxable Social
13
+ * Security phases in at up to 1.85× per converted dollar). The sizing model
14
+ * is always the federal engine, regardless of which TaxCalculator the
15
+ * projection runs — strategies target federal-law boundaries by definition.
16
+ *
17
+ * Threshold scaling matches the projection's expense model: IRMAA thresholds
18
+ * and the FPL index at general inflation beyond the published pack; bracket
19
+ * bounds stay at the latest pack's nominal values (as the tax engine itself
20
+ * does for stand-in years).
21
+ */
22
+ import type { Plan } from '../model/plan.js';
23
+ import type { FilingStatus, ParameterPack } from '../params/types.js';
24
+ import type { TaxYearInput } from '../projection/types.js';
25
+ export type FillTarget = Extract<Plan['strategies']['rothConversion'], {
26
+ mode: 'fillToTarget';
27
+ }>;
28
+ export interface ConversionSizingInput {
29
+ year: number;
30
+ pack: ParameterPack;
31
+ filingStatus: FilingStatus;
32
+ /** Ordinary income before any conversion (wages − pre-tax contributions + RMD − QCD + pensions etc.). */
33
+ ordinaryIncomeBase: number;
34
+ /** Capital gains known before withdrawals (one-time events). */
35
+ capitalGains: number;
36
+ /** Qualified dividends known before withdrawals; included in MAGI and preferential stacking. */
37
+ qualifiedDividends?: number;
38
+ ssBenefits: number;
39
+ peopleAged65Plus: number;
40
+ /** Living household size (FPL). */
41
+ householdSize: number;
42
+ /** Scale applied to IRMAA thresholds / FPL for years beyond the pack. */
43
+ inflationScale: number;
44
+ /** Itemized deductions (nominal) so bracket/MAGI targets use the right deduction. */
45
+ itemizedDeductions?: TaxYearInput['itemizedDeductions'];
46
+ }
47
+ export type SizingResult = {
48
+ ok: true;
49
+ amount: number;
50
+ } | {
51
+ ok: false;
52
+ reason: 'bad_target' | 'already_over_ceiling';
53
+ };
54
+ /** Largest conversion keeping the strategy's metric at or under its ceiling. */
55
+ export declare function sizeRothConversion(strategy: FillTarget, input: ConversionSizingInput): SizingResult;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Roth-conversion sizing (roadmap V3).
3
+ *
4
+ * Fill-to-target strategies size each year's conversion so a chosen metric
5
+ * lands on a ceiling:
6
+ * - topOfBracket: federal taxable income fills the chosen rate bracket
7
+ * - irmaaTier: MAGI stays just under a chosen IRMAA tier threshold
8
+ * - acaCliff: MAGI stays just under 400% FPL (pre-65 credit cliff)
9
+ * - fixedMagi: MAGI lands on a user-set nominal ceiling
10
+ *
11
+ * Sizing is solved by bisection against the federal tax engine, because the
12
+ * metrics are monotone in the conversion amount but kinked (taxable Social
13
+ * Security phases in at up to 1.85× per converted dollar). The sizing model
14
+ * is always the federal engine, regardless of which TaxCalculator the
15
+ * projection runs — strategies target federal-law boundaries by definition.
16
+ *
17
+ * Threshold scaling matches the projection's expense model: IRMAA thresholds
18
+ * and the FPL index at general inflation beyond the published pack; bracket
19
+ * bounds stay at the latest pack's nominal values (as the tax engine itself
20
+ * does for stand-in years).
21
+ */
22
+ import { computeFederalTax } from '../tax/federalTax.js';
23
+ function metricFor(target, detail) {
24
+ return target === 'topOfBracket' ? detail.taxableIncome : detail.magi;
25
+ }
26
+ function ceilingFor(strategy, input) {
27
+ const { pack, filingStatus } = input;
28
+ switch (strategy.target) {
29
+ case 'topOfBracket': {
30
+ const brackets = pack.federalTax.brackets[filingStatus];
31
+ const i = brackets.findIndex((b) => b.ratePct === strategy.targetValue);
32
+ if (i < 0 || i + 1 >= brackets.length)
33
+ return null; // unknown rate or open-ended top bracket
34
+ return brackets[i + 1].lowerBound;
35
+ }
36
+ case 'irmaaTier': {
37
+ const tier = strategy.targetValue;
38
+ if (tier === null || !Number.isInteger(tier) || tier < 1 || tier > pack.medicare.irmaaTiers.length)
39
+ return null;
40
+ return pack.medicare.irmaaTiers[tier - 1].magiOver[filingStatus] * input.inflationScale;
41
+ }
42
+ case 'acaCliff': {
43
+ const fpl = pack.federalPovertyLine.firstPerson +
44
+ pack.federalPovertyLine.perAdditionalPerson * Math.max(0, input.householdSize - 1);
45
+ return fpl * input.inflationScale * (pack.aca.maxFplPctForCredit / 100);
46
+ }
47
+ case 'fixedMagi':
48
+ return strategy.targetValue !== null && strategy.targetValue > 0 ? strategy.targetValue : null;
49
+ }
50
+ }
51
+ /** Largest conversion keeping the strategy's metric at or under its ceiling. */
52
+ export function sizeRothConversion(strategy, input) {
53
+ const ceiling = ceilingFor(strategy, input);
54
+ if (ceiling === null)
55
+ return { ok: false, reason: 'bad_target' };
56
+ const metricAt = (conversion) => metricFor(strategy.target, computeFederalTax({
57
+ year: input.year,
58
+ filingStatus: input.filingStatus,
59
+ ordinaryIncome: input.ordinaryIncomeBase + conversion,
60
+ capitalGains: input.capitalGains,
61
+ qualifiedDividends: input.qualifiedDividends ?? 0,
62
+ ssBenefits: input.ssBenefits,
63
+ peopleAged65Plus: input.peopleAged65Plus,
64
+ itemizedDeductions: input.itemizedDeductions,
65
+ }));
66
+ const base = metricAt(0);
67
+ if (base >= ceiling)
68
+ return { ok: false, reason: 'already_over_ceiling' };
69
+ // Taxable income can rise $0 per converted dollar until deductions are
70
+ // consumed, so expand the upper bound until it brackets the ceiling.
71
+ let lo = 0;
72
+ let hi = Math.max(ceiling - base, 1_000);
73
+ for (let i = 0; i < 30 && metricAt(hi) <= ceiling; i++)
74
+ hi *= 2;
75
+ for (let i = 0; i < 60 && hi - lo > 0.01; i++) {
76
+ const mid = (lo + hi) / 2;
77
+ if (metricAt(mid) > ceiling)
78
+ hi = mid;
79
+ else
80
+ lo = mid;
81
+ }
82
+ return { ok: true, amount: lo };
83
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * 72(t) Substantially Equal Periodic Payments (roadmap V8, §4 tax depth).
3
+ *
4
+ * A 72(t) SEPP lets someone tap a traditional IRA/401(k) before 59½ without the
5
+ * 10% early-withdrawal penalty, IF they take a fixed series of "substantially
6
+ * equal" payments for the LONGER of five years or until age 59½. Modifying the
7
+ * series early triggers retroactive penalties; the engine assumes the schedule
8
+ * is honored (it doesn't model busting).
9
+ *
10
+ * Two of the three IRS-sanctioned methods are supported:
11
+ * - 'rmd': payment = balance ÷ life-expectancy divisor, recomputed
12
+ * each year (varies with the balance).
13
+ * - 'amortization': payment fixed at the first SEPP year, amortizing that
14
+ * year's balance over the life expectancy at the chosen
15
+ * interest rate (IRS Notice 2022-6 allows up to 120% of the
16
+ * federal mid-term AFR; we use a flat assumed rate).
17
+ *
18
+ * The life-expectancy divisor uses the repo's SSA period table
19
+ * (`baselineRemainingYears`) as a documented proxy for the IRS Single Life
20
+ * Table — close enough for a planning tool and avoids carrying a second table.
21
+ */
22
+ import type { Sex } from '../longevity/types.js';
23
+ export type SeppMethod = 'rmd' | 'amortization';
24
+ /** Assumed 72(t) interest rate for the amortization method (the common 5% floor). */
25
+ export declare const SEPP_AMORTIZATION_RATE_PCT = 5;
26
+ /**
27
+ * A SEPP must run for the LONGER of 5 years or until age 59½ (≈ the engine's
28
+ * age-60 penalty boundary). Active in the year the owner attains `age` when the
29
+ * election has started and neither condition has yet been satisfied.
30
+ */
31
+ export declare function seppActive(startAge: number, age: number): boolean;
32
+ /**
33
+ * The penalty-free SEPP distribution for the year, by method.
34
+ * - rmd: `balance` is the current start-of-year balance.
35
+ * - amortization: `balance` is the FIRST SEPP year's balance and `age` should
36
+ * be `startAge`; the result is fixed for the whole series.
37
+ */
38
+ export declare function seppAnnualAmount(method: SeppMethod, balance: number, age: number, sex: Sex, ratePct?: number): number;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * 72(t) Substantially Equal Periodic Payments (roadmap V8, §4 tax depth).
3
+ *
4
+ * A 72(t) SEPP lets someone tap a traditional IRA/401(k) before 59½ without the
5
+ * 10% early-withdrawal penalty, IF they take a fixed series of "substantially
6
+ * equal" payments for the LONGER of five years or until age 59½. Modifying the
7
+ * series early triggers retroactive penalties; the engine assumes the schedule
8
+ * is honored (it doesn't model busting).
9
+ *
10
+ * Two of the three IRS-sanctioned methods are supported:
11
+ * - 'rmd': payment = balance ÷ life-expectancy divisor, recomputed
12
+ * each year (varies with the balance).
13
+ * - 'amortization': payment fixed at the first SEPP year, amortizing that
14
+ * year's balance over the life expectancy at the chosen
15
+ * interest rate (IRS Notice 2022-6 allows up to 120% of the
16
+ * federal mid-term AFR; we use a flat assumed rate).
17
+ *
18
+ * The life-expectancy divisor uses the repo's SSA period table
19
+ * (`baselineRemainingYears`) as a documented proxy for the IRS Single Life
20
+ * Table — close enough for a planning tool and avoids carrying a second table.
21
+ */
22
+ import { baselineRemainingYears } from '../longevity/ssaPeriod2022.js';
23
+ /** Assumed 72(t) interest rate for the amortization method (the common 5% floor). */
24
+ export const SEPP_AMORTIZATION_RATE_PCT = 5;
25
+ /**
26
+ * A SEPP must run for the LONGER of 5 years or until age 59½ (≈ the engine's
27
+ * age-60 penalty boundary). Active in the year the owner attains `age` when the
28
+ * election has started and neither condition has yet been satisfied.
29
+ */
30
+ export function seppActive(startAge, age) {
31
+ if (age < startAge)
32
+ return false;
33
+ return age < 60 || age - startAge < 5;
34
+ }
35
+ /**
36
+ * The penalty-free SEPP distribution for the year, by method.
37
+ * - rmd: `balance` is the current start-of-year balance.
38
+ * - amortization: `balance` is the FIRST SEPP year's balance and `age` should
39
+ * be `startAge`; the result is fixed for the whole series.
40
+ */
41
+ export function seppAnnualAmount(method, balance, age, sex, ratePct = SEPP_AMORTIZATION_RATE_PCT) {
42
+ if (balance <= 0)
43
+ return 0;
44
+ const lifeExpectancy = Math.max(1, baselineRemainingYears(age, sex));
45
+ if (method === 'rmd')
46
+ return balance / lifeExpectancy;
47
+ // Amortization: level payment amortizing `balance` over `lifeExpectancy` years
48
+ // at `ratePct`. With r = 0 this degenerates to balance ÷ years.
49
+ const r = ratePct / 100;
50
+ if (r === 0)
51
+ return balance / lifeExpectancy;
52
+ return (balance * r) / (1 - Math.pow(1 + r, -lifeExpectancy));
53
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * ACA premium tax credit for pre-65 retirees (post-2025 law: enhanced credits
3
+ * expired, 400% FPL cliff restored). The credit is computed against MAGI and
4
+ * the household's poverty-line ratio; $1 over the cliff forfeits everything —
5
+ * a first-class constraint on Roth conversions and gain harvesting.
6
+ *
7
+ * @see DOCS/domain/domain-rules-reference.md §8
8
+ */
9
+ import type { ParameterPack } from '../params/types.js';
10
+ export interface AcaResult {
11
+ /** MAGI as a percentage of the federal poverty line. */
12
+ fplPct: number;
13
+ /** Expected annual contribution toward the benchmark premium. */
14
+ expectedContribution: number;
15
+ credit: number;
16
+ netAnnualPremium: number;
17
+ /** True when income sits above the credit cutoff (the cliff). */
18
+ overCliff: boolean;
19
+ }
20
+ /** Piecewise-linear applicable percentage for a poverty-line ratio. */
21
+ export declare function acaApplicablePct(pack: ParameterPack, fplPct: number): number;
22
+ /**
23
+ * Net marketplace premium after the credit.
24
+ *
25
+ * `fullAnnualPremium` is the household's unsubsidized premium, which v1 also
26
+ * uses as the benchmark-plan proxy (separate benchmark entry is a later
27
+ * refinement). `fplScale` indexes the poverty line for years past the pack.
28
+ */
29
+ export declare function acaNetAnnualPremium(pack: ParameterPack, householdSize: number, magi: number, fullAnnualPremium: number, fplScale?: number): AcaResult;
30
+ /**
31
+ * Net marketplace premium when coverage varies by month (the age-65 transition
32
+ * year). The PTC is a monthly credit — each covered month gets
33
+ * `max(0, premium_m − expectedContribution/12)` — so a member covered for five
34
+ * months owes five-twelfths of the household expected contribution, not all of
35
+ * it. With twelve equal covered months this matches `acaNetAnnualPremium`;
36
+ * months with zero premium (no marketplace coverage) earn no credit.
37
+ */
38
+ export declare function acaNetAnnualPremiumByMonth(pack: ParameterPack, householdSize: number, magi: number,
39
+ /** Household marketplace premium per calendar month (length 12; 0 = not covered). */
40
+ monthlyPremiums: readonly number[], fplScale?: number): AcaResult;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * ACA premium tax credit for pre-65 retirees (post-2025 law: enhanced credits
3
+ * expired, 400% FPL cliff restored). The credit is computed against MAGI and
4
+ * the household's poverty-line ratio; $1 over the cliff forfeits everything —
5
+ * a first-class constraint on Roth conversions and gain harvesting.
6
+ *
7
+ * @see DOCS/domain/domain-rules-reference.md §8
8
+ */
9
+ /** Piecewise-linear applicable percentage for a poverty-line ratio. */
10
+ export function acaApplicablePct(pack, fplPct) {
11
+ const points = pack.aca.applicablePctBreakpoints;
12
+ if (fplPct <= points[0].fplPct)
13
+ return points[0].applicablePct;
14
+ for (let i = 1; i < points.length; i++) {
15
+ const prev = points[i - 1];
16
+ const next = points[i];
17
+ if (fplPct <= next.fplPct) {
18
+ const t = (fplPct - prev.fplPct) / (next.fplPct - prev.fplPct);
19
+ return prev.applicablePct + t * (next.applicablePct - prev.applicablePct);
20
+ }
21
+ }
22
+ return points[points.length - 1].applicablePct;
23
+ }
24
+ /**
25
+ * Net marketplace premium after the credit.
26
+ *
27
+ * `fullAnnualPremium` is the household's unsubsidized premium, which v1 also
28
+ * uses as the benchmark-plan proxy (separate benchmark entry is a later
29
+ * refinement). `fplScale` indexes the poverty line for years past the pack.
30
+ */
31
+ export function acaNetAnnualPremium(pack, householdSize, magi, fullAnnualPremium, fplScale = 1) {
32
+ const fpl = (pack.federalPovertyLine.firstPerson + pack.federalPovertyLine.perAdditionalPerson * Math.max(0, householdSize - 1)) *
33
+ fplScale;
34
+ const fplPct = fpl > 0 ? (magi / fpl) * 100 : Infinity;
35
+ const overCliff = fplPct > pack.aca.maxFplPctForCredit;
36
+ if (overCliff || fullAnnualPremium <= 0) {
37
+ return { fplPct, expectedContribution: 0, credit: 0, netAnnualPremium: fullAnnualPremium, overCliff };
38
+ }
39
+ const expectedContribution = (acaApplicablePct(pack, fplPct) / 100) * magi;
40
+ const credit = Math.max(0, fullAnnualPremium - expectedContribution);
41
+ return {
42
+ fplPct,
43
+ expectedContribution,
44
+ credit,
45
+ netAnnualPremium: fullAnnualPremium - credit,
46
+ overCliff,
47
+ };
48
+ }
49
+ /**
50
+ * Net marketplace premium when coverage varies by month (the age-65 transition
51
+ * year). The PTC is a monthly credit — each covered month gets
52
+ * `max(0, premium_m − expectedContribution/12)` — so a member covered for five
53
+ * months owes five-twelfths of the household expected contribution, not all of
54
+ * it. With twelve equal covered months this matches `acaNetAnnualPremium`;
55
+ * months with zero premium (no marketplace coverage) earn no credit.
56
+ */
57
+ export function acaNetAnnualPremiumByMonth(pack, householdSize, magi,
58
+ /** Household marketplace premium per calendar month (length 12; 0 = not covered). */
59
+ monthlyPremiums, fplScale = 1) {
60
+ const fullAnnualPremium = monthlyPremiums.reduce((sum, premium) => sum + premium, 0);
61
+ const fpl = (pack.federalPovertyLine.firstPerson + pack.federalPovertyLine.perAdditionalPerson * Math.max(0, householdSize - 1)) *
62
+ fplScale;
63
+ const fplPct = fpl > 0 ? (magi / fpl) * 100 : Infinity;
64
+ const overCliff = fplPct > pack.aca.maxFplPctForCredit;
65
+ if (overCliff || fullAnnualPremium <= 0) {
66
+ return { fplPct, expectedContribution: 0, credit: 0, netAnnualPremium: fullAnnualPremium, overCliff };
67
+ }
68
+ const expectedContribution = (acaApplicablePct(pack, fplPct) / 100) * magi;
69
+ let credit = 0;
70
+ for (const premium of monthlyPremiums) {
71
+ if (premium <= 0)
72
+ continue;
73
+ credit += Math.max(0, premium - expectedContribution / 12);
74
+ }
75
+ return {
76
+ fplPct,
77
+ expectedContribution,
78
+ credit,
79
+ netAnnualPremium: fullAnnualPremium - credit,
80
+ overCliff,
81
+ };
82
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Federal income tax (planning-grade), replacing the V1 flat placeholder.
3
+ *
4
+ * Computation order per year:
5
+ * 1. Taxable Social Security via provisional income (unindexed thresholds)
6
+ * 2. AGI = ordinary + capital gains + taxable SS; MAGI ≈ AGI (no foreign
7
+ * exclusions or tax-exempt interest modeled)
8
+ * 3. Deductions: standard + age-65 additions + OBBBA senior deduction
9
+ * (2025–2028, 6%-of-MAGI phase-out); itemized not modeled in v1
10
+ * 4. Ordinary brackets on non-preferential taxable income
11
+ * 5. LTCG/qualified-dividend stacking at 0/15/20% on top of ordinary
12
+ * 6. NIIT 3.8% on investment income over the (unindexed) MAGI threshold
13
+ * 7. Planning-grade AMT screen: modeled add-backs/preference items (notably
14
+ * standard deduction or itemized SALT), AMT exemption/phaseout, and
15
+ * preferential-rate-aware tentative minimum tax.
16
+ *
17
+ * Out of scope here (see DOCS/features/taxes.md): credits, full Form 6251
18
+ * adjustments, early-withdrawal penalties (projection-level), IRMAA
19
+ * (expense-side), state.
20
+ *
21
+ * @see DOCS/domain/domain-rules-reference.md §§1–3
22
+ */
23
+ import type { FilingStatus, ParameterPack } from '../params/types.js';
24
+ import { type TaxCalculator, type TaxYearInput } from '../projection/types.js';
25
+ export interface FederalTaxDetail {
26
+ year: number;
27
+ /** True when this year's figures use a stand-in parameter pack. */
28
+ usesStandInPack: boolean;
29
+ taxableSocialSecurity: number;
30
+ agi: number;
31
+ magi: number;
32
+ deduction: number;
33
+ seniorDeduction: number;
34
+ /** True when the itemized total beat the standard deduction this year. */
35
+ itemized: boolean;
36
+ taxableIncome: number;
37
+ /** Taxable income taxed at ordinary rates (after preferential carve-out). */
38
+ ordinaryTaxable: number;
39
+ /** LTCG + qualified dividends taxed via stacking. */
40
+ preferentialIncome: number;
41
+ ordinaryTax: number;
42
+ capitalGainsTax: number;
43
+ /** AMT add-backs and preference items included in AMTI. */
44
+ amtPreferenceItems: number;
45
+ alternativeMinimumTaxableIncome: number;
46
+ amtExemption: number;
47
+ tentativeMinimumTax: number;
48
+ alternativeMinimumTax: number;
49
+ niit: number;
50
+ totalTax: number;
51
+ /** Additional long-term gains realizable this year still taxed at 0% (gain-harvesting headroom). */
52
+ zeroRateLtcgHeadroom: number;
53
+ }
54
+ /**
55
+ * Room left in the 0% long-term-capital-gains bracket this year: additional
56
+ * preferential income that could be realized and still be taxed at 0%. Gains
57
+ * stack on top of ordinary taxable income, so the 0% layer runs up to the 15%
58
+ * threshold.
59
+ *
60
+ * For retirees on Social Security this is NOT simply `threshold − taxableIncome`:
61
+ * realizing gains raises provisional income, which can make more of the benefit
62
+ * taxable, so taxable income climbs faster than $1 per gain dollar. We solve for
63
+ * the largest additional gain that keeps taxable income at the threshold,
64
+ * modeling that SS phase-in (the dominant interaction). The deduction is held
65
+ * fixed — the second-order senior-deduction MAGI phase-out is not modeled.
66
+ * @see DOCS/domain/domain-rules-reference.md §2
67
+ */
68
+ export declare function zeroRateLtcgHeadroom(pack: ParameterPack, filingStatus: FilingStatus, ordinaryExcludingSs: number, currentGains: number, currentQualifiedDividends: number, ssBenefits: number, deduction: number): number;
69
+ /**
70
+ * Taxable share of Social Security benefits (IRC §86).
71
+ * Provisional income = AGI excluding SS + 50% of benefits (tax-exempt interest not modeled).
72
+ */
73
+ export declare function taxableSocialSecurity(pack: ParameterPack, filingStatus: FilingStatus, agiExcludingSs: number, ssBenefits: number): number;
74
+ /** One year's result of applying a capital-loss carryforward to income. */
75
+ export interface CarryforwardNetting {
76
+ /** Ordinary income — unchanged; the deductible loss rides the capital line, not ordinary income. */
77
+ ordinaryAfter: number;
78
+ /**
79
+ * Net capital gain after the pool absorbs realized gains and the deductible
80
+ * net loss is taken: positive when gains remain, negative (down to
81
+ * −ordinaryOffsetLimit) when a net loss is deducted. Feeds the tax engine's
82
+ * signed `capitalGains` input.
83
+ */
84
+ netCapitalGain: number;
85
+ /** Realized gains the pool absorbed this year. */
86
+ usedAgainstGains: number;
87
+ /** Net loss deducted against income this year (≤ the annual limit); reduces AGI. */
88
+ usedAgainstOrdinary: number;
89
+ /** Pool carried into next year. */
90
+ remaining: number;
91
+ }
92
+ /**
93
+ * Apply a net capital-loss carryforward to one year (IRC §1211(b)/§1212): net
94
+ * against this year's realized gains first, then deduct up to `ordinaryOffsetLimit`
95
+ * ($3,000) of the remainder as a net capital loss — a *negative* figure on the
96
+ * return's capital-gain line that reduces AGI (and so provisional income, taxable
97
+ * SS, and MAGI) regardless of how much other income there is, **not** an offset
98
+ * capped at ordinary income. The rest carries forward indefinitely. Pure — the
99
+ * projection threads the depleting pool year-to-year and feeds the netted figures
100
+ * to BOTH the federal and state calculators, so the AGI cascade falls out. Single
101
+ * pool, no short-/long-term split (a documented planning simplification); the
102
+ * §1212 carryover-worksheet preservation of a deduction "wasted" in a year with
103
+ * no taxable income to absorb it is not modeled (immaterial outside zero-income
104
+ * years). @see DOCS/features/taxes.md
105
+ */
106
+ export declare function applyCapitalLossCarryforward(carryforward: number, ordinaryIncome: number, capitalGains: number, ordinaryOffsetLimit: number): CarryforwardNetting;
107
+ export declare function computeFederalTax(input: TaxYearInput): FederalTaxDetail;
108
+ /** Federal engine behind the projection's pluggable interface. */
109
+ export declare function createFederalTaxCalculator(): TaxCalculator;
110
+ export declare function combineTaxCalculators(...calculators: TaxCalculator[]): TaxCalculator;