@robotixai/calculator-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.
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Deterministic Year-by-Year Projection Engine (Basic Mode)
3
+ *
4
+ * Computes a full retirement projection from current_age to end_age,
5
+ * producing a TimelineRow per year and aggregate Metrics.
6
+ *
7
+ * The `overrideReturns` parameter allows Monte Carlo to inject randomized
8
+ * annual returns — when provided, overrideReturns[yearIndex] is used
9
+ * instead of nominal_return_pct / 100.
10
+ */
11
+ import { CadenceMultiplier } from './defaults';
12
+ import { calculateTax, getRMDAmount, calculateRothConversion } from './tax';
13
+ import { calculateWithdrawal, NEAR_ZERO_THRESHOLD, } from './withdrawal';
14
+ // =============================================================================
15
+ // Helpers
16
+ // =============================================================================
17
+ /**
18
+ * Approximate birth year from current_age.
19
+ * Used for RMD start-age determination. We use a fixed "current year" proxy
20
+ * derived from the scenario (not wall-clock time) so results stay deterministic.
21
+ */
22
+ function estimateBirthYear(currentAge) {
23
+ // Use 2025 as reference year (constant for determinism)
24
+ return 2025 - currentAge;
25
+ }
26
+ /**
27
+ * Compute desired spending for the readiness score denominator.
28
+ * Only meaningful for "Fixed real-dollar amount"; for "Fixed %" we mirror
29
+ * the actual withdrawal so the ratio is 1:1 (capped at 200 by spec).
30
+ */
31
+ function computeDesiredSpending(scenario, priorEndBalance, cpiIndex) {
32
+ const { withdrawal_method, withdrawal_pct, withdrawal_real_amount, withdrawal_frequency, withdrawal_strategy, spending_phases, } = scenario;
33
+ // Age-Banded has its own target (handled elsewhere), but for Standard / GK
34
+ // the desired amount before capping is what matters.
35
+ if (withdrawal_strategy === 'Age-Banded') {
36
+ // For age-banded the "desired" equals the phase amount — we compute this
37
+ // in the main loop where we know the age.
38
+ return 0; // sentinel — caller overrides
39
+ }
40
+ let desired;
41
+ if (withdrawal_method === 'Fixed % of prior-year end balance') {
42
+ desired = priorEndBalance * (withdrawal_pct / 100);
43
+ }
44
+ else {
45
+ desired = withdrawal_real_amount * cpiIndex;
46
+ }
47
+ if (withdrawal_frequency === 'Monthly') {
48
+ desired *= 12;
49
+ }
50
+ return desired;
51
+ }
52
+ // =============================================================================
53
+ // Main Projection
54
+ // =============================================================================
55
+ export function runProjection(scenario, overrideReturns) {
56
+ var _a, _b, _c, _d;
57
+ const { current_age, retirement_age, end_age, current_balance, contrib_amount, contrib_cadence, contrib_increase_pct, nominal_return_pct, inflation_pct, inflation_enabled, fee_pct, perf_fee_pct, enable_taxes, effective_tax_rate_pct, tax_jurisdiction, tax_config, tax_deferred_pct, planning_mode, partner_current_age, partner_income_sources, income_sources, liquidity_events,
58
+ // assets — not used in basic-mode projection (estate_pct is advanced-mode only)
59
+ black_swan_enabled, black_swan_age, black_swan_loss_pct, spending_phases, withdrawal_strategy, } = scenario;
60
+ const timeline = [];
61
+ // Running state
62
+ let prevEndBalance = current_balance;
63
+ let cpiIndex = 1.0;
64
+ let firstShortfallAge = null;
65
+ let gkState = null;
66
+ // Accumulators for Metrics
67
+ let totalContributions = 0;
68
+ let totalWithdrawals = 0;
69
+ let totalFees = 0;
70
+ let totalTaxes = 0;
71
+ let totalDesiredSpending = 0;
72
+ let totalActualWithdrawals = 0;
73
+ const birthYear = estimateBirthYear(current_age);
74
+ const partnerAgeOffset = planning_mode === 'Couple' && partner_current_age != null
75
+ ? partner_current_age - current_age
76
+ : 0;
77
+ // High-water mark for performance fee
78
+ let highWaterMark = current_balance;
79
+ for (let age = current_age; age <= end_age; age++) {
80
+ const yearIndex = age - current_age;
81
+ const startBalance = yearIndex === 0 ? current_balance : prevEndBalance;
82
+ // Update CPI index (starts at 1.0 for year 0)
83
+ if (yearIndex > 0 && inflation_enabled) {
84
+ cpiIndex *= 1 + inflation_pct / 100;
85
+ }
86
+ // ------------------------------------------------------------------
87
+ // 1. CONTRIBUTIONS (pre-retirement only)
88
+ // ------------------------------------------------------------------
89
+ let contributions = 0;
90
+ if (age < retirement_age) {
91
+ const baseContrib = contrib_amount * CadenceMultiplier[contrib_cadence];
92
+ contributions =
93
+ baseContrib * Math.pow(1 + contrib_increase_pct / 100, yearIndex);
94
+ }
95
+ // ------------------------------------------------------------------
96
+ // 2. INCOME SOURCES
97
+ // ------------------------------------------------------------------
98
+ let netIncome = 0;
99
+ let totalIncomeTaxes = 0;
100
+ const processIncomeSource = (source, effectiveAge) => {
101
+ if (!source.enabled)
102
+ return;
103
+ if (effectiveAge < source.start_age || effectiveAge > source.end_age)
104
+ return;
105
+ let annual = source.amount * (source.frequency === 'Monthly' ? 12 : 1);
106
+ if (source.inflation_adjusted) {
107
+ annual *= cpiIndex;
108
+ }
109
+ let incomeTax = 0;
110
+ if (source.taxable) {
111
+ incomeTax = annual * (source.tax_rate / 100);
112
+ }
113
+ netIncome += annual - incomeTax;
114
+ totalIncomeTaxes += incomeTax;
115
+ };
116
+ // Primary income sources
117
+ for (const src of income_sources) {
118
+ processIncomeSource(src, age);
119
+ }
120
+ // Partner income sources (Couple mode)
121
+ if (planning_mode === 'Couple') {
122
+ const partnerAge = age + partnerAgeOffset;
123
+ for (const src of partner_income_sources) {
124
+ processIncomeSource(src, partnerAge);
125
+ }
126
+ }
127
+ // ------------------------------------------------------------------
128
+ // 3. LIQUIDITY EVENTS
129
+ // ------------------------------------------------------------------
130
+ let liquidityNet = 0;
131
+ let liquidityEventTaxes = 0;
132
+ for (const event of liquidity_events) {
133
+ if (!event.enabled)
134
+ continue;
135
+ let fires = false;
136
+ if (event.recurrence === 'One-Time') {
137
+ fires = age === event.start_age;
138
+ }
139
+ else {
140
+ fires = age >= event.start_age && age <= event.end_age;
141
+ }
142
+ if (!fires)
143
+ continue;
144
+ let eventAmount = event.amount;
145
+ if (event.recurrence === 'Monthly') {
146
+ eventAmount *= 12;
147
+ }
148
+ if (event.type === 'Credit') {
149
+ liquidityNet += eventAmount;
150
+ }
151
+ else {
152
+ liquidityNet -= eventAmount;
153
+ }
154
+ if (event.taxable) {
155
+ const eventTax = eventAmount * (event.tax_rate / 100);
156
+ liquidityEventTaxes += eventTax;
157
+ }
158
+ }
159
+ // ------------------------------------------------------------------
160
+ // 4. RMD (US only, if enabled)
161
+ // ------------------------------------------------------------------
162
+ let rmdAmount = 0;
163
+ if (tax_jurisdiction === 'US' &&
164
+ (tax_config === null || tax_config === void 0 ? void 0 : tax_config.enable_rmd)) {
165
+ const taxDeferredBalance = startBalance * (tax_deferred_pct / 100);
166
+ rmdAmount = getRMDAmount(age, taxDeferredBalance, birthYear);
167
+ }
168
+ // ------------------------------------------------------------------
169
+ // 5. ROTH CONVERSIONS (US only, if enabled)
170
+ // ------------------------------------------------------------------
171
+ let rothAmount = 0;
172
+ if (tax_jurisdiction === 'US' &&
173
+ tax_config) {
174
+ rothAmount = calculateRothConversion(age, tax_config);
175
+ }
176
+ // ------------------------------------------------------------------
177
+ // 6. WITHDRAWALS (post-retirement only)
178
+ // ------------------------------------------------------------------
179
+ let withdrawal = 0;
180
+ let desiredSpending = 0;
181
+ let shortfallWithdrawals = 0;
182
+ if (age >= retirement_age) {
183
+ // Available balance for withdrawal cap
184
+ const availableBalance = Math.max(0, startBalance + contributions + netIncome + liquidityNet);
185
+ const priorEndBalance = yearIndex === 0 ? current_balance : prevEndBalance;
186
+ // Compute desired spending (before capping)
187
+ desiredSpending = computeDesiredSpending(scenario, priorEndBalance, cpiIndex);
188
+ // Override for Age-Banded: compute the uncapped phase amount
189
+ if (withdrawal_strategy === 'Age-Banded') {
190
+ const phase = spending_phases.find((p) => age >= p.start_age && age <= p.end_age);
191
+ if (phase) {
192
+ desiredSpending =
193
+ phase.mode === 'percent'
194
+ ? startBalance * (phase.amount / 100)
195
+ : phase.amount * cpiIndex;
196
+ }
197
+ else {
198
+ desiredSpending = 0;
199
+ }
200
+ }
201
+ // Call withdrawal calculator
202
+ const wResult = calculateWithdrawal(scenario, {
203
+ age,
204
+ currentBalance: startBalance,
205
+ priorEndBalance,
206
+ availableBalance,
207
+ cpiIndex,
208
+ gkState,
209
+ });
210
+ withdrawal = wResult.withdrawal;
211
+ if (wResult.gkState) {
212
+ gkState = wResult.gkState;
213
+ }
214
+ // RMD override: if RMD exceeds calculated withdrawal, use RMD
215
+ if (rmdAmount > withdrawal) {
216
+ desiredSpending = Math.max(desiredSpending, rmdAmount);
217
+ withdrawal = Math.min(rmdAmount, availableBalance);
218
+ }
219
+ // Cap at available balance
220
+ withdrawal = Math.min(withdrawal, availableBalance);
221
+ // Track shortfall
222
+ shortfallWithdrawals = Math.max(0, desiredSpending - withdrawal);
223
+ // Near-zero depletion: if post-withdrawal balance < $100, drain fully
224
+ if (wResult.effectivelyDepleted) {
225
+ withdrawal = Math.min(availableBalance, withdrawal);
226
+ }
227
+ }
228
+ // ------------------------------------------------------------------
229
+ // 7. FEES
230
+ // ------------------------------------------------------------------
231
+ const managementFee = startBalance * (fee_pct / 100);
232
+ // Gross gain for performance fee (before fees, using the year's return rate)
233
+ const returnRate = (_a = overrideReturns === null || overrideReturns === void 0 ? void 0 : overrideReturns[yearIndex]) !== null && _a !== void 0 ? _a : nominal_return_pct / 100;
234
+ const grossGain = startBalance * returnRate;
235
+ let perfFee = 0;
236
+ if (perf_fee_pct > 0 && grossGain > 0) {
237
+ // High-water mark: only charge perf fee on gains above the mark
238
+ const currentValue = startBalance + grossGain;
239
+ if (currentValue > highWaterMark) {
240
+ perfFee = (currentValue - highWaterMark) * (perf_fee_pct / 100);
241
+ highWaterMark = currentValue;
242
+ }
243
+ }
244
+ const fees = managementFee + perfFee;
245
+ // ------------------------------------------------------------------
246
+ // 8. TAXES
247
+ // ------------------------------------------------------------------
248
+ let taxes = 0;
249
+ if (enable_taxes) {
250
+ const taxableWithdrawal = age >= retirement_age ? withdrawal : 0;
251
+ const totalTaxableIncome = taxableWithdrawal + rothAmount + liquidityEventTaxes;
252
+ if (tax_config && tax_jurisdiction !== 'Custom') {
253
+ taxes = calculateTax(totalTaxableIncome, tax_config, tax_jurisdiction);
254
+ }
255
+ else {
256
+ // Custom / fallback: flat effective rate
257
+ taxes = totalTaxableIncome * (effective_tax_rate_pct / 100);
258
+ }
259
+ }
260
+ // Income taxes are tracked separately and already deducted from netIncome
261
+ // Add liquidity event taxes to the total taxes column
262
+ taxes += liquidityEventTaxes;
263
+ // ------------------------------------------------------------------
264
+ // 9. GROWTH
265
+ // ------------------------------------------------------------------
266
+ const netFlows = contributions + netIncome + liquidityNet - withdrawal - fees - taxes;
267
+ let growth;
268
+ // ------------------------------------------------------------------
269
+ // 10. BLACK SWAN
270
+ // ------------------------------------------------------------------
271
+ if (black_swan_enabled && age === black_swan_age) {
272
+ // Override growth with the loss
273
+ growth = -(startBalance * (black_swan_loss_pct / 100));
274
+ }
275
+ else {
276
+ // Mid-year cash flow assumption:
277
+ // growth = startBalance * return + netFlows * return * 0.5
278
+ const effectiveReturn = (_b = overrideReturns === null || overrideReturns === void 0 ? void 0 : overrideReturns[yearIndex]) !== null && _b !== void 0 ? _b : nominal_return_pct / 100;
279
+ growth =
280
+ startBalance * effectiveReturn + netFlows * effectiveReturn * 0.5;
281
+ }
282
+ // ------------------------------------------------------------------
283
+ // 11. END BALANCE
284
+ // ------------------------------------------------------------------
285
+ let endBalance = startBalance + netFlows + growth;
286
+ // Track shortfall before flooring
287
+ if (endBalance < 0 && age >= retirement_age && firstShortfallAge === null) {
288
+ firstShortfallAge = age;
289
+ }
290
+ // Near-zero depletion threshold (edge case: asymptotic drain)
291
+ if (endBalance >= 0 &&
292
+ endBalance < NEAR_ZERO_THRESHOLD &&
293
+ age >= retirement_age &&
294
+ firstShortfallAge === null) {
295
+ firstShortfallAge = age;
296
+ }
297
+ // Floor at 0 in basic mode
298
+ endBalance = Math.max(endBalance, 0);
299
+ const endBalanceReal = cpiIndex > 0 ? endBalance / cpiIndex : endBalance;
300
+ // ------------------------------------------------------------------
301
+ // Build TimelineRow
302
+ // ------------------------------------------------------------------
303
+ const row = {
304
+ age,
305
+ start_balance_nominal: startBalance,
306
+ contributions,
307
+ liquidity_net: liquidityNet,
308
+ income: netIncome,
309
+ withdrawals: withdrawal,
310
+ desired_spending: desiredSpending,
311
+ fees,
312
+ taxes,
313
+ income_taxes: totalIncomeTaxes,
314
+ growth,
315
+ end_balance_nominal: endBalance,
316
+ cpi_index: cpiIndex,
317
+ end_balance_real: endBalanceReal,
318
+ // Basic mode: these advanced-mode fields default to simple values
319
+ end_cash_nominal: 0,
320
+ end_debt_nominal: 0,
321
+ end_investments_nominal: endBalance,
322
+ end_liquid_nominal: endBalance,
323
+ end_illiquid_nominal: 0,
324
+ loan_interest: 0,
325
+ loan_principal_repaid: 0,
326
+ mortgage_paid: 0,
327
+ cash_yield: 0,
328
+ insolvency: false,
329
+ shortfall_mandatory: 0,
330
+ shortfall_contributions: 0,
331
+ shortfall_withdrawals: shortfallWithdrawals,
332
+ };
333
+ timeline.push(row);
334
+ // Update running state for next year
335
+ prevEndBalance = endBalance;
336
+ // Update high-water mark based on end balance
337
+ if (endBalance > highWaterMark) {
338
+ highWaterMark = endBalance;
339
+ }
340
+ // Accumulate Metrics
341
+ totalContributions += contributions;
342
+ totalWithdrawals += withdrawal;
343
+ totalFees += fees;
344
+ totalTaxes += taxes + totalIncomeTaxes;
345
+ if (age >= retirement_age) {
346
+ totalDesiredSpending += desiredSpending;
347
+ totalActualWithdrawals += withdrawal;
348
+ }
349
+ }
350
+ // ====================================================================
351
+ // Compute Metrics
352
+ // ====================================================================
353
+ const lastRow = timeline[timeline.length - 1];
354
+ const terminalNominal = (_c = lastRow === null || lastRow === void 0 ? void 0 : lastRow.end_balance_nominal) !== null && _c !== void 0 ? _c : 0;
355
+ const terminalReal = (_d = lastRow === null || lastRow === void 0 ? void 0 : lastRow.end_balance_real) !== null && _d !== void 0 ? _d : 0;
356
+ // Readiness score: ratio of actual to desired spending, capped at 200
357
+ let readinessScore;
358
+ if (totalDesiredSpending > 0) {
359
+ readinessScore = Math.min(200, (totalActualWithdrawals / totalDesiredSpending) * 100);
360
+ }
361
+ else {
362
+ // No desired spending (e.g. no retirement years, or 0 withdrawal target)
363
+ readinessScore = 100;
364
+ }
365
+ // Estate value: terminal balance + projected asset values with estate earmark
366
+ // Note: In basic mode, Asset does not carry estate_pct (that lives on
367
+ // FinancialItem in advanced mode). For basic mode, estate_value = terminal.
368
+ // Advanced mode integration will add per-item estate earmarking later.
369
+ const estateValue = terminalNominal;
370
+ const metrics = {
371
+ terminal_nominal: terminalNominal,
372
+ terminal_real: terminalReal,
373
+ first_shortfall_age: firstShortfallAge,
374
+ readiness_score: readinessScore,
375
+ total_contributions: totalContributions,
376
+ total_withdrawals: totalWithdrawals,
377
+ total_fees: totalFees,
378
+ total_taxes: totalTaxes,
379
+ estate_value: estateValue,
380
+ };
381
+ return { timeline, metrics };
382
+ }
@@ -0,0 +1,30 @@
1
+ import type { Scenario, Metrics } from './types';
2
+ export interface SensitivityFactor {
3
+ name: string;
4
+ label: string;
5
+ lowValue: number;
6
+ highValue: number;
7
+ lowTerminal: number;
8
+ highTerminal: number;
9
+ spread: number;
10
+ }
11
+ /**
12
+ * Runs tornado-chart sensitivity analysis.
13
+ *
14
+ * For each of 7 key parameters, the scenario is cloned and the parameter is
15
+ * adjusted +/- its delta. A deterministic projection is run for each variant
16
+ * and the terminal_real value is recorded.
17
+ *
18
+ * Results are sorted by spread (largest first) so the most impactful
19
+ * parameters appear at the top of the tornado chart.
20
+ *
21
+ * Guards (from EDGE-CASE-REPORT):
22
+ * - retirement_age is clamped to (current_age, end_age)
23
+ * - All percentages are clamped >= 0
24
+ * - contrib_amount and current_balance are clamped >= 0
25
+ * - withdrawal_pct delta is skipped when withdrawal_strategy is Age-Banded
26
+ */
27
+ export declare function runSensitivityAnalysis(scenario: Scenario, projectionFn: (s: Scenario) => {
28
+ metrics: Metrics;
29
+ }): SensitivityFactor[];
30
+ //# sourceMappingURL=sensitivity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sensitivity.d.ts","sourceRoot":"","sources":["../src/sensitivity.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAMjD,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;CAChB;AAkCD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,QAAQ,EAClB,YAAY,EAAE,CAAC,CAAC,EAAE,QAAQ,KAAK;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,GAClD,iBAAiB,EAAE,CAgErB"}
@@ -0,0 +1,92 @@
1
+ const PARAMETERS = [
2
+ { name: 'nominal_return_pct', label: 'Return +/-1%', delta: 1, deltaIsPct: false },
3
+ { name: 'inflation_pct', label: 'Inflation +/-0.5%', delta: 0.5, deltaIsPct: false },
4
+ { name: 'withdrawal_pct', label: 'Withdrawal +/-0.5%', delta: 0.5, deltaIsPct: false },
5
+ { name: 'fee_pct', label: 'Fees +/-0.25%', delta: 0.25, deltaIsPct: false },
6
+ { name: 'retirement_age', label: 'Retire Age +/-2y', delta: 2, deltaIsPct: false },
7
+ { name: 'contrib_amount', label: 'Contrib +/-20%', delta: 20, deltaIsPct: true },
8
+ { name: 'current_balance', label: 'Balance +/-10%', delta: 10, deltaIsPct: true },
9
+ ];
10
+ /**
11
+ * Deep-clone a scenario so mutations don't leak back to the original.
12
+ */
13
+ function cloneScenario(s) {
14
+ return JSON.parse(JSON.stringify(s));
15
+ }
16
+ /**
17
+ * Clamp a value to stay within [min, max].
18
+ */
19
+ function clamp(value, min, max) {
20
+ return Math.max(min, Math.min(max, value));
21
+ }
22
+ /**
23
+ * Runs tornado-chart sensitivity analysis.
24
+ *
25
+ * For each of 7 key parameters, the scenario is cloned and the parameter is
26
+ * adjusted +/- its delta. A deterministic projection is run for each variant
27
+ * and the terminal_real value is recorded.
28
+ *
29
+ * Results are sorted by spread (largest first) so the most impactful
30
+ * parameters appear at the top of the tornado chart.
31
+ *
32
+ * Guards (from EDGE-CASE-REPORT):
33
+ * - retirement_age is clamped to (current_age, end_age)
34
+ * - All percentages are clamped >= 0
35
+ * - contrib_amount and current_balance are clamped >= 0
36
+ * - withdrawal_pct delta is skipped when withdrawal_strategy is Age-Banded
37
+ */
38
+ export function runSensitivityAnalysis(scenario, projectionFn) {
39
+ const factors = [];
40
+ for (const param of PARAMETERS) {
41
+ // Skip withdrawal_pct when strategy is Age-Banded (not applicable)
42
+ if (param.name === 'withdrawal_pct' && scenario.withdrawal_strategy === 'Age-Banded') {
43
+ continue;
44
+ }
45
+ const baselineValue = scenario[param.name];
46
+ // Compute absolute delta
47
+ const absDelta = param.deltaIsPct
48
+ ? baselineValue * (param.delta / 100)
49
+ : param.delta;
50
+ let lowValue = baselineValue - absDelta;
51
+ let highValue = baselineValue + absDelta;
52
+ // --- Clamping guards ---
53
+ if (param.name === 'retirement_age') {
54
+ // retirement_age must stay in (current_age, end_age)
55
+ lowValue = clamp(Math.round(lowValue), scenario.current_age + 1, scenario.end_age - 1);
56
+ highValue = clamp(Math.round(highValue), scenario.current_age + 1, scenario.end_age - 1);
57
+ }
58
+ else if (param.name === 'nominal_return_pct' ||
59
+ param.name === 'inflation_pct' ||
60
+ param.name === 'withdrawal_pct' ||
61
+ param.name === 'fee_pct') {
62
+ // Percentages must stay >= 0
63
+ lowValue = Math.max(0, lowValue);
64
+ highValue = Math.max(0, highValue);
65
+ }
66
+ else if (param.name === 'contrib_amount' || param.name === 'current_balance') {
67
+ // Monetary amounts must stay >= 0
68
+ lowValue = Math.max(0, lowValue);
69
+ highValue = Math.max(0, highValue);
70
+ }
71
+ // Run projection with low value
72
+ const lowScenario = cloneScenario(scenario);
73
+ lowScenario[param.name] = lowValue;
74
+ const lowResult = projectionFn(lowScenario);
75
+ // Run projection with high value
76
+ const highScenario = cloneScenario(scenario);
77
+ highScenario[param.name] = highValue;
78
+ const highResult = projectionFn(highScenario);
79
+ factors.push({
80
+ name: param.name,
81
+ label: param.label,
82
+ lowValue,
83
+ highValue,
84
+ lowTerminal: lowResult.metrics.terminal_real,
85
+ highTerminal: highResult.metrics.terminal_real,
86
+ spread: Math.abs(highResult.metrics.terminal_real - lowResult.metrics.terminal_real),
87
+ });
88
+ }
89
+ // Sort by spread descending (largest impact first)
90
+ factors.sort((a, b) => b.spread - a.spread);
91
+ return factors;
92
+ }
package/dist/tax.d.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Multi-jurisdiction tax calculator for the retirement engine.
3
+ *
4
+ * Jurisdictions: Custom (flat rate), Cayman Islands (zero), US (progressive),
5
+ * UK (progressive with personal allowance taper).
6
+ *
7
+ * Also includes RMD (Required Minimum Distribution) and Roth conversion logic.
8
+ */
9
+ import type { TaxConfig } from './types';
10
+ /**
11
+ * Calculate income tax for the given jurisdiction.
12
+ *
13
+ * @param taxableIncome - Gross income before deductions/allowances.
14
+ * @param config - The scenario's TaxConfig.
15
+ * @param jurisdiction - One of 'Custom', 'Cayman Islands', 'US', 'UK'.
16
+ * @returns Tax amount (always >= 0).
17
+ */
18
+ export declare function calculateTax(taxableIncome: number, config: TaxConfig, jurisdiction: string): number;
19
+ /**
20
+ * Determine the age at which RMDs must begin based on birth year.
21
+ *
22
+ * - Born <= 1950: age 72
23
+ * - Born 1951-1959: age 73
24
+ * - Born >= 1960: age 75
25
+ */
26
+ export declare function getRMDStartAge(birthYear: number): number;
27
+ /**
28
+ * Calculate the Required Minimum Distribution for a given age and balance.
29
+ *
30
+ * @param age - The individual's current age.
31
+ * @param taxDeferredBalance - End-of-prior-year tax-deferred account balance.
32
+ * @param birthYear - Birth year, used to determine RMD start age.
33
+ * @returns The RMD amount, or 0 if not yet required or age > 120.
34
+ */
35
+ export declare function getRMDAmount(age: number, taxDeferredBalance: number, birthYear: number): number;
36
+ /**
37
+ * Calculate the Roth conversion amount for a given age and tax config.
38
+ *
39
+ * If the current age is within the configured conversion window
40
+ * (roth_conversion_start_age <= age <= roth_conversion_end_age) and
41
+ * conversions are enabled, returns the configured conversion amount.
42
+ * Otherwise returns 0.
43
+ *
44
+ * Note: the converted amount adds to taxable income for the conversion year.
45
+ * The caller is responsible for clamping the conversion to the available
46
+ * tax-deferred balance and adding it to taxable income.
47
+ */
48
+ export declare function calculateRothConversion(age: number, config: TaxConfig): number;
49
+ //# sourceMappingURL=tax.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tax.d.ts","sourceRoot":"","sources":["../src/tax.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AA4JzC;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAC1B,aAAa,EAAE,MAAM,EACrB,MAAM,EAAE,SAAS,EACjB,YAAY,EAAE,MAAM,GACnB,MAAM,CAiBR;AAMD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAIxD;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAC1B,GAAG,EAAE,MAAM,EACX,kBAAkB,EAAE,MAAM,EAC1B,SAAS,EAAE,MAAM,GAChB,MAAM,CAUR;AAMD;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,SAAS,GAChB,MAAM,CAMR"}