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

533 lines
21 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.

"""click CLI entrypoint.
Sub-commands:
- migrate — alembic upgrade head
- ingest [wealthfolio] — pull from wealthfolio_sync PG mirror into account_snapshot
- simulate — run a single scenario, pretty-print
- recompute-all — run the 120-scenario Cartesian, persist all
- serve — run the FastAPI on-demand /recompute server
"""
from __future__ import annotations
import asyncio
import logging
import os
import subprocess
import sys
import time
from datetime import date
from decimal import Decimal
from pathlib import Path
import click
import numpy as np
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
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 (
create_wf_sync_engine_from_env,
read_account_snapshots_from_pg,
)
from fire_planner.reporters.cli import format_scenario
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 (
ScenarioSpec,
build_regime_schedule,
build_strategy,
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__)
@click.group()
def cli() -> None:
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"))
@cli.command()
def migrate() -> None:
"""Run `alembic upgrade head`."""
rc = subprocess.run(["alembic", "upgrade", "head"], check=False)
sys.exit(rc.returncode)
@cli.command("col-seed")
@click.option("--ttl-days",
type=int,
default=365,
help="Cache TTL in days (default 365 — matches Viktor's 1y choice).")
def col_seed(ttl_days: int) -> None:
"""Seed `col_snapshot` from baseline.py BASELINES.
Idempotent — uses upsert on (city_slug, source_name). Run once after
the alembic migration creates the table. Subsequent live-scrape
refreshes (Phase 3 CronJob) supersede these rows; the baseline
fallback remains as a last-resort source.
"""
asyncio.run(_col_seed(ttl_days))
async def _col_seed(ttl_days: int) -> None:
from fire_planner.col.baseline import BASELINES
from fire_planner.col.cache import upsert as col_upsert
engine = create_engine_from_env()
factory = make_session_factory(engine)
try:
async with factory() as sess:
for slug, idx in BASELINES.items():
# Tag the source as `baseline` rather than `numbeo` so a
# later live scrape (source_name='numbeo') doesn't conflict
# on the (city_slug, source_name) unique constraint.
tagged = idx.model_copy(
update={"source": idx.source.model_copy(update={"name": "baseline"})}
)
await col_upsert(sess, tagged, ttl_days=ttl_days)
click.echo(f" seeded {slug:20s} total={idx.total_single_with_rent_gbp} GBP")
finally:
await engine.dispose()
click.echo(f"\ncol-seed: {len(BASELINES)} cities upserted (ttl_days={ttl_days}).")
@cli.command("col-refresh-stale")
@click.option("--within-days",
type=int,
default=7,
help="Refresh rows whose expires_at is within this many days.")
@click.option("--ttl-days",
type=int,
default=365,
help="TTL for re-written rows (default 365).")
def col_refresh_stale(within_days: int, ttl_days: int) -> None:
"""Re-scrape COL rows that are within `within_days` of expiry.
Designed for the weekly CronJob. Walks every distinct city_slug in
`col_snapshot` whose newest row will expire within the window,
calls Numbeo+Expatistan via `service.lookup_city_cached`, which
upserts the result. Idempotent — no-op for fresh rows.
"""
asyncio.run(_col_refresh_stale(within_days, ttl_days))
async def _col_refresh_stale(within_days: int, ttl_days: int) -> None:
from sqlalchemy import select, text
from fire_planner.col.service import lookup_city_cached
from fire_planner.db import ColSnapshot
engine = create_engine_from_env()
factory = make_session_factory(engine)
threshold = f"NOW() + INTERVAL '{int(within_days)} days'"
refreshed = 0
failed = 0
try:
async with factory() as sess:
# Find distinct city_slug whose freshest row expires within window.
stmt = (
select(ColSnapshot.city_slug, ColSnapshot.country)
.distinct()
.where(text(f"expires_at <= {threshold}"))
)
rows = (await sess.execute(stmt)).all()
click.echo(f"col-refresh-stale: {len(rows)} city(ies) need refresh "
f"(within_days={within_days})")
for slug, country in rows:
try:
# lookup_city_cached upserts on cache miss, which is
# what "stale" means here — read_fresh returns None.
idx = await lookup_city_cached(sess, slug, country=country or "")
click.echo(f" refreshed {slug:20s}{idx.source.name:10s} "
f"total={idx.total_single_with_rent_gbp}")
refreshed += 1
except Exception as e: # broad — log and continue per-city
click.echo(f" FAILED {slug}: {e}", err=True)
failed += 1
finally:
await engine.dispose()
click.echo(f"\ncol-refresh-stale done: refreshed={refreshed} failed={failed} "
f"ttl_days={ttl_days}")
@cli.command("ingest")
@click.option("--source",
type=click.Choice(["wealthfolio"]),
default="wealthfolio",
help="Data source — currently only wealthfolio is wired.")
@click.option("--as-of",
type=click.DateTime(formats=["%Y-%m-%d"]),
default=None,
help="Valuation date to read; defaults to MAX(valuation_date) in the mirror.")
def ingest(source: str, as_of: date | None) -> None:
"""Pull external state into fire_planner.account_snapshot."""
if source == "wealthfolio":
asyncio.run(_ingest_wealthfolio(as_of))
async def _ingest_wealthfolio(as_of: date | None) -> None:
"""Read account snapshots from wealthfolio_sync PG mirror, upsert."""
wf_engine = create_wf_sync_engine_from_env()
try:
wf_factory = async_sessionmaker(wf_engine, expire_on_commit=False)
async with wf_factory() as wf_sess:
rows = await read_account_snapshots_from_pg(wf_sess, as_of=as_of)
finally:
await wf_engine.dispose()
if not rows:
click.echo(
"warning: no rows read — wealthfolio_sync mirror is empty or "
"no rows on the requested date",
err=True,
)
engine = create_engine_from_env()
factory = make_session_factory(engine)
try:
async with factory() as sess:
n = await upsert_snapshots(sess, rows)
await sess.commit()
click.echo(f"wealthfolio ingest: {n} rows upserted")
finally:
await engine.dispose()
def _build_paths(seed: int, n_paths: int, n_years: int, returns_csv: Path | None) -> np.ndarray:
"""Load returns from CSV (production) or synthetic (smoke tests)."""
if returns_csv and returns_csv.exists():
bundle = load_from_csv(returns_csv)
else:
bundle = synthetic_returns(seed=42)
rng = np.random.default_rng(seed)
return block_bootstrap(bundle, n_paths=n_paths, n_years=n_years, block_size=5, rng=rng)
@cli.command("simulate")
@click.option("--scenario",
required=True,
help="external_id, e.g. cyprus-vpw-leave-y3-glide-rising")
@click.option("--n-paths", type=int, default=10_000)
@click.option("--horizon", type=int, default=60)
@click.option("--spending", type=float, default=100_000.0)
@click.option("--nw-seed", type=float, default=1_000_000.0)
@click.option("--savings", type=float, default=0.0)
@click.option("--floor",
type=float,
default=None,
help="Real-GBP floor for vpw_floor strategy (e.g. 40000).")
@click.option("--returns-csv", type=click.Path(path_type=Path), default=None)
@click.option("--seed", type=int, default=42)
@click.option("--write-db/--no-write-db", default=False, help="Persist results to fire_planner DB.")
def simulate_cmd(
scenario: str,
n_paths: int,
horizon: int,
spending: float,
nw_seed: float,
savings: float,
floor: float | None,
returns_csv: Path | None,
seed: int,
write_db: bool,
) -> None:
"""Run one scenario by external_id and pretty-print the result."""
parts = scenario.split("-")
if len(parts) < 6 or parts[2] != "leave" or parts[4] != "glide":
raise click.UsageError(f"bad scenario id: {scenario!r} "
"(expected jurisdiction-strategy-leave-yN-glide-NAME)")
jurisdiction = parts[0]
# strategy may include underscore (e.g. guyton_klinger), so rebuild
strategy_end = scenario.index("-leave-")
strategy_name = scenario[len(jurisdiction) + 1:strategy_end]
leave_year = int(parts[parts.index("leave") + 1].lstrip("y"))
glide_name = scenario.split("-glide-")[1]
spec = ScenarioSpec(
jurisdiction=jurisdiction,
strategy=strategy_name,
leave_uk_year=leave_year,
glide_path=glide_name,
spending_gbp=Decimal(str(spending)),
nw_seed_gbp=Decimal(str(nw_seed)),
horizon_years=horizon,
savings_per_year_gbp=Decimal(str(savings)),
)
paths = _build_paths(seed, n_paths, horizon, returns_csv)
annual_savings = (np.full(horizon, savings, dtype=np.float64) if savings else None)
started = time.perf_counter()
result = simulate(
paths=paths,
initial_portfolio=nw_seed,
spending_target=spending,
glide=get_glide(glide_name),
strategy=build_strategy(strategy_name, floor=floor),
regime=build_regime_schedule(jurisdiction, leave_year),
horizon_years=horizon,
annual_savings=annual_savings,
)
elapsed = time.perf_counter() - started
click.echo(format_scenario(spec, result))
if write_db:
asyncio.run(_persist(spec, result, seed=seed, elapsed_seconds=elapsed))
click.echo(f"simulate: elapsed={elapsed:.2f}s")
async def _persist(spec: ScenarioSpec, result: object, *, seed: int,
elapsed_seconds: float) -> None:
engine = create_engine_from_env()
factory = make_session_factory(engine)
try:
async with factory() as sess:
from fire_planner.simulator import SimulationResult # local to avoid cycle
assert isinstance(result, SimulationResult)
await write_run(sess, spec, result, seed=seed, elapsed_seconds=elapsed_seconds)
await sess.commit()
finally:
await engine.dispose()
@cli.command("recompute-all")
@click.option("--n-paths", type=int, default=10_000)
@click.option("--horizon", type=int, default=60)
@click.option("--spending", type=float, default=100_000.0)
@click.option("--nw-seed", type=float, default=1_000_000.0)
@click.option("--savings", type=float, default=0.0)
@click.option("--floor",
type=float,
default=None,
help="Real-GBP floor — applied to vpw_floor scenarios in the sweep.")
@click.option("--returns-csv", type=click.Path(path_type=Path), default=None)
@click.option("--seed", type=int, default=42)
def recompute_all(n_paths: int, horizon: int, spending: float, nw_seed: float, savings: float,
floor: float | None, returns_csv: Path | None, seed: int) -> None:
"""Run the full Cartesian (default 120 scenarios) and persist."""
asyncio.run(
_recompute_all(n_paths, horizon, spending, nw_seed, savings, floor, returns_csv, seed))
async def _recompute_all(
n_paths: int,
horizon: int,
spending: float,
nw_seed: float,
savings: float,
floor: float | None,
returns_csv: Path | None,
seed: int,
) -> None:
paths = _build_paths(seed, n_paths, horizon, returns_csv)
specs = cartesian_scenarios(
spending_gbp=Decimal(str(spending)),
nw_seed_gbp=Decimal(str(nw_seed)),
savings_per_year_gbp=Decimal(str(savings)),
horizon_years=horizon,
)
annual_savings = (np.full(horizon, savings, dtype=np.float64) if savings else None)
engine = create_engine_from_env()
factory = make_session_factory(engine)
successes = 0
try:
async with factory() as sess:
for spec in specs:
started = time.perf_counter()
result = simulate(
paths=paths,
initial_portfolio=nw_seed,
spending_target=spending,
glide=get_glide(spec.glide_path),
strategy=build_strategy(spec.strategy, floor=floor),
regime=build_regime_schedule(spec.jurisdiction, spec.leave_uk_year),
horizon_years=horizon,
annual_savings=annual_savings,
)
elapsed = time.perf_counter() - started
await write_run(sess, spec, result, seed=seed, elapsed_seconds=elapsed)
successes += 1
click.echo(f"{spec.external_id}: success={result.success_rate*100:.1f}% "
f"elapsed={elapsed:.2f}s")
await sess.commit()
finally:
await engine.dispose()
click.echo(f"recompute-all done: {successes}/{len(specs)} scenarios written")
@cli.command()
def serve() -> None:
"""Run the FastAPI on-demand /recompute server."""
import uvicorn
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)
if __name__ == "__main__":
cli()