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,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,19 @@
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.rmd_style import RMDStylePolicy
8
+ from fundedness.withdrawals.vpw import VPWPolicy
9
+
10
+ __all__ = [
11
+ "compare_strategies",
12
+ "FixedRealSWRPolicy",
13
+ "GuardrailsPolicy",
14
+ "RMDStylePolicy",
15
+ "VPWPolicy",
16
+ "WithdrawalContext",
17
+ "WithdrawalDecision",
18
+ "WithdrawalPolicy",
19
+ ]