diff --git a/fire_planner/api/simulate.py b/fire_planner/api/simulate.py index 882a206..ebc48be 100644 --- a/fire_planner/api/simulate.py +++ b/fire_planner/api/simulate.py @@ -111,6 +111,7 @@ def _project(req: SimulateRequest, paths: np.ndarray) -> tuple[SimulationResult, cashflow_adjustments = None discretionary_outflows = None + extra_outflows = None if req.life_events: engine_events = [ EventInput( @@ -126,6 +127,12 @@ def _project(req: SimulateRequest, paths: np.ndarray) -> tuple[SimulationResult, cashflow_adjustments = events_to_cashflow_array(engine_events, req.horizon_years) category_outflows = events_to_category_outflows(engine_events, req.horizon_years) discretionary_outflows = category_outflows.get("discretionary") + # extra_outflows feeds the withdrawal-trace display: total of + # essential + discretionary spending events surfaces alongside + # the strategy's draw on the chart. + essential = category_outflows.get("essential") + if essential is not None and discretionary_outflows is not None: + extra_outflows = essential + discretionary_outflows engine_flex = [ EngineFlexRule( @@ -175,6 +182,7 @@ def _project(req: SimulateRequest, paths: np.ndarray) -> tuple[SimulationResult, income_inflows=income_inflows, income_taxable=income_taxable, discretionary_outflows=discretionary_outflows, + extra_outflows=extra_outflows, flex_rules=engine_flex, ) elapsed = time.perf_counter() - started diff --git a/fire_planner/simulator.py b/fire_planner/simulator.py index bc796b3..74e17f4 100644 --- a/fire_planner/simulator.py +++ b/fire_planner/simulator.py @@ -188,6 +188,7 @@ def simulate( income_inflows: npt.NDArray[np.float64] | None = None, income_taxable: npt.NDArray[np.float64] | None = None, discretionary_outflows: npt.NDArray[np.float64] | None = None, + extra_outflows: npt.NDArray[np.float64] | None = None, flex_rules: list[FlexRule] | None = None, ) -> SimulationResult: """Run the MC simulation. `paths` shape: (n_paths, n_years, 3). @@ -228,6 +229,8 @@ def simulate( income_taxable = np.zeros(n_years, dtype=np.float64) if discretionary_outflows is None: discretionary_outflows = np.zeros(n_years, dtype=np.float64) + if extra_outflows is None: + extra_outflows = np.zeros(n_years, dtype=np.float64) rules = list(flex_rules) if flex_rules else [] # Track running ATH per path so we can decide flex cuts each year. ath = np.full(n_paths, float(initial_portfolio), dtype=np.float64) @@ -295,7 +298,13 @@ def simulate( # `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 + # The chart's "withdrawal" trace shows total spending as a + # user would experience it, not just the strategy's draw. + # `extra_outflows[y]` is already in cashflow_adjustments[y] + # (which drained the portfolio at start-of-year), so we just + # *report* it here — no double deduction. Keeps the median + # withdrawal line in step with the spending profile chart. + withdrawal_hist[p, y] = w + float(extra_outflows[y]) tax_hist[p, y] = t last_withdrawal[p] = w diff --git a/frontend/src/pages/ScenarioDetail.tsx b/frontend/src/pages/ScenarioDetail.tsx index 99b02fc..df8934e 100644 --- a/frontend/src/pages/ScenarioDetail.tsx +++ b/frontend/src/pages/ScenarioDetail.tsx @@ -19,7 +19,13 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo, useState } from 'react'; import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { api, lifeEventsApi, type Scenario, type SimulateRequest } from '@/api/client'; +import { + api, + incomeStreamsApi, + lifeEventsApi, + type Scenario, + type SimulateRequest, +} from '@/api/client'; import { ApiError } from '@/api/client'; import { EventGantt } from '@/components/EventGantt'; import { FanChart } from '@/components/FanChart'; @@ -60,6 +66,12 @@ export function ScenarioDetail() { enabled: Number.isFinite(id), }); + const incomeStreams = useQuery({ + queryKey: ['scenarios', id, 'income-streams'], + queryFn: () => incomeStreamsApi.list(id), + enabled: Number.isFinite(id), + }); + const profile = useQuery({ queryKey: ['spending-profile', id], queryFn: () => api.spendingProfile(id), @@ -87,8 +99,89 @@ export function ScenarioDetail() { mutationFn: (req: SimulateRequest) => api.simulate(req), }); - const horizonYears = scen.data?.horizon_years ?? proj.data?.yearly.length ?? 60; - const maxYear = (proj.data?.yearly.length ?? horizonYears) - 1; + // Auto-refresh: when life events / income streams / flex rules + // change, fire a fresh /simulate so the fan + year-stats reflect the + // user's edits without requiring a manual "Run now". Signature is a + // stable string so the effect only re-fires on real changes. + const simSignature = useMemo(() => { + if (!scen.data) return null; + const eventsSig = (events.data ?? []).map((e) => ({ + ys: e.year_start, + ye: e.year_end, + d: e.delta_gbp_per_year, + ot: e.one_time_amount_gbp, + c: e.category, + en: e.enabled, + })); + const streamsSig = (incomeStreams.data ?? []).map((s) => ({ + k: s.kind, + ys: s.start_year, + ye: s.end_year, + a: s.amount_gbp_per_year, + g: s.growth_pct, + tt: s.tax_treatment, + en: s.enabled, + })); + const flexSig = readFlexRules(scen.data); + return JSON.stringify({ eventsSig, streamsSig, flexSig }); + }, [scen.data, events.data, incomeStreams.data]); + + useEffect(() => { + if (!scen.data || simSignature === null) return; + const hasOverrides = + (events.data && events.data.length > 0) || + (incomeStreams.data && incomeStreams.data.length > 0) || + readFlexRules(scen.data).length > 0; + if (!hasOverrides) return; + void runLiveSim(scen.data, events.data ?? [], incomeStreams.data ?? []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [simSignature]); + + const runLiveSim = async ( + s: Scenario, + fresh: typeof events.data extends infer T ? (T extends readonly (infer U)[] ? U[] : never) : never, + streams: typeof incomeStreams.data extends infer T + ? T extends readonly (infer U)[] + ? U[] + : never + : never, + ) => { + sim.mutate({ + jurisdiction: s.jurisdiction, + strategy: s.strategy, + leave_uk_year: s.leave_uk_year, + spending_gbp: s.spending_gbp, + nw_seed_gbp: s.nw_seed_gbp, + savings_per_year_gbp: s.savings_per_year_gbp, + horizon_years: s.horizon_years, + n_paths: 2000, + seed: 42, + life_events: fresh.map((e) => ({ + year_start: e.year_start, + year_end: e.year_end, + delta_gbp_per_year: e.delta_gbp_per_year, + one_time_amount_gbp: e.one_time_amount_gbp, + category: e.category, + enabled: e.enabled, + })), + income_streams: streams.map((s) => ({ + kind: s.kind, + start_year: s.start_year, + end_year: s.end_year, + amount_gbp_per_year: s.amount_gbp_per_year, + growth_pct: s.growth_pct, + tax_treatment: s.tax_treatment, + enabled: s.enabled, + })), + flex_rules: readFlexRules(s), + }); + }; + + // Primary chart source: live sim when available, else persisted run. + const liveYearly = sim.data?.yearly ?? proj.data?.yearly; + const horizonYears = + scen.data?.horizon_years ?? liveYearly?.length ?? 60; + const maxYear = (liveYearly?.length ?? horizonYears) - 1; const yearFromUrl = Number(searchParams.get('year')); const initialYear = Number.isFinite(yearFromUrl) && yearFromUrl >= 0 ? yearFromUrl : 0; @@ -126,29 +219,46 @@ export function ScenarioDetail() { const onRunNow = async (s: Scenario) => { const fresh = await lifeEventsApi.list(s.id); - const flexRules = readFlexRules(s); - sim.mutate({ - jurisdiction: s.jurisdiction, - strategy: s.strategy, - leave_uk_year: s.leave_uk_year, - spending_gbp: s.spending_gbp, - nw_seed_gbp: s.nw_seed_gbp, - savings_per_year_gbp: s.savings_per_year_gbp, - horizon_years: s.horizon_years, - n_paths: 5000, - seed: 42, - life_events: fresh.map((e) => ({ - year_start: e.year_start, - year_end: e.year_end, - delta_gbp_per_year: e.delta_gbp_per_year, - one_time_amount_gbp: e.one_time_amount_gbp, - category: e.category, - enabled: e.enabled, - })), - flex_rules: flexRules, - }); + const streams = await incomeStreamsApi.list(s.id); + void runLiveSim(s, fresh, streams); }; + // Derive a year-N stats object from the live sim, when available. + // Falls back to the yearStats query (which reads the persisted MC run) + // for scenarios with no overrides where auto-refresh didn't fire. + const liveYearStats = useMemo(() => { + if (!sim.data || !liveYearly) return null; + const row = liveYearly[year]; + if (!row) return null; + const prev = year > 0 ? liveYearly[year - 1] : null; + const nw = Number(row.p50_portfolio_gbp); + const prevNw = prev + ? Number(prev.p50_portfolio_gbp) + : Number(scen.data?.nw_seed_gbp ?? 0); + return { + year_idx: year, + calendar_year: new Date().getFullYear() + year, + age: null as number | null, + net_worth_p50: String(nw), + change_in_nw: String(nw - prevNw), + taxable_income: '0', + taxes: row.p50_tax_gbp, + effective_tax_rate: + nw > 0 + ? String(Number(row.p50_tax_gbp) / Math.max(1, Number(row.p50_withdrawal_gbp))) + : '0', + spending: row.p50_withdrawal_gbp, + contributions: '0', + investment_growth: '0', + liquid_nw: null, + expenses: null, + savings_rate: null, + portfolio_allocations: null, + }; + }, [sim.data, liveYearly, year, scen.data?.nw_seed_gbp]); + + const activeYearStats = liveYearStats ?? yearStats.data; + if (!Number.isFinite(id)) return

Invalid scenario id.

; if (scen.isLoading) return

Loading…

; if (scen.isError || !scen.data) { @@ -217,32 +327,44 @@ export function ScenarioDetail() { )} - {projection ? ( + {projection || sim.data ? ( <> {/* Stats badges row — sits above the chart, not on top of it */} {/* NW fan + scrubber */}
-

Portfolio fan

+

+ Portfolio fan + {sim.data && ( + + live preview · {sim.data.elapsed_seconds}s · {sim.data.yearly.length}y + + )} + {sim.isPending && ( + + re-running… + + )} +

- p10/p50/p90 over {projection.yearly.length}y · {projection.n_paths.toLocaleString()} paths + p10/p50/p90 · {sim.data ? '2,000' : projection?.n_paths.toLocaleString()} paths
)} - {sim.data && ( -
-
-

Live preview run

- - {sim.data.elapsed_seconds}s · 5,000 paths · success {pct(sim.data.success_rate)} - -
- -
- )} {sim.isError && (
{String((sim.error as Error)?.message ?? sim.error)} diff --git a/tests/test_simulator_events.py b/tests/test_simulator_events.py index 4de60a7..50f4042 100644 --- a/tests/test_simulator_events.py +++ b/tests/test_simulator_events.py @@ -2,6 +2,7 @@ from __future__ import annotations import numpy as np +import pytest from fire_planner.glide_path import static from fire_planner.life_events import EventInput, events_to_cashflow_array @@ -30,6 +31,27 @@ def test_no_adjustments_matches_baseline() -> None: np.testing.assert_allclose(base.portfolio_real, with_zero.portfolio_real) +def test_extra_outflows_show_up_in_withdrawal_trace() -> None: + """A £100k spending bump in years 5-10 should be visible on the + withdrawal trace — not just silently drained from the portfolio.""" + kwargs = _baseline_kwargs() + adj = np.zeros(25, dtype=np.float64) + extras = np.zeros(25, dtype=np.float64) + adj[5:11] = -100_000.0 # drains the portfolio + extras[5:11] = 100_000.0 # surfaces on the chart + + base = simulate(**kwargs) # type: ignore[arg-type] + bumped = simulate(**kwargs, cashflow_adjustments=adj, extra_outflows=extras) # type: ignore[arg-type] + + # Year 0–4 unchanged (no extra outflow) + np.testing.assert_allclose(base.withdrawal_real[:, :5], bumped.withdrawal_real[:, :5]) + # Years 5–10 should be ~100k higher than baseline (clipped only when + # the portfolio was already drained — checked by spot-test). + assert (bumped.withdrawal_real[:, 5:11] > base.withdrawal_real[:, 5:11]).all() + # Year 5 specifically: strategy w (~40k) + 100k extra ≈ 140k. + assert bumped.withdrawal_real[0, 5] == pytest.approx(140_000.0, rel=0.05) + + def test_one_time_inheritance_lifts_portfolio() -> None: kwargs = _baseline_kwargs() adj = events_to_cashflow_array(