"""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. `category` classifies the event for the flex-spending engine: - "essential" — never trimmed - "discretionary" — trimmed when the portfolio drops vs ATH - "not_spending" — informational (no cashflow impact); still rendered on the milestone timeline """ year_start: int year_end: int | None = None delta_gbp_per_year: float = 0.0 one_time_amount_gbp: float | None = None category: str = "essential" 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 single `(horizon_years,)` real-GBP array. Sign convention: ``delta_gbp_per_year > 0`` is an **inflow** (income or delayed-pension start), ``< 0`` is an **outflow** (extra expense). Categories are NOT consulted here — every event contributes to the headline cashflow array. Flex spending (which trims discretionary outflows) is layered on top via ``events_to_category_outflows``. """ 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 def events_to_category_outflows( events: Iterable[EventInput], horizon_years: int, ) -> dict[str, npt.NDArray[np.float64]]: """Per-category per-year **outflow magnitudes** (always >= 0). Used by flex-spending: each year's discretionary outflow is the candidate the rules can trim. Inflow events (positive delta) and ``not_spending`` events are excluded — flex rules only trim spending. """ out: dict[str, npt.NDArray[np.float64]] = { "essential": np.zeros(horizon_years, dtype=np.float64), "discretionary": np.zeros(horizon_years, dtype=np.float64), } for ev in events: if not ev.enabled: continue if ev.category not in ("essential", "discretionary"): continue start = max(0, int(ev.year_start)) if start >= horizon_years: continue if ev.delta_gbp_per_year and ev.delta_gbp_per_year < 0: outflow = -float(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[ev.category][start:end + 1] += outflow if ev.one_time_amount_gbp and ev.one_time_amount_gbp < 0: out[ev.category][start] += -float(ev.one_time_amount_gbp) return out