fundedness 0.1.0__py3-none-any.whl → 0.2.1__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/__init__.py +34 -1
- fundedness/allocation/__init__.py +8 -0
- fundedness/allocation/merton_optimal.py +220 -0
- fundedness/merton.py +289 -0
- fundedness/optimize.py +473 -0
- fundedness/simulate.py +158 -0
- fundedness/viz/__init__.py +14 -0
- fundedness/viz/optimal.py +542 -0
- fundedness/withdrawals/__init__.py +8 -0
- fundedness/withdrawals/fixed_swr.py +61 -0
- fundedness/withdrawals/merton_optimal.py +286 -0
- {fundedness-0.1.0.dist-info → fundedness-0.2.1.dist-info}/METADATA +41 -9
- {fundedness-0.1.0.dist-info → fundedness-0.2.1.dist-info}/RECORD +15 -10
- {fundedness-0.1.0.dist-info → fundedness-0.2.1.dist-info}/WHEEL +0 -0
- {fundedness-0.1.0.dist-info → fundedness-0.2.1.dist-info}/entry_points.txt +0 -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
|
|
@@ -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
|