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 +38 -0
- fundedness/allocation/__init__.py +12 -0
- fundedness/allocation/base.py +32 -0
- fundedness/allocation/constant.py +25 -0
- fundedness/allocation/glidepath.py +111 -0
- fundedness/cefr.py +241 -0
- fundedness/liabilities.py +221 -0
- fundedness/liquidity.py +49 -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/policies.py +204 -0
- fundedness/risk.py +72 -0
- fundedness/simulate.py +401 -0
- fundedness/viz/__init__.py +19 -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/survival.py +230 -0
- fundedness/viz/tornado.py +236 -0
- fundedness/viz/waterfall.py +203 -0
- fundedness/withdrawals/__init__.py +19 -0
- fundedness/withdrawals/base.py +116 -0
- fundedness/withdrawals/comparison.py +230 -0
- fundedness/withdrawals/fixed_swr.py +113 -0
- fundedness/withdrawals/guardrails.py +136 -0
- fundedness/withdrawals/rmd_style.py +203 -0
- fundedness/withdrawals/vpw.py +136 -0
- fundedness-0.1.0.dist-info/METADATA +233 -0
- fundedness-0.1.0.dist-info/RECORD +38 -0
- fundedness-0.1.0.dist-info/WHEEL +4 -0
- fundedness-0.1.0.dist-info/entry_points.txt +2 -0
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
|