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
|
|
|
"""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
|
fire-planner: Wave 2 chart-first — flex spending, categorised life
events, interactive Visx Gantt + spending-profile chart
Charts are now the primary editor for life events. The Plan-tab body
re-orders to make charts ~80% of viewport real-estate; legacy form
sections are collapsed into a drawer.
Backend:
- alembic 0004: life_event.category enum (essential / discretionary /
not_spending). Defaults to essential so existing rows keep their
full spending impact.
- Simulator gains discretionary_outflows + flex_rules params. Tracks
per-path running ATH, applies the deepest applicable cut to
discretionary outflows when portfolio drops vs ATH (PLab-style flex
spending). Cut amount stays in the portfolio (refund pattern).
- New flex_spending module with FlexRule + applicable_cut +
cuts_per_year (vectorised). Sortable rules; "deepest cut wins" so
users specify cumulative cuts at each tier.
- New /scenarios/{id}/spending-profile endpoint returning per-year
base / essential / discretionary / flex_cut / total breakdown.
- SimulateRequest gains flex_rules + life_event.category roundtrip.
- 8 new tests; 246 total pytest pass; mypy + ruff clean.
Frontend (Visx + ECharts):
- Installed @visx/{scale,shape,group,axis,event,responsive,tooltip}
for native SVG drag interactions.
- New <SpendingProfileChart> — Visx stacked-area of base/essential/
discretionary with red flex-cut overlay, hover tooltip, click-to-
scrub-year.
- New <EventGantt> — interactive Visx Gantt:
* Click empty space → popover create at that year (default
essential spending event)
* Click a bar → inline edit popover (name, kind, range, £/y,
category) with delete button
* Drag bar middle → moves the whole event (year-resolution snap)
* Drag bar edges → resizes year_start / year_end
* All gestures persist via PATCH /life-events/{id}
- New <FlexRulesEditor> — list of {from_ath_pct, cut} tiers, save-on-
change to scenario.config_json.flex_rules.
- Plan-tab redesign: NW fan dominant top with floating stat badges
(Year/Age/NW/Δ NW/Spending/Eff. tax) over the chart; spending-
profile chart middle; Gantt bottom; flex-rules editor; legacy form
sections in a collapsed <details> drawer.
- Frontend typecheck + 7 vitest tests + production build all clean.
2026-05-10 16:49:04 +00:00
|
|
|
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
|
|
|
|
|
"""
|
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
|
|
|
year_start: int
|
|
|
|
|
year_end: int | None = None
|
|
|
|
|
delta_gbp_per_year: float = 0.0
|
|
|
|
|
one_time_amount_gbp: float | None = None
|
fire-planner: Wave 2 chart-first — flex spending, categorised life
events, interactive Visx Gantt + spending-profile chart
Charts are now the primary editor for life events. The Plan-tab body
re-orders to make charts ~80% of viewport real-estate; legacy form
sections are collapsed into a drawer.
Backend:
- alembic 0004: life_event.category enum (essential / discretionary /
not_spending). Defaults to essential so existing rows keep their
full spending impact.
- Simulator gains discretionary_outflows + flex_rules params. Tracks
per-path running ATH, applies the deepest applicable cut to
discretionary outflows when portfolio drops vs ATH (PLab-style flex
spending). Cut amount stays in the portfolio (refund pattern).
- New flex_spending module with FlexRule + applicable_cut +
cuts_per_year (vectorised). Sortable rules; "deepest cut wins" so
users specify cumulative cuts at each tier.
- New /scenarios/{id}/spending-profile endpoint returning per-year
base / essential / discretionary / flex_cut / total breakdown.
- SimulateRequest gains flex_rules + life_event.category roundtrip.
- 8 new tests; 246 total pytest pass; mypy + ruff clean.
Frontend (Visx + ECharts):
- Installed @visx/{scale,shape,group,axis,event,responsive,tooltip}
for native SVG drag interactions.
- New <SpendingProfileChart> — Visx stacked-area of base/essential/
discretionary with red flex-cut overlay, hover tooltip, click-to-
scrub-year.
- New <EventGantt> — interactive Visx Gantt:
* Click empty space → popover create at that year (default
essential spending event)
* Click a bar → inline edit popover (name, kind, range, £/y,
category) with delete button
* Drag bar middle → moves the whole event (year-resolution snap)
* Drag bar edges → resizes year_start / year_end
* All gestures persist via PATCH /life-events/{id}
- New <FlexRulesEditor> — list of {from_ath_pct, cut} tiers, save-on-
change to scenario.config_json.flex_rules.
- Plan-tab redesign: NW fan dominant top with floating stat badges
(Year/Age/NW/Δ NW/Spending/Eff. tax) over the chart; spending-
profile chart middle; Gantt bottom; flex-rules editor; legacy form
sections in a collapsed <details> drawer.
- Frontend typecheck + 7 vitest tests + production build all clean.
2026-05-10 16:49:04 +00:00
|
|
|
category: str = "essential"
|
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
|
|
|
enabled: bool = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def events_to_cashflow_array(
|
|
|
|
|
events: Iterable[EventInput],
|
|
|
|
|
horizon_years: int,
|
|
|
|
|
) -> npt.NDArray[np.float64]:
|
fire-planner: Wave 2 chart-first — flex spending, categorised life
events, interactive Visx Gantt + spending-profile chart
Charts are now the primary editor for life events. The Plan-tab body
re-orders to make charts ~80% of viewport real-estate; legacy form
sections are collapsed into a drawer.
Backend:
- alembic 0004: life_event.category enum (essential / discretionary /
not_spending). Defaults to essential so existing rows keep their
full spending impact.
- Simulator gains discretionary_outflows + flex_rules params. Tracks
per-path running ATH, applies the deepest applicable cut to
discretionary outflows when portfolio drops vs ATH (PLab-style flex
spending). Cut amount stays in the portfolio (refund pattern).
- New flex_spending module with FlexRule + applicable_cut +
cuts_per_year (vectorised). Sortable rules; "deepest cut wins" so
users specify cumulative cuts at each tier.
- New /scenarios/{id}/spending-profile endpoint returning per-year
base / essential / discretionary / flex_cut / total breakdown.
- SimulateRequest gains flex_rules + life_event.category roundtrip.
- 8 new tests; 246 total pytest pass; mypy + ruff clean.
Frontend (Visx + ECharts):
- Installed @visx/{scale,shape,group,axis,event,responsive,tooltip}
for native SVG drag interactions.
- New <SpendingProfileChart> — Visx stacked-area of base/essential/
discretionary with red flex-cut overlay, hover tooltip, click-to-
scrub-year.
- New <EventGantt> — interactive Visx Gantt:
* Click empty space → popover create at that year (default
essential spending event)
* Click a bar → inline edit popover (name, kind, range, £/y,
category) with delete button
* Drag bar middle → moves the whole event (year-resolution snap)
* Drag bar edges → resizes year_start / year_end
* All gestures persist via PATCH /life-events/{id}
- New <FlexRulesEditor> — list of {from_ath_pct, cut} tiers, save-on-
change to scenario.config_json.flex_rules.
- Plan-tab redesign: NW fan dominant top with floating stat badges
(Year/Age/NW/Δ NW/Spending/Eff. tax) over the chart; spending-
profile chart middle; Gantt bottom; flex-rules editor; legacy form
sections in a collapsed <details> drawer.
- Frontend typecheck + 7 vitest tests + production build all clean.
2026-05-10 16:49:04 +00:00
|
|
|
"""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``.
|
|
|
|
|
"""
|
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
|
|
|
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
|
fire-planner: Wave 2 chart-first — flex spending, categorised life
events, interactive Visx Gantt + spending-profile chart
Charts are now the primary editor for life events. The Plan-tab body
re-orders to make charts ~80% of viewport real-estate; legacy form
sections are collapsed into a drawer.
Backend:
- alembic 0004: life_event.category enum (essential / discretionary /
not_spending). Defaults to essential so existing rows keep their
full spending impact.
- Simulator gains discretionary_outflows + flex_rules params. Tracks
per-path running ATH, applies the deepest applicable cut to
discretionary outflows when portfolio drops vs ATH (PLab-style flex
spending). Cut amount stays in the portfolio (refund pattern).
- New flex_spending module with FlexRule + applicable_cut +
cuts_per_year (vectorised). Sortable rules; "deepest cut wins" so
users specify cumulative cuts at each tier.
- New /scenarios/{id}/spending-profile endpoint returning per-year
base / essential / discretionary / flex_cut / total breakdown.
- SimulateRequest gains flex_rules + life_event.category roundtrip.
- 8 new tests; 246 total pytest pass; mypy + ruff clean.
Frontend (Visx + ECharts):
- Installed @visx/{scale,shape,group,axis,event,responsive,tooltip}
for native SVG drag interactions.
- New <SpendingProfileChart> — Visx stacked-area of base/essential/
discretionary with red flex-cut overlay, hover tooltip, click-to-
scrub-year.
- New <EventGantt> — interactive Visx Gantt:
* Click empty space → popover create at that year (default
essential spending event)
* Click a bar → inline edit popover (name, kind, range, £/y,
category) with delete button
* Drag bar middle → moves the whole event (year-resolution snap)
* Drag bar edges → resizes year_start / year_end
* All gestures persist via PATCH /life-events/{id}
- New <FlexRulesEditor> — list of {from_ath_pct, cut} tiers, save-on-
change to scenario.config_json.flex_rules.
- Plan-tab redesign: NW fan dominant top with floating stat badges
(Year/Age/NW/Δ NW/Spending/Eff. tax) over the chart; spending-
profile chart middle; Gantt bottom; flex-rules editor; legacy form
sections in a collapsed <details> drawer.
- Frontend typecheck + 7 vitest tests + production build all clean.
2026-05-10 16:49:04 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|