Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
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>
58 lines
1.9 KiB
Python
58 lines
1.9 KiB
Python
"""Convert life-event records into per-year cashflow adjustments.
|
|
|
|
Two event shapes the engine understands:
|
|
|
|
- **Ranged delta**: `delta_gbp_per_year != 0` applied each year in
|
|
`[year_start, year_end]` (inclusive). Use a negative delta for
|
|
expenses (childcare, sabbatical), positive for income (rental,
|
|
pension that hasn't started yet).
|
|
|
|
- **One-time amount**: `one_time_amount_gbp` applied once at
|
|
`year_start`. Inheritance, house sale proceeds, lump-sum gift.
|
|
|
|
Disabled events (`enabled=False`) are skipped. Year ranges that
|
|
extend past the simulation horizon are clipped — events beyond year
|
|
H simply don't happen in this run.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterable
|
|
from dataclasses import dataclass
|
|
|
|
import numpy as np
|
|
import numpy.typing as npt
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EventInput:
|
|
"""Engine-level event shape — decoupled from the SQLAlchemy ORM and
|
|
the API Pydantic schema so callers can construct them however."""
|
|
year_start: int
|
|
year_end: int | None = None
|
|
delta_gbp_per_year: float = 0.0
|
|
one_time_amount_gbp: float | None = None
|
|
enabled: bool = True
|
|
|
|
|
|
def events_to_cashflow_array(
|
|
events: Iterable[EventInput],
|
|
horizon_years: int,
|
|
) -> npt.NDArray[np.float64]:
|
|
"""Sum a list of events into a `(horizon_years,)` real-GBP array."""
|
|
out = np.zeros(horizon_years, dtype=np.float64)
|
|
for ev in events:
|
|
if not ev.enabled:
|
|
continue
|
|
start = max(0, int(ev.year_start))
|
|
if start >= horizon_years:
|
|
continue
|
|
|
|
if ev.delta_gbp_per_year:
|
|
end = ev.year_end if ev.year_end is not None else ev.year_start
|
|
end = min(int(end), horizon_years - 1)
|
|
if end >= start:
|
|
out[start:end + 1] += float(ev.delta_gbp_per_year)
|
|
|
|
if ev.one_time_amount_gbp:
|
|
out[start] += float(ev.one_time_amount_gbp)
|
|
return out
|