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