@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
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Withdrawal Strategy Implementations
|
|
3
|
+
*
|
|
4
|
+
* Three strategies: Standard, Guyton-Klinger, Age-Banded.
|
|
5
|
+
* Dispatched via calculateWithdrawal() based on scenario.withdrawal_strategy.
|
|
6
|
+
*
|
|
7
|
+
* Edge cases handled:
|
|
8
|
+
* - Near-zero threshold ($100): prevents asymptotic depletion with high withdrawal rates
|
|
9
|
+
* - Age-Banded gaps: return 0, log warning
|
|
10
|
+
* - Age-Banded overlaps: first-match wins (Array.find behavior)
|
|
11
|
+
* - GK oscillation: bounded by floor/ceiling — no special handling needed
|
|
12
|
+
* - RMD override: caller responsibility (if RMD > withdrawal, caller uses RMD)
|
|
13
|
+
*/
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Constants
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
/** Balance below this threshold is treated as depleted (prevents asymptotic depletion). */
|
|
18
|
+
export const NEAR_ZERO_THRESHOLD = 100;
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// 1. Standard Withdrawal
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
/**
|
|
23
|
+
* Calculate withdrawal using the Standard strategy.
|
|
24
|
+
*
|
|
25
|
+
* - "Fixed % of prior-year end balance": priorEndBalance * (withdrawal_pct / 100)
|
|
26
|
+
* - "Fixed real-dollar amount": withdrawal_real_amount * cpiIndex
|
|
27
|
+
*
|
|
28
|
+
* Monthly frequency is annualized (multiply by 12).
|
|
29
|
+
* Result is capped at available balance.
|
|
30
|
+
*/
|
|
31
|
+
export function calculateStandardWithdrawal(params) {
|
|
32
|
+
const { withdrawalMethod, withdrawalPct, withdrawalRealAmount, withdrawalFrequency, priorEndBalance, availableBalance, cpiIndex, } = params;
|
|
33
|
+
let withdrawal;
|
|
34
|
+
if (withdrawalMethod === 'Fixed % of prior-year end balance') {
|
|
35
|
+
withdrawal = priorEndBalance * (withdrawalPct / 100);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
// Fixed real-dollar amount, inflation-adjusted
|
|
39
|
+
withdrawal = withdrawalRealAmount * cpiIndex;
|
|
40
|
+
}
|
|
41
|
+
// Frequency conversion: if the amount is expressed as monthly, annualize it
|
|
42
|
+
if (withdrawalFrequency === 'Monthly') {
|
|
43
|
+
withdrawal *= 12;
|
|
44
|
+
}
|
|
45
|
+
// Cap at available balance
|
|
46
|
+
return Math.min(withdrawal, Math.max(0, availableBalance));
|
|
47
|
+
}
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// 2. Guyton-Klinger Withdrawal
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
/**
|
|
52
|
+
* Calculate withdrawal using the Guyton-Klinger guardrail strategy.
|
|
53
|
+
*
|
|
54
|
+
* First retirement year: compute via standard calculation to set the initial
|
|
55
|
+
* withdrawal and initial rate.
|
|
56
|
+
*
|
|
57
|
+
* Subsequent years:
|
|
58
|
+
* current_rate = priorWithdrawal / currentBalance * 100
|
|
59
|
+
*
|
|
60
|
+
* Prosperity rule (balance grew, rate dropped):
|
|
61
|
+
* if current_rate < initialRate * (1 - prosperity_threshold/100):
|
|
62
|
+
* withdrawal = priorWithdrawal * 1.10 (increase 10%)
|
|
63
|
+
*
|
|
64
|
+
* Capital preservation rule (balance dropped, rate rose):
|
|
65
|
+
* if current_rate > initialRate * (1 + preservation_threshold/100):
|
|
66
|
+
* withdrawal = priorWithdrawal * 0.90 (decrease 10%)
|
|
67
|
+
*
|
|
68
|
+
* Else: withdrawal = priorWithdrawal (no change)
|
|
69
|
+
*
|
|
70
|
+
* Hard limits:
|
|
71
|
+
* max = initialWithdrawal * (1 + ceiling_pct/100)
|
|
72
|
+
* min = initialWithdrawal * (1 - floor_pct/100)
|
|
73
|
+
* withdrawal = clamp(withdrawal, min, max)
|
|
74
|
+
*
|
|
75
|
+
* Capped at available balance.
|
|
76
|
+
*/
|
|
77
|
+
export function calculateGuytonKlingerWithdrawal(params) {
|
|
78
|
+
const { currentBalance, availableBalance, gkState, standardParams, gkCeilingPct, gkFloorPct, gkProsperityThreshold, gkCapitalPreservationThreshold, } = params;
|
|
79
|
+
// --- First retirement year: initialize GK state ---
|
|
80
|
+
if (gkState === null) {
|
|
81
|
+
const firstWithdrawal = calculateStandardWithdrawal(standardParams);
|
|
82
|
+
// Guard against zero balance (division by zero for initial rate)
|
|
83
|
+
const initialRate = currentBalance > 0 ? (firstWithdrawal / currentBalance) * 100 : 0;
|
|
84
|
+
const cappedWithdrawal = Math.min(firstWithdrawal, Math.max(0, availableBalance));
|
|
85
|
+
return {
|
|
86
|
+
withdrawal: cappedWithdrawal,
|
|
87
|
+
gkState: {
|
|
88
|
+
initialWithdrawal: firstWithdrawal,
|
|
89
|
+
initialRate,
|
|
90
|
+
priorWithdrawal: cappedWithdrawal,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// --- Subsequent years ---
|
|
95
|
+
const { initialWithdrawal, initialRate, priorWithdrawal } = gkState;
|
|
96
|
+
// Guard: if balance is zero or near-zero, no meaningful withdrawal
|
|
97
|
+
if (currentBalance <= 0) {
|
|
98
|
+
return {
|
|
99
|
+
withdrawal: 0,
|
|
100
|
+
gkState: {
|
|
101
|
+
initialWithdrawal,
|
|
102
|
+
initialRate,
|
|
103
|
+
priorWithdrawal: 0,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const currentRate = (priorWithdrawal / currentBalance) * 100;
|
|
108
|
+
let withdrawal = priorWithdrawal;
|
|
109
|
+
// Prosperity rule: balance grew enough that the rate dropped below threshold
|
|
110
|
+
const prosperityBound = initialRate * (1 - gkProsperityThreshold / 100);
|
|
111
|
+
if (currentRate < prosperityBound) {
|
|
112
|
+
withdrawal = priorWithdrawal * 1.1;
|
|
113
|
+
}
|
|
114
|
+
// Capital preservation rule: balance dropped enough that the rate exceeded threshold
|
|
115
|
+
const preservationBound = initialRate * (1 + gkCapitalPreservationThreshold / 100);
|
|
116
|
+
if (currentRate > preservationBound) {
|
|
117
|
+
withdrawal = priorWithdrawal * 0.9;
|
|
118
|
+
}
|
|
119
|
+
// Hard limits (ceiling and floor relative to initial withdrawal)
|
|
120
|
+
const maxWithdrawal = initialWithdrawal * (1 + gkCeilingPct / 100);
|
|
121
|
+
const minWithdrawal = initialWithdrawal * (1 - gkFloorPct / 100);
|
|
122
|
+
withdrawal = Math.max(minWithdrawal, Math.min(maxWithdrawal, withdrawal));
|
|
123
|
+
// Cap at available balance
|
|
124
|
+
const cappedWithdrawal = Math.min(withdrawal, Math.max(0, availableBalance));
|
|
125
|
+
return {
|
|
126
|
+
withdrawal: cappedWithdrawal,
|
|
127
|
+
gkState: {
|
|
128
|
+
initialWithdrawal,
|
|
129
|
+
initialRate,
|
|
130
|
+
priorWithdrawal: cappedWithdrawal,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// 3. Age-Banded Withdrawal
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
/**
|
|
138
|
+
* Calculate withdrawal using the Age-Banded strategy.
|
|
139
|
+
*
|
|
140
|
+
* Finds the first spending phase where start_age <= age <= end_age.
|
|
141
|
+
* - If mode = 'percent': withdrawal = currentBalance * (amount / 100)
|
|
142
|
+
* - If mode = 'amount': withdrawal = amount * cpiIndex (inflation-adjusted)
|
|
143
|
+
*
|
|
144
|
+
* If no phase covers the current age, returns 0 and logs a warning (gap).
|
|
145
|
+
* If phases overlap, the first match wins (Array.find behavior).
|
|
146
|
+
*/
|
|
147
|
+
export function calculateAgeBandedWithdrawal(params) {
|
|
148
|
+
const { age, currentBalance, availableBalance, spendingPhases, cpiIndex } = params;
|
|
149
|
+
// Find the first phase that covers this age
|
|
150
|
+
const phase = spendingPhases.find((p) => age >= p.start_age && age <= p.end_age);
|
|
151
|
+
if (!phase) {
|
|
152
|
+
// Gap in spending phases — no withdrawal for this year
|
|
153
|
+
console.warn(`[withdrawal] Age-Banded: no spending phase covers age ${age}. Withdrawal defaults to $0. ` +
|
|
154
|
+
`Check for gaps in spending phase definitions.`);
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
let withdrawal;
|
|
158
|
+
if (phase.mode === 'percent') {
|
|
159
|
+
withdrawal = currentBalance * (phase.amount / 100);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
// mode === 'amount': fixed real-dollar amount, inflation-adjusted
|
|
163
|
+
withdrawal = phase.amount * cpiIndex;
|
|
164
|
+
}
|
|
165
|
+
// Cap at available balance
|
|
166
|
+
return Math.min(withdrawal, Math.max(0, availableBalance));
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Main withdrawal dispatcher.
|
|
170
|
+
*
|
|
171
|
+
* Routes to the correct strategy based on scenario.withdrawal_strategy, then
|
|
172
|
+
* applies the near-zero depletion threshold ($100).
|
|
173
|
+
*/
|
|
174
|
+
export function calculateWithdrawal(scenario, state) {
|
|
175
|
+
const { withdrawal_strategy, withdrawal_method, withdrawal_pct, withdrawal_real_amount, withdrawal_frequency, gk_ceiling_pct, gk_floor_pct, gk_prosperity_threshold, gk_capital_preservation_threshold, spending_phases, } = scenario;
|
|
176
|
+
const { age, currentBalance, priorEndBalance, availableBalance, cpiIndex, gkState } = state;
|
|
177
|
+
// Check near-zero threshold before computing — if already depleted, no withdrawal
|
|
178
|
+
if (currentBalance < NEAR_ZERO_THRESHOLD && currentBalance >= 0) {
|
|
179
|
+
return {
|
|
180
|
+
withdrawal: 0,
|
|
181
|
+
gkState: gkState !== null && gkState !== void 0 ? gkState : undefined,
|
|
182
|
+
effectivelyDepleted: true,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const standardParams = {
|
|
186
|
+
withdrawalMethod: withdrawal_method,
|
|
187
|
+
withdrawalPct: withdrawal_pct,
|
|
188
|
+
withdrawalRealAmount: withdrawal_real_amount,
|
|
189
|
+
withdrawalFrequency: withdrawal_frequency,
|
|
190
|
+
priorEndBalance,
|
|
191
|
+
availableBalance,
|
|
192
|
+
cpiIndex,
|
|
193
|
+
};
|
|
194
|
+
let withdrawal;
|
|
195
|
+
let updatedGkState;
|
|
196
|
+
switch (withdrawal_strategy) {
|
|
197
|
+
case 'Standard': {
|
|
198
|
+
withdrawal = calculateStandardWithdrawal(standardParams);
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
case 'Guyton-Klinger': {
|
|
202
|
+
const gkResult = calculateGuytonKlingerWithdrawal({
|
|
203
|
+
currentBalance,
|
|
204
|
+
availableBalance,
|
|
205
|
+
cpiIndex,
|
|
206
|
+
gkState,
|
|
207
|
+
standardParams,
|
|
208
|
+
gkCeilingPct: gk_ceiling_pct,
|
|
209
|
+
gkFloorPct: gk_floor_pct,
|
|
210
|
+
gkProsperityThreshold: gk_prosperity_threshold,
|
|
211
|
+
gkCapitalPreservationThreshold: gk_capital_preservation_threshold,
|
|
212
|
+
});
|
|
213
|
+
withdrawal = gkResult.withdrawal;
|
|
214
|
+
updatedGkState = gkResult.gkState;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
case 'Age-Banded': {
|
|
218
|
+
withdrawal = calculateAgeBandedWithdrawal({
|
|
219
|
+
age,
|
|
220
|
+
currentBalance,
|
|
221
|
+
availableBalance,
|
|
222
|
+
spendingPhases: spending_phases,
|
|
223
|
+
cpiIndex,
|
|
224
|
+
});
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
default: {
|
|
228
|
+
// Exhaustive check — TypeScript should catch this at compile time
|
|
229
|
+
const _exhaustive = withdrawal_strategy;
|
|
230
|
+
throw new Error(`Unknown withdrawal strategy: ${_exhaustive}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// Near-zero threshold: if balance after withdrawal would be below $100, treat as depleted
|
|
234
|
+
const balanceAfterWithdrawal = availableBalance - withdrawal;
|
|
235
|
+
const effectivelyDepleted = balanceAfterWithdrawal >= 0 && balanceAfterWithdrawal < NEAR_ZERO_THRESHOLD;
|
|
236
|
+
return {
|
|
237
|
+
withdrawal,
|
|
238
|
+
gkState: updatedGkState,
|
|
239
|
+
effectivelyDepleted,
|
|
240
|
+
};
|
|
241
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@robotixai/calculator-engine",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Financial retirement projection engine with Monte Carlo simulation, multi-jurisdiction tax, and withdrawal strategies",
|
|
5
|
+
"private": false,
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"require": "./dist/index.cjs"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc",
|
|
18
|
+
"lint": "tsc --noEmit"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"typescript": "^6.0.2"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"retirement", "calculator", "monte-carlo", "financial-planning",
|
|
26
|
+
"projection", "tax", "withdrawal-strategies", "guyton-klinger"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/rob-otix-ai/calculator-engine"
|
|
32
|
+
}
|
|
33
|
+
}
|