strategies: spending input is honoured + new "Custom" preset with guardrails
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

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>
This commit is contained in:
Viktor Barzin 2026-05-10 01:21:55 +00:00
parent 00ec874889
commit f43322e5ce
10 changed files with 300 additions and 21 deletions

View file

@ -238,6 +238,16 @@ class SimulateRequest(BaseModel):
# recent regime only (~6 years). Glide path is moot. # recent regime only (~6 years). Glide path is moot.
returns_mode: str = Field(default="shiller", pattern="^(shiller|manual|wealthfolio)$") returns_mode: str = Field(default="shiller", pattern="^(shiller|manual|wealthfolio)$")
manual_real_return_pct: Decimal | None = None 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): class SimulateResult(BaseModel):

View file

@ -105,13 +105,22 @@ def _project(req: SimulateRequest, paths: np.ndarray) -> tuple[SimulationResult,
] ]
cashflow_adjustments = events_to_cashflow_array(engine_events, req.horizon_years) 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() started = time.perf_counter()
result = simulate( result = simulate(
paths=paths, paths=paths,
initial_portfolio=float(req.nw_seed_gbp), initial_portfolio=float(req.nw_seed_gbp),
spending_target=float(req.spending_gbp), spending_target=float(req.spending_gbp),
glide=get_glide(req.glide_path), 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), regime=build_regime_schedule(req.jurisdiction, req.leave_uk_year),
horizon_years=req.horizon_years, horizon_years=req.horizon_years,
annual_savings=annual_savings, annual_savings=annual_savings,

View file

@ -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.simulator import RegimeFn, constant_regime, jurisdiction_schedule
from fire_planner.strategies.base import WithdrawalStrategy from fire_planner.strategies.base import WithdrawalStrategy
from fire_planner.strategies.guyton_klinger import GuytonKlingerStrategy 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.trinity import TrinityStrategy
from fire_planner.strategies.vpw import VpwStrategy, VpwWithFloorStrategy from fire_planner.strategies.vpw import VpwStrategy, VpwWithFloorStrategy
from fire_planner.tax.base import TaxRegime from fire_planner.tax.base import TaxRegime
@ -58,7 +59,13 @@ class ScenarioSpec:
f"glide-{self.glide_path}") 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": if name == "trinity":
return TrinityStrategy() return TrinityStrategy()
if name == "guyton_klinger": if name == "guyton_klinger":
@ -69,6 +76,12 @@ def build_strategy(name: str, floor: float | None = None) -> WithdrawalStrategy:
if floor is None: if floor is None:
raise ValueError("vpw_floor strategy requires a `floor` value (real GBP)") raise ValueError("vpw_floor strategy requires a `floor` value (real GBP)")
return VpwWithFloorStrategy(floor=floor) 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}") raise KeyError(f"Unknown strategy: {name!r}")

View file

@ -41,17 +41,26 @@ class GuytonKlingerStrategy(WithdrawalStrategy):
self.initial_rate = initial_rate self.initial_rate = initial_rate
def propose_withdrawal(self, state: StrategyState) -> float: 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: if state.year_idx == 0:
return state.initial_portfolio * self.initial_rate return target_initial
if state.portfolio <= 0: if state.portfolio <= 0:
return 0.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 last_w = state.last_withdrawal
current_rate = last_w / state.portfolio current_rate = last_w / state.portfolio
years_left = state.horizon_years - state.year_idx years_left = state.horizon_years - state.year_idx
# Capital-preservation cut: only if more than 15 years remain. # 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): and years_left > MIN_HORIZON_FOR_CUT):
return last_w * (1 - ADJUSTMENT) 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 * (1 + ADJUSTMENT)
return last_w return last_w

View file

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

View file

@ -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, Withdraw `state.initial_withdrawal` in year 0, then keep that real-£
1998) withdraw 4% of the starting balance in year 1, then keep the amount fixed for the rest of retirement. In a 4% / £1M setup the year-0
real withdrawal constant for the rest of retirement. In our real-GBP draw is £40k, then £40k real every year after. The strategy's
internal frame this is just "the same number every year". `initial_rate` is kept only as a fallback for callers that don't feed
`state.initial_withdrawal`.
""" """
from __future__ import annotations from __future__ import annotations
@ -20,5 +21,10 @@ class TrinityStrategy(WithdrawalStrategy):
def propose_withdrawal(self, state: StrategyState) -> float: def propose_withdrawal(self, state: StrategyState) -> float:
if state.year_idx == 0: 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.initial_portfolio * self.initial_rate
return state.last_withdrawal return state.last_withdrawal

View file

@ -243,6 +243,10 @@ export interface SimulateRequest {
}>; }>;
returns_mode?: 'shiller' | 'manual' | 'wealthfolio'; returns_mode?: 'shiller' | 'manual' | 'wealthfolio';
manual_real_return_pct?: string | null; 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 { export interface SimulateResult {

View file

@ -11,7 +11,7 @@ import { FanChart } from '@/components/FanChart';
import { gbp, pct } from '@/lib/format'; import { gbp, pct } from '@/lib/format';
const JURISDICTIONS = ['uk', 'cyprus', 'bulgaria', 'malaysia', 'thailand', 'uae', 'nomad']; 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 GLIDES = ['rising', 'static_60_40'];
const RETURNS_MODES = ['shiller', 'manual', 'wealthfolio'] as const; const RETURNS_MODES = ['shiller', 'manual', 'wealthfolio'] as const;
@ -35,13 +35,15 @@ const RETURNS_MODE_NOTES: Record<string, string> = {
// reused in the "About the model" panel at the bottom. // reused in the "About the model" panel at the bottom.
const STRATEGY_NOTES: Record<string, string> = { const STRATEGY_NOTES: Record<string, string> = {
trinity: 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: 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: 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_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<string, string> = { const GLIDE_NOTES: Record<string, string> = {
@ -75,6 +77,9 @@ const DEFAULTS: SimulateRequest = {
seed: 42, seed: 42,
returns_mode: 'shiller', returns_mode: 'shiller',
manual_real_return_pct: '0.046', manual_real_return_pct: '0.046',
annual_real_adjust_pct: '0',
guardrail_threshold_pct: null,
guardrail_cut_pct: '0.10',
}; };
export function WhatIf() { export function WhatIf() {
@ -122,11 +127,17 @@ export function WhatIf() {
const onSubmit = (e: React.FormEvent) => { const onSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const isCustom = form.strategy === 'custom';
sim.mutate({ sim.mutate({
...form, ...form,
floor_gbp: form.strategy === 'vpw_floor' ? form.floor_gbp : null, floor_gbp: form.strategy === 'vpw_floor' ? form.floor_gbp : null,
manual_real_return_pct: manual_real_return_pct:
form.returns_mode === 'manual' ? form.manual_real_return_pct : null, 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() {
/> />
</Field> </Field>
)} )}
{form.strategy === 'custom' && (
<>
<Field
label="Annual real adjustment %"
hint="0 = constant real £ (Trinity shape). Positive grows spending each year (e.g. 0.02 = +2%/yr for healthcare). Negative shrinks (e.g. -0.005 = -0.5%/yr to slow down with age)."
>
<input
type="number"
value={form.annual_real_adjust_pct ?? '0'}
step="0.001"
min={-0.1}
max={0.1}
onChange={(e) =>
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"
/>
</Field>
<Field
label="Guardrail trigger: cut spending if NW drops below"
hint="Fraction of starting NW that triggers a spending cut. e.g. 0.80 = cut once portfolio falls below 80% of seed. Leave blank to disable."
>
<input
type="number"
value={form.guardrail_threshold_pct ?? ''}
step="0.05"
min={0}
max={1}
placeholder="(off)"
onChange={(e) =>
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"
/>
</Field>
<Field
label="Guardrail cut size (when triggered)"
hint="Fraction by which to cut last year's withdrawal each triggered year. e.g. 0.10 = -10%."
>
<input
type="number"
value={form.guardrail_cut_pct ?? '0.10'}
step="0.05"
min={0}
max={1}
onChange={(e) => 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"
/>
</Field>
</>
)}
<Field <Field
label="Returns model" label="Returns model"
hint={RETURNS_MODE_NOTES[form.returns_mode ?? 'shiller']} hint={RETURNS_MODE_NOTES[form.returns_mode ?? 'shiller']}
@ -328,6 +393,7 @@ function AboutTheModel() {
<Term name="Guyton-Klinger guardrails">{STRATEGY_NOTES.guyton_klinger}</Term> <Term name="Guyton-Klinger guardrails">{STRATEGY_NOTES.guyton_klinger}</Term>
<Term name="VPW">{STRATEGY_NOTES.vpw}</Term> <Term name="VPW">{STRATEGY_NOTES.vpw}</Term>
<Term name="VPW + floor">{STRATEGY_NOTES.vpw_floor}</Term> <Term name="VPW + floor">{STRATEGY_NOTES.vpw_floor}</Term>
<Term name="Custom spending plan">{STRATEGY_NOTES.custom}</Term>
</Section> </Section>
<Section title="Glide paths (stock/bond mix over time)"> <Section title="Glide paths (stock/bond mix over time)">
<Term name="Rising equity">{GLIDE_NOTES.rising}</Term> <Term name="Rising equity">{GLIDE_NOTES.rising}</Term>

View file

@ -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

View file

@ -36,17 +36,30 @@ def test_trinity_doesnt_increase_with_portfolio_growth() -> None:
last_withdrawal=40_000.0)) == 40_000.0 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) s = GuytonKlingerStrategy(initial_rate=0.055)
# 5.5% of 1M = 55,000 # state default has initial_withdrawal=40_000 → year 0 returns 40_000.
assert s.propose_withdrawal(state()) == 55_000.0 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: 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) s = GuytonKlingerStrategy(initial_rate=0.055)
# last_w = 55,000; portfolio = 700,000 → rate = 7.86% > 6.6% # initial_withdrawal=55k, initial_portfolio=1M → implied rate = 5.5%.
out = s.propose_withdrawal(state(year_idx=5, portfolio=700_000.0, last_withdrawal=55_000.0)) # 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 assert abs(out - 49_500.0) < 0.01