Initial extraction from monorepo
This commit is contained in:
commit
f7ef7ca4ab
56 changed files with 6163 additions and 0 deletions
1
fire_planner/strategies/__init__.py
Normal file
1
fire_planner/strategies/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Withdrawal strategies."""
|
||||
37
fire_planner/strategies/base.py
Normal file
37
fire_planner/strategies/base.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""Withdrawal-strategy abstract base.
|
||||
|
||||
All strategies operate in REAL GBP terms — the simulator deflates by
|
||||
the cumulative CPI index before calling. Brackets inside the tax
|
||||
engines are also assumed to inflate with CPI (simplifying assumption
|
||||
that tax thresholds keep pace with inflation — fiscal drag is a
|
||||
documented v2 follow-up).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StrategyState:
|
||||
"""Inputs to a strategy's per-year decision. Real GBP throughout."""
|
||||
portfolio: float
|
||||
initial_portfolio: float
|
||||
initial_withdrawal: float
|
||||
year_idx: int
|
||||
horizon_years: int
|
||||
last_withdrawal: float
|
||||
expected_real_return: float = 0.04
|
||||
|
||||
|
||||
class WithdrawalStrategy(ABC):
|
||||
name: str
|
||||
|
||||
@abstractmethod
|
||||
def propose_withdrawal(self, state: StrategyState) -> float:
|
||||
"""Return the proposed withdrawal in real GBP for this year.
|
||||
|
||||
The simulator may clip downward if the portfolio is exhausted —
|
||||
strategies can request more than the portfolio holds.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
57
fire_planner/strategies/guyton_klinger.py
Normal file
57
fire_planner/strategies/guyton_klinger.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""Guyton-Klinger 4-rule guardrails (FPA Journal, 2006).
|
||||
|
||||
Decision rules applied each year, in order:
|
||||
|
||||
1. **Portfolio-Management Rule** — choose which asset class to draw from
|
||||
(we delegate to the simulator's rebalance logic; ignored here).
|
||||
2. **Inflation Rule** — skip the inflation uplift on the prior year's
|
||||
withdrawal if both:
|
||||
a. the prior year's nominal portfolio return was negative, AND
|
||||
b. the current withdrawal rate would exceed the initial rate.
|
||||
3. **Capital-Preservation Rule** — cut the withdrawal by 10% if the
|
||||
current rate exceeds 120% of the initial rate AND there are more
|
||||
than 15 years left in the horizon.
|
||||
4. **Prosperity Rule** — increase the withdrawal by 10% if the current
|
||||
rate is below 80% of the initial rate.
|
||||
|
||||
This implementation operates in real GBP, so the inflation-skip rule
|
||||
has no effect (real values don't drift with inflation). The other three
|
||||
rules apply normally. Trade-off: simplifies the math at the cost of
|
||||
slightly under-cutting in nominal-stress scenarios.
|
||||
|
||||
Initial rate baseline: 5.5% of starting portfolio (per Guyton-Klinger
|
||||
paper, allows higher sustainable spend than Trinity by tolerating
|
||||
guardrail cuts).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fire_planner.strategies.base import StrategyState, WithdrawalStrategy
|
||||
|
||||
DEFAULT_INITIAL_RATE = 0.055
|
||||
CAPITAL_PRESERVATION_RATIO = 1.20
|
||||
PROSPERITY_RATIO = 0.80
|
||||
ADJUSTMENT = 0.10
|
||||
MIN_HORIZON_FOR_CUT = 15
|
||||
|
||||
|
||||
class GuytonKlingerStrategy(WithdrawalStrategy):
|
||||
name = "guyton_klinger"
|
||||
|
||||
def __init__(self, initial_rate: float = DEFAULT_INITIAL_RATE) -> None:
|
||||
self.initial_rate = initial_rate
|
||||
|
||||
def propose_withdrawal(self, state: StrategyState) -> float:
|
||||
if state.year_idx == 0:
|
||||
return state.initial_portfolio * self.initial_rate
|
||||
if state.portfolio <= 0:
|
||||
return 0.0
|
||||
last_w = state.last_withdrawal
|
||||
current_rate = last_w / state.portfolio
|
||||
years_left = state.horizon_years - state.year_idx
|
||||
# Capital-preservation cut: only if more than 15 years remain.
|
||||
if (current_rate > self.initial_rate * CAPITAL_PRESERVATION_RATIO
|
||||
and years_left > MIN_HORIZON_FOR_CUT):
|
||||
return last_w * (1 - ADJUSTMENT)
|
||||
if current_rate < self.initial_rate * PROSPERITY_RATIO:
|
||||
return last_w * (1 + ADJUSTMENT)
|
||||
return last_w
|
||||
24
fire_planner/strategies/trinity.py
Normal file
24
fire_planner/strategies/trinity.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""Trinity 4% Safe Withdrawal Rate.
|
||||
|
||||
Bengen's seminal 1994 paper + the Trinity Study (Cooley/Hubbard/Walz,
|
||||
1998) — withdraw 4% of the starting balance in year 1, then keep the
|
||||
real withdrawal constant for the rest of retirement. In our real-GBP
|
||||
internal frame this is just "the same number every year".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fire_planner.strategies.base import StrategyState, WithdrawalStrategy
|
||||
|
||||
DEFAULT_INITIAL_RATE = 0.04
|
||||
|
||||
|
||||
class TrinityStrategy(WithdrawalStrategy):
|
||||
name = "trinity"
|
||||
|
||||
def __init__(self, initial_rate: float = DEFAULT_INITIAL_RATE) -> None:
|
||||
self.initial_rate = initial_rate
|
||||
|
||||
def propose_withdrawal(self, state: StrategyState) -> float:
|
||||
if state.year_idx == 0:
|
||||
return state.initial_portfolio * self.initial_rate
|
||||
return state.last_withdrawal
|
||||
83
fire_planner/strategies/vpw.py
Normal file
83
fire_planner/strategies/vpw.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""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))
|
||||
Loading…
Add table
Add a link
Reference in a new issue