@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.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/advanced.d.ts +24 -0
- package/dist/advanced.d.ts.map +1 -0
- package/dist/advanced.js +747 -0
- package/dist/backtest.d.ts +28 -0
- package/dist/backtest.d.ts.map +1 -0
- package/dist/backtest.js +235 -0
- package/dist/defaults.d.ts +4 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +84 -0
- package/dist/heatmap.d.ts +38 -0
- package/dist/heatmap.d.ts.map +1 -0
- package/dist/heatmap.js +63 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/monte-carlo.d.ts +43 -0
- package/dist/monte-carlo.d.ts.map +1 -0
- package/dist/monte-carlo.js +178 -0
- package/dist/optimizer.d.ts +40 -0
- package/dist/optimizer.d.ts.map +1 -0
- package/dist/optimizer.js +134 -0
- package/dist/portfolio.d.ts +43 -0
- package/dist/portfolio.d.ts.map +1 -0
- package/dist/portfolio.js +86 -0
- package/dist/projection.d.ts +16 -0
- package/dist/projection.d.ts.map +1 -0
- package/dist/projection.js +382 -0
- package/dist/sensitivity.d.ts +30 -0
- package/dist/sensitivity.d.ts.map +1 -0
- package/dist/sensitivity.js +92 -0
- package/dist/tax.d.ts +49 -0
- package/dist/tax.d.ts.map +1 -0
- package/dist/tax.js +210 -0
- package/dist/types.d.ts +250 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/withdrawal.d.ts +136 -0
- package/dist/withdrawal.d.ts.map +1 -0
- package/dist/withdrawal.js +241 -0
- package/package.json +33 -0
package/dist/advanced.js
ADDED
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Advanced Mode Cash Waterfall Engine
|
|
3
|
+
*
|
|
4
|
+
* Tracks individual financial items (cash, investments, properties, loans,
|
|
5
|
+
* salary, pensions, etc.) separately with a cash account as the central
|
|
6
|
+
* reservoir. Implements the 9-step cash waterfall per spec section 14.
|
|
7
|
+
*
|
|
8
|
+
* Resolution order per year:
|
|
9
|
+
* 1. Income phase (salary, pension, SS, rental, cash yield)
|
|
10
|
+
* 2. Mandatory debits (mortgages, loan interest/principal)
|
|
11
|
+
* 3. Investment sales (single/staggered profit-taking)
|
|
12
|
+
* 4. Liquidity events
|
|
13
|
+
* 5. Contributions (pre-retirement)
|
|
14
|
+
* 6. Withdrawal demand (post-retirement)
|
|
15
|
+
* 7. Tax settlement
|
|
16
|
+
* 8. Insolvency check
|
|
17
|
+
* 9. Investment growth (per-item rates, fees, performance fees)
|
|
18
|
+
*/
|
|
19
|
+
import { CadenceMultiplier } from './defaults';
|
|
20
|
+
import { calculateTax } from './tax';
|
|
21
|
+
import { calculateWithdrawal, } from './withdrawal';
|
|
22
|
+
// =============================================================================
|
|
23
|
+
// Helper Functions
|
|
24
|
+
// =============================================================================
|
|
25
|
+
/**
|
|
26
|
+
* Standard amortization payment formula.
|
|
27
|
+
* GUARD: if rate === 0, return principal / term to avoid division by zero.
|
|
28
|
+
* GUARD: if term <= 0, return 0 (perpetual loan — interest-only).
|
|
29
|
+
*/
|
|
30
|
+
function amortizationPayment(principal, annualRate, term) {
|
|
31
|
+
if (term <= 0)
|
|
32
|
+
return 0;
|
|
33
|
+
if (principal <= 0)
|
|
34
|
+
return 0;
|
|
35
|
+
if (annualRate === 0)
|
|
36
|
+
return principal / term;
|
|
37
|
+
const r = annualRate;
|
|
38
|
+
return principal * (r / (1 - Math.pow(1 + r, -term)));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resolve a staggered step value for the given age, falling back to a default.
|
|
42
|
+
* Uses Array.find — first matching step wins (overlap behavior per ADR-012).
|
|
43
|
+
*/
|
|
44
|
+
function resolveStaggeredStep(steps, age) {
|
|
45
|
+
return steps.find((s) => age >= s.start_age && age <= s.end_age);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Resolve salary income for a given age, handling flat vs staggered income_steps
|
|
49
|
+
* and applying raises.
|
|
50
|
+
*/
|
|
51
|
+
function resolveSalaryIncome(item, age, currentAge) {
|
|
52
|
+
var _a;
|
|
53
|
+
if (!item.enabled)
|
|
54
|
+
return 0;
|
|
55
|
+
if (age < item.income_start_age || age > item.income_end_age)
|
|
56
|
+
return 0;
|
|
57
|
+
// Base income: flat or staggered
|
|
58
|
+
let baseIncome;
|
|
59
|
+
if (item.income_mode === 'staggered' && item.income_steps.length > 0) {
|
|
60
|
+
const step = resolveStaggeredStep(item.income_steps, age);
|
|
61
|
+
if (!step)
|
|
62
|
+
return 0;
|
|
63
|
+
const freq = (_a = step.frequency) !== null && _a !== void 0 ? _a : item.income_frequency;
|
|
64
|
+
baseIncome = step.amount * (freq === 'Monthly' ? 12 : 1);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
baseIncome = item.income_amount * (item.income_frequency === 'Monthly' ? 12 : 1);
|
|
68
|
+
}
|
|
69
|
+
// Apply raises
|
|
70
|
+
const yearsWorked = age - Math.max(item.income_start_age, currentAge);
|
|
71
|
+
if (yearsWorked > 0) {
|
|
72
|
+
if (item.salary_raise_mode === 'staggered' && item.salary_raise_steps.length > 0) {
|
|
73
|
+
// For staggered raises, compound year by year from start
|
|
74
|
+
let salary = item.income_amount * (item.income_frequency === 'Monthly' ? 12 : 1);
|
|
75
|
+
for (let a = Math.max(item.income_start_age, currentAge) + 1; a <= age; a++) {
|
|
76
|
+
const raiseStep = resolveStaggeredStep(item.salary_raise_steps, a);
|
|
77
|
+
const raisePct = raiseStep ? raiseStep.raise_pct : 0;
|
|
78
|
+
salary *= 1 + raisePct / 100;
|
|
79
|
+
}
|
|
80
|
+
baseIncome = salary;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
// Flat raise: compound
|
|
84
|
+
baseIncome *= Math.pow(1 + item.salary_raise_pct / 100, yearsWorked);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Add bonus
|
|
88
|
+
baseIncome *= 1 + item.salary_bonus_pct / 100;
|
|
89
|
+
return baseIncome;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Resolve cash yield rate for the given age (flat or staggered).
|
|
93
|
+
*/
|
|
94
|
+
function resolveCashYield(item, age) {
|
|
95
|
+
if (item.cash_yield_mode === 'staggered' && item.cash_yield_steps.length > 0) {
|
|
96
|
+
const step = resolveStaggeredStep(item.cash_yield_steps, age);
|
|
97
|
+
return step ? step.rate_pct : 0;
|
|
98
|
+
}
|
|
99
|
+
return item.rate_pct;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Resolve contribution amount for an investment item at the given age.
|
|
103
|
+
* Returns annual contribution amount.
|
|
104
|
+
*/
|
|
105
|
+
function resolveContributions(item, age, currentAge) {
|
|
106
|
+
var _a, _b;
|
|
107
|
+
// Check age bounds
|
|
108
|
+
const startAge = (_a = item.contrib_start_age) !== null && _a !== void 0 ? _a : currentAge;
|
|
109
|
+
const endAge = (_b = item.contrib_end_age) !== null && _b !== void 0 ? _b : 120;
|
|
110
|
+
if (age < startAge || age > endAge)
|
|
111
|
+
return 0;
|
|
112
|
+
// Check invest_start_age — no contributions before the investment enters the portfolio
|
|
113
|
+
if (item.invest_start_age != null && age < item.invest_start_age)
|
|
114
|
+
return 0;
|
|
115
|
+
if (item.contrib_mode === 'staggered' && item.contrib_steps.length > 0) {
|
|
116
|
+
const step = resolveStaggeredStep(item.contrib_steps, age);
|
|
117
|
+
if (!step)
|
|
118
|
+
return 0;
|
|
119
|
+
// Use the parent's cadence to annualize
|
|
120
|
+
return step.amount * CadenceMultiplier[item.contrib_cadence];
|
|
121
|
+
}
|
|
122
|
+
// Flat mode with annual increase
|
|
123
|
+
const yearsFromStart = age - startAge;
|
|
124
|
+
const baseAnnual = item.contrib_amount * CadenceMultiplier[item.contrib_cadence];
|
|
125
|
+
return baseAnnual * Math.pow(1 + item.contrib_increase_pct / 100, yearsFromStart);
|
|
126
|
+
}
|
|
127
|
+
// =============================================================================
|
|
128
|
+
// Income Category Helpers
|
|
129
|
+
// =============================================================================
|
|
130
|
+
const INCOME_CATEGORIES = new Set([
|
|
131
|
+
'Pension',
|
|
132
|
+
'Social Security',
|
|
133
|
+
'Annuity',
|
|
134
|
+
'Retirement Income',
|
|
135
|
+
'Other',
|
|
136
|
+
]);
|
|
137
|
+
// =============================================================================
|
|
138
|
+
// Main Projection
|
|
139
|
+
// =============================================================================
|
|
140
|
+
export function runAdvancedProjection(scenario, overrideReturns) {
|
|
141
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0, _1;
|
|
142
|
+
const { current_age, retirement_age, end_age, inflation_pct, inflation_enabled, financial_items, liquidity_events, enable_taxes, effective_tax_rate_pct, tax_jurisdiction, tax_config, black_swan_enabled, black_swan_age, black_swan_loss_pct, desired_estate, } = scenario;
|
|
143
|
+
const items = financial_items.filter((item) => item.enabled);
|
|
144
|
+
// -------------------------------------------------------------------------
|
|
145
|
+
// Initialize state
|
|
146
|
+
// -------------------------------------------------------------------------
|
|
147
|
+
const cashItem = items.find((i) => i.category === 'Cash');
|
|
148
|
+
let cashBalance = (_a = cashItem === null || cashItem === void 0 ? void 0 : cashItem.current_value) !== null && _a !== void 0 ? _a : 0;
|
|
149
|
+
// Investment balances indexed by position in original financial_items array
|
|
150
|
+
const investmentBalances = new Map();
|
|
151
|
+
const highWaterMarks = new Map();
|
|
152
|
+
const costBasis = new Map();
|
|
153
|
+
const loanBalances = new Map();
|
|
154
|
+
// Property/Collectables value tracking
|
|
155
|
+
const propertyValues = new Map();
|
|
156
|
+
for (let idx = 0; idx < financial_items.length; idx++) {
|
|
157
|
+
const item = financial_items[idx];
|
|
158
|
+
if (!item.enabled)
|
|
159
|
+
continue;
|
|
160
|
+
if (item.category === 'Investment') {
|
|
161
|
+
investmentBalances.set(idx, item.current_value);
|
|
162
|
+
highWaterMarks.set(idx, item.current_value);
|
|
163
|
+
costBasis.set(idx, item.current_value);
|
|
164
|
+
}
|
|
165
|
+
else if (item.category === 'Property' || item.category === 'Collectables') {
|
|
166
|
+
propertyValues.set(idx, item.current_value);
|
|
167
|
+
costBasis.set(idx, item.current_value);
|
|
168
|
+
}
|
|
169
|
+
else if (item.category === 'Loan') {
|
|
170
|
+
loanBalances.set(idx, item.loan_opening_principal);
|
|
171
|
+
// Credit opening principal to cash if configured
|
|
172
|
+
if (item.loan_credit_at_start && current_age === item.loan_start_age) {
|
|
173
|
+
cashBalance += item.loan_opening_principal;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// -------------------------------------------------------------------------
|
|
178
|
+
// Accumulators
|
|
179
|
+
// -------------------------------------------------------------------------
|
|
180
|
+
const timeline = [];
|
|
181
|
+
let cpiIndex = 1.0;
|
|
182
|
+
let gkState = null;
|
|
183
|
+
let firstShortfallAge = null;
|
|
184
|
+
let totalContributions = 0;
|
|
185
|
+
let totalWithdrawals = 0;
|
|
186
|
+
let totalFees = 0;
|
|
187
|
+
let totalTaxes = 0;
|
|
188
|
+
// -------------------------------------------------------------------------
|
|
189
|
+
// Year-by-year loop
|
|
190
|
+
// -------------------------------------------------------------------------
|
|
191
|
+
for (let age = current_age; age <= end_age; age++) {
|
|
192
|
+
const yearIndex = age - current_age;
|
|
193
|
+
// Snapshot start-of-year balances
|
|
194
|
+
const startCash = cashBalance;
|
|
195
|
+
const startInvestments = sumMap(investmentBalances);
|
|
196
|
+
const startProperties = sumMap(propertyValues);
|
|
197
|
+
const startLoans = sumMap(loanBalances);
|
|
198
|
+
const startBalance = startCash + startInvestments + startProperties - startLoans;
|
|
199
|
+
// Per-year accumulators
|
|
200
|
+
let yearIncome = 0;
|
|
201
|
+
let yearIncomeTaxes = 0;
|
|
202
|
+
let yearContributions = 0;
|
|
203
|
+
let yearWithdrawals = 0;
|
|
204
|
+
let yearDesiredSpending = 0;
|
|
205
|
+
let yearFees = 0;
|
|
206
|
+
let yearTaxes = 0;
|
|
207
|
+
let yearGrowth = 0;
|
|
208
|
+
let yearLiquidityNet = 0;
|
|
209
|
+
let yearLoanInterest = 0;
|
|
210
|
+
let yearLoanPrincipalRepaid = 0;
|
|
211
|
+
let yearMortgagePaid = 0;
|
|
212
|
+
let yearCashYield = 0;
|
|
213
|
+
let shortfallMandatory = 0;
|
|
214
|
+
let shortfallContributions = 0;
|
|
215
|
+
let shortfallWithdrawals = 0;
|
|
216
|
+
let yearTaxableIncome = 0;
|
|
217
|
+
// =====================================================================
|
|
218
|
+
// 1. INCOME PHASE
|
|
219
|
+
// =====================================================================
|
|
220
|
+
// --- Salary items ---
|
|
221
|
+
for (let idx = 0; idx < financial_items.length; idx++) {
|
|
222
|
+
const item = financial_items[idx];
|
|
223
|
+
if (!item.enabled || item.category !== 'Salary')
|
|
224
|
+
continue;
|
|
225
|
+
const gross = resolveSalaryIncome(item, age, current_age);
|
|
226
|
+
if (gross <= 0)
|
|
227
|
+
continue;
|
|
228
|
+
const tax = item.taxable ? gross * (item.tax_rate / 100) : 0;
|
|
229
|
+
const net = gross - tax;
|
|
230
|
+
yearIncome += gross;
|
|
231
|
+
yearIncomeTaxes += tax;
|
|
232
|
+
if (item.taxable)
|
|
233
|
+
yearTaxableIncome += gross;
|
|
234
|
+
// Route income
|
|
235
|
+
if (item.income_destination !== 'cash' &&
|
|
236
|
+
item.reinvest_target_item_index != null &&
|
|
237
|
+
investmentBalances.has(item.reinvest_target_item_index)) {
|
|
238
|
+
const targetIdx = item.reinvest_target_item_index;
|
|
239
|
+
investmentBalances.set(targetIdx, ((_b = investmentBalances.get(targetIdx)) !== null && _b !== void 0 ? _b : 0) + net);
|
|
240
|
+
costBasis.set(targetIdx, ((_c = costBasis.get(targetIdx)) !== null && _c !== void 0 ? _c : 0) + net);
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
cashBalance += net;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// --- Pension/SS/Annuity/RetirementIncome/Other items ---
|
|
247
|
+
for (let idx = 0; idx < financial_items.length; idx++) {
|
|
248
|
+
const item = financial_items[idx];
|
|
249
|
+
if (!item.enabled || !INCOME_CATEGORIES.has(item.category))
|
|
250
|
+
continue;
|
|
251
|
+
if (age < item.income_start_age || age > item.income_end_age)
|
|
252
|
+
continue;
|
|
253
|
+
let income;
|
|
254
|
+
if (item.income_mode === 'staggered' && item.income_steps.length > 0) {
|
|
255
|
+
const step = resolveStaggeredStep(item.income_steps, age);
|
|
256
|
+
if (!step)
|
|
257
|
+
continue;
|
|
258
|
+
const freq = (_d = step.frequency) !== null && _d !== void 0 ? _d : item.income_frequency;
|
|
259
|
+
income = step.amount * (freq === 'Monthly' ? 12 : 1);
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
income = item.income_amount * (item.income_frequency === 'Monthly' ? 12 : 1);
|
|
263
|
+
}
|
|
264
|
+
if (item.inflation_adjusted)
|
|
265
|
+
income *= cpiIndex;
|
|
266
|
+
const tax = item.taxable ? income * (item.tax_rate / 100) : 0;
|
|
267
|
+
const net = income - tax;
|
|
268
|
+
yearIncome += income;
|
|
269
|
+
yearIncomeTaxes += tax;
|
|
270
|
+
if (item.taxable)
|
|
271
|
+
yearTaxableIncome += income;
|
|
272
|
+
cashBalance += net;
|
|
273
|
+
}
|
|
274
|
+
// --- Property rental income ---
|
|
275
|
+
for (let idx = 0; idx < financial_items.length; idx++) {
|
|
276
|
+
const item = financial_items[idx];
|
|
277
|
+
if (!item.enabled || item.category !== 'Property')
|
|
278
|
+
continue;
|
|
279
|
+
if (item.rental_amount <= 0)
|
|
280
|
+
continue;
|
|
281
|
+
if (age < item.rental_start_age || age > item.rental_end_age)
|
|
282
|
+
continue;
|
|
283
|
+
// Skip rental if property doesn't exist yet
|
|
284
|
+
if (item.purchase_age != null && age < item.purchase_age)
|
|
285
|
+
continue;
|
|
286
|
+
// Skip rental if property already sold
|
|
287
|
+
if (item.profit_taking_mode === 'single' &&
|
|
288
|
+
item.sell_at_age != null &&
|
|
289
|
+
age > item.sell_at_age)
|
|
290
|
+
continue;
|
|
291
|
+
let rental = item.rental_amount * (item.rental_frequency === 'Monthly' ? 12 : 1);
|
|
292
|
+
if (item.rental_inflation_adjusted)
|
|
293
|
+
rental *= cpiIndex;
|
|
294
|
+
const tax = item.rental_taxable ? rental * (item.rental_tax_rate / 100) : 0;
|
|
295
|
+
const net = rental - tax;
|
|
296
|
+
yearIncome += rental;
|
|
297
|
+
yearIncomeTaxes += tax;
|
|
298
|
+
if (item.rental_taxable)
|
|
299
|
+
yearTaxableIncome += rental;
|
|
300
|
+
cashBalance += net;
|
|
301
|
+
}
|
|
302
|
+
// --- Cash yield ---
|
|
303
|
+
if (cashItem && cashBalance > 0) {
|
|
304
|
+
const cashRate = resolveCashYield(cashItem, age);
|
|
305
|
+
const yieldAmount = cashBalance * (cashRate / 100);
|
|
306
|
+
cashBalance += yieldAmount;
|
|
307
|
+
yearCashYield += yieldAmount;
|
|
308
|
+
yearIncome += yieldAmount;
|
|
309
|
+
}
|
|
310
|
+
// =====================================================================
|
|
311
|
+
// 2. MANDATORY DEBITS
|
|
312
|
+
// =====================================================================
|
|
313
|
+
// --- Property mortgages ---
|
|
314
|
+
for (let idx = 0; idx < financial_items.length; idx++) {
|
|
315
|
+
const item = financial_items[idx];
|
|
316
|
+
if (!item.enabled || item.category !== 'Property')
|
|
317
|
+
continue;
|
|
318
|
+
if (item.mortgage_payment <= 0)
|
|
319
|
+
continue;
|
|
320
|
+
if (age > item.mortgage_end_age)
|
|
321
|
+
continue;
|
|
322
|
+
const payment = item.mortgage_payment * (item.mortgage_frequency === 'Monthly' ? 12 : 1);
|
|
323
|
+
cashBalance -= payment;
|
|
324
|
+
yearMortgagePaid += payment;
|
|
325
|
+
}
|
|
326
|
+
// --- Loan payments ---
|
|
327
|
+
for (let idx = 0; idx < financial_items.length; idx++) {
|
|
328
|
+
const item = financial_items[idx];
|
|
329
|
+
if (!item.enabled || item.category !== 'Loan')
|
|
330
|
+
continue;
|
|
331
|
+
const balance = (_e = loanBalances.get(idx)) !== null && _e !== void 0 ? _e : 0;
|
|
332
|
+
if (balance <= 0 && !item.loan_draws.some((d) => d.age === age))
|
|
333
|
+
continue;
|
|
334
|
+
// Interest
|
|
335
|
+
const interest = balance * (item.loan_interest_rate_pct / 100);
|
|
336
|
+
cashBalance -= interest;
|
|
337
|
+
yearLoanInterest += interest;
|
|
338
|
+
// Principal
|
|
339
|
+
let principalPayment = 0;
|
|
340
|
+
if (item.loan_payment_mode === 'principal_and_interest' &&
|
|
341
|
+
item.loan_term_years > 0) {
|
|
342
|
+
const elapsed = age - item.loan_start_age;
|
|
343
|
+
const remainingTerm = Math.max(1, item.loan_term_years - elapsed);
|
|
344
|
+
const annualPayment = amortizationPayment(balance, item.loan_interest_rate_pct / 100, remainingTerm);
|
|
345
|
+
principalPayment = Math.max(0, annualPayment - interest);
|
|
346
|
+
}
|
|
347
|
+
else if (item.loan_payment_mode !== 'principal_and_interest') {
|
|
348
|
+
// Interest-only mode: optional fixed principal payment
|
|
349
|
+
principalPayment = item.loan_annual_principal_payment;
|
|
350
|
+
}
|
|
351
|
+
// Cap principal payment at remaining balance
|
|
352
|
+
principalPayment = Math.min(principalPayment, Math.max(0, balance));
|
|
353
|
+
cashBalance -= principalPayment;
|
|
354
|
+
loanBalances.set(idx, balance - principalPayment);
|
|
355
|
+
yearLoanPrincipalRepaid += principalPayment;
|
|
356
|
+
// Loan draws at this age
|
|
357
|
+
for (const draw of item.loan_draws) {
|
|
358
|
+
if (draw.age === age && age >= item.loan_start_age) {
|
|
359
|
+
loanBalances.set(idx, ((_f = loanBalances.get(idx)) !== null && _f !== void 0 ? _f : 0) + draw.amount);
|
|
360
|
+
if (item.loan_credit_at_start) {
|
|
361
|
+
cashBalance += draw.amount;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
// Lump repayments at this age
|
|
366
|
+
for (const repay of item.loan_lump_repayments) {
|
|
367
|
+
if (repay.age === age) {
|
|
368
|
+
const currentBal = (_g = loanBalances.get(idx)) !== null && _g !== void 0 ? _g : 0;
|
|
369
|
+
const actual = Math.min(repay.amount, Math.max(0, currentBal));
|
|
370
|
+
cashBalance -= actual;
|
|
371
|
+
loanBalances.set(idx, currentBal - actual);
|
|
372
|
+
yearLoanPrincipalRepaid += actual;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// Track mandatory shortfall if cash went negative from mandatory debits
|
|
377
|
+
if (cashBalance < 0) {
|
|
378
|
+
shortfallMandatory = Math.abs(Math.min(0, cashBalance - startCash + yearIncome - yearIncomeTaxes + yearCashYield));
|
|
379
|
+
}
|
|
380
|
+
// =====================================================================
|
|
381
|
+
// 3. INVESTMENT SALES
|
|
382
|
+
// =====================================================================
|
|
383
|
+
for (let idx = 0; idx < financial_items.length; idx++) {
|
|
384
|
+
const item = financial_items[idx];
|
|
385
|
+
if (!item.enabled)
|
|
386
|
+
continue;
|
|
387
|
+
if (item.category !== 'Investment' &&
|
|
388
|
+
item.category !== 'Property' &&
|
|
389
|
+
item.category !== 'Collectables')
|
|
390
|
+
continue;
|
|
391
|
+
// Guard: skip sale if asset doesn't exist yet
|
|
392
|
+
if (item.purchase_age != null && age < item.purchase_age)
|
|
393
|
+
continue;
|
|
394
|
+
const isInvestment = item.category === 'Investment';
|
|
395
|
+
const currentValue = isInvestment
|
|
396
|
+
? (_h = investmentBalances.get(idx)) !== null && _h !== void 0 ? _h : 0
|
|
397
|
+
: (_j = propertyValues.get(idx)) !== null && _j !== void 0 ? _j : 0;
|
|
398
|
+
const currentBasis = (_k = costBasis.get(idx)) !== null && _k !== void 0 ? _k : 0;
|
|
399
|
+
if (item.profit_taking_mode === 'single' && item.sell_at_age === age) {
|
|
400
|
+
const proceeds = (_l = item.sale_amount_override) !== null && _l !== void 0 ? _l : currentValue;
|
|
401
|
+
const gain = proceeds - currentBasis;
|
|
402
|
+
const tax = item.taxable_on_sale
|
|
403
|
+
? Math.max(0, gain) * (item.sale_tax_rate / 100)
|
|
404
|
+
: 0;
|
|
405
|
+
cashBalance += proceeds - tax;
|
|
406
|
+
yearTaxes += tax;
|
|
407
|
+
if (item.taxable_on_sale && gain > 0)
|
|
408
|
+
yearTaxableIncome += gain;
|
|
409
|
+
if (isInvestment) {
|
|
410
|
+
investmentBalances.set(idx, 0);
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
propertyValues.set(idx, 0);
|
|
414
|
+
}
|
|
415
|
+
costBasis.set(idx, 0);
|
|
416
|
+
}
|
|
417
|
+
if (item.profit_taking_mode === 'staggered') {
|
|
418
|
+
for (const step of item.profit_taking_steps) {
|
|
419
|
+
// Step matches if age === step.age, or within range if end_age is set
|
|
420
|
+
const matches = step.end_age != null
|
|
421
|
+
? age >= step.age && age <= step.end_age
|
|
422
|
+
: age === step.age;
|
|
423
|
+
if (!matches)
|
|
424
|
+
continue;
|
|
425
|
+
const bal = isInvestment
|
|
426
|
+
? (_m = investmentBalances.get(idx)) !== null && _m !== void 0 ? _m : 0
|
|
427
|
+
: (_o = propertyValues.get(idx)) !== null && _o !== void 0 ? _o : 0;
|
|
428
|
+
const basis = (_p = costBasis.get(idx)) !== null && _p !== void 0 ? _p : 0;
|
|
429
|
+
const sellAmount = bal * (step.pct / 100);
|
|
430
|
+
const proportionalBasis = basis * (step.pct / 100);
|
|
431
|
+
const gain = sellAmount - proportionalBasis;
|
|
432
|
+
const tax = item.taxable_on_sale
|
|
433
|
+
? Math.max(0, gain) * (item.sale_tax_rate / 100)
|
|
434
|
+
: 0;
|
|
435
|
+
cashBalance += sellAmount - tax;
|
|
436
|
+
yearTaxes += tax;
|
|
437
|
+
if (item.taxable_on_sale && gain > 0)
|
|
438
|
+
yearTaxableIncome += gain;
|
|
439
|
+
if (isInvestment) {
|
|
440
|
+
investmentBalances.set(idx, bal - sellAmount);
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
propertyValues.set(idx, bal - sellAmount);
|
|
444
|
+
}
|
|
445
|
+
costBasis.set(idx, basis - proportionalBasis);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// =====================================================================
|
|
450
|
+
// 4. LIQUIDITY EVENTS
|
|
451
|
+
// =====================================================================
|
|
452
|
+
for (const event of liquidity_events) {
|
|
453
|
+
if (!event.enabled)
|
|
454
|
+
continue;
|
|
455
|
+
if (age < event.start_age || age > event.end_age)
|
|
456
|
+
continue;
|
|
457
|
+
// One-Time fires only at start_age
|
|
458
|
+
if (event.recurrence === 'One-Time' && age !== event.start_age)
|
|
459
|
+
continue;
|
|
460
|
+
let amount = event.amount;
|
|
461
|
+
if (event.recurrence === 'Monthly')
|
|
462
|
+
amount *= 12;
|
|
463
|
+
const tax = event.taxable ? amount * (event.tax_rate / 100) : 0;
|
|
464
|
+
if (event.type === 'Credit') {
|
|
465
|
+
cashBalance += amount - tax;
|
|
466
|
+
yearLiquidityNet += amount - tax;
|
|
467
|
+
if (event.taxable)
|
|
468
|
+
yearTaxableIncome += amount;
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
cashBalance -= amount;
|
|
472
|
+
yearLiquidityNet -= amount;
|
|
473
|
+
// Tax on debit events still applied
|
|
474
|
+
yearTaxes += tax;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// =====================================================================
|
|
478
|
+
// 5. CONTRIBUTIONS (age < retirement_age)
|
|
479
|
+
// =====================================================================
|
|
480
|
+
if (age < retirement_age) {
|
|
481
|
+
for (let idx = 0; idx < financial_items.length; idx++) {
|
|
482
|
+
const item = financial_items[idx];
|
|
483
|
+
if (!item.enabled || item.category !== 'Investment')
|
|
484
|
+
continue;
|
|
485
|
+
if (item.contrib_amount <= 0 && item.contrib_steps.length === 0)
|
|
486
|
+
continue;
|
|
487
|
+
let contrib = resolveContributions(item, age, current_age);
|
|
488
|
+
if (contrib <= 0)
|
|
489
|
+
continue;
|
|
490
|
+
// Cap at available cash
|
|
491
|
+
if (contrib > Math.max(0, cashBalance)) {
|
|
492
|
+
shortfallContributions += contrib - Math.max(0, cashBalance);
|
|
493
|
+
contrib = Math.max(0, cashBalance);
|
|
494
|
+
}
|
|
495
|
+
cashBalance -= contrib;
|
|
496
|
+
investmentBalances.set(idx, ((_q = investmentBalances.get(idx)) !== null && _q !== void 0 ? _q : 0) + contrib);
|
|
497
|
+
costBasis.set(idx, ((_r = costBasis.get(idx)) !== null && _r !== void 0 ? _r : 0) + contrib);
|
|
498
|
+
yearContributions += contrib;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
// =====================================================================
|
|
502
|
+
// 6. WITHDRAWAL DEMAND (age >= retirement_age)
|
|
503
|
+
// =====================================================================
|
|
504
|
+
if (age >= retirement_age) {
|
|
505
|
+
// Total portfolio for withdrawal calculation
|
|
506
|
+
const totalPortfolio = cashBalance + sumMap(investmentBalances) + sumMap(propertyValues);
|
|
507
|
+
const priorEndBalance = yearIndex > 0 && timeline.length > 0
|
|
508
|
+
? timeline[timeline.length - 1].end_balance_nominal
|
|
509
|
+
: totalPortfolio;
|
|
510
|
+
const withdrawalResult = calculateWithdrawal(scenario, {
|
|
511
|
+
age,
|
|
512
|
+
currentBalance: totalPortfolio,
|
|
513
|
+
priorEndBalance,
|
|
514
|
+
availableBalance: totalPortfolio,
|
|
515
|
+
cpiIndex,
|
|
516
|
+
gkState,
|
|
517
|
+
});
|
|
518
|
+
if (withdrawalResult.gkState) {
|
|
519
|
+
gkState = withdrawalResult.gkState;
|
|
520
|
+
}
|
|
521
|
+
const desiredWithdrawal = withdrawalResult.withdrawal;
|
|
522
|
+
yearDesiredSpending = desiredWithdrawal;
|
|
523
|
+
// Withdraw from cash first
|
|
524
|
+
const actualFromCash = Math.min(desiredWithdrawal, Math.max(0, cashBalance));
|
|
525
|
+
cashBalance -= actualFromCash;
|
|
526
|
+
const remainingDemand = desiredWithdrawal - actualFromCash;
|
|
527
|
+
if (remainingDemand > 0) {
|
|
528
|
+
shortfallWithdrawals = remainingDemand;
|
|
529
|
+
}
|
|
530
|
+
yearWithdrawals = actualFromCash;
|
|
531
|
+
if (yearWithdrawals > 0)
|
|
532
|
+
yearTaxableIncome += yearWithdrawals;
|
|
533
|
+
}
|
|
534
|
+
// =====================================================================
|
|
535
|
+
// 7. TAX SETTLEMENT
|
|
536
|
+
// =====================================================================
|
|
537
|
+
if (enable_taxes && yearTaxableIncome > 0) {
|
|
538
|
+
let jurisdictionTax = 0;
|
|
539
|
+
if (tax_config) {
|
|
540
|
+
jurisdictionTax = calculateTax(yearTaxableIncome, tax_config, tax_jurisdiction);
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
// Custom / flat rate fallback
|
|
544
|
+
jurisdictionTax = yearTaxableIncome * (effective_tax_rate_pct / 100);
|
|
545
|
+
}
|
|
546
|
+
// Subtract already-withheld income taxes to avoid double-counting
|
|
547
|
+
const netTaxDue = Math.max(0, jurisdictionTax - yearIncomeTaxes);
|
|
548
|
+
cashBalance -= netTaxDue;
|
|
549
|
+
yearTaxes += netTaxDue;
|
|
550
|
+
}
|
|
551
|
+
// =====================================================================
|
|
552
|
+
// 8. INSOLVENCY CHECK
|
|
553
|
+
// =====================================================================
|
|
554
|
+
const insolvency = cashBalance < 0;
|
|
555
|
+
if (insolvency && firstShortfallAge === null) {
|
|
556
|
+
firstShortfallAge = age;
|
|
557
|
+
}
|
|
558
|
+
// =====================================================================
|
|
559
|
+
// 9. INVESTMENT GROWTH
|
|
560
|
+
// =====================================================================
|
|
561
|
+
// --- Black swan event ---
|
|
562
|
+
const blackSwanActive = black_swan_enabled && age === black_swan_age;
|
|
563
|
+
for (let idx = 0; idx < financial_items.length; idx++) {
|
|
564
|
+
const item = financial_items[idx];
|
|
565
|
+
if (!item.enabled || item.category !== 'Investment')
|
|
566
|
+
continue;
|
|
567
|
+
const balance = (_s = investmentBalances.get(idx)) !== null && _s !== void 0 ? _s : 0;
|
|
568
|
+
if (balance <= 0)
|
|
569
|
+
continue;
|
|
570
|
+
let returnRate = overrideReturns != null
|
|
571
|
+
? ((_t = overrideReturns[yearIndex]) !== null && _t !== void 0 ? _t : item.rate_pct / 100)
|
|
572
|
+
: item.rate_pct / 100;
|
|
573
|
+
// Apply black swan
|
|
574
|
+
if (blackSwanActive) {
|
|
575
|
+
returnRate -= black_swan_loss_pct / 100;
|
|
576
|
+
}
|
|
577
|
+
const grossReturn = balance * returnRate;
|
|
578
|
+
// Management fee
|
|
579
|
+
const mgmtFee = balance * (item.fee_pct / 100);
|
|
580
|
+
// Performance fee (high-water mark)
|
|
581
|
+
let perfFee = 0;
|
|
582
|
+
const hwm = (_u = highWaterMarks.get(idx)) !== null && _u !== void 0 ? _u : 0;
|
|
583
|
+
const postGrowthValue = balance + grossReturn - mgmtFee;
|
|
584
|
+
if (postGrowthValue > hwm && item.perf_fee_pct > 0) {
|
|
585
|
+
const gainAboveHWM = postGrowthValue - hwm;
|
|
586
|
+
perfFee = gainAboveHWM * (item.perf_fee_pct / 100);
|
|
587
|
+
highWaterMarks.set(idx, postGrowthValue - perfFee);
|
|
588
|
+
}
|
|
589
|
+
const newBalance = balance + grossReturn - mgmtFee - perfFee;
|
|
590
|
+
investmentBalances.set(idx, Math.max(0, newBalance));
|
|
591
|
+
yearGrowth += grossReturn;
|
|
592
|
+
yearFees += mgmtFee + perfFee;
|
|
593
|
+
}
|
|
594
|
+
// --- Property/Collectables appreciation ---
|
|
595
|
+
for (let idx = 0; idx < financial_items.length; idx++) {
|
|
596
|
+
const item = financial_items[idx];
|
|
597
|
+
if (!item.enabled)
|
|
598
|
+
continue;
|
|
599
|
+
if (item.category !== 'Property' && item.category !== 'Collectables')
|
|
600
|
+
continue;
|
|
601
|
+
const value = (_v = propertyValues.get(idx)) !== null && _v !== void 0 ? _v : 0;
|
|
602
|
+
if (value <= 0)
|
|
603
|
+
continue;
|
|
604
|
+
// Skip growth if not yet purchased
|
|
605
|
+
if (item.purchase_age != null && age < item.purchase_age)
|
|
606
|
+
continue;
|
|
607
|
+
let growthRate = item.rate_pct / 100;
|
|
608
|
+
if (blackSwanActive) {
|
|
609
|
+
growthRate -= black_swan_loss_pct / 100;
|
|
610
|
+
}
|
|
611
|
+
const appreciation = value * growthRate;
|
|
612
|
+
propertyValues.set(idx, value + appreciation);
|
|
613
|
+
yearGrowth += appreciation;
|
|
614
|
+
}
|
|
615
|
+
// =====================================================================
|
|
616
|
+
// Build TimelineRow
|
|
617
|
+
// =====================================================================
|
|
618
|
+
// Update CPI for this year
|
|
619
|
+
if (inflation_enabled) {
|
|
620
|
+
cpiIndex *= 1 + inflation_pct / 100;
|
|
621
|
+
}
|
|
622
|
+
const endCash = cashBalance;
|
|
623
|
+
const endInvestments = sumMap(investmentBalances);
|
|
624
|
+
const endProperties = sumMap(propertyValues);
|
|
625
|
+
const endDebt = sumMap(loanBalances);
|
|
626
|
+
const endBalanceNominal = endCash + endInvestments + endProperties - endDebt;
|
|
627
|
+
const endBalanceReal = cpiIndex > 0 ? endBalanceNominal / cpiIndex : endBalanceNominal;
|
|
628
|
+
// Liquid vs illiquid classification
|
|
629
|
+
let endLiquid = endCash;
|
|
630
|
+
let endIlliquid = 0;
|
|
631
|
+
for (let idx = 0; idx < financial_items.length; idx++) {
|
|
632
|
+
const item = financial_items[idx];
|
|
633
|
+
if (!item.enabled)
|
|
634
|
+
continue;
|
|
635
|
+
if (item.category === 'Investment') {
|
|
636
|
+
const bal = (_w = investmentBalances.get(idx)) !== null && _w !== void 0 ? _w : 0;
|
|
637
|
+
if (item.is_liquid)
|
|
638
|
+
endLiquid += bal;
|
|
639
|
+
else
|
|
640
|
+
endIlliquid += bal;
|
|
641
|
+
}
|
|
642
|
+
else if (item.category === 'Property' || item.category === 'Collectables') {
|
|
643
|
+
const val = (_x = propertyValues.get(idx)) !== null && _x !== void 0 ? _x : 0;
|
|
644
|
+
if (item.is_liquid)
|
|
645
|
+
endLiquid += val;
|
|
646
|
+
else
|
|
647
|
+
endIlliquid += val;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
totalContributions += yearContributions;
|
|
651
|
+
totalWithdrawals += yearWithdrawals;
|
|
652
|
+
totalFees += yearFees;
|
|
653
|
+
totalTaxes += yearTaxes + yearIncomeTaxes;
|
|
654
|
+
const row = {
|
|
655
|
+
age,
|
|
656
|
+
start_balance_nominal: startBalance,
|
|
657
|
+
contributions: yearContributions,
|
|
658
|
+
liquidity_net: yearLiquidityNet,
|
|
659
|
+
income: yearIncome,
|
|
660
|
+
withdrawals: yearWithdrawals,
|
|
661
|
+
desired_spending: yearDesiredSpending,
|
|
662
|
+
fees: yearFees,
|
|
663
|
+
taxes: yearTaxes,
|
|
664
|
+
income_taxes: yearIncomeTaxes,
|
|
665
|
+
growth: yearGrowth,
|
|
666
|
+
end_balance_nominal: endBalanceNominal,
|
|
667
|
+
cpi_index: cpiIndex,
|
|
668
|
+
end_balance_real: endBalanceReal,
|
|
669
|
+
end_cash_nominal: endCash,
|
|
670
|
+
end_debt_nominal: endDebt,
|
|
671
|
+
end_investments_nominal: endInvestments + endProperties,
|
|
672
|
+
end_liquid_nominal: endLiquid,
|
|
673
|
+
end_illiquid_nominal: endIlliquid,
|
|
674
|
+
loan_interest: yearLoanInterest,
|
|
675
|
+
loan_principal_repaid: yearLoanPrincipalRepaid,
|
|
676
|
+
mortgage_paid: yearMortgagePaid,
|
|
677
|
+
cash_yield: yearCashYield,
|
|
678
|
+
insolvency,
|
|
679
|
+
shortfall_mandatory: shortfallMandatory,
|
|
680
|
+
shortfall_contributions: shortfallContributions,
|
|
681
|
+
shortfall_withdrawals: shortfallWithdrawals,
|
|
682
|
+
};
|
|
683
|
+
timeline.push(row);
|
|
684
|
+
}
|
|
685
|
+
// -------------------------------------------------------------------------
|
|
686
|
+
// Compute Metrics
|
|
687
|
+
// -------------------------------------------------------------------------
|
|
688
|
+
const lastRow = timeline[timeline.length - 1];
|
|
689
|
+
const terminalNominal = (_y = lastRow === null || lastRow === void 0 ? void 0 : lastRow.end_balance_nominal) !== null && _y !== void 0 ? _y : 0;
|
|
690
|
+
const terminalReal = (_z = lastRow === null || lastRow === void 0 ? void 0 : lastRow.end_balance_real) !== null && _z !== void 0 ? _z : 0;
|
|
691
|
+
// Estate value: sum of projected values * estate_pct, minus debt
|
|
692
|
+
let estateValue = 0;
|
|
693
|
+
for (let idx = 0; idx < financial_items.length; idx++) {
|
|
694
|
+
const item = financial_items[idx];
|
|
695
|
+
if (!item.enabled || item.estate_pct <= 0)
|
|
696
|
+
continue;
|
|
697
|
+
let projectedValue = 0;
|
|
698
|
+
if (item.category === 'Investment') {
|
|
699
|
+
projectedValue = (_0 = investmentBalances.get(idx)) !== null && _0 !== void 0 ? _0 : 0;
|
|
700
|
+
}
|
|
701
|
+
else if (item.category === 'Property' || item.category === 'Collectables') {
|
|
702
|
+
projectedValue = (_1 = propertyValues.get(idx)) !== null && _1 !== void 0 ? _1 : 0;
|
|
703
|
+
}
|
|
704
|
+
else if (item.category === 'Cash') {
|
|
705
|
+
projectedValue = cashBalance;
|
|
706
|
+
}
|
|
707
|
+
estateValue += projectedValue * (item.estate_pct / 100);
|
|
708
|
+
}
|
|
709
|
+
estateValue -= sumMap(loanBalances);
|
|
710
|
+
estateValue = Math.max(0, estateValue);
|
|
711
|
+
// Readiness score: only meaningful for fixed-dollar withdrawal
|
|
712
|
+
let readinessScore = 100;
|
|
713
|
+
if (scenario.withdrawal_method === 'Fixed real-dollar amount') {
|
|
714
|
+
const totalDesired = timeline
|
|
715
|
+
.filter((r) => r.age >= retirement_age)
|
|
716
|
+
.reduce((sum, r) => sum + r.desired_spending, 0);
|
|
717
|
+
const totalActual = timeline
|
|
718
|
+
.filter((r) => r.age >= retirement_age)
|
|
719
|
+
.reduce((sum, r) => sum + r.withdrawals, 0);
|
|
720
|
+
readinessScore =
|
|
721
|
+
totalDesired > 0
|
|
722
|
+
? Math.min(200, (totalActual / totalDesired) * 100)
|
|
723
|
+
: 100;
|
|
724
|
+
}
|
|
725
|
+
const metrics = {
|
|
726
|
+
terminal_nominal: terminalNominal,
|
|
727
|
+
terminal_real: terminalReal,
|
|
728
|
+
first_shortfall_age: firstShortfallAge,
|
|
729
|
+
readiness_score: readinessScore,
|
|
730
|
+
total_contributions: totalContributions,
|
|
731
|
+
total_withdrawals: totalWithdrawals,
|
|
732
|
+
total_fees: totalFees,
|
|
733
|
+
total_taxes: totalTaxes,
|
|
734
|
+
estate_value: estateValue,
|
|
735
|
+
};
|
|
736
|
+
return { timeline, metrics };
|
|
737
|
+
}
|
|
738
|
+
// =============================================================================
|
|
739
|
+
// Utility
|
|
740
|
+
// =============================================================================
|
|
741
|
+
function sumMap(map) {
|
|
742
|
+
let total = 0;
|
|
743
|
+
for (const v of map.values()) {
|
|
744
|
+
total += v;
|
|
745
|
+
}
|
|
746
|
+
return total;
|
|
747
|
+
}
|