fire-planner: life-event spending bumps now reflected in fan + auto-
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

refresh on scenario edits

Two fixes for the user's report that adding a £100k life-event spend
didn't change the chart:

Engine (simulator.py)
- New `extra_outflows` param. cashflow_adjustments still drains the
  portfolio at start-of-year as before, but the simulator now ALSO
  records the spending in `withdrawal_hist[p, y]` so the chart's red
  median-withdrawal trace shows the bump. Without this, the £100k
  silently came out of the portfolio but the user-facing withdrawal
  trace stayed at the strategy's flat 4% draw.
- simulate.py wires extra_outflows = essential + discretionary
  category outflows from life events.

UX (ScenarioDetail.tsx)
- New auto-refresh: when life events / income streams / flex rules
  change for a scenario, the page fires `/simulate` automatically
  with 2,000 paths and uses the result as the primary fan/year-stats
  source. The persisted MC run is only consulted as a fallback for
  scenarios with no overrides.
- Fan chart title gains a "live preview · Xs · Ny" pill while a sim
  is current, and "re-running…" while a fresh one is in flight.
- Removed the now-redundant "Live preview run" duplicate card lower
  down — the main chart IS the live preview.
- Year-stats badge row reads from sim.data when available so changes
  propagate immediately to NW / Δ NW / Spending / Taxes.

247 pytest pass (+1 new); mypy + ruff clean; frontend typecheck/test/
build green.
This commit is contained in:
Viktor Barzin 2026-05-10 19:17:57 +00:00
parent f9084d1a15
commit eb0dd3ddbf
4 changed files with 199 additions and 49 deletions

View file

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

View file

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