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/__init__.py +38 -0
- fundedness/allocation/__init__.py +12 -0
- fundedness/allocation/base.py +32 -0
- fundedness/allocation/constant.py +25 -0
- fundedness/allocation/glidepath.py +111 -0
- fundedness/cefr.py +241 -0
- fundedness/liabilities.py +221 -0
- fundedness/liquidity.py +49 -0
- fundedness/models/__init__.py +35 -0
- fundedness/models/assets.py +148 -0
- fundedness/models/household.py +153 -0
- fundedness/models/liabilities.py +99 -0
- fundedness/models/market.py +188 -0
- fundedness/models/simulation.py +80 -0
- fundedness/models/tax.py +125 -0
- fundedness/models/utility.py +154 -0
- fundedness/policies.py +204 -0
- fundedness/risk.py +72 -0
- fundedness/simulate.py +401 -0
- fundedness/viz/__init__.py +19 -0
- fundedness/viz/colors.py +110 -0
- fundedness/viz/comparison.py +294 -0
- fundedness/viz/fan_chart.py +193 -0
- fundedness/viz/histogram.py +225 -0
- fundedness/viz/survival.py +230 -0
- fundedness/viz/tornado.py +236 -0
- fundedness/viz/waterfall.py +203 -0
- fundedness/withdrawals/__init__.py +19 -0
- fundedness/withdrawals/base.py +116 -0
- fundedness/withdrawals/comparison.py +230 -0
- fundedness/withdrawals/fixed_swr.py +113 -0
- fundedness/withdrawals/guardrails.py +136 -0
- fundedness/withdrawals/rmd_style.py +203 -0
- fundedness/withdrawals/vpw.py +136 -0
- fundedness-0.1.0.dist-info/METADATA +233 -0
- fundedness-0.1.0.dist-info/RECORD +38 -0
- fundedness-0.1.0.dist-info/WHEEL +4 -0
- fundedness-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Strategy comparison visualizations."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import plotly.graph_objects as go
|
|
7
|
+
from plotly.subplots import make_subplots
|
|
8
|
+
|
|
9
|
+
from fundedness.viz.colors import COLORS, STRATEGY_COLORS, get_plotly_layout_defaults
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_strategy_comparison_chart(
|
|
13
|
+
years: np.ndarray,
|
|
14
|
+
strategies: dict[str, dict[str, np.ndarray]],
|
|
15
|
+
metric: str = "wealth_median",
|
|
16
|
+
title: str = "Strategy Comparison",
|
|
17
|
+
y_label: str = "Portfolio Value ($)",
|
|
18
|
+
height: int = 500,
|
|
19
|
+
width: int | None = None,
|
|
20
|
+
) -> go.Figure:
|
|
21
|
+
"""Create a line chart comparing multiple strategies.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
years: Array of year values
|
|
25
|
+
strategies: Dictionary mapping strategy name to metrics dict
|
|
26
|
+
Each metrics dict should contain arrays for the requested metric
|
|
27
|
+
metric: Which metric to plot (e.g., "wealth_median", "spending_median")
|
|
28
|
+
title: Chart title
|
|
29
|
+
y_label: Y-axis label
|
|
30
|
+
height: Chart height in pixels
|
|
31
|
+
width: Chart width in pixels
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Plotly Figure object
|
|
35
|
+
"""
|
|
36
|
+
fig = go.Figure()
|
|
37
|
+
|
|
38
|
+
for i, (name, metrics) in enumerate(strategies.items()):
|
|
39
|
+
if metric not in metrics:
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
color = STRATEGY_COLORS[i % len(STRATEGY_COLORS)]
|
|
43
|
+
|
|
44
|
+
fig.add_trace(
|
|
45
|
+
go.Scatter(
|
|
46
|
+
x=years,
|
|
47
|
+
y=metrics[metric],
|
|
48
|
+
mode="lines",
|
|
49
|
+
name=name,
|
|
50
|
+
line={"color": color, "width": 2},
|
|
51
|
+
hovertemplate=(
|
|
52
|
+
f"<b>{name}</b><br>"
|
|
53
|
+
"Year: %{x}<br>"
|
|
54
|
+
"Value: $%{y:,.0f}<br>"
|
|
55
|
+
"<extra></extra>"
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Apply layout
|
|
61
|
+
layout = get_plotly_layout_defaults()
|
|
62
|
+
layout.update({
|
|
63
|
+
"title": {"text": title},
|
|
64
|
+
"height": height,
|
|
65
|
+
"xaxis": {
|
|
66
|
+
"title": "Year",
|
|
67
|
+
"gridcolor": COLORS["neutral_light"],
|
|
68
|
+
"dtick": 5,
|
|
69
|
+
},
|
|
70
|
+
"yaxis": {
|
|
71
|
+
"title": y_label,
|
|
72
|
+
"tickformat": "$,.0f",
|
|
73
|
+
"gridcolor": COLORS["neutral_light"],
|
|
74
|
+
},
|
|
75
|
+
"legend": {
|
|
76
|
+
"orientation": "h",
|
|
77
|
+
"yanchor": "bottom",
|
|
78
|
+
"y": 1.02,
|
|
79
|
+
"xanchor": "right",
|
|
80
|
+
"x": 1,
|
|
81
|
+
},
|
|
82
|
+
"hovermode": "x unified",
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
if width:
|
|
86
|
+
layout["width"] = width
|
|
87
|
+
|
|
88
|
+
fig.update_layout(**layout)
|
|
89
|
+
|
|
90
|
+
return fig
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def create_strategy_metrics_table(
|
|
94
|
+
strategies: dict[str, dict[str, Any]],
|
|
95
|
+
metrics_to_show: list[str] | None = None,
|
|
96
|
+
title: str = "Strategy Metrics Summary",
|
|
97
|
+
height: int = 300,
|
|
98
|
+
width: int | None = None,
|
|
99
|
+
) -> go.Figure:
|
|
100
|
+
"""Create a table comparing strategy metrics.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
strategies: Dictionary mapping strategy name to metrics dict
|
|
104
|
+
metrics_to_show: List of metric names to display
|
|
105
|
+
title: Chart title
|
|
106
|
+
height: Chart height in pixels
|
|
107
|
+
width: Chart width in pixels
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Plotly Figure object
|
|
111
|
+
"""
|
|
112
|
+
if metrics_to_show is None:
|
|
113
|
+
metrics_to_show = [
|
|
114
|
+
"success_rate",
|
|
115
|
+
"median_terminal_wealth",
|
|
116
|
+
"median_spending",
|
|
117
|
+
"spending_volatility",
|
|
118
|
+
"worst_drawdown",
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
metric_labels = {
|
|
122
|
+
"success_rate": "Success Rate",
|
|
123
|
+
"median_terminal_wealth": "Median Terminal Wealth",
|
|
124
|
+
"median_spending": "Median Spending",
|
|
125
|
+
"spending_volatility": "Spending Volatility",
|
|
126
|
+
"worst_drawdown": "Worst Drawdown",
|
|
127
|
+
"time_to_ruin_p10": "Time to Ruin (P10)",
|
|
128
|
+
"floor_breach_rate": "Floor Breach Rate",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
metric_formats = {
|
|
132
|
+
"success_rate": lambda x: f"{x:.1%}",
|
|
133
|
+
"median_terminal_wealth": lambda x: f"${x:,.0f}",
|
|
134
|
+
"median_spending": lambda x: f"${x:,.0f}",
|
|
135
|
+
"spending_volatility": lambda x: f"{x:.1%}",
|
|
136
|
+
"worst_drawdown": lambda x: f"{x:.1%}",
|
|
137
|
+
"time_to_ruin_p10": lambda x: f"{x:.1f} years",
|
|
138
|
+
"floor_breach_rate": lambda x: f"{x:.1%}",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# Build table data
|
|
142
|
+
strategy_names = list(strategies.keys())
|
|
143
|
+
header_values = ["Metric"] + strategy_names
|
|
144
|
+
|
|
145
|
+
rows = []
|
|
146
|
+
for metric in metrics_to_show:
|
|
147
|
+
row = [metric_labels.get(metric, metric)]
|
|
148
|
+
formatter = metric_formats.get(metric, lambda x: f"{x:.2f}")
|
|
149
|
+
|
|
150
|
+
for name in strategy_names:
|
|
151
|
+
value = strategies[name].get(metric, None)
|
|
152
|
+
if value is not None:
|
|
153
|
+
row.append(formatter(value))
|
|
154
|
+
else:
|
|
155
|
+
row.append("N/A")
|
|
156
|
+
rows.append(row)
|
|
157
|
+
|
|
158
|
+
# Transpose for plotly table format
|
|
159
|
+
cell_values = list(zip(*rows))
|
|
160
|
+
|
|
161
|
+
fig = go.Figure(
|
|
162
|
+
data=[
|
|
163
|
+
go.Table(
|
|
164
|
+
header={
|
|
165
|
+
"values": header_values,
|
|
166
|
+
"fill_color": COLORS["wealth_primary"],
|
|
167
|
+
"font": {"color": "white", "size": 12},
|
|
168
|
+
"align": "center",
|
|
169
|
+
"height": 30,
|
|
170
|
+
},
|
|
171
|
+
cells={
|
|
172
|
+
"values": cell_values,
|
|
173
|
+
"fill_color": [COLORS["background_alt"]] + [COLORS["background"]] * len(strategy_names),
|
|
174
|
+
"font": {"color": COLORS["text_primary"], "size": 11},
|
|
175
|
+
"align": ["left"] + ["center"] * len(strategy_names),
|
|
176
|
+
"height": 25,
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
]
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
layout = get_plotly_layout_defaults()
|
|
183
|
+
layout.update({
|
|
184
|
+
"title": {"text": title},
|
|
185
|
+
"height": height,
|
|
186
|
+
"margin": {"l": 20, "r": 20, "t": 60, "b": 20},
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
if width:
|
|
190
|
+
layout["width"] = width
|
|
191
|
+
|
|
192
|
+
fig.update_layout(**layout)
|
|
193
|
+
|
|
194
|
+
return fig
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def create_multi_metric_comparison(
|
|
198
|
+
years: np.ndarray,
|
|
199
|
+
strategies: dict[str, dict[str, np.ndarray]],
|
|
200
|
+
title: str = "Multi-Metric Strategy Comparison",
|
|
201
|
+
height: int = 800,
|
|
202
|
+
width: int | None = None,
|
|
203
|
+
) -> go.Figure:
|
|
204
|
+
"""Create a multi-panel chart comparing strategies across metrics.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
years: Array of year values
|
|
208
|
+
strategies: Dictionary mapping strategy name to metrics dict
|
|
209
|
+
title: Chart title
|
|
210
|
+
height: Chart height in pixels
|
|
211
|
+
width: Chart width in pixels
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Plotly Figure object with subplots
|
|
215
|
+
"""
|
|
216
|
+
fig = make_subplots(
|
|
217
|
+
rows=2,
|
|
218
|
+
cols=2,
|
|
219
|
+
subplot_titles=(
|
|
220
|
+
"Median Wealth",
|
|
221
|
+
"Median Spending",
|
|
222
|
+
"Survival Probability",
|
|
223
|
+
"Spending as % of Initial",
|
|
224
|
+
),
|
|
225
|
+
vertical_spacing=0.12,
|
|
226
|
+
horizontal_spacing=0.1,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
metrics_config = [
|
|
230
|
+
("wealth_median", 1, 1, "$,.0f"),
|
|
231
|
+
("spending_median", 1, 2, "$,.0f"),
|
|
232
|
+
("survival_prob", 2, 1, ".0%"),
|
|
233
|
+
("spending_ratio", 2, 2, ".1%"),
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
for i, (name, metrics) in enumerate(strategies.items()):
|
|
237
|
+
color = STRATEGY_COLORS[i % len(STRATEGY_COLORS)]
|
|
238
|
+
|
|
239
|
+
for metric, row, col, fmt in metrics_config:
|
|
240
|
+
if metric not in metrics:
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
values = metrics[metric]
|
|
244
|
+
if metric == "survival_prob":
|
|
245
|
+
values = values * 100 # Convert to percentage
|
|
246
|
+
|
|
247
|
+
fig.add_trace(
|
|
248
|
+
go.Scatter(
|
|
249
|
+
x=years,
|
|
250
|
+
y=values,
|
|
251
|
+
mode="lines",
|
|
252
|
+
name=name,
|
|
253
|
+
line={"color": color, "width": 2},
|
|
254
|
+
showlegend=(row == 1 and col == 1), # Only show legend once
|
|
255
|
+
hovertemplate=(
|
|
256
|
+
f"<b>{name}</b><br>"
|
|
257
|
+
"Year: %{x}<br>"
|
|
258
|
+
"Value: %{y}<br>"
|
|
259
|
+
"<extra></extra>"
|
|
260
|
+
),
|
|
261
|
+
),
|
|
262
|
+
row=row,
|
|
263
|
+
col=col,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# Apply layout
|
|
267
|
+
layout = get_plotly_layout_defaults()
|
|
268
|
+
layout.update({
|
|
269
|
+
"title": {"text": title},
|
|
270
|
+
"height": height,
|
|
271
|
+
"legend": {
|
|
272
|
+
"orientation": "h",
|
|
273
|
+
"yanchor": "bottom",
|
|
274
|
+
"y": 1.02,
|
|
275
|
+
"xanchor": "right",
|
|
276
|
+
"x": 1,
|
|
277
|
+
},
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
if width:
|
|
281
|
+
layout["width"] = width
|
|
282
|
+
|
|
283
|
+
fig.update_layout(**layout)
|
|
284
|
+
|
|
285
|
+
# Update axes
|
|
286
|
+
fig.update_xaxes(title_text="Year", gridcolor=COLORS["neutral_light"])
|
|
287
|
+
fig.update_yaxes(gridcolor=COLORS["neutral_light"])
|
|
288
|
+
|
|
289
|
+
fig.update_yaxes(tickformat="$,.0f", row=1, col=1)
|
|
290
|
+
fig.update_yaxes(tickformat="$,.0f", row=1, col=2)
|
|
291
|
+
fig.update_yaxes(ticksuffix="%", row=2, col=1)
|
|
292
|
+
fig.update_yaxes(ticksuffix="%", row=2, col=2)
|
|
293
|
+
|
|
294
|
+
return fig
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Fan chart visualization for Monte Carlo projections."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import plotly.graph_objects as go
|
|
5
|
+
|
|
6
|
+
from fundedness.viz.colors import COLORS, get_plotly_layout_defaults
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_fan_chart(
|
|
10
|
+
years: np.ndarray,
|
|
11
|
+
percentiles: dict[str, np.ndarray],
|
|
12
|
+
title: str = "Wealth Projection",
|
|
13
|
+
y_label: str = "Portfolio Value ($)",
|
|
14
|
+
show_median_line: bool = True,
|
|
15
|
+
show_floor: float | None = None,
|
|
16
|
+
height: int = 500,
|
|
17
|
+
width: int | None = None,
|
|
18
|
+
) -> go.Figure:
|
|
19
|
+
"""Create a fan chart showing percentile bands over time.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
years: Array of year values (x-axis)
|
|
23
|
+
percentiles: Dictionary mapping percentile names to value arrays
|
|
24
|
+
Expected keys: "P10", "P25", "P50", "P75", "P90"
|
|
25
|
+
title: Chart title
|
|
26
|
+
y_label: Y-axis label
|
|
27
|
+
show_median_line: Whether to show a distinct median line
|
|
28
|
+
show_floor: Optional floor value to show as horizontal line
|
|
29
|
+
height: Chart height in pixels
|
|
30
|
+
width: Chart width in pixels (None = responsive)
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Plotly Figure object
|
|
34
|
+
"""
|
|
35
|
+
fig = go.Figure()
|
|
36
|
+
|
|
37
|
+
# P10-P90 band (outermost)
|
|
38
|
+
if "P10" in percentiles and "P90" in percentiles:
|
|
39
|
+
fig.add_trace(
|
|
40
|
+
go.Scatter(
|
|
41
|
+
x=np.concatenate([years, years[::-1]]),
|
|
42
|
+
y=np.concatenate([percentiles["P90"], percentiles["P10"][::-1]]),
|
|
43
|
+
fill="toself",
|
|
44
|
+
fillcolor="rgba(52, 152, 219, 0.15)",
|
|
45
|
+
line={"width": 0},
|
|
46
|
+
name="P10-P90 Range",
|
|
47
|
+
hoverinfo="skip",
|
|
48
|
+
showlegend=True,
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# P25-P75 band (middle)
|
|
53
|
+
if "P25" in percentiles and "P75" in percentiles:
|
|
54
|
+
fig.add_trace(
|
|
55
|
+
go.Scatter(
|
|
56
|
+
x=np.concatenate([years, years[::-1]]),
|
|
57
|
+
y=np.concatenate([percentiles["P75"], percentiles["P25"][::-1]]),
|
|
58
|
+
fill="toself",
|
|
59
|
+
fillcolor="rgba(52, 152, 219, 0.3)",
|
|
60
|
+
line={"width": 0},
|
|
61
|
+
name="P25-P75 Range",
|
|
62
|
+
hoverinfo="skip",
|
|
63
|
+
showlegend=True,
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Percentile lines
|
|
68
|
+
percentile_styles = {
|
|
69
|
+
"P90": {"color": COLORS["success_secondary"], "dash": "dot", "width": 1},
|
|
70
|
+
"P75": {"color": COLORS["success_primary"], "dash": "dash", "width": 1},
|
|
71
|
+
"P50": {"color": COLORS["wealth_primary"], "dash": "solid", "width": 3},
|
|
72
|
+
"P25": {"color": COLORS["warning_primary"], "dash": "dash", "width": 1},
|
|
73
|
+
"P10": {"color": COLORS["danger_secondary"], "dash": "dot", "width": 1},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for pct_name, values in percentiles.items():
|
|
77
|
+
if pct_name in percentile_styles:
|
|
78
|
+
style = percentile_styles[pct_name]
|
|
79
|
+
is_median = pct_name == "P50"
|
|
80
|
+
|
|
81
|
+
fig.add_trace(
|
|
82
|
+
go.Scatter(
|
|
83
|
+
x=years,
|
|
84
|
+
y=values,
|
|
85
|
+
mode="lines",
|
|
86
|
+
name=pct_name,
|
|
87
|
+
line={
|
|
88
|
+
"color": style["color"],
|
|
89
|
+
"dash": style["dash"],
|
|
90
|
+
"width": style["width"] if not (is_median and show_median_line) else 3,
|
|
91
|
+
},
|
|
92
|
+
hovertemplate=(
|
|
93
|
+
f"<b>{pct_name}</b><br>"
|
|
94
|
+
"Year: %{x}<br>"
|
|
95
|
+
"Value: $%{y:,.0f}<br>"
|
|
96
|
+
"<extra></extra>"
|
|
97
|
+
),
|
|
98
|
+
showlegend=not (pct_name in ["P90", "P75", "P25", "P10"]),
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Add floor line if specified
|
|
103
|
+
if show_floor is not None:
|
|
104
|
+
fig.add_hline(
|
|
105
|
+
y=show_floor,
|
|
106
|
+
line_dash="dash",
|
|
107
|
+
line_color=COLORS["danger_primary"],
|
|
108
|
+
line_width=2,
|
|
109
|
+
annotation_text=f"Floor: ${show_floor:,.0f}",
|
|
110
|
+
annotation_position="top right",
|
|
111
|
+
annotation_font_color=COLORS["danger_primary"],
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Apply layout
|
|
115
|
+
layout = get_plotly_layout_defaults()
|
|
116
|
+
layout.update({
|
|
117
|
+
"title": {"text": title},
|
|
118
|
+
"height": height,
|
|
119
|
+
"xaxis": {
|
|
120
|
+
"title": "Year",
|
|
121
|
+
"gridcolor": COLORS["neutral_light"],
|
|
122
|
+
"dtick": 5,
|
|
123
|
+
},
|
|
124
|
+
"yaxis": {
|
|
125
|
+
"title": y_label,
|
|
126
|
+
"tickformat": "$,.0f",
|
|
127
|
+
"gridcolor": COLORS["neutral_light"],
|
|
128
|
+
"rangemode": "tozero",
|
|
129
|
+
},
|
|
130
|
+
"legend": {
|
|
131
|
+
"orientation": "h",
|
|
132
|
+
"yanchor": "bottom",
|
|
133
|
+
"y": 1.02,
|
|
134
|
+
"xanchor": "right",
|
|
135
|
+
"x": 1,
|
|
136
|
+
},
|
|
137
|
+
"hovermode": "x unified",
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
if width:
|
|
141
|
+
layout["width"] = width
|
|
142
|
+
|
|
143
|
+
fig.update_layout(**layout)
|
|
144
|
+
|
|
145
|
+
return fig
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def create_spending_fan_chart(
|
|
149
|
+
years: np.ndarray,
|
|
150
|
+
percentiles: dict[str, np.ndarray],
|
|
151
|
+
floor_spending: float | None = None,
|
|
152
|
+
target_spending: float | None = None,
|
|
153
|
+
title: str = "Spending Projection",
|
|
154
|
+
height: int = 500,
|
|
155
|
+
width: int | None = None,
|
|
156
|
+
) -> go.Figure:
|
|
157
|
+
"""Create a fan chart specifically for spending projections.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
years: Array of year values
|
|
161
|
+
percentiles: Dictionary mapping percentile names to spending arrays
|
|
162
|
+
floor_spending: Essential spending floor
|
|
163
|
+
target_spending: Target spending level
|
|
164
|
+
title: Chart title
|
|
165
|
+
height: Chart height in pixels
|
|
166
|
+
width: Chart width in pixels
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Plotly Figure object
|
|
170
|
+
"""
|
|
171
|
+
fig = create_fan_chart(
|
|
172
|
+
years=years,
|
|
173
|
+
percentiles=percentiles,
|
|
174
|
+
title=title,
|
|
175
|
+
y_label="Annual Spending ($)",
|
|
176
|
+
show_floor=floor_spending,
|
|
177
|
+
height=height,
|
|
178
|
+
width=width,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Add target spending line if specified
|
|
182
|
+
if target_spending is not None:
|
|
183
|
+
fig.add_hline(
|
|
184
|
+
y=target_spending,
|
|
185
|
+
line_dash="dot",
|
|
186
|
+
line_color=COLORS["success_primary"],
|
|
187
|
+
line_width=2,
|
|
188
|
+
annotation_text=f"Target: ${target_spending:,.0f}",
|
|
189
|
+
annotation_position="top left",
|
|
190
|
+
annotation_font_color=COLORS["success_primary"],
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return fig
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Histogram visualizations for time-to-event distributions."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import plotly.graph_objects as go
|
|
5
|
+
|
|
6
|
+
from fundedness.viz.colors import COLORS, get_plotly_layout_defaults
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_time_distribution_histogram(
|
|
10
|
+
time_to_event: np.ndarray,
|
|
11
|
+
event_name: str = "Ruin",
|
|
12
|
+
planning_horizon: int | None = None,
|
|
13
|
+
percentiles_to_show: list[int] | None = None,
|
|
14
|
+
title: str | None = None,
|
|
15
|
+
height: int = 400,
|
|
16
|
+
width: int | None = None,
|
|
17
|
+
) -> go.Figure:
|
|
18
|
+
"""Create a histogram of time-to-event distribution.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
time_to_event: Array of time values (years until event, inf for no event)
|
|
22
|
+
event_name: Name of the event (e.g., "Ruin", "Floor Breach")
|
|
23
|
+
planning_horizon: Maximum planning horizon (for x-axis)
|
|
24
|
+
percentiles_to_show: Percentiles to mark (e.g., [10, 50, 90])
|
|
25
|
+
title: Chart title (auto-generated if None)
|
|
26
|
+
height: Chart height in pixels
|
|
27
|
+
width: Chart width in pixels
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Plotly Figure object
|
|
31
|
+
"""
|
|
32
|
+
# Filter out infinite values (no event occurred)
|
|
33
|
+
finite_times = time_to_event[np.isfinite(time_to_event)]
|
|
34
|
+
never_occurred_count = np.sum(~np.isfinite(time_to_event))
|
|
35
|
+
total_count = len(time_to_event)
|
|
36
|
+
|
|
37
|
+
if title is None:
|
|
38
|
+
title = f"Time to {event_name} Distribution"
|
|
39
|
+
|
|
40
|
+
fig = go.Figure()
|
|
41
|
+
|
|
42
|
+
if len(finite_times) > 0:
|
|
43
|
+
# Determine bins
|
|
44
|
+
max_time = planning_horizon or int(np.ceil(finite_times.max()))
|
|
45
|
+
bins = np.arange(0, max_time + 2, 1)
|
|
46
|
+
|
|
47
|
+
# Create histogram
|
|
48
|
+
fig.add_trace(
|
|
49
|
+
go.Histogram(
|
|
50
|
+
x=finite_times,
|
|
51
|
+
xbins={"start": 0, "end": max_time + 1, "size": 1},
|
|
52
|
+
marker_color=COLORS["danger_primary"],
|
|
53
|
+
opacity=0.7,
|
|
54
|
+
name=f"Years to {event_name}",
|
|
55
|
+
hovertemplate=(
|
|
56
|
+
"<b>Year %{x}</b><br>"
|
|
57
|
+
"Count: %{y}<br>"
|
|
58
|
+
"<extra></extra>"
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Add percentile lines
|
|
64
|
+
if percentiles_to_show:
|
|
65
|
+
percentile_colors = {
|
|
66
|
+
10: COLORS["danger_secondary"],
|
|
67
|
+
25: COLORS["warning_secondary"],
|
|
68
|
+
50: COLORS["wealth_primary"],
|
|
69
|
+
75: COLORS["success_secondary"],
|
|
70
|
+
90: COLORS["success_primary"],
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for pct in percentiles_to_show:
|
|
74
|
+
value = np.percentile(finite_times, pct)
|
|
75
|
+
color = percentile_colors.get(pct, COLORS["neutral_primary"])
|
|
76
|
+
fig.add_vline(
|
|
77
|
+
x=value,
|
|
78
|
+
line_dash="dash",
|
|
79
|
+
line_color=color,
|
|
80
|
+
line_width=2,
|
|
81
|
+
annotation_text=f"P{pct}: {value:.1f}y",
|
|
82
|
+
annotation_position="top",
|
|
83
|
+
annotation_font_color=color,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Add annotation for "never occurred" count
|
|
87
|
+
if never_occurred_count > 0:
|
|
88
|
+
never_pct = never_occurred_count / total_count * 100
|
|
89
|
+
fig.add_annotation(
|
|
90
|
+
x=0.98,
|
|
91
|
+
y=0.98,
|
|
92
|
+
xref="paper",
|
|
93
|
+
yref="paper",
|
|
94
|
+
text=f"Never {event_name.lower()}ed: {never_pct:.1f}%<br>({never_occurred_count:,} paths)",
|
|
95
|
+
showarrow=False,
|
|
96
|
+
font={"size": 12, "color": COLORS["success_primary"]},
|
|
97
|
+
bgcolor=COLORS["background"],
|
|
98
|
+
bordercolor=COLORS["success_primary"],
|
|
99
|
+
borderwidth=1,
|
|
100
|
+
borderpad=6,
|
|
101
|
+
align="right",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Apply layout
|
|
105
|
+
layout = get_plotly_layout_defaults()
|
|
106
|
+
layout.update({
|
|
107
|
+
"title": {"text": title},
|
|
108
|
+
"height": height,
|
|
109
|
+
"xaxis": {
|
|
110
|
+
"title": f"Years to {event_name}",
|
|
111
|
+
"gridcolor": COLORS["neutral_light"],
|
|
112
|
+
"dtick": 5,
|
|
113
|
+
},
|
|
114
|
+
"yaxis": {
|
|
115
|
+
"title": "Number of Paths",
|
|
116
|
+
"gridcolor": COLORS["neutral_light"],
|
|
117
|
+
},
|
|
118
|
+
"showlegend": False,
|
|
119
|
+
"bargap": 0.1,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
if width:
|
|
123
|
+
layout["width"] = width
|
|
124
|
+
|
|
125
|
+
fig.update_layout(**layout)
|
|
126
|
+
|
|
127
|
+
return fig
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def create_outcome_distribution_histogram(
|
|
131
|
+
terminal_values: np.ndarray,
|
|
132
|
+
initial_value: float | None = None,
|
|
133
|
+
target_value: float | None = None,
|
|
134
|
+
title: str = "Terminal Wealth Distribution",
|
|
135
|
+
height: int = 400,
|
|
136
|
+
width: int | None = None,
|
|
137
|
+
) -> go.Figure:
|
|
138
|
+
"""Create a histogram of terminal portfolio values.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
terminal_values: Array of terminal portfolio values
|
|
142
|
+
initial_value: Starting portfolio value (for reference)
|
|
143
|
+
target_value: Target terminal value (for reference)
|
|
144
|
+
title: Chart title
|
|
145
|
+
height: Chart height in pixels
|
|
146
|
+
width: Chart width in pixels
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Plotly Figure object
|
|
150
|
+
"""
|
|
151
|
+
fig = go.Figure()
|
|
152
|
+
|
|
153
|
+
# Create histogram with log-scale bins for wealth distribution
|
|
154
|
+
fig.add_trace(
|
|
155
|
+
go.Histogram(
|
|
156
|
+
x=terminal_values,
|
|
157
|
+
nbinsx=50,
|
|
158
|
+
marker_color=COLORS["wealth_primary"],
|
|
159
|
+
opacity=0.7,
|
|
160
|
+
name="Terminal Value",
|
|
161
|
+
hovertemplate=(
|
|
162
|
+
"<b>Value Range</b><br>"
|
|
163
|
+
"$%{x:,.0f}<br>"
|
|
164
|
+
"Count: %{y}<br>"
|
|
165
|
+
"<extra></extra>"
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Add reference lines
|
|
171
|
+
if initial_value is not None:
|
|
172
|
+
fig.add_vline(
|
|
173
|
+
x=initial_value,
|
|
174
|
+
line_dash="dash",
|
|
175
|
+
line_color=COLORS["neutral_primary"],
|
|
176
|
+
line_width=2,
|
|
177
|
+
annotation_text=f"Initial: ${initial_value:,.0f}",
|
|
178
|
+
annotation_position="top",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if target_value is not None:
|
|
182
|
+
fig.add_vline(
|
|
183
|
+
x=target_value,
|
|
184
|
+
line_dash="dot",
|
|
185
|
+
line_color=COLORS["success_primary"],
|
|
186
|
+
line_width=2,
|
|
187
|
+
annotation_text=f"Target: ${target_value:,.0f}",
|
|
188
|
+
annotation_position="top",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Add median line
|
|
192
|
+
median_value = np.median(terminal_values)
|
|
193
|
+
fig.add_vline(
|
|
194
|
+
x=median_value,
|
|
195
|
+
line_dash="solid",
|
|
196
|
+
line_color=COLORS["wealth_secondary"],
|
|
197
|
+
line_width=2,
|
|
198
|
+
annotation_text=f"Median: ${median_value:,.0f}",
|
|
199
|
+
annotation_position="bottom",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Apply layout
|
|
203
|
+
layout = get_plotly_layout_defaults()
|
|
204
|
+
layout.update({
|
|
205
|
+
"title": {"text": title},
|
|
206
|
+
"height": height,
|
|
207
|
+
"xaxis": {
|
|
208
|
+
"title": "Portfolio Value ($)",
|
|
209
|
+
"tickformat": "$,.0f",
|
|
210
|
+
"gridcolor": COLORS["neutral_light"],
|
|
211
|
+
},
|
|
212
|
+
"yaxis": {
|
|
213
|
+
"title": "Number of Paths",
|
|
214
|
+
"gridcolor": COLORS["neutral_light"],
|
|
215
|
+
},
|
|
216
|
+
"showlegend": False,
|
|
217
|
+
"bargap": 0.05,
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
if width:
|
|
221
|
+
layout["width"] = width
|
|
222
|
+
|
|
223
|
+
fig.update_layout(**layout)
|
|
224
|
+
|
|
225
|
+
return fig
|