/** * What-If — interactive Monte Carlo. Compact form on the left, fan * chart on the right. Hits POST /simulate (no DB write); ~1-3s for 5k * paths. * * Layout: anchor numbers (NW / spend / horizon) up top; plan (where / * how / when) and returns model in compact cards; advanced knobs * folded away. Hints live in ⓘ popovers, not always-visible * paragraphs, so the form fits on a typical desktop viewport. * * Allocation is hardcoded to 100% stocks at the API layer — the user * is single-allocation, so the glide-path knob was noise. See * `api/simulate.py::_project`. */ 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 { InfoTip } from '@/components/InfoTip'; import { SegmentedControl, type SegmentedOption } from '@/components/SegmentedControl'; import { gbp, pct } from '@/lib/format'; const JURISDICTIONS = ['uk', 'cyprus', 'bulgaria', 'malaysia', 'thailand', 'uae', 'nomad']; type Strategy = 'trinity' | 'guyton_klinger' | 'vpw' | 'vpw_floor' | 'custom'; type ReturnsMode = 'shiller' | 'manual' | 'wealthfolio'; const STRATEGY_OPTIONS: ReadonlyArray> = [ { value: 'trinity', label: 'Trinity' }, { value: 'guyton_klinger', label: 'Guyton-Klinger' }, { value: 'vpw', label: 'VPW' }, { value: 'vpw_floor', label: 'VPW + floor' }, { value: 'custom', label: 'Custom' }, ]; const RETURNS_OPTIONS: ReadonlyArray> = [ { value: 'shiller', label: 'Historical' }, { value: 'manual', label: 'Manual %' }, { value: 'wealthfolio', label: 'Wealthfolio' }, ]; 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.', guyton_klinger: 'Spending in year 1, then guardrails: cut 10% if implied withdrawal rate exceeds 120% of starting (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. Ignores "Annual spending"; 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.', custom: 'Pick everything: initial spending (above), an annual real-£ adjustment (e.g. -0.5%/yr to spend less with age), and an optional drawdown guardrail.', }; const RETURNS_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.', }; 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, 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; 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: 'static_60_40', 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, 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 })); const strategy = form.strategy as Strategy; const returnsMode = (form.returns_mode ?? 'shiller') as ReturnsMode; return (

What if…

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

{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)} )}
)}
); } // ── Sections ───────────────────────────────────────────────────────── function AnchorNumbers({ form, update, }: { form: SimulateRequest; update: (k: K, v: SimulateRequest[K]) => void; }) { return (
update('nw_seed_gbp', v)} /> update('spending_gbp', v)} /> update('horizon_years', clampInt(v, 5, 100))} step={1} />
); } function PlanCard({ form, update, strategy, }: { form: SimulateRequest; update: (k: K, v: SimulateRequest[K]) => void; strategy: Strategy; }) { return (
update('leave_uk_year', clampInt(e.target.value, 0, 60))} className="mt-1 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" />
value={strategy} onChange={(v) => update('strategy', v)} options={STRATEGY_OPTIONS} size="sm" />
{strategy === 'vpw_floor' && (
update('floor_gbp', e.target.value)} className="mt-1 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" />
)} {strategy === 'custom' && (
update('annual_real_adjust_pct', e.target.value)} className="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-xs tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400" />
update('guardrail_threshold_pct', e.target.value === '' ? null : e.target.value) } className="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-xs tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400" />
update('guardrail_cut_pct', e.target.value)} className="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-xs tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400" />
)}
); } function ReturnsCard({ form, update, returnsMode, }: { form: SimulateRequest; update: (k: K, v: SimulateRequest[K]) => void; returnsMode: ReturnsMode; }) { return (
value={returnsMode} onChange={(v) => update('returns_mode', v)} options={RETURNS_OPTIONS} size="sm" /> {returnsMode === 'manual' && ( )}
); } function AdvancedCard({ form, update, }: { form: SimulateRequest; update: (k: K, v: SimulateRequest[K]) => void; }) { return (
Advanced
update('savings_per_year_gbp', e.target.value)} className="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400" />
update('n_paths', clampInt(e.target.value, 100, 50000))} className="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400" />
update('seed', clampInt(e.target.value, 0, 999999))} className="mt-1 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400" />
); } // ── Primitives ─────────────────────────────────────────────────────── function SmallLabel({ text, tip }: { text: string; tip?: string }) { return (
{text} {tip && }
); } function BigNumber({ label, value, onChange, prefix, suffix, step = 1, }: { label: string; value: string; onChange: (v: string) => void; prefix?: string; suffix?: string; step?: number; }) { return ( ); } function clampInt(raw: string, min: number, max: number): number { const n = Math.round(Number(raw)); if (!Number.isFinite(n)) return min; return Math.min(max, Math.max(min, n)); } // ── Result panel + about ───────────────────────────────────────────── 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}
{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_NOTES.shiller} {RETURNS_NOTES.manual} {RETURNS_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%.

All What-If runs use 100% stocks. The glide-path knob was removed in May 2026 — the user is single-allocation in real life, so simulating 60/40 mixes was noise. Persisted Cartesian scenarios still carry their own glide string.

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}
); }