whatif: drop glide-path, compact form into 4 sections
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
The What-If form was a 14-field stack with always-visible hint paragraphs — ~1500px scroll before "Run". The user is single-allocation (100% stocks), so the glide-path knob was noise. Hardcoded `static(1.0)` at the API layer; dropped `glide_path` from `SimulateRequest` (extra field on persisted Scenario rows still honoured for Cartesian sweeps). Frontend reorganised into anchor numbers (NW / spend / horizon at text-2xl), a Plan card (jurisdiction + leave-UK + strategy chips + conditional Floor/Custom sub-card), a Returns card (3-chip segmented control with inline manual %), and a folded Advanced section (savings, MC paths, seed). Verbose hints moved into ⓘ popovers next to each label. Two new primitives: SegmentedControl + InfoTip. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
f43322e5ce
commit
1d347ff65b
7 changed files with 501 additions and 299 deletions
|
|
@ -213,11 +213,16 @@ class LifeEventInput(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class SimulateRequest(BaseModel):
|
class SimulateRequest(BaseModel):
|
||||||
"""Sync, non-persisted simulate. Used by the React UI for what-if."""
|
"""Sync, non-persisted simulate. Used by the React UI for what-if.
|
||||||
|
|
||||||
|
Allocation is hardcoded to 100% stocks at the engine layer
|
||||||
|
(`api/simulate.py::_project`). The UI removed the glide-path knob
|
||||||
|
in 2026-05; persisted Cartesian scenarios still carry their own
|
||||||
|
`glide_path` string on the `scenario` table.
|
||||||
|
"""
|
||||||
jurisdiction: str
|
jurisdiction: str
|
||||||
strategy: str
|
strategy: str
|
||||||
leave_uk_year: int = Field(ge=0, le=60)
|
leave_uk_year: int = Field(ge=0, le=60)
|
||||||
glide_path: str = "rising"
|
|
||||||
spending_gbp: Decimal = Field(gt=0)
|
spending_gbp: Decimal = Field(gt=0)
|
||||||
nw_seed_gbp: Decimal = Field(ge=0)
|
nw_seed_gbp: Decimal = Field(ge=0)
|
||||||
savings_per_year_gbp: Decimal = Decimal("0")
|
savings_per_year_gbp: Decimal = Decimal("0")
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ from fire_planner.api.schemas import (
|
||||||
SimulateRequest,
|
SimulateRequest,
|
||||||
SimulateResult,
|
SimulateResult,
|
||||||
)
|
)
|
||||||
from fire_planner.glide_path import get as get_glide
|
from fire_planner.glide_path import static
|
||||||
from fire_planner.ingest.wealthfolio_pg import create_wf_sync_engine_from_env
|
from fire_planner.ingest.wealthfolio_pg import create_wf_sync_engine_from_env
|
||||||
from fire_planner.life_events import EventInput, events_to_cashflow_array
|
from fire_planner.life_events import EventInput, events_to_cashflow_array
|
||||||
from fire_planner.returns.bootstrap import block_bootstrap
|
from fire_planner.returns.bootstrap import block_bootstrap
|
||||||
|
|
@ -119,7 +119,7 @@ def _project(req: SimulateRequest, paths: np.ndarray) -> tuple[SimulationResult,
|
||||||
paths=paths,
|
paths=paths,
|
||||||
initial_portfolio=float(req.nw_seed_gbp),
|
initial_portfolio=float(req.nw_seed_gbp),
|
||||||
spending_target=float(req.spending_gbp),
|
spending_target=float(req.spending_gbp),
|
||||||
glide=get_glide(req.glide_path),
|
glide=static(1.0),
|
||||||
strategy=strategy,
|
strategy=strategy,
|
||||||
regime=build_regime_schedule(req.jurisdiction, req.leave_uk_year),
|
regime=build_regime_schedule(req.jurisdiction, req.leave_uk_year),
|
||||||
horizon_years=req.horizon_years,
|
horizon_years=req.horizon_years,
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,6 @@ export interface SimulateRequest {
|
||||||
jurisdiction: string;
|
jurisdiction: string;
|
||||||
strategy: string;
|
strategy: string;
|
||||||
leave_uk_year: number;
|
leave_uk_year: number;
|
||||||
glide_path: string;
|
|
||||||
spending_gbp: string;
|
spending_gbp: string;
|
||||||
nw_seed_gbp: string;
|
nw_seed_gbp: string;
|
||||||
savings_per_year_gbp?: string;
|
savings_per_year_gbp?: string;
|
||||||
|
|
|
||||||
40
frontend/src/components/InfoTip.tsx
Normal file
40
frontend/src/components/InfoTip.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
/**
|
||||||
|
* Small ⓘ button that reveals a popover hint on hover or focus. Used to
|
||||||
|
* keep field labels short while still surfacing the long explanation
|
||||||
|
* for the curious. Click also toggles (mobile / no-hover devices).
|
||||||
|
*/
|
||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: ReactNode;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfoTip({ text, label = 'Info' }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<span className="relative inline-flex align-middle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
onMouseEnter={() => setOpen(true)}
|
||||||
|
onMouseLeave={() => setOpen(false)}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onBlur={() => setOpen(false)}
|
||||||
|
className="inline-flex h-4 w-4 items-center justify-center rounded-full border border-slate-300 bg-white text-[10px] font-semibold text-slate-500 hover:text-slate-900 hover:border-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||||
|
>
|
||||||
|
i
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<span
|
||||||
|
role="tooltip"
|
||||||
|
className="absolute left-1/2 top-full z-20 mt-2 w-72 -translate-x-1/2 rounded-md border border-slate-200 bg-white p-3 text-xs leading-snug text-slate-700 shadow-lg"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
frontend/src/components/SegmentedControl.tsx
Normal file
59
frontend/src/components/SegmentedControl.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
/**
|
||||||
|
* One-row chip picker. All options visible at once, current pinned with a
|
||||||
|
* darker bg + ring. Use for low-cardinality enums where a `<select>`
|
||||||
|
* hides the alternatives.
|
||||||
|
*/
|
||||||
|
import { type ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface SegmentedOption<T extends string> {
|
||||||
|
value: T;
|
||||||
|
label: ReactNode;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props<T extends string> {
|
||||||
|
value: T;
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
options: ReadonlyArray<SegmentedOption<T>>;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SegmentedControl<T extends string>({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
size = 'md',
|
||||||
|
className = '',
|
||||||
|
}: Props<T>) {
|
||||||
|
const pad = size === 'sm' ? 'px-2.5 py-1 text-xs' : 'px-3 py-1.5 text-sm';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
className={`inline-flex flex-wrap gap-1 rounded-lg bg-slate-100 p-1 ${className}`}
|
||||||
|
>
|
||||||
|
{options.map((opt) => {
|
||||||
|
const active = opt.value === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={active}
|
||||||
|
title={opt.title}
|
||||||
|
onClick={() => onChange(opt.value)}
|
||||||
|
className={[
|
||||||
|
'rounded-md font-medium transition-colors whitespace-nowrap',
|
||||||
|
pad,
|
||||||
|
active
|
||||||
|
? 'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200'
|
||||||
|
: 'text-slate-600 hover:text-slate-900 hover:bg-white/60',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -60,7 +60,6 @@ export function ScenarioDetail() {
|
||||||
jurisdiction: s.jurisdiction,
|
jurisdiction: s.jurisdiction,
|
||||||
strategy: s.strategy,
|
strategy: s.strategy,
|
||||||
leave_uk_year: s.leave_uk_year,
|
leave_uk_year: s.leave_uk_year,
|
||||||
glide_path: s.glide_path,
|
|
||||||
spending_gbp: s.spending_gbp,
|
spending_gbp: s.spending_gbp,
|
||||||
nw_seed_gbp: s.nw_seed_gbp,
|
nw_seed_gbp: s.nw_seed_gbp,
|
||||||
savings_per_year_gbp: s.savings_per_year_gbp,
|
savings_per_year_gbp: s.savings_per_year_gbp,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
/**
|
/**
|
||||||
* What-If — interactive Monte Carlo. Form on the left, fan chart on the
|
* What-If — interactive Monte Carlo. Compact form on the left, fan
|
||||||
* right. Hits POST /simulate (no DB write); ~1-3s for 5k paths.
|
* 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
@ -8,54 +18,54 @@ import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { api, type SimulateRequest, type SimulateResult } from '@/api/client';
|
import { api, type SimulateRequest, type SimulateResult } from '@/api/client';
|
||||||
import { FanChart } from '@/components/FanChart';
|
import { FanChart } from '@/components/FanChart';
|
||||||
|
import { InfoTip } from '@/components/InfoTip';
|
||||||
|
import { SegmentedControl, type SegmentedOption } from '@/components/SegmentedControl';
|
||||||
import { gbp, pct } from '@/lib/format';
|
import { gbp, pct } from '@/lib/format';
|
||||||
|
|
||||||
const JURISDICTIONS = ['uk', 'cyprus', 'bulgaria', 'malaysia', 'thailand', 'uae', 'nomad'];
|
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> = {
|
type Strategy = 'trinity' | 'guyton_klinger' | 'vpw' | 'vpw_floor' | 'custom';
|
||||||
shiller: 'Historical (Shiller 1871+)',
|
type ReturnsMode = 'shiller' | 'manual' | 'wealthfolio';
|
||||||
manual: 'Manual real return %',
|
|
||||||
wealthfolio: 'My Wealthfolio history',
|
const STRATEGY_OPTIONS: ReadonlyArray<SegmentedOption<Strategy>> = [
|
||||||
|
{ 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<SegmentedOption<ReturnsMode>> = [
|
||||||
|
{ value: 'shiller', label: 'Historical' },
|
||||||
|
{ value: 'manual', label: 'Manual %' },
|
||||||
|
{ value: 'wealthfolio', label: 'Wealthfolio' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STRATEGY_NOTES: Record<Strategy, string> = {
|
||||||
|
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_MODE_NOTES: Record<string, string> = {
|
const RETURNS_NOTES: Record<ReturnsMode, string> = {
|
||||||
shiller:
|
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.',
|
'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:
|
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%?").',
|
'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:
|
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.',
|
'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.',
|
||||||
};
|
|
||||||
|
|
||||||
// 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> = {
|
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.',
|
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.',
|
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).',
|
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.',
|
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).',
|
thailand: 'Foreign-sourced income exempt (v1; the 2024 remittance rule is deferred in this model).',
|
||||||
|
|
@ -67,7 +77,6 @@ const DEFAULTS: SimulateRequest = {
|
||||||
jurisdiction: 'cyprus',
|
jurisdiction: 'cyprus',
|
||||||
strategy: 'guyton_klinger',
|
strategy: 'guyton_klinger',
|
||||||
leave_uk_year: 2,
|
leave_uk_year: 2,
|
||||||
glide_path: 'rising',
|
|
||||||
spending_gbp: '60000',
|
spending_gbp: '60000',
|
||||||
nw_seed_gbp: '1500000',
|
nw_seed_gbp: '1500000',
|
||||||
savings_per_year_gbp: '0',
|
savings_per_year_gbp: '0',
|
||||||
|
|
@ -90,13 +99,10 @@ export function WhatIf() {
|
||||||
|
|
||||||
// Pre-fill NW seed from the latest Wealthfolio snapshot the first
|
// Pre-fill NW seed from the latest Wealthfolio snapshot the first
|
||||||
// time it loads, so opening /what-if always starts from real numbers.
|
// 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
|
// The user can still edit; we won't clobber their input on later refetches.
|
||||||
// refetches.
|
|
||||||
const nw = useQuery({ queryKey: ['networth', 'current'], queryFn: api.networth.current });
|
const nw = useQuery({ queryKey: ['networth', 'current'], queryFn: api.networth.current });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (nwAutoFilled || !nw.data || nw.data.accounts.length === 0) return;
|
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)));
|
const rounded = String(Math.round(Number(nw.data.total_gbp)));
|
||||||
setForm((f) => ({ ...f, nw_seed_gbp: rounded }));
|
setForm((f) => ({ ...f, nw_seed_gbp: rounded }));
|
||||||
setNwAutoFilled(true);
|
setNwAutoFilled(true);
|
||||||
|
|
@ -113,7 +119,7 @@ export function WhatIf() {
|
||||||
jurisdiction: form.jurisdiction,
|
jurisdiction: form.jurisdiction,
|
||||||
strategy: form.strategy,
|
strategy: form.strategy,
|
||||||
leave_uk_year: form.leave_uk_year,
|
leave_uk_year: form.leave_uk_year,
|
||||||
glide_path: form.glide_path,
|
glide_path: 'static_60_40',
|
||||||
spending_gbp: form.spending_gbp,
|
spending_gbp: form.spending_gbp,
|
||||||
nw_seed_gbp: form.nw_seed_gbp,
|
nw_seed_gbp: form.nw_seed_gbp,
|
||||||
savings_per_year_gbp: form.savings_per_year_gbp,
|
savings_per_year_gbp: form.savings_per_year_gbp,
|
||||||
|
|
@ -133,8 +139,6 @@ export function WhatIf() {
|
||||||
floor_gbp: form.strategy === 'vpw_floor' ? form.floor_gbp : null,
|
floor_gbp: form.strategy === 'vpw_floor' ? form.floor_gbp : null,
|
||||||
manual_real_return_pct:
|
manual_real_return_pct:
|
||||||
form.returns_mode === 'manual' ? form.manual_real_return_pct : null,
|
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',
|
annual_real_adjust_pct: isCustom ? form.annual_real_adjust_pct : '0',
|
||||||
guardrail_threshold_pct: isCustom ? form.guardrail_threshold_pct : null,
|
guardrail_threshold_pct: isCustom ? form.guardrail_threshold_pct : null,
|
||||||
guardrail_cut_pct: isCustom ? form.guardrail_cut_pct : '0.10',
|
guardrail_cut_pct: isCustom ? form.guardrail_cut_pct : '0.10',
|
||||||
|
|
@ -151,186 +155,32 @@ export function WhatIf() {
|
||||||
const update = <K extends keyof SimulateRequest>(key: K, value: SimulateRequest[K]) =>
|
const update = <K extends keyof SimulateRequest>(key: K, value: SimulateRequest[K]) =>
|
||||||
setForm((f) => ({ ...f, [key]: value }));
|
setForm((f) => ({ ...f, [key]: value }));
|
||||||
|
|
||||||
|
const strategy = form.strategy as Strategy;
|
||||||
|
const returnsMode = (form.returns_mode ?? 'shiller') as ReturnsMode;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
<header>
|
<header>
|
||||||
<h1 className="text-3xl font-semibold tracking-tight">What if…</h1>
|
<h1 className="text-3xl font-semibold tracking-tight">What if…</h1>
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
Run a single Monte Carlo against the engine. No data persisted.
|
Run a single Monte Carlo against the engine. No data persisted. 100% stocks.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-6 items-start">
|
<div className="grid grid-cols-1 lg:grid-cols-[420px_1fr] gap-6 items-start">
|
||||||
<form
|
<form onSubmit={onSubmit} className="space-y-4 sticky top-4">
|
||||||
onSubmit={onSubmit}
|
<AnchorNumbers form={form} update={update} />
|
||||||
className="rounded-lg border border-slate-200 bg-white p-5 space-y-4 sticky top-4"
|
|
||||||
>
|
<PlanCard form={form} update={update} strategy={strategy} />
|
||||||
<Field label="Jurisdiction" hint={JURISDICTION_NOTES[form.jurisdiction]}>
|
|
||||||
<Select
|
<ReturnsCard form={form} update={update} returnsMode={returnsMode} />
|
||||||
value={form.jurisdiction}
|
|
||||||
onChange={(v) => update('jurisdiction', v)}
|
<AdvancedCard form={form} update={update} />
|
||||||
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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={sim.isPending}
|
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"
|
className="w-full rounded-lg bg-slate-900 text-white text-sm font-semibold px-4 py-3 hover:bg-slate-800 disabled:opacity-60 disabled:cursor-not-allowed shadow-sm"
|
||||||
>
|
>
|
||||||
{sim.isPending ? 'Running…' : 'Run simulation'}
|
{sim.isPending ? 'Running…' : 'Run simulation'}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -380,6 +230,327 @@ export function WhatIf() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sections ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AnchorNumbers({
|
||||||
|
form,
|
||||||
|
update,
|
||||||
|
}: {
|
||||||
|
form: SimulateRequest;
|
||||||
|
update: <K extends keyof SimulateRequest>(k: K, v: SimulateRequest[K]) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<BigNumber
|
||||||
|
label="NW seed"
|
||||||
|
prefix="£"
|
||||||
|
value={form.nw_seed_gbp}
|
||||||
|
onChange={(v) => update('nw_seed_gbp', v)}
|
||||||
|
/>
|
||||||
|
<BigNumber
|
||||||
|
label="Annual spending"
|
||||||
|
prefix="£"
|
||||||
|
value={form.spending_gbp}
|
||||||
|
onChange={(v) => update('spending_gbp', v)}
|
||||||
|
/>
|
||||||
|
<BigNumber
|
||||||
|
label="Horizon"
|
||||||
|
suffix="yrs"
|
||||||
|
value={String(form.horizon_years ?? 60)}
|
||||||
|
onChange={(v) => update('horizon_years', clampInt(v, 5, 100))}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlanCard({
|
||||||
|
form,
|
||||||
|
update,
|
||||||
|
strategy,
|
||||||
|
}: {
|
||||||
|
form: SimulateRequest;
|
||||||
|
update: <K extends keyof SimulateRequest>(k: K, v: SimulateRequest[K]) => void;
|
||||||
|
strategy: Strategy;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<SmallLabel
|
||||||
|
text="Jurisdiction"
|
||||||
|
tip={JURISDICTION_NOTES[form.jurisdiction]}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={form.jurisdiction}
|
||||||
|
onChange={(e) => update('jurisdiction', e.target.value)}
|
||||||
|
className="mt-1 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"
|
||||||
|
>
|
||||||
|
{JURISDICTIONS.map((j) => (
|
||||||
|
<option key={j} value={j}>
|
||||||
|
{j}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<SmallLabel text="Years until leaving UK" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.leave_uk_year}
|
||||||
|
min={0}
|
||||||
|
max={60}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SmallLabel text="Strategy" tip={STRATEGY_NOTES[strategy]} />
|
||||||
|
<div className="mt-1">
|
||||||
|
<SegmentedControl<Strategy>
|
||||||
|
value={strategy}
|
||||||
|
onChange={(v) => update('strategy', v)}
|
||||||
|
options={STRATEGY_OPTIONS}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{strategy === 'vpw_floor' && (
|
||||||
|
<div>
|
||||||
|
<SmallLabel
|
||||||
|
text="Floor (£/yr)"
|
||||||
|
tip="Hard minimum withdrawal — VPW never dips below this even when the schedule says it should."
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={Number(form.floor_gbp ?? 40000)}
|
||||||
|
min={0}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{strategy === 'custom' && (
|
||||||
|
<div className="rounded-md bg-slate-50 border border-slate-200 p-3 grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<SmallLabel
|
||||||
|
text="Real adjust %/yr"
|
||||||
|
tip="0 = constant real £ (Trinity shape). Positive grows spending each year (e.g. 0.02 = +2%/yr for healthcare). Negative shrinks (e.g. -0.005 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="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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<SmallLabel
|
||||||
|
text="Guardrail trigger"
|
||||||
|
tip="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="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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<SmallLabel
|
||||||
|
text="Guardrail cut"
|
||||||
|
tip="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="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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReturnsCard({
|
||||||
|
form,
|
||||||
|
update,
|
||||||
|
returnsMode,
|
||||||
|
}: {
|
||||||
|
form: SimulateRequest;
|
||||||
|
update: <K extends keyof SimulateRequest>(k: K, v: SimulateRequest[K]) => void;
|
||||||
|
returnsMode: ReturnsMode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
|
<SmallLabel text="Returns model" tip={RETURNS_NOTES[returnsMode]} />
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-3">
|
||||||
|
<SegmentedControl<ReturnsMode>
|
||||||
|
value={returnsMode}
|
||||||
|
onChange={(v) => update('returns_mode', v)}
|
||||||
|
options={RETURNS_OPTIONS}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
{returnsMode === 'manual' && (
|
||||||
|
<label className="flex items-center gap-2 text-xs text-slate-600">
|
||||||
|
<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-24 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"
|
||||||
|
/>
|
||||||
|
<span>real / yr (e.g. 0.046 = 4.6%)</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdvancedCard({
|
||||||
|
form,
|
||||||
|
update,
|
||||||
|
}: {
|
||||||
|
form: SimulateRequest;
|
||||||
|
update: <K extends keyof SimulateRequest>(k: K, v: SimulateRequest[K]) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<details className="rounded-lg border border-slate-200 bg-white shadow-sm group">
|
||||||
|
<summary className="cursor-pointer select-none px-4 py-3 text-sm font-medium text-slate-700 flex items-center justify-between hover:bg-slate-50 rounded-lg">
|
||||||
|
<span>Advanced</span>
|
||||||
|
<span className="text-xs text-slate-400 group-open:rotate-180 transition-transform">▾</span>
|
||||||
|
</summary>
|
||||||
|
<div className="px-4 pb-4 pt-1 grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<SmallLabel
|
||||||
|
text="Annual savings"
|
||||||
|
tip="Real-£ added each year (e.g. while still working). 0 = pure decumulation."
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={Number(form.savings_per_year_gbp ?? 0)}
|
||||||
|
min={0}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<SmallLabel
|
||||||
|
text="MC paths"
|
||||||
|
tip="More paths = tighter percentile estimates but slower. 5k is a sweet spot; 50k for final."
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.n_paths ?? 5000}
|
||||||
|
min={100}
|
||||||
|
max={50000}
|
||||||
|
step={100}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<SmallLabel text="Seed" tip="Same seed = same fan. Change to verify a result is robust." />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.seed ?? 42}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Primitives ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SmallLabel({ text, tip }: { text: string; tip?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[11px] uppercase tracking-wide text-slate-500 font-medium">{text}</span>
|
||||||
|
{tip && <InfoTip text={tip} label={text} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BigNumber({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
prefix,
|
||||||
|
suffix,
|
||||||
|
step = 1,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
prefix?: string;
|
||||||
|
suffix?: string;
|
||||||
|
step?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-[11px] uppercase tracking-wide text-slate-500 font-medium">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<div className="mt-1 relative">
|
||||||
|
{prefix && (
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm pointer-events-none">
|
||||||
|
{prefix}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
step={step}
|
||||||
|
min={0}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className={`w-full rounded-md border border-slate-300 bg-white py-2 text-2xl font-semibold tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400 ${
|
||||||
|
prefix ? 'pl-7' : 'pl-3'
|
||||||
|
} ${suffix ? 'pr-12' : 'pr-3'}`}
|
||||||
|
/>
|
||||||
|
{suffix && (
|
||||||
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm pointer-events-none">
|
||||||
|
{suffix}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
function AboutTheModel() {
|
||||||
return (
|
return (
|
||||||
<details className="rounded-lg border border-slate-200 bg-white p-5 group">
|
<details className="rounded-lg border border-slate-200 bg-white p-5 group">
|
||||||
|
|
@ -395,10 +566,6 @@ function AboutTheModel() {
|
||||||
<Term name="VPW + floor">{STRATEGY_NOTES.vpw_floor}</Term>
|
<Term name="VPW + floor">{STRATEGY_NOTES.vpw_floor}</Term>
|
||||||
<Term name="Custom spending plan">{STRATEGY_NOTES.custom}</Term>
|
<Term name="Custom spending plan">{STRATEGY_NOTES.custom}</Term>
|
||||||
</Section>
|
</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">
|
<Section title="Tax jurisdictions">
|
||||||
<Term name="UK">{JURISDICTION_NOTES.uk}</Term>
|
<Term name="UK">{JURISDICTION_NOTES.uk}</Term>
|
||||||
<Term name="Cyprus">{JURISDICTION_NOTES.cyprus}</Term>
|
<Term name="Cyprus">{JURISDICTION_NOTES.cyprus}</Term>
|
||||||
|
|
@ -418,15 +585,22 @@ function AboutTheModel() {
|
||||||
</p>
|
</p>
|
||||||
</Section>
|
</Section>
|
||||||
<Section title="Returns model">
|
<Section title="Returns model">
|
||||||
<Term name="Historical (Shiller 1871+)">{RETURNS_MODE_NOTES.shiller}</Term>
|
<Term name="Historical (Shiller 1871+)">{RETURNS_NOTES.shiller}</Term>
|
||||||
<Term name="Manual real return">{RETURNS_MODE_NOTES.manual}</Term>
|
<Term name="Manual real return">{RETURNS_NOTES.manual}</Term>
|
||||||
<Term name="My Wealthfolio history">{RETURNS_MODE_NOTES.wealthfolio}</Term>
|
<Term name="My Wealthfolio history">{RETURNS_NOTES.wealthfolio}</Term>
|
||||||
<p className="pt-1">
|
<p className="pt-1">
|
||||||
Each path resamples blocks independently so sequence-of-returns risk is preserved.
|
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 /
|
Long-run benchmarks for context: 60/40 real ≈ 4.6%; equities ~9.5% nominal /
|
||||||
17% volatility; bonds ~5%/8%.
|
17% volatility; bonds ~5%/8%.
|
||||||
</p>
|
</p>
|
||||||
</Section>
|
</Section>
|
||||||
|
<Section title="Allocation">
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
<Section title="Success rate">
|
<Section title="Success rate">
|
||||||
<p>
|
<p>
|
||||||
A path counts as a success if the portfolio stays positive through every interim
|
A path counts as a success if the portfolio stays positive through every interim
|
||||||
|
|
@ -466,10 +640,7 @@ function Results({ result, horizon }: { result: SimulateResult; horizon: number
|
||||||
<Stat label="P10 ending" value={gbp(result.p10_ending_gbp)} />
|
<Stat label="P10 ending" value={gbp(result.p10_ending_gbp)} />
|
||||||
<Stat label="P90 ending" value={gbp(result.p90_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 lifetime tax" value={gbp(result.median_lifetime_tax_gbp)} />
|
||||||
<Stat
|
<Stat label="Median years to ruin" value={result.median_years_to_ruin ?? 'never'} />
|
||||||
label="Median years to ruin"
|
|
||||||
value={result.median_years_to_ruin ?? 'never'}
|
|
||||||
/>
|
|
||||||
<Stat label="Horizon" value={`${horizon} years`} />
|
<Stat label="Horizon" value={`${horizon} years`} />
|
||||||
<Stat label="Engine time" value={`${result.elapsed_seconds}s`} />
|
<Stat label="Engine time" value={`${result.elapsed_seconds}s`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -503,74 +674,3 @@ function Stat({
|
||||||
</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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue