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.
- fundedness/__init__.py +71 -0
- fundedness/allocation/__init__.py +20 -0
- fundedness/allocation/base.py +32 -0
- fundedness/allocation/constant.py +25 -0
- fundedness/allocation/glidepath.py +111 -0
- fundedness/allocation/merton_optimal.py +220 -0
- fundedness/cefr.py +241 -0
- fundedness/liabilities.py +221 -0
- fundedness/liquidity.py +49 -0
- fundedness/merton.py +289 -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 +199 -0
- fundedness/models/simulation.py +80 -0
- fundedness/models/tax.py +125 -0
- fundedness/models/utility.py +154 -0
- fundedness/optimize.py +473 -0
- fundedness/policies.py +204 -0
- fundedness/risk.py +72 -0
- fundedness/simulate.py +595 -0
- fundedness/viz/__init__.py +33 -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/optimal.py +542 -0
- fundedness/viz/survival.py +230 -0
- fundedness/viz/tornado.py +236 -0
- fundedness/viz/waterfall.py +203 -0
- fundedness/withdrawals/__init__.py +27 -0
- fundedness/withdrawals/base.py +116 -0
- fundedness/withdrawals/comparison.py +230 -0
- fundedness/withdrawals/fixed_swr.py +174 -0
- fundedness/withdrawals/guardrails.py +136 -0
- fundedness/withdrawals/merton_optimal.py +286 -0
- fundedness/withdrawals/rmd_style.py +203 -0
- fundedness/withdrawals/vpw.py +136 -0
- fundedness-0.2.4.dist-info/METADATA +300 -0
- fundedness-0.2.4.dist-info/RECORD +43 -0
- fundedness-0.2.4.dist-info/WHEEL +4 -0
- fundedness-0.2.4.dist-info/entry_points.txt +2 -0
fundedness/viz/colors.py
ADDED
|
@@ -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
|
+
}
|
|
@@ -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
|