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
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Liability models for future spending obligations."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LiabilityType(str, Enum):
|
|
10
|
+
"""Type of liability/spending obligation."""
|
|
11
|
+
|
|
12
|
+
ESSENTIAL_SPENDING = "essential_spending" # Non-negotiable living expenses
|
|
13
|
+
DISCRETIONARY_SPENDING = "discretionary_spending" # Flexible lifestyle spending
|
|
14
|
+
LEGACY_GOAL = "legacy_goal" # Bequest target
|
|
15
|
+
MORTGAGE = "mortgage" # Home loan payments
|
|
16
|
+
DEBT = "debt" # Other debt obligations
|
|
17
|
+
HEALTHCARE = "healthcare" # Healthcare/long-term care costs
|
|
18
|
+
TAXES = "taxes" # Future tax obligations
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InflationLinkage(str, Enum):
|
|
22
|
+
"""How the liability is linked to inflation."""
|
|
23
|
+
|
|
24
|
+
NONE = "none" # Fixed nominal amount
|
|
25
|
+
CPI = "cpi" # Linked to Consumer Price Index
|
|
26
|
+
WAGE = "wage" # Linked to wage growth
|
|
27
|
+
HEALTHCARE = "healthcare" # Linked to healthcare inflation (typically higher)
|
|
28
|
+
CUSTOM = "custom" # Custom inflation rate
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Liability(BaseModel):
|
|
32
|
+
"""A future spending obligation or liability."""
|
|
33
|
+
|
|
34
|
+
name: str = Field(..., description="Descriptive name for the liability")
|
|
35
|
+
liability_type: LiabilityType = Field(
|
|
36
|
+
default=LiabilityType.ESSENTIAL_SPENDING,
|
|
37
|
+
description="Category of liability",
|
|
38
|
+
)
|
|
39
|
+
annual_amount: float = Field(
|
|
40
|
+
...,
|
|
41
|
+
ge=0,
|
|
42
|
+
description="Annual spending amount in today's dollars",
|
|
43
|
+
)
|
|
44
|
+
start_year: int = Field(
|
|
45
|
+
default=0,
|
|
46
|
+
ge=0,
|
|
47
|
+
description="Years from now when liability begins (0 = now)",
|
|
48
|
+
)
|
|
49
|
+
end_year: Optional[int] = Field(
|
|
50
|
+
default=None,
|
|
51
|
+
ge=0,
|
|
52
|
+
description="Years from now when liability ends (None = until death)",
|
|
53
|
+
)
|
|
54
|
+
inflation_linkage: InflationLinkage = Field(
|
|
55
|
+
default=InflationLinkage.CPI,
|
|
56
|
+
description="How the liability adjusts for inflation",
|
|
57
|
+
)
|
|
58
|
+
custom_inflation_rate: Optional[float] = Field(
|
|
59
|
+
default=None,
|
|
60
|
+
description="Custom inflation rate if inflation_linkage is CUSTOM (decimal)",
|
|
61
|
+
)
|
|
62
|
+
probability: float = Field(
|
|
63
|
+
default=1.0,
|
|
64
|
+
ge=0,
|
|
65
|
+
le=1,
|
|
66
|
+
description="Probability this liability occurs (1.0 = certain)",
|
|
67
|
+
)
|
|
68
|
+
is_essential: bool = Field(
|
|
69
|
+
default=True,
|
|
70
|
+
description="Whether this is essential (floor) vs discretionary (flex) spending",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def duration_years(self) -> Optional[int]:
|
|
75
|
+
"""Duration of the liability in years, if end_year is specified."""
|
|
76
|
+
if self.end_year is None:
|
|
77
|
+
return None
|
|
78
|
+
return self.end_year - self.start_year
|
|
79
|
+
|
|
80
|
+
def get_inflation_rate(self, base_cpi: float = 0.025) -> float:
|
|
81
|
+
"""Get the effective inflation rate for this liability.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
base_cpi: Base CPI inflation rate assumption
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Effective annual inflation rate as decimal
|
|
88
|
+
"""
|
|
89
|
+
match self.inflation_linkage:
|
|
90
|
+
case InflationLinkage.NONE:
|
|
91
|
+
return 0.0
|
|
92
|
+
case InflationLinkage.CPI:
|
|
93
|
+
return base_cpi
|
|
94
|
+
case InflationLinkage.WAGE:
|
|
95
|
+
return base_cpi + 0.01 # Assume 1% real wage growth
|
|
96
|
+
case InflationLinkage.HEALTHCARE:
|
|
97
|
+
return base_cpi + 0.02 # Assume 2% excess healthcare inflation
|
|
98
|
+
case InflationLinkage.CUSTOM:
|
|
99
|
+
return self.custom_inflation_rate or base_cpi
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Market assumptions model."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from pydantic import BaseModel, Field, field_validator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MarketModel(BaseModel):
|
|
10
|
+
"""Market return and risk assumptions."""
|
|
11
|
+
|
|
12
|
+
# Expected returns (annual, real)
|
|
13
|
+
stock_return: float = Field(
|
|
14
|
+
default=0.05,
|
|
15
|
+
description="Expected real return for stocks (decimal)",
|
|
16
|
+
)
|
|
17
|
+
bond_return: float = Field(
|
|
18
|
+
default=0.015,
|
|
19
|
+
description="Expected real return for bonds (decimal)",
|
|
20
|
+
)
|
|
21
|
+
cash_return: float = Field(
|
|
22
|
+
default=0.0,
|
|
23
|
+
description="Expected real return for cash (decimal)",
|
|
24
|
+
)
|
|
25
|
+
real_estate_return: float = Field(
|
|
26
|
+
default=0.03,
|
|
27
|
+
description="Expected real return for real estate (decimal)",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Volatility (annual standard deviation)
|
|
31
|
+
stock_volatility: float = Field(
|
|
32
|
+
default=0.16,
|
|
33
|
+
ge=0,
|
|
34
|
+
description="Annual volatility for stocks (decimal)",
|
|
35
|
+
)
|
|
36
|
+
bond_volatility: float = Field(
|
|
37
|
+
default=0.06,
|
|
38
|
+
ge=0,
|
|
39
|
+
description="Annual volatility for bonds (decimal)",
|
|
40
|
+
)
|
|
41
|
+
cash_volatility: float = Field(
|
|
42
|
+
default=0.01,
|
|
43
|
+
ge=0,
|
|
44
|
+
description="Annual volatility for cash (decimal)",
|
|
45
|
+
)
|
|
46
|
+
real_estate_volatility: float = Field(
|
|
47
|
+
default=0.12,
|
|
48
|
+
ge=0,
|
|
49
|
+
description="Annual volatility for real estate (decimal)",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Correlations
|
|
53
|
+
stock_bond_correlation: float = Field(
|
|
54
|
+
default=0.0,
|
|
55
|
+
ge=-1,
|
|
56
|
+
le=1,
|
|
57
|
+
description="Correlation between stocks and bonds",
|
|
58
|
+
)
|
|
59
|
+
stock_real_estate_correlation: float = Field(
|
|
60
|
+
default=0.5,
|
|
61
|
+
ge=-1,
|
|
62
|
+
le=1,
|
|
63
|
+
description="Correlation between stocks and real estate",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Inflation
|
|
67
|
+
inflation_mean: float = Field(
|
|
68
|
+
default=0.025,
|
|
69
|
+
description="Expected long-term inflation rate (decimal)",
|
|
70
|
+
)
|
|
71
|
+
inflation_volatility: float = Field(
|
|
72
|
+
default=0.015,
|
|
73
|
+
ge=0,
|
|
74
|
+
description="Volatility of inflation (decimal)",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Discount rate
|
|
78
|
+
real_discount_rate: float = Field(
|
|
79
|
+
default=0.02,
|
|
80
|
+
description="Real discount rate for liability PV calculations (decimal)",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Fat tails
|
|
84
|
+
use_fat_tails: bool = Field(
|
|
85
|
+
default=False,
|
|
86
|
+
description="Use t-distribution for fatter tails",
|
|
87
|
+
)
|
|
88
|
+
degrees_of_freedom: int = Field(
|
|
89
|
+
default=5,
|
|
90
|
+
ge=3,
|
|
91
|
+
description="Degrees of freedom for t-distribution (lower = fatter tails)",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@field_validator("degrees_of_freedom")
|
|
95
|
+
@classmethod
|
|
96
|
+
def validate_dof(cls, v: int) -> int:
|
|
97
|
+
"""Ensure degrees of freedom is reasonable."""
|
|
98
|
+
if v < 3:
|
|
99
|
+
raise ValueError("Degrees of freedom must be at least 3")
|
|
100
|
+
return v
|
|
101
|
+
|
|
102
|
+
def get_correlation_matrix(self) -> np.ndarray:
|
|
103
|
+
"""Get the correlation matrix for asset classes.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
4x4 correlation matrix for [stocks, bonds, cash, real_estate]
|
|
107
|
+
"""
|
|
108
|
+
return np.array([
|
|
109
|
+
[1.0, self.stock_bond_correlation, 0.0, self.stock_real_estate_correlation],
|
|
110
|
+
[self.stock_bond_correlation, 1.0, 0.1, 0.2],
|
|
111
|
+
[0.0, 0.1, 1.0, 0.0],
|
|
112
|
+
[self.stock_real_estate_correlation, 0.2, 0.0, 1.0],
|
|
113
|
+
])
|
|
114
|
+
|
|
115
|
+
def get_covariance_matrix(self) -> np.ndarray:
|
|
116
|
+
"""Get the covariance matrix for asset classes.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
4x4 covariance matrix for [stocks, bonds, cash, real_estate]
|
|
120
|
+
"""
|
|
121
|
+
volatilities = np.array([
|
|
122
|
+
self.stock_volatility,
|
|
123
|
+
self.bond_volatility,
|
|
124
|
+
self.cash_volatility,
|
|
125
|
+
self.real_estate_volatility,
|
|
126
|
+
])
|
|
127
|
+
corr = self.get_correlation_matrix()
|
|
128
|
+
# Covariance = outer product of volatilities * correlation
|
|
129
|
+
return np.outer(volatilities, volatilities) * corr
|
|
130
|
+
|
|
131
|
+
def get_cholesky_decomposition(self) -> np.ndarray:
|
|
132
|
+
"""Get Cholesky decomposition for correlated returns generation.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Lower triangular Cholesky matrix
|
|
136
|
+
"""
|
|
137
|
+
cov = self.get_covariance_matrix()
|
|
138
|
+
return np.linalg.cholesky(cov)
|
|
139
|
+
|
|
140
|
+
def expected_portfolio_return(
|
|
141
|
+
self,
|
|
142
|
+
stock_weight: float,
|
|
143
|
+
bond_weight: Optional[float] = None,
|
|
144
|
+
) -> float:
|
|
145
|
+
"""Calculate expected return for a portfolio.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
stock_weight: Weight in stocks (0-1)
|
|
149
|
+
bond_weight: Weight in bonds (remainder is cash if not specified)
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Expected annual real return
|
|
153
|
+
"""
|
|
154
|
+
if bond_weight is None:
|
|
155
|
+
bond_weight = 1 - stock_weight
|
|
156
|
+
|
|
157
|
+
cash_weight = max(0, 1 - stock_weight - bond_weight)
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
stock_weight * self.stock_return
|
|
161
|
+
+ bond_weight * self.bond_return
|
|
162
|
+
+ cash_weight * self.cash_return
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def portfolio_volatility(
|
|
166
|
+
self,
|
|
167
|
+
stock_weight: float,
|
|
168
|
+
bond_weight: Optional[float] = None,
|
|
169
|
+
) -> float:
|
|
170
|
+
"""Calculate portfolio volatility.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
stock_weight: Weight in stocks (0-1)
|
|
174
|
+
bond_weight: Weight in bonds (remainder is cash if not specified)
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Annual portfolio volatility
|
|
178
|
+
"""
|
|
179
|
+
if bond_weight is None:
|
|
180
|
+
bond_weight = 1 - stock_weight
|
|
181
|
+
|
|
182
|
+
cash_weight = max(0, 1 - stock_weight - bond_weight)
|
|
183
|
+
weights = np.array([stock_weight, bond_weight, cash_weight, 0])
|
|
184
|
+
|
|
185
|
+
cov = self.get_covariance_matrix()
|
|
186
|
+
portfolio_variance = weights @ cov @ weights
|
|
187
|
+
|
|
188
|
+
return np.sqrt(portfolio_variance)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Simulation configuration model."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from fundedness.models.market import MarketModel
|
|
8
|
+
from fundedness.models.tax import TaxModel
|
|
9
|
+
from fundedness.models.utility import UtilityModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SimulationConfig(BaseModel):
|
|
13
|
+
"""Configuration for Monte Carlo simulations."""
|
|
14
|
+
|
|
15
|
+
# Simulation parameters
|
|
16
|
+
n_simulations: int = Field(
|
|
17
|
+
default=10000,
|
|
18
|
+
ge=100,
|
|
19
|
+
le=100000,
|
|
20
|
+
description="Number of Monte Carlo paths",
|
|
21
|
+
)
|
|
22
|
+
n_years: int = Field(
|
|
23
|
+
default=50,
|
|
24
|
+
ge=1,
|
|
25
|
+
le=100,
|
|
26
|
+
description="Simulation horizon in years",
|
|
27
|
+
)
|
|
28
|
+
random_seed: int | None = Field(
|
|
29
|
+
default=None,
|
|
30
|
+
description="Random seed for reproducibility (None = random)",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Model components
|
|
34
|
+
market_model: MarketModel = Field(
|
|
35
|
+
default_factory=MarketModel,
|
|
36
|
+
description="Market return and risk assumptions",
|
|
37
|
+
)
|
|
38
|
+
tax_model: TaxModel = Field(
|
|
39
|
+
default_factory=TaxModel,
|
|
40
|
+
description="Tax rate assumptions",
|
|
41
|
+
)
|
|
42
|
+
utility_model: UtilityModel = Field(
|
|
43
|
+
default_factory=UtilityModel,
|
|
44
|
+
description="Utility function parameters",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Return generation
|
|
48
|
+
return_model: Literal["lognormal", "t_distribution", "bootstrap"] = Field(
|
|
49
|
+
default="lognormal",
|
|
50
|
+
description="Model for generating returns",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Output options
|
|
54
|
+
percentiles: list[int] = Field(
|
|
55
|
+
default=[10, 25, 50, 75, 90],
|
|
56
|
+
description="Percentiles to report (0-100)",
|
|
57
|
+
)
|
|
58
|
+
track_spending: bool = Field(
|
|
59
|
+
default=True,
|
|
60
|
+
description="Track spending paths in simulation",
|
|
61
|
+
)
|
|
62
|
+
track_allocation: bool = Field(
|
|
63
|
+
default=False,
|
|
64
|
+
description="Track allocation changes over time",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Performance
|
|
68
|
+
use_vectorized: bool = Field(
|
|
69
|
+
default=True,
|
|
70
|
+
description="Use vectorized numpy operations for speed",
|
|
71
|
+
)
|
|
72
|
+
chunk_size: int = Field(
|
|
73
|
+
default=1000,
|
|
74
|
+
ge=100,
|
|
75
|
+
description="Chunk size for memory-efficient simulation",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def get_percentile_labels(self) -> list[str]:
|
|
79
|
+
"""Get formatted percentile labels."""
|
|
80
|
+
return [f"P{p}" for p in self.percentiles]
|
fundedness/models/tax.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Tax model for after-tax calculations."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from fundedness.models.assets import AccountType
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TaxModel(BaseModel):
|
|
9
|
+
"""Tax rates and assumptions."""
|
|
10
|
+
|
|
11
|
+
# Federal marginal rates
|
|
12
|
+
federal_ordinary_rate: float = Field(
|
|
13
|
+
default=0.24,
|
|
14
|
+
ge=0,
|
|
15
|
+
le=1,
|
|
16
|
+
description="Federal marginal tax rate on ordinary income",
|
|
17
|
+
)
|
|
18
|
+
federal_ltcg_rate: float = Field(
|
|
19
|
+
default=0.15,
|
|
20
|
+
ge=0,
|
|
21
|
+
le=1,
|
|
22
|
+
description="Federal long-term capital gains rate",
|
|
23
|
+
)
|
|
24
|
+
federal_stcg_rate: float = Field(
|
|
25
|
+
default=0.24,
|
|
26
|
+
ge=0,
|
|
27
|
+
le=1,
|
|
28
|
+
description="Federal short-term capital gains rate (usually = ordinary)",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# State rates
|
|
32
|
+
state_ordinary_rate: float = Field(
|
|
33
|
+
default=0.093,
|
|
34
|
+
ge=0,
|
|
35
|
+
le=1,
|
|
36
|
+
description="State marginal tax rate on ordinary income",
|
|
37
|
+
)
|
|
38
|
+
state_ltcg_rate: float = Field(
|
|
39
|
+
default=0.093,
|
|
40
|
+
ge=0,
|
|
41
|
+
le=1,
|
|
42
|
+
description="State long-term capital gains rate",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Other
|
|
46
|
+
niit_rate: float = Field(
|
|
47
|
+
default=0.038,
|
|
48
|
+
ge=0,
|
|
49
|
+
le=1,
|
|
50
|
+
description="Net Investment Income Tax rate (3.8%)",
|
|
51
|
+
)
|
|
52
|
+
niit_applies: bool = Field(
|
|
53
|
+
default=True,
|
|
54
|
+
description="Whether NIIT applies to this household",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Cost basis assumptions
|
|
58
|
+
default_cost_basis_ratio: float = Field(
|
|
59
|
+
default=0.5,
|
|
60
|
+
ge=0,
|
|
61
|
+
le=1,
|
|
62
|
+
description="Default cost basis as fraction of value (for unrealized gains)",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def total_ordinary_rate(self) -> float:
|
|
67
|
+
"""Combined federal + state ordinary income tax rate."""
|
|
68
|
+
return self.federal_ordinary_rate + self.state_ordinary_rate
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def total_ltcg_rate(self) -> float:
|
|
72
|
+
"""Combined federal + state + NIIT long-term capital gains rate."""
|
|
73
|
+
base = self.federal_ltcg_rate + self.state_ltcg_rate
|
|
74
|
+
if self.niit_applies:
|
|
75
|
+
base += self.niit_rate
|
|
76
|
+
return base
|
|
77
|
+
|
|
78
|
+
def get_effective_tax_rate(
|
|
79
|
+
self,
|
|
80
|
+
account_type: AccountType,
|
|
81
|
+
cost_basis_ratio: float | None = None,
|
|
82
|
+
) -> float:
|
|
83
|
+
"""Get the effective tax rate for withdrawals from an account type.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
account_type: Type of account
|
|
87
|
+
cost_basis_ratio: Cost basis as fraction of value (for taxable accounts)
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Effective tax rate as decimal (0-1)
|
|
91
|
+
"""
|
|
92
|
+
match account_type:
|
|
93
|
+
case AccountType.TAX_EXEMPT:
|
|
94
|
+
# Roth: no tax on withdrawals
|
|
95
|
+
return 0.0
|
|
96
|
+
|
|
97
|
+
case AccountType.TAX_DEFERRED:
|
|
98
|
+
# Traditional IRA/401k: taxed as ordinary income
|
|
99
|
+
return self.total_ordinary_rate
|
|
100
|
+
|
|
101
|
+
case AccountType.HSA:
|
|
102
|
+
# HSA: no tax if used for medical expenses
|
|
103
|
+
return 0.0
|
|
104
|
+
|
|
105
|
+
case AccountType.TAXABLE:
|
|
106
|
+
# Taxable: only gains are taxed
|
|
107
|
+
if cost_basis_ratio is None:
|
|
108
|
+
cost_basis_ratio = self.default_cost_basis_ratio
|
|
109
|
+
|
|
110
|
+
# Gains portion = (1 - cost_basis_ratio)
|
|
111
|
+
gains_portion = 1 - cost_basis_ratio
|
|
112
|
+
return gains_portion * self.total_ltcg_rate
|
|
113
|
+
|
|
114
|
+
def get_haircut_by_account_type(self) -> dict[AccountType, float]:
|
|
115
|
+
"""Get tax haircut factors by account type.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Dictionary mapping account type to (1 - tax_rate)
|
|
119
|
+
"""
|
|
120
|
+
return {
|
|
121
|
+
AccountType.TAXABLE: 1 - self.get_effective_tax_rate(AccountType.TAXABLE),
|
|
122
|
+
AccountType.TAX_DEFERRED: 1 - self.get_effective_tax_rate(AccountType.TAX_DEFERRED),
|
|
123
|
+
AccountType.TAX_EXEMPT: 1 - self.get_effective_tax_rate(AccountType.TAX_EXEMPT),
|
|
124
|
+
AccountType.HSA: 1 - self.get_effective_tax_rate(AccountType.HSA),
|
|
125
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Utility model for lifetime utility optimization."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class UtilityModel(BaseModel):
|
|
8
|
+
"""CRRA utility model with subsistence floor."""
|
|
9
|
+
|
|
10
|
+
gamma: float = Field(
|
|
11
|
+
default=3.0,
|
|
12
|
+
gt=0,
|
|
13
|
+
description="Coefficient of relative risk aversion (CRRA parameter)",
|
|
14
|
+
)
|
|
15
|
+
subsistence_floor: float = Field(
|
|
16
|
+
default=30000,
|
|
17
|
+
ge=0,
|
|
18
|
+
description="Minimum annual spending floor in dollars",
|
|
19
|
+
)
|
|
20
|
+
bequest_weight: float = Field(
|
|
21
|
+
default=0.0,
|
|
22
|
+
ge=0,
|
|
23
|
+
le=1,
|
|
24
|
+
description="Weight on bequest utility (0 = no bequest motive)",
|
|
25
|
+
)
|
|
26
|
+
time_preference: float = Field(
|
|
27
|
+
default=0.02,
|
|
28
|
+
ge=0,
|
|
29
|
+
description="Pure rate of time preference (discount rate for utility)",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def utility(self, consumption: float) -> float:
|
|
33
|
+
"""Calculate CRRA utility of consumption.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
consumption: Annual consumption in dollars
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Utility value (can be negative)
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
ValueError: If consumption is below subsistence floor
|
|
43
|
+
"""
|
|
44
|
+
excess = consumption - self.subsistence_floor
|
|
45
|
+
|
|
46
|
+
if excess <= 0:
|
|
47
|
+
# Below floor: large negative utility
|
|
48
|
+
return -1e10
|
|
49
|
+
|
|
50
|
+
if self.gamma == 1.0:
|
|
51
|
+
# Log utility special case
|
|
52
|
+
return np.log(excess)
|
|
53
|
+
|
|
54
|
+
return (excess ** (1 - self.gamma)) / (1 - self.gamma)
|
|
55
|
+
|
|
56
|
+
def marginal_utility(self, consumption: float) -> float:
|
|
57
|
+
"""Calculate marginal utility of consumption.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
consumption: Annual consumption in dollars
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Marginal utility value
|
|
64
|
+
"""
|
|
65
|
+
excess = consumption - self.subsistence_floor
|
|
66
|
+
|
|
67
|
+
if excess <= 0:
|
|
68
|
+
return 1e10 # Very high marginal utility when below floor
|
|
69
|
+
|
|
70
|
+
return excess ** (-self.gamma)
|
|
71
|
+
|
|
72
|
+
def inverse_marginal_utility(self, mu: float) -> float:
|
|
73
|
+
"""Calculate consumption from marginal utility (for optimization).
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
mu: Marginal utility value
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Consumption that produces this marginal utility
|
|
80
|
+
"""
|
|
81
|
+
excess = mu ** (-1 / self.gamma)
|
|
82
|
+
return excess + self.subsistence_floor
|
|
83
|
+
|
|
84
|
+
def certainty_equivalent(
|
|
85
|
+
self,
|
|
86
|
+
consumption_samples: np.ndarray,
|
|
87
|
+
) -> float:
|
|
88
|
+
"""Calculate certainty equivalent consumption.
|
|
89
|
+
|
|
90
|
+
The certainty equivalent is the guaranteed consumption that
|
|
91
|
+
provides the same expected utility as the risky consumption stream.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
consumption_samples: Array of consumption outcomes
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Certainty equivalent consumption value
|
|
98
|
+
"""
|
|
99
|
+
# Calculate expected utility
|
|
100
|
+
utilities = np.array([self.utility(c) for c in consumption_samples])
|
|
101
|
+
expected_utility = np.mean(utilities)
|
|
102
|
+
|
|
103
|
+
# Invert to find certainty equivalent
|
|
104
|
+
if self.gamma == 1.0:
|
|
105
|
+
return np.exp(expected_utility) + self.subsistence_floor
|
|
106
|
+
|
|
107
|
+
# For CRRA: CE = (EU * (1-gamma))^(1/(1-gamma)) + floor
|
|
108
|
+
ce_excess = (expected_utility * (1 - self.gamma)) ** (1 / (1 - self.gamma))
|
|
109
|
+
return ce_excess + self.subsistence_floor
|
|
110
|
+
|
|
111
|
+
def lifetime_utility(
|
|
112
|
+
self,
|
|
113
|
+
consumption_path: np.ndarray,
|
|
114
|
+
survival_probabilities: np.ndarray | None = None,
|
|
115
|
+
) -> float:
|
|
116
|
+
"""Calculate discounted lifetime utility.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
consumption_path: Array of annual consumption values
|
|
120
|
+
survival_probabilities: Probability of being alive at each year (optional)
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Discounted expected lifetime utility
|
|
124
|
+
"""
|
|
125
|
+
n_years = len(consumption_path)
|
|
126
|
+
|
|
127
|
+
if survival_probabilities is None:
|
|
128
|
+
survival_probabilities = np.ones(n_years)
|
|
129
|
+
|
|
130
|
+
total_utility = 0.0
|
|
131
|
+
for t, (consumption, survival_prob) in enumerate(
|
|
132
|
+
zip(consumption_path, survival_probabilities)
|
|
133
|
+
):
|
|
134
|
+
discount = (1 + self.time_preference) ** (-t)
|
|
135
|
+
total_utility += discount * survival_prob * self.utility(consumption)
|
|
136
|
+
|
|
137
|
+
return total_utility
|
|
138
|
+
|
|
139
|
+
def risk_tolerance(self, wealth: float) -> float:
|
|
140
|
+
"""Calculate risk tolerance at a given wealth level.
|
|
141
|
+
|
|
142
|
+
Risk tolerance = 1 / (gamma * wealth) for CRRA utility.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
wealth: Current wealth level
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Risk tolerance as decimal
|
|
149
|
+
"""
|
|
150
|
+
if wealth <= self.subsistence_floor:
|
|
151
|
+
return 0.0 # No risk tolerance below floor
|
|
152
|
+
|
|
153
|
+
excess_wealth = wealth - self.subsistence_floor
|
|
154
|
+
return 1 / (self.gamma * excess_wealth) * excess_wealth
|