whatif: contextual hints + collapsible "About the model" panel
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
The user kept asking what each strategy means. Adding two layers: 1. Inline hint under each dropdown (Jurisdiction, Strategy, Glide path) — short one-liner that updates as the selected option changes. So no clicking required to see what trinity vs guyton_klinger does. 2. <details> panel at the bottom: "About the model" with all strategies, glide paths, jurisdictions described in plain English, plus a note on how the engine treats tax (post-fix: drain = w + tax(w)) and the returns model (Shiller block bootstrap, 60/40 ≈ 4.6% real long run). Plain-English originals — Trinity / Guyton-Klinger / VPW are public-domain finance concepts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
7602f9040e
commit
f2c36bc4a3
1 changed files with 120 additions and 4 deletions
|
|
@ -14,6 +14,37 @@ const JURISDICTIONS = ['uk', 'cyprus', 'bulgaria', 'malaysia', 'thailand', 'uae'
|
|||
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',
|
||||
|
|
@ -103,21 +134,21 @@ export function WhatIf() {
|
|||
onSubmit={onSubmit}
|
||||
className="rounded-lg border border-slate-200 bg-white p-5 space-y-4 sticky top-4"
|
||||
>
|
||||
<Field label="Jurisdiction">
|
||||
<Field label="Jurisdiction" hint={JURISDICTION_NOTES[form.jurisdiction]}>
|
||||
<Select
|
||||
value={form.jurisdiction}
|
||||
onChange={(v) => update('jurisdiction', v)}
|
||||
options={JURISDICTIONS}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Strategy">
|
||||
<Field label="Strategy" hint={STRATEGY_NOTES[form.strategy]}>
|
||||
<Select
|
||||
value={form.strategy}
|
||||
onChange={(v) => update('strategy', v)}
|
||||
options={STRATEGIES}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Glide path">
|
||||
<Field label="Glide path" hint={GLIDE_NOTES[form.glide_path]}>
|
||||
<Select
|
||||
value={form.glide_path}
|
||||
onChange={(v) => update('glide_path', v)}
|
||||
|
|
@ -225,12 +256,88 @@ export function WhatIf() {
|
|||
</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 (
|
||||
<>
|
||||
|
|
@ -278,11 +385,20 @@ function Stat({
|
|||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue