"""Custom user-defined spending plan. A flexible strategy where the user chooses every knob: - `initial_spend` — year 0 withdrawal in real GBP (taken from `state.initial_withdrawal` if not overridden). - `annual_real_adjust_pct` — fraction by which last year's withdrawal scales each subsequent year, on top of inflation. 0.0 = constant real GBP (Trinity-shape). +0.02 = 2%/yr above-inflation creep (e.g. healthcare). -0.005 = -0.5%/yr decreasing spend (slowing down with age). - `guardrail_threshold_pct` — if portfolio drops below this fraction of the starting NW, apply a cut. None = no guardrail. - `guardrail_cut_pct` — fraction by which to cut last year's withdrawal when triggered. Applied multiplicatively each triggered year — not "snap to threshold-implied rate", just a soft cut. The cut is checked AFTER the annual adjustment, so a cut + an increase don't double-apply: cut wins. Compared to Guyton-Klinger this is simpler — one threshold, one cut size, no prosperity rule. If the user wants the prosperity rule behaviour they can pick the GK preset. """ from __future__ import annotations from fire_planner.strategies.base import StrategyState, WithdrawalStrategy class SpendingPlanStrategy(WithdrawalStrategy): name = "custom" def __init__( self, initial_spend: float | None = None, annual_real_adjust_pct: float = 0.0, guardrail_threshold_pct: float | None = None, guardrail_cut_pct: float = 0.10, ) -> None: self.initial_spend = initial_spend self.annual_real_adjust_pct = annual_real_adjust_pct self.guardrail_threshold_pct = guardrail_threshold_pct self.guardrail_cut_pct = guardrail_cut_pct def propose_withdrawal(self, state: StrategyState) -> float: if state.year_idx == 0: # Explicit override wins; otherwise take the user's target. return (self.initial_spend if self.initial_spend is not None and self.initial_spend > 0 else state.initial_withdrawal) if state.portfolio <= 0: return 0.0 proposed = state.last_withdrawal * (1.0 + self.annual_real_adjust_pct) if (self.guardrail_threshold_pct is not None and state.initial_portfolio > 0): trigger_at = state.initial_portfolio * self.guardrail_threshold_pct if state.portfolio < trigger_at: proposed = proposed * (1.0 - self.guardrail_cut_pct) return max(0.0, proposed)