fire-planner/tests/test_simulator_flex.py

71 lines
2.6 KiB
Python
Raw Normal View History

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
"""End-to-end test that flex-spending rules survive £ in the portfolio."""
from __future__ import annotations
import numpy as np
from fire_planner.flex_spending import FlexRule
from fire_planner.glide_path import static
from fire_planner.simulator import simulate
from fire_planner.strategies.trinity import TrinityStrategy
from fire_planner.tax.uae import UaeTaxRegime
def _flat_paths(n_paths: int, n_years: int, real_return: float = 0.0) -> np.ndarray:
"""Returns paths cube where real return == 0% — easy to reason about."""
paths = np.zeros((n_paths, n_years, 3), dtype=np.float64)
paths[:, :, 0] = real_return # nominal stocks
paths[:, :, 1] = real_return # nominal bonds
paths[:, :, 2] = 0.0 # cpi
return paths
def test_flex_rule_saves_money_at_drawdown() -> None:
"""A scenario that drops below ATH triggers a discretionary cut and
ends up richer than the same scenario with no flex rules."""
paths = _flat_paths(n_paths=10, n_years=5, real_return=-0.05)
initial = 1_000_000.0
common = dict(
paths=paths,
initial_portfolio=initial,
spending_target=10_000.0,
glide=static(1.0),
strategy=TrinityStrategy(),
regime=UaeTaxRegime(),
horizon_years=5,
cashflow_adjustments=np.full(5, -20_000.0, dtype=np.float64),
discretionary_outflows=np.full(5, 20_000.0, dtype=np.float64),
)
no_flex = simulate(**common)
with_flex = simulate(
**common,
flex_rules=[FlexRule(from_ath_pct=0.05, cut_discretionary_pct=0.50)],
)
no_flex_end = float(np.median(no_flex.portfolio_real[:, -1]))
with_flex_end = float(np.median(with_flex.portfolio_real[:, -1]))
assert with_flex_end > no_flex_end
assert no_flex_end > 0 # didn't ruin — meaningful comparison
def test_flex_rule_no_op_without_drawdown() -> None:
"""Strong-positive returns, never below ATH → flex rules do nothing."""
paths = _flat_paths(n_paths=10, n_years=5, real_return=0.10)
common = dict(
paths=paths,
initial_portfolio=1_000_000.0,
spending_target=40_000.0,
glide=static(1.0),
strategy=TrinityStrategy(),
regime=UaeTaxRegime(),
horizon_years=5,
cashflow_adjustments=np.full(5, -10_000.0, dtype=np.float64),
discretionary_outflows=np.full(5, 10_000.0, dtype=np.float64),
)
no_flex = simulate(**common)
with_flex = simulate(
**common,
flex_rules=[FlexRule(from_ath_pct=0.10, cut_discretionary_pct=0.50)],
)
assert np.allclose(no_flex.portfolio_real, with_flex.portfolio_real)