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

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

View file

@ -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<string, string> = {
// reused in the "About the model" panel at the bottom.
const STRATEGY_NOTES: Record<string, string> = {
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<string, string> = {
@ -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() {
/>
</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
label="Returns model"
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="VPW">{STRATEGY_NOTES.vpw}</Term>
<Term name="VPW + floor">{STRATEGY_NOTES.vpw_floor}</Term>
<Term name="Custom spending plan">{STRATEGY_NOTES.custom}</Term>
</Section>
<Section title="Glide paths (stock/bond mix over time)">
<Term name="Rising equity">{GLIDE_NOTES.rising}</Term>