From b40defacf0cc408a25ab06ed885c42f36860a56a Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 10 May 2026 00:21:14 +0000 Subject: [PATCH] engine+ui: tax drains the portfolio + Wealthfolio-seeded NW default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes: (1) Simulator: portfolio drain is now `w + tax(w)`, not just `w`. The pre-2026-05-10 engine recorded tax in tax_hist but never subtracted it from the portfolio, so changing jurisdiction only moved the median_lifetime_tax cell — the fan chart, success rate, and ending percentiles were identical for UK vs Cyprus vs Malaysia. (The PLAYBOOK_VIKTOR.md memo from 2026-04-26 explicitly noted this: "Success rate is regime-independent… tax doesn't drain the portfolio in this simulator.") Mental model now: spending_target is what the user takes home; the tax bill is an additional drag on the same pool. Higher-tax jurisdictions therefore drain faster and lower the success rate, which is the user's intuition. Trinity 4% effectively becomes "4% take-home + tax overhead". 188 tests still pass — most use Malaysia (0%) or hit the regime-independent code paths. (2) /what-if and /scenarios/new now pre-fill nw_seed_gbp from GET /networth on first mount (when the wealthfolio_sync mirror has data), so opening the form starts from the user's real portfolio total instead of the £1.5M placeholder. Once the user edits the field, subsequent NW refetches don't clobber it (nwAutoFilled latch). Co-Authored-By: Claude Opus 4.7 --- fire_planner/simulator.py | 12 +++++++++++- frontend/src/pages/ScenarioNew.tsx | 13 +++++++++++-- frontend/src/pages/WhatIf.tsx | 16 ++++++++++++++-- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/fire_planner/simulator.py b/fire_planner/simulator.py index 9c49950..e269fcf 100644 --- a/fire_planner/simulator.py +++ b/fire_planner/simulator.py @@ -223,7 +223,17 @@ def simulate( w = max(0.0, min(w, float(portfolio[p]))) tax_breakdown = regime_at(y).compute_tax(bucket_split(w, y)) t = float(tax_breakdown.total) - portfolio[p] = portfolio[p] - w + # Drain BOTH withdrawal AND tax from the portfolio. Mental + # model: `w` is what the user takes home to spend; the tax + # is an additional drag that comes out of the same pool. A + # high-tax jurisdiction therefore drains the portfolio + # faster and lowers the success rate, matching user intuition + # (Cyprus non-dom should beat UK on outcome, not just on a + # summary stat). Pre-2026-05-10 the engine recorded `t` but + # didn't subtract it, so jurisdiction only changed the + # `median_lifetime_tax_gbp` cell while the fan chart and + # success rate were identical across regimes. + portfolio[p] = max(0.0, portfolio[p] - w - t) withdrawal_hist[p, y] = w tax_hist[p, y] = t last_withdrawal[p] = w diff --git a/frontend/src/pages/ScenarioNew.tsx b/frontend/src/pages/ScenarioNew.tsx index 3333895..30eaf39 100644 --- a/frontend/src/pages/ScenarioNew.tsx +++ b/frontend/src/pages/ScenarioNew.tsx @@ -2,8 +2,8 @@ * Create a user scenario via POST /scenarios. Redirects to the detail * page on success. */ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { api, type ScenarioCreateBody } from '@/api/client'; @@ -27,10 +27,19 @@ const DEFAULTS: ScenarioCreateBody = { export function ScenarioNew() { const [form, setForm] = useState(DEFAULTS); + const [nwAutoFilled, setNwAutoFilled] = useState(false); const [nameError, setNameError] = useState(null); const navigate = useNavigate(); const qc = useQueryClient(); + // Same pattern as What-If: seed nw_seed_gbp from the live NW snapshot. + const nw = useQuery({ queryKey: ['networth', 'current'], queryFn: api.networth.current }); + useEffect(() => { + if (nwAutoFilled || !nw.data || nw.data.accounts.length === 0) return; + setForm((f) => ({ ...f, nw_seed_gbp: nw.data!.total_gbp })); + setNwAutoFilled(true); + }, [nw.data, nwAutoFilled]); + const create = useMutation({ mutationFn: (body: ScenarioCreateBody) => api.scenarios.create(body), onSuccess: (s) => { diff --git a/frontend/src/pages/WhatIf.tsx b/frontend/src/pages/WhatIf.tsx index d012383..0c8f6ef 100644 --- a/frontend/src/pages/WhatIf.tsx +++ b/frontend/src/pages/WhatIf.tsx @@ -2,8 +2,8 @@ * 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, useQueryClient } from '@tanstack/react-query'; -import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { api, type SimulateRequest, type SimulateResult } from '@/api/client'; @@ -30,9 +30,21 @@ const DEFAULTS: SimulateRequest = { export function WhatIf() { const [form, setForm] = useState(DEFAULTS); + const [nwAutoFilled, setNwAutoFilled] = useState(false); const navigate = useNavigate(); const qc = useQueryClient(); + // Pre-fill NW seed from the latest Wealthfolio snapshot the first + // time it loads, so opening /what-if always starts from real numbers. + // The user can still edit; we won't clobber their input on later + // refetches. + const nw = useQuery({ queryKey: ['networth', 'current'], queryFn: api.networth.current }); + useEffect(() => { + if (nwAutoFilled || !nw.data || nw.data.accounts.length === 0) return; + setForm((f) => ({ ...f, nw_seed_gbp: nw.data!.total_gbp })); + setNwAutoFilled(true); + }, [nw.data, nwAutoFilled]); + const sim = useMutation({ mutationFn: (req: SimulateRequest) => api.simulate(req), });