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 +34 -1
- fundedness/allocation/__init__.py +8 -0
- fundedness/allocation/merton_optimal.py +220 -0
- fundedness/merton.py +289 -0
- fundedness/optimize.py +473 -0
- fundedness/simulate.py +158 -0
- fundedness/viz/__init__.py +14 -0
- fundedness/viz/optimal.py +542 -0
- fundedness/withdrawals/__init__.py +8 -0
- fundedness/withdrawals/fixed_swr.py +61 -0
- fundedness/withdrawals/merton_optimal.py +286 -0
- {fundedness-0.1.0.dist-info → fundedness-0.2.1.dist-info}/METADATA +41 -9
- {fundedness-0.1.0.dist-info → fundedness-0.2.1.dist-info}/RECORD +15 -10
- {fundedness-0.1.0.dist-info → fundedness-0.2.1.dist-info}/WHEEL +0 -0
- {fundedness-0.1.0.dist-info → fundedness-0.2.1.dist-info}/entry_points.txt +0 -0
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
|
|
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
|