fire-planner/fire_planner/strategies/trinity.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

30 lines
1.2 KiB
Python
Raw Permalink 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.

"""Constant-real-£ withdrawal (the classic 4% rule shape).
Withdraw `state.initial_withdrawal` in year 0, then keep that real-£
amount fixed for the rest of retirement. In a 4% / £1M setup the year-0
draw is £40k, then £40k real every year after. The strategy's
`initial_rate` is kept only as a fallback for callers that don't feed
`state.initial_withdrawal`.
"""
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:
# Year 0 = the user's target spending. Falls back to
# initial_rate × initial_portfolio if no target was provided
# (zero or missing) for backwards compatibility.
if state.initial_withdrawal > 0:
return state.initial_withdrawal
return state.initial_portfolio * self.initial_rate
return state.last_withdrawal