fundedness 0.1.0__py3-none-any.whl → 0.2.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.

@@ -0,0 +1,542 @@
1
+ """Visualization components for utility optimization and optimal policies."""
2
+
3
+ import numpy as np
4
+ import plotly.graph_objects as go
5
+ from plotly.subplots import make_subplots
6
+
7
+ from fundedness.merton import (
8
+ optimal_allocation_by_wealth,
9
+ optimal_spending_by_age,
10
+ merton_optimal_allocation,
11
+ merton_optimal_spending_rate,
12
+ )
13
+ from fundedness.models.market import MarketModel
14
+ from fundedness.models.utility import UtilityModel
15
+ from fundedness.viz.colors import COLORS, get_plotly_layout_defaults
16
+
17
+
18
+ def create_optimal_allocation_curve(
19
+ market_model: MarketModel,
20
+ utility_model: UtilityModel,
21
+ max_wealth: float = 3_000_000,
22
+ n_points: int = 100,
23
+ title: str = "Optimal Equity Allocation by Wealth",
24
+ ) -> go.Figure:
25
+ """Create a chart showing optimal equity allocation vs wealth.
26
+
27
+ Shows how allocation should decrease as wealth approaches the
28
+ subsistence floor.
29
+
30
+ Args:
31
+ market_model: Market assumptions
32
+ utility_model: Utility parameters
33
+ max_wealth: Maximum wealth to show on x-axis
34
+ n_points: Number of points to plot
35
+ title: Chart title
36
+
37
+ Returns:
38
+ Plotly Figure object
39
+ """
40
+ floor = utility_model.subsistence_floor
41
+ wealth_levels = np.linspace(floor * 0.5, max_wealth, n_points)
42
+
43
+ allocations = optimal_allocation_by_wealth(
44
+ market_model=market_model,
45
+ utility_model=utility_model,
46
+ wealth_levels=wealth_levels,
47
+ )
48
+
49
+ # Unconstrained optimal
50
+ k_star = merton_optimal_allocation(market_model, utility_model)
51
+
52
+ fig = go.Figure()
53
+
54
+ # Main allocation curve
55
+ fig.add_trace(go.Scatter(
56
+ x=wealth_levels,
57
+ y=allocations * 100,
58
+ mode="lines",
59
+ name="Optimal Allocation",
60
+ line=dict(color=COLORS["wealth_primary"], width=3),
61
+ hovertemplate="Wealth: $%{x:,.0f}<br>Equity: %{y:.1f}%<extra></extra>",
62
+ ))
63
+
64
+ # Unconstrained optimal line
65
+ fig.add_trace(go.Scatter(
66
+ x=[wealth_levels[0], wealth_levels[-1]],
67
+ y=[k_star * 100, k_star * 100],
68
+ mode="lines",
69
+ name=f"Unconstrained Optimal ({k_star:.0%})",
70
+ line=dict(color=COLORS["neutral_secondary"], width=2, dash="dash"),
71
+ ))
72
+
73
+ # Floor line
74
+ fig.add_trace(go.Scatter(
75
+ x=[floor, floor],
76
+ y=[0, k_star * 100],
77
+ mode="lines",
78
+ name=f"Subsistence Floor (${floor:,.0f})",
79
+ line=dict(color=COLORS["danger_primary"], width=2, dash="dot"),
80
+ ))
81
+
82
+ # Layout
83
+ layout = get_plotly_layout_defaults()
84
+ layout.update(
85
+ title=dict(text=title),
86
+ xaxis=dict(
87
+ title="Wealth ($)",
88
+ tickformat="$,.0f",
89
+ range=[0, max_wealth],
90
+ ),
91
+ yaxis=dict(
92
+ title="Equity Allocation (%)",
93
+ range=[0, min(100, k_star * 100 + 10)],
94
+ ),
95
+ legend=dict(
96
+ orientation="h",
97
+ yanchor="bottom",
98
+ y=1.02,
99
+ xanchor="center",
100
+ x=0.5,
101
+ ),
102
+ showlegend=True,
103
+ )
104
+ fig.update_layout(**layout)
105
+
106
+ return fig
107
+
108
+
109
+ def create_optimal_spending_curve(
110
+ market_model: MarketModel,
111
+ utility_model: UtilityModel,
112
+ starting_age: int = 65,
113
+ end_age: int = 100,
114
+ title: str = "Optimal Spending Rate by Age",
115
+ show_comparison: bool = True,
116
+ ) -> go.Figure:
117
+ """Create a chart showing optimal spending rate vs age.
118
+
119
+ Shows how spending rate should increase as remaining horizon shortens.
120
+
121
+ Args:
122
+ market_model: Market assumptions
123
+ utility_model: Utility parameters
124
+ starting_age: Starting age to plot
125
+ end_age: Ending age to plot
126
+ title: Chart title
127
+ show_comparison: Whether to show 4% rule comparison
128
+
129
+ Returns:
130
+ Plotly Figure object
131
+ """
132
+ rates = optimal_spending_by_age(
133
+ market_model=market_model,
134
+ utility_model=utility_model,
135
+ starting_age=starting_age,
136
+ end_age=end_age,
137
+ )
138
+
139
+ ages = list(rates.keys())
140
+ spending_rates = [rates[age] * 100 for age in ages]
141
+
142
+ fig = go.Figure()
143
+
144
+ # Main spending curve
145
+ fig.add_trace(go.Scatter(
146
+ x=ages,
147
+ y=spending_rates,
148
+ mode="lines",
149
+ name="Merton Optimal",
150
+ line=dict(color=COLORS["success_primary"], width=3),
151
+ hovertemplate="Age: %{x}<br>Spending Rate: %{y:.1f}%<extra></extra>",
152
+ ))
153
+
154
+ # 4% rule comparison
155
+ if show_comparison:
156
+ fig.add_trace(go.Scatter(
157
+ x=ages,
158
+ y=[4.0] * len(ages),
159
+ mode="lines",
160
+ name="Fixed 4% Rule",
161
+ line=dict(color=COLORS["warning_primary"], width=2, dash="dash"),
162
+ ))
163
+
164
+ # Infinite horizon rate
165
+ infinite_rate = merton_optimal_spending_rate(market_model, utility_model) * 100
166
+ fig.add_trace(go.Scatter(
167
+ x=[starting_age, end_age],
168
+ y=[infinite_rate, infinite_rate],
169
+ mode="lines",
170
+ name=f"Infinite Horizon ({infinite_rate:.1f}%)",
171
+ line=dict(color=COLORS["neutral_secondary"], width=1, dash="dot"),
172
+ ))
173
+
174
+ layout = get_plotly_layout_defaults()
175
+ layout.update(
176
+ title=dict(text=title),
177
+ xaxis=dict(
178
+ title="Age",
179
+ range=[starting_age, end_age],
180
+ ),
181
+ yaxis=dict(
182
+ title="Spending Rate (%)",
183
+ range=[0, max(15, max(spending_rates) + 2)],
184
+ ),
185
+ legend=dict(
186
+ orientation="h",
187
+ yanchor="bottom",
188
+ y=1.02,
189
+ xanchor="center",
190
+ x=0.5,
191
+ ),
192
+ )
193
+ fig.update_layout(**layout)
194
+
195
+ return fig
196
+
197
+
198
+ def create_utility_comparison_chart(
199
+ strategy_names: list[str],
200
+ expected_utilities: list[float],
201
+ certainty_equivalents: list[float],
202
+ title: str = "Utility Comparison Across Strategies",
203
+ ) -> go.Figure:
204
+ """Create a comparison chart of utility metrics across strategies.
205
+
206
+ Shows both expected lifetime utility and certainty equivalent
207
+ consumption for each strategy.
208
+
209
+ Args:
210
+ strategy_names: Names of strategies being compared
211
+ expected_utilities: Expected lifetime utility for each strategy
212
+ certainty_equivalents: CE consumption for each strategy
213
+ title: Chart title
214
+
215
+ Returns:
216
+ Plotly Figure object
217
+ """
218
+ fig = make_subplots(
219
+ rows=1,
220
+ cols=2,
221
+ subplot_titles=("Expected Lifetime Utility", "Certainty Equivalent Consumption"),
222
+ horizontal_spacing=0.15,
223
+ )
224
+
225
+ # Normalize utilities for display (shift to positive)
226
+ min_utility = min(expected_utilities)
227
+ display_utilities = [u - min_utility + 1 for u in expected_utilities]
228
+
229
+ # Utility bar chart
230
+ fig.add_trace(
231
+ go.Bar(
232
+ x=strategy_names,
233
+ y=display_utilities,
234
+ marker_color=COLORS["wealth_primary"],
235
+ text=[f"{u:.2e}" for u in expected_utilities],
236
+ textposition="outside",
237
+ hovertemplate="%{x}<br>Utility: %{text}<extra></extra>",
238
+ showlegend=False,
239
+ ),
240
+ row=1,
241
+ col=1,
242
+ )
243
+
244
+ # CE consumption bar chart
245
+ fig.add_trace(
246
+ go.Bar(
247
+ x=strategy_names,
248
+ y=certainty_equivalents,
249
+ marker_color=COLORS["success_primary"],
250
+ text=[f"${ce:,.0f}" for ce in certainty_equivalents],
251
+ textposition="outside",
252
+ hovertemplate="%{x}<br>CE: %{text}<extra></extra>",
253
+ showlegend=False,
254
+ ),
255
+ row=1,
256
+ col=2,
257
+ )
258
+
259
+ layout = get_plotly_layout_defaults()
260
+ layout.update(
261
+ title=dict(text=title),
262
+ height=400,
263
+ )
264
+ fig.update_layout(**layout)
265
+
266
+ fig.update_yaxes(title_text="Utility (shifted)", row=1, col=1)
267
+ fig.update_yaxes(title_text="CE Consumption ($)", tickformat="$,.0f", row=1, col=2)
268
+
269
+ return fig
270
+
271
+
272
+ def create_optimal_policy_summary(
273
+ market_model: MarketModel,
274
+ utility_model: UtilityModel,
275
+ wealth: float,
276
+ starting_age: int = 65,
277
+ end_age: int = 100,
278
+ title: str = "Optimal Policy Summary",
279
+ ) -> go.Figure:
280
+ """Create a summary chart showing key optimal policy values.
281
+
282
+ Displays optimal allocation, spending rate, and other key metrics
283
+ in a combined visualization.
284
+
285
+ Args:
286
+ market_model: Market assumptions
287
+ utility_model: Utility parameters
288
+ wealth: Current wealth level
289
+ starting_age: Current age
290
+ end_age: Planning horizon end age
291
+ title: Chart title
292
+
293
+ Returns:
294
+ Plotly Figure object
295
+ """
296
+ from fundedness.merton import calculate_merton_optimal
297
+
298
+ remaining_years = end_age - starting_age
299
+ result = calculate_merton_optimal(
300
+ wealth=wealth,
301
+ market_model=market_model,
302
+ utility_model=utility_model,
303
+ remaining_years=remaining_years,
304
+ )
305
+
306
+ fig = make_subplots(
307
+ rows=2,
308
+ cols=2,
309
+ specs=[[{"type": "indicator"}, {"type": "indicator"}],
310
+ [{"type": "indicator"}, {"type": "indicator"}]],
311
+ subplot_titles=(
312
+ "Optimal Equity Allocation",
313
+ "Wealth-Adjusted Allocation",
314
+ "Optimal Spending Rate",
315
+ "Certainty Equivalent Return",
316
+ ),
317
+ )
318
+
319
+ # Optimal allocation gauge
320
+ fig.add_trace(
321
+ go.Indicator(
322
+ mode="gauge+number",
323
+ value=result.optimal_equity_allocation * 100,
324
+ number={"suffix": "%"},
325
+ gauge=dict(
326
+ axis=dict(range=[0, 100]),
327
+ bar=dict(color=COLORS["wealth_primary"]),
328
+ steps=[
329
+ {"range": [0, 40], "color": COLORS["success_light"]},
330
+ {"range": [40, 70], "color": COLORS["warning_light"]},
331
+ {"range": [70, 100], "color": COLORS["danger_light"]},
332
+ ],
333
+ ),
334
+ ),
335
+ row=1,
336
+ col=1,
337
+ )
338
+
339
+ # Wealth-adjusted allocation gauge
340
+ fig.add_trace(
341
+ go.Indicator(
342
+ mode="gauge+number",
343
+ value=result.wealth_adjusted_allocation * 100,
344
+ number={"suffix": "%"},
345
+ gauge=dict(
346
+ axis=dict(range=[0, 100]),
347
+ bar=dict(color=COLORS["accent_primary"]),
348
+ steps=[
349
+ {"range": [0, 40], "color": COLORS["success_light"]},
350
+ {"range": [40, 70], "color": COLORS["warning_light"]},
351
+ {"range": [70, 100], "color": COLORS["danger_light"]},
352
+ ],
353
+ ),
354
+ ),
355
+ row=1,
356
+ col=2,
357
+ )
358
+
359
+ # Spending rate gauge
360
+ fig.add_trace(
361
+ go.Indicator(
362
+ mode="gauge+number",
363
+ value=result.optimal_spending_rate * 100,
364
+ number={"suffix": "%"},
365
+ gauge=dict(
366
+ axis=dict(range=[0, 15]),
367
+ bar=dict(color=COLORS["success_primary"]),
368
+ steps=[
369
+ {"range": [0, 4], "color": COLORS["success_light"]},
370
+ {"range": [4, 7], "color": COLORS["warning_light"]},
371
+ {"range": [7, 15], "color": COLORS["danger_light"]},
372
+ ],
373
+ ),
374
+ ),
375
+ row=2,
376
+ col=1,
377
+ )
378
+
379
+ # CE return gauge
380
+ fig.add_trace(
381
+ go.Indicator(
382
+ mode="gauge+number",
383
+ value=result.certainty_equivalent_return * 100,
384
+ number={"suffix": "%"},
385
+ gauge=dict(
386
+ axis=dict(range=[0, 8]),
387
+ bar=dict(color=COLORS["neutral_primary"]),
388
+ steps=[
389
+ {"range": [0, 2], "color": COLORS["danger_light"]},
390
+ {"range": [2, 4], "color": COLORS["warning_light"]},
391
+ {"range": [4, 8], "color": COLORS["success_light"]},
392
+ ],
393
+ ),
394
+ ),
395
+ row=2,
396
+ col=2,
397
+ )
398
+
399
+ layout = get_plotly_layout_defaults()
400
+ layout.update(
401
+ title=dict(text=title),
402
+ height=500,
403
+ )
404
+ fig.update_layout(**layout)
405
+
406
+ return fig
407
+
408
+
409
+ def create_spending_comparison_by_age(
410
+ market_model: MarketModel,
411
+ utility_model: UtilityModel,
412
+ initial_wealth: float,
413
+ starting_age: int = 65,
414
+ end_age: int = 95,
415
+ swr_rate: float = 0.04,
416
+ title: str = "Spending Comparison: Merton Optimal vs 4% Rule",
417
+ ) -> go.Figure:
418
+ """Compare dollar spending between Merton optimal and fixed SWR.
419
+
420
+ Shows how absolute spending differs over time, not just rates.
421
+
422
+ Args:
423
+ market_model: Market assumptions
424
+ utility_model: Utility parameters
425
+ initial_wealth: Starting portfolio value
426
+ starting_age: Starting age
427
+ end_age: Ending age
428
+ swr_rate: Fixed SWR rate for comparison
429
+ title: Chart title
430
+
431
+ Returns:
432
+ Plotly Figure object
433
+ """
434
+ ages = list(range(starting_age, end_age + 1))
435
+
436
+ # Merton optimal spending (assuming constant wealth for illustration)
437
+ rates = optimal_spending_by_age(market_model, utility_model, starting_age, end_age)
438
+ merton_spending = [initial_wealth * rates[age] for age in ages]
439
+
440
+ # Fixed SWR spending (grows with inflation estimate)
441
+ inflation = market_model.inflation_mean
442
+ swr_spending = [
443
+ initial_wealth * swr_rate * (1 + inflation) ** (age - starting_age)
444
+ for age in ages
445
+ ]
446
+
447
+ fig = go.Figure()
448
+
449
+ fig.add_trace(go.Scatter(
450
+ x=ages,
451
+ y=merton_spending,
452
+ mode="lines",
453
+ name="Merton Optimal",
454
+ line=dict(color=COLORS["success_primary"], width=3),
455
+ fill="tozeroy",
456
+ fillcolor="rgba(39, 174, 96, 0.2)",
457
+ hovertemplate="Age %{x}<br>$%{y:,.0f}/year<extra></extra>",
458
+ ))
459
+
460
+ fig.add_trace(go.Scatter(
461
+ x=ages,
462
+ y=swr_spending,
463
+ mode="lines",
464
+ name=f"Fixed {swr_rate:.0%} SWR",
465
+ line=dict(color=COLORS["warning_primary"], width=3, dash="dash"),
466
+ hovertemplate="Age %{x}<br>$%{y:,.0f}/year<extra></extra>",
467
+ ))
468
+
469
+ layout = get_plotly_layout_defaults()
470
+ layout.update(
471
+ title=dict(text=title),
472
+ xaxis=dict(title="Age"),
473
+ yaxis=dict(title="Annual Spending ($)", tickformat="$,.0f"),
474
+ legend=dict(
475
+ orientation="h",
476
+ yanchor="bottom",
477
+ y=1.02,
478
+ xanchor="center",
479
+ x=0.5,
480
+ ),
481
+ )
482
+ fig.update_layout(**layout)
483
+
484
+ return fig
485
+
486
+
487
+ def create_sensitivity_heatmap(
488
+ market_model: MarketModel,
489
+ gamma_range: tuple[float, float] = (1.5, 5.0),
490
+ rtp_range: tuple[float, float] = (0.01, 0.05),
491
+ n_points: int = 20,
492
+ metric: str = "spending_rate",
493
+ title: str = "Sensitivity: Optimal Spending Rate",
494
+ ) -> go.Figure:
495
+ """Create a heatmap showing sensitivity to gamma and time preference.
496
+
497
+ Args:
498
+ market_model: Market assumptions
499
+ gamma_range: Range of risk aversion values
500
+ rtp_range: Range of time preference values
501
+ n_points: Grid resolution
502
+ metric: "spending_rate" or "allocation"
503
+ title: Chart title
504
+
505
+ Returns:
506
+ Plotly Figure object
507
+ """
508
+ gammas = np.linspace(gamma_range[0], gamma_range[1], n_points)
509
+ rtps = np.linspace(rtp_range[0], rtp_range[1], n_points)
510
+
511
+ values = np.zeros((n_points, n_points))
512
+
513
+ for i, gamma in enumerate(gammas):
514
+ for j, rtp in enumerate(rtps):
515
+ utility_model = UtilityModel(gamma=gamma, time_preference=rtp)
516
+ if metric == "spending_rate":
517
+ values[i, j] = merton_optimal_spending_rate(market_model, utility_model) * 100
518
+ else:
519
+ values[i, j] = merton_optimal_allocation(market_model, utility_model) * 100
520
+
521
+ fig = go.Figure(data=go.Heatmap(
522
+ z=values,
523
+ x=rtps * 100,
524
+ y=gammas,
525
+ colorscale="Viridis",
526
+ colorbar=dict(title="%" if metric == "spending_rate" else "Equity %"),
527
+ hovertemplate=(
528
+ "Time Pref: %{x:.1f}%<br>"
529
+ "Gamma: %{y:.1f}<br>"
530
+ "Value: %{z:.1f}%<extra></extra>"
531
+ ),
532
+ ))
533
+
534
+ layout = get_plotly_layout_defaults()
535
+ layout.update(
536
+ title=dict(text=title),
537
+ xaxis=dict(title="Time Preference (%)"),
538
+ yaxis=dict(title="Risk Aversion (gamma)"),
539
+ )
540
+ fig.update_layout(**layout)
541
+
542
+ return fig
@@ -4,14 +4,22 @@ from fundedness.withdrawals.base import WithdrawalContext, WithdrawalDecision, W
4
4
  from fundedness.withdrawals.comparison import compare_strategies
5
5
  from fundedness.withdrawals.fixed_swr import FixedRealSWRPolicy
6
6
  from fundedness.withdrawals.guardrails import GuardrailsPolicy
7
+ from fundedness.withdrawals.merton_optimal import (
8
+ FloorAdjustedMertonPolicy,
9
+ MertonOptimalSpendingPolicy,
10
+ SmoothedMertonPolicy,
11
+ )
7
12
  from fundedness.withdrawals.rmd_style import RMDStylePolicy
8
13
  from fundedness.withdrawals.vpw import VPWPolicy
9
14
 
10
15
  __all__ = [
11
16
  "compare_strategies",
12
17
  "FixedRealSWRPolicy",
18
+ "FloorAdjustedMertonPolicy",
13
19
  "GuardrailsPolicy",
20
+ "MertonOptimalSpendingPolicy",
14
21
  "RMDStylePolicy",
22
+ "SmoothedMertonPolicy",
15
23
  "VPWPolicy",
16
24
  "WithdrawalContext",
17
25
  "WithdrawalDecision",
@@ -65,6 +65,38 @@ class FixedRealSWRPolicy(BaseWithdrawalPolicy):
65
65
  notes=f"Year {context.year}: base ${base_amount:,.0f} × {inflation_factor:.3f} inflation",
66
66
  )
67
67
 
68
+ def get_spending(
69
+ self,
70
+ wealth: np.ndarray,
71
+ year: int,
72
+ initial_wealth: float,
73
+ ) -> np.ndarray:
74
+ """Get spending for simulation (vectorized interface).
75
+
76
+ Args:
77
+ wealth: Current portfolio values (n_simulations,)
78
+ year: Current simulation year
79
+ initial_wealth: Starting portfolio value
80
+
81
+ Returns:
82
+ Spending amounts for each simulation path
83
+ """
84
+ # Base withdrawal from initial wealth
85
+ base_amount = initial_wealth * self.withdrawal_rate
86
+
87
+ # Adjust for cumulative inflation
88
+ inflation_factor = (1 + self.inflation_rate) ** year
89
+ spending = np.full_like(wealth, base_amount * inflation_factor)
90
+
91
+ # Apply floor if set
92
+ if self.floor_spending is not None:
93
+ spending = np.maximum(spending, self.floor_spending)
94
+
95
+ # Can't spend more than we have
96
+ spending = np.minimum(spending, np.maximum(wealth, 0))
97
+
98
+ return spending
99
+
68
100
 
69
101
  @dataclass
70
102
  class PercentOfPortfolioPolicy(BaseWithdrawalPolicy):
@@ -74,6 +106,7 @@ class PercentOfPortfolioPolicy(BaseWithdrawalPolicy):
74
106
  """
75
107
 
76
108
  withdrawal_rate: float = 0.04
109
+ floor: float | None = None
77
110
 
78
111
  @property
79
112
  def name(self) -> str:
@@ -111,3 +144,31 @@ class PercentOfPortfolioPolicy(BaseWithdrawalPolicy):
111
144
  is_floor_breach=is_floor_breach,
112
145
  is_ceiling_hit=is_ceiling_hit,
113
146
  )
147
+
148
+ def get_spending(
149
+ self,
150
+ wealth: np.ndarray,
151
+ year: int,
152
+ initial_wealth: float,
153
+ ) -> np.ndarray:
154
+ """Get spending for simulation (vectorized interface).
155
+
156
+ Args:
157
+ wealth: Current portfolio values (n_simulations,)
158
+ year: Current simulation year
159
+ initial_wealth: Starting portfolio value
160
+
161
+ Returns:
162
+ Spending amounts for each simulation path
163
+ """
164
+ spending = wealth * self.withdrawal_rate
165
+
166
+ # Apply floor if set
167
+ floor = self.floor or self.floor_spending
168
+ if floor is not None:
169
+ spending = np.maximum(spending, floor)
170
+
171
+ # Can't spend more than we have
172
+ spending = np.minimum(spending, np.maximum(wealth, 0))
173
+
174
+ return spending