fundedness 0.1.0__py3-none-any.whl → 0.2.1__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 CHANGED
@@ -4,12 +4,23 @@ This package provides tools for:
4
4
  - CEFR (Certainty-Equivalent Funded Ratio) calculations
5
5
  - Monte Carlo retirement simulations
6
6
  - Withdrawal strategy comparison
7
+ - Utility-optimal spending and allocation (Merton framework)
7
8
  - Beautiful Plotly visualizations
8
9
  """
9
10
 
10
- __version__ = "0.1.0"
11
+ __version__ = "0.2.1"
11
12
 
12
13
  from fundedness.cefr import CEFRResult, compute_cefr
14
+ from fundedness.merton import (
15
+ MertonOptimalResult,
16
+ calculate_merton_optimal,
17
+ certainty_equivalent_return,
18
+ merton_optimal_allocation,
19
+ merton_optimal_spending_rate,
20
+ optimal_allocation_by_wealth,
21
+ optimal_spending_by_age,
22
+ wealth_adjusted_optimal_allocation,
23
+ )
13
24
  from fundedness.models import (
14
25
  Asset,
15
26
  BalanceSheet,
@@ -21,11 +32,33 @@ from fundedness.models import (
21
32
  TaxModel,
22
33
  UtilityModel,
23
34
  )
35
+ from fundedness.simulate import (
36
+ SimulationResult,
37
+ run_simulation,
38
+ run_simulation_with_policy,
39
+ run_simulation_with_utility,
40
+ )
24
41
 
25
42
  __all__ = [
26
43
  "__version__",
44
+ # CEFR
27
45
  "compute_cefr",
28
46
  "CEFRResult",
47
+ # Merton optimal
48
+ "calculate_merton_optimal",
49
+ "certainty_equivalent_return",
50
+ "merton_optimal_allocation",
51
+ "merton_optimal_spending_rate",
52
+ "MertonOptimalResult",
53
+ "optimal_allocation_by_wealth",
54
+ "optimal_spending_by_age",
55
+ "wealth_adjusted_optimal_allocation",
56
+ # Simulation
57
+ "run_simulation",
58
+ "run_simulation_with_policy",
59
+ "run_simulation_with_utility",
60
+ "SimulationResult",
61
+ # Models
29
62
  "Asset",
30
63
  "BalanceSheet",
31
64
  "Household",
@@ -3,10 +3,18 @@
3
3
  from fundedness.allocation.base import AllocationPolicy
4
4
  from fundedness.allocation.constant import ConstantAllocationPolicy
5
5
  from fundedness.allocation.glidepath import AgeBasedGlidepathPolicy, RisingEquityGlidepathPolicy
6
+ from fundedness.allocation.merton_optimal import (
7
+ FloorProtectionAllocationPolicy,
8
+ MertonOptimalAllocationPolicy,
9
+ WealthBasedAllocationPolicy,
10
+ )
6
11
 
7
12
  __all__ = [
8
13
  "AllocationPolicy",
9
14
  "AgeBasedGlidepathPolicy",
10
15
  "ConstantAllocationPolicy",
16
+ "FloorProtectionAllocationPolicy",
17
+ "MertonOptimalAllocationPolicy",
11
18
  "RisingEquityGlidepathPolicy",
19
+ "WealthBasedAllocationPolicy",
12
20
  ]
@@ -0,0 +1,220 @@
1
+ """Merton optimal allocation policy based on utility maximization."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ import numpy as np
6
+
7
+ from fundedness.merton import (
8
+ merton_optimal_allocation,
9
+ wealth_adjusted_optimal_allocation,
10
+ )
11
+ from fundedness.models.market import MarketModel
12
+ from fundedness.models.utility import UtilityModel
13
+
14
+
15
+ @dataclass
16
+ class MertonOptimalAllocationPolicy:
17
+ """Allocation policy based on Merton optimal portfolio theory.
18
+
19
+ This policy determines equity allocation using the Merton formula,
20
+ with adjustments for wealth level relative to subsistence floor.
21
+
22
+ Key characteristics:
23
+ - Base allocation from Merton: k* = (mu - r) / (gamma * sigma^2)
24
+ - Allocation decreases as wealth approaches subsistence floor
25
+ - Configurable bounds to prevent extreme positions
26
+
27
+ Attributes:
28
+ market_model: Market return and risk assumptions
29
+ utility_model: Utility parameters including risk aversion
30
+ min_equity: Minimum equity allocation
31
+ max_equity: Maximum equity allocation
32
+ use_wealth_adjustment: Whether to reduce allocation near floor
33
+ """
34
+
35
+ market_model: MarketModel = field(default_factory=MarketModel)
36
+ utility_model: UtilityModel = field(default_factory=UtilityModel)
37
+ min_equity: float = 0.0
38
+ max_equity: float = 1.0
39
+ use_wealth_adjustment: bool = True
40
+
41
+ @property
42
+ def name(self) -> str:
43
+ k_star = merton_optimal_allocation(self.market_model, self.utility_model)
44
+ return f"Merton Optimal ({k_star:.0%})"
45
+
46
+ def get_unconstrained_allocation(self) -> float:
47
+ """Get the unconstrained Merton optimal allocation.
48
+
49
+ Returns:
50
+ Optimal equity allocation (may exceed bounds)
51
+ """
52
+ return merton_optimal_allocation(self.market_model, self.utility_model)
53
+
54
+ def get_allocation(
55
+ self,
56
+ wealth: float | np.ndarray,
57
+ year: int,
58
+ initial_wealth: float,
59
+ ) -> float | np.ndarray:
60
+ """Get the optimal stock allocation.
61
+
62
+ Args:
63
+ wealth: Current portfolio value(s)
64
+ year: Current year in simulation (not used but required by interface)
65
+ initial_wealth: Starting portfolio value (not used but required)
66
+
67
+ Returns:
68
+ Stock allocation as decimal (0-1), scalar or array matching wealth
69
+ """
70
+ if not self.use_wealth_adjustment:
71
+ # Use fixed Merton optimal allocation
72
+ k_star = merton_optimal_allocation(self.market_model, self.utility_model)
73
+ return np.clip(k_star, self.min_equity, self.max_equity)
74
+
75
+ # Apply wealth-adjusted allocation
76
+ if isinstance(wealth, np.ndarray):
77
+ allocations = np.zeros_like(wealth, dtype=float)
78
+ for i, w in enumerate(wealth):
79
+ allocations[i] = wealth_adjusted_optimal_allocation(
80
+ wealth=w,
81
+ market_model=self.market_model,
82
+ utility_model=self.utility_model,
83
+ min_allocation=self.min_equity,
84
+ max_allocation=self.max_equity,
85
+ )
86
+ return allocations
87
+ else:
88
+ return wealth_adjusted_optimal_allocation(
89
+ wealth=wealth,
90
+ market_model=self.market_model,
91
+ utility_model=self.utility_model,
92
+ min_allocation=self.min_equity,
93
+ max_allocation=self.max_equity,
94
+ )
95
+
96
+
97
+ @dataclass
98
+ class WealthBasedAllocationPolicy:
99
+ """Allocation that varies with wealth relative to floor.
100
+
101
+ This is a simplified version that linearly interpolates between
102
+ a minimum allocation at the floor and maximum at a target wealth.
103
+
104
+ More intuitive than full Merton but captures the key insight that
105
+ risk capacity depends on distance from subsistence.
106
+
107
+ Attributes:
108
+ floor_wealth: Wealth level at which equity is at minimum
109
+ target_wealth: Wealth level at which equity reaches maximum
110
+ min_equity: Equity allocation at floor
111
+ max_equity: Equity allocation at target and above
112
+ """
113
+
114
+ floor_wealth: float = 500_000
115
+ target_wealth: float = 2_000_000
116
+ min_equity: float = 0.2
117
+ max_equity: float = 0.8
118
+
119
+ @property
120
+ def name(self) -> str:
121
+ return f"Wealth-Based ({self.min_equity:.0%}-{self.max_equity:.0%})"
122
+
123
+ def get_allocation(
124
+ self,
125
+ wealth: float | np.ndarray,
126
+ year: int,
127
+ initial_wealth: float,
128
+ ) -> float | np.ndarray:
129
+ """Get allocation based on current wealth level.
130
+
131
+ Args:
132
+ wealth: Current portfolio value(s)
133
+ year: Current year (not used)
134
+ initial_wealth: Starting value (not used)
135
+
136
+ Returns:
137
+ Stock allocation interpolated by wealth
138
+ """
139
+ # Linear interpolation between floor and target
140
+ wealth_range = self.target_wealth - self.floor_wealth
141
+ equity_range = self.max_equity - self.min_equity
142
+
143
+ if isinstance(wealth, np.ndarray):
144
+ progress = (wealth - self.floor_wealth) / wealth_range
145
+ progress = np.clip(progress, 0, 1)
146
+ return self.min_equity + progress * equity_range
147
+ else:
148
+ progress = (wealth - self.floor_wealth) / wealth_range
149
+ progress = max(0, min(1, progress))
150
+ return self.min_equity + progress * equity_range
151
+
152
+
153
+ @dataclass
154
+ class FloorProtectionAllocationPolicy:
155
+ """Allocation that increases equity as wealth grows above floor.
156
+
157
+ Inspired by CPPI (Constant Proportion Portfolio Insurance), this
158
+ policy allocates equity as a multiple of the "cushion" (wealth above
159
+ the floor-protection level).
160
+
161
+ Attributes:
162
+ utility_model: For subsistence floor value
163
+ multiplier: Equity = multiplier * (wealth - floor_reserve) / wealth
164
+ floor_years: Years of floor spending to protect
165
+ min_equity: Minimum equity allocation
166
+ max_equity: Maximum equity allocation
167
+ """
168
+
169
+ utility_model: UtilityModel = field(default_factory=UtilityModel)
170
+ multiplier: float = 3.0
171
+ floor_years: int = 10
172
+ min_equity: float = 0.1
173
+ max_equity: float = 0.9
174
+
175
+ @property
176
+ def name(self) -> str:
177
+ return f"Floor Protection (m={self.multiplier})"
178
+
179
+ def get_floor_reserve(self) -> float:
180
+ """Get the wealth level that protects floor spending.
181
+
182
+ Returns:
183
+ Wealth needed to fund floor spending for floor_years
184
+ """
185
+ return self.utility_model.subsistence_floor * self.floor_years
186
+
187
+ def get_allocation(
188
+ self,
189
+ wealth: float | np.ndarray,
190
+ year: int,
191
+ initial_wealth: float,
192
+ ) -> float | np.ndarray:
193
+ """Get allocation based on cushion above floor reserve.
194
+
195
+ Args:
196
+ wealth: Current portfolio value(s)
197
+ year: Current year (not used)
198
+ initial_wealth: Starting value (not used)
199
+
200
+ Returns:
201
+ Stock allocation based on cushion
202
+ """
203
+ floor_reserve = self.get_floor_reserve()
204
+
205
+ if isinstance(wealth, np.ndarray):
206
+ cushion = np.maximum(wealth - floor_reserve, 0)
207
+ # Equity = multiplier * cushion / wealth
208
+ # But avoid division by zero
209
+ allocation = np.where(
210
+ wealth > 0,
211
+ self.multiplier * cushion / wealth,
212
+ 0.0,
213
+ )
214
+ return np.clip(allocation, self.min_equity, self.max_equity)
215
+ else:
216
+ if wealth <= 0:
217
+ return self.min_equity
218
+ cushion = max(wealth - floor_reserve, 0)
219
+ allocation = self.multiplier * cushion / wealth
220
+ return max(self.min_equity, min(self.max_equity, allocation))
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