engine+ui: tax drains the portfolio + Wealthfolio-seeded NW default
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
f781afe3fa
commit
b40defacf0
3 changed files with 36 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue