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>
576 lines
22 KiB
TypeScript
576 lines
22 KiB
TypeScript
/**
|
|
* 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<string, string> = {
|
|
shiller: 'Historical (Shiller 1871+)',
|
|
manual: 'Manual real return %',
|
|
wealthfolio: 'My Wealthfolio history',
|
|
};
|
|
|
|
const RETURNS_MODE_NOTES: Record<string, string> = {
|
|
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<string, string> = {
|
|
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<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,
|
|
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<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();
|
|
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 = <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>
|
|
)}
|
|
{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']}
|
|
>
|
|
<select
|
|
value={form.returns_mode ?? 'shiller'}
|
|
onChange={(e) =>
|
|
update('returns_mode', e.target.value as 'shiller' | 'manual' | 'wealthfolio')
|
|
}
|
|
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"
|
|
>
|
|
{RETURNS_MODES.map((m) => (
|
|
<option key={m} value={m}>
|
|
{RETURNS_MODE_LABELS[m]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
{form.returns_mode === 'manual' && (
|
|
<Field label="Real return % (e.g. 0.046 = 4.6%)">
|
|
<input
|
|
type="number"
|
|
value={form.manual_real_return_pct ?? '0.046'}
|
|
step="0.001"
|
|
min={-0.5}
|
|
max={1}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</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>
|
|
<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>
|
|
<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 model">
|
|
<Term name="Historical (Shiller 1871+)">{RETURNS_MODE_NOTES.shiller}</Term>
|
|
<Term name="Manual real return">{RETURNS_MODE_NOTES.manual}</Term>
|
|
<Term name="My Wealthfolio history">{RETURNS_MODE_NOTES.wealthfolio}</Term>
|
|
<p className="pt-1">
|
|
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%.
|
|
</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"
|
|
/>
|
|
);
|
|
}
|