feat(fire-target): per-Case FIRE-number solver for the retirement countdown
Some checks are pending
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>
This commit is contained in:
parent
4bf1aaa96a
commit
edb4d11352
15 changed files with 1072 additions and 6 deletions
78
tests/test_spend_model.py
Normal file
78
tests/test_spend_model.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""Spend model: per-Case real-GBP spend, COL-scaled by country.
|
||||
|
||||
London is the identity baseline (ratios = 1.0); cheaper countries scale the
|
||||
COL-driven buckets (rent, non-rent essentials, kids) down while Holidays stay
|
||||
fixed. The ×1.5 safety multiplier applies to the whole spend.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from fire_planner.spend_model import (
|
||||
ANCA,
|
||||
LONDON_RATIOS,
|
||||
VIKTOR,
|
||||
Case,
|
||||
ColRatios,
|
||||
case_base_spend,
|
||||
col_ratios_from_snapshot,
|
||||
kids_annual_spend,
|
||||
scaled_person_spend,
|
||||
)
|
||||
|
||||
|
||||
def test_london_identity_is_raw_sum() -> None:
|
||||
# Viktor's measured buckets sum to his nominal trailing-12mo spend.
|
||||
assert scaled_person_spend(VIKTOR, LONDON_RATIOS) == pytest.approx(40_492.0, abs=1.0)
|
||||
|
||||
|
||||
def test_holidays_are_fixed_across_countries() -> None:
|
||||
cheap = ColRatios(rent_ratio=0.5, non_rent_ratio=0.5)
|
||||
scaled = scaled_person_spend(VIKTOR, cheap)
|
||||
# rent + non-rent halve; holidays unchanged.
|
||||
expected = VIKTOR.rent * 0.5 + VIKTOR.non_rent_usual * 0.5 + VIKTOR.holidays
|
||||
assert scaled == pytest.approx(expected)
|
||||
# Holidays floor: spend can never drop below the fixed holiday spend.
|
||||
assert scaled > VIKTOR.holidays
|
||||
|
||||
|
||||
def test_safety_multiplier_applies_to_case() -> None:
|
||||
solo = case_base_spend(Case.SOLO, LONDON_RATIOS)
|
||||
assert solo == pytest.approx(scaled_person_spend(VIKTOR, LONDON_RATIOS) * 1.5)
|
||||
|
||||
|
||||
def test_household_adds_anca() -> None:
|
||||
hh = case_base_spend(Case.HOUSEHOLD, LONDON_RATIOS)
|
||||
expected = (scaled_person_spend(VIKTOR, LONDON_RATIOS)
|
||||
+ scaled_person_spend(ANCA, LONDON_RATIOS)) * 1.5
|
||||
assert hh == pytest.approx(expected)
|
||||
# Household ~£82k * 1.5 ≈ £121.6k at London prices.
|
||||
assert hh == pytest.approx(121_638.0, abs=50.0)
|
||||
|
||||
|
||||
def test_family_base_equals_household_kids_are_separate() -> None:
|
||||
# Kids are modelled as a cashflow, not folded into the GK spend target.
|
||||
assert case_base_spend(Case.FAMILY, LONDON_RATIOS) == pytest.approx(
|
||||
case_base_spend(Case.HOUSEHOLD, LONDON_RATIOS))
|
||||
|
||||
|
||||
def test_kids_are_col_driven_and_safety_scaled() -> None:
|
||||
assert kids_annual_spend(LONDON_RATIOS) == pytest.approx(15_000 * 1.5)
|
||||
cheap = ColRatios(rent_ratio=0.3, non_rent_ratio=0.5)
|
||||
# Kids scale by the non-rent (services) ratio.
|
||||
assert kids_annual_spend(cheap) == pytest.approx(15_000 * 0.5 * 1.5)
|
||||
|
||||
|
||||
def test_col_ratios_from_snapshot_sofia() -> None:
|
||||
# Sofia vs London (Numbeo, May 2026): rent 679/2317, no-rent 713/1092.
|
||||
r = col_ratios_from_snapshot(
|
||||
city_no_rent=713.0, city_rent_1bed=679.0,
|
||||
london_no_rent=1092.0, london_rent_1bed=2317.0,
|
||||
)
|
||||
assert r.rent_ratio == pytest.approx(679.0 / 2317.0)
|
||||
assert r.non_rent_ratio == pytest.approx(713.0 / 1092.0)
|
||||
|
||||
|
||||
def test_cheaper_country_lowers_case_spend() -> None:
|
||||
sofia = col_ratios_from_snapshot(713.0, 679.0, 1092.0, 2317.0)
|
||||
assert case_base_spend(Case.SOLO, sofia) < case_base_spend(Case.SOLO, LONDON_RATIOS)
|
||||
Loading…
Add table
Add a link
Reference in a new issue