fundedness 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of fundedness might be problematic. Click here for more details.

Files changed (43) hide show
  1. fundedness/__init__.py +71 -0
  2. fundedness/allocation/__init__.py +20 -0
  3. fundedness/allocation/base.py +32 -0
  4. fundedness/allocation/constant.py +25 -0
  5. fundedness/allocation/glidepath.py +111 -0
  6. fundedness/allocation/merton_optimal.py +220 -0
  7. fundedness/cefr.py +241 -0
  8. fundedness/liabilities.py +221 -0
  9. fundedness/liquidity.py +49 -0
  10. fundedness/merton.py +289 -0
  11. fundedness/models/__init__.py +35 -0
  12. fundedness/models/assets.py +148 -0
  13. fundedness/models/household.py +153 -0
  14. fundedness/models/liabilities.py +99 -0
  15. fundedness/models/market.py +188 -0
  16. fundedness/models/simulation.py +80 -0
  17. fundedness/models/tax.py +125 -0
  18. fundedness/models/utility.py +154 -0
  19. fundedness/optimize.py +473 -0
  20. fundedness/policies.py +204 -0
  21. fundedness/risk.py +72 -0
  22. fundedness/simulate.py +559 -0
  23. fundedness/viz/__init__.py +33 -0
  24. fundedness/viz/colors.py +110 -0
  25. fundedness/viz/comparison.py +294 -0
  26. fundedness/viz/fan_chart.py +193 -0
  27. fundedness/viz/histogram.py +225 -0
  28. fundedness/viz/optimal.py +542 -0
  29. fundedness/viz/survival.py +230 -0
  30. fundedness/viz/tornado.py +236 -0
  31. fundedness/viz/waterfall.py +203 -0
  32. fundedness/withdrawals/__init__.py +27 -0
  33. fundedness/withdrawals/base.py +116 -0
  34. fundedness/withdrawals/comparison.py +230 -0
  35. fundedness/withdrawals/fixed_swr.py +174 -0
  36. fundedness/withdrawals/guardrails.py +136 -0
  37. fundedness/withdrawals/merton_optimal.py +286 -0
  38. fundedness/withdrawals/rmd_style.py +203 -0
  39. fundedness/withdrawals/vpw.py +136 -0
  40. fundedness-0.2.2.dist-info/METADATA +299 -0
  41. fundedness-0.2.2.dist-info/RECORD +43 -0
  42. fundedness-0.2.2.dist-info/WHEEL +4 -0
  43. fundedness-0.2.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,116 @@
1
+ """Base classes for withdrawal strategies."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Protocol
5
+
6
+ import numpy as np
7
+
8
+
9
+ @dataclass
10
+ class WithdrawalContext:
11
+ """Context information for making a withdrawal decision."""
12
+
13
+ current_wealth: float | np.ndarray
14
+ initial_wealth: float
15
+ year: int
16
+ age: int | None = None
17
+ inflation_cumulative: float = 1.0 # Cumulative inflation since start
18
+ previous_spending: float | np.ndarray | None = None
19
+ market_return_ytd: float | None = None # Year-to-date market return
20
+
21
+
22
+ @dataclass
23
+ class WithdrawalDecision:
24
+ """Result of a withdrawal decision."""
25
+
26
+ amount: float | np.ndarray
27
+ is_floor_breach: bool | np.ndarray = False
28
+ is_ceiling_hit: bool | np.ndarray = False
29
+ notes: str = ""
30
+
31
+
32
+ class WithdrawalPolicy(Protocol):
33
+ """Protocol defining the interface for withdrawal strategies."""
34
+
35
+ @property
36
+ def name(self) -> str:
37
+ """Human-readable name for the strategy."""
38
+ ...
39
+
40
+ @property
41
+ def description(self) -> str:
42
+ """Brief description of how the strategy works."""
43
+ ...
44
+
45
+ def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
46
+ """Calculate the withdrawal amount for the given context.
47
+
48
+ Args:
49
+ context: Current state information
50
+
51
+ Returns:
52
+ WithdrawalDecision with amount and metadata
53
+ """
54
+ ...
55
+
56
+ def get_initial_withdrawal(self, initial_wealth: float) -> float:
57
+ """Calculate the first year's withdrawal.
58
+
59
+ Args:
60
+ initial_wealth: Starting portfolio value
61
+
62
+ Returns:
63
+ First year withdrawal amount
64
+ """
65
+ ...
66
+
67
+
68
+ @dataclass
69
+ class BaseWithdrawalPolicy:
70
+ """Base class with common functionality for withdrawal policies."""
71
+
72
+ floor_spending: float | None = None
73
+ ceiling_spending: float | None = None
74
+
75
+ def apply_guardrails(
76
+ self,
77
+ amount: float | np.ndarray,
78
+ wealth: float | np.ndarray,
79
+ ) -> tuple[float | np.ndarray, bool | np.ndarray, bool | np.ndarray]:
80
+ """Apply floor and ceiling guardrails to withdrawal amount.
81
+
82
+ Args:
83
+ amount: Proposed withdrawal amount
84
+ wealth: Current wealth
85
+
86
+ Returns:
87
+ Tuple of (adjusted_amount, is_floor_breach, is_ceiling_hit)
88
+ """
89
+ is_floor_breach = False
90
+ is_ceiling_hit = False
91
+
92
+ # Apply floor
93
+ if self.floor_spending is not None:
94
+ if isinstance(amount, np.ndarray):
95
+ is_floor_breach = amount < self.floor_spending
96
+ amount = np.maximum(amount, self.floor_spending)
97
+ else:
98
+ is_floor_breach = amount < self.floor_spending
99
+ amount = max(amount, self.floor_spending)
100
+
101
+ # Apply ceiling
102
+ if self.ceiling_spending is not None:
103
+ if isinstance(amount, np.ndarray):
104
+ is_ceiling_hit = amount > self.ceiling_spending
105
+ amount = np.minimum(amount, self.ceiling_spending)
106
+ else:
107
+ is_ceiling_hit = amount > self.ceiling_spending
108
+ amount = min(amount, self.ceiling_spending)
109
+
110
+ # Can't withdraw more than we have
111
+ if isinstance(amount, np.ndarray) or isinstance(wealth, np.ndarray):
112
+ amount = np.minimum(amount, np.maximum(wealth, 0))
113
+ else:
114
+ amount = min(amount, max(wealth, 0))
115
+
116
+ return amount, is_floor_breach, is_ceiling_hit
@@ -0,0 +1,230 @@
1
+ """Withdrawal strategy comparison framework."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+
8
+ from fundedness.models.simulation import SimulationConfig
9
+ from fundedness.simulate import SimulationResult, generate_returns
10
+ from fundedness.withdrawals.base import WithdrawalContext, WithdrawalPolicy
11
+
12
+
13
+ @dataclass
14
+ class StrategyComparisonResult:
15
+ """Results from comparing multiple withdrawal strategies."""
16
+
17
+ strategy_names: list[str]
18
+ results: dict[str, SimulationResult]
19
+ metrics: dict[str, dict[str, Any]]
20
+
21
+ def get_summary_table(self) -> dict[str, list]:
22
+ """Get a summary table of key metrics across strategies."""
23
+ return {
24
+ "Strategy": self.strategy_names,
25
+ "Success Rate": [
26
+ self.metrics[name]["success_rate"] for name in self.strategy_names
27
+ ],
28
+ "Median Terminal Wealth": [
29
+ self.metrics[name]["median_terminal_wealth"] for name in self.strategy_names
30
+ ],
31
+ "Median Spending (Year 1)": [
32
+ self.metrics[name]["median_initial_spending"] for name in self.strategy_names
33
+ ],
34
+ "Spending Volatility": [
35
+ self.metrics[name]["spending_volatility"] for name in self.strategy_names
36
+ ],
37
+ "Floor Breach Rate": [
38
+ self.metrics[name]["floor_breach_rate"] for name in self.strategy_names
39
+ ],
40
+ }
41
+
42
+
43
+ def run_strategy_simulation(
44
+ policy: WithdrawalPolicy,
45
+ initial_wealth: float,
46
+ config: SimulationConfig,
47
+ stock_weight: float = 0.6,
48
+ starting_age: int = 65,
49
+ spending_floor: float | None = None,
50
+ ) -> SimulationResult:
51
+ """Run a Monte Carlo simulation with a specific withdrawal strategy.
52
+
53
+ Args:
54
+ policy: Withdrawal policy to use
55
+ initial_wealth: Starting portfolio value
56
+ config: Simulation configuration
57
+ stock_weight: Asset allocation to stocks
58
+ starting_age: Starting age for age-based strategies
59
+ spending_floor: Minimum acceptable spending
60
+
61
+ Returns:
62
+ SimulationResult with paths and metrics
63
+ """
64
+ n_sim = config.n_simulations
65
+ n_years = config.n_years
66
+ seed = config.random_seed
67
+
68
+ # Generate returns
69
+ returns = generate_returns(
70
+ n_simulations=n_sim,
71
+ n_years=n_years,
72
+ market_model=config.market_model,
73
+ stock_weight=stock_weight,
74
+ random_seed=seed,
75
+ )
76
+
77
+ # Initialize paths
78
+ wealth_paths = np.zeros((n_sim, n_years + 1))
79
+ wealth_paths[:, 0] = initial_wealth
80
+ spending_paths = np.zeros((n_sim, n_years))
81
+
82
+ time_to_ruin = np.full(n_sim, np.inf)
83
+ time_to_floor_breach = np.full(n_sim, np.inf) if spending_floor else None
84
+
85
+ previous_spending = None
86
+
87
+ # Simulate year by year
88
+ for year in range(n_years):
89
+ current_wealth = wealth_paths[:, year]
90
+
91
+ # Create context
92
+ context = WithdrawalContext(
93
+ current_wealth=current_wealth,
94
+ initial_wealth=initial_wealth,
95
+ year=year,
96
+ age=starting_age + year,
97
+ previous_spending=previous_spending,
98
+ )
99
+
100
+ # Get withdrawal decision
101
+ decision = policy.calculate_withdrawal(context)
102
+ spending = decision.amount
103
+
104
+ spending_paths[:, year] = spending
105
+ previous_spending = spending
106
+
107
+ # Track floor breach
108
+ if time_to_floor_breach is not None and spending_floor:
109
+ floor_breach_mask = (spending < spending_floor) & np.isinf(time_to_floor_breach)
110
+ time_to_floor_breach[floor_breach_mask] = year
111
+
112
+ # Update wealth
113
+ wealth_after_spending = np.maximum(current_wealth - spending, 0)
114
+ wealth_paths[:, year + 1] = wealth_after_spending * (1 + returns[:, year])
115
+
116
+ # Track ruin
117
+ ruin_mask = (wealth_paths[:, year + 1] <= 0) & np.isinf(time_to_ruin)
118
+ time_to_ruin[ruin_mask] = year + 1
119
+
120
+ # Calculate percentiles
121
+ wealth_percentiles = {}
122
+ spending_percentiles = {}
123
+
124
+ for p in config.percentiles:
125
+ key = f"P{p}"
126
+ wealth_percentiles[key] = np.percentile(wealth_paths[:, 1:], p, axis=0)
127
+ spending_percentiles[key] = np.percentile(spending_paths, p, axis=0)
128
+
129
+ terminal_wealth = wealth_paths[:, -1]
130
+
131
+ return SimulationResult(
132
+ wealth_paths=wealth_paths[:, 1:],
133
+ spending_paths=spending_paths,
134
+ time_to_ruin=time_to_ruin,
135
+ time_to_floor_breach=time_to_floor_breach,
136
+ wealth_percentiles=wealth_percentiles,
137
+ spending_percentiles=spending_percentiles,
138
+ success_rate=np.mean(np.isinf(time_to_ruin)),
139
+ floor_breach_rate=np.mean(~np.isinf(time_to_floor_breach)) if time_to_floor_breach is not None else 0.0,
140
+ median_terminal_wealth=np.median(terminal_wealth),
141
+ mean_terminal_wealth=np.mean(terminal_wealth),
142
+ n_simulations=n_sim,
143
+ n_years=n_years,
144
+ random_seed=seed,
145
+ )
146
+
147
+
148
+ def compare_strategies(
149
+ policies: list[WithdrawalPolicy],
150
+ initial_wealth: float,
151
+ config: SimulationConfig,
152
+ stock_weight: float = 0.6,
153
+ starting_age: int = 65,
154
+ spending_floor: float | None = None,
155
+ ) -> StrategyComparisonResult:
156
+ """Compare multiple withdrawal strategies using the same random draws.
157
+
158
+ Args:
159
+ policies: List of withdrawal policies to compare
160
+ initial_wealth: Starting portfolio value
161
+ config: Simulation configuration
162
+ stock_weight: Asset allocation to stocks
163
+ starting_age: Starting age for age-based strategies
164
+ spending_floor: Minimum acceptable spending
165
+
166
+ Returns:
167
+ StrategyComparisonResult with all results and metrics
168
+ """
169
+ strategy_names = [p.name for p in policies]
170
+ results = {}
171
+ metrics = {}
172
+
173
+ # Use same seed for all strategies for fair comparison
174
+ base_seed = config.random_seed or 42
175
+
176
+ for i, policy in enumerate(policies):
177
+ # Use same seed for reproducibility
178
+ config_copy = SimulationConfig(
179
+ n_simulations=config.n_simulations,
180
+ n_years=config.n_years,
181
+ random_seed=base_seed,
182
+ market_model=config.market_model,
183
+ tax_model=config.tax_model,
184
+ utility_model=config.utility_model,
185
+ percentiles=config.percentiles,
186
+ )
187
+
188
+ result = run_strategy_simulation(
189
+ policy=policy,
190
+ initial_wealth=initial_wealth,
191
+ config=config_copy,
192
+ stock_weight=stock_weight,
193
+ starting_age=starting_age,
194
+ spending_floor=spending_floor,
195
+ )
196
+
197
+ results[policy.name] = result
198
+
199
+ # Calculate additional metrics
200
+ spending_paths = result.spending_paths
201
+ if spending_paths is not None:
202
+ # Spending volatility (coefficient of variation of spending changes)
203
+ spending_changes = np.diff(spending_paths, axis=1) / spending_paths[:, :-1]
204
+ spending_volatility = np.nanstd(spending_changes)
205
+
206
+ # Median initial spending
207
+ median_initial_spending = np.median(spending_paths[:, 0])
208
+
209
+ # Average spending
210
+ avg_spending = np.mean(spending_paths)
211
+ else:
212
+ spending_volatility = 0
213
+ median_initial_spending = 0
214
+ avg_spending = 0
215
+
216
+ metrics[policy.name] = {
217
+ "success_rate": result.success_rate,
218
+ "floor_breach_rate": result.floor_breach_rate,
219
+ "median_terminal_wealth": result.median_terminal_wealth,
220
+ "mean_terminal_wealth": result.mean_terminal_wealth,
221
+ "median_initial_spending": median_initial_spending,
222
+ "average_spending": avg_spending,
223
+ "spending_volatility": spending_volatility,
224
+ }
225
+
226
+ return StrategyComparisonResult(
227
+ strategy_names=strategy_names,
228
+ results=results,
229
+ metrics=metrics,
230
+ )
@@ -0,0 +1,174 @@
1
+ """Fixed Safe Withdrawal Rate (SWR) policy."""
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
+ @dataclass
15
+ class FixedRealSWRPolicy(BaseWithdrawalPolicy):
16
+ """Classic fixed real (inflation-adjusted) withdrawal strategy.
17
+
18
+ The "4% rule" approach: withdraw a fixed percentage of initial portfolio,
19
+ then adjust for inflation each year.
20
+ """
21
+
22
+ withdrawal_rate: float = 0.04 # 4% default
23
+ inflation_rate: float = 0.025 # 2.5% expected inflation
24
+
25
+ @property
26
+ def name(self) -> str:
27
+ return f"Fixed {self.withdrawal_rate:.1%} SWR"
28
+
29
+ @property
30
+ def description(self) -> str:
31
+ return (
32
+ f"Withdraw {self.withdrawal_rate:.1%} of initial portfolio in year 1, "
33
+ f"then adjust for {self.inflation_rate:.1%} inflation annually."
34
+ )
35
+
36
+ def get_initial_withdrawal(self, initial_wealth: float) -> float:
37
+ """Calculate first year withdrawal."""
38
+ return initial_wealth * self.withdrawal_rate
39
+
40
+ def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
41
+ """Calculate inflation-adjusted withdrawal.
42
+
43
+ Args:
44
+ context: Current state including initial wealth and year
45
+
46
+ Returns:
47
+ WithdrawalDecision with fixed real amount
48
+ """
49
+ # Base withdrawal from initial wealth
50
+ base_amount = context.initial_wealth * self.withdrawal_rate
51
+
52
+ # Adjust for cumulative inflation
53
+ inflation_factor = (1 + self.inflation_rate) ** context.year
54
+ nominal_amount = base_amount * inflation_factor
55
+
56
+ # Apply guardrails and wealth cap
57
+ amount, is_floor_breach, is_ceiling_hit = self.apply_guardrails(
58
+ nominal_amount, context.current_wealth
59
+ )
60
+
61
+ return WithdrawalDecision(
62
+ amount=amount,
63
+ is_floor_breach=is_floor_breach,
64
+ is_ceiling_hit=is_ceiling_hit,
65
+ notes=f"Year {context.year}: base ${base_amount:,.0f} × {inflation_factor:.3f} inflation",
66
+ )
67
+
68
+ def get_spending(
69
+ self,
70
+ wealth: np.ndarray,
71
+ year: int,
72
+ initial_wealth: float,
73
+ ) -> np.ndarray:
74
+ """Get spending for simulation (vectorized interface).
75
+
76
+ Args:
77
+ wealth: Current portfolio values (n_simulations,)
78
+ year: Current simulation year
79
+ initial_wealth: Starting portfolio value
80
+
81
+ Returns:
82
+ Spending amounts for each simulation path
83
+ """
84
+ # Base withdrawal from initial wealth
85
+ base_amount = initial_wealth * self.withdrawal_rate
86
+
87
+ # Adjust for cumulative inflation
88
+ inflation_factor = (1 + self.inflation_rate) ** year
89
+ spending = np.full_like(wealth, base_amount * inflation_factor)
90
+
91
+ # Apply floor if set
92
+ if self.floor_spending is not None:
93
+ spending = np.maximum(spending, self.floor_spending)
94
+
95
+ # Can't spend more than we have
96
+ spending = np.minimum(spending, np.maximum(wealth, 0))
97
+
98
+ return spending
99
+
100
+
101
+ @dataclass
102
+ class PercentOfPortfolioPolicy(BaseWithdrawalPolicy):
103
+ """Withdraw a fixed percentage of current portfolio value each year.
104
+
105
+ More volatile than fixed SWR but automatically adjusts to portfolio performance.
106
+ """
107
+
108
+ withdrawal_rate: float = 0.04
109
+ floor: float | None = None
110
+
111
+ @property
112
+ def name(self) -> str:
113
+ return f"{self.withdrawal_rate:.1%} of Portfolio"
114
+
115
+ @property
116
+ def description(self) -> str:
117
+ return f"Withdraw {self.withdrawal_rate:.1%} of current portfolio value each year."
118
+
119
+ def get_initial_withdrawal(self, initial_wealth: float) -> float:
120
+ """Calculate first year withdrawal."""
121
+ return initial_wealth * self.withdrawal_rate
122
+
123
+ def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
124
+ """Calculate percentage-of-portfolio withdrawal.
125
+
126
+ Args:
127
+ context: Current state including current wealth
128
+
129
+ Returns:
130
+ WithdrawalDecision based on current portfolio value
131
+ """
132
+ if isinstance(context.current_wealth, np.ndarray):
133
+ amount = context.current_wealth * self.withdrawal_rate
134
+ else:
135
+ amount = context.current_wealth * self.withdrawal_rate
136
+
137
+ # Apply guardrails
138
+ amount, is_floor_breach, is_ceiling_hit = self.apply_guardrails(
139
+ amount, context.current_wealth
140
+ )
141
+
142
+ return WithdrawalDecision(
143
+ amount=amount,
144
+ is_floor_breach=is_floor_breach,
145
+ is_ceiling_hit=is_ceiling_hit,
146
+ )
147
+
148
+ def get_spending(
149
+ self,
150
+ wealth: np.ndarray,
151
+ year: int,
152
+ initial_wealth: float,
153
+ ) -> np.ndarray:
154
+ """Get spending for simulation (vectorized interface).
155
+
156
+ Args:
157
+ wealth: Current portfolio values (n_simulations,)
158
+ year: Current simulation year
159
+ initial_wealth: Starting portfolio value
160
+
161
+ Returns:
162
+ Spending amounts for each simulation path
163
+ """
164
+ spending = wealth * self.withdrawal_rate
165
+
166
+ # Apply floor if set
167
+ floor = self.floor or self.floor_spending
168
+ if floor is not None:
169
+ spending = np.maximum(spending, floor)
170
+
171
+ # Can't spend more than we have
172
+ spending = np.minimum(spending, np.maximum(wealth, 0))
173
+
174
+ return spending
@@ -0,0 +1,136 @@
1
+ """Guardrails withdrawal strategy (Guyton-Klinger style)."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ import numpy as np
6
+
7
+ from fundedness.withdrawals.base import (
8
+ BaseWithdrawalPolicy,
9
+ WithdrawalContext,
10
+ WithdrawalDecision,
11
+ )
12
+
13
+
14
+ @dataclass
15
+ class GuardrailsPolicy(BaseWithdrawalPolicy):
16
+ """Guyton-Klinger style guardrails withdrawal strategy.
17
+
18
+ Start with initial withdrawal rate, then adjust based on portfolio performance:
19
+ - If withdrawal rate rises above upper guardrail, cut spending
20
+ - If withdrawal rate falls below lower guardrail, increase spending
21
+ - Otherwise, adjust previous spending for inflation
22
+ """
23
+
24
+ initial_rate: float = 0.05 # Starting withdrawal rate (5%)
25
+ upper_guardrail: float = 0.06 # Cut spending if rate exceeds this
26
+ lower_guardrail: float = 0.04 # Raise spending if rate falls below this
27
+ cut_amount: float = 0.10 # Cut spending by 10% when hitting upper rail
28
+ raise_amount: float = 0.10 # Raise spending by 10% when hitting lower rail
29
+ inflation_rate: float = 0.025
30
+ no_raise_in_down_year: bool = True # Don't raise spending after negative returns
31
+
32
+ _initial_spending: float = field(default=0.0, init=False, repr=False)
33
+
34
+ @property
35
+ def name(self) -> str:
36
+ return "Guardrails"
37
+
38
+ @property
39
+ def description(self) -> str:
40
+ return (
41
+ f"Start at {self.initial_rate:.1%}, adjust for inflation, but cut by "
42
+ f"{self.cut_amount:.0%} if rate > {self.upper_guardrail:.1%} or raise by "
43
+ f"{self.raise_amount:.0%} if rate < {self.lower_guardrail:.1%}."
44
+ )
45
+
46
+ def get_initial_withdrawal(self, initial_wealth: float) -> float:
47
+ """Calculate first year withdrawal."""
48
+ self._initial_spending = initial_wealth * self.initial_rate
49
+ return self._initial_spending
50
+
51
+ def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
52
+ """Calculate guardrails-adjusted withdrawal.
53
+
54
+ Args:
55
+ context: Current state including previous spending and market returns
56
+
57
+ Returns:
58
+ WithdrawalDecision with guardrail-adjusted amount
59
+ """
60
+ # Get previous spending (or calculate initial)
61
+ if context.previous_spending is None or context.year == 0:
62
+ if isinstance(context.current_wealth, np.ndarray):
63
+ base_spending = np.full_like(
64
+ context.current_wealth,
65
+ context.initial_wealth * self.initial_rate,
66
+ )
67
+ else:
68
+ base_spending = context.initial_wealth * self.initial_rate
69
+ else:
70
+ # Inflation-adjust previous spending
71
+ base_spending = context.previous_spending * (1 + self.inflation_rate)
72
+
73
+ # Calculate current withdrawal rate
74
+ if isinstance(context.current_wealth, np.ndarray):
75
+ current_rate = np.where(
76
+ context.current_wealth > 0,
77
+ base_spending / context.current_wealth,
78
+ np.inf,
79
+ )
80
+ else:
81
+ current_rate = (
82
+ base_spending / context.current_wealth
83
+ if context.current_wealth > 0
84
+ else float("inf")
85
+ )
86
+
87
+ # Apply guardrails
88
+ amount = base_spending
89
+
90
+ if isinstance(current_rate, np.ndarray):
91
+ # Vectorized guardrail logic
92
+ # Cut spending if above upper guardrail
93
+ above_upper = current_rate > self.upper_guardrail
94
+ amount = np.where(above_upper, amount * (1 - self.cut_amount), amount)
95
+
96
+ # Raise spending if below lower guardrail (unless down year rule)
97
+ below_lower = current_rate < self.lower_guardrail
98
+ if self.no_raise_in_down_year and context.market_return_ytd is not None:
99
+ below_lower = below_lower & (context.market_return_ytd >= 0)
100
+ amount = np.where(below_lower, amount * (1 + self.raise_amount), amount)
101
+
102
+ is_ceiling_hit = below_lower # Hit ceiling = spending was raised
103
+ is_floor_breach = above_upper # Hit floor = spending was cut
104
+ else:
105
+ is_floor_breach = False
106
+ is_ceiling_hit = False
107
+
108
+ if current_rate > self.upper_guardrail:
109
+ amount = amount * (1 - self.cut_amount)
110
+ is_floor_breach = True
111
+ elif current_rate < self.lower_guardrail:
112
+ can_raise = True
113
+ if self.no_raise_in_down_year and context.market_return_ytd is not None:
114
+ can_raise = context.market_return_ytd >= 0
115
+ if can_raise:
116
+ amount = amount * (1 + self.raise_amount)
117
+ is_ceiling_hit = True
118
+
119
+ # Apply absolute floor/ceiling
120
+ amount, floor_breach, ceiling_hit = self.apply_guardrails(
121
+ amount, context.current_wealth
122
+ )
123
+
124
+ if isinstance(is_floor_breach, np.ndarray):
125
+ is_floor_breach = is_floor_breach | floor_breach
126
+ is_ceiling_hit = is_ceiling_hit | ceiling_hit
127
+ else:
128
+ is_floor_breach = is_floor_breach or floor_breach
129
+ is_ceiling_hit = is_ceiling_hit or ceiling_hit
130
+
131
+ return WithdrawalDecision(
132
+ amount=amount,
133
+ is_floor_breach=is_floor_breach,
134
+ is_ceiling_hit=is_ceiling_hit,
135
+ notes=f"Current rate: {np.mean(current_rate) if isinstance(current_rate, np.ndarray) else current_rate:.2%}",
136
+ )