fire-planner/tests/test_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

59 lines
2.2 KiB
Python

"""Tests for the flex-spending engine."""
from __future__ import annotations
import numpy as np
import pytest
from fire_planner.flex_spending import FlexRule, applicable_cut, cuts_per_year
def test_applicable_cut_picks_deepest_rule() -> None:
rules = [
FlexRule(from_ath_pct=0.10, cut_discretionary_pct=0.20),
FlexRule(from_ath_pct=0.30, cut_discretionary_pct=0.60),
FlexRule(from_ath_pct=0.50, cut_discretionary_pct=0.90),
]
# No drawdown — no cut.
assert applicable_cut(0.0, rules) == 0.0
# 9% drop — below first threshold.
assert applicable_cut(0.09, rules) == 0.0
# 15% drop — only first rule fires.
assert applicable_cut(0.15, rules) == pytest.approx(0.20)
# 35% drop — first + second; deepest cut wins (0.60, not 0.80).
assert applicable_cut(0.35, rules) == pytest.approx(0.60)
# 60% drop — all three; 0.90 wins.
assert applicable_cut(0.60, rules) == pytest.approx(0.90)
def test_applicable_cut_empty_rules() -> None:
assert applicable_cut(0.5, []) == 0.0
def test_cuts_per_year_handles_running_ath() -> None:
# Single path. Year 0 seed=1000, year 1 = 1200 (new ATH), year 2 = 800
# (-33% from ATH 1200), year 3 = 900 (still -25% from ATH 1200), year
# 4 = 1300 (new ATH).
portfolio = np.array([[1000, 1200, 800, 900, 1300]], dtype=np.float64)
rules = [
FlexRule(from_ath_pct=0.10, cut_discretionary_pct=0.20),
FlexRule(from_ath_pct=0.30, cut_discretionary_pct=0.60),
]
cuts = cuts_per_year(portfolio, rules)
# cuts[:, y] uses portfolio[:, y] (start-of-year decision based on
# the prior year's close).
# y=0: portfolio=1000 == ATH → 0
# y=1: portfolio=1200 == ATH → 0
# y=2: drawdown = 1 - 800/1200 = 0.333 → 0.60
# y=3: drawdown = 1 - 900/1200 = 0.25 → 0.20
assert cuts.shape == (1, 4)
assert cuts[0, 0] == pytest.approx(0.0)
assert cuts[0, 1] == pytest.approx(0.0)
assert cuts[0, 2] == pytest.approx(0.60)
assert cuts[0, 3] == pytest.approx(0.20)
def test_cuts_per_year_no_rules_returns_zeros() -> None:
portfolio = np.array([[1000, 800, 600]], dtype=np.float64)
cuts = cuts_per_year(portfolio, [])
assert cuts.shape == (1, 2)
assert (cuts == 0).all()