From bb74bc0add80c3b16b9b4e6c769e4f5bc70956de Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 9 May 2026 22:08:00 +0000 Subject: [PATCH] frontend: What-If page with fan chart driven by /simulate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /what-if route. Sticky form on the left (jurisdiction, strategy, glide, NW seed, spending, savings, horizon, optional floor for vpw_floor, MC paths) submits to POST /simulate; results panel renders summary stats + the new FanChart. FanChart component layers seven series: - p10 invisible baseline (line, transparent) - p10→p25 stacked area (low opacity) - p25→p75 stacked area (IQR, mid opacity) - p75→p90 stacked area (low opacity) - p50 solid median line (drawn last, prominent) - p10 + p90 dashed lines on top of the bands Stacking deltas keeps the band fills clean — plotting raw quantiles each as their own area would overlap badly. Reusable by scenario detail in the next chunk (same ProjectionPoint[] shape). 5 tests pass (was 2). 470 KB gzipped (ECharts). Co-Authored-By: Claude Opus 4.7 --- frontend/src/App.tsx | 30 ++- frontend/src/components/FanChart.test.tsx | 41 ++++ frontend/src/components/FanChart.tsx | 202 ++++++++++++++++ frontend/src/pages/WhatIf.tsx | 281 ++++++++++++++++++++++ 4 files changed, 550 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/FanChart.test.tsx create mode 100644 frontend/src/components/FanChart.tsx create mode 100644 frontend/src/pages/WhatIf.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e28d9bf..1416624 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,9 @@ import { useQuery } from '@tanstack/react-query'; -import { Route, Routes, Link } from 'react-router-dom'; +import { NavLink, Route, Routes, Link } from 'react-router-dom'; import { api } from '@/api/client'; import { Dashboard } from '@/pages/Dashboard'; +import { WhatIf } from '@/pages/WhatIf'; export function App() { const health = useQuery({ queryKey: ['health'], queryFn: api.health }); @@ -11,9 +12,15 @@ export function App() {
- - fire-planner - +
+ + fire-planner + + +
api:{' '} @@ -29,8 +36,23 @@ export function App() {
} /> + } />
); } + +function NavTab({ to, children }: { to: string; children: React.ReactNode }) { + return ( + + `${isActive ? 'text-slate-900 font-medium' : 'text-slate-500 hover:text-slate-800'}` + } + > + {children} + + ); +} diff --git a/frontend/src/components/FanChart.test.tsx b/frontend/src/components/FanChart.test.tsx new file mode 100644 index 0000000..b441d01 --- /dev/null +++ b/frontend/src/components/FanChart.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; + +import { FanChart } from './FanChart'; +import type { ProjectionPoint } from '@/api/client'; + +vi.mock('echarts-for-react', () => ({ + default: ({ option }: { option: { series?: unknown[] } }) => ( +
+ ), +})); + +const point = (year: number, p50 = 1_000_000): ProjectionPoint => ({ + year_idx: year, + p10_portfolio_gbp: String(p50 * 0.7), + p25_portfolio_gbp: String(p50 * 0.85), + p50_portfolio_gbp: String(p50), + p75_portfolio_gbp: String(p50 * 1.15), + p90_portfolio_gbp: String(p50 * 1.3), + p50_withdrawal_gbp: '60000', + p50_tax_gbp: '8000', + survival_rate: '1.0', +}); + +describe('FanChart', () => { + it('shows empty state when there are no points', () => { + render(); + expect(screen.getByText('No projection data.')).toBeInTheDocument(); + }); + + it('renders 7 base series for fan + median + p10/p90 lines', () => { + render(); + const chart = screen.getByTestId('chart'); + expect(chart.dataset.seriesCount).toBe('7'); + }); + + it('adds an 8th series when showWithdrawal is on', () => { + render(); + expect(screen.getByTestId('chart').dataset.seriesCount).toBe('8'); + }); +}); diff --git a/frontend/src/components/FanChart.tsx b/frontend/src/components/FanChart.tsx new file mode 100644 index 0000000..7db9d41 --- /dev/null +++ b/frontend/src/components/FanChart.tsx @@ -0,0 +1,202 @@ +/** + * Monte Carlo fan chart — confidence bands (p10/p25/p75/p90) + median line. + * + * Renders five layered series in ECharts: + * 1. p10 invisible baseline (line, transparent, no fill) + * 2. (p25 - p10) band stacks on p10 with a low-opacity fill + * 3. (p75 - p25) band stacks on top with mid-opacity (the inter-quartile) + * 4. (p90 - p75) band stacks on top with low-opacity (upper tail) + * 5. p50 median standalone solid line, drawn last so it sits above + * + * Stacking the deltas instead of plotting raw quantiles keeps the band + * fills clean — ECharts area renderers fill to the *baseline* by default, + * which would overlap badly if we just plotted p10/p25/p50/p75/p90 lines + * each with their own area. + */ +import { useMemo } from 'react'; +import ReactECharts from 'echarts-for-react'; +import type { EChartsOption } from 'echarts'; + +import type { ProjectionPoint } from '@/api/client'; +import { gbpCompact } from '@/lib/format'; + +interface Props { + yearly: ProjectionPoint[]; + height?: number; + showWithdrawal?: boolean; +} + +export function FanChart({ yearly, height = 360, showWithdrawal = false }: Props) { + const option = useMemo(() => buildFan(yearly, showWithdrawal), [ + yearly, + showWithdrawal, + ]); + if (yearly.length === 0) { + return

No projection data.

; + } + return ; +} + +function buildFan(yearly: ProjectionPoint[], showWithdrawal: boolean): EChartsOption { + const years = yearly.map((p) => p.year_idx); + const p10 = yearly.map((p) => num(p.p10_portfolio_gbp)); + const p25 = yearly.map((p) => num(p.p25_portfolio_gbp)); + const p50 = yearly.map((p) => num(p.p50_portfolio_gbp)); + const p75 = yearly.map((p) => num(p.p75_portfolio_gbp)); + const p90 = yearly.map((p) => num(p.p90_portfolio_gbp)); + const wd = yearly.map((p) => num(p.p50_withdrawal_gbp)); + + // Deltas — what each band stacks on top of the previous. + const d_25_10 = p25.map((v, i) => Math.max(0, v - p10[i]!)); + const d_75_25 = p75.map((v, i) => Math.max(0, v - p25[i]!)); + const d_90_75 = p90.map((v, i) => Math.max(0, v - p75[i]!)); + + const series: EChartsOption['series'] = [ + { + name: 'p10', + type: 'line', + stack: 'fan', + data: p10, + lineStyle: { opacity: 0 }, + itemStyle: { opacity: 0 }, + symbol: 'none', + tooltip: { show: false }, + }, + { + name: 'p10–p25', + type: 'line', + stack: 'fan', + data: d_25_10, + lineStyle: { opacity: 0 }, + symbol: 'none', + areaStyle: { color: 'rgba(80, 110, 230, 0.12)' }, + tooltip: { show: false }, + }, + { + name: 'p25–p75 (IQR)', + type: 'line', + stack: 'fan', + data: d_75_25, + lineStyle: { opacity: 0 }, + symbol: 'none', + areaStyle: { color: 'rgba(80, 110, 230, 0.28)' }, + tooltip: { show: false }, + }, + { + name: 'p75–p90', + type: 'line', + stack: 'fan', + data: d_90_75, + lineStyle: { opacity: 0 }, + symbol: 'none', + areaStyle: { color: 'rgba(80, 110, 230, 0.12)' }, + tooltip: { show: false }, + }, + { + name: 'median', + type: 'line', + data: p50, + lineStyle: { width: 2, color: 'rgb(40, 70, 200)' }, + itemStyle: { color: 'rgb(40, 70, 200)' }, + symbol: 'none', + smooth: true, + z: 10, + }, + { + name: 'p10', + type: 'line', + data: p10, + lineStyle: { width: 1, color: 'rgba(40, 70, 200, 0.5)', type: 'dashed' }, + symbol: 'none', + smooth: true, + z: 5, + }, + { + name: 'p90', + type: 'line', + data: p90, + lineStyle: { width: 1, color: 'rgba(40, 70, 200, 0.5)', type: 'dashed' }, + symbol: 'none', + smooth: true, + z: 5, + }, + ]; + + if (showWithdrawal) { + series.push({ + name: 'median withdrawal', + type: 'line', + yAxisIndex: 1, + data: wd, + lineStyle: { width: 1, color: 'rgb(190, 90, 60)' }, + symbol: 'none', + smooth: true, + }); + } + + return { + grid: { left: 60, right: showWithdrawal ? 70 : 24, top: 30, bottom: 40 }, + tooltip: { + trigger: 'axis', + formatter: (params) => { + const arr = Array.isArray(params) ? params : [params]; + const year = arr[0]?.name ?? ''; + const idx = years.indexOf(Number(year)); + if (idx < 0) return ''; + return [ + `Year ${year}`, + `p10: ${gbpCompact(p10[idx]!)}`, + `p25: ${gbpCompact(p25[idx]!)}`, + `p50: ${gbpCompact(p50[idx]!)}`, + `p75: ${gbpCompact(p75[idx]!)}`, + `p90: ${gbpCompact(p90[idx]!)}`, + showWithdrawal ? `withdrawal: ${gbpCompact(wd[idx]!)}` : '', + ] + .filter(Boolean) + .join('
'); + }, + }, + legend: { + bottom: 0, + data: [ + 'median', + 'p10', + 'p90', + 'p25–p75 (IQR)', + ...(showWithdrawal ? ['median withdrawal'] : []), + ], + }, + xAxis: { + type: 'category', + data: years, + name: 'years from now', + nameLocation: 'middle', + nameGap: 24, + }, + yAxis: [ + { + type: 'value', + name: 'portfolio (real GBP)', + nameLocation: 'middle', + nameGap: 50, + axisLabel: { formatter: (v: number) => gbpCompact(v) }, + }, + ...(showWithdrawal + ? [ + { + type: 'value' as const, + name: 'withdrawal', + nameLocation: 'middle' as const, + nameGap: 50, + axisLabel: { formatter: (v: number) => gbpCompact(v) }, + }, + ] + : []), + ], + series, + }; +} + +function num(s: string): number { + return Number(s); +} diff --git a/frontend/src/pages/WhatIf.tsx b/frontend/src/pages/WhatIf.tsx new file mode 100644 index 0000000..8b4ec1d --- /dev/null +++ b/frontend/src/pages/WhatIf.tsx @@ -0,0 +1,281 @@ +/** + * 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 } from '@tanstack/react-query'; +import { useState } from 'react'; + +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']; + +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 sim = useMutation({ + mutationFn: (req: SimulateRequest) => api.simulate(req), + }); + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + sim.mutate({ + ...form, + floor_gbp: form.strategy === 'vpw_floor' ? form.floor_gbp : null, + }); + }; + + 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" + /> + ); +}