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>
149 lines
5.1 KiB
Python
149 lines
5.1 KiB
Python
"""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)
|