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
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Merton optimal spending 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_spending_rate,
|
|
9
|
+
certainty_equivalent_return,
|
|
10
|
+
)
|
|
11
|
+
from fundedness.models.market import MarketModel
|
|
12
|
+
from fundedness.models.utility import UtilityModel
|
|
13
|
+
from fundedness.withdrawals.base import (
|
|
14
|
+
BaseWithdrawalPolicy,
|
|
15
|
+
WithdrawalContext,
|
|
16
|
+
WithdrawalDecision,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class MertonOptimalSpendingPolicy(BaseWithdrawalPolicy):
|
|
22
|
+
"""Spending policy based on Merton optimal consumption theory.
|
|
23
|
+
|
|
24
|
+
This policy determines spending by applying the Merton optimal spending
|
|
25
|
+
rate to current wealth, adjusted for the remaining time horizon.
|
|
26
|
+
|
|
27
|
+
Key characteristics:
|
|
28
|
+
- Spending rate starts low (~2-3%) and rises with age
|
|
29
|
+
- Rate depends on risk aversion, time preference, and market assumptions
|
|
30
|
+
- Adapts to actual wealth (not locked to initial withdrawal amount)
|
|
31
|
+
- Optional smoothing to reduce year-to-year volatility
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
market_model: Market return and risk assumptions
|
|
35
|
+
utility_model: Utility parameters including risk aversion
|
|
36
|
+
starting_age: Age at retirement/simulation start
|
|
37
|
+
end_age: Assumed maximum age for planning
|
|
38
|
+
smoothing_factor: Blend current with previous spending (0-1, 0=no smoothing)
|
|
39
|
+
min_spending_rate: Minimum spending rate floor
|
|
40
|
+
max_spending_rate: Maximum spending rate ceiling
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
market_model: MarketModel = field(default_factory=MarketModel)
|
|
44
|
+
utility_model: UtilityModel = field(default_factory=UtilityModel)
|
|
45
|
+
starting_age: int = 65
|
|
46
|
+
end_age: int = 100
|
|
47
|
+
smoothing_factor: float = 0.5
|
|
48
|
+
min_spending_rate: float = 0.02
|
|
49
|
+
max_spending_rate: float = 0.15
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def name(self) -> str:
|
|
53
|
+
return "Merton Optimal"
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def description(self) -> str:
|
|
57
|
+
gamma = self.utility_model.gamma
|
|
58
|
+
return f"Utility-optimal spending (gamma={gamma})"
|
|
59
|
+
|
|
60
|
+
def get_optimal_rate(self, remaining_years: float) -> float:
|
|
61
|
+
"""Get the optimal spending rate for given remaining years.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
remaining_years: Years until end of planning horizon
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Optimal spending rate as decimal
|
|
68
|
+
"""
|
|
69
|
+
rate = merton_optimal_spending_rate(
|
|
70
|
+
market_model=self.market_model,
|
|
71
|
+
utility_model=self.utility_model,
|
|
72
|
+
remaining_years=remaining_years,
|
|
73
|
+
)
|
|
74
|
+
return np.clip(rate, self.min_spending_rate, self.max_spending_rate)
|
|
75
|
+
|
|
76
|
+
def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
|
|
77
|
+
"""Calculate withdrawal using Merton optimal spending rate.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
context: Current state information
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
WithdrawalDecision with amount and metadata
|
|
84
|
+
"""
|
|
85
|
+
# Determine current age
|
|
86
|
+
if context.age is not None:
|
|
87
|
+
current_age = context.age
|
|
88
|
+
else:
|
|
89
|
+
current_age = self.starting_age + context.year
|
|
90
|
+
|
|
91
|
+
remaining_years = max(1, self.end_age - current_age)
|
|
92
|
+
|
|
93
|
+
# Get optimal spending rate
|
|
94
|
+
rate = self.get_optimal_rate(remaining_years)
|
|
95
|
+
|
|
96
|
+
# Handle vectorized wealth
|
|
97
|
+
if isinstance(context.current_wealth, np.ndarray):
|
|
98
|
+
wealth = context.current_wealth
|
|
99
|
+
else:
|
|
100
|
+
wealth = context.current_wealth
|
|
101
|
+
|
|
102
|
+
# Calculate raw spending
|
|
103
|
+
raw_spending = wealth * rate
|
|
104
|
+
|
|
105
|
+
# Apply smoothing if we have previous spending
|
|
106
|
+
if self.smoothing_factor > 0 and context.previous_spending is not None:
|
|
107
|
+
# Adjust previous spending for inflation
|
|
108
|
+
prev_real = context.previous_spending / context.inflation_cumulative
|
|
109
|
+
smoothed = (
|
|
110
|
+
self.smoothing_factor * prev_real * context.inflation_cumulative
|
|
111
|
+
+ (1 - self.smoothing_factor) * raw_spending
|
|
112
|
+
)
|
|
113
|
+
spending = smoothed
|
|
114
|
+
else:
|
|
115
|
+
spending = raw_spending
|
|
116
|
+
|
|
117
|
+
# Apply guardrails
|
|
118
|
+
amount, is_floor_breach, is_ceiling_hit = self.apply_guardrails(
|
|
119
|
+
spending, context.current_wealth
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return WithdrawalDecision(
|
|
123
|
+
amount=amount,
|
|
124
|
+
is_floor_breach=is_floor_breach,
|
|
125
|
+
is_ceiling_hit=is_ceiling_hit,
|
|
126
|
+
notes=f"Rate: {rate:.1%}, Remaining: {remaining_years}y",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def get_initial_withdrawal(self, initial_wealth: float) -> float:
|
|
130
|
+
"""Calculate first year withdrawal.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
initial_wealth: Starting portfolio value
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
First year withdrawal amount
|
|
137
|
+
"""
|
|
138
|
+
remaining_years = self.end_age - self.starting_age
|
|
139
|
+
rate = self.get_optimal_rate(remaining_years)
|
|
140
|
+
return initial_wealth * rate
|
|
141
|
+
|
|
142
|
+
def get_spending(
|
|
143
|
+
self,
|
|
144
|
+
wealth: np.ndarray,
|
|
145
|
+
year: int,
|
|
146
|
+
initial_wealth: float,
|
|
147
|
+
) -> np.ndarray:
|
|
148
|
+
"""Get spending for simulation (vectorized interface).
|
|
149
|
+
|
|
150
|
+
This method is used by the Monte Carlo simulation engine.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
wealth: Current portfolio values (n_simulations,)
|
|
154
|
+
year: Current simulation year
|
|
155
|
+
initial_wealth: Starting portfolio value
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Spending amounts for each simulation path
|
|
159
|
+
"""
|
|
160
|
+
current_age = self.starting_age + year
|
|
161
|
+
remaining_years = max(1, self.end_age - current_age)
|
|
162
|
+
rate = self.get_optimal_rate(remaining_years)
|
|
163
|
+
|
|
164
|
+
spending = wealth * rate
|
|
165
|
+
|
|
166
|
+
# Ensure non-negative and bounded by wealth
|
|
167
|
+
spending = np.maximum(spending, 0)
|
|
168
|
+
spending = np.minimum(spending, np.maximum(wealth, 0))
|
|
169
|
+
|
|
170
|
+
# Apply floor if set
|
|
171
|
+
if self.floor_spending is not None:
|
|
172
|
+
spending = np.maximum(spending, self.floor_spending)
|
|
173
|
+
# But still can't spend more than we have
|
|
174
|
+
spending = np.minimum(spending, np.maximum(wealth, 0))
|
|
175
|
+
|
|
176
|
+
return spending
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class SmoothedMertonPolicy(MertonOptimalSpendingPolicy):
|
|
181
|
+
"""Merton optimal with aggressive smoothing for stable spending.
|
|
182
|
+
|
|
183
|
+
This variant applies stronger smoothing to reduce spending volatility,
|
|
184
|
+
trading off some optimality for a more stable spending experience.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
smoothing_factor: float = 0.7
|
|
188
|
+
adaptation_rate: float = 0.1
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def name(self) -> str:
|
|
192
|
+
return "Smoothed Merton"
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def description(self) -> str:
|
|
196
|
+
return "Merton optimal with spending smoothing"
|
|
197
|
+
|
|
198
|
+
def get_spending(
|
|
199
|
+
self,
|
|
200
|
+
wealth: np.ndarray,
|
|
201
|
+
year: int,
|
|
202
|
+
initial_wealth: float,
|
|
203
|
+
) -> np.ndarray:
|
|
204
|
+
"""Get smoothed spending for simulation.
|
|
205
|
+
|
|
206
|
+
Uses exponential smoothing of the optimal spending amount.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
wealth: Current portfolio values
|
|
210
|
+
year: Current simulation year
|
|
211
|
+
initial_wealth: Starting portfolio value
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Smoothed spending amounts
|
|
215
|
+
"""
|
|
216
|
+
# Get raw Merton optimal spending
|
|
217
|
+
current_age = self.starting_age + year
|
|
218
|
+
remaining_years = max(1, self.end_age - current_age)
|
|
219
|
+
rate = self.get_optimal_rate(remaining_years)
|
|
220
|
+
|
|
221
|
+
optimal_spending = wealth * rate
|
|
222
|
+
|
|
223
|
+
# For first year or if tracking isn't set up, use optimal directly
|
|
224
|
+
# In practice, smoothing would be applied via simulation state
|
|
225
|
+
spending = optimal_spending
|
|
226
|
+
|
|
227
|
+
# Apply floor if set
|
|
228
|
+
if self.floor_spending is not None:
|
|
229
|
+
spending = np.maximum(spending, self.floor_spending)
|
|
230
|
+
spending = np.minimum(spending, np.maximum(wealth, 0))
|
|
231
|
+
|
|
232
|
+
return spending
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass
|
|
236
|
+
class FloorAdjustedMertonPolicy(MertonOptimalSpendingPolicy):
|
|
237
|
+
"""Merton optimal that accounts for subsistence floor in spending.
|
|
238
|
+
|
|
239
|
+
This variant only applies the optimal rate to wealth above the
|
|
240
|
+
floor-supporting level, ensuring floor spending is always protected.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
years_of_floor_to_protect: int = 5
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def name(self) -> str:
|
|
247
|
+
return "Floor-Protected Merton"
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def description(self) -> str:
|
|
251
|
+
return f"Merton optimal protecting {self.years_of_floor_to_protect}y floor"
|
|
252
|
+
|
|
253
|
+
def get_spending(
|
|
254
|
+
self,
|
|
255
|
+
wealth: np.ndarray,
|
|
256
|
+
year: int,
|
|
257
|
+
initial_wealth: float,
|
|
258
|
+
) -> np.ndarray:
|
|
259
|
+
"""Get spending that protects floor for several years.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
wealth: Current portfolio values
|
|
263
|
+
year: Current simulation year
|
|
264
|
+
initial_wealth: Starting portfolio value
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
Floor-protected spending amounts
|
|
268
|
+
"""
|
|
269
|
+
current_age = self.starting_age + year
|
|
270
|
+
remaining_years = max(1, self.end_age - current_age)
|
|
271
|
+
rate = self.get_optimal_rate(remaining_years)
|
|
272
|
+
|
|
273
|
+
floor = self.utility_model.subsistence_floor
|
|
274
|
+
protected_wealth = floor * self.years_of_floor_to_protect
|
|
275
|
+
|
|
276
|
+
# Only apply rate to wealth above protected level
|
|
277
|
+
excess_wealth = np.maximum(wealth - protected_wealth, 0)
|
|
278
|
+
flex_spending = excess_wealth * rate
|
|
279
|
+
|
|
280
|
+
# Total spending = floor + flexible portion
|
|
281
|
+
spending = floor + flex_spending
|
|
282
|
+
|
|
283
|
+
# Can't spend more than we have
|
|
284
|
+
spending = np.minimum(spending, np.maximum(wealth, 0))
|
|
285
|
+
|
|
286
|
+
return spending
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""RMD-style withdrawal strategy."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from fundedness.withdrawals.base import (
|
|
8
|
+
BaseWithdrawalPolicy,
|
|
9
|
+
WithdrawalContext,
|
|
10
|
+
WithdrawalDecision,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# IRS Uniform Lifetime Table (2024)
|
|
15
|
+
# Maps age to distribution period (divisor)
|
|
16
|
+
RMD_TABLE = {
|
|
17
|
+
72: 27.4, 73: 26.5, 74: 25.5, 75: 24.6, 76: 23.7,
|
|
18
|
+
77: 22.9, 78: 22.0, 79: 21.1, 80: 20.2, 81: 19.4,
|
|
19
|
+
82: 18.5, 83: 17.7, 84: 16.8, 85: 16.0, 86: 15.2,
|
|
20
|
+
87: 14.4, 88: 13.7, 89: 12.9, 90: 12.2, 91: 11.5,
|
|
21
|
+
92: 10.8, 93: 10.1, 94: 9.5, 95: 8.9, 96: 8.4,
|
|
22
|
+
97: 7.8, 98: 7.3, 99: 6.8, 100: 6.4, 101: 6.0,
|
|
23
|
+
102: 5.6, 103: 5.2, 104: 4.9, 105: 4.6, 106: 4.3,
|
|
24
|
+
107: 4.1, 108: 3.9, 109: 3.7, 110: 3.5, 111: 3.4,
|
|
25
|
+
112: 3.3, 113: 3.1, 114: 3.0, 115: 2.9, 116: 2.8,
|
|
26
|
+
117: 2.7, 118: 2.5, 119: 2.3, 120: 2.0,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_rmd_divisor(age: int) -> float:
|
|
31
|
+
"""Get RMD distribution period for given age.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
age: Current age
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Distribution period (divisor)
|
|
38
|
+
"""
|
|
39
|
+
if age < 72:
|
|
40
|
+
# Extrapolate backwards (not actual RMD, but useful for strategy)
|
|
41
|
+
return 27.4 + (72 - age) * 1.0 # Approximate slope
|
|
42
|
+
elif age > 120:
|
|
43
|
+
return 2.0
|
|
44
|
+
else:
|
|
45
|
+
return RMD_TABLE.get(age, 2.0)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class RMDStylePolicy(BaseWithdrawalPolicy):
|
|
50
|
+
"""RMD-style withdrawal strategy.
|
|
51
|
+
|
|
52
|
+
Uses IRS Required Minimum Distribution table to determine withdrawals.
|
|
53
|
+
Withdrawal = Portfolio Value / Distribution Period
|
|
54
|
+
|
|
55
|
+
This approach automatically increases withdrawal rate as you age,
|
|
56
|
+
similar to how RMDs work for tax-deferred accounts.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
starting_age: int = 65
|
|
60
|
+
multiplier: float = 1.0 # Scale factor (1.0 = exact RMD, 1.5 = 150% of RMD)
|
|
61
|
+
start_before_72: bool = True # Apply RMD-style before actual RMD age
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def name(self) -> str:
|
|
65
|
+
mult_str = f" × {self.multiplier}" if self.multiplier != 1.0 else ""
|
|
66
|
+
return f"RMD-Style{mult_str}"
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def description(self) -> str:
|
|
70
|
+
return (
|
|
71
|
+
"Withdraw based on IRS RMD table divisors. "
|
|
72
|
+
"Withdrawal rate automatically increases with age."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def get_initial_withdrawal(self, initial_wealth: float) -> float:
|
|
76
|
+
"""Calculate first year withdrawal."""
|
|
77
|
+
divisor = get_rmd_divisor(self.starting_age)
|
|
78
|
+
return (initial_wealth / divisor) * self.multiplier
|
|
79
|
+
|
|
80
|
+
def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
|
|
81
|
+
"""Calculate RMD-style withdrawal.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
context: Current state including age or year
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
WithdrawalDecision based on RMD table
|
|
88
|
+
"""
|
|
89
|
+
# Determine current age
|
|
90
|
+
if context.age is not None:
|
|
91
|
+
current_age = context.age
|
|
92
|
+
else:
|
|
93
|
+
current_age = self.starting_age + context.year
|
|
94
|
+
|
|
95
|
+
# Get divisor
|
|
96
|
+
divisor = get_rmd_divisor(current_age)
|
|
97
|
+
|
|
98
|
+
# Calculate withdrawal
|
|
99
|
+
if isinstance(context.current_wealth, np.ndarray):
|
|
100
|
+
amount = (context.current_wealth / divisor) * self.multiplier
|
|
101
|
+
else:
|
|
102
|
+
amount = (context.current_wealth / divisor) * self.multiplier
|
|
103
|
+
|
|
104
|
+
# Apply guardrails
|
|
105
|
+
amount, is_floor_breach, is_ceiling_hit = self.apply_guardrails(
|
|
106
|
+
amount, context.current_wealth
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
withdrawal_rate = 1 / divisor * self.multiplier
|
|
110
|
+
|
|
111
|
+
return WithdrawalDecision(
|
|
112
|
+
amount=amount,
|
|
113
|
+
is_floor_breach=is_floor_breach,
|
|
114
|
+
is_ceiling_hit=is_ceiling_hit,
|
|
115
|
+
notes=f"Age {current_age}, divisor: {divisor:.1f}, rate: {withdrawal_rate:.2%}",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class AmortizationPolicy(BaseWithdrawalPolicy):
|
|
121
|
+
"""Amortization-based withdrawal strategy.
|
|
122
|
+
|
|
123
|
+
Treats the portfolio like a mortgage in reverse - calculates the level
|
|
124
|
+
payment that would exhaust the portfolio over the planning horizon
|
|
125
|
+
given expected returns.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
starting_age: int = 65
|
|
129
|
+
planning_age: int = 95 # Age to plan to
|
|
130
|
+
expected_return: float = 0.04 # Expected real return
|
|
131
|
+
recalculate_annually: bool = True # Recalculate each year
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def name(self) -> str:
|
|
135
|
+
return "Amortization"
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def description(self) -> str:
|
|
139
|
+
return (
|
|
140
|
+
f"Calculate level payment to exhaust portfolio by age {self.planning_age} "
|
|
141
|
+
f"assuming {self.expected_return:.1%} real return."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def _calculate_pmt(self, wealth: float, years_remaining: int) -> float:
|
|
145
|
+
"""Calculate amortization payment.
|
|
146
|
+
|
|
147
|
+
PMT = PV * r / (1 - (1+r)^-n)
|
|
148
|
+
"""
|
|
149
|
+
if years_remaining <= 0:
|
|
150
|
+
return wealth # Spend it all
|
|
151
|
+
|
|
152
|
+
r = self.expected_return
|
|
153
|
+
n = years_remaining
|
|
154
|
+
|
|
155
|
+
if r == 0:
|
|
156
|
+
return wealth / n
|
|
157
|
+
|
|
158
|
+
# Standard amortization formula
|
|
159
|
+
pmt = wealth * r / (1 - (1 + r) ** (-n))
|
|
160
|
+
return pmt
|
|
161
|
+
|
|
162
|
+
def get_initial_withdrawal(self, initial_wealth: float) -> float:
|
|
163
|
+
"""Calculate first year withdrawal."""
|
|
164
|
+
years = self.planning_age - self.starting_age
|
|
165
|
+
return self._calculate_pmt(initial_wealth, years)
|
|
166
|
+
|
|
167
|
+
def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
|
|
168
|
+
"""Calculate amortization-based withdrawal.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
context: Current state
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
WithdrawalDecision based on amortization formula
|
|
175
|
+
"""
|
|
176
|
+
# Determine current age and years remaining
|
|
177
|
+
if context.age is not None:
|
|
178
|
+
current_age = context.age
|
|
179
|
+
else:
|
|
180
|
+
current_age = self.starting_age + context.year
|
|
181
|
+
|
|
182
|
+
years_remaining = max(1, self.planning_age - current_age)
|
|
183
|
+
|
|
184
|
+
# Calculate payment
|
|
185
|
+
if isinstance(context.current_wealth, np.ndarray):
|
|
186
|
+
amount = np.array([
|
|
187
|
+
self._calculate_pmt(w, years_remaining)
|
|
188
|
+
for w in context.current_wealth
|
|
189
|
+
])
|
|
190
|
+
else:
|
|
191
|
+
amount = self._calculate_pmt(context.current_wealth, years_remaining)
|
|
192
|
+
|
|
193
|
+
# Apply guardrails
|
|
194
|
+
amount, is_floor_breach, is_ceiling_hit = self.apply_guardrails(
|
|
195
|
+
amount, context.current_wealth
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
return WithdrawalDecision(
|
|
199
|
+
amount=amount,
|
|
200
|
+
is_floor_breach=is_floor_breach,
|
|
201
|
+
is_ceiling_hit=is_ceiling_hit,
|
|
202
|
+
notes=f"Years remaining: {years_remaining}",
|
|
203
|
+
)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Variable Percentage Withdrawal (VPW) strategy."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from fundedness.withdrawals.base import (
|
|
8
|
+
BaseWithdrawalPolicy,
|
|
9
|
+
WithdrawalContext,
|
|
10
|
+
WithdrawalDecision,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# VPW percentage table based on age and asset allocation
|
|
15
|
+
# Source: Bogleheads VPW methodology
|
|
16
|
+
# These are the percentages of portfolio to withdraw at each age
|
|
17
|
+
VPW_TABLE = {
|
|
18
|
+
# Age: {stock_pct: withdrawal_pct}
|
|
19
|
+
# Simplified table - in practice would interpolate
|
|
20
|
+
60: {0: 0.037, 25: 0.039, 50: 0.042, 75: 0.046, 100: 0.051},
|
|
21
|
+
65: {0: 0.041, 25: 0.044, 50: 0.047, 75: 0.052, 100: 0.058},
|
|
22
|
+
70: {0: 0.047, 25: 0.050, 50: 0.054, 75: 0.060, 100: 0.068},
|
|
23
|
+
75: {0: 0.054, 25: 0.058, 50: 0.064, 75: 0.071, 100: 0.081},
|
|
24
|
+
80: {0: 0.064, 25: 0.069, 50: 0.076, 75: 0.086, 100: 0.099},
|
|
25
|
+
85: {0: 0.078, 25: 0.084, 50: 0.093, 75: 0.106, 100: 0.124},
|
|
26
|
+
90: {0: 0.097, 25: 0.106, 50: 0.118, 75: 0.135, 100: 0.160},
|
|
27
|
+
95: {0: 0.127, 25: 0.139, 50: 0.156, 75: 0.180, 100: 0.214},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_vpw_rate(age: int, stock_allocation: int = 50) -> float:
|
|
32
|
+
"""Get VPW withdrawal rate for given age and allocation.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
age: Current age
|
|
36
|
+
stock_allocation: Stock allocation as integer percentage (0-100)
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Withdrawal rate as decimal
|
|
40
|
+
"""
|
|
41
|
+
# Find bracketing ages
|
|
42
|
+
ages = sorted(VPW_TABLE.keys())
|
|
43
|
+
|
|
44
|
+
if age <= ages[0]:
|
|
45
|
+
age_key = ages[0]
|
|
46
|
+
elif age >= ages[-1]:
|
|
47
|
+
age_key = ages[-1]
|
|
48
|
+
else:
|
|
49
|
+
# Find closest age
|
|
50
|
+
age_key = min(ages, key=lambda x: abs(x - age))
|
|
51
|
+
|
|
52
|
+
# Find closest allocation
|
|
53
|
+
allocations = sorted(VPW_TABLE[age_key].keys())
|
|
54
|
+
if stock_allocation <= allocations[0]:
|
|
55
|
+
alloc_key = allocations[0]
|
|
56
|
+
elif stock_allocation >= allocations[-1]:
|
|
57
|
+
alloc_key = allocations[-1]
|
|
58
|
+
else:
|
|
59
|
+
alloc_key = min(allocations, key=lambda x: abs(x - stock_allocation))
|
|
60
|
+
|
|
61
|
+
return VPW_TABLE[age_key][alloc_key]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class VPWPolicy(BaseWithdrawalPolicy):
|
|
66
|
+
"""Variable Percentage Withdrawal (VPW) strategy.
|
|
67
|
+
|
|
68
|
+
Withdrawal rate varies based on age and remaining life expectancy.
|
|
69
|
+
Uses actuarial tables to determine appropriate withdrawal percentage.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
starting_age: int = 65
|
|
73
|
+
stock_allocation: int = 50 # As integer percentage
|
|
74
|
+
smoothing_factor: float = 0.0 # 0 = pure VPW, 1 = fully smoothed
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def name(self) -> str:
|
|
78
|
+
return "VPW"
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def description(self) -> str:
|
|
82
|
+
return (
|
|
83
|
+
"Variable Percentage Withdrawal based on age and life expectancy. "
|
|
84
|
+
"Withdrawal rate increases as you age."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def get_initial_withdrawal(self, initial_wealth: float) -> float:
|
|
88
|
+
"""Calculate first year withdrawal."""
|
|
89
|
+
rate = get_vpw_rate(self.starting_age, self.stock_allocation)
|
|
90
|
+
return initial_wealth * rate
|
|
91
|
+
|
|
92
|
+
def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
|
|
93
|
+
"""Calculate VPW withdrawal.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
context: Current state including age or year
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
WithdrawalDecision based on VPW table
|
|
100
|
+
"""
|
|
101
|
+
# Determine current age
|
|
102
|
+
if context.age is not None:
|
|
103
|
+
current_age = context.age
|
|
104
|
+
else:
|
|
105
|
+
current_age = self.starting_age + context.year
|
|
106
|
+
|
|
107
|
+
# Get VPW rate for current age
|
|
108
|
+
vpw_rate = get_vpw_rate(current_age, self.stock_allocation)
|
|
109
|
+
|
|
110
|
+
# Calculate base withdrawal
|
|
111
|
+
if isinstance(context.current_wealth, np.ndarray):
|
|
112
|
+
base_amount = context.current_wealth * vpw_rate
|
|
113
|
+
else:
|
|
114
|
+
base_amount = context.current_wealth * vpw_rate
|
|
115
|
+
|
|
116
|
+
# Apply smoothing if requested
|
|
117
|
+
if self.smoothing_factor > 0 and context.previous_spending is not None:
|
|
118
|
+
smoothed = (
|
|
119
|
+
self.smoothing_factor * context.previous_spending
|
|
120
|
+
+ (1 - self.smoothing_factor) * base_amount
|
|
121
|
+
)
|
|
122
|
+
amount = smoothed
|
|
123
|
+
else:
|
|
124
|
+
amount = base_amount
|
|
125
|
+
|
|
126
|
+
# Apply guardrails
|
|
127
|
+
amount, is_floor_breach, is_ceiling_hit = self.apply_guardrails(
|
|
128
|
+
amount, context.current_wealth
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return WithdrawalDecision(
|
|
132
|
+
amount=amount,
|
|
133
|
+
is_floor_breach=is_floor_breach,
|
|
134
|
+
is_ceiling_hit=is_ceiling_hit,
|
|
135
|
+
notes=f"Age {current_age}, VPW rate: {vpw_rate:.2%}",
|
|
136
|
+
)
|