feat(fire-target): per-Case FIRE-number solver for the retirement countdown
Some checks are pending
Some checks are pending
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:
parent
4bf1aaa96a
commit
edb4d11352
15 changed files with 1072 additions and 6 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue