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.

Files changed (43) hide show
  1. fundedness/__init__.py +71 -0
  2. fundedness/allocation/__init__.py +20 -0
  3. fundedness/allocation/base.py +32 -0
  4. fundedness/allocation/constant.py +25 -0
  5. fundedness/allocation/glidepath.py +111 -0
  6. fundedness/allocation/merton_optimal.py +220 -0
  7. fundedness/cefr.py +241 -0
  8. fundedness/liabilities.py +221 -0
  9. fundedness/liquidity.py +49 -0
  10. fundedness/merton.py +289 -0
  11. fundedness/models/__init__.py +35 -0
  12. fundedness/models/assets.py +148 -0
  13. fundedness/models/household.py +153 -0
  14. fundedness/models/liabilities.py +99 -0
  15. fundedness/models/market.py +188 -0
  16. fundedness/models/simulation.py +80 -0
  17. fundedness/models/tax.py +125 -0
  18. fundedness/models/utility.py +154 -0
  19. fundedness/optimize.py +473 -0
  20. fundedness/policies.py +204 -0
  21. fundedness/risk.py +72 -0
  22. fundedness/simulate.py +559 -0
  23. fundedness/viz/__init__.py +33 -0
  24. fundedness/viz/colors.py +110 -0
  25. fundedness/viz/comparison.py +294 -0
  26. fundedness/viz/fan_chart.py +193 -0
  27. fundedness/viz/histogram.py +225 -0
  28. fundedness/viz/optimal.py +542 -0
  29. fundedness/viz/survival.py +230 -0
  30. fundedness/viz/tornado.py +236 -0
  31. fundedness/viz/waterfall.py +203 -0
  32. fundedness/withdrawals/__init__.py +27 -0
  33. fundedness/withdrawals/base.py +116 -0
  34. fundedness/withdrawals/comparison.py +230 -0
  35. fundedness/withdrawals/fixed_swr.py +174 -0
  36. fundedness/withdrawals/guardrails.py +136 -0
  37. fundedness/withdrawals/merton_optimal.py +286 -0
  38. fundedness/withdrawals/rmd_style.py +203 -0
  39. fundedness/withdrawals/vpw.py +136 -0
  40. fundedness-0.2.2.dist-info/METADATA +299 -0
  41. fundedness-0.2.2.dist-info/RECORD +43 -0
  42. fundedness-0.2.2.dist-info/WHEEL +4 -0
  43. 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]
@@ -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