fire-planner/tests/test_spend_model.py
Viktor Barzin edb4d11352
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
feat(fire-target): per-Case FIRE-number solver for the retirement countdown
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>
2026-06-28 11:49:23 +00:00

78 lines
2.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)