fire-planner/tests/test_simulator_events.py
Viktor Barzin 2fc92c12f5
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
engine+api: plumb life events into the simulator
Until now life events were stored but ignored by the engine — pure
metadata. Now they actually move portfolios.

Engine:
- simulator.simulate() takes optional cashflow_adjustments: a (n_years,)
  real-GBP array applied each year *after* savings + return but
  *before* withdrawal. Positive = inflow, negative = outflow.
- New fire_planner/life_events.py with EventInput dataclass +
  events_to_cashflow_array(events, horizon). Handles ranged deltas,
  one-time amounts, disabled events, year clipping past horizon,
  negative year_start (clipped to 0), and summing multiple events.

API:
- /simulate accepts optional life_events list. Server converts each
  to EventInput, builds cashflow_adjustments, passes to simulate().
- Frontend Run-now on scenario detail now fetches the scenario's
  life events and includes them in the request — projections finally
  reflect "retire at 50, kid born at y3, inheritance at y22".

Tests: 11 events helper + 4 end-to-end engine + 1 API integration =
16 new tests. 188 total (was 172). mypy strict + ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 22:30:33 +00:00

73 lines
3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""End-to-end: cashflow_adjustments make portfolios bigger or smaller."""
from __future__ import annotations
import numpy as np
from fire_planner.glide_path import static
from fire_planner.life_events import EventInput, events_to_cashflow_array
from fire_planner.simulator import simulate
from fire_planner.strategies.trinity import TrinityStrategy
from fire_planner.tax.malaysia import MalaysiaTaxRegime
from tests.test_simulator import fixed_paths
def _baseline_kwargs() -> dict[str, object]:
"""0% real returns, 25y, Trinity 4%, no taxes (Malaysia) — predictable."""
paths = fixed_paths(n_paths=1, n_years=25, stock_ret=0.0, bond_ret=0.0, cpi=0.0)
return dict(
paths=paths,
initial_portfolio=1_000_000.0,
spending_target=40_000.0,
glide=static(0.6),
strategy=TrinityStrategy(initial_rate=0.04),
regime=MalaysiaTaxRegime(),
)
def test_no_adjustments_matches_baseline() -> None:
base = simulate(**_baseline_kwargs()) # type: ignore[arg-type]
with_zero = simulate(**_baseline_kwargs(), cashflow_adjustments=np.zeros(25)) # type: ignore[arg-type]
np.testing.assert_allclose(base.portfolio_real, with_zero.portfolio_real)
def test_one_time_inheritance_lifts_portfolio() -> None:
kwargs = _baseline_kwargs()
adj = events_to_cashflow_array(
[EventInput(year_start=10, one_time_amount_gbp=250_000)],
horizon_years=25,
)
base = simulate(**kwargs) # type: ignore[arg-type]
enhanced = simulate(**kwargs, cashflow_adjustments=adj) # type: ignore[arg-type]
# Year 11 onward should be exactly £250k higher under 0% returns +
# constant Trinity withdrawal.
delta = enhanced.portfolio_real[0, 11:] - base.portfolio_real[0, 11:]
assert np.all(delta > 0)
# Year 11 specifically: +£250k landed at end of year 10, withdrawn
# nothing extra in y10. By y11 just propagated forward.
assert enhanced.portfolio_real[0, 11] - base.portfolio_real[0, 11] == 250_000
def test_ongoing_expense_drains_portfolio() -> None:
kwargs = _baseline_kwargs()
adj = events_to_cashflow_array(
[EventInput(year_start=0, year_end=5, delta_gbp_per_year=-20_000)],
horizon_years=25,
)
base = simulate(**kwargs) # type: ignore[arg-type]
drained = simulate(**kwargs, cashflow_adjustments=adj) # type: ignore[arg-type]
# 6 years × £20k expense = £120k less by end of year 6, 0% growth.
delta = base.portfolio_real[0, 6] - drained.portfolio_real[0, 6]
assert delta == 120_000
def test_event_can_force_failure() -> None:
"""A massive expense early on can ruin an otherwise-successful run."""
kwargs = _baseline_kwargs()
adj = events_to_cashflow_array(
[EventInput(year_start=2, one_time_amount_gbp=-1_500_000)],
horizon_years=25,
)
base = simulate(**kwargs) # type: ignore[arg-type]
ruined = simulate(**kwargs, cashflow_adjustments=adj) # type: ignore[arg-type]
assert base.success_rate == 1.0
assert ruined.success_rate == 0.0