feat(fire-target): per-Case FIRE-number solver for the retirement countdown
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

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>
This commit is contained in:
Viktor Barzin 2026-06-28 11:49:23 +00:00
parent 4bf1aaa96a
commit edb4d11352
15 changed files with 1072 additions and 6 deletions

125
fire_planner/spend_model.py Normal file
View file

@ -0,0 +1,125 @@
"""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,
)