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.
- 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 +188 -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 +559 -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.2.dist-info/METADATA +299 -0
- fundedness-0.2.2.dist-info/RECORD +43 -0
- fundedness-0.2.2.dist-info/WHEEL +4 -0
- 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
|
+
)
|