engine+ui: tax drains the portfolio + Wealthfolio-seeded NW default
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

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 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-05-10 00:21:14 +00:00
parent f781afe3fa
commit b40defacf0
3 changed files with 36 additions and 5 deletions

View file

@ -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

View file

@ -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<ScenarioCreateBody>(DEFAULTS);
const [nwAutoFilled, setNwAutoFilled] = useState(false);
const [nameError, setNameError] = useState<string | null>(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) => {

View file

@ -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<SimulateRequest>(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),
});