fire-planner: life-event spending bumps now reflected in fan + auto-
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
f9084d1a15
commit
eb0dd3ddbf
4 changed files with 199 additions and 49 deletions
|
|
@ -111,6 +111,7 @@ def _project(req: SimulateRequest, paths: np.ndarray) -> tuple[SimulationResult,
|
||||||
|
|
||||||
cashflow_adjustments = None
|
cashflow_adjustments = None
|
||||||
discretionary_outflows = None
|
discretionary_outflows = None
|
||||||
|
extra_outflows = None
|
||||||
if req.life_events:
|
if req.life_events:
|
||||||
engine_events = [
|
engine_events = [
|
||||||
EventInput(
|
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)
|
cashflow_adjustments = events_to_cashflow_array(engine_events, req.horizon_years)
|
||||||
category_outflows = events_to_category_outflows(engine_events, req.horizon_years)
|
category_outflows = events_to_category_outflows(engine_events, req.horizon_years)
|
||||||
discretionary_outflows = category_outflows.get("discretionary")
|
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 = [
|
engine_flex = [
|
||||||
EngineFlexRule(
|
EngineFlexRule(
|
||||||
|
|
@ -175,6 +182,7 @@ def _project(req: SimulateRequest, paths: np.ndarray) -> tuple[SimulationResult,
|
||||||
income_inflows=income_inflows,
|
income_inflows=income_inflows,
|
||||||
income_taxable=income_taxable,
|
income_taxable=income_taxable,
|
||||||
discretionary_outflows=discretionary_outflows,
|
discretionary_outflows=discretionary_outflows,
|
||||||
|
extra_outflows=extra_outflows,
|
||||||
flex_rules=engine_flex,
|
flex_rules=engine_flex,
|
||||||
)
|
)
|
||||||
elapsed = time.perf_counter() - started
|
elapsed = time.perf_counter() - started
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,7 @@ def simulate(
|
||||||
income_inflows: npt.NDArray[np.float64] | None = None,
|
income_inflows: npt.NDArray[np.float64] | None = None,
|
||||||
income_taxable: npt.NDArray[np.float64] | None = None,
|
income_taxable: npt.NDArray[np.float64] | None = None,
|
||||||
discretionary_outflows: 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,
|
flex_rules: list[FlexRule] | None = None,
|
||||||
) -> SimulationResult:
|
) -> SimulationResult:
|
||||||
"""Run the MC simulation. `paths` shape: (n_paths, n_years, 3).
|
"""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)
|
income_taxable = np.zeros(n_years, dtype=np.float64)
|
||||||
if discretionary_outflows is None:
|
if discretionary_outflows is None:
|
||||||
discretionary_outflows = np.zeros(n_years, dtype=np.float64)
|
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 []
|
rules = list(flex_rules) if flex_rules else []
|
||||||
# Track running ATH per path so we can decide flex cuts each year.
|
# Track running ATH per path so we can decide flex cuts each year.
|
||||||
ath = np.full(n_paths, float(initial_portfolio), dtype=np.float64)
|
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
|
# `median_lifetime_tax_gbp` cell while the fan chart and
|
||||||
# success rate were identical across regimes.
|
# success rate were identical across regimes.
|
||||||
portfolio[p] = max(0.0, portfolio[p] - w - t)
|
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
|
tax_hist[p, y] = t
|
||||||
last_withdrawal[p] = w
|
last_withdrawal[p] = w
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,13 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
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 { ApiError } from '@/api/client';
|
||||||
import { EventGantt } from '@/components/EventGantt';
|
import { EventGantt } from '@/components/EventGantt';
|
||||||
import { FanChart } from '@/components/FanChart';
|
import { FanChart } from '@/components/FanChart';
|
||||||
|
|
@ -60,6 +66,12 @@ export function ScenarioDetail() {
|
||||||
enabled: Number.isFinite(id),
|
enabled: Number.isFinite(id),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const incomeStreams = useQuery({
|
||||||
|
queryKey: ['scenarios', id, 'income-streams'],
|
||||||
|
queryFn: () => incomeStreamsApi.list(id),
|
||||||
|
enabled: Number.isFinite(id),
|
||||||
|
});
|
||||||
|
|
||||||
const profile = useQuery({
|
const profile = useQuery({
|
||||||
queryKey: ['spending-profile', id],
|
queryKey: ['spending-profile', id],
|
||||||
queryFn: () => api.spendingProfile(id),
|
queryFn: () => api.spendingProfile(id),
|
||||||
|
|
@ -87,8 +99,89 @@ export function ScenarioDetail() {
|
||||||
mutationFn: (req: SimulateRequest) => api.simulate(req),
|
mutationFn: (req: SimulateRequest) => api.simulate(req),
|
||||||
});
|
});
|
||||||
|
|
||||||
const horizonYears = scen.data?.horizon_years ?? proj.data?.yearly.length ?? 60;
|
// Auto-refresh: when life events / income streams / flex rules
|
||||||
const maxYear = (proj.data?.yearly.length ?? horizonYears) - 1;
|
// 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 yearFromUrl = Number(searchParams.get('year'));
|
||||||
const initialYear = Number.isFinite(yearFromUrl) && yearFromUrl >= 0 ? yearFromUrl : 0;
|
const initialYear = Number.isFinite(yearFromUrl) && yearFromUrl >= 0 ? yearFromUrl : 0;
|
||||||
|
|
@ -126,29 +219,46 @@ export function ScenarioDetail() {
|
||||||
|
|
||||||
const onRunNow = async (s: Scenario) => {
|
const onRunNow = async (s: Scenario) => {
|
||||||
const fresh = await lifeEventsApi.list(s.id);
|
const fresh = await lifeEventsApi.list(s.id);
|
||||||
const flexRules = readFlexRules(s);
|
const streams = await incomeStreamsApi.list(s.id);
|
||||||
sim.mutate({
|
void runLiveSim(s, fresh, streams);
|
||||||
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,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 <p className="text-red-700">Invalid scenario id.</p>;
|
if (!Number.isFinite(id)) return <p className="text-red-700">Invalid scenario id.</p>;
|
||||||
if (scen.isLoading) return <p className="text-slate-500">Loading…</p>;
|
if (scen.isLoading) return <p className="text-slate-500">Loading…</p>;
|
||||||
if (scen.isError || !scen.data) {
|
if (scen.isError || !scen.data) {
|
||||||
|
|
@ -217,32 +327,44 @@ export function ScenarioDetail() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{projection ? (
|
{projection || sim.data ? (
|
||||||
<>
|
<>
|
||||||
{/* Stats badges row — sits above the chart, not on top of it */}
|
{/* Stats badges row — sits above the chart, not on top of it */}
|
||||||
<StatsBadges
|
<StatsBadges
|
||||||
year={year}
|
year={year}
|
||||||
netWorth={yearStats.data?.net_worth_p50}
|
netWorth={activeYearStats?.net_worth_p50}
|
||||||
changeNw={yearStats.data?.change_in_nw}
|
changeNw={activeYearStats?.change_in_nw}
|
||||||
spending={yearStats.data?.spending}
|
spending={activeYearStats?.spending}
|
||||||
taxes={yearStats.data?.taxes}
|
taxes={activeYearStats?.taxes}
|
||||||
effectiveRate={yearStats.data?.effective_tax_rate}
|
effectiveRate={activeYearStats?.effective_tax_rate}
|
||||||
age={yearStats.data?.age ?? null}
|
age={activeYearStats?.age ?? null}
|
||||||
calendarYear={yearStats.data?.calendar_year}
|
calendarYear={activeYearStats?.calendar_year}
|
||||||
successRate={projection.success_rate}
|
successRate={sim.data?.success_rate ?? projection?.success_rate ?? '0'}
|
||||||
ruined={isRuined(yearStats.data?.net_worth_p50)}
|
ruined={isRuined(activeYearStats?.net_worth_p50)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* NW fan + scrubber */}
|
{/* NW fan + scrubber */}
|
||||||
<div className="rounded-lg border border-slate-200 bg-white p-5">
|
<div className="rounded-lg border border-slate-200 bg-white p-5">
|
||||||
<div className="flex items-baseline justify-between mb-2">
|
<div className="flex items-baseline justify-between mb-2">
|
||||||
<h2 className="text-lg font-semibold">Portfolio fan</h2>
|
<h2 className="text-lg font-semibold">
|
||||||
|
Portfolio fan
|
||||||
|
{sim.data && (
|
||||||
|
<span className="ml-2 text-xs font-normal text-emerald-700 bg-emerald-50 border border-emerald-200 rounded px-1.5 py-0.5">
|
||||||
|
live preview · {sim.data.elapsed_seconds}s · {sim.data.yearly.length}y
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{sim.isPending && (
|
||||||
|
<span className="ml-2 text-xs font-normal text-slate-500">
|
||||||
|
re-running…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
<span className="text-xs text-slate-500">
|
<span className="text-xs text-slate-500">
|
||||||
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
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<FanChart
|
<FanChart
|
||||||
yearly={projection.yearly}
|
yearly={liveYearly ?? []}
|
||||||
height={420}
|
height={420}
|
||||||
showWithdrawal
|
showWithdrawal
|
||||||
milestones={milestones}
|
milestones={milestones}
|
||||||
|
|
@ -304,17 +426,6 @@ export function ScenarioDetail() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sim.data && (
|
|
||||||
<div className="rounded-lg border border-slate-200 bg-white p-5">
|
|
||||||
<div className="flex items-baseline justify-between mb-3">
|
|
||||||
<h2 className="text-lg font-semibold">Live preview run</h2>
|
|
||||||
<span className="text-xs text-slate-500">
|
|
||||||
{sim.data.elapsed_seconds}s · 5,000 paths · success {pct(sim.data.success_rate)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<FanChart yearly={sim.data.yearly} height={360} showWithdrawal />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sim.isError && (
|
{sim.isError && (
|
||||||
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-red-800 text-sm">
|
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-red-800 text-sm">
|
||||||
{String((sim.error as Error)?.message ?? sim.error)}
|
{String((sim.error as Error)?.message ?? sim.error)}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
from fire_planner.glide_path import static
|
from fire_planner.glide_path import static
|
||||||
from fire_planner.life_events import EventInput, events_to_cashflow_array
|
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)
|
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:
|
def test_one_time_inheritance_lifts_portfolio() -> None:
|
||||||
kwargs = _baseline_kwargs()
|
kwargs = _baseline_kwargs()
|
||||||
adj = events_to_cashflow_array(
|
adj = events_to_cashflow_array(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue