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