126 lines
4.2 KiB
Python
126 lines
4.2 KiB
Python
|
|
"""Real-GBP spend model for the FIRE-countdown Cases.
|
|||
|
|
|
|||
|
|
Three life-stage Cases, each an annual spend in TODAY's money, re-scaled to a
|
|||
|
|
chosen country's cost of living:
|
|||
|
|
|
|||
|
|
- ``SOLO`` — Viktor only
|
|||
|
|
- ``HOUSEHOLD`` — Viktor + Anca
|
|||
|
|
- ``FAMILY`` — Household + 2 kids (kids handled as a cashflow, see
|
|||
|
|
:mod:`fire_planner.fire_target`, not folded into the GK spend target)
|
|||
|
|
|
|||
|
|
Each person's spend splits into three buckets, scaled differently by country:
|
|||
|
|
|
|||
|
|
- ``rent`` — scales by the 1-bed rent ratio (city / London)
|
|||
|
|
- ``non_rent_usual`` — scales by the no-rent basket ratio (city / London)
|
|||
|
|
- ``holidays`` — FIXED: globally priced, unchanged by where you live
|
|||
|
|
|
|||
|
|
A safety multiplier (Viktor's ×1.5) is applied to the whole annual spend.
|
|||
|
|
Everything is real GBP; the simulator runs in real terms, so no inflation
|
|||
|
|
handling lives here.
|
|||
|
|
|
|||
|
|
The per-person constants are sourced from the actualbudget HTTP API
|
|||
|
|
(trailing 12 months, 2025-06..2026-05). Viktor's are live category-level
|
|||
|
|
figures; Anca's are her on-record split (2026-06) pending a live re-pull —
|
|||
|
|
the household total still validates at ~£82k against the recorded ~£80k.
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from dataclasses import dataclass
|
|||
|
|
from enum import Enum
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Case(str, Enum):
|
|||
|
|
"""A life-stage the countdown tracks."""
|
|||
|
|
SOLO = "solo"
|
|||
|
|
HOUSEHOLD = "household"
|
|||
|
|
FAMILY = "family"
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class PersonSpend:
|
|||
|
|
"""One person's real-GBP annual spend, split by COL behaviour."""
|
|||
|
|
rent: float
|
|||
|
|
non_rent_usual: float
|
|||
|
|
holidays: float
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class ColRatios:
|
|||
|
|
"""Cost-of-living ratios of a country relative to London (London = 1.0)."""
|
|||
|
|
rent_ratio: float
|
|||
|
|
non_rent_ratio: float
|
|||
|
|
|
|||
|
|
|
|||
|
|
# London is the measurement baseline, so its ratios are the identity.
|
|||
|
|
LONDON_RATIOS = ColRatios(rent_ratio=1.0, non_rent_ratio=1.0)
|
|||
|
|
|
|||
|
|
# --- Sourced constants (actualbudget, trailing 12mo 2025-06..2026-05) ---
|
|||
|
|
# Viktor: live category-level pull. rent £17,406; non-rent Usual £13,704
|
|||
|
|
# (Eating Out, Bills, Groceries, Shopping, Entertainment, Gifts, Gym, Commute);
|
|||
|
|
# Holidays £9,382 (Travel + Work Travel).
|
|||
|
|
VIKTOR = PersonSpend(rent=17_406.0, non_rent_usual=13_704.0, holidays=9_382.0)
|
|||
|
|
# Anca: on-record split (2026-06). total ~£40,600 incl. ~£11,250 rent share.
|
|||
|
|
# TODO: re-pull live once her actualbudget http-api has her budget downloaded.
|
|||
|
|
ANCA = PersonSpend(rent=11_250.0, non_rent_usual=23_350.0, holidays=6_000.0)
|
|||
|
|
|
|||
|
|
# 2 kids combined, London terms; COL-driven (childcare/school ~ local services).
|
|||
|
|
KIDS_BASE_GBP = 15_000.0
|
|||
|
|
KIDS_START_YEAR = 5
|
|||
|
|
KIDS_END_YEAR = 22
|
|||
|
|
|
|||
|
|
# Viktor's chosen padding on measured real spend.
|
|||
|
|
SAFETY_MULTIPLIER = 1.5
|
|||
|
|
|
|||
|
|
|
|||
|
|
def scaled_person_spend(person: PersonSpend, ratios: ColRatios) -> float:
|
|||
|
|
"""A person's real-GBP annual spend in a country (pre safety multiplier)."""
|
|||
|
|
return (person.rent * ratios.rent_ratio
|
|||
|
|
+ person.non_rent_usual * ratios.non_rent_ratio
|
|||
|
|
+ person.holidays)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def case_base_spend(
|
|||
|
|
case: Case,
|
|||
|
|
ratios: ColRatios,
|
|||
|
|
*,
|
|||
|
|
viktor: PersonSpend = VIKTOR,
|
|||
|
|
anca: PersonSpend = ANCA,
|
|||
|
|
safety: float = SAFETY_MULTIPLIER,
|
|||
|
|
) -> float:
|
|||
|
|
"""The GK spending target for a Case in a country, with safety applied.
|
|||
|
|
|
|||
|
|
Family equals Household here — kids are layered on as a cashflow so the GK
|
|||
|
|
guardrails flex the household base but never the (essential) kids cost.
|
|||
|
|
"""
|
|||
|
|
base = scaled_person_spend(viktor, ratios)
|
|||
|
|
if case in (Case.HOUSEHOLD, Case.FAMILY):
|
|||
|
|
base += scaled_person_spend(anca, ratios)
|
|||
|
|
return base * safety
|
|||
|
|
|
|||
|
|
|
|||
|
|
def kids_annual_spend(
|
|||
|
|
ratios: ColRatios,
|
|||
|
|
*,
|
|||
|
|
kids_base: float = KIDS_BASE_GBP,
|
|||
|
|
safety: float = SAFETY_MULTIPLIER,
|
|||
|
|
) -> float:
|
|||
|
|
"""Annual real-GBP kids cost in a country (childcare/school = local services)."""
|
|||
|
|
return kids_base * ratios.non_rent_ratio * safety
|
|||
|
|
|
|||
|
|
|
|||
|
|
def col_ratios_from_snapshot(
|
|||
|
|
city_no_rent: float,
|
|||
|
|
city_rent_1bed: float,
|
|||
|
|
london_no_rent: float,
|
|||
|
|
london_rent_1bed: float,
|
|||
|
|
) -> ColRatios:
|
|||
|
|
"""Build :class:`ColRatios` from ``col_snapshot`` figures.
|
|||
|
|
|
|||
|
|
rent ratio = city 1-bed rent / London 1-bed rent;
|
|||
|
|
non-rent ratio = city no-rent basket / London no-rent basket.
|
|||
|
|
"""
|
|||
|
|
return ColRatios(
|
|||
|
|
rent_ratio=city_rent_1bed / london_rent_1bed,
|
|||
|
|
non_rent_ratio=city_no_rent / london_no_rent,
|
|||
|
|
)
|