feat(fire-target): per-Case FIRE-number solver for the retirement countdown
Some checks are pending
Some checks are pending
Add a Monte-Carlo "FIRE number" solver so the wealth dashboard can show a £ countdown to retirement across life-stage cases, in today's money. Viktor wants to see, per country, how far his net worth is from being able to retire for good under three cases — Solo (his spend ×1.5), Household (+Anca ×1.5), Family (+2 kids) — with cost-of-living re-scaling per country and a 99% Guyton-Klinger success bar. - spend_model: per-Case real-GBP spend, COL-scaled (rent + non-rent essentials scale by country; Holidays fixed), ×1.5 safety. Constants sourced live from actualbudget (Viktor) / on-record (Anca). - geo: city -> tax jurisdiction (nomad fallback). - fire_target: binary-search the smallest LIQUID net worth where GK reaches the bar; pension modelled as a tranche unlocking at ~57, kids ramp + optional home as cashflows. New fire_target table (migration 0007) + idempotent upsert. - recompute-fire-targets CLI: solve every Case x country and persist for Grafana. - CONTEXT.md glossary + ADR-0001 (why MC-threshold on liquid NW, not 25x spend). Reuses the existing simulator unchanged (its cashflow hooks already supported pension/kids/home). 345 tests pass; mypy + ruff clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4bf1aaa96a
commit
edb4d11352
15 changed files with 1072 additions and 6 deletions
149
fire_planner/fire_target.py
Normal file
149
fire_planner/fire_target.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""Solve each Case's FIRE number — the smallest liquid net worth at which a
|
||||
Guyton-Klinger plan reaches the bar (default 99%).
|
||||
|
||||
The search is a binary search over ``initial_portfolio``: success is monotone in
|
||||
starting capital because the GK year-0 draw is an absolute real amount, so more
|
||||
seed always means a lower withdrawal rate and never a worse outcome.
|
||||
|
||||
Cashflows layered onto the run via the existing simulator hooks:
|
||||
- pension unlock — a grown lump that becomes available at age 57
|
||||
- kids ramp — an essential per-year outflow over the child-rearing window
|
||||
- home purchase — an optional one-time outflow
|
||||
|
||||
See ADR-0001 for why we seed on liquid NW and model the pension as a tranche.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from fire_planner.glide_path import get as get_glide
|
||||
from fire_planner.life_events import EventInput, events_to_cashflow_array
|
||||
from fire_planner.scenarios import _JURISDICTION_CONSTRUCTORS
|
||||
from fire_planner.simulator import RegimeFn, constant_regime, simulate
|
||||
from fire_planner.spend_model import Case
|
||||
from fire_planner.strategies.guyton_klinger import GuytonKlingerStrategy
|
||||
|
||||
DEFAULT_BAR = 0.99
|
||||
DEFAULT_PENSION_REAL_GROWTH = 0.03
|
||||
DEFAULT_HI_GBP = 5_000_000.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TargetInputs:
|
||||
"""Everything the solver needs for one (Case × country × with-home) target."""
|
||||
case: Case
|
||||
country_slug: str
|
||||
country_display: str
|
||||
jurisdiction: str
|
||||
annual_spend_gbp: float
|
||||
horizon_years: int
|
||||
glide_name: str = "rising"
|
||||
bar: float = DEFAULT_BAR
|
||||
# Pension: locked tranche that joins at age 57.
|
||||
pension_now_gbp: float = 0.0
|
||||
pension_real_growth: float = DEFAULT_PENSION_REAL_GROWTH
|
||||
years_to_pension: int = 0
|
||||
# Kids: essential per-year outflow (Family only).
|
||||
kids_annual_gbp: float = 0.0
|
||||
kids_start_year: int = 5
|
||||
kids_end_year: int = 22
|
||||
# Optional one-time home purchase.
|
||||
with_home: bool = False
|
||||
home_amount_gbp: float = 0.0
|
||||
home_year: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SolveResult:
|
||||
target_nw_gbp: float
|
||||
success_at_target: float
|
||||
pension_at_unlock_gbp: float
|
||||
reached_bar: bool
|
||||
|
||||
|
||||
def pension_at_unlock(inp: TargetInputs) -> float:
|
||||
"""Current pension value compounded at the assumed real rate to age 57."""
|
||||
if inp.pension_now_gbp <= 0:
|
||||
return 0.0
|
||||
years = max(0, inp.years_to_pension)
|
||||
return inp.pension_now_gbp * (1.0 + inp.pension_real_growth) ** years
|
||||
|
||||
|
||||
def build_cashflows(inp: TargetInputs, horizon: int) -> npt.NDArray[np.float64]:
|
||||
"""Per-year real-GBP cashflow array (pension inflow, kids/home outflows)."""
|
||||
events: list[EventInput] = []
|
||||
p_at = pension_at_unlock(inp)
|
||||
if p_at > 0 and 0 <= inp.years_to_pension < horizon:
|
||||
events.append(EventInput(year_start=inp.years_to_pension, one_time_amount_gbp=p_at))
|
||||
if inp.kids_annual_gbp > 0:
|
||||
events.append(EventInput(
|
||||
year_start=inp.kids_start_year,
|
||||
year_end=inp.kids_end_year,
|
||||
delta_gbp_per_year=-inp.kids_annual_gbp,
|
||||
))
|
||||
if inp.with_home and inp.home_amount_gbp > 0:
|
||||
events.append(EventInput(year_start=inp.home_year,
|
||||
one_time_amount_gbp=-inp.home_amount_gbp))
|
||||
return events_to_cashflow_array(events, horizon)
|
||||
|
||||
|
||||
def _regime(jurisdiction: str) -> RegimeFn:
|
||||
cls = _JURISDICTION_CONSTRUCTORS.get(jurisdiction)
|
||||
if cls is None:
|
||||
raise KeyError(f"Unknown jurisdiction: {jurisdiction!r}")
|
||||
return constant_regime(cls())
|
||||
|
||||
|
||||
def success_at_nw(
|
||||
paths: npt.NDArray[np.float64],
|
||||
initial: float,
|
||||
inp: TargetInputs,
|
||||
cashflows: npt.NDArray[np.float64],
|
||||
) -> float:
|
||||
"""Success rate of the GK plan seeded at ``initial`` liquid net worth."""
|
||||
result = simulate(
|
||||
paths=paths,
|
||||
initial_portfolio=initial,
|
||||
spending_target=inp.annual_spend_gbp,
|
||||
glide=get_glide(inp.glide_name),
|
||||
strategy=GuytonKlingerStrategy(),
|
||||
regime=_regime(inp.jurisdiction),
|
||||
horizon_years=inp.horizon_years,
|
||||
cashflow_adjustments=cashflows,
|
||||
)
|
||||
return result.success_rate
|
||||
|
||||
|
||||
def solve_target_nw(
|
||||
paths: npt.NDArray[np.float64],
|
||||
inp: TargetInputs,
|
||||
*,
|
||||
lo: float = 0.0,
|
||||
hi: float = DEFAULT_HI_GBP,
|
||||
tol: float = 5_000.0,
|
||||
max_iter: int = 40,
|
||||
) -> SolveResult:
|
||||
"""Binary-search the smallest seed NW in ``[lo, hi]`` meeting ``inp.bar``."""
|
||||
cashflows = build_cashflows(inp, inp.horizon_years)
|
||||
p_at = pension_at_unlock(inp)
|
||||
|
||||
s_hi = success_at_nw(paths, hi, inp, cashflows)
|
||||
if s_hi < inp.bar:
|
||||
# Even the ceiling can't reach the bar — report it, flagged.
|
||||
return SolveResult(hi, s_hi, p_at, reached_bar=False)
|
||||
if success_at_nw(paths, lo, inp, cashflows) >= inp.bar:
|
||||
return SolveResult(lo, 1.0, p_at, reached_bar=True)
|
||||
|
||||
for _ in range(max_iter):
|
||||
if hi - lo <= tol:
|
||||
break
|
||||
mid = 0.5 * (lo + hi)
|
||||
if success_at_nw(paths, mid, inp, cashflows) >= inp.bar:
|
||||
hi = mid
|
||||
else:
|
||||
lo = mid
|
||||
|
||||
return SolveResult(hi, success_at_nw(paths, hi, inp, cashflows), p_at, reached_bar=True)
|
||||
Loading…
Add table
Add a link
Reference in a new issue