feat(fire-target): per-Case FIRE-number solver for the retirement countdown
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

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:
Viktor Barzin 2026-06-28 11:49:23 +00:00
parent 4bf1aaa96a
commit edb4d11352
15 changed files with 1072 additions and 6 deletions

78
tests/test_spend_model.py Normal file
View 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)