fundedness 0.2.2__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.

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 +188 -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 +559 -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.2.dist-info/METADATA +299 -0
  41. fundedness-0.2.2.dist-info/RECORD +43 -0
  42. fundedness-0.2.2.dist-info/WHEEL +4 -0
  43. fundedness-0.2.2.dist-info/entry_points.txt +2 -0
@@ -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