@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,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"}