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