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

@ -21,10 +21,18 @@ from pathlib import Path
import click
import numpy as np
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from fire_planner.db import create_engine_from_env, make_session_factory
from fire_planner.db import (
AccountSnapshot,
ColSnapshot,
create_engine_from_env,
make_session_factory,
)
from fire_planner.examples.cli import examples_cli
from fire_planner.fire_target import TargetInputs, solve_target_nw
from fire_planner.geo import jurisdiction_for_city
from fire_planner.glide_path import get as get_glide
from fire_planner.ingest.wealthfolio import upsert_snapshots
from fire_planner.ingest.wealthfolio_pg import (
@ -32,7 +40,7 @@ from fire_planner.ingest.wealthfolio_pg import (
read_account_snapshots_from_pg,
)
from fire_planner.reporters.cli import format_scenario
from fire_planner.reporters.pg import write_run
from fire_planner.reporters.pg import upsert_fire_target, write_run
from fire_planner.returns.bootstrap import block_bootstrap
from fire_planner.returns.shiller import load_from_csv, synthetic_returns
from fire_planner.scenarios import (
@ -42,6 +50,16 @@ from fire_planner.scenarios import (
cartesian_scenarios,
)
from fire_planner.simulator import simulate
from fire_planner.spend_model import (
KIDS_END_YEAR,
KIDS_START_YEAR,
Case,
case_base_spend,
col_ratios_from_snapshot,
kids_annual_spend,
)
PENSION_UNLOCK_AGE = 57
log = logging.getLogger(__name__)
@ -364,6 +382,150 @@ def serve() -> None:
uvicorn.run("fire_planner.app:app", host="0.0.0.0", port=8080)
async def _load_col_latest(sess: AsyncSession, slug: str) -> ColSnapshot | None:
"""Most-recent col_snapshot row for a city slug (any source)."""
stmt = (select(ColSnapshot)
.where(ColSnapshot.city_slug == slug)
.order_by(ColSnapshot.snapshot_date.desc())
.limit(1))
return (await sess.execute(stmt)).scalars().first()
async def _all_city_slugs(sess: AsyncSession) -> list[str]:
stmt = select(ColSnapshot.city_slug).distinct().order_by(ColSnapshot.city_slug)
return list((await sess.execute(stmt)).scalars().all())
async def _current_liquid_and_pension(sess: AsyncSession) -> tuple[float, float]:
"""(liquid_nw, pension_nw) from the latest account_snapshot date.
Liquid = everything except WORKPLACE_PENSION; pension = WORKPLACE_PENSION.
"""
latest = (await sess.execute(
select(func.max(AccountSnapshot.snapshot_date)))).scalar_one_or_none()
if latest is None:
return 0.0, 0.0
rows = (await sess.execute(
select(AccountSnapshot.account_type, AccountSnapshot.market_value_gbp)
.where(AccountSnapshot.snapshot_date == latest))).all()
liquid = sum(float(v) for t, v in rows if t != "WORKPLACE_PENSION")
pension = sum(float(v) for t, v in rows if t == "WORKPLACE_PENSION")
return liquid, pension
@cli.command("recompute-fire-targets")
@click.option("--n-paths", type=int, default=2_000)
@click.option("--horizon", type=int, default=60)
@click.option("--countries", default="all", help="csv of city slugs, or 'all'.")
@click.option("--bar", type=float, default=0.99)
@click.option("--age", type=int, default=28, help="current age — sets the pension-unlock year.")
@click.option("--pension-now", type=float, default=None,
help="override current pension £; else read from account_snapshot.")
@click.option("--pension-real-growth", type=float, default=0.03)
@click.option("--kids-base", type=float, default=15_000.0)
@click.option("--home-amount", type=float, default=200_000.0)
@click.option("--home-year", type=int, default=0)
@click.option("--returns-csv", type=click.Path(path_type=Path), default=None)
@click.option("--seed", type=int, default=42)
def recompute_fire_targets_cmd(
n_paths: int,
horizon: int,
countries: str,
bar: float,
age: int,
pension_now: float | None,
pension_real_growth: float,
kids_base: float,
home_amount: float,
home_year: int,
returns_csv: Path | None,
seed: int,
) -> None:
"""Solve each Case's FIRE number per country and upsert fire_target.
Family gets both a no-home and a with-home target; Solo/Household get no-home
only. Targets seed on liquid NW; the pension is injected as a grown tranche.
"""
asyncio.run(_recompute_fire_targets(
n_paths, horizon, countries, bar, age, pension_now, pension_real_growth,
kids_base, home_amount, home_year, returns_csv, seed))
async def _recompute_fire_targets(
n_paths: int,
horizon: int,
countries: str,
bar: float,
age: int,
pension_now: float | None,
pension_real_growth: float,
kids_base: float,
home_amount: float,
home_year: int,
returns_csv: Path | None,
seed: int,
) -> None:
paths = _build_paths(seed, n_paths, horizon, returns_csv)
years_to_pension = max(0, PENSION_UNLOCK_AGE - age)
engine = create_engine_from_env()
factory = make_session_factory(engine)
written = 0
try:
async with factory() as sess:
london = await _load_col_latest(sess, "london")
if london is None:
raise click.ClickException("no london col_snapshot baseline; run col-seed first")
if pension_now is None:
_liquid, pension_now = await _current_liquid_and_pension(sess)
slugs = (await _all_city_slugs(sess) if countries.strip() == "all"
else [s.strip() for s in countries.split(",") if s.strip()])
click.echo(f"fire-targets: {len(slugs)} countries, pension £{pension_now:,.0f} "
f"-> unlocks in {years_to_pension}y, n_paths={n_paths}")
for slug in slugs:
col = await _load_col_latest(sess, slug)
if col is None:
click.echo(f" {slug}: no col_snapshot, skipped")
continue
ratios = col_ratios_from_snapshot(
city_no_rent=float(col.total_no_rent_gbp),
city_rent_1bed=float(col.rent_1bed_center_gbp),
london_no_rent=float(london.total_no_rent_gbp),
london_rent_1bed=float(london.rent_1bed_center_gbp),
)
jur = jurisdiction_for_city(slug)
kids_cf = kids_annual_spend(ratios, kids_base=kids_base)
for case in (Case.SOLO, Case.HOUSEHOLD, Case.FAMILY):
spend = case_base_spend(case, ratios)
home_variants = (False, True) if case is Case.FAMILY else (False,)
for with_home in home_variants:
inp = TargetInputs(
case=case, country_slug=slug, country_display=col.city_display,
jurisdiction=jur, annual_spend_gbp=spend, horizon_years=horizon,
glide_name="rising", bar=bar,
pension_now_gbp=float(pension_now),
pension_real_growth=pension_real_growth,
years_to_pension=years_to_pension,
kids_annual_gbp=(kids_cf if case is Case.FAMILY else 0.0),
kids_start_year=KIDS_START_YEAR, kids_end_year=KIDS_END_YEAR,
with_home=with_home, home_amount_gbp=home_amount, home_year=home_year,
)
# Bound the search to a sane SWR band (spend × 60 ≈
# 1.67% floor) so the binary search converges fast.
res = solve_target_nw(
paths, inp, hi=min(5_000_000.0, spend * 60.0), tol=15_000.0)
await upsert_fire_target(sess, inp, res, n_paths)
written += 1
tag = "+home" if with_home else ""
flag = "" if res.reached_bar else " (BAR NOT REACHED)"
click.echo(f" {case.value}/{slug}{tag}: spend £{spend:,.0f} "
f"-> target £{res.target_nw_gbp:,.0f} "
f"({res.success_at_target*100:.1f}%){flag}")
await sess.commit()
finally:
await engine.dispose()
click.echo(f"recompute-fire-targets done: {written} targets written")
cli.add_command(examples_cli)