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
fundedness/cefr.py ADDED
@@ -0,0 +1,241 @@
1
+ """CEFR (Certainty-Equivalent Funded Ratio) calculation engine."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from fundedness.liabilities import calculate_total_liability_pv
6
+ from fundedness.liquidity import get_liquidity_factor
7
+ from fundedness.models.assets import Asset, BalanceSheet
8
+ from fundedness.models.household import Household
9
+ from fundedness.models.liabilities import Liability
10
+ from fundedness.models.tax import TaxModel
11
+ from fundedness.risk import get_reliability_factor
12
+
13
+
14
+ @dataclass
15
+ class AssetHaircutDetail:
16
+ """Detailed haircut breakdown for a single asset."""
17
+
18
+ asset: Asset
19
+ gross_value: float
20
+ tax_rate: float
21
+ after_tax_value: float
22
+ liquidity_factor: float
23
+ after_liquidity_value: float
24
+ reliability_factor: float
25
+ net_value: float
26
+
27
+ @property
28
+ def total_haircut(self) -> float:
29
+ """Total haircut as decimal (1 - net/gross)."""
30
+ if self.gross_value == 0:
31
+ return 0.0
32
+ return 1 - (self.net_value / self.gross_value)
33
+
34
+ @property
35
+ def tax_haircut(self) -> float:
36
+ """Tax haircut amount in dollars."""
37
+ return self.gross_value - self.after_tax_value
38
+
39
+ @property
40
+ def liquidity_haircut(self) -> float:
41
+ """Liquidity haircut amount in dollars."""
42
+ return self.after_tax_value - self.after_liquidity_value
43
+
44
+ @property
45
+ def reliability_haircut(self) -> float:
46
+ """Reliability haircut amount in dollars."""
47
+ return self.after_liquidity_value - self.net_value
48
+
49
+
50
+ @dataclass
51
+ class CEFRResult:
52
+ """Complete CEFR calculation result with breakdown."""
53
+
54
+ # Main ratio
55
+ cefr: float
56
+
57
+ # Numerator components
58
+ gross_assets: float
59
+ total_tax_haircut: float
60
+ total_liquidity_haircut: float
61
+ total_reliability_haircut: float
62
+ net_assets: float
63
+
64
+ # Denominator
65
+ liability_pv: float
66
+
67
+ # Detailed breakdowns
68
+ asset_details: list[AssetHaircutDetail] = field(default_factory=list)
69
+
70
+ @property
71
+ def total_haircut(self) -> float:
72
+ """Total haircut amount."""
73
+ return self.total_tax_haircut + self.total_liquidity_haircut + self.total_reliability_haircut
74
+
75
+ @property
76
+ def haircut_percentage(self) -> float:
77
+ """Total haircut as percentage of gross assets."""
78
+ if self.gross_assets == 0:
79
+ return 0.0
80
+ return self.total_haircut / self.gross_assets
81
+
82
+ @property
83
+ def is_funded(self) -> bool:
84
+ """Whether CEFR >= 1.0 (fully funded)."""
85
+ return self.cefr >= 1.0
86
+
87
+ @property
88
+ def funding_gap(self) -> float:
89
+ """Dollar gap if underfunded (positive = gap, negative = surplus)."""
90
+ return self.liability_pv - self.net_assets
91
+
92
+ def get_interpretation(self) -> str:
93
+ """Get a human-readable interpretation of the CEFR."""
94
+ if self.cefr >= 2.0:
95
+ return "Excellent: Very well-funded with significant buffer"
96
+ elif self.cefr >= 1.5:
97
+ return "Strong: Well-funded with comfortable margin"
98
+ elif self.cefr >= 1.0:
99
+ return "Adequate: Fully funded but limited cushion"
100
+ elif self.cefr >= 0.8:
101
+ return "Marginal: Slightly underfunded, minor adjustments needed"
102
+ elif self.cefr >= 0.5:
103
+ return "Concerning: Significantly underfunded, action required"
104
+ else:
105
+ return "Critical: Severely underfunded, major changes needed"
106
+
107
+
108
+ def compute_asset_haircuts(
109
+ asset: Asset,
110
+ tax_model: TaxModel,
111
+ ) -> AssetHaircutDetail:
112
+ """Compute all haircuts for a single asset.
113
+
114
+ Args:
115
+ asset: The asset to analyze
116
+ tax_model: Tax rate assumptions
117
+
118
+ Returns:
119
+ Detailed haircut breakdown
120
+ """
121
+ gross_value = asset.value
122
+
123
+ # Step 1: Tax haircut
124
+ cost_basis_ratio = None
125
+ if asset.cost_basis is not None and asset.value > 0:
126
+ cost_basis_ratio = asset.cost_basis / asset.value
127
+
128
+ tax_rate = tax_model.get_effective_tax_rate(
129
+ account_type=asset.account_type,
130
+ cost_basis_ratio=cost_basis_ratio,
131
+ )
132
+ after_tax_value = gross_value * (1 - tax_rate)
133
+
134
+ # Step 2: Liquidity haircut
135
+ liquidity_factor = get_liquidity_factor(asset.liquidity_class)
136
+ after_liquidity_value = after_tax_value * liquidity_factor
137
+
138
+ # Step 3: Reliability haircut
139
+ reliability_factor = get_reliability_factor(
140
+ concentration_level=asset.concentration_level,
141
+ asset_class=asset.asset_class,
142
+ )
143
+ net_value = after_liquidity_value * reliability_factor
144
+
145
+ return AssetHaircutDetail(
146
+ asset=asset,
147
+ gross_value=gross_value,
148
+ tax_rate=tax_rate,
149
+ after_tax_value=after_tax_value,
150
+ liquidity_factor=liquidity_factor,
151
+ after_liquidity_value=after_liquidity_value,
152
+ reliability_factor=reliability_factor,
153
+ net_value=net_value,
154
+ )
155
+
156
+
157
+ def compute_cefr(
158
+ household: Household | None = None,
159
+ balance_sheet: BalanceSheet | None = None,
160
+ liabilities: list[Liability] | None = None,
161
+ tax_model: TaxModel | None = None,
162
+ planning_horizon: int | None = None,
163
+ real_discount_rate: float = 0.02,
164
+ base_inflation: float = 0.025,
165
+ ) -> CEFRResult:
166
+ """Compute the Certainty-Equivalent Funded Ratio (CEFR).
167
+
168
+ CEFR = Σ(Asset × (1-τ) × λ × ρ) / PV(Liabilities)
169
+
170
+ Where:
171
+ τ = tax rate
172
+ λ = liquidity factor
173
+ ρ = reliability factor
174
+
175
+ Args:
176
+ household: Complete household model (alternative to separate components)
177
+ balance_sheet: Asset holdings (if household not provided)
178
+ liabilities: Future spending obligations (if household not provided)
179
+ tax_model: Tax rate assumptions (defaults to TaxModel())
180
+ planning_horizon: Years to plan for (defaults to household horizon or 30)
181
+ real_discount_rate: Real discount rate for liability PV
182
+ base_inflation: Base inflation assumption
183
+
184
+ Returns:
185
+ CEFRResult with complete breakdown
186
+ """
187
+ # Extract components from household or use provided values
188
+ if household is not None:
189
+ balance_sheet = household.balance_sheet
190
+ liabilities = household.liabilities
191
+ if planning_horizon is None:
192
+ planning_horizon = household.planning_horizon
193
+ else:
194
+ if balance_sheet is None:
195
+ balance_sheet = BalanceSheet()
196
+ if liabilities is None:
197
+ liabilities = []
198
+
199
+ if planning_horizon is None:
200
+ planning_horizon = 30
201
+
202
+ if tax_model is None:
203
+ tax_model = TaxModel()
204
+
205
+ # Compute asset haircuts
206
+ asset_details = [
207
+ compute_asset_haircuts(asset, tax_model)
208
+ for asset in balance_sheet.assets
209
+ ]
210
+
211
+ # Aggregate numerator
212
+ gross_assets = sum(d.gross_value for d in asset_details)
213
+ total_tax_haircut = sum(d.tax_haircut for d in asset_details)
214
+ total_liquidity_haircut = sum(d.liquidity_haircut for d in asset_details)
215
+ total_reliability_haircut = sum(d.reliability_haircut for d in asset_details)
216
+ net_assets = sum(d.net_value for d in asset_details)
217
+
218
+ # Compute liability PV (denominator)
219
+ liability_pv, _ = calculate_total_liability_pv(
220
+ liabilities=liabilities,
221
+ planning_horizon=planning_horizon,
222
+ real_discount_rate=real_discount_rate,
223
+ base_inflation=base_inflation,
224
+ )
225
+
226
+ # Calculate CEFR
227
+ if liability_pv == 0:
228
+ cefr = float("inf") if net_assets > 0 else 0.0
229
+ else:
230
+ cefr = net_assets / liability_pv
231
+
232
+ return CEFRResult(
233
+ cefr=cefr,
234
+ gross_assets=gross_assets,
235
+ total_tax_haircut=total_tax_haircut,
236
+ total_liquidity_haircut=total_liquidity_haircut,
237
+ total_reliability_haircut=total_reliability_haircut,
238
+ net_assets=net_assets,
239
+ liability_pv=liability_pv,
240
+ asset_details=asset_details,
241
+ )
@@ -0,0 +1,221 @@
1
+ """Liability present value calculations."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+
7
+ from fundedness.models.liabilities import Liability
8
+
9
+
10
+ @dataclass
11
+ class LiabilityPV:
12
+ """Present value calculation result for a liability."""
13
+
14
+ liability: Liability
15
+ present_value: float
16
+ nominal_total: float
17
+ inflation_adjustment: float
18
+ discount_factor: float
19
+
20
+
21
+ def calculate_annuity_pv(
22
+ annual_payment: float,
23
+ n_years: int,
24
+ discount_rate: float,
25
+ growth_rate: float = 0.0,
26
+ start_year: int = 0,
27
+ ) -> float:
28
+ """Calculate present value of a growing annuity.
29
+
30
+ Args:
31
+ annual_payment: Annual payment amount (in today's dollars)
32
+ n_years: Number of payment years
33
+ discount_rate: Annual real discount rate (decimal)
34
+ growth_rate: Annual growth rate of payments (decimal, e.g., for inflation)
35
+ start_year: Years until first payment (0 = immediate)
36
+
37
+ Returns:
38
+ Present value of the annuity
39
+ """
40
+ if n_years <= 0:
41
+ return 0.0
42
+
43
+ if discount_rate == growth_rate:
44
+ # Special case: growing perpetuity formula doesn't apply
45
+ # Use simple sum
46
+ pv = annual_payment * n_years / ((1 + discount_rate) ** start_year)
47
+ return pv
48
+
49
+ # Present value of growing annuity formula
50
+ # PV = P * [1 - ((1+g)/(1+r))^n] / (r - g)
51
+ # where P = first payment, g = growth rate, r = discount rate, n = years
52
+
53
+ factor = ((1 + growth_rate) / (1 + discount_rate)) ** n_years
54
+ if abs(discount_rate - growth_rate) < 1e-10:
55
+ # Avoid division by near-zero
56
+ pv_factor = n_years / (1 + discount_rate)
57
+ else:
58
+ pv_factor = (1 - factor) / (discount_rate - growth_rate)
59
+
60
+ pv = annual_payment * pv_factor
61
+
62
+ # Discount back to today if payments start in the future
63
+ if start_year > 0:
64
+ pv = pv / ((1 + discount_rate) ** start_year)
65
+
66
+ return pv
67
+
68
+
69
+ def calculate_liability_pv(
70
+ liability: Liability,
71
+ planning_horizon: int,
72
+ real_discount_rate: float = 0.02,
73
+ base_inflation: float = 0.025,
74
+ ) -> LiabilityPV:
75
+ """Calculate present value of a single liability.
76
+
77
+ Args:
78
+ liability: The liability to value
79
+ planning_horizon: Total planning horizon in years
80
+ real_discount_rate: Real discount rate (decimal)
81
+ base_inflation: Base CPI inflation assumption (decimal)
82
+
83
+ Returns:
84
+ LiabilityPV with calculation details
85
+ """
86
+ # Determine duration
87
+ end_year = liability.end_year if liability.end_year is not None else planning_horizon
88
+ n_years = max(0, end_year - liability.start_year)
89
+
90
+ if n_years <= 0:
91
+ return LiabilityPV(
92
+ liability=liability,
93
+ present_value=0.0,
94
+ nominal_total=0.0,
95
+ inflation_adjustment=1.0,
96
+ discount_factor=1.0,
97
+ )
98
+
99
+ # Get inflation rate for this liability
100
+ inflation_rate = liability.get_inflation_rate(base_inflation)
101
+
102
+ # Calculate PV
103
+ pv = calculate_annuity_pv(
104
+ annual_payment=liability.annual_amount,
105
+ n_years=n_years,
106
+ discount_rate=real_discount_rate,
107
+ growth_rate=inflation_rate - base_inflation, # Real growth above CPI
108
+ start_year=liability.start_year,
109
+ )
110
+
111
+ # Apply probability adjustment
112
+ pv *= liability.probability
113
+
114
+ # Calculate nominal total for reference
115
+ nominal_total = liability.annual_amount * n_years
116
+
117
+ # Calculate inflation adjustment factor
118
+ avg_inflation_factor = (1 + inflation_rate) ** (n_years / 2)
119
+
120
+ # Calculate average discount factor
121
+ avg_discount_factor = 1 / ((1 + real_discount_rate) ** (liability.start_year + n_years / 2))
122
+
123
+ return LiabilityPV(
124
+ liability=liability,
125
+ present_value=pv,
126
+ nominal_total=nominal_total,
127
+ inflation_adjustment=avg_inflation_factor,
128
+ discount_factor=avg_discount_factor,
129
+ )
130
+
131
+
132
+ def calculate_total_liability_pv(
133
+ liabilities: list[Liability],
134
+ planning_horizon: int,
135
+ real_discount_rate: float = 0.02,
136
+ base_inflation: float = 0.025,
137
+ ) -> tuple[float, list[LiabilityPV]]:
138
+ """Calculate total present value of all liabilities.
139
+
140
+ Args:
141
+ liabilities: List of liabilities to value
142
+ planning_horizon: Total planning horizon in years
143
+ real_discount_rate: Real discount rate (decimal)
144
+ base_inflation: Base CPI inflation assumption (decimal)
145
+
146
+ Returns:
147
+ Tuple of (total_pv, list of LiabilityPV details)
148
+ """
149
+ details = [
150
+ calculate_liability_pv(
151
+ liability=liability,
152
+ planning_horizon=planning_horizon,
153
+ real_discount_rate=real_discount_rate,
154
+ base_inflation=base_inflation,
155
+ )
156
+ for liability in liabilities
157
+ ]
158
+
159
+ total_pv = sum(d.present_value for d in details)
160
+
161
+ return total_pv, details
162
+
163
+
164
+ def calculate_essential_liability_pv(
165
+ liabilities: list[Liability],
166
+ planning_horizon: int,
167
+ real_discount_rate: float = 0.02,
168
+ base_inflation: float = 0.025,
169
+ ) -> float:
170
+ """Calculate present value of essential (floor) liabilities only.
171
+
172
+ Args:
173
+ liabilities: List of all liabilities
174
+ planning_horizon: Total planning horizon in years
175
+ real_discount_rate: Real discount rate (decimal)
176
+ base_inflation: Base CPI inflation assumption (decimal)
177
+
178
+ Returns:
179
+ Present value of essential liabilities
180
+ """
181
+ essential = [l for l in liabilities if l.is_essential]
182
+ total_pv, _ = calculate_total_liability_pv(
183
+ liabilities=essential,
184
+ planning_horizon=planning_horizon,
185
+ real_discount_rate=real_discount_rate,
186
+ base_inflation=base_inflation,
187
+ )
188
+ return total_pv
189
+
190
+
191
+ def generate_liability_schedule(
192
+ liabilities: list[Liability],
193
+ n_years: int,
194
+ base_inflation: float = 0.025,
195
+ ) -> np.ndarray:
196
+ """Generate year-by-year liability schedule.
197
+
198
+ Args:
199
+ liabilities: List of liabilities
200
+ n_years: Number of years to project
201
+ base_inflation: Base CPI inflation assumption
202
+
203
+ Returns:
204
+ Array of shape (n_years,) with total liability per year
205
+ """
206
+ schedule = np.zeros(n_years)
207
+
208
+ for liability in liabilities:
209
+ inflation_rate = liability.get_inflation_rate(base_inflation)
210
+ end_year = min(
211
+ liability.end_year if liability.end_year is not None else n_years,
212
+ n_years,
213
+ )
214
+
215
+ for year in range(liability.start_year, end_year):
216
+ if year < n_years:
217
+ # Adjust for inflation from year 0
218
+ inflation_factor = (1 + inflation_rate) ** year
219
+ schedule[year] += liability.annual_amount * inflation_factor * liability.probability
220
+
221
+ return schedule
@@ -0,0 +1,49 @@
1
+ """Liquidity factor mappings for CEFR calculations."""
2
+
3
+ from fundedness.models.assets import LiquidityClass
4
+
5
+ # Default liquidity factors by class
6
+ # These represent the fraction of asset value that can be readily accessed
7
+ DEFAULT_LIQUIDITY_FACTORS: dict[LiquidityClass, float] = {
8
+ LiquidityClass.CASH: 1.0, # Immediately liquid
9
+ LiquidityClass.TAXABLE_INDEX: 0.95, # Small trading costs
10
+ LiquidityClass.RETIREMENT: 0.85, # Early withdrawal penalties, RMD constraints
11
+ LiquidityClass.HOME_EQUITY: 0.50, # HELOC access, selling costs
12
+ LiquidityClass.PRIVATE_BUSINESS: 0.30, # Very illiquid, long sale process
13
+ LiquidityClass.RESTRICTED: 0.20, # Vesting constraints, lockups
14
+ }
15
+
16
+
17
+ def get_liquidity_factor(
18
+ liquidity_class: LiquidityClass,
19
+ custom_factors: dict[LiquidityClass, float] | None = None,
20
+ ) -> float:
21
+ """Get the liquidity factor for an asset class.
22
+
23
+ Args:
24
+ liquidity_class: The liquidity classification of the asset
25
+ custom_factors: Optional custom factor overrides
26
+
27
+ Returns:
28
+ Liquidity factor between 0 and 1
29
+ """
30
+ if custom_factors and liquidity_class in custom_factors:
31
+ return custom_factors[liquidity_class]
32
+ return DEFAULT_LIQUIDITY_FACTORS.get(liquidity_class, 1.0)
33
+
34
+
35
+ def get_all_liquidity_factors(
36
+ custom_factors: dict[LiquidityClass, float] | None = None,
37
+ ) -> dict[LiquidityClass, float]:
38
+ """Get all liquidity factors with optional overrides.
39
+
40
+ Args:
41
+ custom_factors: Optional custom factor overrides
42
+
43
+ Returns:
44
+ Dictionary of liquidity class to factor
45
+ """
46
+ factors = DEFAULT_LIQUIDITY_FACTORS.copy()
47
+ if custom_factors:
48
+ factors.update(custom_factors)
49
+ return factors