diff --git a/fire_planner/api/schemas.py b/fire_planner/api/schemas.py index 3ac84a5..b64aa5c 100644 --- a/fire_planner/api/schemas.py +++ b/fire_planner/api/schemas.py @@ -238,6 +238,16 @@ class SimulateRequest(BaseModel): # recent regime only (~6 years). Glide path is moot. returns_mode: str = Field(default="shiller", pattern="^(shiller|manual|wealthfolio)$") manual_real_return_pct: Decimal | None = None + # Custom spending-plan parameters — only consulted when strategy="custom". + # All real-£ / real-fraction. annual_real_adjust_pct = 0 means constant + # real spending (Trinity-shape). Non-zero scales last year's withdrawal + # multiplicatively each year (e.g. -0.005 for slow-down with age, + # +0.02 for healthcare creep). Guardrail cuts spending by + # `guardrail_cut_pct` whenever the portfolio falls below + # `guardrail_threshold_pct` of its starting value; null disables. + annual_real_adjust_pct: Decimal = Decimal("0") + guardrail_threshold_pct: Decimal | None = None + guardrail_cut_pct: Decimal = Decimal("0.10") class SimulateResult(BaseModel): diff --git a/fire_planner/api/simulate.py b/fire_planner/api/simulate.py index c329531..5e3e4ff 100644 --- a/fire_planner/api/simulate.py +++ b/fire_planner/api/simulate.py @@ -105,13 +105,22 @@ def _project(req: SimulateRequest, paths: np.ndarray) -> tuple[SimulationResult, ] cashflow_adjustments = events_to_cashflow_array(engine_events, req.horizon_years) + strategy = build_strategy( + req.strategy, + floor=floor, + annual_real_adjust_pct=float(req.annual_real_adjust_pct), + guardrail_threshold_pct=(float(req.guardrail_threshold_pct) + if req.guardrail_threshold_pct is not None else None), + guardrail_cut_pct=float(req.guardrail_cut_pct), + ) + started = time.perf_counter() result = simulate( paths=paths, initial_portfolio=float(req.nw_seed_gbp), spending_target=float(req.spending_gbp), glide=get_glide(req.glide_path), - strategy=build_strategy(req.strategy, floor=floor), + strategy=strategy, regime=build_regime_schedule(req.jurisdiction, req.leave_uk_year), horizon_years=req.horizon_years, annual_savings=annual_savings, diff --git a/fire_planner/scenarios.py b/fire_planner/scenarios.py index 59284d7..7e42aca 100644 --- a/fire_planner/scenarios.py +++ b/fire_planner/scenarios.py @@ -22,6 +22,7 @@ from fire_planner.glide_path import GLIDE_PATHS from fire_planner.simulator import RegimeFn, constant_regime, jurisdiction_schedule from fire_planner.strategies.base import WithdrawalStrategy from fire_planner.strategies.guyton_klinger import GuytonKlingerStrategy +from fire_planner.strategies.spending_plan import SpendingPlanStrategy from fire_planner.strategies.trinity import TrinityStrategy from fire_planner.strategies.vpw import VpwStrategy, VpwWithFloorStrategy from fire_planner.tax.base import TaxRegime @@ -58,7 +59,13 @@ class ScenarioSpec: f"glide-{self.glide_path}") -def build_strategy(name: str, floor: float | None = None) -> WithdrawalStrategy: +def build_strategy( + name: str, + floor: float | None = None, + annual_real_adjust_pct: float = 0.0, + guardrail_threshold_pct: float | None = None, + guardrail_cut_pct: float = 0.10, +) -> WithdrawalStrategy: if name == "trinity": return TrinityStrategy() if name == "guyton_klinger": @@ -69,6 +76,12 @@ def build_strategy(name: str, floor: float | None = None) -> WithdrawalStrategy: if floor is None: raise ValueError("vpw_floor strategy requires a `floor` value (real GBP)") return VpwWithFloorStrategy(floor=floor) + if name == "custom": + return SpendingPlanStrategy( + annual_real_adjust_pct=annual_real_adjust_pct, + guardrail_threshold_pct=guardrail_threshold_pct, + guardrail_cut_pct=guardrail_cut_pct, + ) raise KeyError(f"Unknown strategy: {name!r}") diff --git a/fire_planner/strategies/guyton_klinger.py b/fire_planner/strategies/guyton_klinger.py index 1ff3522..9d66aa9 100644 --- a/fire_planner/strategies/guyton_klinger.py +++ b/fire_planner/strategies/guyton_klinger.py @@ -41,17 +41,26 @@ class GuytonKlingerStrategy(WithdrawalStrategy): 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 state.initial_portfolio * self.initial_rate + 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 > self.initial_rate * CAPITAL_PRESERVATION_RATIO + if (current_rate > implied_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: + if current_rate < implied_initial_rate * PROSPERITY_RATIO: return last_w * (1 + ADJUSTMENT) return last_w diff --git a/fire_planner/strategies/spending_plan.py b/fire_planner/strategies/spending_plan.py new file mode 100644 index 0000000..830d88c --- /dev/null +++ b/fire_planner/strategies/spending_plan.py @@ -0,0 +1,62 @@ +"""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) diff --git a/fire_planner/strategies/trinity.py b/fire_planner/strategies/trinity.py index ea0227a..75f0805 100644 --- a/fire_planner/strategies/trinity.py +++ b/fire_planner/strategies/trinity.py @@ -1,9 +1,10 @@ -"""Trinity 4% Safe Withdrawal Rate. +"""Constant-real-£ withdrawal (the classic 4% rule shape). -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". +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 @@ -20,5 +21,10 @@ class TrinityStrategy(WithdrawalStrategy): 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 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 495ad6d..6484886 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -243,6 +243,10 @@ export interface SimulateRequest { }>; returns_mode?: 'shiller' | 'manual' | 'wealthfolio'; manual_real_return_pct?: string | null; + // Custom spending-plan params (only consulted when strategy='custom') + annual_real_adjust_pct?: string; + guardrail_threshold_pct?: string | null; + guardrail_cut_pct?: string; } export interface SimulateResult { diff --git a/frontend/src/pages/WhatIf.tsx b/frontend/src/pages/WhatIf.tsx index 2b51024..bbc68ac 100644 --- a/frontend/src/pages/WhatIf.tsx +++ b/frontend/src/pages/WhatIf.tsx @@ -11,7 +11,7 @@ import { FanChart } from '@/components/FanChart'; import { gbp, pct } from '@/lib/format'; const JURISDICTIONS = ['uk', 'cyprus', 'bulgaria', 'malaysia', 'thailand', 'uae', 'nomad']; -const STRATEGIES = ['trinity', 'guyton_klinger', 'vpw', 'vpw_floor']; +const STRATEGIES = ['trinity', 'guyton_klinger', 'vpw', 'vpw_floor', 'custom']; const GLIDES = ['rising', 'static_60_40']; const RETURNS_MODES = ['shiller', 'manual', 'wealthfolio'] as const; @@ -35,13 +35,15 @@ const RETURNS_MODE_NOTES: Record = { // reused in the "About the model" panel at the bottom. const STRATEGY_NOTES: Record = { trinity: - 'Withdraw 4% of the starting portfolio in year 1, then keep that real-£ amount fixed. Simple and famous, but rigid — never adapts to market crashes.', + 'Withdraw your "Annual spending" amount in year 1, then keep that real-£ amount fixed. Simple Trinity-style — never adapts to market crashes. Now driven by the spending input you set above (was a hardcoded 4% in earlier versions).', guyton_klinger: - 'Start higher (~5.5%) and follow guardrail rules: cut spending if the portfolio drops too far, raise it if it grows enough. Adapts to markets, sustainable on long horizons.', + 'Withdraw your "Annual spending" amount in year 1, then follow guardrails: cut by 10% if the implied withdrawal rate exceeds 120% of the starting rate (and >15y left), raise 10% if it drops below 80%. Adapts to markets.', vpw: - 'Variable Percentage Withdrawal — each year, withdraw a percentage based on years left and expected real return (annuity-style). Mathematically can\'t fail, but income swings can be wide.', + 'Variable Percentage Withdrawal — each year, withdraw a percentage based on years left and expected real return (annuity-style). Ignores the "Annual spending" input — withdrawal is fully algorithmic. Mathematically can\'t fail, but income swings can be wide.', vpw_floor: - 'VPW with a hard real-£ floor: never withdraw less than the floor, even if VPW says you should. Trades guaranteed lifestyle against ruin risk in bad sequences.', + 'VPW with a hard real-£ floor: never withdraw less than the floor, even if VPW says you should. Ignores "Annual spending" but uses the floor input. Trades guaranteed lifestyle against ruin risk in bad sequences.', + custom: + 'Pick everything: initial spending (the "Annual spending" field above), an annual real-£ adjustment (e.g. -0.5%/yr to spend less as you age), and an optional drawdown guardrail that cuts spending by N% if the portfolio falls below X% of starting NW.', }; const GLIDE_NOTES: Record = { @@ -75,6 +77,9 @@ const DEFAULTS: SimulateRequest = { seed: 42, returns_mode: 'shiller', manual_real_return_pct: '0.046', + annual_real_adjust_pct: '0', + guardrail_threshold_pct: null, + guardrail_cut_pct: '0.10', }; export function WhatIf() { @@ -122,11 +127,17 @@ export function WhatIf() { const onSubmit = (e: React.FormEvent) => { e.preventDefault(); + const isCustom = form.strategy === 'custom'; sim.mutate({ ...form, floor_gbp: form.strategy === 'vpw_floor' ? form.floor_gbp : null, manual_real_return_pct: form.returns_mode === 'manual' ? form.manual_real_return_pct : null, + // Only send custom-plan params when strategy='custom' to avoid + // confusing reads in the persisted history later. + annual_real_adjust_pct: isCustom ? form.annual_real_adjust_pct : '0', + guardrail_threshold_pct: isCustom ? form.guardrail_threshold_pct : null, + guardrail_cut_pct: isCustom ? form.guardrail_cut_pct : '0.10', }); }; @@ -221,6 +232,60 @@ export function WhatIf() { /> )} + {form.strategy === 'custom' && ( + <> + + + update('annual_real_adjust_pct', e.target.value) + } + className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400" + /> + + + + update( + 'guardrail_threshold_pct', + e.target.value === '' ? null : e.target.value, + ) + } + className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400" + /> + + + update('guardrail_cut_pct', e.target.value)} + className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400" + /> + + + )} {STRATEGY_NOTES.guyton_klinger} {STRATEGY_NOTES.vpw} {STRATEGY_NOTES.vpw_floor} + {STRATEGY_NOTES.custom}
{GLIDE_NOTES.rising} diff --git a/tests/test_spending_plan.py b/tests/test_spending_plan.py new file mode 100644 index 0000000..5b5d967 --- /dev/null +++ b/tests/test_spending_plan.py @@ -0,0 +1,87 @@ +"""SpendingPlanStrategy: user-customisable initial spend + annual adjust + guardrail.""" +from fire_planner.strategies.base import StrategyState +from fire_planner.strategies.spending_plan import SpendingPlanStrategy + + +def state(**overrides: float | int) -> StrategyState: + base = dict( + portfolio=1_000_000.0, + initial_portfolio=1_000_000.0, + initial_withdrawal=40_000.0, + year_idx=0, + horizon_years=60, + last_withdrawal=40_000.0, + expected_real_return=0.04, + ) + base.update(overrides) + return StrategyState(**base) # type: ignore[arg-type] + + +def test_year_zero_takes_initial_withdrawal() -> None: + s = SpendingPlanStrategy() + assert s.propose_withdrawal(state()) == 40_000.0 + + +def test_year_zero_explicit_override() -> None: + s = SpendingPlanStrategy(initial_spend=72_000.0) + assert s.propose_withdrawal(state()) == 72_000.0 + + +def test_constant_real_with_zero_adjust() -> None: + s = SpendingPlanStrategy(annual_real_adjust_pct=0.0) + assert s.propose_withdrawal(state(year_idx=10, last_withdrawal=40_000.0)) == 40_000.0 + + +def test_positive_annual_adjust_grows_spending() -> None: + """+2% real adjust → year 5 spend is last_w × 1.02 (one step from year 4).""" + s = SpendingPlanStrategy(annual_real_adjust_pct=0.02) + out = s.propose_withdrawal(state(year_idx=5, last_withdrawal=40_000.0)) + assert abs(out - 40_800.0) < 1e-6 + + +def test_negative_annual_adjust_shrinks_spending() -> None: + s = SpendingPlanStrategy(annual_real_adjust_pct=-0.005) + out = s.propose_withdrawal(state(year_idx=10, last_withdrawal=40_000.0)) + assert abs(out - 39_800.0) < 1e-6 + + +def test_guardrail_not_triggered_when_portfolio_above_threshold() -> None: + s = SpendingPlanStrategy(guardrail_threshold_pct=0.80, guardrail_cut_pct=0.10) + # portfolio at 90% of starting → above threshold → no cut + out = s.propose_withdrawal(state(year_idx=3, portfolio=900_000.0, + last_withdrawal=40_000.0)) + assert out == 40_000.0 + + +def test_guardrail_triggered_cuts_by_pct() -> None: + s = SpendingPlanStrategy(guardrail_threshold_pct=0.80, guardrail_cut_pct=0.10) + # portfolio at 70% of starting → below threshold → 10% cut + out = s.propose_withdrawal(state(year_idx=3, portfolio=700_000.0, + last_withdrawal=40_000.0)) + assert abs(out - 36_000.0) < 1e-6 + + +def test_guardrail_combines_with_annual_adjust() -> None: + """Adjust applies first, then cut — both fire.""" + s = SpendingPlanStrategy( + annual_real_adjust_pct=0.02, + guardrail_threshold_pct=0.80, + guardrail_cut_pct=0.10, + ) + out = s.propose_withdrawal(state(year_idx=3, portfolio=700_000.0, + last_withdrawal=40_000.0)) + # 40_000 * 1.02 = 40_800; trigger; 40_800 * 0.90 = 36_720 + assert abs(out - 36_720.0) < 1e-6 + + +def test_guardrail_disabled_when_threshold_none() -> None: + s = SpendingPlanStrategy(guardrail_threshold_pct=None) + out = s.propose_withdrawal(state(year_idx=3, portfolio=10_000.0, + last_withdrawal=40_000.0)) + assert out == 40_000.0 # no cut despite tiny portfolio + + +def test_returns_zero_when_portfolio_drained() -> None: + s = SpendingPlanStrategy() + out = s.propose_withdrawal(state(year_idx=5, portfolio=0.0, last_withdrawal=40_000.0)) + assert out == 0.0 diff --git a/tests/test_strategies.py b/tests/test_strategies.py index 6edec74..577e91e 100644 --- a/tests/test_strategies.py +++ b/tests/test_strategies.py @@ -36,17 +36,30 @@ def test_trinity_doesnt_increase_with_portfolio_growth() -> None: last_withdrawal=40_000.0)) == 40_000.0 -def test_gk_year_zero_uses_initial_rate() -> None: +def test_gk_year_zero_uses_initial_withdrawal() -> None: + """Year-0 honours the user's target spending (state.initial_withdrawal), + not the strategy's preset rate. The preset rate now only matters as a + fallback when initial_withdrawal isn't set.""" s = GuytonKlingerStrategy(initial_rate=0.055) - # 5.5% of 1M = 55,000 - assert s.propose_withdrawal(state()) == 55_000.0 + # state default has initial_withdrawal=40_000 → year 0 returns 40_000. + assert s.propose_withdrawal(state()) == 40_000.0 + + +def test_gk_year_zero_falls_back_to_preset_when_no_target() -> None: + s = GuytonKlingerStrategy(initial_rate=0.055) + # Override initial_withdrawal=0 → fall back to 5.5% × 1M = 55_000. + assert s.propose_withdrawal(state(initial_withdrawal=0)) == 55_000.0 def test_gk_capital_preservation_cut() -> None: - """Portfolio crashed: current rate now > 120% of 5.5% = 6.6%; > 15y left → cut 10%.""" + """Portfolio crashed: current rate now > 120% of the implied initial rate (5.5%); + > 15y left → cut 10%. Implied rate = initial_withdrawal / initial_portfolio.""" s = GuytonKlingerStrategy(initial_rate=0.055) - # last_w = 55,000; portfolio = 700,000 → rate = 7.86% > 6.6% - out = s.propose_withdrawal(state(year_idx=5, portfolio=700_000.0, last_withdrawal=55_000.0)) + # initial_withdrawal=55k, initial_portfolio=1M → implied rate = 5.5%. + # last_w = 55k; portfolio = 700k → current rate = 7.86% > 6.6% guardrail. + out = s.propose_withdrawal(state(year_idx=5, portfolio=700_000.0, + last_withdrawal=55_000.0, + initial_withdrawal=55_000.0)) assert abs(out - 49_500.0) < 0.01