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.
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 +199 -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 +595 -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.4.dist-info/METADATA +300 -0
  41. fundedness-0.2.4.dist-info/RECORD +43 -0
  42. fundedness-0.2.4.dist-info/WHEEL +4 -0
  43. 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
+ ]