fire-planner/fire_planner/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

125 lines
4.2 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.

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