fire-planner/frontend/src/pages/WhatIf.tsx

458 lines
16 KiB
TypeScript
Raw Normal View History

/**
* What-If interactive Monte Carlo. Form on the left, fan chart on the
* right. Hits POST /simulate (no DB write); ~1-3s for 5k paths.
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api, type SimulateRequest, type SimulateResult } from '@/api/client';
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 GLIDES = ['rising', 'static_60_40'];
// Plain-English notes shown next to each dropdown so the user knows
// what each option does without leaving the page. Same content gets
// 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.',
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.',
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.',
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.',
};
const GLIDE_NOTES: Record<string, string> = {
rising:
'Rising-equity glide path (Pfau/Kitces 2014): start ~30% stocks, ramp to ~70% over 15 years. Reduces sequence-of-returns risk in early retirement.',
static_60_40:
'Constant 60/40 stocks/bonds for the whole horizon. The classic baseline.',
};
const JURISDICTION_NOTES: Record<string, string> = {
uk: 'UK 2026/27 PAYE + NI + CGT + dividend rules. Personal allowance tapers above £100k; pension withdrawals 25% tax-free.',
cyprus: 'Cyprus 60-day non-dom: 17-year exemption on foreign dividends + interest. 2.65% GeSY healthcare levy capped at €180k.',
bulgaria: 'Flat 10% on worldwide income. EU/EEA capital gains exempt (we apply 10% conservatively).',
malaysia: 'Foreign-sourced income exempt through 2036. Effective 0% on a typical retiree withdrawal.',
thailand: 'Foreign-sourced income exempt (v1; the 2024 remittance rule is deferred in this model).',
uae: 'No personal income tax, no levy. Effective 0%.',
nomad: 'Tax-free baseline + a 1% regulatory-risk premium to hedge against OECD/CRS rules tightening.',
};
const DEFAULTS: SimulateRequest = {
jurisdiction: 'cyprus',
strategy: 'guyton_klinger',
leave_uk_year: 2,
glide_path: 'rising',
spending_gbp: '60000',
nw_seed_gbp: '1500000',
savings_per_year_gbp: '0',
horizon_years: 60,
floor_gbp: null,
n_paths: 5000,
seed: 42,
};
export function WhatIf() {
const [form, setForm] = useState<SimulateRequest>(DEFAULTS);
const [nwAutoFilled, setNwAutoFilled] = useState(false);
const navigate = useNavigate();
const qc = useQueryClient();
// Pre-fill NW seed from the latest Wealthfolio snapshot the first
// time it loads, so opening /what-if always starts from real numbers.
// The user can still edit; we won't clobber their input on later
// refetches.
const nw = useQuery({ queryKey: ['networth', 'current'], queryFn: api.networth.current });
useEffect(() => {
if (nwAutoFilled || !nw.data || nw.data.accounts.length === 0) return;
// Round to whole pounds — pence from the API don't survive HTML5
// step validation, and the user doesn't think in pence at this scale.
const rounded = String(Math.round(Number(nw.data.total_gbp)));
setForm((f) => ({ ...f, nw_seed_gbp: rounded }));
setNwAutoFilled(true);
}, [nw.data, nwAutoFilled]);
const sim = useMutation({
mutationFn: (req: SimulateRequest) => api.simulate(req),
});
const save = useMutation({
mutationFn: (name: string) =>
api.scenarios.create({
name,
jurisdiction: form.jurisdiction,
strategy: form.strategy,
leave_uk_year: form.leave_uk_year,
glide_path: form.glide_path,
spending_gbp: form.spending_gbp,
nw_seed_gbp: form.nw_seed_gbp,
savings_per_year_gbp: form.savings_per_year_gbp,
horizon_years: form.horizon_years,
}),
onSuccess: (s) => {
qc.invalidateQueries({ queryKey: ['scenarios'] });
navigate(`/scenarios/${s.id}`);
},
});
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
sim.mutate({
...form,
floor_gbp: form.strategy === 'vpw_floor' ? form.floor_gbp : null,
});
};
const onSaveAs = () => {
const suggested = `${form.jurisdiction}-${form.strategy}-leave-y${form.leave_uk_year}`;
const name = prompt('Save as scenario — name:', suggested);
if (!name?.trim()) return;
save.mutate(name.trim());
};
const update = <K extends keyof SimulateRequest>(key: K, value: SimulateRequest[K]) =>
setForm((f) => ({ ...f, [key]: value }));
return (
<section className="space-y-6">
<header>
<h1 className="text-3xl font-semibold tracking-tight">What if</h1>
<p className="text-sm text-slate-500">
Run a single Monte Carlo against the engine. No data persisted.
</p>
</header>
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-6 items-start">
<form
onSubmit={onSubmit}
className="rounded-lg border border-slate-200 bg-white p-5 space-y-4 sticky top-4"
>
<Field label="Jurisdiction" hint={JURISDICTION_NOTES[form.jurisdiction]}>
<Select
value={form.jurisdiction}
onChange={(v) => update('jurisdiction', v)}
options={JURISDICTIONS}
/>
</Field>
<Field label="Strategy" hint={STRATEGY_NOTES[form.strategy]}>
<Select
value={form.strategy}
onChange={(v) => update('strategy', v)}
options={STRATEGIES}
/>
</Field>
<Field label="Glide path" hint={GLIDE_NOTES[form.glide_path]}>
<Select
value={form.glide_path}
onChange={(v) => update('glide_path', v)}
options={GLIDES}
/>
</Field>
<Field label="Years until leaving UK">
<NumberInput
value={form.leave_uk_year}
onChange={(v) => update('leave_uk_year', v)}
min={0}
max={60}
/>
</Field>
<Field label="Annual spending (£)">
<NumberInput
value={Number(form.spending_gbp)}
onChange={(v) => update('spending_gbp', String(v))}
min={0}
/>
</Field>
<Field label="NW seed (£)">
<NumberInput
value={Number(form.nw_seed_gbp)}
onChange={(v) => update('nw_seed_gbp', String(v))}
min={0}
/>
</Field>
<Field label="Annual savings (£)">
<NumberInput
value={Number(form.savings_per_year_gbp ?? 0)}
onChange={(v) => update('savings_per_year_gbp', String(v))}
min={0}
/>
</Field>
<Field label="Horizon (years)">
<NumberInput
value={form.horizon_years ?? 60}
onChange={(v) => update('horizon_years', v)}
min={5}
max={100}
/>
</Field>
{form.strategy === 'vpw_floor' && (
<Field label="Floor (£/yr)">
<NumberInput
value={Number(form.floor_gbp ?? 40000)}
onChange={(v) => update('floor_gbp', String(v))}
min={0}
/>
</Field>
)}
<Field label="Monte Carlo paths">
<NumberInput
value={form.n_paths ?? 5000}
onChange={(v) => update('n_paths', v)}
min={100}
max={50000}
step={100}
/>
</Field>
<button
type="submit"
disabled={sim.isPending}
className="w-full rounded-md bg-slate-900 text-white text-sm font-medium px-4 py-2 hover:bg-slate-800 disabled:opacity-60 disabled:cursor-not-allowed"
>
{sim.isPending ? 'Running…' : 'Run simulation'}
</button>
</form>
<div className="space-y-4 min-w-0">
{sim.isError && (
<div className="rounded-md border border-red-200 bg-red-50 p-4 text-red-800 text-sm">
{String((sim.error as Error)?.message ?? sim.error)}
</div>
)}
{!sim.data && !sim.isPending && !sim.isError && (
<div className="rounded-lg border border-slate-200 bg-white p-12 text-center text-slate-500">
Set parameters on the left and run a simulation.
</div>
)}
{sim.isPending && (
<div className="rounded-lg border border-slate-200 bg-white p-12 text-center text-slate-500">
Running Monte Carlo
</div>
)}
{sim.data && (
<>
<Results result={sim.data} horizon={form.horizon_years ?? 60} />
<div className="flex items-center gap-3">
<button
type="button"
onClick={onSaveAs}
disabled={save.isPending}
className="rounded-md border border-slate-300 bg-white text-sm px-4 py-2 hover:bg-slate-50 disabled:opacity-60"
>
{save.isPending ? 'Saving…' : 'Save as scenario'}
</button>
{save.isError && (
<span className="text-xs text-red-700">
{String((save.error as Error)?.message ?? save.error)}
</span>
)}
</div>
</>
)}
<AboutTheModel />
</div>
</div>
</section>
);
}
function AboutTheModel() {
return (
<details className="rounded-lg border border-slate-200 bg-white p-5 group">
<summary className="cursor-pointer text-base font-semibold flex items-center justify-between">
<span>About the model</span>
<span className="text-xs text-slate-500 group-open:hidden">Click to expand</span>
</summary>
<div className="mt-4 space-y-5 text-sm text-slate-700">
<Section title="Withdrawal strategies">
<Term name="Trinity 4%">{STRATEGY_NOTES.trinity}</Term>
<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>
</Section>
<Section title="Glide paths (stock/bond mix over time)">
<Term name="Rising equity">{GLIDE_NOTES.rising}</Term>
<Term name="Static 60/40">{GLIDE_NOTES.static_60_40}</Term>
</Section>
<Section title="Tax jurisdictions">
<Term name="UK">{JURISDICTION_NOTES.uk}</Term>
<Term name="Cyprus">{JURISDICTION_NOTES.cyprus}</Term>
<Term name="Bulgaria">{JURISDICTION_NOTES.bulgaria}</Term>
<Term name="Malaysia">{JURISDICTION_NOTES.malaysia}</Term>
<Term name="Thailand">{JURISDICTION_NOTES.thailand}</Term>
<Term name="UAE">{JURISDICTION_NOTES.uae}</Term>
<Term name="Nomad (no fixed residency)">{JURISDICTION_NOTES.nomad}</Term>
</Section>
<Section title="How the engine treats tax">
<p>
Each year the strategy proposes a real-£ withdrawal <code>w</code>; the chosen
jurisdiction's tax engine computes <code>tax(w)</code>; the portfolio drops by
<code> w + tax(w)</code>. Higher-tax jurisdictions therefore drain the portfolio
faster and lower the success rate switching jurisdiction visibly moves the fan,
not just the lifetime-tax cell.
</p>
</Section>
<Section title="Returns">
<p>
Real returns are sampled by 5-year block bootstrap from the Shiller 1871+ series
(or a synthetic Shiller-calibrated stream). 60/40 long-run real 4.6%; equities
are ~9.5% nominal / 17% volatility, bonds ~5%/8%. Each path resamples blocks
independently so sequence-of-returns risk is preserved.
</p>
</Section>
<Section title="Success rate">
<p>
A path counts as a success if the portfolio stays positive through every interim
year. The very last year-end is excluded because VPW deliberately drains to ~0
at the horizon by construction.
</p>
</Section>
</div>
</details>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<h3 className="text-sm font-semibold text-slate-900 mb-2">{title}</h3>
<div className="space-y-2">{children}</div>
</div>
);
}
function Term({ name, children }: { name: string; children: React.ReactNode }) {
return (
<div>
<span className="font-medium">{name}.</span>{' '}
<span className="text-slate-600">{children}</span>
</div>
);
}
function Results({ result, horizon }: { result: SimulateResult; horizon: number }) {
return (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Stat label="Success rate" value={pct(result.success_rate)} accent />
<Stat label="Median ending NW" value={gbp(result.p50_ending_gbp)} />
<Stat label="P10 ending" value={gbp(result.p10_ending_gbp)} />
<Stat label="P90 ending" value={gbp(result.p90_ending_gbp)} />
<Stat label="Median lifetime tax" value={gbp(result.median_lifetime_tax_gbp)} />
<Stat
label="Median years to ruin"
value={result.median_years_to_ruin ?? 'never'}
/>
<Stat label="Horizon" value={`${horizon} years`} />
<Stat label="Engine time" value={`${result.elapsed_seconds}s`} />
</div>
<div className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="text-lg font-semibold mb-2">Portfolio fan</h2>
<FanChart yearly={result.yearly} height={420} showWithdrawal />
</div>
</>
);
}
function Stat({
label,
value,
accent,
}: {
label: string;
value: string | number;
accent?: boolean;
}) {
return (
<div className="rounded-md border border-slate-200 bg-white p-3">
<div className="text-xs uppercase tracking-wide text-slate-500">{label}</div>
<div
className={`text-xl font-semibold tabular-nums mt-1 ${
accent ? 'text-emerald-700' : ''
}`}
>
{value}
</div>
</div>
);
}
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<label className="block">
<span className="text-xs uppercase tracking-wide text-slate-500">{label}</span>
<div className="mt-1">{children}</div>
{hint && <p className="mt-1 text-xs text-slate-500 leading-snug">{hint}</p>}
</label>
);
}
function Select({
value,
onChange,
options,
}: {
value: string;
onChange: (v: string) => void;
options: string[];
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400"
>
{options.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
);
}
function NumberInput({
value,
onChange,
min,
max,
step = 1,
}: {
value: number;
onChange: (v: number) => void;
min?: number;
max?: number;
step?: number;
}) {
return (
<input
type="number"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => {
const n = Number(e.target.value);
if (Number.isFinite(n)) onChange(n);
}}
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"
/>
);
}