fire-planner/tests/test_fire_target.py
Viktor Barzin 1b8809a01b
Some checks failed
Build and Push / lint-and-test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
Build and Push / deploy (push) Has been cancelled
Build and Push / notify-failure (push) Has been cancelled
fix(fire-target): Family/home targets monotonic (kills Family==Household)
The recompute solved each Case's FIRE number with an independent binary search,
so Monte-Carlo path noise + the coarse £15k tolerance made Family (Household + 2
kids) tie or even UNDERCUT Household (6 hard inversions + 5 exact ties across the
22 countries) — the ~£20k kids cost quantised to ~£0.

Now solve the Cases in increasing-cost order and lower-bound each by the previous
Case's target on the SAME return paths: a heavier Case (more spend / +kids /
+home) can never need less net worth than a lighter one, so
Solo <= Household <= Family <= Family+home holds by construction. tol tightened
15k -> 1k so the genuine but small kids/home increment resolves instead of
snapping to the previous grid step. Kids/home were already modelled correctly
(verified) — this is purely a solver-resolution + monotonicity fix.

Found + verified via the fire-countdown flaw-hunt workflow. 346 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:42:34 +00:00

131 lines
5.2 KiB
Python

"""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
def test_nested_lo_bound_guarantees_case_monotonicity() -> None:
"""The recompute solves Cases in increasing-cost order, lower-bounding each
by the previous target on the same paths. That must yield
Solo <= Household <= Family <= Family+home with NO inversions — the fix for
the 'Family == / < Household' Monte-Carlo-noise flaw."""
paths = _paths()
solo = solve_target_nw(paths, _inp(annual_spend_gbp=30_000.0), tol=1_000.0)
hh = solve_target_nw(paths, _inp(annual_spend_gbp=50_000.0),
lo=solo.target_nw_gbp, tol=1_000.0)
fam = solve_target_nw(paths, _inp(annual_spend_gbp=50_000.0, kids_annual_gbp=12_000.0),
lo=hh.target_nw_gbp, tol=1_000.0)
famh = solve_target_nw(paths, _inp(annual_spend_gbp=50_000.0, kids_annual_gbp=12_000.0,
with_home=True, home_amount_gbp=100_000.0),
lo=fam.target_nw_gbp, tol=1_000.0)
assert solo.target_nw_gbp <= hh.target_nw_gbp <= fam.target_nw_gbp <= famh.target_nw_gbp