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
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Survival curve visualization."""
|
|
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_survival_curve(
|
|
10
|
+
years: np.ndarray,
|
|
11
|
+
survival_prob: np.ndarray,
|
|
12
|
+
floor_survival_prob: np.ndarray | None = None,
|
|
13
|
+
title: str = "Portfolio Survival Probability",
|
|
14
|
+
threshold_years: list[int] | None = None,
|
|
15
|
+
height: int = 450,
|
|
16
|
+
width: int | None = None,
|
|
17
|
+
) -> go.Figure:
|
|
18
|
+
"""Create a survival curve showing probability of not running out of money.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
years: Array of year values
|
|
22
|
+
survival_prob: Probability of portfolio survival at each year (above ruin)
|
|
23
|
+
floor_survival_prob: Probability of being above spending floor at each year
|
|
24
|
+
title: Chart title
|
|
25
|
+
threshold_years: Years to highlight with vertical lines (e.g., [20, 30])
|
|
26
|
+
height: Chart height in pixels
|
|
27
|
+
width: Chart width in pixels
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Plotly Figure object
|
|
31
|
+
"""
|
|
32
|
+
fig = go.Figure()
|
|
33
|
+
|
|
34
|
+
# Main survival curve (above ruin)
|
|
35
|
+
fig.add_trace(
|
|
36
|
+
go.Scatter(
|
|
37
|
+
x=years,
|
|
38
|
+
y=survival_prob * 100,
|
|
39
|
+
mode="lines",
|
|
40
|
+
name="Above Ruin",
|
|
41
|
+
line={
|
|
42
|
+
"color": COLORS["wealth_primary"],
|
|
43
|
+
"width": 3,
|
|
44
|
+
},
|
|
45
|
+
fill="tozeroy",
|
|
46
|
+
fillcolor="rgba(52, 152, 219, 0.2)",
|
|
47
|
+
hovertemplate=(
|
|
48
|
+
"<b>Year %{x}</b><br>"
|
|
49
|
+
"Survival Probability: %{y:.1f}%<br>"
|
|
50
|
+
"<extra></extra>"
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Floor survival curve if provided
|
|
56
|
+
if floor_survival_prob is not None:
|
|
57
|
+
fig.add_trace(
|
|
58
|
+
go.Scatter(
|
|
59
|
+
x=years,
|
|
60
|
+
y=floor_survival_prob * 100,
|
|
61
|
+
mode="lines",
|
|
62
|
+
name="Above Floor",
|
|
63
|
+
line={
|
|
64
|
+
"color": COLORS["success_primary"],
|
|
65
|
+
"width": 2,
|
|
66
|
+
"dash": "dash",
|
|
67
|
+
},
|
|
68
|
+
hovertemplate=(
|
|
69
|
+
"<b>Year %{x}</b><br>"
|
|
70
|
+
"Floor Probability: %{y:.1f}%<br>"
|
|
71
|
+
"<extra></extra>"
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Add threshold year markers
|
|
77
|
+
if threshold_years:
|
|
78
|
+
for year in threshold_years:
|
|
79
|
+
if year <= years[-1]:
|
|
80
|
+
idx = np.searchsorted(years, year)
|
|
81
|
+
if idx < len(survival_prob):
|
|
82
|
+
prob = survival_prob[idx] * 100
|
|
83
|
+
fig.add_vline(
|
|
84
|
+
x=year,
|
|
85
|
+
line_dash="dot",
|
|
86
|
+
line_color=COLORS["neutral_primary"],
|
|
87
|
+
annotation_text=f"Year {year}: {prob:.0f}%",
|
|
88
|
+
annotation_position="top",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Add horizontal reference lines
|
|
92
|
+
for prob_level, label in [(90, "90%"), (75, "75%"), (50, "50%")]:
|
|
93
|
+
fig.add_hline(
|
|
94
|
+
y=prob_level,
|
|
95
|
+
line_dash="dot",
|
|
96
|
+
line_color=COLORS["neutral_light"],
|
|
97
|
+
line_width=1,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Apply layout
|
|
101
|
+
layout = get_plotly_layout_defaults()
|
|
102
|
+
layout.update({
|
|
103
|
+
"title": {"text": title},
|
|
104
|
+
"height": height,
|
|
105
|
+
"xaxis": {
|
|
106
|
+
"title": "Years",
|
|
107
|
+
"gridcolor": COLORS["neutral_light"],
|
|
108
|
+
"dtick": 5,
|
|
109
|
+
},
|
|
110
|
+
"yaxis": {
|
|
111
|
+
"title": "Probability (%)",
|
|
112
|
+
"range": [0, 105],
|
|
113
|
+
"gridcolor": COLORS["neutral_light"],
|
|
114
|
+
"ticksuffix": "%",
|
|
115
|
+
},
|
|
116
|
+
"legend": {
|
|
117
|
+
"orientation": "h",
|
|
118
|
+
"yanchor": "bottom",
|
|
119
|
+
"y": 1.02,
|
|
120
|
+
"xanchor": "right",
|
|
121
|
+
"x": 1,
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
if width:
|
|
126
|
+
layout["width"] = width
|
|
127
|
+
|
|
128
|
+
fig.update_layout(**layout)
|
|
129
|
+
|
|
130
|
+
return fig
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def create_dual_survival_chart(
|
|
134
|
+
years: np.ndarray,
|
|
135
|
+
ruin_prob: np.ndarray,
|
|
136
|
+
floor_breach_prob: np.ndarray,
|
|
137
|
+
title: str = "Risk Timeline",
|
|
138
|
+
height: int = 450,
|
|
139
|
+
width: int | None = None,
|
|
140
|
+
) -> go.Figure:
|
|
141
|
+
"""Create a chart showing both ruin and floor breach probabilities over time.
|
|
142
|
+
|
|
143
|
+
Shows the cumulative probability of experiencing each event by each year.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
years: Array of year values
|
|
147
|
+
ruin_prob: Cumulative probability of ruin by each year
|
|
148
|
+
floor_breach_prob: Cumulative probability of floor breach by each year
|
|
149
|
+
title: Chart title
|
|
150
|
+
height: Chart height in pixels
|
|
151
|
+
width: Chart width in pixels
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Plotly Figure object
|
|
155
|
+
"""
|
|
156
|
+
fig = go.Figure()
|
|
157
|
+
|
|
158
|
+
# Floor breach probability (less severe, but more common)
|
|
159
|
+
fig.add_trace(
|
|
160
|
+
go.Scatter(
|
|
161
|
+
x=years,
|
|
162
|
+
y=floor_breach_prob * 100,
|
|
163
|
+
mode="lines",
|
|
164
|
+
name="Floor Breach Risk",
|
|
165
|
+
line={
|
|
166
|
+
"color": COLORS["warning_primary"],
|
|
167
|
+
"width": 2,
|
|
168
|
+
},
|
|
169
|
+
fill="tozeroy",
|
|
170
|
+
fillcolor="rgba(243, 156, 18, 0.15)",
|
|
171
|
+
hovertemplate=(
|
|
172
|
+
"<b>Year %{x}</b><br>"
|
|
173
|
+
"Floor Breach Risk: %{y:.1f}%<br>"
|
|
174
|
+
"<extra></extra>"
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Ruin probability (more severe)
|
|
180
|
+
fig.add_trace(
|
|
181
|
+
go.Scatter(
|
|
182
|
+
x=years,
|
|
183
|
+
y=ruin_prob * 100,
|
|
184
|
+
mode="lines",
|
|
185
|
+
name="Ruin Risk",
|
|
186
|
+
line={
|
|
187
|
+
"color": COLORS["danger_primary"],
|
|
188
|
+
"width": 2,
|
|
189
|
+
},
|
|
190
|
+
fill="tozeroy",
|
|
191
|
+
fillcolor="rgba(231, 76, 60, 0.15)",
|
|
192
|
+
hovertemplate=(
|
|
193
|
+
"<b>Year %{x}</b><br>"
|
|
194
|
+
"Ruin Risk: %{y:.1f}%<br>"
|
|
195
|
+
"<extra></extra>"
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Apply layout
|
|
201
|
+
layout = get_plotly_layout_defaults()
|
|
202
|
+
layout.update({
|
|
203
|
+
"title": {"text": title},
|
|
204
|
+
"height": height,
|
|
205
|
+
"xaxis": {
|
|
206
|
+
"title": "Years",
|
|
207
|
+
"gridcolor": COLORS["neutral_light"],
|
|
208
|
+
"dtick": 5,
|
|
209
|
+
},
|
|
210
|
+
"yaxis": {
|
|
211
|
+
"title": "Cumulative Probability (%)",
|
|
212
|
+
"range": [0, max(50, max(ruin_prob.max(), floor_breach_prob.max()) * 100 * 1.1)],
|
|
213
|
+
"gridcolor": COLORS["neutral_light"],
|
|
214
|
+
"ticksuffix": "%",
|
|
215
|
+
},
|
|
216
|
+
"legend": {
|
|
217
|
+
"orientation": "h",
|
|
218
|
+
"yanchor": "bottom",
|
|
219
|
+
"y": 1.02,
|
|
220
|
+
"xanchor": "right",
|
|
221
|
+
"x": 1,
|
|
222
|
+
},
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
if width:
|
|
226
|
+
layout["width"] = width
|
|
227
|
+
|
|
228
|
+
fig.update_layout(**layout)
|
|
229
|
+
|
|
230
|
+
return fig
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Tornado chart for sensitivity analysis."""
|
|
2
|
+
|
|
3
|
+
import plotly.graph_objects as go
|
|
4
|
+
|
|
5
|
+
from fundedness.viz.colors import COLORS, get_plotly_layout_defaults
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_tornado_chart(
|
|
9
|
+
parameters: list[str],
|
|
10
|
+
low_values: list[float],
|
|
11
|
+
high_values: list[float],
|
|
12
|
+
base_value: float,
|
|
13
|
+
parameter_labels: list[str] | None = None,
|
|
14
|
+
title: str = "Sensitivity Analysis",
|
|
15
|
+
value_label: str = "CEFR",
|
|
16
|
+
height: int = 500,
|
|
17
|
+
width: int | None = None,
|
|
18
|
+
) -> go.Figure:
|
|
19
|
+
"""Create a tornado chart for sensitivity analysis.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
parameters: List of parameter names
|
|
23
|
+
low_values: Outcome values when parameter is at low end
|
|
24
|
+
high_values: Outcome values when parameter is at high end
|
|
25
|
+
base_value: Baseline outcome value
|
|
26
|
+
parameter_labels: Display labels for parameters (uses parameters if None)
|
|
27
|
+
title: Chart title
|
|
28
|
+
value_label: Label for the outcome metric
|
|
29
|
+
height: Chart height in pixels
|
|
30
|
+
width: Chart width in pixels
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Plotly Figure object
|
|
34
|
+
"""
|
|
35
|
+
if parameter_labels is None:
|
|
36
|
+
parameter_labels = parameters
|
|
37
|
+
|
|
38
|
+
# Calculate ranges and sort by total impact
|
|
39
|
+
impacts = []
|
|
40
|
+
for i, (low, high) in enumerate(zip(low_values, high_values)):
|
|
41
|
+
low_impact = base_value - low
|
|
42
|
+
high_impact = high - base_value
|
|
43
|
+
total_impact = abs(high - low)
|
|
44
|
+
impacts.append({
|
|
45
|
+
"param": parameter_labels[i],
|
|
46
|
+
"low": low,
|
|
47
|
+
"high": high,
|
|
48
|
+
"low_impact": low_impact,
|
|
49
|
+
"high_impact": high_impact,
|
|
50
|
+
"total_impact": total_impact,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
# Sort by total impact (largest first)
|
|
54
|
+
impacts.sort(key=lambda x: x["total_impact"], reverse=True)
|
|
55
|
+
|
|
56
|
+
fig = go.Figure()
|
|
57
|
+
|
|
58
|
+
# Low impact bars (extending left from base)
|
|
59
|
+
fig.add_trace(
|
|
60
|
+
go.Bar(
|
|
61
|
+
y=[i["param"] for i in impacts],
|
|
62
|
+
x=[-(base_value - i["low"]) for i in impacts],
|
|
63
|
+
orientation="h",
|
|
64
|
+
name="Low Scenario",
|
|
65
|
+
marker_color=COLORS["danger_primary"],
|
|
66
|
+
text=[f"{i['low']:.2f}" for i in impacts],
|
|
67
|
+
textposition="outside",
|
|
68
|
+
hovertemplate=(
|
|
69
|
+
"<b>%{y}</b><br>"
|
|
70
|
+
f"Low {value_label}: " + "%{customdata:.2f}<br>"
|
|
71
|
+
"<extra></extra>"
|
|
72
|
+
),
|
|
73
|
+
customdata=[i["low"] for i in impacts],
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# High impact bars (extending right from base)
|
|
78
|
+
fig.add_trace(
|
|
79
|
+
go.Bar(
|
|
80
|
+
y=[i["param"] for i in impacts],
|
|
81
|
+
x=[i["high"] - base_value for i in impacts],
|
|
82
|
+
orientation="h",
|
|
83
|
+
name="High Scenario",
|
|
84
|
+
marker_color=COLORS["success_primary"],
|
|
85
|
+
text=[f"{i['high']:.2f}" for i in impacts],
|
|
86
|
+
textposition="outside",
|
|
87
|
+
hovertemplate=(
|
|
88
|
+
"<b>%{y}</b><br>"
|
|
89
|
+
f"High {value_label}: " + "%{customdata:.2f}<br>"
|
|
90
|
+
"<extra></extra>"
|
|
91
|
+
),
|
|
92
|
+
customdata=[i["high"] for i in impacts],
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Add base value line
|
|
97
|
+
fig.add_vline(
|
|
98
|
+
x=0,
|
|
99
|
+
line_color=COLORS["text_primary"],
|
|
100
|
+
line_width=2,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Apply layout
|
|
104
|
+
layout = get_plotly_layout_defaults()
|
|
105
|
+
|
|
106
|
+
# Calculate x-axis range
|
|
107
|
+
max_deviation = max(
|
|
108
|
+
max(abs(base_value - i["low"]) for i in impacts),
|
|
109
|
+
max(abs(i["high"] - base_value) for i in impacts),
|
|
110
|
+
)
|
|
111
|
+
x_range = [-max_deviation * 1.3, max_deviation * 1.3]
|
|
112
|
+
|
|
113
|
+
layout.update({
|
|
114
|
+
"title": {"text": title},
|
|
115
|
+
"height": height,
|
|
116
|
+
"xaxis": {
|
|
117
|
+
"title": f"Change in {value_label} from Base ({base_value:.2f})",
|
|
118
|
+
"gridcolor": COLORS["neutral_light"],
|
|
119
|
+
"range": x_range,
|
|
120
|
+
"zeroline": True,
|
|
121
|
+
"zerolinecolor": COLORS["text_primary"],
|
|
122
|
+
"zerolinewidth": 2,
|
|
123
|
+
},
|
|
124
|
+
"yaxis": {
|
|
125
|
+
"title": "",
|
|
126
|
+
"autorange": "reversed", # Largest impact at top
|
|
127
|
+
},
|
|
128
|
+
"barmode": "overlay",
|
|
129
|
+
"legend": {
|
|
130
|
+
"orientation": "h",
|
|
131
|
+
"yanchor": "bottom",
|
|
132
|
+
"y": 1.02,
|
|
133
|
+
"xanchor": "right",
|
|
134
|
+
"x": 1,
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
if width:
|
|
139
|
+
layout["width"] = width
|
|
140
|
+
|
|
141
|
+
fig.update_layout(**layout)
|
|
142
|
+
|
|
143
|
+
# Add base value annotation
|
|
144
|
+
fig.add_annotation(
|
|
145
|
+
x=0,
|
|
146
|
+
y=1.1,
|
|
147
|
+
xref="x",
|
|
148
|
+
yref="paper",
|
|
149
|
+
text=f"Base: {base_value:.2f}",
|
|
150
|
+
showarrow=False,
|
|
151
|
+
font={"size": 12, "color": COLORS["text_primary"]},
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return fig
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def create_scenario_comparison_chart(
|
|
158
|
+
scenarios: list[str],
|
|
159
|
+
values: list[float],
|
|
160
|
+
base_scenario: str | None = None,
|
|
161
|
+
title: str = "Scenario Comparison",
|
|
162
|
+
value_label: str = "CEFR",
|
|
163
|
+
height: int = 400,
|
|
164
|
+
width: int | None = None,
|
|
165
|
+
) -> go.Figure:
|
|
166
|
+
"""Create a bar chart comparing different scenarios.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
scenarios: List of scenario names
|
|
170
|
+
values: Outcome values for each scenario
|
|
171
|
+
base_scenario: Name of the base scenario (highlighted differently)
|
|
172
|
+
title: Chart title
|
|
173
|
+
value_label: Label for the outcome metric
|
|
174
|
+
height: Chart height in pixels
|
|
175
|
+
width: Chart width in pixels
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Plotly Figure object
|
|
179
|
+
"""
|
|
180
|
+
colors = []
|
|
181
|
+
for scenario in scenarios:
|
|
182
|
+
if scenario == base_scenario:
|
|
183
|
+
colors.append(COLORS["wealth_primary"])
|
|
184
|
+
elif values[scenarios.index(scenario)] >= 1.0:
|
|
185
|
+
colors.append(COLORS["success_primary"])
|
|
186
|
+
else:
|
|
187
|
+
colors.append(COLORS["warning_primary"])
|
|
188
|
+
|
|
189
|
+
fig = go.Figure()
|
|
190
|
+
|
|
191
|
+
fig.add_trace(
|
|
192
|
+
go.Bar(
|
|
193
|
+
x=scenarios,
|
|
194
|
+
y=values,
|
|
195
|
+
marker_color=colors,
|
|
196
|
+
text=[f"{v:.2f}" for v in values],
|
|
197
|
+
textposition="outside",
|
|
198
|
+
hovertemplate=(
|
|
199
|
+
"<b>%{x}</b><br>"
|
|
200
|
+
f"{value_label}: " + "%{y:.2f}<br>"
|
|
201
|
+
"<extra></extra>"
|
|
202
|
+
),
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Add threshold line at 1.0 for CEFR
|
|
207
|
+
if value_label == "CEFR":
|
|
208
|
+
fig.add_hline(
|
|
209
|
+
y=1.0,
|
|
210
|
+
line_dash="dash",
|
|
211
|
+
line_color=COLORS["neutral_primary"],
|
|
212
|
+
annotation_text="Fully Funded (1.0)",
|
|
213
|
+
annotation_position="top right",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Apply layout
|
|
217
|
+
layout = get_plotly_layout_defaults()
|
|
218
|
+
layout.update({
|
|
219
|
+
"title": {"text": title},
|
|
220
|
+
"height": height,
|
|
221
|
+
"xaxis": {
|
|
222
|
+
"title": "Scenario",
|
|
223
|
+
},
|
|
224
|
+
"yaxis": {
|
|
225
|
+
"title": value_label,
|
|
226
|
+
"gridcolor": COLORS["neutral_light"],
|
|
227
|
+
},
|
|
228
|
+
"showlegend": False,
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
if width:
|
|
232
|
+
layout["width"] = width
|
|
233
|
+
|
|
234
|
+
fig.update_layout(**layout)
|
|
235
|
+
|
|
236
|
+
return fig
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""CEFR waterfall chart visualization."""
|
|
2
|
+
|
|
3
|
+
import plotly.graph_objects as go
|
|
4
|
+
|
|
5
|
+
from fundedness.cefr import CEFRResult
|
|
6
|
+
from fundedness.viz.colors import COLORS, WATERFALL_COLORS, get_plotly_layout_defaults
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_cefr_waterfall(
|
|
10
|
+
cefr_result: CEFRResult,
|
|
11
|
+
show_liability: bool = True,
|
|
12
|
+
title: str = "CEFR Calculation Breakdown",
|
|
13
|
+
height: int = 500,
|
|
14
|
+
width: int | None = None,
|
|
15
|
+
) -> go.Figure:
|
|
16
|
+
"""Create a waterfall chart showing CEFR calculation breakdown.
|
|
17
|
+
|
|
18
|
+
Shows: Gross Assets → Tax Haircut → Liquidity Haircut → Reliability Haircut → Net Assets
|
|
19
|
+
Optionally shows liability comparison.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
cefr_result: CEFR calculation result
|
|
23
|
+
show_liability: Whether to show liability PV for comparison
|
|
24
|
+
title: Chart title
|
|
25
|
+
height: Chart height in pixels
|
|
26
|
+
width: Chart width in pixels (None = responsive)
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Plotly Figure object
|
|
30
|
+
"""
|
|
31
|
+
# Prepare data
|
|
32
|
+
labels = [
|
|
33
|
+
"Gross Assets",
|
|
34
|
+
"Tax Haircut",
|
|
35
|
+
"Liquidity Haircut",
|
|
36
|
+
"Reliability Haircut",
|
|
37
|
+
"Net Assets",
|
|
38
|
+
]
|
|
39
|
+
values = [
|
|
40
|
+
cefr_result.gross_assets,
|
|
41
|
+
-cefr_result.total_tax_haircut,
|
|
42
|
+
-cefr_result.total_liquidity_haircut,
|
|
43
|
+
-cefr_result.total_reliability_haircut,
|
|
44
|
+
cefr_result.net_assets,
|
|
45
|
+
]
|
|
46
|
+
measures = ["absolute", "relative", "relative", "relative", "total"]
|
|
47
|
+
|
|
48
|
+
if show_liability and cefr_result.liability_pv > 0:
|
|
49
|
+
labels.append("Liability PV")
|
|
50
|
+
values.append(cefr_result.liability_pv)
|
|
51
|
+
measures.append("absolute")
|
|
52
|
+
|
|
53
|
+
# Create waterfall
|
|
54
|
+
fig = go.Figure(
|
|
55
|
+
go.Waterfall(
|
|
56
|
+
name="CEFR",
|
|
57
|
+
orientation="v",
|
|
58
|
+
measure=measures,
|
|
59
|
+
x=labels,
|
|
60
|
+
textposition="outside",
|
|
61
|
+
text=[f"${abs(v):,.0f}" for v in values],
|
|
62
|
+
y=values,
|
|
63
|
+
connector={"line": {"color": COLORS["neutral_light"]}},
|
|
64
|
+
increasing={"marker": {"color": WATERFALL_COLORS["increase"]}},
|
|
65
|
+
decreasing={"marker": {"color": WATERFALL_COLORS["decrease"]}},
|
|
66
|
+
totals={"marker": {"color": WATERFALL_COLORS["total"]}},
|
|
67
|
+
hovertemplate=(
|
|
68
|
+
"<b>%{x}</b><br>"
|
|
69
|
+
"Value: $%{y:,.0f}<br>"
|
|
70
|
+
"<extra></extra>"
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Add liability reference line if shown
|
|
76
|
+
if show_liability and cefr_result.liability_pv > 0:
|
|
77
|
+
fig.add_hline(
|
|
78
|
+
y=cefr_result.liability_pv,
|
|
79
|
+
line_dash="dash",
|
|
80
|
+
line_color=COLORS["danger_secondary"],
|
|
81
|
+
annotation_text=f"Liability PV: ${cefr_result.liability_pv:,.0f}",
|
|
82
|
+
annotation_position="top right",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Apply layout
|
|
86
|
+
layout = get_plotly_layout_defaults()
|
|
87
|
+
layout.update({
|
|
88
|
+
"title": {"text": title},
|
|
89
|
+
"height": height,
|
|
90
|
+
"yaxis": {
|
|
91
|
+
"title": "Value ($)",
|
|
92
|
+
"tickformat": "$,.0f",
|
|
93
|
+
"gridcolor": COLORS["neutral_light"],
|
|
94
|
+
},
|
|
95
|
+
"xaxis": {
|
|
96
|
+
"title": "",
|
|
97
|
+
},
|
|
98
|
+
"showlegend": False,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
if width:
|
|
102
|
+
layout["width"] = width
|
|
103
|
+
|
|
104
|
+
fig.update_layout(**layout)
|
|
105
|
+
|
|
106
|
+
# Add CEFR annotation
|
|
107
|
+
cefr_text = f"CEFR: {cefr_result.cefr:.2f}"
|
|
108
|
+
if cefr_result.is_funded:
|
|
109
|
+
cefr_color = COLORS["success_primary"]
|
|
110
|
+
else:
|
|
111
|
+
cefr_color = COLORS["danger_primary"]
|
|
112
|
+
|
|
113
|
+
fig.add_annotation(
|
|
114
|
+
x=0.02,
|
|
115
|
+
y=0.98,
|
|
116
|
+
xref="paper",
|
|
117
|
+
yref="paper",
|
|
118
|
+
text=f"<b>{cefr_text}</b>",
|
|
119
|
+
showarrow=False,
|
|
120
|
+
font={"size": 16, "color": cefr_color},
|
|
121
|
+
bgcolor=COLORS["background"],
|
|
122
|
+
bordercolor=cefr_color,
|
|
123
|
+
borderwidth=2,
|
|
124
|
+
borderpad=8,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return fig
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def create_haircut_breakdown_bar(
|
|
131
|
+
cefr_result: CEFRResult,
|
|
132
|
+
title: str = "Haircut Breakdown by Category",
|
|
133
|
+
height: int = 400,
|
|
134
|
+
width: int | None = None,
|
|
135
|
+
) -> go.Figure:
|
|
136
|
+
"""Create a horizontal bar chart showing haircut breakdown.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
cefr_result: CEFR calculation result
|
|
140
|
+
title: Chart title
|
|
141
|
+
height: Chart height in pixels
|
|
142
|
+
width: Chart width in pixels (None = responsive)
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Plotly Figure object
|
|
146
|
+
"""
|
|
147
|
+
categories = ["Tax", "Liquidity", "Reliability"]
|
|
148
|
+
values = [
|
|
149
|
+
cefr_result.total_tax_haircut,
|
|
150
|
+
cefr_result.total_liquidity_haircut,
|
|
151
|
+
cefr_result.total_reliability_haircut,
|
|
152
|
+
]
|
|
153
|
+
percentages = [
|
|
154
|
+
v / cefr_result.gross_assets * 100 if cefr_result.gross_assets > 0 else 0
|
|
155
|
+
for v in values
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
colors = [
|
|
159
|
+
COLORS["warning_primary"],
|
|
160
|
+
COLORS["accent_primary"],
|
|
161
|
+
COLORS["danger_primary"],
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
fig = go.Figure()
|
|
165
|
+
|
|
166
|
+
fig.add_trace(
|
|
167
|
+
go.Bar(
|
|
168
|
+
y=categories,
|
|
169
|
+
x=values,
|
|
170
|
+
orientation="h",
|
|
171
|
+
marker_color=colors,
|
|
172
|
+
text=[f"${v:,.0f} ({p:.1f}%)" for v, p in zip(values, percentages)],
|
|
173
|
+
textposition="auto",
|
|
174
|
+
hovertemplate=(
|
|
175
|
+
"<b>%{y} Haircut</b><br>"
|
|
176
|
+
"Amount: $%{x:,.0f}<br>"
|
|
177
|
+
"<extra></extra>"
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Apply layout
|
|
183
|
+
layout = get_plotly_layout_defaults()
|
|
184
|
+
layout.update({
|
|
185
|
+
"title": {"text": title},
|
|
186
|
+
"height": height,
|
|
187
|
+
"xaxis": {
|
|
188
|
+
"title": "Haircut Amount ($)",
|
|
189
|
+
"tickformat": "$,.0f",
|
|
190
|
+
"gridcolor": COLORS["neutral_light"],
|
|
191
|
+
},
|
|
192
|
+
"yaxis": {
|
|
193
|
+
"title": "",
|
|
194
|
+
},
|
|
195
|
+
"showlegend": False,
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
if width:
|
|
199
|
+
layout["width"] = width
|
|
200
|
+
|
|
201
|
+
fig.update_layout(**layout)
|
|
202
|
+
|
|
203
|
+
return fig
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Withdrawal strategy implementations."""
|
|
2
|
+
|
|
3
|
+
from fundedness.withdrawals.base import WithdrawalContext, WithdrawalDecision, WithdrawalPolicy
|
|
4
|
+
from fundedness.withdrawals.comparison import compare_strategies
|
|
5
|
+
from fundedness.withdrawals.fixed_swr import FixedRealSWRPolicy
|
|
6
|
+
from fundedness.withdrawals.guardrails import GuardrailsPolicy
|
|
7
|
+
from fundedness.withdrawals.merton_optimal import (
|
|
8
|
+
FloorAdjustedMertonPolicy,
|
|
9
|
+
MertonOptimalSpendingPolicy,
|
|
10
|
+
SmoothedMertonPolicy,
|
|
11
|
+
)
|
|
12
|
+
from fundedness.withdrawals.rmd_style import RMDStylePolicy
|
|
13
|
+
from fundedness.withdrawals.vpw import VPWPolicy
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"compare_strategies",
|
|
17
|
+
"FixedRealSWRPolicy",
|
|
18
|
+
"FloorAdjustedMertonPolicy",
|
|
19
|
+
"GuardrailsPolicy",
|
|
20
|
+
"MertonOptimalSpendingPolicy",
|
|
21
|
+
"RMDStylePolicy",
|
|
22
|
+
"SmoothedMertonPolicy",
|
|
23
|
+
"VPWPolicy",
|
|
24
|
+
"WithdrawalContext",
|
|
25
|
+
"WithdrawalDecision",
|
|
26
|
+
"WithdrawalPolicy",
|
|
27
|
+
]
|