fire-planner/fire_planner/flex_spending.py
Viktor Barzin 64eb90c3dc
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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

89 lines
3.6 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.

"""ProjectionLab-style flex-spending rules.
A `FlexRule` says "if the portfolio is at least ``from_ath_pct`` below its
running all-time-high, trim discretionary spending by ``cut_discretionary_pct``".
Multiple rules stack via "deepest applicable cut wins" — users specify
*cumulative* cuts at each tier, so a [-0.10 → 20%, -0.30 → 60%] config
trims by 60% (not 80%) at -30%.
The engine path:
per year y, per path p:
drawdown[p,y] = 1 - portfolio[p,y] / ath[p,y]
cut_pct[p,y] = max(rule.cut for rule in flex_rules if drawdown[p,y] >= rule.from_ath_pct)
discretionary_after_flex[p,y] = discretionary_baseline[y] * (1 - cut_pct[p,y])
The cuts are applied to the *baseline* discretionary spend each year (so a
£10k/y travel budget cut by 60% becomes £4k that year), and the saved
amount is *not* drawn from the portfolio. The simulator subtracts the
saved amount from the cashflow drawdown before calling the strategy.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import numpy.typing as npt
@dataclass(frozen=True)
class FlexRule:
"""Engine-level flex rule. ``from_ath_pct`` is the absolute drop
magnitude (positive fraction); ``cut_discretionary_pct`` is the
fraction to remove from discretionary spending at that depth."""
from_ath_pct: float
cut_discretionary_pct: float
def applicable_cut(drawdown: float, rules: list[FlexRule]) -> float:
"""Return the cut fraction for a single (path, year) pair.
``drawdown`` is 1 portfolio/ath (in the [0, 1] range — clamp inside
the simulator before calling). The deepest rule whose threshold is
satisfied wins.
"""
if not rules:
return 0.0
best = 0.0
for rule in rules:
if drawdown >= rule.from_ath_pct and rule.cut_discretionary_pct > best:
best = rule.cut_discretionary_pct
return best
def cuts_per_year(
portfolio_real: npt.NDArray[np.float64],
rules: list[FlexRule],
) -> npt.NDArray[np.float64]:
"""Vectorised version of ``applicable_cut`` across every (path, year).
``portfolio_real`` shape: ``(n_paths, n_years + 1)`` — index 0 is the
seed, last column is the horizon. Returns ``(n_paths, n_years)``: the
cut applied at the start of year ``y`` is decided by the portfolio
*after year y-1's close* (i.e. column ``y`` in the input).
"""
if not rules or portfolio_real.size == 0:
return np.zeros((portfolio_real.shape[0], portfolio_real.shape[1] - 1),
dtype=np.float64)
n_paths, ncols = portfolio_real.shape
n_years = ncols - 1
# Running ATH per path. np.maximum.accumulate over axis=1 gives us
# the running max — exactly what we want.
ath = np.maximum.accumulate(portfolio_real, axis=1)
# Avoid divide-by-zero. If ATH is 0 (only happens if seed is 0 and the
# portfolio never grew), drawdown is treated as 0.
safe_ath = np.where(ath > 0, ath, 1.0)
drawdown = np.clip(1.0 - portfolio_real / safe_ath, 0.0, 1.0)
cuts = np.zeros((n_paths, n_years), dtype=np.float64)
sorted_rules = sorted(rules,
key=lambda r: r.cut_discretionary_pct,
reverse=True)
for rule in sorted_rules:
# Each rule's cut applies wherever drawdown >= threshold AND a
# higher cut hasn't already been recorded (because we iterate
# rules from largest cut down).
# Drawdown at year y end-of-year-(y-1) — column y of drawdown.
mask = (drawdown[:, :n_years] >= rule.from_ath_pct) & (
cuts < rule.cut_discretionary_pct)
cuts[mask] = rule.cut_discretionary_pct
return cuts