fundedness 0.2.4__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.
- 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 +199 -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 +595 -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.4.dist-info/METADATA +300 -0
- fundedness-0.2.4.dist-info/RECORD +43 -0
- fundedness-0.2.4.dist-info/WHEEL +4 -0
- fundedness-0.2.4.dist-info/entry_points.txt +2 -0
fundedness/merton.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Merton optimal consumption and portfolio choice formulas.
|
|
2
|
+
|
|
3
|
+
Implements the analytical solutions from Robert Merton's continuous-time
|
|
4
|
+
portfolio optimization framework for retirement planning.
|
|
5
|
+
|
|
6
|
+
References:
|
|
7
|
+
- Merton, R.C. (1969). Lifetime Portfolio Selection under Uncertainty.
|
|
8
|
+
- Haghani, V. & White, J. (2023). The Missing Billionaires. Wiley.
|
|
9
|
+
|
|
10
|
+
Key formulas:
|
|
11
|
+
- Optimal equity allocation: k* = (mu - r) / (gamma * sigma^2)
|
|
12
|
+
- Certainty equivalent return: rce = r + k*(mu - r) - gamma*k^2*sigma^2/2
|
|
13
|
+
- Optimal spending rate: c* = rce - (rce - rtp) / gamma
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from fundedness.models.market import MarketModel
|
|
21
|
+
from fundedness.models.utility import UtilityModel
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class MertonOptimalResult:
|
|
26
|
+
"""Results from Merton optimal calculations."""
|
|
27
|
+
|
|
28
|
+
optimal_equity_allocation: float
|
|
29
|
+
certainty_equivalent_return: float
|
|
30
|
+
optimal_spending_rate: float
|
|
31
|
+
wealth_adjusted_allocation: float
|
|
32
|
+
risk_premium: float
|
|
33
|
+
portfolio_volatility: float
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def merton_optimal_allocation(
|
|
37
|
+
market_model: MarketModel,
|
|
38
|
+
utility_model: UtilityModel,
|
|
39
|
+
) -> float:
|
|
40
|
+
"""Calculate Merton optimal equity allocation.
|
|
41
|
+
|
|
42
|
+
The Merton formula gives the optimal fraction to invest in risky assets:
|
|
43
|
+
k* = (mu - r) / (gamma * sigma^2)
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
market_model: Market return and risk assumptions
|
|
47
|
+
utility_model: Utility parameters including risk aversion
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Optimal equity allocation as decimal (can exceed 1.0 for leveraged)
|
|
51
|
+
"""
|
|
52
|
+
mu = market_model.stock_return
|
|
53
|
+
r = market_model.bond_return
|
|
54
|
+
gamma = utility_model.gamma
|
|
55
|
+
sigma = market_model.stock_volatility
|
|
56
|
+
|
|
57
|
+
if sigma == 0 or gamma == 0:
|
|
58
|
+
return 0.0
|
|
59
|
+
|
|
60
|
+
k_star = (mu - r) / (gamma * sigma**2)
|
|
61
|
+
|
|
62
|
+
return k_star
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def certainty_equivalent_return(
|
|
66
|
+
market_model: MarketModel,
|
|
67
|
+
utility_model: UtilityModel,
|
|
68
|
+
equity_allocation: float | None = None,
|
|
69
|
+
) -> float:
|
|
70
|
+
"""Calculate certainty equivalent return for a portfolio.
|
|
71
|
+
|
|
72
|
+
The certainty equivalent return is the guaranteed return that provides
|
|
73
|
+
the same expected utility as the risky portfolio:
|
|
74
|
+
rce = r + k*(mu - r) - gamma*k^2*sigma^2/2
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
market_model: Market return and risk assumptions
|
|
78
|
+
utility_model: Utility parameters including risk aversion
|
|
79
|
+
equity_allocation: Equity allocation (uses optimal if None)
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Certainty equivalent return as decimal
|
|
83
|
+
"""
|
|
84
|
+
if equity_allocation is None:
|
|
85
|
+
equity_allocation = merton_optimal_allocation(market_model, utility_model)
|
|
86
|
+
|
|
87
|
+
mu = market_model.stock_return
|
|
88
|
+
r = market_model.bond_return
|
|
89
|
+
gamma = utility_model.gamma
|
|
90
|
+
sigma = market_model.stock_volatility
|
|
91
|
+
|
|
92
|
+
k = equity_allocation
|
|
93
|
+
risk_premium = k * (mu - r)
|
|
94
|
+
risk_penalty = gamma * k**2 * sigma**2 / 2
|
|
95
|
+
|
|
96
|
+
rce = r + risk_premium - risk_penalty
|
|
97
|
+
|
|
98
|
+
return rce
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def merton_optimal_spending_rate(
|
|
102
|
+
market_model: MarketModel,
|
|
103
|
+
utility_model: UtilityModel,
|
|
104
|
+
remaining_years: float | None = None,
|
|
105
|
+
) -> float:
|
|
106
|
+
"""Calculate Merton optimal spending rate.
|
|
107
|
+
|
|
108
|
+
The optimal spending rate for an infinite horizon is:
|
|
109
|
+
c* = rce - (rce - rtp) / gamma
|
|
110
|
+
|
|
111
|
+
For finite horizons, the rate is adjusted upward as horizon shortens.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
market_model: Market return and risk assumptions
|
|
115
|
+
utility_model: Utility parameters including risk aversion and time preference
|
|
116
|
+
remaining_years: Years until planning horizon ends (None for infinite)
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Optimal spending rate as decimal (e.g., 0.03 = 3%)
|
|
120
|
+
"""
|
|
121
|
+
rce = certainty_equivalent_return(market_model, utility_model)
|
|
122
|
+
rtp = utility_model.time_preference
|
|
123
|
+
gamma = utility_model.gamma
|
|
124
|
+
|
|
125
|
+
if gamma == 1.0:
|
|
126
|
+
# Log utility special case
|
|
127
|
+
c_star = rtp
|
|
128
|
+
else:
|
|
129
|
+
c_star = rce - (rce - rtp) / gamma
|
|
130
|
+
|
|
131
|
+
# Finite horizon adjustment
|
|
132
|
+
if remaining_years is not None and remaining_years > 0:
|
|
133
|
+
# Use annuity factor to increase spending rate for finite horizon
|
|
134
|
+
# c_finite = c_infinite + 1 / remaining_years (approximate)
|
|
135
|
+
if rce > 0:
|
|
136
|
+
# Annuity present value factor
|
|
137
|
+
pv_factor = (1 - (1 + rce) ** (-remaining_years)) / rce
|
|
138
|
+
if pv_factor > 0:
|
|
139
|
+
annuity_rate = 1 / pv_factor
|
|
140
|
+
c_star = max(c_star, annuity_rate)
|
|
141
|
+
else:
|
|
142
|
+
# With non-positive returns, simple 1/N rule
|
|
143
|
+
c_star = max(c_star, 1 / remaining_years)
|
|
144
|
+
|
|
145
|
+
return max(c_star, 0.0) # Can't have negative spending
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def wealth_adjusted_optimal_allocation(
|
|
149
|
+
wealth: float,
|
|
150
|
+
market_model: MarketModel,
|
|
151
|
+
utility_model: UtilityModel,
|
|
152
|
+
min_allocation: float = 0.0,
|
|
153
|
+
max_allocation: float = 1.0,
|
|
154
|
+
) -> float:
|
|
155
|
+
"""Calculate wealth-adjusted optimal equity allocation.
|
|
156
|
+
|
|
157
|
+
Near the subsistence floor, the optimal allocation approaches zero
|
|
158
|
+
because the investor cannot afford to take risk. As wealth rises
|
|
159
|
+
above the floor, allocation approaches the unconstrained Merton optimal.
|
|
160
|
+
|
|
161
|
+
The formula is:
|
|
162
|
+
k_adjusted = k* * (W - F) / W
|
|
163
|
+
|
|
164
|
+
Where W is wealth and F is the subsistence floor.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
wealth: Current portfolio value
|
|
168
|
+
market_model: Market return and risk assumptions
|
|
169
|
+
utility_model: Utility parameters
|
|
170
|
+
min_allocation: Minimum equity allocation (floor)
|
|
171
|
+
max_allocation: Maximum equity allocation (ceiling)
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Adjusted equity allocation as decimal, bounded by min/max
|
|
175
|
+
"""
|
|
176
|
+
k_star = merton_optimal_allocation(market_model, utility_model)
|
|
177
|
+
floor = utility_model.subsistence_floor
|
|
178
|
+
|
|
179
|
+
if wealth <= floor:
|
|
180
|
+
return min_allocation
|
|
181
|
+
|
|
182
|
+
# Scale by distance from floor
|
|
183
|
+
wealth_ratio = (wealth - floor) / wealth
|
|
184
|
+
k_adjusted = k_star * wealth_ratio
|
|
185
|
+
|
|
186
|
+
# Apply bounds
|
|
187
|
+
return np.clip(k_adjusted, min_allocation, max_allocation)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def calculate_merton_optimal(
|
|
191
|
+
wealth: float,
|
|
192
|
+
market_model: MarketModel,
|
|
193
|
+
utility_model: UtilityModel,
|
|
194
|
+
remaining_years: float | None = None,
|
|
195
|
+
) -> MertonOptimalResult:
|
|
196
|
+
"""Calculate all Merton optimal values for given wealth.
|
|
197
|
+
|
|
198
|
+
This is the main entry point for getting all optimal policy parameters.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
wealth: Current portfolio value
|
|
202
|
+
market_model: Market return and risk assumptions
|
|
203
|
+
utility_model: Utility parameters
|
|
204
|
+
remaining_years: Years until planning horizon ends
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
MertonOptimalResult with all optimal values
|
|
208
|
+
"""
|
|
209
|
+
k_star = merton_optimal_allocation(market_model, utility_model)
|
|
210
|
+
rce = certainty_equivalent_return(market_model, utility_model)
|
|
211
|
+
c_star = merton_optimal_spending_rate(market_model, utility_model, remaining_years)
|
|
212
|
+
k_adjusted = wealth_adjusted_optimal_allocation(wealth, market_model, utility_model)
|
|
213
|
+
|
|
214
|
+
risk_premium = market_model.stock_return - market_model.bond_return
|
|
215
|
+
portfolio_vol = k_star * market_model.stock_volatility
|
|
216
|
+
|
|
217
|
+
return MertonOptimalResult(
|
|
218
|
+
optimal_equity_allocation=k_star,
|
|
219
|
+
certainty_equivalent_return=rce,
|
|
220
|
+
optimal_spending_rate=c_star,
|
|
221
|
+
wealth_adjusted_allocation=k_adjusted,
|
|
222
|
+
risk_premium=risk_premium,
|
|
223
|
+
portfolio_volatility=portfolio_vol,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def optimal_spending_by_age(
|
|
228
|
+
market_model: MarketModel,
|
|
229
|
+
utility_model: UtilityModel,
|
|
230
|
+
starting_age: int,
|
|
231
|
+
end_age: int = 100,
|
|
232
|
+
) -> dict[int, float]:
|
|
233
|
+
"""Calculate optimal spending rates for each age.
|
|
234
|
+
|
|
235
|
+
Spending rate increases with age as the remaining horizon shortens.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
market_model: Market return and risk assumptions
|
|
239
|
+
utility_model: Utility parameters
|
|
240
|
+
starting_age: Current age
|
|
241
|
+
end_age: Assumed maximum age
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Dictionary mapping age to optimal spending rate
|
|
245
|
+
"""
|
|
246
|
+
rates = {}
|
|
247
|
+
for age in range(starting_age, end_age + 1):
|
|
248
|
+
remaining_years = end_age - age
|
|
249
|
+
if remaining_years <= 0:
|
|
250
|
+
rates[age] = 1.0 # Spend everything at end
|
|
251
|
+
else:
|
|
252
|
+
rates[age] = merton_optimal_spending_rate(
|
|
253
|
+
market_model, utility_model, remaining_years
|
|
254
|
+
)
|
|
255
|
+
return rates
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def optimal_allocation_by_wealth(
|
|
259
|
+
market_model: MarketModel,
|
|
260
|
+
utility_model: UtilityModel,
|
|
261
|
+
wealth_levels: np.ndarray,
|
|
262
|
+
min_allocation: float = 0.0,
|
|
263
|
+
max_allocation: float = 1.0,
|
|
264
|
+
) -> np.ndarray:
|
|
265
|
+
"""Calculate optimal allocation for a range of wealth levels.
|
|
266
|
+
|
|
267
|
+
Useful for generating allocation curves showing how equity percentage
|
|
268
|
+
should vary with distance from subsistence floor.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
market_model: Market return and risk assumptions
|
|
272
|
+
utility_model: Utility parameters
|
|
273
|
+
wealth_levels: Array of wealth values to calculate for
|
|
274
|
+
min_allocation: Minimum equity allocation
|
|
275
|
+
max_allocation: Maximum equity allocation
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
Array of optimal allocations corresponding to wealth_levels
|
|
279
|
+
"""
|
|
280
|
+
allocations = np.zeros_like(wealth_levels, dtype=float)
|
|
281
|
+
for i, wealth in enumerate(wealth_levels):
|
|
282
|
+
allocations[i] = wealth_adjusted_optimal_allocation(
|
|
283
|
+
wealth=wealth,
|
|
284
|
+
market_model=market_model,
|
|
285
|
+
utility_model=utility_model,
|
|
286
|
+
min_allocation=min_allocation,
|
|
287
|
+
max_allocation=max_allocation,
|
|
288
|
+
)
|
|
289
|
+
return allocations
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Pydantic data models for the fundedness package."""
|
|
2
|
+
|
|
3
|
+
from fundedness.models.assets import (
|
|
4
|
+
AccountType,
|
|
5
|
+
Asset,
|
|
6
|
+
AssetClass,
|
|
7
|
+
BalanceSheet,
|
|
8
|
+
ConcentrationLevel,
|
|
9
|
+
LiquidityClass,
|
|
10
|
+
)
|
|
11
|
+
from fundedness.models.household import Household, Person
|
|
12
|
+
from fundedness.models.liabilities import InflationLinkage, Liability, LiabilityType
|
|
13
|
+
from fundedness.models.market import MarketModel
|
|
14
|
+
from fundedness.models.simulation import SimulationConfig
|
|
15
|
+
from fundedness.models.tax import TaxModel
|
|
16
|
+
from fundedness.models.utility import UtilityModel
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AccountType",
|
|
20
|
+
"Asset",
|
|
21
|
+
"AssetClass",
|
|
22
|
+
"BalanceSheet",
|
|
23
|
+
"ConcentrationLevel",
|
|
24
|
+
"Household",
|
|
25
|
+
"InflationLinkage",
|
|
26
|
+
"Liability",
|
|
27
|
+
"LiabilityClass",
|
|
28
|
+
"LiabilityType",
|
|
29
|
+
"LiquidityClass",
|
|
30
|
+
"MarketModel",
|
|
31
|
+
"Person",
|
|
32
|
+
"SimulationConfig",
|
|
33
|
+
"TaxModel",
|
|
34
|
+
"UtilityModel",
|
|
35
|
+
]
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Asset and balance sheet models."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field, field_validator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AccountType(str, Enum):
|
|
10
|
+
"""Tax treatment of an account."""
|
|
11
|
+
|
|
12
|
+
TAXABLE = "taxable"
|
|
13
|
+
TAX_DEFERRED = "tax_deferred" # Traditional IRA, 401(k)
|
|
14
|
+
TAX_EXEMPT = "tax_exempt" # Roth IRA, Roth 401(k)
|
|
15
|
+
HSA = "hsa"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AssetClass(str, Enum):
|
|
19
|
+
"""Broad asset class categories."""
|
|
20
|
+
|
|
21
|
+
CASH = "cash"
|
|
22
|
+
BONDS = "bonds"
|
|
23
|
+
STOCKS = "stocks"
|
|
24
|
+
REAL_ESTATE = "real_estate"
|
|
25
|
+
ALTERNATIVES = "alternatives"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LiquidityClass(str, Enum):
|
|
29
|
+
"""Liquidity classification for assets."""
|
|
30
|
+
|
|
31
|
+
CASH = "cash" # Immediate liquidity
|
|
32
|
+
TAXABLE_INDEX = "taxable_index" # Public securities in taxable accounts
|
|
33
|
+
RETIREMENT = "retirement" # Tax-advantaged retirement accounts
|
|
34
|
+
HOME_EQUITY = "home_equity" # Primary residence equity
|
|
35
|
+
PRIVATE_BUSINESS = "private_business" # Illiquid business interests
|
|
36
|
+
RESTRICTED = "restricted" # Restricted stock, vesting schedules
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ConcentrationLevel(str, Enum):
|
|
40
|
+
"""Concentration/diversification level."""
|
|
41
|
+
|
|
42
|
+
DIVERSIFIED = "diversified" # Broad index funds
|
|
43
|
+
SECTOR = "sector" # Sector-specific concentration
|
|
44
|
+
SINGLE_STOCK = "single_stock" # Individual company stock
|
|
45
|
+
STARTUP = "startup" # Early-stage company equity
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Asset(BaseModel):
|
|
49
|
+
"""A single asset holding."""
|
|
50
|
+
|
|
51
|
+
name: str = Field(..., description="Descriptive name for the asset")
|
|
52
|
+
value: float = Field(..., ge=0, description="Current market value in dollars")
|
|
53
|
+
account_type: AccountType = Field(
|
|
54
|
+
default=AccountType.TAXABLE,
|
|
55
|
+
description="Tax treatment of the account",
|
|
56
|
+
)
|
|
57
|
+
asset_class: AssetClass = Field(
|
|
58
|
+
default=AssetClass.STOCKS,
|
|
59
|
+
description="Broad asset class category",
|
|
60
|
+
)
|
|
61
|
+
liquidity_class: LiquidityClass = Field(
|
|
62
|
+
default=LiquidityClass.TAXABLE_INDEX,
|
|
63
|
+
description="Liquidity classification",
|
|
64
|
+
)
|
|
65
|
+
concentration_level: ConcentrationLevel = Field(
|
|
66
|
+
default=ConcentrationLevel.DIVERSIFIED,
|
|
67
|
+
description="Concentration/diversification level",
|
|
68
|
+
)
|
|
69
|
+
cost_basis: Optional[float] = Field(
|
|
70
|
+
default=None,
|
|
71
|
+
ge=0,
|
|
72
|
+
description="Cost basis for tax calculations (taxable accounts only)",
|
|
73
|
+
)
|
|
74
|
+
expected_return: Optional[float] = Field(
|
|
75
|
+
default=None,
|
|
76
|
+
description="Override expected return (annual, decimal)",
|
|
77
|
+
)
|
|
78
|
+
volatility: Optional[float] = Field(
|
|
79
|
+
default=None,
|
|
80
|
+
ge=0,
|
|
81
|
+
description="Override volatility (annual standard deviation, decimal)",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
@field_validator("cost_basis")
|
|
85
|
+
@classmethod
|
|
86
|
+
def validate_cost_basis(cls, v: Optional[float], info) -> Optional[float]:
|
|
87
|
+
"""Warn if cost basis exceeds value (unrealized loss)."""
|
|
88
|
+
return v
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def unrealized_gain(self) -> Optional[float]:
|
|
92
|
+
"""Calculate unrealized gain/loss if cost basis is known."""
|
|
93
|
+
if self.cost_basis is None:
|
|
94
|
+
return None
|
|
95
|
+
return self.value - self.cost_basis
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class BalanceSheet(BaseModel):
|
|
99
|
+
"""Collection of assets representing a household's balance sheet."""
|
|
100
|
+
|
|
101
|
+
assets: list[Asset] = Field(default_factory=list, description="List of asset holdings")
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def total_value(self) -> float:
|
|
105
|
+
"""Total market value of all assets."""
|
|
106
|
+
return sum(asset.value for asset in self.assets)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def by_account_type(self) -> dict[AccountType, float]:
|
|
110
|
+
"""Total value by account type."""
|
|
111
|
+
result: dict[AccountType, float] = {}
|
|
112
|
+
for asset in self.assets:
|
|
113
|
+
result[asset.account_type] = result.get(asset.account_type, 0) + asset.value
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def by_asset_class(self) -> dict[AssetClass, float]:
|
|
118
|
+
"""Total value by asset class."""
|
|
119
|
+
result: dict[AssetClass, float] = {}
|
|
120
|
+
for asset in self.assets:
|
|
121
|
+
result[asset.asset_class] = result.get(asset.asset_class, 0) + asset.value
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def by_liquidity_class(self) -> dict[LiquidityClass, float]:
|
|
126
|
+
"""Total value by liquidity class."""
|
|
127
|
+
result: dict[LiquidityClass, float] = {}
|
|
128
|
+
for asset in self.assets:
|
|
129
|
+
result[asset.liquidity_class] = result.get(asset.liquidity_class, 0) + asset.value
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
def get_stock_allocation(self) -> float:
|
|
133
|
+
"""Calculate percentage allocated to stocks."""
|
|
134
|
+
if self.total_value == 0:
|
|
135
|
+
return 0.0
|
|
136
|
+
stock_value = sum(
|
|
137
|
+
asset.value for asset in self.assets if asset.asset_class == AssetClass.STOCKS
|
|
138
|
+
)
|
|
139
|
+
return stock_value / self.total_value
|
|
140
|
+
|
|
141
|
+
def get_bond_allocation(self) -> float:
|
|
142
|
+
"""Calculate percentage allocated to bonds."""
|
|
143
|
+
if self.total_value == 0:
|
|
144
|
+
return 0.0
|
|
145
|
+
bond_value = sum(
|
|
146
|
+
asset.value for asset in self.assets if asset.asset_class == AssetClass.BONDS
|
|
147
|
+
)
|
|
148
|
+
return bond_value / self.total_value
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Household and person models."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field, model_validator
|
|
6
|
+
|
|
7
|
+
from fundedness.models.assets import BalanceSheet
|
|
8
|
+
from fundedness.models.liabilities import Liability
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Person(BaseModel):
|
|
12
|
+
"""An individual person in the household."""
|
|
13
|
+
|
|
14
|
+
name: str = Field(..., description="Person's name")
|
|
15
|
+
age: int = Field(..., ge=0, le=120, description="Current age")
|
|
16
|
+
retirement_age: Optional[int] = Field(
|
|
17
|
+
default=None,
|
|
18
|
+
ge=0,
|
|
19
|
+
le=120,
|
|
20
|
+
description="Expected retirement age (None if already retired)",
|
|
21
|
+
)
|
|
22
|
+
life_expectancy: int = Field(
|
|
23
|
+
default=95,
|
|
24
|
+
ge=0,
|
|
25
|
+
le=120,
|
|
26
|
+
description="Planning life expectancy",
|
|
27
|
+
)
|
|
28
|
+
social_security_age: int = Field(
|
|
29
|
+
default=67,
|
|
30
|
+
ge=62,
|
|
31
|
+
le=70,
|
|
32
|
+
description="Age to claim Social Security",
|
|
33
|
+
)
|
|
34
|
+
social_security_annual: float = Field(
|
|
35
|
+
default=0,
|
|
36
|
+
ge=0,
|
|
37
|
+
description="Expected annual Social Security benefit at claiming age (today's dollars)",
|
|
38
|
+
)
|
|
39
|
+
pension_annual: float = Field(
|
|
40
|
+
default=0,
|
|
41
|
+
ge=0,
|
|
42
|
+
description="Expected annual pension benefit (today's dollars)",
|
|
43
|
+
)
|
|
44
|
+
pension_start_age: Optional[int] = Field(
|
|
45
|
+
default=None,
|
|
46
|
+
ge=0,
|
|
47
|
+
le=120,
|
|
48
|
+
description="Age when pension payments begin",
|
|
49
|
+
)
|
|
50
|
+
is_primary: bool = Field(
|
|
51
|
+
default=True,
|
|
52
|
+
description="Whether this is the primary earner/planner",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@model_validator(mode="after")
|
|
56
|
+
def validate_ages(self) -> "Person":
|
|
57
|
+
"""Validate age relationships."""
|
|
58
|
+
if self.retirement_age is not None and self.retirement_age < self.age:
|
|
59
|
+
# Already past retirement age, treat as retired
|
|
60
|
+
self.retirement_age = None
|
|
61
|
+
if self.life_expectancy < self.age:
|
|
62
|
+
raise ValueError("Life expectancy must be greater than current age")
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def years_to_retirement(self) -> int:
|
|
67
|
+
"""Years until retirement (0 if already retired)."""
|
|
68
|
+
if self.retirement_age is None:
|
|
69
|
+
return 0
|
|
70
|
+
return max(0, self.retirement_age - self.age)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def years_in_retirement(self) -> int:
|
|
74
|
+
"""Expected years in retirement."""
|
|
75
|
+
retirement_age = self.retirement_age or self.age
|
|
76
|
+
return max(0, self.life_expectancy - retirement_age)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def planning_horizon(self) -> int:
|
|
80
|
+
"""Total years in planning horizon."""
|
|
81
|
+
return max(0, self.life_expectancy - self.age)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Household(BaseModel):
|
|
85
|
+
"""A household unit for financial planning."""
|
|
86
|
+
|
|
87
|
+
name: str = Field(
|
|
88
|
+
default="My Household",
|
|
89
|
+
description="Household name",
|
|
90
|
+
)
|
|
91
|
+
members: list[Person] = Field(
|
|
92
|
+
default_factory=list,
|
|
93
|
+
description="Household members",
|
|
94
|
+
)
|
|
95
|
+
balance_sheet: BalanceSheet = Field(
|
|
96
|
+
default_factory=BalanceSheet,
|
|
97
|
+
description="Household balance sheet",
|
|
98
|
+
)
|
|
99
|
+
liabilities: list[Liability] = Field(
|
|
100
|
+
default_factory=list,
|
|
101
|
+
description="Future spending obligations",
|
|
102
|
+
)
|
|
103
|
+
state: str = Field(
|
|
104
|
+
default="CA",
|
|
105
|
+
description="State of residence (for tax calculations)",
|
|
106
|
+
)
|
|
107
|
+
filing_status: str = Field(
|
|
108
|
+
default="married_filing_jointly",
|
|
109
|
+
description="Tax filing status",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def primary_member(self) -> Optional[Person]:
|
|
114
|
+
"""Get the primary household member."""
|
|
115
|
+
for member in self.members:
|
|
116
|
+
if member.is_primary:
|
|
117
|
+
return member
|
|
118
|
+
return self.members[0] if self.members else None
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def planning_horizon(self) -> int:
|
|
122
|
+
"""Planning horizon based on longest-lived member."""
|
|
123
|
+
if not self.members:
|
|
124
|
+
return 30 # Default
|
|
125
|
+
return max(member.planning_horizon for member in self.members)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def total_assets(self) -> float:
|
|
129
|
+
"""Total asset value."""
|
|
130
|
+
return self.balance_sheet.total_value
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def essential_spending(self) -> float:
|
|
134
|
+
"""Total annual essential spending."""
|
|
135
|
+
return sum(
|
|
136
|
+
liability.annual_amount
|
|
137
|
+
for liability in self.liabilities
|
|
138
|
+
if liability.is_essential
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def discretionary_spending(self) -> float:
|
|
143
|
+
"""Total annual discretionary spending."""
|
|
144
|
+
return sum(
|
|
145
|
+
liability.annual_amount
|
|
146
|
+
for liability in self.liabilities
|
|
147
|
+
if not liability.is_essential
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def total_spending(self) -> float:
|
|
152
|
+
"""Total annual spending target."""
|
|
153
|
+
return self.essential_spending + self.discretionary_spending
|