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>
78 lines
2.9 KiB
Python
78 lines
2.9 KiB
Python
"""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)
|