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.
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 +199 -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 +595 -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.4.dist-info/METADATA +300 -0
  41. fundedness-0.2.4.dist-info/RECORD +43 -0
  42. fundedness-0.2.4.dist-info/WHEEL +4 -0
  43. 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
+ )