/** * 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 = { 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 = { 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, }; 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(); 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 = (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} /> 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) => ( ))} ); } 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" /> ); }