fundedness 0.2.4__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.
- fundedness/__init__.py +71 -0
- fundedness/allocation/__init__.py +20 -0
- fundedness/allocation/base.py +32 -0
- fundedness/allocation/constant.py +25 -0
- fundedness/allocation/glidepath.py +111 -0
- fundedness/allocation/merton_optimal.py +220 -0
- fundedness/cefr.py +241 -0
- fundedness/liabilities.py +221 -0
- fundedness/liquidity.py +49 -0
- fundedness/merton.py +289 -0
- fundedness/models/__init__.py +35 -0
- fundedness/models/assets.py +148 -0
- fundedness/models/household.py +153 -0
- fundedness/models/liabilities.py +99 -0
- fundedness/models/market.py +199 -0
- fundedness/models/simulation.py +80 -0
- fundedness/models/tax.py +125 -0
- fundedness/models/utility.py +154 -0
- fundedness/optimize.py +473 -0
- fundedness/policies.py +204 -0
- fundedness/risk.py +72 -0
- fundedness/simulate.py +595 -0
- fundedness/viz/__init__.py +33 -0
- fundedness/viz/colors.py +110 -0
- fundedness/viz/comparison.py +294 -0
- fundedness/viz/fan_chart.py +193 -0
- fundedness/viz/histogram.py +225 -0
- fundedness/viz/optimal.py +542 -0
- fundedness/viz/survival.py +230 -0
- fundedness/viz/tornado.py +236 -0
- fundedness/viz/waterfall.py +203 -0
- fundedness/withdrawals/__init__.py +27 -0
- fundedness/withdrawals/base.py +116 -0
- fundedness/withdrawals/comparison.py +230 -0
- fundedness/withdrawals/fixed_swr.py +174 -0
- fundedness/withdrawals/guardrails.py +136 -0
- fundedness/withdrawals/merton_optimal.py +286 -0
- fundedness/withdrawals/rmd_style.py +203 -0
- fundedness/withdrawals/vpw.py +136 -0
- fundedness-0.2.4.dist-info/METADATA +300 -0
- fundedness-0.2.4.dist-info/RECORD +43 -0
- fundedness-0.2.4.dist-info/WHEEL +4 -0
- fundedness-0.2.4.dist-info/entry_points.txt +2 -0
fundedness/risk.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Reliability/risk factor mappings for CEFR calculations."""
|
|
2
|
+
|
|
3
|
+
from fundedness.models.assets import AssetClass, ConcentrationLevel
|
|
4
|
+
|
|
5
|
+
# Default reliability factors by concentration level
|
|
6
|
+
# These represent the certainty-equivalent haircut for concentration risk
|
|
7
|
+
DEFAULT_RELIABILITY_FACTORS: dict[ConcentrationLevel, float] = {
|
|
8
|
+
ConcentrationLevel.DIVERSIFIED: 0.85, # Broad market index
|
|
9
|
+
ConcentrationLevel.SECTOR: 0.70, # Sector concentration
|
|
10
|
+
ConcentrationLevel.SINGLE_STOCK: 0.60, # Individual company
|
|
11
|
+
ConcentrationLevel.STARTUP: 0.30, # Early-stage, high uncertainty
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
# Additional reliability adjustments by asset class
|
|
15
|
+
ASSET_CLASS_RELIABILITY: dict[AssetClass, float] = {
|
|
16
|
+
AssetClass.CASH: 1.0, # No reliability haircut for cash
|
|
17
|
+
AssetClass.BONDS: 0.95, # Slight credit/duration risk
|
|
18
|
+
AssetClass.STOCKS: 1.0, # Base reliability (modified by concentration)
|
|
19
|
+
AssetClass.REAL_ESTATE: 0.90, # Valuation uncertainty
|
|
20
|
+
AssetClass.ALTERNATIVES: 0.80, # Higher uncertainty
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_reliability_factor(
|
|
25
|
+
concentration_level: ConcentrationLevel,
|
|
26
|
+
asset_class: AssetClass | None = None,
|
|
27
|
+
custom_factors: dict[ConcentrationLevel, float] | None = None,
|
|
28
|
+
) -> float:
|
|
29
|
+
"""Get the reliability factor for an asset.
|
|
30
|
+
|
|
31
|
+
The reliability factor combines concentration risk with asset class risk.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
concentration_level: The concentration level of the asset
|
|
35
|
+
asset_class: Optional asset class for additional adjustment
|
|
36
|
+
custom_factors: Optional custom concentration factor overrides
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Reliability factor between 0 and 1
|
|
40
|
+
"""
|
|
41
|
+
# Get concentration-based factor
|
|
42
|
+
if custom_factors and concentration_level in custom_factors:
|
|
43
|
+
concentration_factor = custom_factors[concentration_level]
|
|
44
|
+
else:
|
|
45
|
+
concentration_factor = DEFAULT_RELIABILITY_FACTORS.get(concentration_level, 1.0)
|
|
46
|
+
|
|
47
|
+
# Apply asset class adjustment if provided
|
|
48
|
+
if asset_class is not None:
|
|
49
|
+
asset_adjustment = ASSET_CLASS_RELIABILITY.get(asset_class, 1.0)
|
|
50
|
+
# Cash and bonds don't need concentration haircut
|
|
51
|
+
if asset_class in (AssetClass.CASH, AssetClass.BONDS):
|
|
52
|
+
return asset_adjustment
|
|
53
|
+
return concentration_factor * asset_adjustment
|
|
54
|
+
|
|
55
|
+
return concentration_factor
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_all_reliability_factors(
|
|
59
|
+
custom_factors: dict[ConcentrationLevel, float] | None = None,
|
|
60
|
+
) -> dict[ConcentrationLevel, float]:
|
|
61
|
+
"""Get all reliability factors with optional overrides.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
custom_factors: Optional custom factor overrides
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Dictionary of concentration level to factor
|
|
68
|
+
"""
|
|
69
|
+
factors = DEFAULT_RELIABILITY_FACTORS.copy()
|
|
70
|
+
if custom_factors:
|
|
71
|
+
factors.update(custom_factors)
|
|
72
|
+
return factors
|
fundedness/simulate.py
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
"""Monte Carlo simulation engine for retirement projections."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy import stats
|
|
7
|
+
|
|
8
|
+
from fundedness.models.market import MarketModel
|
|
9
|
+
from fundedness.models.simulation import SimulationConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SimulationResult:
|
|
14
|
+
"""Results from a Monte Carlo simulation."""
|
|
15
|
+
|
|
16
|
+
# Core paths (shape: n_simulations x n_years)
|
|
17
|
+
wealth_paths: np.ndarray
|
|
18
|
+
spending_paths: np.ndarray | None = None
|
|
19
|
+
|
|
20
|
+
# Time metrics (shape: n_simulations)
|
|
21
|
+
time_to_ruin: np.ndarray | None = None
|
|
22
|
+
time_to_floor_breach: np.ndarray | None = None
|
|
23
|
+
|
|
24
|
+
# Percentile summaries (shape: n_percentiles x n_years)
|
|
25
|
+
wealth_percentiles: dict[str, np.ndarray] = field(default_factory=dict)
|
|
26
|
+
spending_percentiles: dict[str, np.ndarray] = field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
# Aggregate metrics
|
|
29
|
+
success_rate: float = 0.0 # % of paths that never hit ruin
|
|
30
|
+
floor_breach_rate: float = 0.0 # % of paths that breach spending floor
|
|
31
|
+
median_terminal_wealth: float = 0.0
|
|
32
|
+
mean_terminal_wealth: float = 0.0
|
|
33
|
+
|
|
34
|
+
# Utility metrics (for utility-integrated simulation)
|
|
35
|
+
utility_paths: np.ndarray | None = None # shape: n_simulations x n_years
|
|
36
|
+
expected_lifetime_utility: float | None = None
|
|
37
|
+
certainty_equivalent_consumption: float | None = None
|
|
38
|
+
utility_percentiles: dict[str, np.ndarray] = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
# Configuration
|
|
41
|
+
n_simulations: int = 0
|
|
42
|
+
n_years: int = 0
|
|
43
|
+
random_seed: int | None = None
|
|
44
|
+
|
|
45
|
+
def get_survival_probability(self) -> np.ndarray:
|
|
46
|
+
"""Calculate survival probability at each year.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Array of shape (n_years,) with P(not ruined) at each year
|
|
50
|
+
"""
|
|
51
|
+
if self.time_to_ruin is None:
|
|
52
|
+
return np.ones(self.n_years)
|
|
53
|
+
|
|
54
|
+
survival = np.zeros(self.n_years)
|
|
55
|
+
for year in range(self.n_years):
|
|
56
|
+
survival[year] = np.mean(self.time_to_ruin > year)
|
|
57
|
+
return survival
|
|
58
|
+
|
|
59
|
+
def get_floor_survival_probability(self) -> np.ndarray:
|
|
60
|
+
"""Calculate probability of being above spending floor at each year.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Array of shape (n_years,) with P(above floor) at each year
|
|
64
|
+
"""
|
|
65
|
+
if self.time_to_floor_breach is None:
|
|
66
|
+
return np.ones(self.n_years)
|
|
67
|
+
|
|
68
|
+
survival = np.zeros(self.n_years)
|
|
69
|
+
for year in range(self.n_years):
|
|
70
|
+
survival[year] = np.mean(self.time_to_floor_breach > year)
|
|
71
|
+
return survival
|
|
72
|
+
|
|
73
|
+
def get_percentile(self, percentile: int, metric: str = "wealth") -> np.ndarray:
|
|
74
|
+
"""Get a specific percentile path.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
percentile: Percentile value (0-100)
|
|
78
|
+
metric: "wealth" or "spending"
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Array of shape (n_years,) with percentile values
|
|
82
|
+
"""
|
|
83
|
+
key = f"P{percentile}"
|
|
84
|
+
if metric == "wealth":
|
|
85
|
+
return self.wealth_percentiles.get(key, np.zeros(self.n_years))
|
|
86
|
+
else:
|
|
87
|
+
return self.spending_percentiles.get(key, np.zeros(self.n_years))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def generate_returns(
|
|
91
|
+
n_simulations: int,
|
|
92
|
+
n_years: int,
|
|
93
|
+
market_model: MarketModel,
|
|
94
|
+
stock_weight: float,
|
|
95
|
+
bond_weight: float | None = None,
|
|
96
|
+
random_seed: int | None = None,
|
|
97
|
+
) -> np.ndarray:
|
|
98
|
+
"""Generate correlated portfolio returns.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
n_simulations: Number of simulation paths
|
|
102
|
+
n_years: Number of years to simulate
|
|
103
|
+
market_model: Market assumptions
|
|
104
|
+
stock_weight: Portfolio weight in stocks
|
|
105
|
+
bond_weight: Portfolio weight in bonds (rest is cash if None)
|
|
106
|
+
random_seed: Random seed for reproducibility
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Array of shape (n_simulations, n_years) with portfolio returns
|
|
110
|
+
"""
|
|
111
|
+
rng = np.random.default_rng(random_seed)
|
|
112
|
+
|
|
113
|
+
if bond_weight is None:
|
|
114
|
+
bond_weight = 1 - stock_weight
|
|
115
|
+
cash_weight = max(0, 1 - stock_weight - bond_weight)
|
|
116
|
+
|
|
117
|
+
# Portfolio expected return and volatility
|
|
118
|
+
portfolio_return = market_model.expected_portfolio_return(stock_weight, bond_weight)
|
|
119
|
+
portfolio_vol = market_model.portfolio_volatility(stock_weight, bond_weight)
|
|
120
|
+
|
|
121
|
+
# Generate returns
|
|
122
|
+
if market_model.use_fat_tails:
|
|
123
|
+
# Use t-distribution for fatter tails
|
|
124
|
+
z = stats.t.rvs(
|
|
125
|
+
df=market_model.degrees_of_freedom,
|
|
126
|
+
size=(n_simulations, n_years),
|
|
127
|
+
random_state=rng,
|
|
128
|
+
)
|
|
129
|
+
# Scale t-distribution to have unit variance
|
|
130
|
+
scale_factor = np.sqrt(market_model.degrees_of_freedom / (market_model.degrees_of_freedom - 2))
|
|
131
|
+
z = z / scale_factor
|
|
132
|
+
else:
|
|
133
|
+
# Standard normal
|
|
134
|
+
z = rng.standard_normal((n_simulations, n_years))
|
|
135
|
+
|
|
136
|
+
# Convert to returns (log-normal model)
|
|
137
|
+
# r = μ - σ²/2 + σ*z (continuous compounding adjustment)
|
|
138
|
+
returns = portfolio_return - portfolio_vol**2 / 2 + portfolio_vol * z
|
|
139
|
+
|
|
140
|
+
return returns
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def run_simulation(
|
|
144
|
+
initial_wealth: float,
|
|
145
|
+
annual_spending: float | np.ndarray,
|
|
146
|
+
config: SimulationConfig,
|
|
147
|
+
stock_weight: float | np.ndarray = 0.6,
|
|
148
|
+
spending_floor: float | None = None,
|
|
149
|
+
inflation_rate: float = 0.025,
|
|
150
|
+
) -> SimulationResult:
|
|
151
|
+
"""Run Monte Carlo simulation of retirement portfolio.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
initial_wealth: Starting portfolio value
|
|
155
|
+
annual_spending: Annual spending (constant or array by year)
|
|
156
|
+
config: Simulation configuration
|
|
157
|
+
stock_weight: Allocation to stocks (constant or array by year)
|
|
158
|
+
spending_floor: Minimum acceptable spending (for floor breach tracking)
|
|
159
|
+
inflation_rate: Annual inflation rate for real spending
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
SimulationResult with all paths and metrics
|
|
163
|
+
"""
|
|
164
|
+
n_sim = config.n_simulations
|
|
165
|
+
n_years = config.n_years
|
|
166
|
+
seed = config.random_seed
|
|
167
|
+
|
|
168
|
+
# Handle spending as array
|
|
169
|
+
if isinstance(annual_spending, (int, float)):
|
|
170
|
+
spending_schedule = np.full(n_years, annual_spending)
|
|
171
|
+
else:
|
|
172
|
+
spending_schedule = np.array(annual_spending)[:n_years]
|
|
173
|
+
if len(spending_schedule) < n_years:
|
|
174
|
+
# Extend with last value
|
|
175
|
+
spending_schedule = np.pad(
|
|
176
|
+
spending_schedule,
|
|
177
|
+
(0, n_years - len(spending_schedule)),
|
|
178
|
+
mode="edge",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Handle stock weight as array
|
|
182
|
+
if isinstance(stock_weight, (int, float)):
|
|
183
|
+
stock_weights = np.full(n_years, stock_weight)
|
|
184
|
+
else:
|
|
185
|
+
stock_weights = np.array(stock_weight)[:n_years]
|
|
186
|
+
if len(stock_weights) < n_years:
|
|
187
|
+
stock_weights = np.pad(
|
|
188
|
+
stock_weights,
|
|
189
|
+
(0, n_years - len(stock_weights)),
|
|
190
|
+
mode="edge",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Generate returns for each year's allocation
|
|
194
|
+
# For simplicity, use average allocation for return generation
|
|
195
|
+
avg_stock_weight = np.mean(stock_weights)
|
|
196
|
+
returns = generate_returns(
|
|
197
|
+
n_simulations=n_sim,
|
|
198
|
+
n_years=n_years,
|
|
199
|
+
market_model=config.market_model,
|
|
200
|
+
stock_weight=avg_stock_weight,
|
|
201
|
+
random_seed=seed,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Initialize paths
|
|
205
|
+
wealth_paths = np.zeros((n_sim, n_years + 1))
|
|
206
|
+
wealth_paths[:, 0] = initial_wealth
|
|
207
|
+
|
|
208
|
+
spending_paths = np.zeros((n_sim, n_years)) if config.track_spending else None
|
|
209
|
+
|
|
210
|
+
time_to_ruin = np.full(n_sim, np.inf)
|
|
211
|
+
time_to_floor_breach = np.full(n_sim, np.inf) if spending_floor else None
|
|
212
|
+
|
|
213
|
+
# Simulate year by year
|
|
214
|
+
for year in range(n_years):
|
|
215
|
+
# Current wealth
|
|
216
|
+
current_wealth = wealth_paths[:, year]
|
|
217
|
+
|
|
218
|
+
# Spending (adjusted for inflation)
|
|
219
|
+
real_spending = spending_schedule[year]
|
|
220
|
+
nominal_spending = real_spending * (1 + inflation_rate) ** year
|
|
221
|
+
|
|
222
|
+
# Actual spending (can't spend more than we have)
|
|
223
|
+
actual_spending = np.minimum(nominal_spending, np.maximum(current_wealth, 0))
|
|
224
|
+
|
|
225
|
+
if spending_paths is not None:
|
|
226
|
+
spending_paths[:, year] = actual_spending
|
|
227
|
+
|
|
228
|
+
# Track floor breach
|
|
229
|
+
if time_to_floor_breach is not None and spending_floor:
|
|
230
|
+
floor_breach_mask = (actual_spending < spending_floor * (1 + inflation_rate) ** year)
|
|
231
|
+
floor_breach_mask &= np.isinf(time_to_floor_breach)
|
|
232
|
+
time_to_floor_breach[floor_breach_mask] = year
|
|
233
|
+
|
|
234
|
+
# Wealth after spending
|
|
235
|
+
wealth_after_spending = current_wealth - actual_spending
|
|
236
|
+
|
|
237
|
+
# Apply returns (only on positive wealth)
|
|
238
|
+
wealth_with_returns = wealth_after_spending * (1 + returns[:, year])
|
|
239
|
+
wealth_with_returns = np.maximum(wealth_with_returns, 0) # Can't go negative
|
|
240
|
+
|
|
241
|
+
wealth_paths[:, year + 1] = wealth_with_returns
|
|
242
|
+
|
|
243
|
+
# Track ruin (wealth hits zero)
|
|
244
|
+
ruin_mask = (wealth_paths[:, year + 1] <= 0) & np.isinf(time_to_ruin)
|
|
245
|
+
time_to_ruin[ruin_mask] = year + 1
|
|
246
|
+
|
|
247
|
+
# Calculate percentiles
|
|
248
|
+
wealth_percentiles = {}
|
|
249
|
+
spending_percentiles = {}
|
|
250
|
+
|
|
251
|
+
for p in config.percentiles:
|
|
252
|
+
key = f"P{p}"
|
|
253
|
+
wealth_percentiles[key] = np.percentile(wealth_paths[:, 1:], p, axis=0)
|
|
254
|
+
if spending_paths is not None:
|
|
255
|
+
spending_percentiles[key] = np.percentile(spending_paths, p, axis=0)
|
|
256
|
+
|
|
257
|
+
# Aggregate metrics
|
|
258
|
+
terminal_wealth = wealth_paths[:, -1]
|
|
259
|
+
success_rate = np.mean(np.isinf(time_to_ruin))
|
|
260
|
+
floor_breach_rate = 0.0
|
|
261
|
+
if time_to_floor_breach is not None:
|
|
262
|
+
floor_breach_rate = np.mean(~np.isinf(time_to_floor_breach))
|
|
263
|
+
|
|
264
|
+
return SimulationResult(
|
|
265
|
+
wealth_paths=wealth_paths[:, 1:], # Exclude initial wealth
|
|
266
|
+
spending_paths=spending_paths,
|
|
267
|
+
time_to_ruin=time_to_ruin,
|
|
268
|
+
time_to_floor_breach=time_to_floor_breach,
|
|
269
|
+
wealth_percentiles=wealth_percentiles,
|
|
270
|
+
spending_percentiles=spending_percentiles,
|
|
271
|
+
success_rate=success_rate,
|
|
272
|
+
floor_breach_rate=floor_breach_rate,
|
|
273
|
+
median_terminal_wealth=np.median(terminal_wealth),
|
|
274
|
+
mean_terminal_wealth=np.mean(terminal_wealth),
|
|
275
|
+
n_simulations=n_sim,
|
|
276
|
+
n_years=n_years,
|
|
277
|
+
random_seed=seed,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def run_simulation_with_policy(
|
|
282
|
+
initial_wealth: float,
|
|
283
|
+
spending_policy: "SpendingPolicy",
|
|
284
|
+
allocation_policy: "AllocationPolicy",
|
|
285
|
+
config: SimulationConfig,
|
|
286
|
+
spending_floor: float | None = None,
|
|
287
|
+
) -> SimulationResult:
|
|
288
|
+
"""Run simulation with dynamic spending and allocation policies.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
initial_wealth: Starting portfolio value
|
|
292
|
+
spending_policy: Policy determining annual spending
|
|
293
|
+
allocation_policy: Policy determining asset allocation
|
|
294
|
+
config: Simulation configuration
|
|
295
|
+
spending_floor: Minimum acceptable spending
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
SimulationResult with all paths and metrics
|
|
299
|
+
"""
|
|
300
|
+
n_sim = config.n_simulations
|
|
301
|
+
n_years = config.n_years
|
|
302
|
+
seed = config.random_seed
|
|
303
|
+
|
|
304
|
+
rng = np.random.default_rng(seed)
|
|
305
|
+
|
|
306
|
+
# Initialize paths
|
|
307
|
+
wealth_paths = np.zeros((n_sim, n_years + 1))
|
|
308
|
+
wealth_paths[:, 0] = initial_wealth
|
|
309
|
+
spending_paths = np.zeros((n_sim, n_years))
|
|
310
|
+
|
|
311
|
+
time_to_ruin = np.full(n_sim, np.inf)
|
|
312
|
+
time_to_floor_breach = np.full(n_sim, np.inf) if spending_floor else None
|
|
313
|
+
|
|
314
|
+
# Generate all random draws upfront
|
|
315
|
+
z = rng.standard_normal((n_sim, n_years))
|
|
316
|
+
|
|
317
|
+
# Simulate year by year
|
|
318
|
+
for year in range(n_years):
|
|
319
|
+
current_wealth = wealth_paths[:, year]
|
|
320
|
+
|
|
321
|
+
# Get spending from policy (vectorized)
|
|
322
|
+
spending = spending_policy.get_spending(
|
|
323
|
+
wealth=current_wealth,
|
|
324
|
+
year=year,
|
|
325
|
+
initial_wealth=initial_wealth,
|
|
326
|
+
)
|
|
327
|
+
spending_paths[:, year] = spending
|
|
328
|
+
|
|
329
|
+
# Track floor breach
|
|
330
|
+
if time_to_floor_breach is not None and spending_floor:
|
|
331
|
+
floor_breach_mask = (spending < spending_floor) & np.isinf(time_to_floor_breach)
|
|
332
|
+
time_to_floor_breach[floor_breach_mask] = year
|
|
333
|
+
|
|
334
|
+
# Get allocation from policy
|
|
335
|
+
stock_weight = allocation_policy.get_allocation(
|
|
336
|
+
wealth=current_wealth,
|
|
337
|
+
year=year,
|
|
338
|
+
initial_wealth=initial_wealth,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# Calculate returns for this allocation
|
|
342
|
+
# Handle both scalar and array allocations
|
|
343
|
+
if isinstance(stock_weight, np.ndarray):
|
|
344
|
+
# Array allocation: compute returns inline for each path
|
|
345
|
+
bond_weight = 1 - stock_weight
|
|
346
|
+
portfolio_return = (
|
|
347
|
+
stock_weight * config.market_model.stock_return
|
|
348
|
+
+ bond_weight * config.market_model.bond_return
|
|
349
|
+
)
|
|
350
|
+
portfolio_vol = np.sqrt(
|
|
351
|
+
stock_weight**2 * config.market_model.stock_volatility**2
|
|
352
|
+
+ bond_weight**2 * config.market_model.bond_volatility**2
|
|
353
|
+
+ 2 * stock_weight * bond_weight
|
|
354
|
+
* config.market_model.stock_volatility
|
|
355
|
+
* config.market_model.bond_volatility
|
|
356
|
+
* config.market_model.stock_bond_correlation
|
|
357
|
+
)
|
|
358
|
+
else:
|
|
359
|
+
# Scalar allocation: use market model methods
|
|
360
|
+
portfolio_return = config.market_model.expected_portfolio_return(stock_weight)
|
|
361
|
+
portfolio_vol = config.market_model.portfolio_volatility(stock_weight)
|
|
362
|
+
|
|
363
|
+
returns = portfolio_return - portfolio_vol**2 / 2 + portfolio_vol * z[:, year]
|
|
364
|
+
|
|
365
|
+
# Update wealth
|
|
366
|
+
wealth_after_spending = np.maximum(current_wealth - spending, 0)
|
|
367
|
+
wealth_paths[:, year + 1] = wealth_after_spending * (1 + returns)
|
|
368
|
+
|
|
369
|
+
# Track ruin
|
|
370
|
+
ruin_mask = (wealth_paths[:, year + 1] <= 0) & np.isinf(time_to_ruin)
|
|
371
|
+
time_to_ruin[ruin_mask] = year + 1
|
|
372
|
+
|
|
373
|
+
# Calculate percentiles
|
|
374
|
+
wealth_percentiles = {}
|
|
375
|
+
spending_percentiles = {}
|
|
376
|
+
|
|
377
|
+
for p in config.percentiles:
|
|
378
|
+
key = f"P{p}"
|
|
379
|
+
wealth_percentiles[key] = np.percentile(wealth_paths[:, 1:], p, axis=0)
|
|
380
|
+
spending_percentiles[key] = np.percentile(spending_paths, p, axis=0)
|
|
381
|
+
|
|
382
|
+
terminal_wealth = wealth_paths[:, -1]
|
|
383
|
+
|
|
384
|
+
return SimulationResult(
|
|
385
|
+
wealth_paths=wealth_paths[:, 1:],
|
|
386
|
+
spending_paths=spending_paths,
|
|
387
|
+
time_to_ruin=time_to_ruin,
|
|
388
|
+
time_to_floor_breach=time_to_floor_breach,
|
|
389
|
+
wealth_percentiles=wealth_percentiles,
|
|
390
|
+
spending_percentiles=spending_percentiles,
|
|
391
|
+
success_rate=np.mean(np.isinf(time_to_ruin)),
|
|
392
|
+
floor_breach_rate=np.mean(~np.isinf(time_to_floor_breach)) if time_to_floor_breach is not None else 0.0,
|
|
393
|
+
median_terminal_wealth=np.median(terminal_wealth),
|
|
394
|
+
mean_terminal_wealth=np.mean(terminal_wealth),
|
|
395
|
+
n_simulations=n_sim,
|
|
396
|
+
n_years=n_years,
|
|
397
|
+
random_seed=seed,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# Type hints for policies (avoid circular imports)
|
|
402
|
+
class SpendingPolicy:
|
|
403
|
+
"""Protocol for spending policies."""
|
|
404
|
+
|
|
405
|
+
def get_spending(
|
|
406
|
+
self,
|
|
407
|
+
wealth: np.ndarray,
|
|
408
|
+
year: int,
|
|
409
|
+
initial_wealth: float,
|
|
410
|
+
) -> np.ndarray:
|
|
411
|
+
"""Get spending amount for each simulation path."""
|
|
412
|
+
raise NotImplementedError
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class AllocationPolicy:
|
|
416
|
+
"""Protocol for allocation policies."""
|
|
417
|
+
|
|
418
|
+
def get_allocation(
|
|
419
|
+
self,
|
|
420
|
+
wealth: np.ndarray,
|
|
421
|
+
year: int,
|
|
422
|
+
initial_wealth: float,
|
|
423
|
+
) -> float | np.ndarray:
|
|
424
|
+
"""Get stock allocation (can be constant or per-path)."""
|
|
425
|
+
raise NotImplementedError
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def run_simulation_with_utility(
|
|
429
|
+
initial_wealth: float,
|
|
430
|
+
spending_policy: "SpendingPolicy",
|
|
431
|
+
allocation_policy: "AllocationPolicy",
|
|
432
|
+
config: SimulationConfig,
|
|
433
|
+
utility_model: "UtilityModel",
|
|
434
|
+
spending_floor: float | None = None,
|
|
435
|
+
survival_probabilities: np.ndarray | None = None,
|
|
436
|
+
) -> SimulationResult:
|
|
437
|
+
"""Run simulation tracking lifetime utility.
|
|
438
|
+
|
|
439
|
+
This function extends run_simulation_with_policy to also track utility
|
|
440
|
+
at each time step, calculate expected lifetime utility, and compute
|
|
441
|
+
the certainty equivalent consumption.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
initial_wealth: Starting portfolio value
|
|
445
|
+
spending_policy: Policy determining annual spending
|
|
446
|
+
allocation_policy: Policy determining asset allocation
|
|
447
|
+
config: Simulation configuration
|
|
448
|
+
utility_model: Utility model for calculating period utility
|
|
449
|
+
spending_floor: Minimum acceptable spending
|
|
450
|
+
survival_probabilities: P(alive) at each year (optional)
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
SimulationResult with utility metrics populated
|
|
454
|
+
"""
|
|
455
|
+
# Import here to avoid circular imports
|
|
456
|
+
from fundedness.models.utility import UtilityModel
|
|
457
|
+
|
|
458
|
+
n_sim = config.n_simulations
|
|
459
|
+
n_years = config.n_years
|
|
460
|
+
seed = config.random_seed
|
|
461
|
+
|
|
462
|
+
rng = np.random.default_rng(seed)
|
|
463
|
+
|
|
464
|
+
# Initialize paths
|
|
465
|
+
wealth_paths = np.zeros((n_sim, n_years + 1))
|
|
466
|
+
wealth_paths[:, 0] = initial_wealth
|
|
467
|
+
spending_paths = np.zeros((n_sim, n_years))
|
|
468
|
+
utility_paths = np.zeros((n_sim, n_years))
|
|
469
|
+
|
|
470
|
+
time_to_ruin = np.full(n_sim, np.inf)
|
|
471
|
+
time_to_floor_breach = np.full(n_sim, np.inf) if spending_floor else None
|
|
472
|
+
|
|
473
|
+
# Default survival probabilities (all survive)
|
|
474
|
+
if survival_probabilities is None:
|
|
475
|
+
survival_probabilities = np.ones(n_years)
|
|
476
|
+
|
|
477
|
+
# Generate all random draws upfront
|
|
478
|
+
z = rng.standard_normal((n_sim, n_years))
|
|
479
|
+
|
|
480
|
+
# Simulate year by year
|
|
481
|
+
for year in range(n_years):
|
|
482
|
+
current_wealth = wealth_paths[:, year]
|
|
483
|
+
|
|
484
|
+
# Get spending from policy
|
|
485
|
+
spending = spending_policy.get_spending(
|
|
486
|
+
wealth=current_wealth,
|
|
487
|
+
year=year,
|
|
488
|
+
initial_wealth=initial_wealth,
|
|
489
|
+
)
|
|
490
|
+
spending_paths[:, year] = spending
|
|
491
|
+
|
|
492
|
+
# Calculate utility for this period's consumption
|
|
493
|
+
for i in range(n_sim):
|
|
494
|
+
utility_paths[i, year] = utility_model.utility(spending[i])
|
|
495
|
+
|
|
496
|
+
# Track floor breach
|
|
497
|
+
if time_to_floor_breach is not None and spending_floor:
|
|
498
|
+
floor_breach_mask = (spending < spending_floor) & np.isinf(time_to_floor_breach)
|
|
499
|
+
time_to_floor_breach[floor_breach_mask] = year
|
|
500
|
+
|
|
501
|
+
# Get allocation from policy
|
|
502
|
+
stock_weight = allocation_policy.get_allocation(
|
|
503
|
+
wealth=current_wealth,
|
|
504
|
+
year=year,
|
|
505
|
+
initial_wealth=initial_wealth,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
# Calculate returns for this allocation
|
|
509
|
+
# Handle both scalar and array allocations
|
|
510
|
+
if isinstance(stock_weight, np.ndarray):
|
|
511
|
+
# Array allocation: compute returns inline for each path
|
|
512
|
+
bond_weight = 1 - stock_weight
|
|
513
|
+
portfolio_return = (
|
|
514
|
+
stock_weight * config.market_model.stock_return
|
|
515
|
+
+ bond_weight * config.market_model.bond_return
|
|
516
|
+
)
|
|
517
|
+
portfolio_vol = np.sqrt(
|
|
518
|
+
stock_weight**2 * config.market_model.stock_volatility**2
|
|
519
|
+
+ bond_weight**2 * config.market_model.bond_volatility**2
|
|
520
|
+
+ 2 * stock_weight * bond_weight
|
|
521
|
+
* config.market_model.stock_volatility
|
|
522
|
+
* config.market_model.bond_volatility
|
|
523
|
+
* config.market_model.stock_bond_correlation
|
|
524
|
+
)
|
|
525
|
+
else:
|
|
526
|
+
# Scalar allocation: use market model methods
|
|
527
|
+
portfolio_return = config.market_model.expected_portfolio_return(stock_weight)
|
|
528
|
+
portfolio_vol = config.market_model.portfolio_volatility(stock_weight)
|
|
529
|
+
|
|
530
|
+
returns = portfolio_return - portfolio_vol**2 / 2 + portfolio_vol * z[:, year]
|
|
531
|
+
|
|
532
|
+
# Update wealth
|
|
533
|
+
wealth_after_spending = np.maximum(current_wealth - spending, 0)
|
|
534
|
+
wealth_paths[:, year + 1] = wealth_after_spending * (1 + returns)
|
|
535
|
+
|
|
536
|
+
# Track ruin
|
|
537
|
+
ruin_mask = (wealth_paths[:, year + 1] <= 0) & np.isinf(time_to_ruin)
|
|
538
|
+
time_to_ruin[ruin_mask] = year + 1
|
|
539
|
+
|
|
540
|
+
# Calculate discounted lifetime utility for each path
|
|
541
|
+
discount_factors = np.array([
|
|
542
|
+
(1 + utility_model.time_preference) ** (-t) * survival_probabilities[t]
|
|
543
|
+
for t in range(n_years)
|
|
544
|
+
])
|
|
545
|
+
|
|
546
|
+
# Lifetime utility per path
|
|
547
|
+
discounted_utilities = utility_paths * discount_factors
|
|
548
|
+
lifetime_utilities = np.sum(discounted_utilities, axis=1)
|
|
549
|
+
|
|
550
|
+
# Expected lifetime utility (mean across paths)
|
|
551
|
+
expected_lifetime_utility = np.mean(lifetime_utilities)
|
|
552
|
+
|
|
553
|
+
# Certainty equivalent consumption
|
|
554
|
+
# Find the constant consumption that gives same expected utility
|
|
555
|
+
mean_spending = np.mean(spending_paths)
|
|
556
|
+
ce_consumption = utility_model.certainty_equivalent(
|
|
557
|
+
np.mean(spending_paths, axis=1) # Average spending per path
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
# Calculate percentiles
|
|
561
|
+
wealth_percentiles = {}
|
|
562
|
+
spending_percentiles = {}
|
|
563
|
+
utility_percentiles = {}
|
|
564
|
+
|
|
565
|
+
for p in config.percentiles:
|
|
566
|
+
key = f"P{p}"
|
|
567
|
+
wealth_percentiles[key] = np.percentile(wealth_paths[:, 1:], p, axis=0)
|
|
568
|
+
spending_percentiles[key] = np.percentile(spending_paths, p, axis=0)
|
|
569
|
+
utility_percentiles[key] = np.percentile(utility_paths, p, axis=0)
|
|
570
|
+
|
|
571
|
+
terminal_wealth = wealth_paths[:, -1]
|
|
572
|
+
|
|
573
|
+
return SimulationResult(
|
|
574
|
+
wealth_paths=wealth_paths[:, 1:],
|
|
575
|
+
spending_paths=spending_paths,
|
|
576
|
+
utility_paths=utility_paths,
|
|
577
|
+
time_to_ruin=time_to_ruin,
|
|
578
|
+
time_to_floor_breach=time_to_floor_breach,
|
|
579
|
+
wealth_percentiles=wealth_percentiles,
|
|
580
|
+
spending_percentiles=spending_percentiles,
|
|
581
|
+
utility_percentiles=utility_percentiles,
|
|
582
|
+
success_rate=np.mean(np.isinf(time_to_ruin)),
|
|
583
|
+
floor_breach_rate=np.mean(~np.isinf(time_to_floor_breach)) if time_to_floor_breach is not None else 0.0,
|
|
584
|
+
median_terminal_wealth=np.median(terminal_wealth),
|
|
585
|
+
mean_terminal_wealth=np.mean(terminal_wealth),
|
|
586
|
+
expected_lifetime_utility=expected_lifetime_utility,
|
|
587
|
+
certainty_equivalent_consumption=ce_consumption,
|
|
588
|
+
n_simulations=n_sim,
|
|
589
|
+
n_years=n_years,
|
|
590
|
+
random_seed=seed,
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# Type alias for UtilityModel (to avoid import issues)
|
|
595
|
+
UtilityModel = "UtilityModel"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Plotly visualization components for fundedness."""
|
|
2
|
+
|
|
3
|
+
from fundedness.viz.colors import COLORS
|
|
4
|
+
from fundedness.viz.comparison import create_strategy_comparison_chart
|
|
5
|
+
from fundedness.viz.fan_chart import create_fan_chart
|
|
6
|
+
from fundedness.viz.histogram import create_time_distribution_histogram
|
|
7
|
+
from fundedness.viz.optimal import (
|
|
8
|
+
create_optimal_allocation_curve,
|
|
9
|
+
create_optimal_policy_summary,
|
|
10
|
+
create_optimal_spending_curve,
|
|
11
|
+
create_sensitivity_heatmap,
|
|
12
|
+
create_spending_comparison_by_age,
|
|
13
|
+
create_utility_comparison_chart,
|
|
14
|
+
)
|
|
15
|
+
from fundedness.viz.survival import create_survival_curve
|
|
16
|
+
from fundedness.viz.tornado import create_tornado_chart
|
|
17
|
+
from fundedness.viz.waterfall import create_cefr_waterfall
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"COLORS",
|
|
21
|
+
"create_cefr_waterfall",
|
|
22
|
+
"create_fan_chart",
|
|
23
|
+
"create_optimal_allocation_curve",
|
|
24
|
+
"create_optimal_policy_summary",
|
|
25
|
+
"create_optimal_spending_curve",
|
|
26
|
+
"create_sensitivity_heatmap",
|
|
27
|
+
"create_spending_comparison_by_age",
|
|
28
|
+
"create_strategy_comparison_chart",
|
|
29
|
+
"create_survival_curve",
|
|
30
|
+
"create_time_distribution_histogram",
|
|
31
|
+
"create_tornado_chart",
|
|
32
|
+
"create_utility_comparison_chart",
|
|
33
|
+
]
|