"""FIRE-number solver: smallest liquid NW where GK reaches the bar. Uses deterministic fixed-return paths so thresholds are exact step functions and the ordering properties (pension lowers the target, kids/home raise it) hold without statistical noise. """ from __future__ import annotations import pytest from fire_planner.fire_target import ( TargetInputs, build_cashflows, pension_at_unlock, solve_target_nw, success_at_nw, ) from fire_planner.spend_model import Case from tests.test_simulator import fixed_paths def _paths(n_years: int = 30): # 2% nominal everything -> 0% real return; clean arithmetic. return fixed_paths(n_paths=1, n_years=n_years, stock_ret=0.02, bond_ret=0.02, cpi=0.02) def _inp(**over) -> TargetInputs: base = dict( case=Case.SOLO, country_slug="kuala-lumpur", country_display="Kuala Lumpur", jurisdiction="malaysia", # 0% on foreign income -> no tax drag annual_spend_gbp=40_000.0, horizon_years=30, glide_name="static_60_40", ) base.update(over) return TargetInputs(**base) def test_pension_at_unlock_compounds_real_growth() -> None: inp = _inp(pension_now_gbp=100_000.0, pension_real_growth=0.03, years_to_pension=10) assert pension_at_unlock(inp) == pytest.approx(100_000 * 1.03 ** 10) def test_build_cashflows_places_pension_kids_home() -> None: inp = _inp( pension_now_gbp=100_000.0, pension_real_growth=0.0, years_to_pension=10, kids_annual_gbp=10_000.0, kids_start_year=5, kids_end_year=8, with_home=True, home_amount_gbp=50_000.0, home_year=0, ) cf = build_cashflows(inp, inp.horizon_years) assert cf.shape == (30,) assert cf[10] == pytest.approx(100_000.0 - 0.0) # pension lump (no growth) ... # ... but home is at year 0 and kids at 5-8, so year 10 is pension only. assert cf[0] == pytest.approx(-50_000.0) # home outflow assert cf[5] == pytest.approx(-10_000.0) # kids ramp assert cf[8] == pytest.approx(-10_000.0) assert cf[9] == pytest.approx(0.0) # kids ended def test_success_is_monotone_in_net_worth() -> None: inp = _inp() cf = build_cashflows(inp, inp.horizon_years) s_low = success_at_nw(_paths(), 300_000.0, inp, cf) s_high = success_at_nw(_paths(), 3_000_000.0, inp, cf) assert s_low <= s_high assert s_high == pytest.approx(1.0) def test_solver_finds_a_threshold() -> None: inp = _inp() res = solve_target_nw(_paths(), inp, tol=2_000.0) assert res.reached_bar # At the target, the bar is met; just below it, it is not. cf = build_cashflows(inp, inp.horizon_years) assert success_at_nw(_paths(), res.target_nw_gbp, inp, cf) >= inp.bar assert success_at_nw(_paths(), res.target_nw_gbp - 5_000.0, inp, cf) < inp.bar def test_pension_lowers_target() -> None: no_pension = solve_target_nw(_paths(), _inp(), tol=2_000.0) with_pension = solve_target_nw( _paths(), _inp(pension_now_gbp=200_000.0, pension_real_growth=0.0, years_to_pension=10), tol=2_000.0, ) assert with_pension.target_nw_gbp < no_pension.target_nw_gbp def test_kids_raise_target() -> None: no_kids = solve_target_nw(_paths(), _inp(), tol=2_000.0) with_kids = solve_target_nw( _paths(), _inp(kids_annual_gbp=12_000.0, kids_start_year=5, kids_end_year=22), tol=2_000.0, ) assert with_kids.target_nw_gbp > no_kids.target_nw_gbp def test_home_raises_target_meaningfully() -> None: no_home = solve_target_nw(_paths(), _inp(), tol=2_000.0) with_home = solve_target_nw( _paths(), _inp(with_home=True, home_amount_gbp=100_000.0, home_year=0), tol=2_000.0, ) # A home costs money, so the target rises — by a non-trivial amount. The # increase is < face value because GK anchors its draw rate to the seed and # absorbs part of a one-time hit via later guardrail cuts. assert with_home.target_nw_gbp > no_home.target_nw_gbp + 10_000.0 def test_unreachable_bar_returns_not_reached() -> None: # Spend far above what any NW in range can sustain. res = solve_target_nw(_paths(), _inp(annual_spend_gbp=2_000_000.0), hi=1_000_000.0, tol=2_000.0) assert not res.reached_bar