monteplan 0.5.0__py3-none-any.whl

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.
Files changed (53) hide show
  1. monteplan/__init__.py +23 -0
  2. monteplan/analytics/__init__.py +0 -0
  3. monteplan/analytics/metrics.py +169 -0
  4. monteplan/analytics/sensitivity.py +486 -0
  5. monteplan/analytics/swr.py +104 -0
  6. monteplan/cli/__init__.py +0 -0
  7. monteplan/cli/main.py +87 -0
  8. monteplan/config/__init__.py +0 -0
  9. monteplan/config/defaults.py +117 -0
  10. monteplan/config/schema.py +497 -0
  11. monteplan/core/__init__.py +0 -0
  12. monteplan/core/engine.py +503 -0
  13. monteplan/core/rng.py +13 -0
  14. monteplan/core/state.py +107 -0
  15. monteplan/core/timeline.py +71 -0
  16. monteplan/io/__init__.py +0 -0
  17. monteplan/io/serialize.py +135 -0
  18. monteplan/io/yaml_loader.py +38 -0
  19. monteplan/models/__init__.py +0 -0
  20. monteplan/models/inflation.py +143 -0
  21. monteplan/models/returns/__init__.py +0 -0
  22. monteplan/models/returns/base.py +20 -0
  23. monteplan/models/returns/bootstrap.py +55 -0
  24. monteplan/models/returns/mvn.py +130 -0
  25. monteplan/models/returns/regime_switching.py +121 -0
  26. monteplan/models/stress.py +118 -0
  27. monteplan/policies/__init__.py +0 -0
  28. monteplan/policies/contributions.py +31 -0
  29. monteplan/policies/rebalancing.py +68 -0
  30. monteplan/policies/spending/__init__.py +0 -0
  31. monteplan/policies/spending/base.py +21 -0
  32. monteplan/policies/spending/constant_real.py +28 -0
  33. monteplan/policies/spending/floor_ceiling.py +32 -0
  34. monteplan/policies/spending/guardrails.py +82 -0
  35. monteplan/policies/spending/percent_of_portfolio.py +28 -0
  36. monteplan/policies/spending/vpw.py +38 -0
  37. monteplan/policies/withdrawals.py +70 -0
  38. monteplan/py.typed +0 -0
  39. monteplan/taxes/__init__.py +0 -0
  40. monteplan/taxes/base.py +60 -0
  41. monteplan/taxes/rmd.py +53 -0
  42. monteplan/taxes/simple.py +46 -0
  43. monteplan/taxes/tables/__init__.py +0 -0
  44. monteplan/taxes/tables/rmd_divisors.yaml +56 -0
  45. monteplan/taxes/tables/us_federal_2024.yaml +37 -0
  46. monteplan/taxes/us_federal.py +189 -0
  47. monteplan/utils/__init__.py +0 -0
  48. monteplan/utils/exceptions.py +15 -0
  49. monteplan-0.5.0.dist-info/METADATA +150 -0
  50. monteplan-0.5.0.dist-info/RECORD +53 -0
  51. monteplan-0.5.0.dist-info/WHEEL +4 -0
  52. monteplan-0.5.0.dist-info/entry_points.txt +2 -0
  53. monteplan-0.5.0.dist-info/licenses/LICENSE +190 -0
monteplan/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """monteplan — Monte Carlo financial planning simulator."""
2
+
3
+ __version__ = "0.5.0"
4
+
5
+ from monteplan.analytics.swr import SWRResult as SWRResult
6
+ from monteplan.analytics.swr import find_safe_withdrawal_rate as find_safe_withdrawal_rate
7
+ from monteplan.config.defaults import default_market as default_market
8
+ from monteplan.config.defaults import default_plan as default_plan
9
+ from monteplan.config.defaults import default_policies as default_policies
10
+ from monteplan.config.defaults import default_sim_config as default_sim_config
11
+ from monteplan.config.schema import AccountConfig as AccountConfig
12
+ from monteplan.config.schema import AssetClass as AssetClass
13
+ from monteplan.config.schema import DiscreteEvent as DiscreteEvent
14
+ from monteplan.config.schema import GlidePath as GlidePath
15
+ from monteplan.config.schema import GuaranteedIncomeStream as GuaranteedIncomeStream
16
+ from monteplan.config.schema import MarketAssumptions as MarketAssumptions
17
+ from monteplan.config.schema import PlanConfig as PlanConfig
18
+ from monteplan.config.schema import PolicyBundle as PolicyBundle
19
+ from monteplan.config.schema import RothConversionConfig as RothConversionConfig
20
+ from monteplan.config.schema import SimulationConfig as SimulationConfig
21
+ from monteplan.config.schema import SpendingPolicyConfig as SpendingPolicyConfig
22
+ from monteplan.core.engine import SimulationResult as SimulationResult
23
+ from monteplan.core.engine import simulate as simulate
File without changes
@@ -0,0 +1,169 @@
1
+ """Simulation result metrics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class SimulationMetrics:
12
+ """Computed metrics from a simulation run."""
13
+
14
+ success_probability: float
15
+ terminal_wealth_p5: float
16
+ terminal_wealth_p25: float
17
+ terminal_wealth_p50: float
18
+ terminal_wealth_p75: float
19
+ terminal_wealth_p95: float
20
+ mean_terminal_wealth: float
21
+ mean_shortfall: float
22
+ shortfall_probability: float
23
+
24
+
25
+ def compute_metrics(
26
+ wealth_history: np.ndarray,
27
+ retirement_step: int,
28
+ ) -> SimulationMetrics:
29
+ """Compute simulation metrics from wealth history.
30
+
31
+ Args:
32
+ wealth_history: (n_paths, n_steps+1) array of total wealth at each step.
33
+ retirement_step: Step index when retirement begins.
34
+
35
+ Returns:
36
+ SimulationMetrics with success/failure statistics.
37
+ """
38
+ retirement_wealth = wealth_history[:, retirement_step:]
39
+
40
+ # Success = never ran out during retirement
41
+ success_mask = np.all(retirement_wealth > 0, axis=1)
42
+ success_probability = float(success_mask.mean())
43
+
44
+ # Terminal wealth
45
+ terminal = wealth_history[:, -1]
46
+ pcts = np.percentile(terminal, [5, 25, 50, 75, 95])
47
+
48
+ # Shortfall: paths where terminal wealth is 0
49
+ failed_mask = ~success_mask
50
+ shortfall_probability = float(failed_mask.mean())
51
+
52
+ if failed_mask.any():
53
+ # Mean number of months with zero wealth across failed paths
54
+ zero_months = (retirement_wealth[failed_mask] <= 0).sum(axis=1)
55
+ mean_shortfall = float(zero_months.mean())
56
+ else:
57
+ mean_shortfall = 0.0
58
+
59
+ return SimulationMetrics(
60
+ success_probability=success_probability,
61
+ terminal_wealth_p5=float(pcts[0]),
62
+ terminal_wealth_p25=float(pcts[1]),
63
+ terminal_wealth_p50=float(pcts[2]),
64
+ terminal_wealth_p75=float(pcts[3]),
65
+ terminal_wealth_p95=float(pcts[4]),
66
+ mean_terminal_wealth=float(terminal.mean()),
67
+ mean_shortfall=mean_shortfall,
68
+ shortfall_probability=shortfall_probability,
69
+ )
70
+
71
+
72
+ def max_drawdown_distribution(
73
+ wealth_history: np.ndarray,
74
+ ) -> dict[str, float]:
75
+ """Compute per-path maximum drawdown and return percentiles.
76
+
77
+ Maximum drawdown = largest peak-to-trough decline as a fraction.
78
+
79
+ Args:
80
+ wealth_history: (n_paths, n_steps+1) array of total wealth.
81
+
82
+ Returns:
83
+ Dict with p5, p25, p50, p75, p95, mean of max drawdown (as fractions).
84
+ """
85
+ # Running maximum along the time axis
86
+ running_max = np.maximum.accumulate(wealth_history, axis=1)
87
+ safe_max = np.where(running_max > 0, running_max, 1.0)
88
+ drawdowns = (running_max - wealth_history) / safe_max
89
+ # Per-path max drawdown
90
+ max_dd = drawdowns.max(axis=1)
91
+
92
+ pcts = np.percentile(max_dd, [5, 25, 50, 75, 95])
93
+ return {
94
+ "p5": float(pcts[0]),
95
+ "p25": float(pcts[1]),
96
+ "p50": float(pcts[2]),
97
+ "p75": float(pcts[3]),
98
+ "p95": float(pcts[4]),
99
+ "mean": float(max_dd.mean()),
100
+ }
101
+
102
+
103
+ def spending_volatility(
104
+ spending_history: np.ndarray,
105
+ retirement_step: int,
106
+ ) -> dict[str, float]:
107
+ """Compute per-path spending volatility during retirement.
108
+
109
+ Volatility = standard deviation of month-over-month spending changes
110
+ divided by mean spending (coefficient of variation of changes).
111
+
112
+ Args:
113
+ spending_history: (n_paths, n_steps) array of monthly spending.
114
+ retirement_step: Step index when retirement begins.
115
+
116
+ Returns:
117
+ Dict with p50 and mean of spending volatility across paths.
118
+ """
119
+ retirement_spending = spending_history[:, retirement_step:]
120
+
121
+ # Month-over-month changes
122
+ if retirement_spending.shape[1] < 2:
123
+ return {"p50": 0.0, "mean": 0.0}
124
+
125
+ changes = np.diff(retirement_spending, axis=1)
126
+ mean_spending = retirement_spending[:, :-1].mean(axis=1)
127
+ safe_mean = np.where(mean_spending > 0, mean_spending, 1.0)
128
+
129
+ # Per-path coefficient of variation of changes
130
+ change_std = changes.std(axis=1)
131
+ cv = change_std / safe_mean
132
+
133
+ return {
134
+ "p50": float(np.median(cv)),
135
+ "mean": float(cv.mean()),
136
+ }
137
+
138
+
139
+ def ruin_by_age(
140
+ wealth_history: np.ndarray,
141
+ retirement_step: int,
142
+ current_age: int,
143
+ ) -> tuple[np.ndarray, np.ndarray]:
144
+ """Compute fraction of paths depleted at each age.
145
+
146
+ Args:
147
+ wealth_history: (n_paths, n_steps+1) array of total wealth.
148
+ retirement_step: Step index when retirement begins.
149
+ current_age: Starting age.
150
+
151
+ Returns:
152
+ Tuple of (ages, ruin_fractions) arrays.
153
+ """
154
+ n_paths, n_time = wealth_history.shape
155
+ # Check depletion at each step from retirement onward
156
+ retired_wealth = wealth_history[:, retirement_step:]
157
+ depleted = retired_wealth <= 0 # (n_paths, n_retirement_steps)
158
+ # Cumulative: once depleted, stays depleted
159
+ cumulative_depleted = np.maximum.accumulate(depleted.astype(int), axis=1)
160
+ ruin_fractions = cumulative_depleted.mean(axis=0)
161
+
162
+ # Ages for each step
163
+ n_retirement_steps = retired_wealth.shape[1]
164
+ ages = np.linspace(
165
+ current_age + retirement_step / 12.0,
166
+ current_age + (retirement_step + n_retirement_steps - 1) / 12.0,
167
+ n_retirement_steps,
168
+ )
169
+ return ages, ruin_fractions
@@ -0,0 +1,486 @@
1
+ """One-at-a-time (OAT) sensitivity analysis for simulation parameters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import concurrent.futures
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+
12
+ from monteplan.config.schema import (
13
+ AssetClass,
14
+ MarketAssumptions,
15
+ PlanConfig,
16
+ PolicyBundle,
17
+ SimulationConfig,
18
+ )
19
+ from monteplan.core.engine import simulate
20
+
21
+ # Type aliases for getter/setter callables
22
+ _Getter = Callable[..., float]
23
+ _Setter = Callable[..., Any]
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class SensitivityResult:
28
+ """Result of perturbing a single parameter."""
29
+
30
+ parameter_name: str
31
+ base_value: float
32
+ low_value: float
33
+ high_value: float
34
+ base_success: float
35
+ low_success: float
36
+ high_success: float
37
+ impact: float # high_success - low_success
38
+
39
+
40
+ @dataclass
41
+ class SensitivityReport:
42
+ """Full report of one-at-a-time sensitivity analysis."""
43
+
44
+ results: list[SensitivityResult] = field(default_factory=list)
45
+ base_success_probability: float = 0.0
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class HeatmapResult:
50
+ """Result of a 2D sensitivity grid analysis."""
51
+
52
+ x_param_name: str
53
+ y_param_name: str
54
+ x_values: list[float]
55
+ y_values: list[float]
56
+ success_grid: list[list[float]] # [y_idx][x_idx]
57
+ base_x_value: float
58
+ base_y_value: float
59
+ base_success: float
60
+
61
+
62
+ @dataclass
63
+ class _ParamSpec:
64
+ """Internal spec for a perturbable parameter."""
65
+
66
+ getter: _Getter
67
+ setter: _Setter
68
+ is_additive: bool = False
69
+ additive_delta: float = 0.0
70
+ target: str = "market" # "market", "plan", or "policies"
71
+
72
+
73
+ def _make_market_return_setter(idx: int) -> _Setter:
74
+ def setter(m: MarketAssumptions, val: float) -> MarketAssumptions:
75
+ new_returns = list(m.expected_annual_returns)
76
+ new_returns[idx] = val
77
+ return m.model_copy(update={"expected_annual_returns": new_returns})
78
+
79
+ return setter
80
+
81
+
82
+ def _make_market_vol_setter(idx: int) -> _Setter:
83
+ def setter(m: MarketAssumptions, val: float) -> MarketAssumptions:
84
+ new_vols = list(m.annual_volatilities)
85
+ new_vols[idx] = val
86
+ return m.model_copy(update={"annual_volatilities": new_vols})
87
+
88
+ return setter
89
+
90
+
91
+ def _make_contribution_setter(idx: int) -> _Setter:
92
+ def setter(p: PlanConfig, val: float) -> PlanConfig:
93
+ new_accounts = list(p.accounts)
94
+ new_accounts[idx] = new_accounts[idx].model_copy(
95
+ update={"annual_contribution": max(0.0, val)}
96
+ )
97
+ return p.model_copy(update={"accounts": new_accounts})
98
+
99
+ return setter
100
+
101
+
102
+ def _stock_allocation_setter(m: MarketAssumptions, val: float) -> MarketAssumptions:
103
+ """Set stock allocation weight (first asset), adjusting second asset to keep sum=1."""
104
+ clamped = max(0.0, min(1.0, val))
105
+ new_assets = list(m.assets)
106
+ new_assets[0] = AssetClass(name=new_assets[0].name, weight=clamped)
107
+ if len(new_assets) > 1:
108
+ new_assets[1] = AssetClass(name=new_assets[1].name, weight=1.0 - clamped)
109
+ return m.model_copy(update={"assets": new_assets})
110
+
111
+
112
+ def _build_param_registry(
113
+ plan: PlanConfig,
114
+ market: MarketAssumptions,
115
+ policies: PolicyBundle | None = None,
116
+ ) -> dict[str, _ParamSpec]:
117
+ """Build the full registry of perturbable parameters."""
118
+ all_params: dict[str, _ParamSpec] = {}
119
+ n_assets = len(market.assets)
120
+
121
+ for i in range(n_assets):
122
+ asset_name = market.assets[i].name
123
+ all_params[f"{asset_name} Return"] = _ParamSpec(
124
+ getter=lambda m, idx=i: m.expected_annual_returns[idx],
125
+ setter=_make_market_return_setter(i),
126
+ is_additive=False,
127
+ )
128
+ all_params[f"{asset_name} Volatility"] = _ParamSpec(
129
+ getter=lambda m, idx=i: m.annual_volatilities[idx],
130
+ setter=_make_market_vol_setter(i),
131
+ is_additive=False,
132
+ )
133
+
134
+ all_params["Inflation Rate"] = _ParamSpec(
135
+ getter=lambda m: m.inflation_mean,
136
+ setter=lambda m, v: m.model_copy(update={"inflation_mean": v}),
137
+ is_additive=False,
138
+ )
139
+
140
+ all_params["Monthly Spending"] = _ParamSpec(
141
+ getter=lambda p: p.monthly_spending,
142
+ setter=lambda p, v: p.model_copy(update={"monthly_spending": v}),
143
+ is_additive=False,
144
+ target="plan",
145
+ )
146
+
147
+ all_params["Retirement Age"] = _ParamSpec(
148
+ getter=lambda p: float(p.retirement_age),
149
+ setter=lambda p, v: p.model_copy(update={"retirement_age": int(round(v))}),
150
+ is_additive=True,
151
+ additive_delta=2.0,
152
+ target="plan",
153
+ )
154
+
155
+ for i, acct in enumerate(plan.accounts):
156
+ if acct.annual_contribution > 0:
157
+ all_params[f"{acct.account_type.title()} Contribution"] = _ParamSpec(
158
+ getter=lambda p, idx=i: p.accounts[idx].annual_contribution,
159
+ setter=_make_contribution_setter(i),
160
+ is_additive=False,
161
+ target="plan",
162
+ )
163
+
164
+ # Stock allocation (first asset weight)
165
+ if n_assets >= 2:
166
+ all_params["Stock Allocation"] = _ParamSpec(
167
+ getter=lambda m: m.assets[0].weight,
168
+ setter=_stock_allocation_setter,
169
+ is_additive=False,
170
+ )
171
+
172
+ # State tax rate (only register when non-zero)
173
+ if policies is not None and policies.state_tax_rate > 0:
174
+ all_params["State Tax Rate"] = _ParamSpec(
175
+ getter=lambda p: p.state_tax_rate,
176
+ setter=lambda p, v: p.model_copy(update={"state_tax_rate": max(0.0, min(0.15, v))}),
177
+ is_additive=True,
178
+ additive_delta=0.02,
179
+ target="policies",
180
+ )
181
+
182
+ return all_params
183
+
184
+
185
+ def _run_one(
186
+ plan: PlanConfig,
187
+ market: MarketAssumptions,
188
+ policies: PolicyBundle,
189
+ sim_config: SimulationConfig,
190
+ ) -> float:
191
+ """Run a single simulation and return success probability.
192
+
193
+ Top-level function so it is picklable for ProcessPoolExecutor.
194
+ """
195
+ try:
196
+ result = simulate(plan, market, policies, sim_config)
197
+ return result.success_probability
198
+ except Exception:
199
+ return -1.0 # sentinel for failure
200
+
201
+
202
+ def run_sensitivity(
203
+ plan: PlanConfig,
204
+ market: MarketAssumptions,
205
+ policies: PolicyBundle,
206
+ sim_config: SimulationConfig,
207
+ perturbation_pct: float = 0.10,
208
+ parameters: list[str] | None = None,
209
+ max_workers: int | None = None,
210
+ ) -> SensitivityReport:
211
+ """Run OAT sensitivity analysis.
212
+
213
+ Perturbs each parameter by ±perturbation_pct (or additive for ages)
214
+ and measures the impact on success probability.
215
+
216
+ Args:
217
+ plan: Financial plan configuration.
218
+ market: Market assumptions.
219
+ policies: Policy bundle.
220
+ sim_config: Simulation config (n_paths capped at 2000 for speed).
221
+ perturbation_pct: Fractional perturbation (default 10%).
222
+ parameters: Optional list of parameter names to vary. If None,
223
+ uses all default parameters.
224
+ max_workers: Maximum number of parallel workers. ``None`` uses
225
+ all available cores; ``1`` forces sequential execution.
226
+
227
+ Returns:
228
+ SensitivityReport with per-parameter results.
229
+ """
230
+ # Cap paths for speed
231
+ capped_paths = min(sim_config.n_paths, 2000)
232
+ base_sim = sim_config.model_copy(update={"n_paths": capped_paths, "preset": None})
233
+
234
+ # Base run
235
+ base_result = simulate(plan, market, policies, base_sim)
236
+ base_success = base_result.success_probability
237
+
238
+ # Define all perturbable parameters
239
+ all_params = _build_param_registry(plan, market, policies)
240
+
241
+ # Filter to requested parameters
242
+ if parameters is not None:
243
+ param_set = {k: v for k, v in all_params.items() if k in parameters}
244
+ else:
245
+ param_set = all_params
246
+
247
+ # Build all perturbation jobs upfront (picklable tuples)
248
+ # Each job: (param_name, direction, plan, market, policies, sim_config)
249
+ jobs: list[tuple[str, str, PlanConfig, MarketAssumptions, PolicyBundle, SimulationConfig]] = []
250
+ param_vals: dict[str, tuple[float, float, float]] = {} # name -> (base, low, high)
251
+
252
+ for param_name, spec in param_set.items():
253
+ if spec.target == "plan":
254
+ target_obj: PlanConfig | MarketAssumptions | PolicyBundle = plan
255
+ elif spec.target == "policies":
256
+ target_obj = policies
257
+ else:
258
+ target_obj = market
259
+ base_val: float = spec.getter(target_obj)
260
+
261
+ if spec.is_additive:
262
+ delta = spec.additive_delta
263
+ low_val = base_val - delta
264
+ high_val = base_val + delta
265
+ else:
266
+ low_val = base_val * (1.0 - perturbation_pct)
267
+ high_val = base_val * (1.0 + perturbation_pct)
268
+
269
+ param_vals[param_name] = (base_val, low_val, high_val)
270
+
271
+ # Build low perturbation config
272
+ if spec.target == "plan":
273
+ try:
274
+ low_plan: PlanConfig = spec.setter(plan, low_val)
275
+ jobs.append((param_name, "low", low_plan, market, policies, base_sim))
276
+ except Exception:
277
+ jobs.append((param_name, "low_fail", plan, market, policies, base_sim))
278
+ elif spec.target == "policies":
279
+ try:
280
+ low_policies: PolicyBundle = spec.setter(policies, low_val)
281
+ jobs.append((param_name, "low", plan, market, low_policies, base_sim))
282
+ except Exception:
283
+ jobs.append((param_name, "low_fail", plan, market, policies, base_sim))
284
+ else:
285
+ try:
286
+ low_market: MarketAssumptions = spec.setter(market, low_val)
287
+ jobs.append((param_name, "low", plan, low_market, policies, base_sim))
288
+ except Exception:
289
+ jobs.append((param_name, "low_fail", plan, market, policies, base_sim))
290
+
291
+ # Build high perturbation config
292
+ if spec.target == "plan":
293
+ try:
294
+ high_plan: PlanConfig = spec.setter(plan, high_val)
295
+ jobs.append((param_name, "high", high_plan, market, policies, base_sim))
296
+ except Exception:
297
+ jobs.append((param_name, "high_fail", plan, market, policies, base_sim))
298
+ elif spec.target == "policies":
299
+ try:
300
+ high_policies: PolicyBundle = spec.setter(policies, high_val)
301
+ jobs.append((param_name, "high", plan, market, high_policies, base_sim))
302
+ except Exception:
303
+ jobs.append((param_name, "high_fail", plan, market, policies, base_sim))
304
+ else:
305
+ try:
306
+ high_market: MarketAssumptions = spec.setter(market, high_val)
307
+ jobs.append((param_name, "high", plan, high_market, policies, base_sim))
308
+ except Exception:
309
+ jobs.append((param_name, "high_fail", plan, market, policies, base_sim))
310
+
311
+ # Run all perturbation simulations
312
+ results_map: dict[tuple[str, str], float] = {}
313
+
314
+ if max_workers == 1:
315
+ # Sequential execution
316
+ for param_name, direction, j_plan, j_market, j_policies, j_sim in jobs:
317
+ if direction.endswith("_fail"):
318
+ results_map[(param_name, direction.replace("_fail", ""))] = base_success
319
+ else:
320
+ results_map[(param_name, direction)] = _run_one(
321
+ j_plan, j_market, j_policies, j_sim
322
+ )
323
+ else:
324
+ # Parallel execution
325
+ with concurrent.futures.ProcessPoolExecutor(max_workers=max_workers) as executor:
326
+ future_to_key: dict[concurrent.futures.Future[float], tuple[str, str]] = {}
327
+ for param_name, direction, j_plan, j_market, j_policies, j_sim in jobs:
328
+ if direction.endswith("_fail"):
329
+ results_map[(param_name, direction.replace("_fail", ""))] = base_success
330
+ else:
331
+ future = executor.submit(_run_one, j_plan, j_market, j_policies, j_sim)
332
+ future_to_key[future] = (param_name, direction)
333
+
334
+ for future in concurrent.futures.as_completed(future_to_key):
335
+ key = future_to_key[future]
336
+ success = future.result()
337
+ if success < 0:
338
+ # Sentinel: simulation failed, use base success
339
+ results_map[key] = base_success
340
+ else:
341
+ results_map[key] = success
342
+
343
+ # Assemble report in original parameter order
344
+ report = SensitivityReport(base_success_probability=base_success)
345
+
346
+ for param_name in param_set:
347
+ base_val, low_val, high_val = param_vals[param_name]
348
+ low_success = results_map.get((param_name, "low"), base_success)
349
+ high_success = results_map.get((param_name, "high"), base_success)
350
+
351
+ report.results.append(
352
+ SensitivityResult(
353
+ parameter_name=param_name,
354
+ base_value=base_val,
355
+ low_value=low_val,
356
+ high_value=high_val,
357
+ base_success=base_success,
358
+ low_success=low_success,
359
+ high_success=high_success,
360
+ impact=high_success - low_success,
361
+ )
362
+ )
363
+
364
+ return report
365
+
366
+
367
+ def run_2d_sensitivity(
368
+ plan: PlanConfig,
369
+ market: MarketAssumptions,
370
+ policies: PolicyBundle,
371
+ sim_config: SimulationConfig,
372
+ x_param: str,
373
+ y_param: str,
374
+ x_range: tuple[float, float],
375
+ y_range: tuple[float, float],
376
+ x_steps: int = 12,
377
+ y_steps: int = 12,
378
+ max_workers: int | None = None,
379
+ ) -> HeatmapResult:
380
+ """Run a 2D grid sensitivity analysis.
381
+
382
+ Varies two parameters simultaneously and measures success probability
383
+ across an x_steps x y_steps grid.
384
+
385
+ Args:
386
+ plan: Financial plan configuration.
387
+ market: Market assumptions.
388
+ policies: Policy bundle.
389
+ sim_config: Simulation config (n_paths capped at 1000 for speed).
390
+ x_param: Name of the x-axis parameter.
391
+ y_param: Name of the y-axis parameter.
392
+ x_range: (min, max) range for x parameter.
393
+ y_range: (min, max) range for y parameter.
394
+ x_steps: Number of grid points along x-axis.
395
+ y_steps: Number of grid points along y-axis.
396
+ max_workers: Maximum number of parallel workers.
397
+
398
+ Returns:
399
+ HeatmapResult with the success probability grid.
400
+ """
401
+ # Cap paths for speed
402
+ capped_paths = min(sim_config.n_paths, 1000)
403
+ base_sim = sim_config.model_copy(update={"n_paths": capped_paths, "preset": None})
404
+
405
+ all_params = _build_param_registry(plan, market, policies)
406
+
407
+ if x_param not in all_params:
408
+ raise ValueError(f"Unknown parameter: {x_param}")
409
+ if y_param not in all_params:
410
+ raise ValueError(f"Unknown parameter: {y_param}")
411
+
412
+ x_spec = all_params[x_param]
413
+ y_spec = all_params[y_param]
414
+
415
+ x_values = np.linspace(x_range[0], x_range[1], x_steps).tolist()
416
+ y_values = np.linspace(y_range[0], y_range[1], y_steps).tolist()
417
+
418
+ # Get base values
419
+ def _resolve_target(
420
+ spec: _ParamSpec,
421
+ ) -> PlanConfig | MarketAssumptions | PolicyBundle:
422
+ if spec.target == "plan":
423
+ return plan
424
+ if spec.target == "policies":
425
+ return policies
426
+ return market
427
+
428
+ base_x = x_spec.getter(_resolve_target(x_spec))
429
+ base_y = y_spec.getter(_resolve_target(y_spec))
430
+
431
+ # Base run
432
+ base_result = simulate(plan, market, policies, base_sim)
433
+ base_success = base_result.success_probability * 100
434
+
435
+ # Build all grid jobs
436
+ jobs: list[tuple[int, int, PlanConfig, MarketAssumptions, PolicyBundle, SimulationConfig]] = []
437
+ for yi, yv in enumerate(y_values):
438
+ for xi, xv in enumerate(x_values):
439
+ cur_plan = plan
440
+ cur_market = market
441
+ cur_policies = policies
442
+ # Apply x parameter
443
+ if x_spec.target == "plan":
444
+ cur_plan = x_spec.setter(cur_plan, xv)
445
+ elif x_spec.target == "policies":
446
+ cur_policies = x_spec.setter(cur_policies, xv)
447
+ else:
448
+ cur_market = x_spec.setter(cur_market, xv)
449
+ # Apply y parameter
450
+ if y_spec.target == "plan":
451
+ cur_plan = y_spec.setter(cur_plan, yv)
452
+ elif y_spec.target == "policies":
453
+ cur_policies = y_spec.setter(cur_policies, yv)
454
+ else:
455
+ cur_market = y_spec.setter(cur_market, yv)
456
+ jobs.append((yi, xi, cur_plan, cur_market, cur_policies, base_sim))
457
+
458
+ # Run all jobs
459
+ grid: list[list[float]] = [[0.0] * x_steps for _ in range(y_steps)]
460
+
461
+ if max_workers == 1:
462
+ for yi, xi, j_plan, j_market, j_policies, j_sim in jobs:
463
+ success = _run_one(j_plan, j_market, j_policies, j_sim)
464
+ grid[yi][xi] = max(success, 0.0) * 100
465
+ else:
466
+ with concurrent.futures.ProcessPoolExecutor(max_workers=max_workers) as executor:
467
+ future_to_idx: dict[concurrent.futures.Future[float], tuple[int, int]] = {}
468
+ for yi, xi, j_plan, j_market, j_policies, j_sim in jobs:
469
+ future = executor.submit(_run_one, j_plan, j_market, j_policies, j_sim)
470
+ future_to_idx[future] = (yi, xi)
471
+
472
+ for future in concurrent.futures.as_completed(future_to_idx):
473
+ yi, xi = future_to_idx[future]
474
+ success = future.result()
475
+ grid[yi][xi] = max(success, 0.0) * 100
476
+
477
+ return HeatmapResult(
478
+ x_param_name=x_param,
479
+ y_param_name=y_param,
480
+ x_values=x_values,
481
+ y_values=y_values,
482
+ success_grid=grid,
483
+ base_x_value=base_x,
484
+ base_y_value=base_y,
485
+ base_success=base_success,
486
+ )