fire-planner: Wave 2 chart-first — flex spending, categorised life
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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.
This commit is contained in:
parent
9cc781a8d6
commit
64eb90c3dc
19 changed files with 2581 additions and 88 deletions
|
|
@ -33,6 +33,7 @@ from decimal import Decimal
|
|||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from fire_planner.flex_spending import FlexRule, applicable_cut
|
||||
from fire_planner.glide_path import GlideFn
|
||||
from fire_planner.strategies.base import StrategyState, WithdrawalStrategy
|
||||
from fire_planner.tax.base import TaxInputs, TaxRegime
|
||||
|
|
@ -186,6 +187,8 @@ def simulate(
|
|||
bucket_split: _BucketSplit = default_bucket_split,
|
||||
income_inflows: npt.NDArray[np.float64] | None = None,
|
||||
income_taxable: npt.NDArray[np.float64] | None = None,
|
||||
discretionary_outflows: npt.NDArray[np.float64] | None = None,
|
||||
flex_rules: list[FlexRule] | None = None,
|
||||
) -> SimulationResult:
|
||||
"""Run the MC simulation. `paths` shape: (n_paths, n_years, 3).
|
||||
|
||||
|
|
@ -223,6 +226,11 @@ def simulate(
|
|||
income_inflows = np.zeros(n_years, dtype=np.float64)
|
||||
if income_taxable is None:
|
||||
income_taxable = np.zeros(n_years, dtype=np.float64)
|
||||
if discretionary_outflows is None:
|
||||
discretionary_outflows = np.zeros(n_years, dtype=np.float64)
|
||||
rules = list(flex_rules) if flex_rules else []
|
||||
# Track running ATH per path so we can decide flex cuts each year.
|
||||
ath = np.full(n_paths, float(initial_portfolio), dtype=np.float64)
|
||||
|
||||
for y in range(n_years):
|
||||
alloc = glide(y)
|
||||
|
|
@ -246,6 +254,19 @@ def simulate(
|
|||
income_tax_breakdown = regime_at(y).compute_tax(
|
||||
TaxInputs(earned_income=Decimal(str(round(float(income_taxable[y]), 2)))))
|
||||
portfolio = portfolio - float(income_tax_breakdown.total)
|
||||
# Flex spending: per-path, decide the cut from this year's
|
||||
# drawdown-from-ATH and refund the trimmed discretionary
|
||||
# back to the portfolio. The cashflow_adjustments array already
|
||||
# subtracted the *baseline* discretionary, so we add back
|
||||
# `cut_pct * baseline` to leave only the post-cut amount drawn.
|
||||
if rules and discretionary_outflows[y] > 0.0:
|
||||
for p in range(n_paths):
|
||||
if ath[p] <= 0:
|
||||
continue
|
||||
drawdown = max(0.0, 1.0 - portfolio[p] / ath[p])
|
||||
cut = applicable_cut(drawdown, rules)
|
||||
if cut > 0:
|
||||
portfolio[p] += cut * float(discretionary_outflows[y])
|
||||
|
||||
# Strategy is per-path Python — 600k iterations at 60y × 10k paths.
|
||||
# Profiled: ~3 seconds for the full Trinity / GK / VPW set.
|
||||
|
|
@ -280,6 +301,9 @@ def simulate(
|
|||
|
||||
portfolio_history[:, y + 1] = np.clip(portfolio, a_min=0.0, a_max=None)
|
||||
portfolio = portfolio_history[:, y + 1]
|
||||
# Update running ATH per path so next year's flex decision uses
|
||||
# the post-close peak.
|
||||
np.maximum(ath, portfolio, out=ath)
|
||||
|
||||
# Success = portfolio stayed positive through every interim year.
|
||||
# Excludes the very last year-end because VPW deliberately drains
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue