Skip to content

Withdrawal Policies

Classes implementing different withdrawal strategies.

Base Policy

fundedness.withdrawals.base.WithdrawalPolicy

Bases: Protocol

Protocol defining the interface for withdrawal strategies.

Source code in fundedness/withdrawals/base.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
class WithdrawalPolicy(Protocol):
    """Protocol defining the interface for withdrawal strategies."""

    @property
    def name(self) -> str:
        """Human-readable name for the strategy."""
        ...

    @property
    def description(self) -> str:
        """Brief description of how the strategy works."""
        ...

    def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
        """Calculate the withdrawal amount for the given context.

        Args:
            context: Current state information

        Returns:
            WithdrawalDecision with amount and metadata
        """
        ...

    def get_initial_withdrawal(self, initial_wealth: float) -> float:
        """Calculate the first year's withdrawal.

        Args:
            initial_wealth: Starting portfolio value

        Returns:
            First year withdrawal amount
        """
        ...

description property

Brief description of how the strategy works.

name property

Human-readable name for the strategy.

calculate_withdrawal(context)

Calculate the withdrawal amount for the given context.

Parameters:

Name Type Description Default
context WithdrawalContext

Current state information

required

Returns:

Type Description
WithdrawalDecision

WithdrawalDecision with amount and metadata

Source code in fundedness/withdrawals/base.py
45
46
47
48
49
50
51
52
53
54
def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
    """Calculate the withdrawal amount for the given context.

    Args:
        context: Current state information

    Returns:
        WithdrawalDecision with amount and metadata
    """
    ...

get_initial_withdrawal(initial_wealth)

Calculate the first year's withdrawal.

Parameters:

Name Type Description Default
initial_wealth float

Starting portfolio value

required

Returns:

Type Description
float

First year withdrawal amount

Source code in fundedness/withdrawals/base.py
56
57
58
59
60
61
62
63
64
65
def get_initial_withdrawal(self, initial_wealth: float) -> float:
    """Calculate the first year's withdrawal.

    Args:
        initial_wealth: Starting portfolio value

    Returns:
        First year withdrawal amount
    """
    ...

fundedness.withdrawals.base.BaseWithdrawalPolicy dataclass

Base class with common functionality for withdrawal policies.

Source code in fundedness/withdrawals/base.py
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
@dataclass
class BaseWithdrawalPolicy:
    """Base class with common functionality for withdrawal policies."""

    floor_spending: float | None = None
    ceiling_spending: float | None = None

    def apply_guardrails(
        self,
        amount: float | np.ndarray,
        wealth: float | np.ndarray,
    ) -> tuple[float | np.ndarray, bool | np.ndarray, bool | np.ndarray]:
        """Apply floor and ceiling guardrails to withdrawal amount.

        Args:
            amount: Proposed withdrawal amount
            wealth: Current wealth

        Returns:
            Tuple of (adjusted_amount, is_floor_breach, is_ceiling_hit)
        """
        is_floor_breach = False
        is_ceiling_hit = False

        # Apply floor
        if self.floor_spending is not None:
            if isinstance(amount, np.ndarray):
                is_floor_breach = amount < self.floor_spending
                amount = np.maximum(amount, self.floor_spending)
            else:
                is_floor_breach = amount < self.floor_spending
                amount = max(amount, self.floor_spending)

        # Apply ceiling
        if self.ceiling_spending is not None:
            if isinstance(amount, np.ndarray):
                is_ceiling_hit = amount > self.ceiling_spending
                amount = np.minimum(amount, self.ceiling_spending)
            else:
                is_ceiling_hit = amount > self.ceiling_spending
                amount = min(amount, self.ceiling_spending)

        # Can't withdraw more than we have
        if isinstance(amount, np.ndarray) or isinstance(wealth, np.ndarray):
            amount = np.minimum(amount, np.maximum(wealth, 0))
        else:
            amount = min(amount, max(wealth, 0))

        return amount, is_floor_breach, is_ceiling_hit

apply_guardrails(amount, wealth)

Apply floor and ceiling guardrails to withdrawal amount.

Parameters:

Name Type Description Default
amount float | ndarray

Proposed withdrawal amount

required
wealth float | ndarray

Current wealth

required

Returns:

Type Description
tuple[float | ndarray, bool | ndarray, bool | ndarray]

Tuple of (adjusted_amount, is_floor_breach, is_ceiling_hit)

Source code in fundedness/withdrawals/base.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
def apply_guardrails(
    self,
    amount: float | np.ndarray,
    wealth: float | np.ndarray,
) -> tuple[float | np.ndarray, bool | np.ndarray, bool | np.ndarray]:
    """Apply floor and ceiling guardrails to withdrawal amount.

    Args:
        amount: Proposed withdrawal amount
        wealth: Current wealth

    Returns:
        Tuple of (adjusted_amount, is_floor_breach, is_ceiling_hit)
    """
    is_floor_breach = False
    is_ceiling_hit = False

    # Apply floor
    if self.floor_spending is not None:
        if isinstance(amount, np.ndarray):
            is_floor_breach = amount < self.floor_spending
            amount = np.maximum(amount, self.floor_spending)
        else:
            is_floor_breach = amount < self.floor_spending
            amount = max(amount, self.floor_spending)

    # Apply ceiling
    if self.ceiling_spending is not None:
        if isinstance(amount, np.ndarray):
            is_ceiling_hit = amount > self.ceiling_spending
            amount = np.minimum(amount, self.ceiling_spending)
        else:
            is_ceiling_hit = amount > self.ceiling_spending
            amount = min(amount, self.ceiling_spending)

    # Can't withdraw more than we have
    if isinstance(amount, np.ndarray) or isinstance(wealth, np.ndarray):
        amount = np.minimum(amount, np.maximum(wealth, 0))
    else:
        amount = min(amount, max(wealth, 0))

    return amount, is_floor_breach, is_ceiling_hit

Fixed SWR

fundedness.withdrawals.fixed_swr.FixedRealSWRPolicy dataclass

Bases: BaseWithdrawalPolicy

Classic fixed real (inflation-adjusted) withdrawal strategy.

The "4% rule" approach: withdraw a fixed percentage of initial portfolio, then adjust for inflation each year.

Source code in fundedness/withdrawals/fixed_swr.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
@dataclass
class FixedRealSWRPolicy(BaseWithdrawalPolicy):
    """Classic fixed real (inflation-adjusted) withdrawal strategy.

    The "4% rule" approach: withdraw a fixed percentage of initial portfolio,
    then adjust for inflation each year.
    """

    withdrawal_rate: float = 0.04  # 4% default
    inflation_rate: float = 0.025  # 2.5% expected inflation

    @property
    def name(self) -> str:
        return f"Fixed {self.withdrawal_rate:.1%} SWR"

    @property
    def description(self) -> str:
        return (
            f"Withdraw {self.withdrawal_rate:.1%} of initial portfolio in year 1, "
            f"then adjust for {self.inflation_rate:.1%} inflation annually."
        )

    def get_initial_withdrawal(self, initial_wealth: float) -> float:
        """Calculate first year withdrawal."""
        return initial_wealth * self.withdrawal_rate

    def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
        """Calculate inflation-adjusted withdrawal.

        Args:
            context: Current state including initial wealth and year

        Returns:
            WithdrawalDecision with fixed real amount
        """
        # Base withdrawal from initial wealth
        base_amount = context.initial_wealth * self.withdrawal_rate

        # Adjust for cumulative inflation
        inflation_factor = (1 + self.inflation_rate) ** context.year
        nominal_amount = base_amount * inflation_factor

        # Apply guardrails and wealth cap
        amount, is_floor_breach, is_ceiling_hit = self.apply_guardrails(
            nominal_amount, context.current_wealth
        )

        return WithdrawalDecision(
            amount=amount,
            is_floor_breach=is_floor_breach,
            is_ceiling_hit=is_ceiling_hit,
            notes=f"Year {context.year}: base ${base_amount:,.0f} × {inflation_factor:.3f} inflation",
        )

    def get_spending(
        self,
        wealth: np.ndarray,
        year: int,
        initial_wealth: float,
    ) -> np.ndarray:
        """Get spending for simulation (vectorized interface).

        Args:
            wealth: Current portfolio values (n_simulations,)
            year: Current simulation year
            initial_wealth: Starting portfolio value

        Returns:
            Spending amounts for each simulation path
        """
        # Base withdrawal from initial wealth
        base_amount = initial_wealth * self.withdrawal_rate

        # Adjust for cumulative inflation
        inflation_factor = (1 + self.inflation_rate) ** year
        spending = np.full_like(wealth, base_amount * inflation_factor)

        # Apply floor if set
        if self.floor_spending is not None:
            spending = np.maximum(spending, self.floor_spending)

        # Can't spend more than we have
        spending = np.minimum(spending, np.maximum(wealth, 0))

        return spending

calculate_withdrawal(context)

Calculate inflation-adjusted withdrawal.

Parameters:

Name Type Description Default
context WithdrawalContext

Current state including initial wealth and year

required

Returns:

Type Description
WithdrawalDecision

WithdrawalDecision with fixed real amount

Source code in fundedness/withdrawals/fixed_swr.py
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
    """Calculate inflation-adjusted withdrawal.

    Args:
        context: Current state including initial wealth and year

    Returns:
        WithdrawalDecision with fixed real amount
    """
    # Base withdrawal from initial wealth
    base_amount = context.initial_wealth * self.withdrawal_rate

    # Adjust for cumulative inflation
    inflation_factor = (1 + self.inflation_rate) ** context.year
    nominal_amount = base_amount * inflation_factor

    # Apply guardrails and wealth cap
    amount, is_floor_breach, is_ceiling_hit = self.apply_guardrails(
        nominal_amount, context.current_wealth
    )

    return WithdrawalDecision(
        amount=amount,
        is_floor_breach=is_floor_breach,
        is_ceiling_hit=is_ceiling_hit,
        notes=f"Year {context.year}: base ${base_amount:,.0f} × {inflation_factor:.3f} inflation",
    )

get_initial_withdrawal(initial_wealth)

Calculate first year withdrawal.

Source code in fundedness/withdrawals/fixed_swr.py
36
37
38
def get_initial_withdrawal(self, initial_wealth: float) -> float:
    """Calculate first year withdrawal."""
    return initial_wealth * self.withdrawal_rate

get_spending(wealth, year, initial_wealth)

Get spending for simulation (vectorized interface).

Parameters:

Name Type Description Default
wealth ndarray

Current portfolio values (n_simulations,)

required
year int

Current simulation year

required
initial_wealth float

Starting portfolio value

required

Returns:

Type Description
ndarray

Spending amounts for each simulation path

Source code in fundedness/withdrawals/fixed_swr.py
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
def get_spending(
    self,
    wealth: np.ndarray,
    year: int,
    initial_wealth: float,
) -> np.ndarray:
    """Get spending for simulation (vectorized interface).

    Args:
        wealth: Current portfolio values (n_simulations,)
        year: Current simulation year
        initial_wealth: Starting portfolio value

    Returns:
        Spending amounts for each simulation path
    """
    # Base withdrawal from initial wealth
    base_amount = initial_wealth * self.withdrawal_rate

    # Adjust for cumulative inflation
    inflation_factor = (1 + self.inflation_rate) ** year
    spending = np.full_like(wealth, base_amount * inflation_factor)

    # Apply floor if set
    if self.floor_spending is not None:
        spending = np.maximum(spending, self.floor_spending)

    # Can't spend more than we have
    spending = np.minimum(spending, np.maximum(wealth, 0))

    return spending

Percentage of Portfolio

fundedness.withdrawals.fixed_swr.PercentOfPortfolioPolicy dataclass

Bases: BaseWithdrawalPolicy

Withdraw a fixed percentage of current portfolio value each year.

More volatile than fixed SWR but automatically adjusts to portfolio performance.

Source code in fundedness/withdrawals/fixed_swr.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
@dataclass
class PercentOfPortfolioPolicy(BaseWithdrawalPolicy):
    """Withdraw a fixed percentage of current portfolio value each year.

    More volatile than fixed SWR but automatically adjusts to portfolio performance.
    """

    withdrawal_rate: float = 0.04
    floor: float | None = None

    @property
    def name(self) -> str:
        return f"{self.withdrawal_rate:.1%} of Portfolio"

    @property
    def description(self) -> str:
        return f"Withdraw {self.withdrawal_rate:.1%} of current portfolio value each year."

    def get_initial_withdrawal(self, initial_wealth: float) -> float:
        """Calculate first year withdrawal."""
        return initial_wealth * self.withdrawal_rate

    def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
        """Calculate percentage-of-portfolio withdrawal.

        Args:
            context: Current state including current wealth

        Returns:
            WithdrawalDecision based on current portfolio value
        """
        if isinstance(context.current_wealth, np.ndarray):
            amount = context.current_wealth * self.withdrawal_rate
        else:
            amount = context.current_wealth * self.withdrawal_rate

        # Apply guardrails
        amount, is_floor_breach, is_ceiling_hit = self.apply_guardrails(
            amount, context.current_wealth
        )

        return WithdrawalDecision(
            amount=amount,
            is_floor_breach=is_floor_breach,
            is_ceiling_hit=is_ceiling_hit,
        )

    def get_spending(
        self,
        wealth: np.ndarray,
        year: int,
        initial_wealth: float,
    ) -> np.ndarray:
        """Get spending for simulation (vectorized interface).

        Args:
            wealth: Current portfolio values (n_simulations,)
            year: Current simulation year
            initial_wealth: Starting portfolio value

        Returns:
            Spending amounts for each simulation path
        """
        spending = wealth * self.withdrawal_rate

        # Apply floor if set
        floor = self.floor or self.floor_spending
        if floor is not None:
            spending = np.maximum(spending, floor)

        # Can't spend more than we have
        spending = np.minimum(spending, np.maximum(wealth, 0))

        return spending

calculate_withdrawal(context)

Calculate percentage-of-portfolio withdrawal.

Parameters:

Name Type Description Default
context WithdrawalContext

Current state including current wealth

required

Returns:

Type Description
WithdrawalDecision

WithdrawalDecision based on current portfolio value

Source code in fundedness/withdrawals/fixed_swr.py
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
    """Calculate percentage-of-portfolio withdrawal.

    Args:
        context: Current state including current wealth

    Returns:
        WithdrawalDecision based on current portfolio value
    """
    if isinstance(context.current_wealth, np.ndarray):
        amount = context.current_wealth * self.withdrawal_rate
    else:
        amount = context.current_wealth * self.withdrawal_rate

    # Apply guardrails
    amount, is_floor_breach, is_ceiling_hit = self.apply_guardrails(
        amount, context.current_wealth
    )

    return WithdrawalDecision(
        amount=amount,
        is_floor_breach=is_floor_breach,
        is_ceiling_hit=is_ceiling_hit,
    )

get_initial_withdrawal(initial_wealth)

Calculate first year withdrawal.

Source code in fundedness/withdrawals/fixed_swr.py
119
120
121
def get_initial_withdrawal(self, initial_wealth: float) -> float:
    """Calculate first year withdrawal."""
    return initial_wealth * self.withdrawal_rate

get_spending(wealth, year, initial_wealth)

Get spending for simulation (vectorized interface).

Parameters:

Name Type Description Default
wealth ndarray

Current portfolio values (n_simulations,)

required
year int

Current simulation year

required
initial_wealth float

Starting portfolio value

required

Returns:

Type Description
ndarray

Spending amounts for each simulation path

Source code in fundedness/withdrawals/fixed_swr.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
def get_spending(
    self,
    wealth: np.ndarray,
    year: int,
    initial_wealth: float,
) -> np.ndarray:
    """Get spending for simulation (vectorized interface).

    Args:
        wealth: Current portfolio values (n_simulations,)
        year: Current simulation year
        initial_wealth: Starting portfolio value

    Returns:
        Spending amounts for each simulation path
    """
    spending = wealth * self.withdrawal_rate

    # Apply floor if set
    floor = self.floor or self.floor_spending
    if floor is not None:
        spending = np.maximum(spending, floor)

    # Can't spend more than we have
    spending = np.minimum(spending, np.maximum(wealth, 0))

    return spending

Guardrails

fundedness.withdrawals.guardrails.GuardrailsPolicy dataclass

Bases: BaseWithdrawalPolicy

Guyton-Klinger style guardrails withdrawal strategy.

Start with initial withdrawal rate, then adjust based on portfolio performance: - If withdrawal rate rises above upper guardrail, cut spending - If withdrawal rate falls below lower guardrail, increase spending - Otherwise, adjust previous spending for inflation

Source code in fundedness/withdrawals/guardrails.py
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
@dataclass
class GuardrailsPolicy(BaseWithdrawalPolicy):
    """Guyton-Klinger style guardrails withdrawal strategy.

    Start with initial withdrawal rate, then adjust based on portfolio performance:
    - If withdrawal rate rises above upper guardrail, cut spending
    - If withdrawal rate falls below lower guardrail, increase spending
    - Otherwise, adjust previous spending for inflation
    """

    initial_rate: float = 0.05  # Starting withdrawal rate (5%)
    upper_guardrail: float = 0.06  # Cut spending if rate exceeds this
    lower_guardrail: float = 0.04  # Raise spending if rate falls below this
    cut_amount: float = 0.10  # Cut spending by 10% when hitting upper rail
    raise_amount: float = 0.10  # Raise spending by 10% when hitting lower rail
    inflation_rate: float = 0.025
    no_raise_in_down_year: bool = True  # Don't raise spending after negative returns

    _initial_spending: float = field(default=0.0, init=False, repr=False)

    @property
    def name(self) -> str:
        return "Guardrails"

    @property
    def description(self) -> str:
        return (
            f"Start at {self.initial_rate:.1%}, adjust for inflation, but cut by "
            f"{self.cut_amount:.0%} if rate > {self.upper_guardrail:.1%} or raise by "
            f"{self.raise_amount:.0%} if rate < {self.lower_guardrail:.1%}."
        )

    def get_initial_withdrawal(self, initial_wealth: float) -> float:
        """Calculate first year withdrawal."""
        self._initial_spending = initial_wealth * self.initial_rate
        return self._initial_spending

    def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
        """Calculate guardrails-adjusted withdrawal.

        Args:
            context: Current state including previous spending and market returns

        Returns:
            WithdrawalDecision with guardrail-adjusted amount
        """
        # Get previous spending (or calculate initial)
        if context.previous_spending is None or context.year == 0:
            if isinstance(context.current_wealth, np.ndarray):
                base_spending = np.full_like(
                    context.current_wealth,
                    context.initial_wealth * self.initial_rate,
                )
            else:
                base_spending = context.initial_wealth * self.initial_rate
        else:
            # Inflation-adjust previous spending
            base_spending = context.previous_spending * (1 + self.inflation_rate)

        # Calculate current withdrawal rate
        if isinstance(context.current_wealth, np.ndarray):
            current_rate = np.where(
                context.current_wealth > 0,
                base_spending / context.current_wealth,
                np.inf,
            )
        else:
            current_rate = (
                base_spending / context.current_wealth
                if context.current_wealth > 0
                else float("inf")
            )

        # Apply guardrails
        amount = base_spending

        if isinstance(current_rate, np.ndarray):
            # Vectorized guardrail logic
            # Cut spending if above upper guardrail
            above_upper = current_rate > self.upper_guardrail
            amount = np.where(above_upper, amount * (1 - self.cut_amount), amount)

            # Raise spending if below lower guardrail (unless down year rule)
            below_lower = current_rate < self.lower_guardrail
            if self.no_raise_in_down_year and context.market_return_ytd is not None:
                below_lower = below_lower & (context.market_return_ytd >= 0)
            amount = np.where(below_lower, amount * (1 + self.raise_amount), amount)

            is_ceiling_hit = below_lower  # Hit ceiling = spending was raised
            is_floor_breach = above_upper  # Hit floor = spending was cut
        else:
            is_floor_breach = False
            is_ceiling_hit = False

            if current_rate > self.upper_guardrail:
                amount = amount * (1 - self.cut_amount)
                is_floor_breach = True
            elif current_rate < self.lower_guardrail:
                can_raise = True
                if self.no_raise_in_down_year and context.market_return_ytd is not None:
                    can_raise = context.market_return_ytd >= 0
                if can_raise:
                    amount = amount * (1 + self.raise_amount)
                    is_ceiling_hit = True

        # Apply absolute floor/ceiling
        amount, floor_breach, ceiling_hit = self.apply_guardrails(
            amount, context.current_wealth
        )

        if isinstance(is_floor_breach, np.ndarray):
            is_floor_breach = is_floor_breach | floor_breach
            is_ceiling_hit = is_ceiling_hit | ceiling_hit
        else:
            is_floor_breach = is_floor_breach or floor_breach
            is_ceiling_hit = is_ceiling_hit or ceiling_hit

        return WithdrawalDecision(
            amount=amount,
            is_floor_breach=is_floor_breach,
            is_ceiling_hit=is_ceiling_hit,
            notes=f"Current rate: {np.mean(current_rate) if isinstance(current_rate, np.ndarray) else current_rate:.2%}",
        )

calculate_withdrawal(context)

Calculate guardrails-adjusted withdrawal.

Parameters:

Name Type Description Default
context WithdrawalContext

Current state including previous spending and market returns

required

Returns:

Type Description
WithdrawalDecision

WithdrawalDecision with guardrail-adjusted amount

Source code in fundedness/withdrawals/guardrails.py
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
    """Calculate guardrails-adjusted withdrawal.

    Args:
        context: Current state including previous spending and market returns

    Returns:
        WithdrawalDecision with guardrail-adjusted amount
    """
    # Get previous spending (or calculate initial)
    if context.previous_spending is None or context.year == 0:
        if isinstance(context.current_wealth, np.ndarray):
            base_spending = np.full_like(
                context.current_wealth,
                context.initial_wealth * self.initial_rate,
            )
        else:
            base_spending = context.initial_wealth * self.initial_rate
    else:
        # Inflation-adjust previous spending
        base_spending = context.previous_spending * (1 + self.inflation_rate)

    # Calculate current withdrawal rate
    if isinstance(context.current_wealth, np.ndarray):
        current_rate = np.where(
            context.current_wealth > 0,
            base_spending / context.current_wealth,
            np.inf,
        )
    else:
        current_rate = (
            base_spending / context.current_wealth
            if context.current_wealth > 0
            else float("inf")
        )

    # Apply guardrails
    amount = base_spending

    if isinstance(current_rate, np.ndarray):
        # Vectorized guardrail logic
        # Cut spending if above upper guardrail
        above_upper = current_rate > self.upper_guardrail
        amount = np.where(above_upper, amount * (1 - self.cut_amount), amount)

        # Raise spending if below lower guardrail (unless down year rule)
        below_lower = current_rate < self.lower_guardrail
        if self.no_raise_in_down_year and context.market_return_ytd is not None:
            below_lower = below_lower & (context.market_return_ytd >= 0)
        amount = np.where(below_lower, amount * (1 + self.raise_amount), amount)

        is_ceiling_hit = below_lower  # Hit ceiling = spending was raised
        is_floor_breach = above_upper  # Hit floor = spending was cut
    else:
        is_floor_breach = False
        is_ceiling_hit = False

        if current_rate > self.upper_guardrail:
            amount = amount * (1 - self.cut_amount)
            is_floor_breach = True
        elif current_rate < self.lower_guardrail:
            can_raise = True
            if self.no_raise_in_down_year and context.market_return_ytd is not None:
                can_raise = context.market_return_ytd >= 0
            if can_raise:
                amount = amount * (1 + self.raise_amount)
                is_ceiling_hit = True

    # Apply absolute floor/ceiling
    amount, floor_breach, ceiling_hit = self.apply_guardrails(
        amount, context.current_wealth
    )

    if isinstance(is_floor_breach, np.ndarray):
        is_floor_breach = is_floor_breach | floor_breach
        is_ceiling_hit = is_ceiling_hit | ceiling_hit
    else:
        is_floor_breach = is_floor_breach or floor_breach
        is_ceiling_hit = is_ceiling_hit or ceiling_hit

    return WithdrawalDecision(
        amount=amount,
        is_floor_breach=is_floor_breach,
        is_ceiling_hit=is_ceiling_hit,
        notes=f"Current rate: {np.mean(current_rate) if isinstance(current_rate, np.ndarray) else current_rate:.2%}",
    )

get_initial_withdrawal(initial_wealth)

Calculate first year withdrawal.

Source code in fundedness/withdrawals/guardrails.py
46
47
48
49
def get_initial_withdrawal(self, initial_wealth: float) -> float:
    """Calculate first year withdrawal."""
    self._initial_spending = initial_wealth * self.initial_rate
    return self._initial_spending

Variable Percentage Withdrawal

fundedness.withdrawals.vpw.VPWPolicy dataclass

Bases: BaseWithdrawalPolicy

Variable Percentage Withdrawal (VPW) strategy.

Withdrawal rate varies based on age and remaining life expectancy. Uses actuarial tables to determine appropriate withdrawal percentage.

Source code in fundedness/withdrawals/vpw.py
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
@dataclass
class VPWPolicy(BaseWithdrawalPolicy):
    """Variable Percentage Withdrawal (VPW) strategy.

    Withdrawal rate varies based on age and remaining life expectancy.
    Uses actuarial tables to determine appropriate withdrawal percentage.
    """

    starting_age: int = 65
    stock_allocation: int = 50  # As integer percentage
    smoothing_factor: float = 0.0  # 0 = pure VPW, 1 = fully smoothed

    @property
    def name(self) -> str:
        return "VPW"

    @property
    def description(self) -> str:
        return (
            "Variable Percentage Withdrawal based on age and life expectancy. "
            "Withdrawal rate increases as you age."
        )

    def get_initial_withdrawal(self, initial_wealth: float) -> float:
        """Calculate first year withdrawal."""
        rate = get_vpw_rate(self.starting_age, self.stock_allocation)
        return initial_wealth * rate

    def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
        """Calculate VPW withdrawal.

        Args:
            context: Current state including age or year

        Returns:
            WithdrawalDecision based on VPW table
        """
        # Determine current age
        if context.age is not None:
            current_age = context.age
        else:
            current_age = self.starting_age + context.year

        # Get VPW rate for current age
        vpw_rate = get_vpw_rate(current_age, self.stock_allocation)

        # Calculate base withdrawal
        if isinstance(context.current_wealth, np.ndarray):
            base_amount = context.current_wealth * vpw_rate
        else:
            base_amount = context.current_wealth * vpw_rate

        # Apply smoothing if requested
        if self.smoothing_factor > 0 and context.previous_spending is not None:
            smoothed = (
                self.smoothing_factor * context.previous_spending
                + (1 - self.smoothing_factor) * base_amount
            )
            amount = smoothed
        else:
            amount = base_amount

        # Apply guardrails
        amount, is_floor_breach, is_ceiling_hit = self.apply_guardrails(
            amount, context.current_wealth
        )

        return WithdrawalDecision(
            amount=amount,
            is_floor_breach=is_floor_breach,
            is_ceiling_hit=is_ceiling_hit,
            notes=f"Age {current_age}, VPW rate: {vpw_rate:.2%}",
        )

calculate_withdrawal(context)

Calculate VPW withdrawal.

Parameters:

Name Type Description Default
context WithdrawalContext

Current state including age or year

required

Returns:

Type Description
WithdrawalDecision

WithdrawalDecision based on VPW table

Source code in fundedness/withdrawals/vpw.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
    """Calculate VPW withdrawal.

    Args:
        context: Current state including age or year

    Returns:
        WithdrawalDecision based on VPW table
    """
    # Determine current age
    if context.age is not None:
        current_age = context.age
    else:
        current_age = self.starting_age + context.year

    # Get VPW rate for current age
    vpw_rate = get_vpw_rate(current_age, self.stock_allocation)

    # Calculate base withdrawal
    if isinstance(context.current_wealth, np.ndarray):
        base_amount = context.current_wealth * vpw_rate
    else:
        base_amount = context.current_wealth * vpw_rate

    # Apply smoothing if requested
    if self.smoothing_factor > 0 and context.previous_spending is not None:
        smoothed = (
            self.smoothing_factor * context.previous_spending
            + (1 - self.smoothing_factor) * base_amount
        )
        amount = smoothed
    else:
        amount = base_amount

    # Apply guardrails
    amount, is_floor_breach, is_ceiling_hit = self.apply_guardrails(
        amount, context.current_wealth
    )

    return WithdrawalDecision(
        amount=amount,
        is_floor_breach=is_floor_breach,
        is_ceiling_hit=is_ceiling_hit,
        notes=f"Age {current_age}, VPW rate: {vpw_rate:.2%}",
    )

get_initial_withdrawal(initial_wealth)

Calculate first year withdrawal.

Source code in fundedness/withdrawals/vpw.py
87
88
89
90
def get_initial_withdrawal(self, initial_wealth: float) -> float:
    """Calculate first year withdrawal."""
    rate = get_vpw_rate(self.starting_age, self.stock_allocation)
    return initial_wealth * rate

RMD-Style

fundedness.withdrawals.rmd_style.RMDStylePolicy dataclass

Bases: BaseWithdrawalPolicy

RMD-style withdrawal strategy.

Uses IRS Required Minimum Distribution table to determine withdrawals. Withdrawal = Portfolio Value / Distribution Period

This approach automatically increases withdrawal rate as you age, similar to how RMDs work for tax-deferred accounts.

Source code in fundedness/withdrawals/rmd_style.py
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
@dataclass
class RMDStylePolicy(BaseWithdrawalPolicy):
    """RMD-style withdrawal strategy.

    Uses IRS Required Minimum Distribution table to determine withdrawals.
    Withdrawal = Portfolio Value / Distribution Period

    This approach automatically increases withdrawal rate as you age,
    similar to how RMDs work for tax-deferred accounts.
    """

    starting_age: int = 65
    multiplier: float = 1.0  # Scale factor (1.0 = exact RMD, 1.5 = 150% of RMD)
    start_before_72: bool = True  # Apply RMD-style before actual RMD age

    @property
    def name(self) -> str:
        mult_str = f" × {self.multiplier}" if self.multiplier != 1.0 else ""
        return f"RMD-Style{mult_str}"

    @property
    def description(self) -> str:
        return (
            "Withdraw based on IRS RMD table divisors. "
            "Withdrawal rate automatically increases with age."
        )

    def get_initial_withdrawal(self, initial_wealth: float) -> float:
        """Calculate first year withdrawal."""
        divisor = get_rmd_divisor(self.starting_age)
        return (initial_wealth / divisor) * self.multiplier

    def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
        """Calculate RMD-style withdrawal.

        Args:
            context: Current state including age or year

        Returns:
            WithdrawalDecision based on RMD table
        """
        # Determine current age
        if context.age is not None:
            current_age = context.age
        else:
            current_age = self.starting_age + context.year

        # Get divisor
        divisor = get_rmd_divisor(current_age)

        # Calculate withdrawal
        if isinstance(context.current_wealth, np.ndarray):
            amount = (context.current_wealth / divisor) * self.multiplier
        else:
            amount = (context.current_wealth / divisor) * self.multiplier

        # Apply guardrails
        amount, is_floor_breach, is_ceiling_hit = self.apply_guardrails(
            amount, context.current_wealth
        )

        withdrawal_rate = 1 / divisor * self.multiplier

        return WithdrawalDecision(
            amount=amount,
            is_floor_breach=is_floor_breach,
            is_ceiling_hit=is_ceiling_hit,
            notes=f"Age {current_age}, divisor: {divisor:.1f}, rate: {withdrawal_rate:.2%}",
        )

calculate_withdrawal(context)

Calculate RMD-style withdrawal.

Parameters:

Name Type Description Default
context WithdrawalContext

Current state including age or year

required

Returns:

Type Description
WithdrawalDecision

WithdrawalDecision based on RMD table

Source code in fundedness/withdrawals/rmd_style.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
def calculate_withdrawal(self, context: WithdrawalContext) -> WithdrawalDecision:
    """Calculate RMD-style withdrawal.

    Args:
        context: Current state including age or year

    Returns:
        WithdrawalDecision based on RMD table
    """
    # Determine current age
    if context.age is not None:
        current_age = context.age
    else:
        current_age = self.starting_age + context.year

    # Get divisor
    divisor = get_rmd_divisor(current_age)

    # Calculate withdrawal
    if isinstance(context.current_wealth, np.ndarray):
        amount = (context.current_wealth / divisor) * self.multiplier
    else:
        amount = (context.current_wealth / divisor) * self.multiplier

    # Apply guardrails
    amount, is_floor_breach, is_ceiling_hit = self.apply_guardrails(
        amount, context.current_wealth
    )

    withdrawal_rate = 1 / divisor * self.multiplier

    return WithdrawalDecision(
        amount=amount,
        is_floor_breach=is_floor_breach,
        is_ceiling_hit=is_ceiling_hit,
        notes=f"Age {current_age}, divisor: {divisor:.1f}, rate: {withdrawal_rate:.2%}",
    )

get_initial_withdrawal(initial_wealth)

Calculate first year withdrawal.

Source code in fundedness/withdrawals/rmd_style.py
75
76
77
78
def get_initial_withdrawal(self, initial_wealth: float) -> float:
    """Calculate first year withdrawal."""
    divisor = get_rmd_divisor(self.starting_age)
    return (initial_wealth / divisor) * self.multiplier