fundedness 0.2.2__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.

Potentially problematic release.


This version of fundedness might be problematic. Click here for more details.

Files changed (43) hide show
  1. fundedness/__init__.py +71 -0
  2. fundedness/allocation/__init__.py +20 -0
  3. fundedness/allocation/base.py +32 -0
  4. fundedness/allocation/constant.py +25 -0
  5. fundedness/allocation/glidepath.py +111 -0
  6. fundedness/allocation/merton_optimal.py +220 -0
  7. fundedness/cefr.py +241 -0
  8. fundedness/liabilities.py +221 -0
  9. fundedness/liquidity.py +49 -0
  10. fundedness/merton.py +289 -0
  11. fundedness/models/__init__.py +35 -0
  12. fundedness/models/assets.py +148 -0
  13. fundedness/models/household.py +153 -0
  14. fundedness/models/liabilities.py +99 -0
  15. fundedness/models/market.py +188 -0
  16. fundedness/models/simulation.py +80 -0
  17. fundedness/models/tax.py +125 -0
  18. fundedness/models/utility.py +154 -0
  19. fundedness/optimize.py +473 -0
  20. fundedness/policies.py +204 -0
  21. fundedness/risk.py +72 -0
  22. fundedness/simulate.py +559 -0
  23. fundedness/viz/__init__.py +33 -0
  24. fundedness/viz/colors.py +110 -0
  25. fundedness/viz/comparison.py +294 -0
  26. fundedness/viz/fan_chart.py +193 -0
  27. fundedness/viz/histogram.py +225 -0
  28. fundedness/viz/optimal.py +542 -0
  29. fundedness/viz/survival.py +230 -0
  30. fundedness/viz/tornado.py +236 -0
  31. fundedness/viz/waterfall.py +203 -0
  32. fundedness/withdrawals/__init__.py +27 -0
  33. fundedness/withdrawals/base.py +116 -0
  34. fundedness/withdrawals/comparison.py +230 -0
  35. fundedness/withdrawals/fixed_swr.py +174 -0
  36. fundedness/withdrawals/guardrails.py +136 -0
  37. fundedness/withdrawals/merton_optimal.py +286 -0
  38. fundedness/withdrawals/rmd_style.py +203 -0
  39. fundedness/withdrawals/vpw.py +136 -0
  40. fundedness-0.2.2.dist-info/METADATA +299 -0
  41. fundedness-0.2.2.dist-info/RECORD +43 -0
  42. fundedness-0.2.2.dist-info/WHEEL +4 -0
  43. fundedness-0.2.2.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,559 @@
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
+ portfolio_return = config.market_model.expected_portfolio_return(stock_weight)
343
+ portfolio_vol = config.market_model.portfolio_volatility(stock_weight)
344
+
345
+ returns = portfolio_return - portfolio_vol**2 / 2 + portfolio_vol * z[:, year]
346
+
347
+ # Update wealth
348
+ wealth_after_spending = np.maximum(current_wealth - spending, 0)
349
+ wealth_paths[:, year + 1] = wealth_after_spending * (1 + returns)
350
+
351
+ # Track ruin
352
+ ruin_mask = (wealth_paths[:, year + 1] <= 0) & np.isinf(time_to_ruin)
353
+ time_to_ruin[ruin_mask] = year + 1
354
+
355
+ # Calculate percentiles
356
+ wealth_percentiles = {}
357
+ spending_percentiles = {}
358
+
359
+ for p in config.percentiles:
360
+ key = f"P{p}"
361
+ wealth_percentiles[key] = np.percentile(wealth_paths[:, 1:], p, axis=0)
362
+ spending_percentiles[key] = np.percentile(spending_paths, p, axis=0)
363
+
364
+ terminal_wealth = wealth_paths[:, -1]
365
+
366
+ return SimulationResult(
367
+ wealth_paths=wealth_paths[:, 1:],
368
+ spending_paths=spending_paths,
369
+ time_to_ruin=time_to_ruin,
370
+ time_to_floor_breach=time_to_floor_breach,
371
+ wealth_percentiles=wealth_percentiles,
372
+ spending_percentiles=spending_percentiles,
373
+ success_rate=np.mean(np.isinf(time_to_ruin)),
374
+ floor_breach_rate=np.mean(~np.isinf(time_to_floor_breach)) if time_to_floor_breach is not None else 0.0,
375
+ median_terminal_wealth=np.median(terminal_wealth),
376
+ mean_terminal_wealth=np.mean(terminal_wealth),
377
+ n_simulations=n_sim,
378
+ n_years=n_years,
379
+ random_seed=seed,
380
+ )
381
+
382
+
383
+ # Type hints for policies (avoid circular imports)
384
+ class SpendingPolicy:
385
+ """Protocol for spending policies."""
386
+
387
+ def get_spending(
388
+ self,
389
+ wealth: np.ndarray,
390
+ year: int,
391
+ initial_wealth: float,
392
+ ) -> np.ndarray:
393
+ """Get spending amount for each simulation path."""
394
+ raise NotImplementedError
395
+
396
+
397
+ class AllocationPolicy:
398
+ """Protocol for allocation policies."""
399
+
400
+ def get_allocation(
401
+ self,
402
+ wealth: np.ndarray,
403
+ year: int,
404
+ initial_wealth: float,
405
+ ) -> float | np.ndarray:
406
+ """Get stock allocation (can be constant or per-path)."""
407
+ raise NotImplementedError
408
+
409
+
410
+ def run_simulation_with_utility(
411
+ initial_wealth: float,
412
+ spending_policy: "SpendingPolicy",
413
+ allocation_policy: "AllocationPolicy",
414
+ config: SimulationConfig,
415
+ utility_model: "UtilityModel",
416
+ spending_floor: float | None = None,
417
+ survival_probabilities: np.ndarray | None = None,
418
+ ) -> SimulationResult:
419
+ """Run simulation tracking lifetime utility.
420
+
421
+ This function extends run_simulation_with_policy to also track utility
422
+ at each time step, calculate expected lifetime utility, and compute
423
+ the certainty equivalent consumption.
424
+
425
+ Args:
426
+ initial_wealth: Starting portfolio value
427
+ spending_policy: Policy determining annual spending
428
+ allocation_policy: Policy determining asset allocation
429
+ config: Simulation configuration
430
+ utility_model: Utility model for calculating period utility
431
+ spending_floor: Minimum acceptable spending
432
+ survival_probabilities: P(alive) at each year (optional)
433
+
434
+ Returns:
435
+ SimulationResult with utility metrics populated
436
+ """
437
+ # Import here to avoid circular imports
438
+ from fundedness.models.utility import UtilityModel
439
+
440
+ n_sim = config.n_simulations
441
+ n_years = config.n_years
442
+ seed = config.random_seed
443
+
444
+ rng = np.random.default_rng(seed)
445
+
446
+ # Initialize paths
447
+ wealth_paths = np.zeros((n_sim, n_years + 1))
448
+ wealth_paths[:, 0] = initial_wealth
449
+ spending_paths = np.zeros((n_sim, n_years))
450
+ utility_paths = np.zeros((n_sim, n_years))
451
+
452
+ time_to_ruin = np.full(n_sim, np.inf)
453
+ time_to_floor_breach = np.full(n_sim, np.inf) if spending_floor else None
454
+
455
+ # Default survival probabilities (all survive)
456
+ if survival_probabilities is None:
457
+ survival_probabilities = np.ones(n_years)
458
+
459
+ # Generate all random draws upfront
460
+ z = rng.standard_normal((n_sim, n_years))
461
+
462
+ # Simulate year by year
463
+ for year in range(n_years):
464
+ current_wealth = wealth_paths[:, year]
465
+
466
+ # Get spending from policy
467
+ spending = spending_policy.get_spending(
468
+ wealth=current_wealth,
469
+ year=year,
470
+ initial_wealth=initial_wealth,
471
+ )
472
+ spending_paths[:, year] = spending
473
+
474
+ # Calculate utility for this period's consumption
475
+ for i in range(n_sim):
476
+ utility_paths[i, year] = utility_model.utility(spending[i])
477
+
478
+ # Track floor breach
479
+ if time_to_floor_breach is not None and spending_floor:
480
+ floor_breach_mask = (spending < spending_floor) & np.isinf(time_to_floor_breach)
481
+ time_to_floor_breach[floor_breach_mask] = year
482
+
483
+ # Get allocation from policy
484
+ stock_weight = allocation_policy.get_allocation(
485
+ wealth=current_wealth,
486
+ year=year,
487
+ initial_wealth=initial_wealth,
488
+ )
489
+
490
+ # Calculate returns for this allocation
491
+ portfolio_return = config.market_model.expected_portfolio_return(stock_weight)
492
+ portfolio_vol = config.market_model.portfolio_volatility(stock_weight)
493
+
494
+ returns = portfolio_return - portfolio_vol**2 / 2 + portfolio_vol * z[:, year]
495
+
496
+ # Update wealth
497
+ wealth_after_spending = np.maximum(current_wealth - spending, 0)
498
+ wealth_paths[:, year + 1] = wealth_after_spending * (1 + returns)
499
+
500
+ # Track ruin
501
+ ruin_mask = (wealth_paths[:, year + 1] <= 0) & np.isinf(time_to_ruin)
502
+ time_to_ruin[ruin_mask] = year + 1
503
+
504
+ # Calculate discounted lifetime utility for each path
505
+ discount_factors = np.array([
506
+ (1 + utility_model.time_preference) ** (-t) * survival_probabilities[t]
507
+ for t in range(n_years)
508
+ ])
509
+
510
+ # Lifetime utility per path
511
+ discounted_utilities = utility_paths * discount_factors
512
+ lifetime_utilities = np.sum(discounted_utilities, axis=1)
513
+
514
+ # Expected lifetime utility (mean across paths)
515
+ expected_lifetime_utility = np.mean(lifetime_utilities)
516
+
517
+ # Certainty equivalent consumption
518
+ # Find the constant consumption that gives same expected utility
519
+ mean_spending = np.mean(spending_paths)
520
+ ce_consumption = utility_model.certainty_equivalent(
521
+ np.mean(spending_paths, axis=1) # Average spending per path
522
+ )
523
+
524
+ # Calculate percentiles
525
+ wealth_percentiles = {}
526
+ spending_percentiles = {}
527
+ utility_percentiles = {}
528
+
529
+ for p in config.percentiles:
530
+ key = f"P{p}"
531
+ wealth_percentiles[key] = np.percentile(wealth_paths[:, 1:], p, axis=0)
532
+ spending_percentiles[key] = np.percentile(spending_paths, p, axis=0)
533
+ utility_percentiles[key] = np.percentile(utility_paths, p, axis=0)
534
+
535
+ terminal_wealth = wealth_paths[:, -1]
536
+
537
+ return SimulationResult(
538
+ wealth_paths=wealth_paths[:, 1:],
539
+ spending_paths=spending_paths,
540
+ utility_paths=utility_paths,
541
+ time_to_ruin=time_to_ruin,
542
+ time_to_floor_breach=time_to_floor_breach,
543
+ wealth_percentiles=wealth_percentiles,
544
+ spending_percentiles=spending_percentiles,
545
+ utility_percentiles=utility_percentiles,
546
+ success_rate=np.mean(np.isinf(time_to_ruin)),
547
+ floor_breach_rate=np.mean(~np.isinf(time_to_floor_breach)) if time_to_floor_breach is not None else 0.0,
548
+ median_terminal_wealth=np.median(terminal_wealth),
549
+ mean_terminal_wealth=np.mean(terminal_wealth),
550
+ expected_lifetime_utility=expected_lifetime_utility,
551
+ certainty_equivalent_consumption=ce_consumption,
552
+ n_simulations=n_sim,
553
+ n_years=n_years,
554
+ random_seed=seed,
555
+ )
556
+
557
+
558
+ # Type alias for UtilityModel (to avoid import issues)
559
+ 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
+ ]