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
@@ -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