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

View file

@ -3,7 +3,18 @@ from datetime import date, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import JSON, TIMESTAMP, Boolean, Date, Integer, Numeric, String, func, text
from sqlalchemy import (
JSON,
TIMESTAMP,
Boolean,
Date,
Integer,
Numeric,
String,
UniqueConstraint,
func,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@ -360,6 +371,47 @@ class FireExample(Base):
server_default=func.now())
class FireTarget(Base):
"""Solved FIRE number per (Case × country × with-home) for the countdown.
One row per combination; the Grafana countdown reads `target_nw_gbp` for the
selected country and diffs it against current liquid net worth. Seeded on
LIQUID net worth (the pension joins later as `pension_at_unlock_gbp`) see
ADR-0001. `reached_bar=False` flags a combination that can't hit the bar
within the search range (target then holds the search ceiling).
"""
__tablename__ = "fire_target"
__table_args__ = (
UniqueConstraint("case", "country_slug", "with_home", "bar",
name="uq_fire_target_case_country_home_bar"),
{"schema": SCHEMA_NAME},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
case: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
country_slug: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
country_display: Mapped[str] = mapped_column(String(128), nullable=False)
jurisdiction: Mapped[str] = mapped_column(String(32), nullable=False)
with_home: Mapped[bool] = mapped_column(Boolean, nullable=False,
server_default=text("false"))
bar: Mapped[Decimal] = mapped_column(Numeric(4, 3), nullable=False,
server_default=text("0.99"))
strategy: Mapped[str] = mapped_column(String(32), nullable=False,
server_default=text("'guyton_klinger'"))
annual_spend_gbp: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
target_nw_gbp: Mapped[Decimal] = mapped_column(Numeric(16, 2), nullable=False)
pension_at_unlock_gbp: Mapped[Decimal] = mapped_column(Numeric(16, 2), nullable=False,
server_default=text("0"))
success_at_target: Mapped[Decimal] = mapped_column(Numeric(6, 4), nullable=False)
reached_bar: Mapped[bool] = mapped_column(Boolean, nullable=False,
server_default=text("true"))
horizon_years: Mapped[int] = mapped_column(Integer, nullable=False)
n_paths: Mapped[int] = mapped_column(Integer, nullable=False)
updated_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True),
nullable=False,
server_default=func.now())
def create_engine_from_env() -> AsyncEngine:
url = os.environ["DB_CONNECTION_STRING"]
return create_async_engine(url, pool_pre_ping=True)