@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,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Monte Carlo Simulation Engine
|
|
3
|
+
*
|
|
4
|
+
* Deterministic, seeded PRNG-based Monte Carlo runner for retirement projections.
|
|
5
|
+
* Generates randomized annual returns and delegates year-by-year calculation to
|
|
6
|
+
* a caller-provided projection function, keeping MC fully decoupled from the
|
|
7
|
+
* projection engine.
|
|
8
|
+
*/
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// SeededRNG — Deterministic PRNG (mulberry32)
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
export class SeededRNG {
|
|
13
|
+
constructor(seed = 42) {
|
|
14
|
+
this.state = seed;
|
|
15
|
+
}
|
|
16
|
+
/** Returns a uniform random number in [0, 1). Mulberry32 algorithm. */
|
|
17
|
+
next() {
|
|
18
|
+
this.state |= 0;
|
|
19
|
+
this.state = (this.state + 0x6d2b79f5) | 0;
|
|
20
|
+
let t = Math.imul(this.state ^ (this.state >>> 15), 1 | this.state);
|
|
21
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
22
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
23
|
+
}
|
|
24
|
+
/** Returns a standard normal random variate via Box-Muller transform. */
|
|
25
|
+
gaussian() {
|
|
26
|
+
let u1 = this.next();
|
|
27
|
+
const u2 = this.next();
|
|
28
|
+
// CRITICAL GUARD: clamp u1 to [1e-10, 1) to prevent ln(0) = -Infinity
|
|
29
|
+
u1 = Math.max(u1, 1e-10);
|
|
30
|
+
const z0 = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
|
|
31
|
+
return z0;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// generateReturn — Single random annual return
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
export function generateReturn(rng, mean, stdev, distribution) {
|
|
38
|
+
const z = rng.gaussian();
|
|
39
|
+
if (distribution === 'log-normal') {
|
|
40
|
+
// Log-normal return generation per spec:
|
|
41
|
+
// σ_ln = sqrt(ln(1 + (stdev / (1 + mean))²))
|
|
42
|
+
// μ_ln = ln(1 + mean) - σ_ln² / 2
|
|
43
|
+
// return exp(μ_ln + σ_ln * z) - 1
|
|
44
|
+
const ratio = stdev / (1 + mean);
|
|
45
|
+
const sigmaLn = Math.sqrt(Math.log(1 + ratio * ratio));
|
|
46
|
+
const muLn = Math.log(1 + mean) - (sigmaLn * sigmaLn) / 2;
|
|
47
|
+
return Math.exp(muLn + sigmaLn * z) - 1;
|
|
48
|
+
}
|
|
49
|
+
// Normal distribution
|
|
50
|
+
let result = mean + stdev * z;
|
|
51
|
+
// CRITICAL GUARD: clamp return to >= -1.0 (can't lose more than 100%)
|
|
52
|
+
if (result < -1.0) {
|
|
53
|
+
result = -1.0;
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// extractPercentile — Safe percentile extraction
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
export function extractPercentile(sortedArray, p) {
|
|
61
|
+
if (sortedArray.length === 0)
|
|
62
|
+
return 0;
|
|
63
|
+
const index = Math.floor(p * sortedArray.length);
|
|
64
|
+
// Clamp index to valid range
|
|
65
|
+
return sortedArray[Math.min(index, sortedArray.length - 1)];
|
|
66
|
+
}
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// runMonteCarloSimulation — Main MC runner
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
export function runMonteCarloSimulation(scenario, projectionFn, options = {}) {
|
|
71
|
+
var _a, _b, _c;
|
|
72
|
+
const runs = (_a = options.runs) !== null && _a !== void 0 ? _a : 1000;
|
|
73
|
+
const seed = (_b = options.seed) !== null && _b !== void 0 ? _b : 42;
|
|
74
|
+
const budgetMs = (_c = options.budgetMs) !== null && _c !== void 0 ? _c : 50000;
|
|
75
|
+
// Validation: mc_runs must be 0 (disabled) or >= 100. Reject 1-99.
|
|
76
|
+
if (runs === 0) {
|
|
77
|
+
return {
|
|
78
|
+
probability_no_shortfall: 0,
|
|
79
|
+
median_terminal: 0,
|
|
80
|
+
p10_terminal: 0,
|
|
81
|
+
p90_terminal: 0,
|
|
82
|
+
fan_chart: [],
|
|
83
|
+
terminal_distribution: [],
|
|
84
|
+
runs_completed: 0,
|
|
85
|
+
truncated: false,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (runs < 100 || runs > 10000) {
|
|
89
|
+
throw new Error(`mc_runs must be 0 (disabled) or between 100 and 10000. Got: ${runs}`);
|
|
90
|
+
}
|
|
91
|
+
const rng = new SeededRNG(seed);
|
|
92
|
+
const startTime = Date.now();
|
|
93
|
+
const numYears = scenario.end_age - scenario.current_age;
|
|
94
|
+
const mean = scenario.nominal_return_pct / 100;
|
|
95
|
+
const stdev = scenario.return_stdev_pct / 100;
|
|
96
|
+
const distribution = scenario.return_distribution;
|
|
97
|
+
// Storage for all runs
|
|
98
|
+
const terminalRealValues = [];
|
|
99
|
+
let noShortfallCount = 0;
|
|
100
|
+
let truncated = false;
|
|
101
|
+
let runsCompleted = 0;
|
|
102
|
+
// Balance paths: balancePaths[runIndex][yearIndex] = end_balance_real
|
|
103
|
+
// We store these to compute fan chart percentiles across runs per age.
|
|
104
|
+
const balancePaths = [];
|
|
105
|
+
for (let run = 0; run < runs; run++) {
|
|
106
|
+
// Budget guard: check wall clock after each batch of 100 runs
|
|
107
|
+
if (run > 0 && run % 100 === 0) {
|
|
108
|
+
const elapsed = Date.now() - startTime;
|
|
109
|
+
if (elapsed > budgetMs) {
|
|
110
|
+
truncated = true;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Generate array of random annual returns (one per year)
|
|
115
|
+
const annualReturns = [];
|
|
116
|
+
for (let y = 0; y < numYears; y++) {
|
|
117
|
+
annualReturns.push(generateReturn(rng, mean, stdev, distribution));
|
|
118
|
+
}
|
|
119
|
+
// Run projectionFn with randomized returns
|
|
120
|
+
const { timeline, metrics } = projectionFn(scenario, annualReturns);
|
|
121
|
+
// Record terminal real value
|
|
122
|
+
terminalRealValues.push(metrics.terminal_real);
|
|
123
|
+
// Record shortfall status
|
|
124
|
+
if (metrics.first_shortfall_age === null) {
|
|
125
|
+
noShortfallCount++;
|
|
126
|
+
}
|
|
127
|
+
// Store full balance path (end_balance_real per year) for fan chart
|
|
128
|
+
const path = timeline.map((row) => row.end_balance_real);
|
|
129
|
+
balancePaths.push(path);
|
|
130
|
+
runsCompleted = run + 1;
|
|
131
|
+
}
|
|
132
|
+
// -----------------------------------------------------------------------
|
|
133
|
+
// Extract percentiles from terminal values
|
|
134
|
+
// -----------------------------------------------------------------------
|
|
135
|
+
const sortedTerminals = [...terminalRealValues].sort((a, b) => a - b);
|
|
136
|
+
const p10Terminal = extractPercentile(sortedTerminals, 0.10);
|
|
137
|
+
const p50Terminal = extractPercentile(sortedTerminals, 0.50);
|
|
138
|
+
const p90Terminal = extractPercentile(sortedTerminals, 0.90);
|
|
139
|
+
// -----------------------------------------------------------------------
|
|
140
|
+
// Build fan chart: for each age, extract percentiles across all runs
|
|
141
|
+
// -----------------------------------------------------------------------
|
|
142
|
+
const fanChart = [];
|
|
143
|
+
if (balancePaths.length > 0 && balancePaths[0].length > 0) {
|
|
144
|
+
const pathLength = balancePaths[0].length;
|
|
145
|
+
for (let yearIdx = 0; yearIdx < pathLength; yearIdx++) {
|
|
146
|
+
// Collect balances at this year across all completed runs
|
|
147
|
+
const balancesAtYear = [];
|
|
148
|
+
for (let r = 0; r < runsCompleted; r++) {
|
|
149
|
+
if (yearIdx < balancePaths[r].length) {
|
|
150
|
+
balancesAtYear.push(balancePaths[r][yearIdx]);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
balancesAtYear.sort((a, b) => a - b);
|
|
154
|
+
fanChart.push({
|
|
155
|
+
age: scenario.current_age + yearIdx + 1,
|
|
156
|
+
p10: extractPercentile(balancesAtYear, 0.10),
|
|
157
|
+
p25: extractPercentile(balancesAtYear, 0.25),
|
|
158
|
+
p50: extractPercentile(balancesAtYear, 0.50),
|
|
159
|
+
p75: extractPercentile(balancesAtYear, 0.75),
|
|
160
|
+
p90: extractPercentile(balancesAtYear, 0.90),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// -----------------------------------------------------------------------
|
|
165
|
+
// Compute probability of no shortfall
|
|
166
|
+
// -----------------------------------------------------------------------
|
|
167
|
+
const probabilityNoShortfall = runsCompleted > 0 ? (noShortfallCount / runsCompleted) * 100 : 0;
|
|
168
|
+
return {
|
|
169
|
+
probability_no_shortfall: probabilityNoShortfall,
|
|
170
|
+
median_terminal: p50Terminal,
|
|
171
|
+
p10_terminal: p10Terminal,
|
|
172
|
+
p90_terminal: p90Terminal,
|
|
173
|
+
fan_chart: fanChart,
|
|
174
|
+
terminal_distribution: terminalRealValues,
|
|
175
|
+
runs_completed: runsCompleted,
|
|
176
|
+
truncated,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Scenario, Metrics } from './types';
|
|
2
|
+
export interface OptimizerResult {
|
|
3
|
+
retirementAge: number;
|
|
4
|
+
terminalReal: number;
|
|
5
|
+
survived: boolean;
|
|
6
|
+
mcSuccessPct: number | null;
|
|
7
|
+
}
|
|
8
|
+
export interface OptimizerOutput {
|
|
9
|
+
results: OptimizerResult[];
|
|
10
|
+
earliestViableAge: number | null;
|
|
11
|
+
minContribution: number | null;
|
|
12
|
+
}
|
|
13
|
+
export interface OptimizerOptions {
|
|
14
|
+
/** MC success threshold percentage (default 90). */
|
|
15
|
+
mcThreshold?: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Finds the earliest viable retirement age by testing each candidate from
|
|
19
|
+
* current_age + 1 to end_age - 1.
|
|
20
|
+
*
|
|
21
|
+
* For each candidate:
|
|
22
|
+
* - Runs a deterministic projection
|
|
23
|
+
* - Checks terminal_real >= desired_estate AND no shortfall
|
|
24
|
+
* - If mcFn provided: runs MC (capped at 300 runs externally) and checks
|
|
25
|
+
* success >= mcThreshold
|
|
26
|
+
*
|
|
27
|
+
* Also binary-searches for the minimum contribution amount at the earliest
|
|
28
|
+
* viable age (within $1 tolerance).
|
|
29
|
+
*
|
|
30
|
+
* @param scenario Base scenario
|
|
31
|
+
* @param projectionFn Deterministic projection function
|
|
32
|
+
* @param mcFn Optional Monte Carlo function (should use 300 capped runs)
|
|
33
|
+
* @param options Optional { mcThreshold } (default 90)
|
|
34
|
+
*/
|
|
35
|
+
export declare function findEarliestRetirementAge(scenario: Scenario, projectionFn: (s: Scenario) => {
|
|
36
|
+
metrics: Metrics;
|
|
37
|
+
}, mcFn?: (s: Scenario) => {
|
|
38
|
+
probability_no_shortfall: number;
|
|
39
|
+
}, options?: OptimizerOptions): OptimizerOutput;
|
|
40
|
+
//# sourceMappingURL=optimizer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"optimizer.d.ts","sourceRoot":"","sources":["../src/optimizer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAMjD,MAAM,WAAW,eAAe;IAC9B,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,OAAO,CAAC;IAClB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,eAAe,EAAE,CAAC;IAC3B,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,gBAAgB;IAC/B,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AA4GD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,QAAQ,EAClB,YAAY,EAAE,CAAC,CAAC,EAAE,QAAQ,KAAK;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,EACnD,IAAI,CAAC,EAAE,CAAC,CAAC,EAAE,QAAQ,KAAK;IAAE,wBAAwB,EAAE,MAAM,CAAA;CAAE,EAC5D,OAAO,CAAC,EAAE,gBAAgB,GACzB,eAAe,CAkDjB"}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep-clone a scenario.
|
|
3
|
+
*/
|
|
4
|
+
function cloneScenario(s) {
|
|
5
|
+
return JSON.parse(JSON.stringify(s));
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Checks whether a given retirement age is viable for the scenario.
|
|
9
|
+
*
|
|
10
|
+
* Viable means:
|
|
11
|
+
* 1. Deterministic projection survives (no shortfall)
|
|
12
|
+
* 2. terminal_real >= desired_estate
|
|
13
|
+
* 3. If MC function provided: MC success >= threshold
|
|
14
|
+
*/
|
|
15
|
+
function isViable(scenario, retirementAge, projectionFn, mcFn, mcThreshold = 90) {
|
|
16
|
+
var _a;
|
|
17
|
+
const s = cloneScenario(scenario);
|
|
18
|
+
s.retirement_age = retirementAge;
|
|
19
|
+
const proj = projectionFn(s);
|
|
20
|
+
const terminalReal = proj.metrics.terminal_real;
|
|
21
|
+
const survived = proj.metrics.first_shortfall_age === null;
|
|
22
|
+
const meetsEstate = terminalReal >= ((_a = scenario.desired_estate) !== null && _a !== void 0 ? _a : 0);
|
|
23
|
+
let mcSuccessPct = null;
|
|
24
|
+
let mcPasses = true;
|
|
25
|
+
if (mcFn) {
|
|
26
|
+
const mcResult = mcFn(s);
|
|
27
|
+
mcSuccessPct = mcResult.probability_no_shortfall;
|
|
28
|
+
mcPasses = mcSuccessPct >= mcThreshold;
|
|
29
|
+
}
|
|
30
|
+
const viable = survived && meetsEstate && mcPasses;
|
|
31
|
+
return {
|
|
32
|
+
result: {
|
|
33
|
+
retirementAge,
|
|
34
|
+
terminalReal,
|
|
35
|
+
survived,
|
|
36
|
+
mcSuccessPct,
|
|
37
|
+
},
|
|
38
|
+
viable,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Binary search for the minimum contribution amount (within $1 tolerance)
|
|
43
|
+
* that makes a given retirement age viable.
|
|
44
|
+
*
|
|
45
|
+
* Returns the minimum contribution, or null if even the maximum doesn't work.
|
|
46
|
+
*/
|
|
47
|
+
function findMinContribution(scenario, retirementAge, projectionFn, mcFn, mcThreshold = 90) {
|
|
48
|
+
// Search between 0 and a reasonable upper bound.
|
|
49
|
+
// Use 10x the current contribution or $100,000/month as the ceiling.
|
|
50
|
+
const maxContrib = Math.max(scenario.contrib_amount * 10, 100000);
|
|
51
|
+
let lo = 0;
|
|
52
|
+
let hi = maxContrib;
|
|
53
|
+
// First check if even the max contribution works
|
|
54
|
+
const sMax = cloneScenario(scenario);
|
|
55
|
+
sMax.retirement_age = retirementAge;
|
|
56
|
+
sMax.contrib_amount = hi;
|
|
57
|
+
const maxCheck = isViable(sMax, retirementAge, projectionFn, mcFn, mcThreshold);
|
|
58
|
+
if (!maxCheck.viable) {
|
|
59
|
+
return null; // Even maximum contribution doesn't make it viable
|
|
60
|
+
}
|
|
61
|
+
// Check if zero contribution already works
|
|
62
|
+
const sMin = cloneScenario(scenario);
|
|
63
|
+
sMin.retirement_age = retirementAge;
|
|
64
|
+
sMin.contrib_amount = 0;
|
|
65
|
+
const minCheck = isViable(sMin, retirementAge, projectionFn, mcFn, mcThreshold);
|
|
66
|
+
if (minCheck.viable) {
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
// Binary search within $1 tolerance
|
|
70
|
+
while (hi - lo > 1) {
|
|
71
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
72
|
+
const s = cloneScenario(scenario);
|
|
73
|
+
s.retirement_age = retirementAge;
|
|
74
|
+
s.contrib_amount = mid;
|
|
75
|
+
const check = isViable(s, retirementAge, projectionFn, mcFn, mcThreshold);
|
|
76
|
+
if (check.viable) {
|
|
77
|
+
hi = mid;
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
lo = mid;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return hi;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Finds the earliest viable retirement age by testing each candidate from
|
|
87
|
+
* current_age + 1 to end_age - 1.
|
|
88
|
+
*
|
|
89
|
+
* For each candidate:
|
|
90
|
+
* - Runs a deterministic projection
|
|
91
|
+
* - Checks terminal_real >= desired_estate AND no shortfall
|
|
92
|
+
* - If mcFn provided: runs MC (capped at 300 runs externally) and checks
|
|
93
|
+
* success >= mcThreshold
|
|
94
|
+
*
|
|
95
|
+
* Also binary-searches for the minimum contribution amount at the earliest
|
|
96
|
+
* viable age (within $1 tolerance).
|
|
97
|
+
*
|
|
98
|
+
* @param scenario Base scenario
|
|
99
|
+
* @param projectionFn Deterministic projection function
|
|
100
|
+
* @param mcFn Optional Monte Carlo function (should use 300 capped runs)
|
|
101
|
+
* @param options Optional { mcThreshold } (default 90)
|
|
102
|
+
*/
|
|
103
|
+
export function findEarliestRetirementAge(scenario, projectionFn, mcFn, options) {
|
|
104
|
+
var _a;
|
|
105
|
+
const mcThreshold = (_a = options === null || options === void 0 ? void 0 : options.mcThreshold) !== null && _a !== void 0 ? _a : 90;
|
|
106
|
+
const results = [];
|
|
107
|
+
let earliestViableAge = null;
|
|
108
|
+
const startAge = scenario.current_age + 1;
|
|
109
|
+
const endAge = scenario.end_age - 1;
|
|
110
|
+
// Budget guard: track wall-clock time (50s limit per CONTRACT-005)
|
|
111
|
+
const startTime = Date.now();
|
|
112
|
+
const BUDGET_MS = 50000;
|
|
113
|
+
for (let age = startAge; age <= endAge; age++) {
|
|
114
|
+
// Budget guard: abort if we've exceeded 50 seconds
|
|
115
|
+
if (Date.now() - startTime > BUDGET_MS) {
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
const { result, viable } = isViable(scenario, age, projectionFn, mcFn, mcThreshold);
|
|
119
|
+
results.push(result);
|
|
120
|
+
if (viable && earliestViableAge === null) {
|
|
121
|
+
earliestViableAge = age;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Find minimum contribution for earliest viable age
|
|
125
|
+
let minContribution = null;
|
|
126
|
+
if (earliestViableAge !== null) {
|
|
127
|
+
minContribution = findMinContribution(scenario, earliestViableAge, projectionFn, mcFn, mcThreshold);
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
results,
|
|
131
|
+
earliestViableAge,
|
|
132
|
+
minContribution,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Asset, FinancialItem } from './types';
|
|
2
|
+
export interface BlendedPortfolio {
|
|
3
|
+
totalValue: number;
|
|
4
|
+
blendedReturn: number;
|
|
5
|
+
blendedFee: number;
|
|
6
|
+
blendedPerfFee: number;
|
|
7
|
+
liquidPct: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Computes weighted-average return, fee, perf-fee, and liquid percentage
|
|
11
|
+
* across a set of basic-mode assets.
|
|
12
|
+
*
|
|
13
|
+
* Only enabled assets are included. If total value is 0 (or no enabled
|
|
14
|
+
* assets), returns zeroed-out values.
|
|
15
|
+
*
|
|
16
|
+
* Formulas (from spec section 14):
|
|
17
|
+
* total = sum of enabled asset values
|
|
18
|
+
* blended_return = sum(asset.current_value * asset.rate_pct) / total
|
|
19
|
+
* blended_fee = sum(asset.current_value * asset.fee_pct) / total (if fee exists)
|
|
20
|
+
* blended_perf_fee = sum(asset.current_value * asset.perf_fee_pct) / total (if exists)
|
|
21
|
+
* liquid_pct = sum(liquid asset values) / total * 100
|
|
22
|
+
*
|
|
23
|
+
* Note: Asset schema may not carry fee_pct / perf_fee_pct — those fields are
|
|
24
|
+
* optional on the Asset type. When absent they default to 0.
|
|
25
|
+
*/
|
|
26
|
+
export declare function blendPortfolio(assets: Asset[]): BlendedPortfolio;
|
|
27
|
+
/**
|
|
28
|
+
* Computes the estate value at the end of a projection.
|
|
29
|
+
*
|
|
30
|
+
* From spec section 14:
|
|
31
|
+
* estate = endBalanceNominal - endDebtNominal
|
|
32
|
+
* + sum( item.current_value * (1 + rate)^years * estate_pct/100 )
|
|
33
|
+
*
|
|
34
|
+
* Only enabled financial items with estate_pct > 0 contribute additional
|
|
35
|
+
* estate value beyond the main portfolio balance.
|
|
36
|
+
*
|
|
37
|
+
* @param endBalanceNominal Nominal portfolio balance at projection end
|
|
38
|
+
* @param endDebtNominal Total outstanding loan/debt balances at projection end
|
|
39
|
+
* @param financialItems Advanced-mode financial items (for estate earmarks)
|
|
40
|
+
* @param yearsHeld Number of years from start to end of projection
|
|
41
|
+
*/
|
|
42
|
+
export declare function calculateEstateValue(endBalanceNominal: number, endDebtNominal: number, financialItems: FinancialItem[], yearsHeld: number): number;
|
|
43
|
+
//# sourceMappingURL=portfolio.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"portfolio.d.ts","sourceRoot":"","sources":["../src/portfolio.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAMpD,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,gBAAgB,CAsDhE;AAMD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,oBAAoB,CAClC,iBAAiB,EAAE,MAAM,EACzB,cAAc,EAAE,MAAM,EACtB,cAAc,EAAE,aAAa,EAAE,EAC/B,SAAS,EAAE,MAAM,GAChB,MAAM,CAcR"}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Computes weighted-average return, fee, perf-fee, and liquid percentage
|
|
3
|
+
* across a set of basic-mode assets.
|
|
4
|
+
*
|
|
5
|
+
* Only enabled assets are included. If total value is 0 (or no enabled
|
|
6
|
+
* assets), returns zeroed-out values.
|
|
7
|
+
*
|
|
8
|
+
* Formulas (from spec section 14):
|
|
9
|
+
* total = sum of enabled asset values
|
|
10
|
+
* blended_return = sum(asset.current_value * asset.rate_pct) / total
|
|
11
|
+
* blended_fee = sum(asset.current_value * asset.fee_pct) / total (if fee exists)
|
|
12
|
+
* blended_perf_fee = sum(asset.current_value * asset.perf_fee_pct) / total (if exists)
|
|
13
|
+
* liquid_pct = sum(liquid asset values) / total * 100
|
|
14
|
+
*
|
|
15
|
+
* Note: Asset schema may not carry fee_pct / perf_fee_pct — those fields are
|
|
16
|
+
* optional on the Asset type. When absent they default to 0.
|
|
17
|
+
*/
|
|
18
|
+
export function blendPortfolio(assets) {
|
|
19
|
+
const enabled = assets.filter((a) => a.enabled !== false);
|
|
20
|
+
if (enabled.length === 0) {
|
|
21
|
+
return {
|
|
22
|
+
totalValue: 0,
|
|
23
|
+
blendedReturn: 0,
|
|
24
|
+
blendedFee: 0,
|
|
25
|
+
blendedPerfFee: 0,
|
|
26
|
+
liquidPct: 0,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const totalValue = enabled.reduce((sum, a) => sum + a.current_value, 0);
|
|
30
|
+
if (totalValue === 0) {
|
|
31
|
+
return {
|
|
32
|
+
totalValue: 0,
|
|
33
|
+
blendedReturn: 0,
|
|
34
|
+
blendedFee: 0,
|
|
35
|
+
blendedPerfFee: 0,
|
|
36
|
+
liquidPct: 0,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const blendedReturn = enabled.reduce((sum, a) => sum + a.current_value * a.rate_pct, 0) / totalValue;
|
|
40
|
+
// Asset schema doesn't always include fee fields; coalesce to 0
|
|
41
|
+
const blendedFee = enabled.reduce((sum, a) => { var _a; return sum + a.current_value * ((_a = a.fee_pct) !== null && _a !== void 0 ? _a : 0); }, 0) / totalValue;
|
|
42
|
+
const blendedPerfFee = enabled.reduce((sum, a) => { var _a; return sum + a.current_value * ((_a = a.perf_fee_pct) !== null && _a !== void 0 ? _a : 0); }, 0) / totalValue;
|
|
43
|
+
const liquidTotal = enabled
|
|
44
|
+
.filter((a) => a.is_liquid)
|
|
45
|
+
.reduce((sum, a) => sum + a.current_value, 0);
|
|
46
|
+
const liquidPct = (liquidTotal / totalValue) * 100;
|
|
47
|
+
return {
|
|
48
|
+
totalValue,
|
|
49
|
+
blendedReturn,
|
|
50
|
+
blendedFee,
|
|
51
|
+
blendedPerfFee,
|
|
52
|
+
liquidPct,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Estate Value Calculation
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
/**
|
|
59
|
+
* Computes the estate value at the end of a projection.
|
|
60
|
+
*
|
|
61
|
+
* From spec section 14:
|
|
62
|
+
* estate = endBalanceNominal - endDebtNominal
|
|
63
|
+
* + sum( item.current_value * (1 + rate)^years * estate_pct/100 )
|
|
64
|
+
*
|
|
65
|
+
* Only enabled financial items with estate_pct > 0 contribute additional
|
|
66
|
+
* estate value beyond the main portfolio balance.
|
|
67
|
+
*
|
|
68
|
+
* @param endBalanceNominal Nominal portfolio balance at projection end
|
|
69
|
+
* @param endDebtNominal Total outstanding loan/debt balances at projection end
|
|
70
|
+
* @param financialItems Advanced-mode financial items (for estate earmarks)
|
|
71
|
+
* @param yearsHeld Number of years from start to end of projection
|
|
72
|
+
*/
|
|
73
|
+
export function calculateEstateValue(endBalanceNominal, endDebtNominal, financialItems, yearsHeld) {
|
|
74
|
+
let estate = endBalanceNominal - endDebtNominal;
|
|
75
|
+
for (const item of financialItems) {
|
|
76
|
+
// Skip disabled items and those with no estate earmark
|
|
77
|
+
if (!item.enabled)
|
|
78
|
+
continue;
|
|
79
|
+
if (item.estate_pct <= 0)
|
|
80
|
+
continue;
|
|
81
|
+
const rate = item.rate_pct / 100;
|
|
82
|
+
const projectedValue = item.current_value * Math.pow(1 + rate, yearsHeld);
|
|
83
|
+
estate += projectedValue * (item.estate_pct / 100);
|
|
84
|
+
}
|
|
85
|
+
return estate;
|
|
86
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
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 type { Scenario, TimelineRow, Metrics } from './types';
|
|
12
|
+
export declare function runProjection(scenario: Scenario, overrideReturns?: number[]): {
|
|
13
|
+
timeline: TimelineRow[];
|
|
14
|
+
metrics: Metrics;
|
|
15
|
+
};
|
|
16
|
+
//# sourceMappingURL=projection.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"projection.d.ts","sourceRoot":"","sources":["../src/projection.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,OAAO,EAAgB,MAAM,SAAS,CAAC;AAoE5E,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,QAAQ,EAClB,eAAe,CAAC,EAAE,MAAM,EAAE,GACzB;IAAE,QAAQ,EAAE,WAAW,EAAE,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CA2a/C"}
|