frontend: What-If page with fan chart driven by /simulate
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 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
/ * *
* What - If — interactive Monte Carlo . Form on the left , fan chart on the
* right . Hits POST / simulate ( no DB write ) ; ~ 1 - 3 s for 5 k paths .
* /
2026-05-10 00:21:14 +00:00
import { useMutation , useQuery , useQueryClient } from '@tanstack/react-query' ;
import { useEffect , useState } from 'react' ;
2026-05-09 22:20:21 +00:00
import { useNavigate } from 'react-router-dom' ;
frontend: What-If page with fan chart driven by /simulate
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 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
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' ] ;
strategies: spending input is honoured + new "Custom" preset with guardrails
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>
2026-05-10 01:21:55 +00:00
const STRATEGIES = [ 'trinity' , 'guyton_klinger' , 'vpw' , 'vpw_floor' , 'custom' ] ;
frontend: What-If page with fan chart driven by /simulate
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 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
const GLIDES = [ 'rising' , 'static_60_40' ] ;
returns: 3 models — Shiller bootstrap (default), manual %, Wealthfolio history
Adds a "Returns model" picker on /what-if that switches how the
simulator's `paths` (n_paths × n_years × 3) is built:
1. shiller (default) — current behaviour, block-bootstrap of the
Shiller 1871+ historical series (or its synthetic-calibrated
fallback when the CSV isn't mounted).
2. manual — every year of every path = the user's "real return %"
input. Deterministic, no fan, useful for sanity checks. New
helper `constant_real_return_paths` constructs the (n_paths,
n_years, 3) tensor with stock=bond=real, cpi=0 so the simulator's
`(1+nominal)/(1+cpi)-1` short-circuits to exactly the input.
3. wealthfolio — pulls daily_account_valuation from the wealthfolio_sync
PG mirror, sums total_value + net_contribution across accounts per
day (FX-adjusted), strips contribution deltas to isolate market
return, compounds daily returns into per-calendar-year samples,
block-bootstraps with block_size=1 (only ~6 distinct samples
available, no serial-correlation signal to preserve). Glide path
is a no-op in this mode — the user's actual blended portfolio is
treated as a single asset.
API: SimulateRequest gains `returns_mode` ("shiller"|"manual"|
"wealthfolio") + `manual_real_return_pct`. simulate.py's `_build_paths`
dispatches; wealthfolio mode opens a transient session against the
mirror DB.
UI: new Field on the form (next to Strategy / Glide path) with a
contextual hint that explains each option's tradeoff. The "About the
model" panel at the bottom now has a "Returns model" section
mirroring the same content. The Manual % input only shows when
returns_mode='manual'.
10 new tests on the Wealthfolio helper (contribution-stripping,
multi-account aggregation, FX, partial-year drop, TOTAL filter,
empty-input, plus 3 deterministic-paths tests). 198 backend tests +
7 frontend tests. mypy strict + ruff + tsc strict all pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 01:04:25 +00:00
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.' ,
} ;
frontend: What-If page with fan chart driven by /simulate
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 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
2026-05-10 00:43:59 +00:00
// 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 :
strategies: spending input is honoured + new "Custom" preset with guardrails
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>
2026-05-10 01:21:55 +00:00
'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).' ,
2026-05-10 00:43:59 +00:00
guyton_klinger :
strategies: spending input is honoured + new "Custom" preset with guardrails
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>
2026-05-10 01:21:55 +00:00
'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.' ,
2026-05-10 00:43:59 +00:00
vpw :
strategies: spending input is honoured + new "Custom" preset with guardrails
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>
2026-05-10 01:21:55 +00:00
'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.' ,
2026-05-10 00:43:59 +00:00
vpw_floor :
strategies: spending input is honoured + new "Custom" preset with guardrails
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>
2026-05-10 01:21:55 +00:00
'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.' ,
2026-05-10 00:43:59 +00:00
} ;
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.' ,
} ;
frontend: What-If page with fan chart driven by /simulate
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 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
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: 3 models — Shiller bootstrap (default), manual %, Wealthfolio history
Adds a "Returns model" picker on /what-if that switches how the
simulator's `paths` (n_paths × n_years × 3) is built:
1. shiller (default) — current behaviour, block-bootstrap of the
Shiller 1871+ historical series (or its synthetic-calibrated
fallback when the CSV isn't mounted).
2. manual — every year of every path = the user's "real return %"
input. Deterministic, no fan, useful for sanity checks. New
helper `constant_real_return_paths` constructs the (n_paths,
n_years, 3) tensor with stock=bond=real, cpi=0 so the simulator's
`(1+nominal)/(1+cpi)-1` short-circuits to exactly the input.
3. wealthfolio — pulls daily_account_valuation from the wealthfolio_sync
PG mirror, sums total_value + net_contribution across accounts per
day (FX-adjusted), strips contribution deltas to isolate market
return, compounds daily returns into per-calendar-year samples,
block-bootstraps with block_size=1 (only ~6 distinct samples
available, no serial-correlation signal to preserve). Glide path
is a no-op in this mode — the user's actual blended portfolio is
treated as a single asset.
API: SimulateRequest gains `returns_mode` ("shiller"|"manual"|
"wealthfolio") + `manual_real_return_pct`. simulate.py's `_build_paths`
dispatches; wealthfolio mode opens a transient session against the
mirror DB.
UI: new Field on the form (next to Strategy / Glide path) with a
contextual hint that explains each option's tradeoff. The "About the
model" panel at the bottom now has a "Returns model" section
mirroring the same content. The Manual % input only shows when
returns_mode='manual'.
10 new tests on the Wealthfolio helper (contribution-stripping,
multi-account aggregation, FX, partial-year drop, TOTAL filter,
empty-input, plus 3 deterministic-paths tests). 198 backend tests +
7 frontend tests. mypy strict + ruff + tsc strict all pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 01:04:25 +00:00
returns_mode : 'shiller' ,
manual_real_return_pct : '0.046' ,
strategies: spending input is honoured + new "Custom" preset with guardrails
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>
2026-05-10 01:21:55 +00:00
annual_real_adjust_pct : '0' ,
guardrail_threshold_pct : null ,
guardrail_cut_pct : '0.10' ,
frontend: What-If page with fan chart driven by /simulate
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 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
} ;
export function WhatIf() {
const [ form , setForm ] = useState < SimulateRequest > ( DEFAULTS ) ;
2026-05-10 00:21:14 +00:00
const [ nwAutoFilled , setNwAutoFilled ] = useState ( false ) ;
2026-05-09 22:20:21 +00:00
const navigate = useNavigate ( ) ;
const qc = useQueryClient ( ) ;
2026-05-10 00:21:14 +00:00
// 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 ;
2026-05-10 00:34:09 +00:00
// 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 } ) ) ;
2026-05-10 00:21:14 +00:00
setNwAutoFilled ( true ) ;
} , [ nw . data , nwAutoFilled ] ) ;
frontend: What-If page with fan chart driven by /simulate
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 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
const sim = useMutation ( {
mutationFn : ( req : SimulateRequest ) = > api . simulate ( req ) ,
} ) ;
2026-05-09 22:20:21 +00:00
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 } ` ) ;
} ,
} ) ;
frontend: What-If page with fan chart driven by /simulate
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 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
const onSubmit = ( e : React.FormEvent ) = > {
e . preventDefault ( ) ;
strategies: spending input is honoured + new "Custom" preset with guardrails
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>
2026-05-10 01:21:55 +00:00
const isCustom = form . strategy === 'custom' ;
frontend: What-If page with fan chart driven by /simulate
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 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
sim . mutate ( {
. . . form ,
floor_gbp : form.strategy === 'vpw_floor' ? form.floor_gbp : null ,
returns: 3 models — Shiller bootstrap (default), manual %, Wealthfolio history
Adds a "Returns model" picker on /what-if that switches how the
simulator's `paths` (n_paths × n_years × 3) is built:
1. shiller (default) — current behaviour, block-bootstrap of the
Shiller 1871+ historical series (or its synthetic-calibrated
fallback when the CSV isn't mounted).
2. manual — every year of every path = the user's "real return %"
input. Deterministic, no fan, useful for sanity checks. New
helper `constant_real_return_paths` constructs the (n_paths,
n_years, 3) tensor with stock=bond=real, cpi=0 so the simulator's
`(1+nominal)/(1+cpi)-1` short-circuits to exactly the input.
3. wealthfolio — pulls daily_account_valuation from the wealthfolio_sync
PG mirror, sums total_value + net_contribution across accounts per
day (FX-adjusted), strips contribution deltas to isolate market
return, compounds daily returns into per-calendar-year samples,
block-bootstraps with block_size=1 (only ~6 distinct samples
available, no serial-correlation signal to preserve). Glide path
is a no-op in this mode — the user's actual blended portfolio is
treated as a single asset.
API: SimulateRequest gains `returns_mode` ("shiller"|"manual"|
"wealthfolio") + `manual_real_return_pct`. simulate.py's `_build_paths`
dispatches; wealthfolio mode opens a transient session against the
mirror DB.
UI: new Field on the form (next to Strategy / Glide path) with a
contextual hint that explains each option's tradeoff. The "About the
model" panel at the bottom now has a "Returns model" section
mirroring the same content. The Manual % input only shows when
returns_mode='manual'.
10 new tests on the Wealthfolio helper (contribution-stripping,
multi-account aggregation, FX, partial-year drop, TOTAL filter,
empty-input, plus 3 deterministic-paths tests). 198 backend tests +
7 frontend tests. mypy strict + ruff + tsc strict all pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 01:04:25 +00:00
manual_real_return_pct :
form . returns_mode === 'manual' ? form.manual_real_return_pct : null ,
strategies: spending input is honoured + new "Custom" preset with guardrails
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>
2026-05-10 01:21:55 +00:00
// 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' ,
frontend: What-If page with fan chart driven by /simulate
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 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
} ) ;
} ;
2026-05-09 22:20:21 +00:00
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 ( ) ) ;
} ;
frontend: What-If page with fan chart driven by /simulate
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 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
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"
>
2026-05-10 00:43:59 +00:00
< Field label = "Jurisdiction" hint = { JURISDICTION_NOTES [ form . jurisdiction ] } >
frontend: What-If page with fan chart driven by /simulate
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 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
< Select
value = { form . jurisdiction }
onChange = { ( v ) = > update ( 'jurisdiction' , v ) }
options = { JURISDICTIONS }
/ >
< / Field >
2026-05-10 00:43:59 +00:00
< Field label = "Strategy" hint = { STRATEGY_NOTES [ form . strategy ] } >
frontend: What-If page with fan chart driven by /simulate
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 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
< Select
value = { form . strategy }
onChange = { ( v ) = > update ( 'strategy' , v ) }
options = { STRATEGIES }
/ >
< / Field >
2026-05-10 00:43:59 +00:00
< Field label = "Glide path" hint = { GLIDE_NOTES [ form . glide_path ] } >
frontend: What-If page with fan chart driven by /simulate
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 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
< 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 >
) }
strategies: spending input is honoured + new "Custom" preset with guardrails
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>
2026-05-10 01:21:55 +00:00
{ 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 >
< / >
) }
returns: 3 models — Shiller bootstrap (default), manual %, Wealthfolio history
Adds a "Returns model" picker on /what-if that switches how the
simulator's `paths` (n_paths × n_years × 3) is built:
1. shiller (default) — current behaviour, block-bootstrap of the
Shiller 1871+ historical series (or its synthetic-calibrated
fallback when the CSV isn't mounted).
2. manual — every year of every path = the user's "real return %"
input. Deterministic, no fan, useful for sanity checks. New
helper `constant_real_return_paths` constructs the (n_paths,
n_years, 3) tensor with stock=bond=real, cpi=0 so the simulator's
`(1+nominal)/(1+cpi)-1` short-circuits to exactly the input.
3. wealthfolio — pulls daily_account_valuation from the wealthfolio_sync
PG mirror, sums total_value + net_contribution across accounts per
day (FX-adjusted), strips contribution deltas to isolate market
return, compounds daily returns into per-calendar-year samples,
block-bootstraps with block_size=1 (only ~6 distinct samples
available, no serial-correlation signal to preserve). Glide path
is a no-op in this mode — the user's actual blended portfolio is
treated as a single asset.
API: SimulateRequest gains `returns_mode` ("shiller"|"manual"|
"wealthfolio") + `manual_real_return_pct`. simulate.py's `_build_paths`
dispatches; wealthfolio mode opens a transient session against the
mirror DB.
UI: new Field on the form (next to Strategy / Glide path) with a
contextual hint that explains each option's tradeoff. The "About the
model" panel at the bottom now has a "Returns model" section
mirroring the same content. The Manual % input only shows when
returns_mode='manual'.
10 new tests on the Wealthfolio helper (contribution-stripping,
multi-account aggregation, FX, partial-year drop, TOTAL filter,
empty-input, plus 3 deterministic-paths tests). 198 backend tests +
7 frontend tests. mypy strict + ruff + tsc strict all pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 01:04:25 +00:00
< 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 >
) }
frontend: What-If page with fan chart driven by /simulate
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 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
< Field label = "Monte Carlo paths" >
< NumberInput
value = { form . n_paths ? ? 5000 }
onChange = { ( v ) = > update ( 'n_paths' , v ) }
min = { 100 }
max = { 50000 }
2026-05-09 23:46:45 +00:00
step = { 100 }
frontend: What-If page with fan chart driven by /simulate
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 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
/ >
< / 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 >
) }
2026-05-09 22:20:21 +00:00
{ 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 >
< / >
) }
2026-05-10 00:43:59 +00:00
< AboutTheModel / >
frontend: What-If page with fan chart driven by /simulate
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 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
< / div >
< / div >
< / section >
) ;
}
2026-05-10 00:43:59 +00:00
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 >
strategies: spending input is honoured + new "Custom" preset with guardrails
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>
2026-05-10 01:21:55 +00:00
< Term name = "Custom spending plan" > { STRATEGY_NOTES . custom } < / Term >
2026-05-10 00:43:59 +00:00
< / 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 >
returns: 3 models — Shiller bootstrap (default), manual %, Wealthfolio history
Adds a "Returns model" picker on /what-if that switches how the
simulator's `paths` (n_paths × n_years × 3) is built:
1. shiller (default) — current behaviour, block-bootstrap of the
Shiller 1871+ historical series (or its synthetic-calibrated
fallback when the CSV isn't mounted).
2. manual — every year of every path = the user's "real return %"
input. Deterministic, no fan, useful for sanity checks. New
helper `constant_real_return_paths` constructs the (n_paths,
n_years, 3) tensor with stock=bond=real, cpi=0 so the simulator's
`(1+nominal)/(1+cpi)-1` short-circuits to exactly the input.
3. wealthfolio — pulls daily_account_valuation from the wealthfolio_sync
PG mirror, sums total_value + net_contribution across accounts per
day (FX-adjusted), strips contribution deltas to isolate market
return, compounds daily returns into per-calendar-year samples,
block-bootstraps with block_size=1 (only ~6 distinct samples
available, no serial-correlation signal to preserve). Glide path
is a no-op in this mode — the user's actual blended portfolio is
treated as a single asset.
API: SimulateRequest gains `returns_mode` ("shiller"|"manual"|
"wealthfolio") + `manual_real_return_pct`. simulate.py's `_build_paths`
dispatches; wealthfolio mode opens a transient session against the
mirror DB.
UI: new Field on the form (next to Strategy / Glide path) with a
contextual hint that explains each option's tradeoff. The "About the
model" panel at the bottom now has a "Returns model" section
mirroring the same content. The Manual % input only shows when
returns_mode='manual'.
10 new tests on the Wealthfolio helper (contribution-stripping,
multi-account aggregation, FX, partial-year drop, TOTAL filter,
empty-input, plus 3 deterministic-paths tests). 198 backend tests +
7 frontend tests. mypy strict + ruff + tsc strict all pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 01:04:25 +00:00
< 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 % .
2026-05-10 00:43:59 +00:00
< / 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 >
) ;
}
frontend: What-If page with fan chart driven by /simulate
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 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
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 >
) ;
}
2026-05-10 00:43:59 +00:00
function Field ( {
label ,
hint ,
children ,
} : {
label : string ;
hint? : string ;
children : React.ReactNode ;
} ) {
frontend: What-If page with fan chart driven by /simulate
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 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
return (
< label className = "block" >
< span className = "text-xs uppercase tracking-wide text-slate-500" > { label } < / span >
< div className = "mt-1" > { children } < / div >
2026-05-10 00:43:59 +00:00
{ hint && < p className = "mt-1 text-xs text-slate-500 leading-snug" > { hint } < / p > }
frontend: What-If page with fan chart driven by /simulate
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 <noreply@anthropic.com>
2026-05-09 22:08:00 +00:00
< / 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"
/ >
) ;
}