fire-planner/fire_planner/strategies/guyton_klinger.py
Viktor Barzin f43322e5ce
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
strategies: spending input is honoured + new "Custom" preset with guardrails
The user noticed the "Annual spending" field was a no-op for Trinity,
GK, VPW, VPW+floor — the strategies internally hardcoded the year-0
withdrawal as `initial_portfolio × initial_rate` (4% / 5.5%) and
ignored what the user typed. Two fixes:

(1) Trinity + GK now use state.initial_withdrawal (= the user's
    spending_target) as the year-0 draw. GK's guardrail anchor
    becomes the implied initial rate (initial_withdrawal /
    initial_portfolio), so the rule shape adapts to the user's
    chosen rate. Both strategies still fall back to their preset
    rate × initial_portfolio when initial_withdrawal isn't set
    (test paths). VPW and VPW+floor stay algorithmic — they're
    "withdraw-what's-sustainable" by design and don't take a
    spending input.

(2) New "custom" preset (SpendingPlanStrategy) exposing all the
    knobs:
    - initial_spend = "Annual spending" input
    - annual_real_adjust_pct = scale last year's withdrawal by N%
      each year (0 = constant real £, +0.02 = 2%/yr healthcare
      creep, -0.005 = -0.5%/yr slow-down with age)
    - guardrail_threshold_pct = if portfolio falls below X% of
      starting NW, trigger a cut (None = disabled)
    - guardrail_cut_pct = cut last year's withdrawal by Y% each
      triggered year

Adjust applies first, then guardrail cut — so a triggered year in
+2% adjust mode goes 40k → 40.8k → 36.7k.

UI: "custom" added to the strategy dropdown; when selected, three
extra fields appear (annual real adjustment %, guardrail trigger
threshold, guardrail cut size) with hints. The existing inputs
(spending, NW seed) drive year 0 across all strategies that use
them. About-the-model panel updated.

10 new tests on SpendingPlanStrategy + adjusted GK tests for the
new spending_target-aware behaviour. 209 backend tests + 7
frontend tests. mypy + ruff + tsc all pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 01:21:55 +00:00

66 lines
2.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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:
# Year 0 = the user's target spending; the implied initial rate
# (initial_withdrawal / initial_portfolio) becomes the anchor
# the guardrails compare against. Falls back to the preset rate
# × initial_portfolio when no target was given.
target_initial = (state.initial_withdrawal
if state.initial_withdrawal > 0 else
state.initial_portfolio * self.initial_rate)
if state.year_idx == 0:
return target_initial
if state.portfolio <= 0:
return 0.0
implied_initial_rate = (target_initial / state.initial_portfolio
if state.initial_portfolio > 0 else self.initial_rate)
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 > implied_initial_rate * CAPITAL_PRESERVATION_RATIO
and years_left > MIN_HORIZON_FOR_CUT):
return last_w * (1 - ADJUSTMENT)
if current_rate < implied_initial_rate * PROSPERITY_RATIO:
return last_w * (1 + ADJUSTMENT)
return last_w