Initial extraction from monorepo

This commit is contained in:
Viktor Barzin 2026-05-07 17:06:19 +00:00
commit f7ef7ca4ab
56 changed files with 6163 additions and 0 deletions

View file

@ -0,0 +1 @@
"""Withdrawal strategies."""

View 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

View 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

View 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

View 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))