fire-planner/fire_planner/fire_target.py
Viktor Barzin edb4d11352
Some checks are pending
Build and Push / lint-and-test (push) Waiting to run
Build and Push / build (push) Blocked by required conditions
Build and Push / deploy (push) Blocked by required conditions
Build and Push / notify-failure (push) Blocked by required conditions
feat(fire-target): per-Case FIRE-number solver for the retirement countdown
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>
2026-06-28 11:49:23 +00:00

149 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)