fire-planner/fire_planner/strategies/vpw.py
2026-05-07 17:06:19 +00:00

83 lines
3.1 KiB
Python

"""VPW — Variable Percentage Withdrawal (Bogleheads).
Withdrawal rate is the standard PMT (annuity-payment) formula given a
target real return and the years remaining:
rate(n, r) = r / (1 - (1 + r)^-n)
At year `y` of an `H`-year horizon, withdraw
`portfolio * rate(H - y, expected_real_return)`. The withdrawal scales
with the portfolio — bear markets cut spending immediately, bull
markets allow more — eliminating ruin risk at the cost of variable
income.
Bogleheads VPW table values (60% stocks, 40% bonds, 5% real expected):
- Age 35, 60y horizon: 5.30%
- Age 50, 45y horizon: 5.86%
- Age 65, 30y horizon: 7.09%
- Age 80, 15y horizon: 11.42%
Default `expected_real_return=0.05` matches Bogleheads' 60/40 assumption.
"""
from __future__ import annotations
from fire_planner.strategies.base import StrategyState, WithdrawalStrategy
DEFAULT_EXPECTED_REAL_RETURN = 0.05
def pmt_rate(years_remaining: int, real_rate: float) -> float:
"""PMT formula, capped at 1.0.
Ordinary-annuity convention matches Bogleheads' VPW table for
horizons > 1y. At 1y remaining the textbook formula returns
`(1+r)` because of end-of-period interest accrual, which would
propose a withdrawal larger than the portfolio — Bogleheads caps
at 100% in that case, and so do we.
"""
if years_remaining <= 0:
return 1.0
if abs(real_rate) < 1e-9:
return 1.0 / years_remaining
return min(1.0, real_rate / (1.0 - (1.0 + real_rate)**-years_remaining))
class VpwStrategy(WithdrawalStrategy):
name = "vpw"
def __init__(self, expected_real_return: float = DEFAULT_EXPECTED_REAL_RETURN) -> None:
self.expected_real_return = expected_real_return
def propose_withdrawal(self, state: StrategyState) -> float:
if state.portfolio <= 0:
return 0.0
years_left = state.horizon_years - state.year_idx
rate = pmt_rate(years_left, self.expected_real_return)
return state.portfolio * rate
class VpwWithFloorStrategy(WithdrawalStrategy):
"""VPW with a real-GBP floor — the never-drop-below safety net.
Each year propose `max(floor, vpw_proposed)`, then clip to portfolio
so we cannot withdraw more than exists. The floor is the binding
constraint in bad sequences; in good sequences VPW dominates and
spending scales up. The simulator's success_mask uses the
portfolio-positive-through-interim-years check, so a floor that
drains the portfolio early is still penalised the right way.
"""
name = "vpw_floor"
def __init__(self,
floor: float,
expected_real_return: float = DEFAULT_EXPECTED_REAL_RETURN) -> None:
self.floor = floor
self.expected_real_return = expected_real_return
def propose_withdrawal(self, state: StrategyState) -> float:
if state.portfolio <= 0:
return 0.0
years_left = state.horizon_years - state.year_idx
rate = pmt_rate(years_left, self.expected_real_return)
vpw_proposed = state.portfolio * rate
return min(state.portfolio, max(self.floor, vpw_proposed))