fundedness 0.1.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.

Potentially problematic release.


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

fundedness/simulate.py ADDED
@@ -0,0 +1,401 @@
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
+ # Configuration
35
+ n_simulations: int = 0
36
+ n_years: int = 0
37
+ random_seed: int | None = None
38
+
39
+ def get_survival_probability(self) -> np.ndarray:
40
+ """Calculate survival probability at each year.
41
+
42
+ Returns:
43
+ Array of shape (n_years,) with P(not ruined) at each year
44
+ """
45
+ if self.time_to_ruin is None:
46
+ return np.ones(self.n_years)
47
+
48
+ survival = np.zeros(self.n_years)
49
+ for year in range(self.n_years):
50
+ survival[year] = np.mean(self.time_to_ruin > year)
51
+ return survival
52
+
53
+ def get_floor_survival_probability(self) -> np.ndarray:
54
+ """Calculate probability of being above spending floor at each year.
55
+
56
+ Returns:
57
+ Array of shape (n_years,) with P(above floor) at each year
58
+ """
59
+ if self.time_to_floor_breach is None:
60
+ return np.ones(self.n_years)
61
+
62
+ survival = np.zeros(self.n_years)
63
+ for year in range(self.n_years):
64
+ survival[year] = np.mean(self.time_to_floor_breach > year)
65
+ return survival
66
+
67
+ def get_percentile(self, percentile: int, metric: str = "wealth") -> np.ndarray:
68
+ """Get a specific percentile path.
69
+
70
+ Args:
71
+ percentile: Percentile value (0-100)
72
+ metric: "wealth" or "spending"
73
+
74
+ Returns:
75
+ Array of shape (n_years,) with percentile values
76
+ """
77
+ key = f"P{percentile}"
78
+ if metric == "wealth":
79
+ return self.wealth_percentiles.get(key, np.zeros(self.n_years))
80
+ else:
81
+ return self.spending_percentiles.get(key, np.zeros(self.n_years))
82
+
83
+
84
+ def generate_returns(
85
+ n_simulations: int,
86
+ n_years: int,
87
+ market_model: MarketModel,
88
+ stock_weight: float,
89
+ bond_weight: float | None = None,
90
+ random_seed: int | None = None,
91
+ ) -> np.ndarray:
92
+ """Generate correlated portfolio returns.
93
+
94
+ Args:
95
+ n_simulations: Number of simulation paths
96
+ n_years: Number of years to simulate
97
+ market_model: Market assumptions
98
+ stock_weight: Portfolio weight in stocks
99
+ bond_weight: Portfolio weight in bonds (rest is cash if None)
100
+ random_seed: Random seed for reproducibility
101
+
102
+ Returns:
103
+ Array of shape (n_simulations, n_years) with portfolio returns
104
+ """
105
+ rng = np.random.default_rng(random_seed)
106
+
107
+ if bond_weight is None:
108
+ bond_weight = 1 - stock_weight
109
+ cash_weight = max(0, 1 - stock_weight - bond_weight)
110
+
111
+ # Portfolio expected return and volatility
112
+ portfolio_return = market_model.expected_portfolio_return(stock_weight, bond_weight)
113
+ portfolio_vol = market_model.portfolio_volatility(stock_weight, bond_weight)
114
+
115
+ # Generate returns
116
+ if market_model.use_fat_tails:
117
+ # Use t-distribution for fatter tails
118
+ z = stats.t.rvs(
119
+ df=market_model.degrees_of_freedom,
120
+ size=(n_simulations, n_years),
121
+ random_state=rng,
122
+ )
123
+ # Scale t-distribution to have unit variance
124
+ scale_factor = np.sqrt(market_model.degrees_of_freedom / (market_model.degrees_of_freedom - 2))
125
+ z = z / scale_factor
126
+ else:
127
+ # Standard normal
128
+ z = rng.standard_normal((n_simulations, n_years))
129
+
130
+ # Convert to returns (log-normal model)
131
+ # r = μ - σ²/2 + σ*z (continuous compounding adjustment)
132
+ returns = portfolio_return - portfolio_vol**2 / 2 + portfolio_vol * z
133
+
134
+ return returns
135
+
136
+
137
+ def run_simulation(
138
+ initial_wealth: float,
139
+ annual_spending: float | np.ndarray,
140
+ config: SimulationConfig,
141
+ stock_weight: float | np.ndarray = 0.6,
142
+ spending_floor: float | None = None,
143
+ inflation_rate: float = 0.025,
144
+ ) -> SimulationResult:
145
+ """Run Monte Carlo simulation of retirement portfolio.
146
+
147
+ Args:
148
+ initial_wealth: Starting portfolio value
149
+ annual_spending: Annual spending (constant or array by year)
150
+ config: Simulation configuration
151
+ stock_weight: Allocation to stocks (constant or array by year)
152
+ spending_floor: Minimum acceptable spending (for floor breach tracking)
153
+ inflation_rate: Annual inflation rate for real spending
154
+
155
+ Returns:
156
+ SimulationResult with all paths and metrics
157
+ """
158
+ n_sim = config.n_simulations
159
+ n_years = config.n_years
160
+ seed = config.random_seed
161
+
162
+ # Handle spending as array
163
+ if isinstance(annual_spending, (int, float)):
164
+ spending_schedule = np.full(n_years, annual_spending)
165
+ else:
166
+ spending_schedule = np.array(annual_spending)[:n_years]
167
+ if len(spending_schedule) < n_years:
168
+ # Extend with last value
169
+ spending_schedule = np.pad(
170
+ spending_schedule,
171
+ (0, n_years - len(spending_schedule)),
172
+ mode="edge",
173
+ )
174
+
175
+ # Handle stock weight as array
176
+ if isinstance(stock_weight, (int, float)):
177
+ stock_weights = np.full(n_years, stock_weight)
178
+ else:
179
+ stock_weights = np.array(stock_weight)[:n_years]
180
+ if len(stock_weights) < n_years:
181
+ stock_weights = np.pad(
182
+ stock_weights,
183
+ (0, n_years - len(stock_weights)),
184
+ mode="edge",
185
+ )
186
+
187
+ # Generate returns for each year's allocation
188
+ # For simplicity, use average allocation for return generation
189
+ avg_stock_weight = np.mean(stock_weights)
190
+ returns = generate_returns(
191
+ n_simulations=n_sim,
192
+ n_years=n_years,
193
+ market_model=config.market_model,
194
+ stock_weight=avg_stock_weight,
195
+ random_seed=seed,
196
+ )
197
+
198
+ # Initialize paths
199
+ wealth_paths = np.zeros((n_sim, n_years + 1))
200
+ wealth_paths[:, 0] = initial_wealth
201
+
202
+ spending_paths = np.zeros((n_sim, n_years)) if config.track_spending else None
203
+
204
+ time_to_ruin = np.full(n_sim, np.inf)
205
+ time_to_floor_breach = np.full(n_sim, np.inf) if spending_floor else None
206
+
207
+ # Simulate year by year
208
+ for year in range(n_years):
209
+ # Current wealth
210
+ current_wealth = wealth_paths[:, year]
211
+
212
+ # Spending (adjusted for inflation)
213
+ real_spending = spending_schedule[year]
214
+ nominal_spending = real_spending * (1 + inflation_rate) ** year
215
+
216
+ # Actual spending (can't spend more than we have)
217
+ actual_spending = np.minimum(nominal_spending, np.maximum(current_wealth, 0))
218
+
219
+ if spending_paths is not None:
220
+ spending_paths[:, year] = actual_spending
221
+
222
+ # Track floor breach
223
+ if time_to_floor_breach is not None and spending_floor:
224
+ floor_breach_mask = (actual_spending < spending_floor * (1 + inflation_rate) ** year)
225
+ floor_breach_mask &= np.isinf(time_to_floor_breach)
226
+ time_to_floor_breach[floor_breach_mask] = year
227
+
228
+ # Wealth after spending
229
+ wealth_after_spending = current_wealth - actual_spending
230
+
231
+ # Apply returns (only on positive wealth)
232
+ wealth_with_returns = wealth_after_spending * (1 + returns[:, year])
233
+ wealth_with_returns = np.maximum(wealth_with_returns, 0) # Can't go negative
234
+
235
+ wealth_paths[:, year + 1] = wealth_with_returns
236
+
237
+ # Track ruin (wealth hits zero)
238
+ ruin_mask = (wealth_paths[:, year + 1] <= 0) & np.isinf(time_to_ruin)
239
+ time_to_ruin[ruin_mask] = year + 1
240
+
241
+ # Calculate percentiles
242
+ wealth_percentiles = {}
243
+ spending_percentiles = {}
244
+
245
+ for p in config.percentiles:
246
+ key = f"P{p}"
247
+ wealth_percentiles[key] = np.percentile(wealth_paths[:, 1:], p, axis=0)
248
+ if spending_paths is not None:
249
+ spending_percentiles[key] = np.percentile(spending_paths, p, axis=0)
250
+
251
+ # Aggregate metrics
252
+ terminal_wealth = wealth_paths[:, -1]
253
+ success_rate = np.mean(np.isinf(time_to_ruin))
254
+ floor_breach_rate = 0.0
255
+ if time_to_floor_breach is not None:
256
+ floor_breach_rate = np.mean(~np.isinf(time_to_floor_breach))
257
+
258
+ return SimulationResult(
259
+ wealth_paths=wealth_paths[:, 1:], # Exclude initial wealth
260
+ spending_paths=spending_paths,
261
+ time_to_ruin=time_to_ruin,
262
+ time_to_floor_breach=time_to_floor_breach,
263
+ wealth_percentiles=wealth_percentiles,
264
+ spending_percentiles=spending_percentiles,
265
+ success_rate=success_rate,
266
+ floor_breach_rate=floor_breach_rate,
267
+ median_terminal_wealth=np.median(terminal_wealth),
268
+ mean_terminal_wealth=np.mean(terminal_wealth),
269
+ n_simulations=n_sim,
270
+ n_years=n_years,
271
+ random_seed=seed,
272
+ )
273
+
274
+
275
+ def run_simulation_with_policy(
276
+ initial_wealth: float,
277
+ spending_policy: "SpendingPolicy",
278
+ allocation_policy: "AllocationPolicy",
279
+ config: SimulationConfig,
280
+ spending_floor: float | None = None,
281
+ ) -> SimulationResult:
282
+ """Run simulation with dynamic spending and allocation policies.
283
+
284
+ Args:
285
+ initial_wealth: Starting portfolio value
286
+ spending_policy: Policy determining annual spending
287
+ allocation_policy: Policy determining asset allocation
288
+ config: Simulation configuration
289
+ spending_floor: Minimum acceptable spending
290
+
291
+ Returns:
292
+ SimulationResult with all paths and metrics
293
+ """
294
+ n_sim = config.n_simulations
295
+ n_years = config.n_years
296
+ seed = config.random_seed
297
+
298
+ rng = np.random.default_rng(seed)
299
+
300
+ # Initialize paths
301
+ wealth_paths = np.zeros((n_sim, n_years + 1))
302
+ wealth_paths[:, 0] = initial_wealth
303
+ spending_paths = np.zeros((n_sim, n_years))
304
+
305
+ time_to_ruin = np.full(n_sim, np.inf)
306
+ time_to_floor_breach = np.full(n_sim, np.inf) if spending_floor else None
307
+
308
+ # Generate all random draws upfront
309
+ z = rng.standard_normal((n_sim, n_years))
310
+
311
+ # Simulate year by year
312
+ for year in range(n_years):
313
+ current_wealth = wealth_paths[:, year]
314
+
315
+ # Get spending from policy (vectorized)
316
+ spending = spending_policy.get_spending(
317
+ wealth=current_wealth,
318
+ year=year,
319
+ initial_wealth=initial_wealth,
320
+ )
321
+ spending_paths[:, year] = spending
322
+
323
+ # Track floor breach
324
+ if time_to_floor_breach is not None and spending_floor:
325
+ floor_breach_mask = (spending < spending_floor) & np.isinf(time_to_floor_breach)
326
+ time_to_floor_breach[floor_breach_mask] = year
327
+
328
+ # Get allocation from policy
329
+ stock_weight = allocation_policy.get_allocation(
330
+ wealth=current_wealth,
331
+ year=year,
332
+ initial_wealth=initial_wealth,
333
+ )
334
+
335
+ # Calculate returns for this allocation
336
+ portfolio_return = config.market_model.expected_portfolio_return(stock_weight)
337
+ portfolio_vol = config.market_model.portfolio_volatility(stock_weight)
338
+
339
+ returns = portfolio_return - portfolio_vol**2 / 2 + portfolio_vol * z[:, year]
340
+
341
+ # Update wealth
342
+ wealth_after_spending = np.maximum(current_wealth - spending, 0)
343
+ wealth_paths[:, year + 1] = wealth_after_spending * (1 + returns)
344
+
345
+ # Track ruin
346
+ ruin_mask = (wealth_paths[:, year + 1] <= 0) & np.isinf(time_to_ruin)
347
+ time_to_ruin[ruin_mask] = year + 1
348
+
349
+ # Calculate percentiles
350
+ wealth_percentiles = {}
351
+ spending_percentiles = {}
352
+
353
+ for p in config.percentiles:
354
+ key = f"P{p}"
355
+ wealth_percentiles[key] = np.percentile(wealth_paths[:, 1:], p, axis=0)
356
+ spending_percentiles[key] = np.percentile(spending_paths, p, axis=0)
357
+
358
+ terminal_wealth = wealth_paths[:, -1]
359
+
360
+ return SimulationResult(
361
+ wealth_paths=wealth_paths[:, 1:],
362
+ spending_paths=spending_paths,
363
+ time_to_ruin=time_to_ruin,
364
+ time_to_floor_breach=time_to_floor_breach,
365
+ wealth_percentiles=wealth_percentiles,
366
+ spending_percentiles=spending_percentiles,
367
+ success_rate=np.mean(np.isinf(time_to_ruin)),
368
+ floor_breach_rate=np.mean(~np.isinf(time_to_floor_breach)) if time_to_floor_breach is not None else 0.0,
369
+ median_terminal_wealth=np.median(terminal_wealth),
370
+ mean_terminal_wealth=np.mean(terminal_wealth),
371
+ n_simulations=n_sim,
372
+ n_years=n_years,
373
+ random_seed=seed,
374
+ )
375
+
376
+
377
+ # Type hints for policies (avoid circular imports)
378
+ class SpendingPolicy:
379
+ """Protocol for spending policies."""
380
+
381
+ def get_spending(
382
+ self,
383
+ wealth: np.ndarray,
384
+ year: int,
385
+ initial_wealth: float,
386
+ ) -> np.ndarray:
387
+ """Get spending amount for each simulation path."""
388
+ raise NotImplementedError
389
+
390
+
391
+ class AllocationPolicy:
392
+ """Protocol for allocation policies."""
393
+
394
+ def get_allocation(
395
+ self,
396
+ wealth: np.ndarray,
397
+ year: int,
398
+ initial_wealth: float,
399
+ ) -> float | np.ndarray:
400
+ """Get stock allocation (can be constant or per-path)."""
401
+ raise NotImplementedError
@@ -0,0 +1,19 @@
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.survival import create_survival_curve
8
+ from fundedness.viz.tornado import create_tornado_chart
9
+ from fundedness.viz.waterfall import create_cefr_waterfall
10
+
11
+ __all__ = [
12
+ "COLORS",
13
+ "create_cefr_waterfall",
14
+ "create_fan_chart",
15
+ "create_strategy_comparison_chart",
16
+ "create_survival_curve",
17
+ "create_time_distribution_histogram",
18
+ "create_tornado_chart",
19
+ ]
@@ -0,0 +1,110 @@
1
+ """Color palette and styling constants for visualizations."""
2
+
3
+ # Primary colors
4
+ COLORS = {
5
+ # Blues for wealth/assets
6
+ "wealth_primary": "#3498db",
7
+ "wealth_secondary": "#2980b9",
8
+ "wealth_light": "#5dade2",
9
+ "wealth_dark": "#1a5276",
10
+
11
+ # Greens for spending/survival/positive outcomes
12
+ "success_primary": "#27ae60",
13
+ "success_secondary": "#2ecc71",
14
+ "success_light": "#58d68d",
15
+ "success_dark": "#1e8449",
16
+
17
+ # Reds for haircuts/negative outcomes
18
+ "danger_primary": "#e74c3c",
19
+ "danger_secondary": "#c0392b",
20
+ "danger_light": "#ec7063",
21
+ "danger_dark": "#922b21",
22
+
23
+ # Oranges for warnings/caution
24
+ "warning_primary": "#f39c12",
25
+ "warning_secondary": "#e67e22",
26
+ "warning_light": "#f5b041",
27
+ "warning_dark": "#b9770e",
28
+
29
+ # Purples for alternatives/special
30
+ "accent_primary": "#9b59b6",
31
+ "accent_secondary": "#8e44ad",
32
+ "accent_light": "#bb8fce",
33
+ "accent_dark": "#6c3483",
34
+
35
+ # Grays for neutral elements
36
+ "neutral_primary": "#7f8c8d",
37
+ "neutral_secondary": "#95a5a6",
38
+ "neutral_light": "#bdc3c7",
39
+ "neutral_dark": "#5d6d7e",
40
+
41
+ # Background colors
42
+ "background": "#ffffff",
43
+ "background_alt": "#f8f9fa",
44
+
45
+ # Text colors
46
+ "text_primary": "#2c3e50",
47
+ "text_secondary": "#7f8c8d",
48
+ }
49
+
50
+ # Fan chart percentile colors (gradient from optimistic to pessimistic)
51
+ FAN_CHART_COLORS = {
52
+ "P90": "rgba(46, 204, 113, 0.3)", # Light green (optimistic)
53
+ "P75": "rgba(46, 204, 113, 0.4)",
54
+ "P50": "rgba(52, 152, 219, 0.8)", # Solid blue (median)
55
+ "P25": "rgba(231, 76, 60, 0.4)",
56
+ "P10": "rgba(231, 76, 60, 0.3)", # Light red (pessimistic)
57
+ }
58
+
59
+ # Waterfall chart colors
60
+ WATERFALL_COLORS = {
61
+ "increase": COLORS["success_primary"],
62
+ "decrease": COLORS["danger_primary"],
63
+ "total": COLORS["wealth_primary"],
64
+ }
65
+
66
+ # Strategy comparison colors (for withdrawal lab)
67
+ STRATEGY_COLORS = [
68
+ COLORS["wealth_primary"],
69
+ COLORS["success_primary"],
70
+ COLORS["accent_primary"],
71
+ COLORS["warning_primary"],
72
+ COLORS["danger_primary"],
73
+ ]
74
+
75
+ # Default Plotly template settings
76
+ TEMPLATE_SETTINGS = {
77
+ "template": "plotly_white",
78
+ "font_family": "Inter, system-ui, -apple-system, sans-serif",
79
+ "title_font_size": 18,
80
+ "axis_font_size": 12,
81
+ "legend_font_size": 11,
82
+ }
83
+
84
+
85
+ def get_plotly_layout_defaults() -> dict:
86
+ """Get default layout settings for consistent styling."""
87
+ return {
88
+ "template": TEMPLATE_SETTINGS["template"],
89
+ "font": {
90
+ "family": TEMPLATE_SETTINGS["font_family"],
91
+ "size": TEMPLATE_SETTINGS["axis_font_size"],
92
+ "color": COLORS["text_primary"],
93
+ },
94
+ "title": {
95
+ "font": {
96
+ "size": TEMPLATE_SETTINGS["title_font_size"],
97
+ "color": COLORS["text_primary"],
98
+ },
99
+ "x": 0.5,
100
+ "xanchor": "center",
101
+ },
102
+ "paper_bgcolor": COLORS["background"],
103
+ "plot_bgcolor": COLORS["background"],
104
+ "margin": {"l": 60, "r": 40, "t": 60, "b": 60},
105
+ "hoverlabel": {
106
+ "bgcolor": COLORS["background"],
107
+ "font_size": 12,
108
+ "font_family": TEMPLATE_SETTINGS["font_family"],
109
+ },
110
+ }