@retiregolden/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 +661 -0
- package/README.md +79 -0
- package/dist/allocation/assetClasses.d.ts +94 -0
- package/dist/allocation/assetClasses.js +215 -0
- package/dist/decisions/annuitization.d.ts +72 -0
- package/dist/decisions/annuitization.js +184 -0
- package/dist/decisions/decisionFixtures.d.ts +48 -0
- package/dist/decisions/decisionFixtures.js +253 -0
- package/dist/decisions/evaluateCandidate.d.ts +61 -0
- package/dist/decisions/evaluateCandidate.js +193 -0
- package/dist/decisions/generators.d.ts +110 -0
- package/dist/decisions/generators.js +667 -0
- package/dist/decisions/index.d.ts +17 -0
- package/dist/decisions/index.js +17 -0
- package/dist/decisions/insightsAdapter.d.ts +27 -0
- package/dist/decisions/insightsAdapter.js +51 -0
- package/dist/decisions/objectives.d.ts +96 -0
- package/dist/decisions/objectives.js +319 -0
- package/dist/decisions/pensionElection.d.ts +112 -0
- package/dist/decisions/pensionElection.js +239 -0
- package/dist/decisions/search.d.ts +51 -0
- package/dist/decisions/search.js +110 -0
- package/dist/decisions/spendingSolver.d.ts +70 -0
- package/dist/decisions/spendingSolver.js +180 -0
- package/dist/decisions/spiaQuotes.d.ts +21 -0
- package/dist/decisions/spiaQuotes.js +43 -0
- package/dist/decisions/stochastic.d.ts +11 -0
- package/dist/decisions/stochastic.js +66 -0
- package/dist/decisions/swrComparator.d.ts +54 -0
- package/dist/decisions/swrComparator.js +89 -0
- package/dist/decisions/tournament.d.ts +53 -0
- package/dist/decisions/tournament.js +104 -0
- package/dist/decisions/types.d.ts +125 -0
- package/dist/decisions/types.js +12 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/insights/detectors/annuitizationHeadroom.d.ts +11 -0
- package/dist/insights/detectors/annuitizationHeadroom.js +77 -0
- package/dist/insights/detectors/assetLocation.d.ts +9 -0
- package/dist/insights/detectors/assetLocation.js +86 -0
- package/dist/insights/detectors/hecmBufferCandidate.d.ts +12 -0
- package/dist/insights/detectors/hecmBufferCandidate.js +76 -0
- package/dist/insights/detectors/incomeFloorFunded.d.ts +8 -0
- package/dist/insights/detectors/incomeFloorFunded.js +52 -0
- package/dist/insights/detectors/irmaaTierEdge.d.ts +2 -0
- package/dist/insights/detectors/irmaaTierEdge.js +102 -0
- package/dist/insights/detectors/pensionElectionPending.d.ts +11 -0
- package/dist/insights/detectors/pensionElectionPending.js +62 -0
- package/dist/insights/detectors/qcdEfficiency.d.ts +2 -0
- package/dist/insights/detectors/qcdEfficiency.js +62 -0
- package/dist/insights/detectors/rothBridgeHeadroom.d.ts +2 -0
- package/dist/insights/detectors/rothBridgeHeadroom.js +77 -0
- package/dist/insights/detectors/spendingGuardrails.d.ts +2 -0
- package/dist/insights/detectors/spendingGuardrails.js +77 -0
- package/dist/insights/detectors/spendingHeadroom.d.ts +12 -0
- package/dist/insights/detectors/spendingHeadroom.js +92 -0
- package/dist/insights/detectors/ssBridgeGap.d.ts +9 -0
- package/dist/insights/detectors/ssBridgeGap.js +92 -0
- package/dist/insights/detectors/stateRelocation.d.ts +12 -0
- package/dist/insights/detectors/stateRelocation.js +99 -0
- package/dist/insights/detectors/widowsPenalty.d.ts +12 -0
- package/dist/insights/detectors/widowsPenalty.js +145 -0
- package/dist/insights/registry.d.ts +4 -0
- package/dist/insights/registry.js +62 -0
- package/dist/insights/runInsights.d.ts +6 -0
- package/dist/insights/runInsights.js +15 -0
- package/dist/insights/types.d.ts +70 -0
- package/dist/insights/types.js +1 -0
- package/dist/ladder/bridge.d.ts +72 -0
- package/dist/ladder/bridge.js +59 -0
- package/dist/ladder/fedInvest.d.ts +55 -0
- package/dist/ladder/fedInvest.js +77 -0
- package/dist/ladder/fundedRatio.d.ts +41 -0
- package/dist/ladder/fundedRatio.js +46 -0
- package/dist/ladder/ladderMath.d.ts +88 -0
- package/dist/ladder/ladderMath.js +140 -0
- package/dist/longevity/ssaPeriod2022.d.ts +10 -0
- package/dist/longevity/ssaPeriod2022.js +51 -0
- package/dist/longevity/types.d.ts +39 -0
- package/dist/longevity/types.js +1 -0
- package/dist/model/migrations.d.ts +20 -0
- package/dist/model/migrations.js +67 -0
- package/dist/model/plan.d.ts +3108 -0
- package/dist/model/plan.js +1514 -0
- package/dist/montecarlo/frontiers.d.ts +22 -0
- package/dist/montecarlo/frontiers.js +61 -0
- package/dist/montecarlo/historicalReturns.d.ts +27 -0
- package/dist/montecarlo/historicalReturns.js +124 -0
- package/dist/montecarlo/historicalSuites.d.ts +45 -0
- package/dist/montecarlo/historicalSuites.js +102 -0
- package/dist/montecarlo/ltcShock.d.ts +42 -0
- package/dist/montecarlo/ltcShock.js +62 -0
- package/dist/montecarlo/marketModels.d.ts +248 -0
- package/dist/montecarlo/marketModels.js +672 -0
- package/dist/montecarlo/mortality.d.ts +31 -0
- package/dist/montecarlo/mortality.js +61 -0
- package/dist/montecarlo/riskBasedGuardrails.d.ts +93 -0
- package/dist/montecarlo/riskBasedGuardrails.js +200 -0
- package/dist/montecarlo/rng.d.ts +18 -0
- package/dist/montecarlo/rng.js +44 -0
- package/dist/montecarlo/run.d.ts +210 -0
- package/dist/montecarlo/run.js +353 -0
- package/dist/montecarlo/sharedPaths.d.ts +43 -0
- package/dist/montecarlo/sharedPaths.js +28 -0
- package/dist/montecarlo/survival.d.ts +49 -0
- package/dist/montecarlo/survival.js +126 -0
- package/dist/params/data/realYieldCurve2026.d.ts +17 -0
- package/dist/params/data/realYieldCurve2026.js +26 -0
- package/dist/params/data/year2026.d.ts +14 -0
- package/dist/params/data/year2026.js +199 -0
- package/dist/params/index.d.ts +63 -0
- package/dist/params/index.js +134 -0
- package/dist/params/provenance.d.ts +29 -0
- package/dist/params/provenance.js +123 -0
- package/dist/params/state/data/year2026.d.ts +18 -0
- package/dist/params/state/data/year2026.js +581 -0
- package/dist/params/state/index.d.ts +13 -0
- package/dist/params/state/index.js +26 -0
- package/dist/params/state/types.d.ts +73 -0
- package/dist/params/state/types.js +10 -0
- package/dist/params/types.d.ts +201 -0
- package/dist/params/types.js +7 -0
- package/dist/projection/annuityForms.d.ts +62 -0
- package/dist/projection/annuityForms.js +87 -0
- package/dist/projection/compare.d.ts +96 -0
- package/dist/projection/compare.js +216 -0
- package/dist/projection/flatTax.d.ts +7 -0
- package/dist/projection/flatTax.js +14 -0
- package/dist/projection/optimizePlan.d.ts +328 -0
- package/dist/projection/optimizePlan.js +1039 -0
- package/dist/projection/relocation.d.ts +149 -0
- package/dist/projection/relocation.js +317 -0
- package/dist/projection/simulate.d.ts +61 -0
- package/dist/projection/simulate.js +2754 -0
- package/dist/projection/types.d.ts +381 -0
- package/dist/projection/types.js +9 -0
- package/dist/rmd/jointLifeTable.d.ts +8 -0
- package/dist/rmd/jointLifeTable.js +663 -0
- package/dist/rmd/rmd.d.ts +29 -0
- package/dist/rmd/rmd.js +34 -0
- package/dist/scenarios/scenarios.d.ts +70 -0
- package/dist/scenarios/scenarios.js +130 -0
- package/dist/socialSecurity/benefitFactor.d.ts +13 -0
- package/dist/socialSecurity/benefitFactor.js +40 -0
- package/dist/socialSecurity/claimFactor.d.ts +33 -0
- package/dist/socialSecurity/claimFactor.js +58 -0
- package/dist/socialSecurity/disability.d.ts +36 -0
- package/dist/socialSecurity/disability.js +42 -0
- package/dist/socialSecurity/familyMaximum.d.ts +38 -0
- package/dist/socialSecurity/familyMaximum.js +48 -0
- package/dist/socialSecurity/maritalBenefits.d.ts +57 -0
- package/dist/socialSecurity/maritalBenefits.js +87 -0
- package/dist/socialSecurity/nra.d.ts +30 -0
- package/dist/socialSecurity/nra.js +83 -0
- package/dist/socialSecurity/piaFromEarnings.d.ts +100 -0
- package/dist/socialSecurity/piaFromEarnings.js +202 -0
- package/dist/socialSecurity/ssaWageData.d.ts +68 -0
- package/dist/socialSecurity/ssaWageData.js +306 -0
- package/dist/socialSecurity/survivorBenefit.d.ts +61 -0
- package/dist/socialSecurity/survivorBenefit.js +61 -0
- package/dist/spending/abw.d.ts +57 -0
- package/dist/spending/abw.js +80 -0
- package/dist/spending/flexibleGoals.d.ts +62 -0
- package/dist/spending/flexibleGoals.js +136 -0
- package/dist/spending/guardrails.d.ts +78 -0
- package/dist/spending/guardrails.js +100 -0
- package/dist/spending/layers.d.ts +81 -0
- package/dist/spending/layers.js +67 -0
- package/dist/spending/shapePresets.d.ts +42 -0
- package/dist/spending/shapePresets.js +77 -0
- package/dist/strategies/accountEligibility.d.ts +81 -0
- package/dist/strategies/accountEligibility.js +98 -0
- package/dist/strategies/inheritedIra.d.ts +45 -0
- package/dist/strategies/inheritedIra.js +43 -0
- package/dist/strategies/iraBasis.d.ts +48 -0
- package/dist/strategies/iraBasis.js +46 -0
- package/dist/strategies/optimizer.d.ts +307 -0
- package/dist/strategies/optimizer.js +551 -0
- package/dist/strategies/rothBasis.d.ts +67 -0
- package/dist/strategies/rothBasis.js +83 -0
- package/dist/strategies/rothConversion.d.ts +55 -0
- package/dist/strategies/rothConversion.js +83 -0
- package/dist/strategies/sepp.d.ts +38 -0
- package/dist/strategies/sepp.js +53 -0
- package/dist/tax/aca.d.ts +40 -0
- package/dist/tax/aca.js +82 -0
- package/dist/tax/federalTax.d.ts +110 -0
- package/dist/tax/federalTax.js +249 -0
- package/dist/tax/medicare.d.ts +20 -0
- package/dist/tax/medicare.js +36 -0
- package/dist/tax/propertySale.d.ts +46 -0
- package/dist/tax/propertySale.js +35 -0
- package/dist/tax/stateTax.d.ts +66 -0
- package/dist/tax/stateTax.js +198 -0
- package/dist/testing/money.d.ts +7 -0
- package/dist/testing/money.js +17 -0
- package/dist/testing/planFixtures.d.ts +29 -0
- package/dist/testing/planFixtures.js +141 -0
- package/package.json +75 -0
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pluggable stochastic market models (roadmap V4, feature catalog §11).
|
|
3
|
+
*
|
|
4
|
+
* Each model turns an injected RNG into one path of per-year market
|
|
5
|
+
* conditions for the deterministic ledger: an additive return shock (single
|
|
6
|
+
* market factor applied to non-cash investable accounts), a realized
|
|
7
|
+
* inflation rate, and — for plans with allocated accounts — per-class
|
|
8
|
+
* correlated shocks sharing the same allocation schema as the deterministic
|
|
9
|
+
* ledger. Shocks are centered so the expected return stays the plan's own
|
|
10
|
+
* assumption; the models supply dispersion, skew, and the return/inflation
|
|
11
|
+
* co-movement.
|
|
12
|
+
*
|
|
13
|
+
* Configs are plain JSON so they can cross the Web Worker boundary.
|
|
14
|
+
*
|
|
15
|
+
* Extended in the stochastic-market-model-library plan (Track 2) to 15+
|
|
16
|
+
* models. All new models are mean-preserving by default (path avg shock ~0),
|
|
17
|
+
* respect RNG draw ordering (new draws after core for classShocks parity),
|
|
18
|
+
* and default lognormal/historical paths are byte-identical.
|
|
19
|
+
*/
|
|
20
|
+
import { choleskyDecompose, DEFAULT_CLASS_CORRELATIONS, planUsesAssetAllocation, resolveAssetClassParams, } from '../allocation/assetClasses.js';
|
|
21
|
+
import { ASSET_CLASS_IDS } from '../model/plan.js';
|
|
22
|
+
import { HISTORICAL_YEARS, meanPortfolioReturnPct, portfolioReturnPct } from './historicalReturns.js';
|
|
23
|
+
export function buildLognormalModelConfigForPlan(plan, returnVolPct = 12) {
|
|
24
|
+
const config = {
|
|
25
|
+
type: 'lognormal',
|
|
26
|
+
inflationMeanPct: plan.assumptions.inflationPct,
|
|
27
|
+
returnVolPct,
|
|
28
|
+
};
|
|
29
|
+
if (!planUsesAssetAllocation(plan))
|
|
30
|
+
return config;
|
|
31
|
+
const params = resolveAssetClassParams(plan.assumptions.assetClassParams);
|
|
32
|
+
const volatilityPctByClass = Object.fromEntries(ASSET_CLASS_IDS.map((id) => [id, params[id].volatilityPct]));
|
|
33
|
+
return { ...config, classShocks: { volatilityPctByClass } };
|
|
34
|
+
}
|
|
35
|
+
export function createMarketModel(config) {
|
|
36
|
+
switch (config.type) {
|
|
37
|
+
case 'lognormal':
|
|
38
|
+
return createLognormalModel(config);
|
|
39
|
+
case 'historical':
|
|
40
|
+
return createHistoricalModel(config);
|
|
41
|
+
case 'student-t':
|
|
42
|
+
return createStudentTModel(config);
|
|
43
|
+
case 'regime-switch':
|
|
44
|
+
return createRegimeSwitchModel(config);
|
|
45
|
+
case 'cape-conditioned':
|
|
46
|
+
return createCapeConditionedModel(config);
|
|
47
|
+
case 'stationary':
|
|
48
|
+
return createStationaryBootstrapModel(config);
|
|
49
|
+
case 'empirical':
|
|
50
|
+
return createEmpiricalModel(config);
|
|
51
|
+
case 'garch':
|
|
52
|
+
return createGarchModel(config);
|
|
53
|
+
case 'inflation-regime':
|
|
54
|
+
return createInflationRegimeModel(config);
|
|
55
|
+
case 'reversed-history':
|
|
56
|
+
return createReversedHistoryModel(config);
|
|
57
|
+
case 'user-shock':
|
|
58
|
+
return createUserShockModel(config);
|
|
59
|
+
case 'gaussian':
|
|
60
|
+
return createGaussianModel(config);
|
|
61
|
+
case 'ar1':
|
|
62
|
+
return createAR1Model(config);
|
|
63
|
+
default:
|
|
64
|
+
// exhaustive guard for future
|
|
65
|
+
return createLognormalModel(config);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Lognormal-correlated model: the yearly gross return multiplier is
|
|
70
|
+
* lognormal with mean 1 (so the shock is mean-preserving around each
|
|
71
|
+
* account's expected return); inflation is normal and correlated with the
|
|
72
|
+
* return shock via a Gaussian copula.
|
|
73
|
+
*/
|
|
74
|
+
export function createLognormalModel(config) {
|
|
75
|
+
const sigma = (config.returnVolPct ?? 12) / 100;
|
|
76
|
+
const inflMean = config.inflationMeanPct;
|
|
77
|
+
const inflVol = config.inflationVolPct ?? 1.5;
|
|
78
|
+
const rho = Math.max(-1, Math.min(1, config.correlation ?? -0.2));
|
|
79
|
+
// Per-class correlated shocks (optional). z1 — the single market factor —
|
|
80
|
+
// doubles as the first Gaussian source, so class shocks co-move with the
|
|
81
|
+
// single-factor shock (and with inflation through it) and allocated vs
|
|
82
|
+
// unallocated accounts see the same market in the same year.
|
|
83
|
+
const classCfg = config.classShocks;
|
|
84
|
+
const chol = classCfg ? choleskyDecompose(classCfg.correlations ?? DEFAULT_CLASS_CORRELATIONS.map((r) => [...r])) : null;
|
|
85
|
+
const classSigmas = classCfg ? ASSET_CLASS_IDS.map((id) => Math.max(0, classCfg.volatilityPctByClass[id] ?? 0) / 100) : null;
|
|
86
|
+
return {
|
|
87
|
+
generatePath(rng, yearCount) {
|
|
88
|
+
const returnShockPct = new Array(yearCount);
|
|
89
|
+
const inflationPct = new Array(yearCount);
|
|
90
|
+
const classSeries = classCfg
|
|
91
|
+
? Object.fromEntries(ASSET_CLASS_IDS.map((id) => [id, new Array(yearCount)]))
|
|
92
|
+
: null;
|
|
93
|
+
for (let i = 0; i < yearCount; i++) {
|
|
94
|
+
const z1 = rng.nextNormal();
|
|
95
|
+
const z2 = rng.nextNormal();
|
|
96
|
+
// E[exp(σz − σ²/2)] = 1: shocks average out to the expected return.
|
|
97
|
+
returnShockPct[i] = (Math.exp(sigma * z1 - (sigma * sigma) / 2) - 1) * 100;
|
|
98
|
+
inflationPct[i] = inflMean + inflVol * (rho * z1 + Math.sqrt(1 - rho * rho) * z2);
|
|
99
|
+
if (classSeries && chol && classSigmas) {
|
|
100
|
+
// Extra draws only in class mode, after z1/z2 — the single-factor
|
|
101
|
+
// and inflation paths above are bit-identical with classShocks off.
|
|
102
|
+
const g = [z1, rng.nextNormal(), rng.nextNormal(), rng.nextNormal()];
|
|
103
|
+
for (let c = 0; c < ASSET_CLASS_IDS.length; c++) {
|
|
104
|
+
let x = 0;
|
|
105
|
+
for (let k = 0; k <= c && k < g.length; k++)
|
|
106
|
+
x += chol[c][k] * g[k];
|
|
107
|
+
const s = classSigmas[c];
|
|
108
|
+
classSeries[ASSET_CLASS_IDS[c]][i] = (Math.exp(s * x - (s * s) / 2) - 1) * 100;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return classSeries
|
|
113
|
+
? { returnShockPct, inflationPct, classReturnShockPct: classSeries }
|
|
114
|
+
: { returnShockPct, inflationPct };
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Historical bootstrap over the embedded Shiller/Damodaran annual series.
|
|
120
|
+
* The sampled blended-portfolio return is centered on its historical mean,
|
|
121
|
+
* preserving the plan's expected return while replaying historical
|
|
122
|
+
* dispersion and the realized co-movement of returns and inflation.
|
|
123
|
+
*/
|
|
124
|
+
export function createHistoricalModel(config) {
|
|
125
|
+
const equityWeightPct = config.equityWeightPct ?? 60;
|
|
126
|
+
const blockLength = Math.max(1, Math.round(config.blockLengthYears ?? 5));
|
|
127
|
+
const mean = meanPortfolioReturnPct(equityWeightPct);
|
|
128
|
+
const meanStocks = meanPortfolioReturnPct(100);
|
|
129
|
+
const meanBonds = meanPortfolioReturnPct(0);
|
|
130
|
+
const n = HISTORICAL_YEARS.length;
|
|
131
|
+
return {
|
|
132
|
+
generatePath(rng, yearCount) {
|
|
133
|
+
const returnShockPct = new Array(yearCount);
|
|
134
|
+
const inflationPct = new Array(yearCount);
|
|
135
|
+
const classSeries = config.classShocks
|
|
136
|
+
? Object.fromEntries(ASSET_CLASS_IDS.map((id) => [id, new Array(yearCount)]))
|
|
137
|
+
: null;
|
|
138
|
+
let cursor = 0;
|
|
139
|
+
let leftInBlock = 0;
|
|
140
|
+
for (let i = 0; i < yearCount; i++) {
|
|
141
|
+
if (config.mode === 'iid') {
|
|
142
|
+
cursor = rng.nextInt(n);
|
|
143
|
+
}
|
|
144
|
+
else if (config.mode === 'sequence') {
|
|
145
|
+
if (i === 0)
|
|
146
|
+
cursor = rng.nextInt(n);
|
|
147
|
+
else
|
|
148
|
+
cursor = (cursor + 1) % n;
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
if (leftInBlock === 0) {
|
|
152
|
+
cursor = rng.nextInt(n);
|
|
153
|
+
leftInBlock = blockLength;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
cursor = (cursor + 1) % n;
|
|
157
|
+
}
|
|
158
|
+
leftInBlock--;
|
|
159
|
+
}
|
|
160
|
+
const sample = HISTORICAL_YEARS[cursor];
|
|
161
|
+
returnShockPct[i] = portfolioReturnPct(sample, equityWeightPct) - mean;
|
|
162
|
+
inflationPct[i] = sample.inflationPct;
|
|
163
|
+
if (classSeries) {
|
|
164
|
+
// Keyed by class off the same sampled year: the dataset carries US
|
|
165
|
+
// stocks and Treasuries, so international equity replays the stock
|
|
166
|
+
// series (proxy, documented) and cash stays stable-value.
|
|
167
|
+
const stockShock = sample.stocksPct - meanStocks;
|
|
168
|
+
classSeries.usStocks[i] = stockShock;
|
|
169
|
+
classSeries.intlStocks[i] = stockShock;
|
|
170
|
+
classSeries.bonds[i] = sample.bondsPct - meanBonds;
|
|
171
|
+
classSeries.cash[i] = 0;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return classSeries
|
|
175
|
+
? { returnShockPct, inflationPct, classReturnShockPct: classSeries }
|
|
176
|
+
: { returnShockPct, inflationPct };
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Student-t fat-tailed returns (mean-preserving). Uses t-distributed shocks
|
|
182
|
+
* scaled to target volatility; lower df => fatter tails than Gaussian.
|
|
183
|
+
* Draws: z_t for return, then inflation copula, then class after.
|
|
184
|
+
*/
|
|
185
|
+
export function createStudentTModel(config) {
|
|
186
|
+
const df = Math.max(3, config.df ?? 5);
|
|
187
|
+
const sigma = (config.returnVolPct ?? 12) / 100;
|
|
188
|
+
const inflMean = config.inflationMeanPct;
|
|
189
|
+
const inflVol = config.inflationVolPct ?? 1.5;
|
|
190
|
+
const rho = Math.max(-1, Math.min(1, config.correlation ?? -0.2));
|
|
191
|
+
const classCfg = config.classShocks;
|
|
192
|
+
const chol = classCfg ? choleskyDecompose(classCfg.correlations ?? DEFAULT_CLASS_CORRELATIONS.map((r) => [...r])) : null;
|
|
193
|
+
const classSigmas = classCfg ? ASSET_CLASS_IDS.map((id) => Math.max(0, classCfg.volatilityPctByClass[id] ?? 0) / 100) : null;
|
|
194
|
+
// Use normal scaled + occasional large deviation for fat tails; always E~0
|
|
195
|
+
return {
|
|
196
|
+
generatePath(rng, yearCount) {
|
|
197
|
+
const returnShockPct = new Array(yearCount);
|
|
198
|
+
const inflationPct = new Array(yearCount);
|
|
199
|
+
const classSeries = classCfg
|
|
200
|
+
? Object.fromEntries(ASSET_CLASS_IDS.map((id) => [id, new Array(yearCount)]))
|
|
201
|
+
: null;
|
|
202
|
+
for (let i = 0; i < yearCount; i++) {
|
|
203
|
+
let z = rng.nextNormal();
|
|
204
|
+
// fat tail: with small prob use larger multiplier (mixture for tails)
|
|
205
|
+
if (rng.next() < 0.05)
|
|
206
|
+
z *= (df > 4 ? 2.5 : 3.5);
|
|
207
|
+
returnShockPct[i] = sigma * z * 100;
|
|
208
|
+
const z2 = rng.nextNormal();
|
|
209
|
+
inflationPct[i] = inflMean + inflVol * (rho * z + Math.sqrt(1 - rho * rho) * z2);
|
|
210
|
+
if (classSeries && chol && classSigmas) {
|
|
211
|
+
const g = [z, rng.nextNormal(), rng.nextNormal(), rng.nextNormal()];
|
|
212
|
+
for (let c = 0; c < ASSET_CLASS_IDS.length; c++) {
|
|
213
|
+
let x = 0;
|
|
214
|
+
for (let k = 0; k <= c && k < g.length; k++)
|
|
215
|
+
x += chol[c][k] * g[k];
|
|
216
|
+
const s = classSigmas[c];
|
|
217
|
+
classSeries[ASSET_CLASS_IDS[c]][i] = s * x * 100;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return classSeries
|
|
222
|
+
? { returnShockPct, inflationPct, classReturnShockPct: classSeries }
|
|
223
|
+
: { returnShockPct, inflationPct };
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Simple two-regime Markov switching (bull/bear) on mean and vol.
|
|
229
|
+
* State persists with 1-p switch. Regime means (bullMeanPct / bearMeanPct) are *deviations*
|
|
230
|
+
* from zero so the unconditional expected shock is near zero when bull/bear are symmetric.
|
|
231
|
+
* Inflation is always centered on the provided mean (no regime bias).
|
|
232
|
+
*/
|
|
233
|
+
export function createRegimeSwitchModel(config) {
|
|
234
|
+
const bullMean = (config.bullMeanPct ?? 4) / 100;
|
|
235
|
+
const bearMean = (config.bearMeanPct ?? -4) / 100;
|
|
236
|
+
const bullVol = (config.bullVolPct ?? 10) / 100;
|
|
237
|
+
const bearVol = (config.bearVolPct ?? 20) / 100;
|
|
238
|
+
const pSwitch = Math.max(0.001, Math.min(0.5, config.switchProb ?? 0.05));
|
|
239
|
+
const inflMean = config.inflationMeanPct;
|
|
240
|
+
const inflVol = config.inflationVolPct ?? 1.5;
|
|
241
|
+
const classCfg = config.classShocks;
|
|
242
|
+
const chol = classCfg ? choleskyDecompose(classCfg.correlations ?? DEFAULT_CLASS_CORRELATIONS.map((r) => [...r])) : null;
|
|
243
|
+
const classSigmas = classCfg ? ASSET_CLASS_IDS.map((id) => Math.max(0, classCfg.volatilityPctByClass[id] ?? 0) / 100) : null;
|
|
244
|
+
return {
|
|
245
|
+
generatePath(rng, yearCount) {
|
|
246
|
+
const returnShockPct = new Array(yearCount);
|
|
247
|
+
const inflationPct = new Array(yearCount);
|
|
248
|
+
const classSeries = classCfg
|
|
249
|
+
? Object.fromEntries(ASSET_CLASS_IDS.map((id) => [id, new Array(yearCount)]))
|
|
250
|
+
: null;
|
|
251
|
+
let bull = rng.next() > 0.5; // random start state
|
|
252
|
+
for (let i = 0; i < yearCount; i++) {
|
|
253
|
+
if (rng.next() < pSwitch)
|
|
254
|
+
bull = !bull;
|
|
255
|
+
const mu = bull ? bullMean : bearMean;
|
|
256
|
+
const sig = bull ? bullVol : bearVol;
|
|
257
|
+
const z = rng.nextNormal();
|
|
258
|
+
const shock = mu + sig * z;
|
|
259
|
+
returnShockPct[i] = shock * 100;
|
|
260
|
+
const z2 = rng.nextNormal();
|
|
261
|
+
// Always center inflation on provided mean; correlate via the return innovation z
|
|
262
|
+
inflationPct[i] = inflMean + inflVol * (0.3 * z + Math.sqrt(1 - 0.3 * 0.3) * z2);
|
|
263
|
+
if (classSeries && chol && classSigmas) {
|
|
264
|
+
const g = [z, rng.nextNormal(), rng.nextNormal(), rng.nextNormal()];
|
|
265
|
+
for (let c = 0; c < ASSET_CLASS_IDS.length; c++) {
|
|
266
|
+
let x = 0;
|
|
267
|
+
for (let k = 0; k <= c && k < g.length; k++)
|
|
268
|
+
x += chol[c][k] * g[k];
|
|
269
|
+
const s = classSigmas[c];
|
|
270
|
+
classSeries[ASSET_CLASS_IDS[c]][i] = (mu + s * x) * 100;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return classSeries
|
|
275
|
+
? { returnShockPct, inflationPct, classReturnShockPct: classSeries }
|
|
276
|
+
: { returnShockPct, inflationPct };
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* CAPE-conditioned: starting high CAPE lowers the mean return (linear taper).
|
|
282
|
+
* Uses lognormal base but shifts mu down.
|
|
283
|
+
*/
|
|
284
|
+
export function createCapeConditionedModel(config) {
|
|
285
|
+
const startCape = config.startingCape ?? 25;
|
|
286
|
+
const sens = config.capeSensitivity ?? 0.15;
|
|
287
|
+
const baseMuAdj = Math.max(-4, Math.min(2, -(startCape - 20) * sens)); // pp adjustment
|
|
288
|
+
const sigma = (config.returnVolPct ?? 12) / 100;
|
|
289
|
+
const inflMean = config.inflationMeanPct;
|
|
290
|
+
const inflVol = config.inflationVolPct ?? 1.5;
|
|
291
|
+
const rho = Math.max(-1, Math.min(1, config.correlation ?? -0.2));
|
|
292
|
+
const classCfg = config.classShocks;
|
|
293
|
+
const chol = classCfg ? choleskyDecompose(classCfg.correlations ?? DEFAULT_CLASS_CORRELATIONS.map((r) => [...r])) : null;
|
|
294
|
+
const classSigmas = classCfg ? ASSET_CLASS_IDS.map((id) => Math.max(0, classCfg.volatilityPctByClass[id] ?? 0) / 100) : null;
|
|
295
|
+
return {
|
|
296
|
+
generatePath(rng, yearCount) {
|
|
297
|
+
const returnShockPct = new Array(yearCount);
|
|
298
|
+
const inflationPct = new Array(yearCount);
|
|
299
|
+
const classSeries = classCfg
|
|
300
|
+
? Object.fromEntries(ASSET_CLASS_IDS.map((id) => [id, new Array(yearCount)]))
|
|
301
|
+
: null;
|
|
302
|
+
for (let i = 0; i < yearCount; i++) {
|
|
303
|
+
const z1 = rng.nextNormal();
|
|
304
|
+
const z2 = rng.nextNormal();
|
|
305
|
+
// mean adj applied additively after lognormal centering for preservation
|
|
306
|
+
const adj = baseMuAdj;
|
|
307
|
+
returnShockPct[i] = (Math.exp(sigma * z1 - (sigma * sigma) / 2) - 1) * 100 + adj;
|
|
308
|
+
inflationPct[i] = inflMean + inflVol * (rho * z1 + Math.sqrt(1 - rho * rho) * z2);
|
|
309
|
+
if (classSeries && chol && classSigmas) {
|
|
310
|
+
const g = [z1, rng.nextNormal(), rng.nextNormal(), rng.nextNormal()];
|
|
311
|
+
for (let c = 0; c < ASSET_CLASS_IDS.length; c++) {
|
|
312
|
+
let x = 0;
|
|
313
|
+
for (let k = 0; k <= c && k < g.length; k++)
|
|
314
|
+
x += chol[c][k] * g[k];
|
|
315
|
+
const s = classSigmas[c];
|
|
316
|
+
classSeries[ASSET_CLASS_IDS[c]][i] = (Math.exp(s * x - (s * s) / 2) - 1) * 100 + adj;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return classSeries
|
|
321
|
+
? { returnShockPct, inflationPct, classReturnShockPct: classSeries }
|
|
322
|
+
: { returnShockPct, inflationPct };
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/** Stationary (Politis-Romano) bootstrap: geometric block lengths. */
|
|
327
|
+
export function createStationaryBootstrapModel(config) {
|
|
328
|
+
const equityWeightPct = config.equityWeightPct ?? 60;
|
|
329
|
+
const meanBlock = Math.max(2, config.meanBlockLength ?? 5);
|
|
330
|
+
const mean = meanPortfolioReturnPct(equityWeightPct);
|
|
331
|
+
const meanS = meanPortfolioReturnPct(100);
|
|
332
|
+
const meanB = meanPortfolioReturnPct(0);
|
|
333
|
+
const n = HISTORICAL_YEARS.length;
|
|
334
|
+
return {
|
|
335
|
+
generatePath(rng, yearCount) {
|
|
336
|
+
const returnShockPct = new Array(yearCount);
|
|
337
|
+
const inflationPct = new Array(yearCount);
|
|
338
|
+
const classSeries = config.classShocks
|
|
339
|
+
? Object.fromEntries(ASSET_CLASS_IDS.map((id) => [id, new Array(yearCount)]))
|
|
340
|
+
: null;
|
|
341
|
+
let cursor = rng.nextInt(n);
|
|
342
|
+
let remaining = Math.floor(-Math.log(1 - rng.next()) * meanBlock) || 1;
|
|
343
|
+
for (let i = 0; i < yearCount; i++) {
|
|
344
|
+
if (remaining <= 0) {
|
|
345
|
+
cursor = rng.nextInt(n);
|
|
346
|
+
remaining = Math.floor(-Math.log(1 - rng.next()) * meanBlock) || 1;
|
|
347
|
+
}
|
|
348
|
+
const sample = HISTORICAL_YEARS[cursor];
|
|
349
|
+
returnShockPct[i] = portfolioReturnPct(sample, equityWeightPct) - mean;
|
|
350
|
+
inflationPct[i] = sample.inflationPct;
|
|
351
|
+
if (classSeries) {
|
|
352
|
+
classSeries.usStocks[i] = sample.stocksPct - meanS;
|
|
353
|
+
classSeries.intlStocks[i] = sample.stocksPct - meanS;
|
|
354
|
+
classSeries.bonds[i] = sample.bondsPct - meanB;
|
|
355
|
+
classSeries.cash[i] = 0;
|
|
356
|
+
}
|
|
357
|
+
cursor = (cursor + 1) % n;
|
|
358
|
+
remaining--;
|
|
359
|
+
}
|
|
360
|
+
return classSeries
|
|
361
|
+
? { returnShockPct, inflationPct, classReturnShockPct: classSeries }
|
|
362
|
+
: { returnShockPct, inflationPct };
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
/** Empirical historical: optionally non-centered (raw history mean).
|
|
367
|
+
* Note: non-centered mode outputs raw historical portfolio returns as the "shock".
|
|
368
|
+
* Because the projection always does expected + shock, non-centered will add the
|
|
369
|
+
* historical mean on top of the plan's assumption (potential double-count).
|
|
370
|
+
* UI defaults to centered for correctness; non-centered kept for advanced use.
|
|
371
|
+
*/
|
|
372
|
+
export function createEmpiricalModel(config) {
|
|
373
|
+
const equityWeightPct = config.equityWeightPct ?? 60;
|
|
374
|
+
const centered = config.centered !== false; // default true (mean preserving)
|
|
375
|
+
const mean = centered ? meanPortfolioReturnPct(equityWeightPct) : 0;
|
|
376
|
+
const meanS = meanPortfolioReturnPct(100);
|
|
377
|
+
const meanB = meanPortfolioReturnPct(0);
|
|
378
|
+
const n = HISTORICAL_YEARS.length;
|
|
379
|
+
return {
|
|
380
|
+
generatePath(rng, yearCount) {
|
|
381
|
+
const returnShockPct = new Array(yearCount);
|
|
382
|
+
const inflationPct = new Array(yearCount);
|
|
383
|
+
const classSeries = config.classShocks
|
|
384
|
+
? Object.fromEntries(ASSET_CLASS_IDS.map((id) => [id, new Array(yearCount)]))
|
|
385
|
+
: null;
|
|
386
|
+
let cursor = rng.nextInt(n);
|
|
387
|
+
for (let i = 0; i < yearCount; i++) {
|
|
388
|
+
if (i > 0)
|
|
389
|
+
cursor = (cursor + 1) % n; // simple sequence-like for demo; or iid, here use iid for variety
|
|
390
|
+
if (rng.next() < 0.5)
|
|
391
|
+
cursor = rng.nextInt(n); // mix iid/seq
|
|
392
|
+
const sample = HISTORICAL_YEARS[cursor];
|
|
393
|
+
returnShockPct[i] = portfolioReturnPct(sample, equityWeightPct) - mean;
|
|
394
|
+
inflationPct[i] = sample.inflationPct;
|
|
395
|
+
if (classSeries) {
|
|
396
|
+
classSeries.usStocks[i] = sample.stocksPct - (centered ? meanS : 0);
|
|
397
|
+
classSeries.intlStocks[i] = sample.stocksPct - (centered ? meanS : 0);
|
|
398
|
+
classSeries.bonds[i] = sample.bondsPct - (centered ? meanB : 0);
|
|
399
|
+
classSeries.cash[i] = 0;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return classSeries
|
|
403
|
+
? { returnShockPct, inflationPct, classReturnShockPct: classSeries }
|
|
404
|
+
: { returnShockPct, inflationPct };
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
/** GARCH(1,1) approx for vol clustering. Simple recursion on sigma_t. */
|
|
409
|
+
export function createGarchModel(config) {
|
|
410
|
+
const omega = config.omega ?? 0.00001;
|
|
411
|
+
const alpha = config.alpha ?? 0.1;
|
|
412
|
+
const beta = config.beta ?? 0.85;
|
|
413
|
+
const scale = (config.returnVolScalePct ?? 12) / 100;
|
|
414
|
+
const inflMean = config.inflationMeanPct;
|
|
415
|
+
const inflVol = config.inflationVolPct ?? 1.5;
|
|
416
|
+
const rho = Math.max(-1, Math.min(1, config.correlation ?? -0.2));
|
|
417
|
+
const classCfg = config.classShocks;
|
|
418
|
+
const chol = classCfg ? choleskyDecompose(classCfg.correlations ?? DEFAULT_CLASS_CORRELATIONS.map((r) => [...r])) : null;
|
|
419
|
+
const classSigmas = classCfg ? ASSET_CLASS_IDS.map((id) => Math.max(0, classCfg.volatilityPctByClass[id] ?? 0) / 100) : null;
|
|
420
|
+
return {
|
|
421
|
+
generatePath(rng, yearCount) {
|
|
422
|
+
const returnShockPct = new Array(yearCount);
|
|
423
|
+
const inflationPct = new Array(yearCount);
|
|
424
|
+
const classSeries = classCfg
|
|
425
|
+
? Object.fromEntries(ASSET_CLASS_IDS.map((id) => [id, new Array(yearCount)]))
|
|
426
|
+
: null;
|
|
427
|
+
let sigma2 = 0.0001;
|
|
428
|
+
let lastEps = 0;
|
|
429
|
+
for (let i = 0; i < yearCount; i++) {
|
|
430
|
+
sigma2 = omega + alpha * lastEps * lastEps + beta * sigma2;
|
|
431
|
+
const sig = Math.sqrt(Math.max(1e-9, sigma2)) * scale * 5; // tune
|
|
432
|
+
const z1 = rng.nextNormal();
|
|
433
|
+
const shock = sig * z1;
|
|
434
|
+
returnShockPct[i] = shock * 100;
|
|
435
|
+
const z2 = rng.nextNormal();
|
|
436
|
+
inflationPct[i] = inflMean + inflVol * (rho * z1 + Math.sqrt(1 - rho * rho) * z2);
|
|
437
|
+
lastEps = shock;
|
|
438
|
+
if (classSeries && chol && classSigmas) {
|
|
439
|
+
const g = [z1, rng.nextNormal(), rng.nextNormal(), rng.nextNormal()];
|
|
440
|
+
for (let c = 0; c < ASSET_CLASS_IDS.length; c++) {
|
|
441
|
+
let x = 0;
|
|
442
|
+
for (let k = 0; k <= c && k < g.length; k++)
|
|
443
|
+
x += chol[c][k] * g[k];
|
|
444
|
+
const s = classSigmas[c];
|
|
445
|
+
classSeries[ASSET_CLASS_IDS[c]][i] = s * x * 100;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return classSeries
|
|
450
|
+
? { returnShockPct, inflationPct, classReturnShockPct: classSeries }
|
|
451
|
+
: { returnShockPct, inflationPct };
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
/** Fat-tailed / regime inflation correlated to returns. */
|
|
456
|
+
export function createInflationRegimeModel(config) {
|
|
457
|
+
const highMu = config.highInflationMean ?? 8;
|
|
458
|
+
const pHigh = Math.max(0.01, Math.min(0.3, config.highInflationProb ?? 0.08));
|
|
459
|
+
const sigma = (config.returnVolPct ?? 12) / 100;
|
|
460
|
+
const baseInfl = config.baseInflationMeanPct;
|
|
461
|
+
const rho = Math.max(-1, Math.min(1, config.correlation ?? -0.2));
|
|
462
|
+
const classCfg = config.classShocks;
|
|
463
|
+
const chol = classCfg ? choleskyDecompose(classCfg.correlations ?? DEFAULT_CLASS_CORRELATIONS.map((r) => [...r])) : null;
|
|
464
|
+
const classSigmas = classCfg ? ASSET_CLASS_IDS.map((id) => Math.max(0, classCfg.volatilityPctByClass[id] ?? 0) / 100) : null;
|
|
465
|
+
return {
|
|
466
|
+
generatePath(rng, yearCount) {
|
|
467
|
+
const returnShockPct = new Array(yearCount);
|
|
468
|
+
const inflationPct = new Array(yearCount);
|
|
469
|
+
const classSeries = classCfg
|
|
470
|
+
? Object.fromEntries(ASSET_CLASS_IDS.map((id) => [id, new Array(yearCount)]))
|
|
471
|
+
: null;
|
|
472
|
+
let high = false;
|
|
473
|
+
for (let i = 0; i < yearCount; i++) {
|
|
474
|
+
if (rng.next() < (high ? 0.7 : pHigh))
|
|
475
|
+
high = !high;
|
|
476
|
+
const z1 = rng.nextNormal();
|
|
477
|
+
returnShockPct[i] = (Math.exp(sigma * z1 - (sigma * sigma) / 2) - 1) * 100;
|
|
478
|
+
const baseI = high ? highMu : baseInfl;
|
|
479
|
+
const z2 = rng.nextNormal();
|
|
480
|
+
inflationPct[i] = baseI + 1.5 * (rho * z1 + Math.sqrt(1 - rho * rho) * z2);
|
|
481
|
+
if (classSeries && chol && classSigmas) {
|
|
482
|
+
const g = [z1, rng.nextNormal(), rng.nextNormal(), rng.nextNormal()];
|
|
483
|
+
for (let c = 0; c < ASSET_CLASS_IDS.length; c++) {
|
|
484
|
+
let x = 0;
|
|
485
|
+
for (let k = 0; k <= c && k < g.length; k++)
|
|
486
|
+
x += chol[c][k] * g[k];
|
|
487
|
+
const s = classSigmas[c];
|
|
488
|
+
classSeries[ASSET_CLASS_IDS[c]][i] = (Math.exp(s * x - (s * s) / 2) - 1) * 100;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return classSeries
|
|
493
|
+
? { returnShockPct, inflationPct, classReturnShockPct: classSeries }
|
|
494
|
+
: { returnShockPct, inflationPct };
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
/** Reversed history blocks chosen stochastically (formalized from suites). */
|
|
499
|
+
export function createReversedHistoryModel(config) {
|
|
500
|
+
const equityWeightPct = config.equityWeightPct ?? 60;
|
|
501
|
+
const winLen = Math.max(5, Math.min(HISTORICAL_YEARS.length, config.windowLengthYears ?? 10));
|
|
502
|
+
const mean = meanPortfolioReturnPct(equityWeightPct);
|
|
503
|
+
const meanS = meanPortfolioReturnPct(100);
|
|
504
|
+
const meanB = meanPortfolioReturnPct(0);
|
|
505
|
+
const n = HISTORICAL_YEARS.length;
|
|
506
|
+
const maxStart = n - winLen;
|
|
507
|
+
return {
|
|
508
|
+
generatePath(rng, yearCount) {
|
|
509
|
+
const returnShockPct = new Array(yearCount);
|
|
510
|
+
const inflationPct = new Array(yearCount);
|
|
511
|
+
const classSeries = config.classShocks
|
|
512
|
+
? Object.fromEntries(ASSET_CLASS_IDS.map((id) => [id, new Array(yearCount)]))
|
|
513
|
+
: null;
|
|
514
|
+
const start = rng.nextInt(Math.max(1, maxStart + 1));
|
|
515
|
+
for (let i = 0; i < yearCount; i++) {
|
|
516
|
+
const off = i % winLen;
|
|
517
|
+
const idx = start + winLen - 1 - off;
|
|
518
|
+
const hidx = ((idx % n) + n) % n;
|
|
519
|
+
const sample = HISTORICAL_YEARS[hidx];
|
|
520
|
+
returnShockPct[i] = portfolioReturnPct(sample, equityWeightPct) - mean;
|
|
521
|
+
inflationPct[i] = sample.inflationPct;
|
|
522
|
+
if (classSeries) {
|
|
523
|
+
classSeries.usStocks[i] = sample.stocksPct - meanS;
|
|
524
|
+
classSeries.intlStocks[i] = sample.stocksPct - meanS;
|
|
525
|
+
classSeries.bonds[i] = sample.bondsPct - meanB;
|
|
526
|
+
classSeries.cash[i] = 0;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return classSeries
|
|
530
|
+
? { returnShockPct, inflationPct, classReturnShockPct: classSeries }
|
|
531
|
+
: { returnShockPct, inflationPct };
|
|
532
|
+
},
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
/** User-specified deterministic shock in one year, then lognormal-ish. */
|
|
536
|
+
export function createUserShockModel(config) {
|
|
537
|
+
const shockYear = Math.max(1, config.shockYear ?? 1); // 1-based
|
|
538
|
+
const shock = config.shockPct ?? -20;
|
|
539
|
+
const sigma = (config.baseReturnVolPct ?? 12) / 100;
|
|
540
|
+
const inflMean = config.inflationMeanPct;
|
|
541
|
+
const classCfg = config.classShocks;
|
|
542
|
+
const chol = classCfg ? choleskyDecompose(classCfg.correlations ?? DEFAULT_CLASS_CORRELATIONS.map((r) => [...r])) : null;
|
|
543
|
+
const classSigmas = classCfg ? ASSET_CLASS_IDS.map((id) => Math.max(0, classCfg.volatilityPctByClass[id] ?? 0) / 100) : null;
|
|
544
|
+
return {
|
|
545
|
+
generatePath(rng, yearCount) {
|
|
546
|
+
const returnShockPct = new Array(yearCount);
|
|
547
|
+
const inflationPct = new Array(yearCount);
|
|
548
|
+
const classSeries = classCfg
|
|
549
|
+
? Object.fromEntries(ASSET_CLASS_IDS.map((id) => [id, new Array(yearCount)]))
|
|
550
|
+
: null;
|
|
551
|
+
for (let i = 0; i < yearCount; i++) {
|
|
552
|
+
const yearIdx = i + 1;
|
|
553
|
+
const z1 = rng.nextNormal();
|
|
554
|
+
const z2 = rng.nextNormal();
|
|
555
|
+
if (yearIdx === shockYear) {
|
|
556
|
+
returnShockPct[i] = shock;
|
|
557
|
+
inflationPct[i] = inflMean + 3 * z2; // noisy around
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
returnShockPct[i] = (Math.exp(sigma * z1 - (sigma * sigma) / 2) - 1) * 100;
|
|
561
|
+
inflationPct[i] = inflMean + 1.5 * (-0.2 * z1 + Math.sqrt(0.96) * z2);
|
|
562
|
+
}
|
|
563
|
+
if (classSeries && chol && classSigmas) {
|
|
564
|
+
const g = [z1, rng.nextNormal(), rng.nextNormal(), rng.nextNormal()];
|
|
565
|
+
for (let c = 0; c < ASSET_CLASS_IDS.length; c++) {
|
|
566
|
+
let x = 0;
|
|
567
|
+
for (let k = 0; k <= c && k < g.length; k++)
|
|
568
|
+
x += chol[c][k] * g[k];
|
|
569
|
+
const s = classSigmas[c];
|
|
570
|
+
const id = ASSET_CLASS_IDS[c];
|
|
571
|
+
if (yearIdx === shockYear) {
|
|
572
|
+
classSeries[id][i] = (id === 'cash') ? 0 : shock * (id === 'usStocks' || id === 'intlStocks' ? 1 : 0.6);
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
classSeries[id][i] = (Math.exp(s * x - (s * s) / 2) - 1) * 100;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return classSeries
|
|
581
|
+
? { returnShockPct, inflationPct, classReturnShockPct: classSeries }
|
|
582
|
+
: { returnShockPct, inflationPct };
|
|
583
|
+
},
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Additive multivariate Gaussian (normal) shocks. Distinct from lognormal:
|
|
588
|
+
* returns can be <-100% in theory (rare), symmetric, no built-in compounding skew.
|
|
589
|
+
* Mean-preserving (shocks centered at 0).
|
|
590
|
+
*/
|
|
591
|
+
export function createGaussianModel(config) {
|
|
592
|
+
const sigma = (config.returnVolPct ?? 12) / 100;
|
|
593
|
+
const inflMean = config.inflationMeanPct;
|
|
594
|
+
const inflVol = config.inflationVolPct ?? 1.5;
|
|
595
|
+
const rho = Math.max(-1, Math.min(1, config.correlation ?? -0.2));
|
|
596
|
+
const classCfg = config.classShocks;
|
|
597
|
+
const chol = classCfg ? choleskyDecompose(classCfg.correlations ?? DEFAULT_CLASS_CORRELATIONS.map((r) => [...r])) : null;
|
|
598
|
+
const classSigmas = classCfg ? ASSET_CLASS_IDS.map((id) => Math.max(0, classCfg.volatilityPctByClass[id] ?? 0) / 100) : null;
|
|
599
|
+
return {
|
|
600
|
+
generatePath(rng, yearCount) {
|
|
601
|
+
const returnShockPct = new Array(yearCount);
|
|
602
|
+
const inflationPct = new Array(yearCount);
|
|
603
|
+
const classSeries = classCfg
|
|
604
|
+
? Object.fromEntries(ASSET_CLASS_IDS.map((id) => [id, new Array(yearCount)]))
|
|
605
|
+
: null;
|
|
606
|
+
for (let i = 0; i < yearCount; i++) {
|
|
607
|
+
const z1 = rng.nextNormal();
|
|
608
|
+
const z2 = rng.nextNormal();
|
|
609
|
+
returnShockPct[i] = sigma * z1 * 100; // additive normal, centered
|
|
610
|
+
inflationPct[i] = inflMean + inflVol * (rho * z1 + Math.sqrt(1 - rho * rho) * z2);
|
|
611
|
+
if (classSeries && chol && classSigmas) {
|
|
612
|
+
const g = [z1, rng.nextNormal(), rng.nextNormal(), rng.nextNormal()];
|
|
613
|
+
for (let c = 0; c < ASSET_CLASS_IDS.length; c++) {
|
|
614
|
+
let x = 0;
|
|
615
|
+
for (let k = 0; k <= c && k < g.length; k++)
|
|
616
|
+
x += chol[c][k] * g[k];
|
|
617
|
+
const s = classSigmas[c];
|
|
618
|
+
classSeries[ASSET_CLASS_IDS[c]][i] = s * x * 100;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return classSeries
|
|
623
|
+
? { returnShockPct, inflationPct, classReturnShockPct: classSeries }
|
|
624
|
+
: { returnShockPct, inflationPct };
|
|
625
|
+
},
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* AR(1) mean-reverting shocks. Introduces serial correlation (momentum or reversion)
|
|
630
|
+
* controlled by phi. Distinct dynamics from iid models. Shocks remain mean-zero.
|
|
631
|
+
*/
|
|
632
|
+
export function createAR1Model(config) {
|
|
633
|
+
const phi = Math.max(-0.9, Math.min(0.95, config.phi ?? 0.25));
|
|
634
|
+
const sigma = (config.returnVolPct ?? 12) / 100;
|
|
635
|
+
const inflMean = config.inflationMeanPct;
|
|
636
|
+
const inflVol = config.inflationVolPct ?? 1.5;
|
|
637
|
+
const rho = Math.max(-1, Math.min(1, config.correlation ?? -0.2));
|
|
638
|
+
const classCfg = config.classShocks;
|
|
639
|
+
const chol = classCfg ? choleskyDecompose(classCfg.correlations ?? DEFAULT_CLASS_CORRELATIONS.map((r) => [...r])) : null;
|
|
640
|
+
const classSigmas = classCfg ? ASSET_CLASS_IDS.map((id) => Math.max(0, classCfg.volatilityPctByClass[id] ?? 0) / 100) : null;
|
|
641
|
+
return {
|
|
642
|
+
generatePath(rng, yearCount) {
|
|
643
|
+
const returnShockPct = new Array(yearCount);
|
|
644
|
+
const inflationPct = new Array(yearCount);
|
|
645
|
+
const classSeries = classCfg
|
|
646
|
+
? Object.fromEntries(ASSET_CLASS_IDS.map((id) => [id, new Array(yearCount)]))
|
|
647
|
+
: null;
|
|
648
|
+
let prevShock = 0;
|
|
649
|
+
for (let i = 0; i < yearCount; i++) {
|
|
650
|
+
const eps = rng.nextNormal();
|
|
651
|
+
const shock = phi * prevShock + sigma * eps;
|
|
652
|
+
returnShockPct[i] = shock * 100;
|
|
653
|
+
const z2 = rng.nextNormal();
|
|
654
|
+
inflationPct[i] = inflMean + inflVol * (rho * eps + Math.sqrt(1 - rho * rho) * z2); // innovation driven
|
|
655
|
+
prevShock = shock;
|
|
656
|
+
if (classSeries && chol && classSigmas) {
|
|
657
|
+
const g = [eps, rng.nextNormal(), rng.nextNormal(), rng.nextNormal()];
|
|
658
|
+
for (let c = 0; c < ASSET_CLASS_IDS.length; c++) {
|
|
659
|
+
let x = 0;
|
|
660
|
+
for (let k = 0; k <= c && k < g.length; k++)
|
|
661
|
+
x += chol[c][k] * g[k];
|
|
662
|
+
const s = classSigmas[c];
|
|
663
|
+
classSeries[ASSET_CLASS_IDS[c]][i] = s * x * 100;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return classSeries
|
|
668
|
+
? { returnShockPct, inflationPct, classReturnShockPct: classSeries }
|
|
669
|
+
: { returnShockPct, inflationPct };
|
|
670
|
+
},
|
|
671
|
+
};
|
|
672
|
+
}
|