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.

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