strategies: spending input is honoured + new "Custom" preset with guardrails
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
00ec874889
commit
f43322e5ce
10 changed files with 300 additions and 21 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue