/** * 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', 'custom']; const GLIDES = ['rising', 'static_60_40']; const RETURNS_MODES = ['shiller', 'manual', 'wealthfolio'] as const; const RETURNS_MODE_LABELS: Record = { shiller: 'Historical (Shiller 1871+)', manual: 'Manual real return %', wealthfolio: 'My Wealthfolio history', }; const RETURNS_MODE_NOTES: Record = { shiller: 'Block-bootstrap of US historical real returns (Shiller 1871+). Broadest regime coverage — includes 1929/1973/2000/2008-style bad sequences. Best default for stress-testing.', manual: 'Every year, every path returns the % you type. Deterministic — no fan, no volatility. Useful for sanity checks ("what if my real return is exactly 5%?").', wealthfolio: 'Block-bootstrap of your actual blended portfolio returns from wealthfolio_sync (~6 years, 2020-present). Reflects your real account mix but biased to the recent regime. Glide path is ignored in this mode.', }; // 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 = { trinity: '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: '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). 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. 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 = { 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 = { 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, 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() { const [form, setForm] = useState(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(); 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', }); }; 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 = (key: K, value: SimulateRequest[K]) => setForm((f) => ({ ...f, [key]: value })); return (

What if…

Run a single Monte Carlo against the engine. No data persisted.

update('strategy', v)} options={STRATEGIES} /> 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" /> )} {form.returns_mode === 'manual' && ( update('manual_real_return_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('n_paths', v)} min={100} max={50000} step={100} />
{sim.isError && (
{String((sim.error as Error)?.message ?? sim.error)}
)} {!sim.data && !sim.isPending && !sim.isError && (
Set parameters on the left and run a simulation.
)} {sim.isPending && (
Running Monte Carlo…
)} {sim.data && ( <>
{save.isError && ( {String((save.error as Error)?.message ?? save.error)} )}
)}
); } function AboutTheModel() { return (
About the model Click to expand
{STRATEGY_NOTES.trinity} {STRATEGY_NOTES.guyton_klinger} {STRATEGY_NOTES.vpw} {STRATEGY_NOTES.vpw_floor} {STRATEGY_NOTES.custom}
{GLIDE_NOTES.rising} {GLIDE_NOTES.static_60_40}
{JURISDICTION_NOTES.uk} {JURISDICTION_NOTES.cyprus} {JURISDICTION_NOTES.bulgaria} {JURISDICTION_NOTES.malaysia} {JURISDICTION_NOTES.thailand} {JURISDICTION_NOTES.uae} {JURISDICTION_NOTES.nomad}

Each year the strategy proposes a real-£ withdrawal w; the chosen jurisdiction's tax engine computes tax(w); the portfolio drops by w + tax(w). 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.

{RETURNS_MODE_NOTES.shiller} {RETURNS_MODE_NOTES.manual} {RETURNS_MODE_NOTES.wealthfolio}

Each path resamples blocks independently so sequence-of-returns risk is preserved. Long-run benchmarks for context: 60/40 real ≈ 4.6%; equities ~9.5% nominal / 17% volatility; bonds ~5%/8%.

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.

); } function Section({ title, children }: { title: string; children: React.ReactNode }) { return (

{title}

{children}
); } function Term({ name, children }: { name: string; children: React.ReactNode }) { return (
{name}.{' '} {children}
); } function Results({ result, horizon }: { result: SimulateResult; horizon: number }) { return ( <>

Portfolio fan

); } function Stat({ label, value, accent, }: { label: string; value: string | number; accent?: boolean; }) { return (
{label}
{value}
); } function Field({ label, hint, children, }: { label: string; hint?: string; children: React.ReactNode; }) { return ( ); } function Select({ value, onChange, options, }: { value: string; onChange: (v: string) => void; options: string[]; }) { return ( ); } function NumberInput({ value, onChange, min, max, step = 1, }: { value: number; onChange: (v: number) => void; min?: number; max?: number; step?: number; }) { return ( { 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" /> ); }