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.
- 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 +188 -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 +559 -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.2.dist-info/METADATA +299 -0
- fundedness-0.2.2.dist-info/RECORD +43 -0
- fundedness-0.2.2.dist-info/WHEEL +4 -0
- 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
|
fundedness/liquidity.py
ADDED
|
@@ -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
|