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.
- monteplan/__init__.py +23 -0
- monteplan/analytics/__init__.py +0 -0
- monteplan/analytics/metrics.py +169 -0
- monteplan/analytics/sensitivity.py +486 -0
- monteplan/analytics/swr.py +104 -0
- monteplan/cli/__init__.py +0 -0
- monteplan/cli/main.py +87 -0
- monteplan/config/__init__.py +0 -0
- monteplan/config/defaults.py +117 -0
- monteplan/config/schema.py +497 -0
- monteplan/core/__init__.py +0 -0
- monteplan/core/engine.py +503 -0
- monteplan/core/rng.py +13 -0
- monteplan/core/state.py +107 -0
- monteplan/core/timeline.py +71 -0
- monteplan/io/__init__.py +0 -0
- monteplan/io/serialize.py +135 -0
- monteplan/io/yaml_loader.py +38 -0
- monteplan/models/__init__.py +0 -0
- monteplan/models/inflation.py +143 -0
- monteplan/models/returns/__init__.py +0 -0
- monteplan/models/returns/base.py +20 -0
- monteplan/models/returns/bootstrap.py +55 -0
- monteplan/models/returns/mvn.py +130 -0
- monteplan/models/returns/regime_switching.py +121 -0
- monteplan/models/stress.py +118 -0
- monteplan/policies/__init__.py +0 -0
- monteplan/policies/contributions.py +31 -0
- monteplan/policies/rebalancing.py +68 -0
- monteplan/policies/spending/__init__.py +0 -0
- monteplan/policies/spending/base.py +21 -0
- monteplan/policies/spending/constant_real.py +28 -0
- monteplan/policies/spending/floor_ceiling.py +32 -0
- monteplan/policies/spending/guardrails.py +82 -0
- monteplan/policies/spending/percent_of_portfolio.py +28 -0
- monteplan/policies/spending/vpw.py +38 -0
- monteplan/policies/withdrawals.py +70 -0
- monteplan/py.typed +0 -0
- monteplan/taxes/__init__.py +0 -0
- monteplan/taxes/base.py +60 -0
- monteplan/taxes/rmd.py +53 -0
- monteplan/taxes/simple.py +46 -0
- monteplan/taxes/tables/__init__.py +0 -0
- monteplan/taxes/tables/rmd_divisors.yaml +56 -0
- monteplan/taxes/tables/us_federal_2024.yaml +37 -0
- monteplan/taxes/us_federal.py +189 -0
- monteplan/utils/__init__.py +0 -0
- monteplan/utils/exceptions.py +15 -0
- monteplan-0.5.0.dist-info/METADATA +150 -0
- monteplan-0.5.0.dist-info/RECORD +53 -0
- monteplan-0.5.0.dist-info/WHEEL +4 -0
- monteplan-0.5.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|