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.

fundedness/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """Fundedness: A Python financial planning toolkit.
2
+
3
+ This package provides tools for:
4
+ - CEFR (Certainty-Equivalent Funded Ratio) calculations
5
+ - Monte Carlo retirement simulations
6
+ - Withdrawal strategy comparison
7
+ - Beautiful Plotly visualizations
8
+ """
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ from fundedness.cefr import CEFRResult, compute_cefr
13
+ from fundedness.models import (
14
+ Asset,
15
+ BalanceSheet,
16
+ Household,
17
+ Liability,
18
+ MarketModel,
19
+ Person,
20
+ SimulationConfig,
21
+ TaxModel,
22
+ UtilityModel,
23
+ )
24
+
25
+ __all__ = [
26
+ "__version__",
27
+ "compute_cefr",
28
+ "CEFRResult",
29
+ "Asset",
30
+ "BalanceSheet",
31
+ "Household",
32
+ "Liability",
33
+ "MarketModel",
34
+ "Person",
35
+ "SimulationConfig",
36
+ "TaxModel",
37
+ "UtilityModel",
38
+ ]
@@ -0,0 +1,12 @@
1
+ """Asset allocation strategy implementations."""
2
+
3
+ from fundedness.allocation.base import AllocationPolicy
4
+ from fundedness.allocation.constant import ConstantAllocationPolicy
5
+ from fundedness.allocation.glidepath import AgeBasedGlidepathPolicy, RisingEquityGlidepathPolicy
6
+
7
+ __all__ = [
8
+ "AllocationPolicy",
9
+ "AgeBasedGlidepathPolicy",
10
+ "ConstantAllocationPolicy",
11
+ "RisingEquityGlidepathPolicy",
12
+ ]
@@ -0,0 +1,32 @@
1
+ """Base class for allocation policies."""
2
+
3
+ from typing import Protocol
4
+
5
+ import numpy as np
6
+
7
+
8
+ class AllocationPolicy(Protocol):
9
+ """Protocol defining the interface for allocation strategies."""
10
+
11
+ @property
12
+ def name(self) -> str:
13
+ """Human-readable name for the strategy."""
14
+ ...
15
+
16
+ def get_allocation(
17
+ self,
18
+ wealth: float | np.ndarray,
19
+ year: int,
20
+ initial_wealth: float,
21
+ ) -> float | np.ndarray:
22
+ """Get the stock allocation for the given context.
23
+
24
+ Args:
25
+ wealth: Current portfolio value(s)
26
+ year: Current year in simulation
27
+ initial_wealth: Starting portfolio value
28
+
29
+ Returns:
30
+ Stock allocation as decimal (0-1)
31
+ """
32
+ ...
@@ -0,0 +1,25 @@
1
+ """Constant allocation policy."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+
7
+
8
+ @dataclass
9
+ class ConstantAllocationPolicy:
10
+ """Maintain a constant stock/bond allocation."""
11
+
12
+ stock_weight: float = 0.6
13
+
14
+ @property
15
+ def name(self) -> str:
16
+ return f"{self.stock_weight:.0%} Stocks"
17
+
18
+ def get_allocation(
19
+ self,
20
+ wealth: float | np.ndarray,
21
+ year: int,
22
+ initial_wealth: float,
23
+ ) -> float:
24
+ """Return constant stock allocation."""
25
+ return self.stock_weight
@@ -0,0 +1,111 @@
1
+ """Glidepath allocation policies."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+
7
+
8
+ @dataclass
9
+ class AgeBasedGlidepathPolicy:
10
+ """Traditional declining equity glidepath based on age.
11
+
12
+ Classic approach: reduce equity allocation as you age to reduce
13
+ sequence-of-returns risk.
14
+ """
15
+
16
+ initial_stock_weight: float = 0.7
17
+ final_stock_weight: float = 0.3
18
+ years_to_final: int = 30
19
+
20
+ @property
21
+ def name(self) -> str:
22
+ return f"Glidepath ({self.initial_stock_weight:.0%} → {self.final_stock_weight:.0%})"
23
+
24
+ def get_allocation(
25
+ self,
26
+ wealth: float | np.ndarray,
27
+ year: int,
28
+ initial_wealth: float,
29
+ ) -> float:
30
+ """Calculate stock allocation based on years into retirement."""
31
+ progress = min(year / self.years_to_final, 1.0)
32
+ stock_weight = self.initial_stock_weight - progress * (
33
+ self.initial_stock_weight - self.final_stock_weight
34
+ )
35
+ return stock_weight
36
+
37
+
38
+ @dataclass
39
+ class RisingEquityGlidepathPolicy:
40
+ """Rising equity glidepath (bonds-first approach).
41
+
42
+ Start conservative, increase equity over time as sequence-of-returns
43
+ risk decreases and remaining lifespan shortens.
44
+
45
+ Based on research by Wade Pfau and Michael Kitces showing that
46
+ rising equity glidepaths can improve outcomes in some scenarios.
47
+ """
48
+
49
+ initial_stock_weight: float = 0.3
50
+ final_stock_weight: float = 0.7
51
+ years_to_final: int = 20
52
+
53
+ @property
54
+ def name(self) -> str:
55
+ return f"Rising Equity ({self.initial_stock_weight:.0%} → {self.final_stock_weight:.0%})"
56
+
57
+ def get_allocation(
58
+ self,
59
+ wealth: float | np.ndarray,
60
+ year: int,
61
+ initial_wealth: float,
62
+ ) -> float:
63
+ """Calculate stock allocation - increasing over time."""
64
+ progress = min(year / self.years_to_final, 1.0)
65
+ stock_weight = self.initial_stock_weight + progress * (
66
+ self.final_stock_weight - self.initial_stock_weight
67
+ )
68
+ return stock_weight
69
+
70
+
71
+ @dataclass
72
+ class VShapedGlidepathPolicy:
73
+ """V-shaped glidepath: reduce then increase equity.
74
+
75
+ Start moderate, reduce equity in early retirement (highest sequence risk),
76
+ then increase equity as the portfolio stabilizes and remaining horizon shortens.
77
+ """
78
+
79
+ initial_stock_weight: float = 0.5
80
+ minimum_stock_weight: float = 0.3
81
+ final_stock_weight: float = 0.6
82
+ years_to_minimum: int = 10
83
+ years_to_final: int = 30
84
+
85
+ @property
86
+ def name(self) -> str:
87
+ return "V-Shaped Glidepath"
88
+
89
+ def get_allocation(
90
+ self,
91
+ wealth: float | np.ndarray,
92
+ year: int,
93
+ initial_wealth: float,
94
+ ) -> float:
95
+ """Calculate V-shaped stock allocation."""
96
+ if year <= self.years_to_minimum:
97
+ # Declining phase
98
+ progress = year / self.years_to_minimum
99
+ stock_weight = self.initial_stock_weight - progress * (
100
+ self.initial_stock_weight - self.minimum_stock_weight
101
+ )
102
+ else:
103
+ # Rising phase
104
+ years_in_rising = year - self.years_to_minimum
105
+ years_remaining = self.years_to_final - self.years_to_minimum
106
+ progress = min(years_in_rising / years_remaining, 1.0)
107
+ stock_weight = self.minimum_stock_weight + progress * (
108
+ self.final_stock_weight - self.minimum_stock_weight
109
+ )
110
+
111
+ return stock_weight
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