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

3
.gitignore vendored
View file

@ -6,3 +6,6 @@ __pycache__/
.ruff_cache/ .ruff_cache/
.hypothesis/ .hypothesis/
*.egg-info/ *.egg-info/
# agent worktrees
.worktrees/

43
CONTEXT.md Normal file
View file

@ -0,0 +1,43 @@
# FIRE Planner — Context
A personal FIRE engine: a Monte-Carlo retirement-success simulator plus a live
"can I retire yet?" countdown that tracks net worth against per-life-stage targets.
## Language
**Case**:
A named life-stage the countdown tracks. Solo (Viktor only), Household (Viktor +
Anca), Family (Household + 2 kids). Each Case is a real-GBP annual spend.
_Avoid_: scenario (reserved for the Cartesian simulator row — jurisdiction ×
strategy × leave-year × glide).
**FIRE number / Target NW**:
The smallest liquid net worth (real GBP, country-specific) at which a Case's
Guyton-Klinger plan reaches the bar. Computed by Monte-Carlo threshold, not a
fixed SWR multiple.
_Avoid_: goal, magic number, the number.
**The bar**:
The Monte-Carlo success probability that counts as "can retire" — 99%.
**Countdown**:
Target NW current liquid NW, in real GBP (today's money). Rendered with
progress %, projected date, and runway.
**Bridge pot / liquid NW**:
Assets spendable before the workplace pension unlocks (~57). Funds the early
sequence-risk years. The pension is excluded from it and modelled as a tranche
that joins later.
_Avoid_: net worth (which includes the locked pension).
**COL-driven spend**:
Spend that re-scales by a country's cost of living — rent (by 1-bed rent ratio)
and non-rent essentials (by the no-rent basket ratio), plus kids' costs.
_Contrast_: **Fixed spend** — Holidays, globally priced, unchanged by country.
**Safety multiplier**:
The ×1.5 padding Viktor applies on top of measured real spend.
**Re-entry trigger**:
The written rule — take paid work if the portfolio is below £1.0M for two
consecutive quarters. A guardrail, surfaced on the dashboard, not a failure.

View file

@ -0,0 +1,60 @@
"""add fire_target table for the FIRE-countdown solver
Revision ID: 0007
Revises: 0006
Create Date: 2026-06-28 00:00:00.000000
One solved FIRE number per (case, country, with_home, bar). The Grafana
countdown reads target_nw_gbp for the selected country and diffs it against
current liquid net worth. Seeded on liquid NW; the pension joins as
pension_at_unlock_gbp (see docs/adr/0001).
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0007"
down_revision: str | None = "0006"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
SCHEMA = "fire_planner"
def upgrade() -> None:
op.create_table(
"fire_target",
sa.Column("id", sa.Integer(), nullable=False, autoincrement=True),
sa.Column("case", sa.String(length=16), nullable=False),
sa.Column("country_slug", sa.String(length=64), nullable=False),
sa.Column("country_display", sa.String(length=128), nullable=False),
sa.Column("jurisdiction", sa.String(length=32), nullable=False),
sa.Column("with_home", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("bar", sa.Numeric(4, 3), nullable=False, server_default=sa.text("0.99")),
sa.Column("strategy", sa.String(length=32), nullable=False,
server_default=sa.text("'guyton_klinger'")),
sa.Column("annual_spend_gbp", sa.Numeric(12, 2), nullable=False),
sa.Column("target_nw_gbp", sa.Numeric(16, 2), nullable=False),
sa.Column("pension_at_unlock_gbp", sa.Numeric(16, 2), nullable=False,
server_default=sa.text("0")),
sa.Column("success_at_target", sa.Numeric(6, 4), nullable=False),
sa.Column("reached_bar", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("horizon_years", sa.Integer(), nullable=False),
sa.Column("n_paths", sa.Integer(), nullable=False),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False,
server_default=sa.func.now()),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("case", "country_slug", "with_home", "bar",
name="uq_fire_target_case_country_home_bar"),
schema=SCHEMA,
)
op.create_index("ix_fire_target_case", "fire_target", ["case"], schema=SCHEMA)
op.create_index("ix_fire_target_country_slug", "fire_target", ["country_slug"], schema=SCHEMA)
def downgrade() -> None:
op.drop_index("ix_fire_target_country_slug", table_name="fire_target", schema=SCHEMA)
op.drop_index("ix_fire_target_case", table_name="fire_target", schema=SCHEMA)
op.drop_table("fire_target", schema=SCHEMA)

View file

@ -0,0 +1,30 @@
# FIRE number via Monte-Carlo NW-threshold on the liquid pot
We compute each Case's target net worth (its "FIRE number") by searching net
worth for the smallest value at which Guyton-Klinger reaches a 99% Monte-Carlo
success rate — **not** a fixed safe-withdrawal-rate multiple (e.g. 25× spend).
A fixed multiple cannot honour the structure the engine already models: the
rising 30→70 equity glide, per-jurisdiction taxes drained from the portfolio,
the kids-cost ramp, an optional one-time home purchase, and a workplace pension
that is locked until ~57. Our block-bootstrap's empirical perpetual SWR is also
~2.53%, materially below the textbook 4%, so a 25× multiple would understate
the number anyway.
The solver seeds on **liquid** net worth — it excludes the Fidelity workplace
pension (inaccessible until ~57) and injects that pension as a grown lump at age
57. Early-retirement sequence risk is funded only by spendable assets, so seeding
total net worth would overstate safety exactly where the 99% bar is decided.
## Consequences
- A vectorised NW search (binary search over `initial_portfolio`, monotone
because the GK year-0 draw is an absolute real amount) populates a
`fire_target` table, one row per (Case × country × with-home).
- Targets vary by country through **both** COL-scaled spend and the destination
tax regime (the simulator drains tax from the portfolio since 2026-05).
- The Grafana countdown reads `fire_target` for the selected country and diffs
it against current liquid net worth from `account_snapshot`.
- Pension growth to age 57 is modelled deterministically (current value
compounded at an assumed real rate), not per-path — a conservative
simplification, revisitable if it ever binds.

View file

@ -21,10 +21,18 @@ from pathlib import Path
import click import click
import numpy as np 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.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.glide_path import get as get_glide
from fire_planner.ingest.wealthfolio import upsert_snapshots from fire_planner.ingest.wealthfolio import upsert_snapshots
from fire_planner.ingest.wealthfolio_pg import ( from fire_planner.ingest.wealthfolio_pg import (
@ -32,7 +40,7 @@ from fire_planner.ingest.wealthfolio_pg import (
read_account_snapshots_from_pg, read_account_snapshots_from_pg,
) )
from fire_planner.reporters.cli import format_scenario 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.bootstrap import block_bootstrap
from fire_planner.returns.shiller import load_from_csv, synthetic_returns from fire_planner.returns.shiller import load_from_csv, synthetic_returns
from fire_planner.scenarios import ( from fire_planner.scenarios import (
@ -42,6 +50,16 @@ from fire_planner.scenarios import (
cartesian_scenarios, cartesian_scenarios,
) )
from fire_planner.simulator import simulate 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__) log = logging.getLogger(__name__)
@ -364,6 +382,150 @@ def serve() -> None:
uvicorn.run("fire_planner.app:app", host="0.0.0.0", port=8080) 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) cli.add_command(examples_cli)

View file

@ -3,7 +3,18 @@ from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from typing import Any 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.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@ -360,6 +371,47 @@ class FireExample(Base):
server_default=func.now()) 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: def create_engine_from_env() -> AsyncEngine:
url = os.environ["DB_CONNECTION_STRING"] url = os.environ["DB_CONNECTION_STRING"]
return create_async_engine(url, pool_pre_ping=True) return create_async_engine(url, pool_pre_ping=True)

149
fire_planner/fire_target.py Normal file
View file

@ -0,0 +1,149 @@
"""Solve each Case's FIRE number — the smallest liquid net worth at which a
Guyton-Klinger plan reaches the bar (default 99%).
The search is a binary search over ``initial_portfolio``: success is monotone in
starting capital because the GK year-0 draw is an absolute real amount, so more
seed always means a lower withdrawal rate and never a worse outcome.
Cashflows layered onto the run via the existing simulator hooks:
- pension unlock a grown lump that becomes available at age 57
- kids ramp an essential per-year outflow over the child-rearing window
- home purchase an optional one-time outflow
See ADR-0001 for why we seed on liquid NW and model the pension as a tranche.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import numpy.typing as npt
from fire_planner.glide_path import get as get_glide
from fire_planner.life_events import EventInput, events_to_cashflow_array
from fire_planner.scenarios import _JURISDICTION_CONSTRUCTORS
from fire_planner.simulator import RegimeFn, constant_regime, simulate
from fire_planner.spend_model import Case
from fire_planner.strategies.guyton_klinger import GuytonKlingerStrategy
DEFAULT_BAR = 0.99
DEFAULT_PENSION_REAL_GROWTH = 0.03
DEFAULT_HI_GBP = 5_000_000.0
@dataclass(frozen=True)
class TargetInputs:
"""Everything the solver needs for one (Case × country × with-home) target."""
case: Case
country_slug: str
country_display: str
jurisdiction: str
annual_spend_gbp: float
horizon_years: int
glide_name: str = "rising"
bar: float = DEFAULT_BAR
# Pension: locked tranche that joins at age 57.
pension_now_gbp: float = 0.0
pension_real_growth: float = DEFAULT_PENSION_REAL_GROWTH
years_to_pension: int = 0
# Kids: essential per-year outflow (Family only).
kids_annual_gbp: float = 0.0
kids_start_year: int = 5
kids_end_year: int = 22
# Optional one-time home purchase.
with_home: bool = False
home_amount_gbp: float = 0.0
home_year: int = 0
@dataclass(frozen=True)
class SolveResult:
target_nw_gbp: float
success_at_target: float
pension_at_unlock_gbp: float
reached_bar: bool
def pension_at_unlock(inp: TargetInputs) -> float:
"""Current pension value compounded at the assumed real rate to age 57."""
if inp.pension_now_gbp <= 0:
return 0.0
years = max(0, inp.years_to_pension)
return inp.pension_now_gbp * (1.0 + inp.pension_real_growth) ** years
def build_cashflows(inp: TargetInputs, horizon: int) -> npt.NDArray[np.float64]:
"""Per-year real-GBP cashflow array (pension inflow, kids/home outflows)."""
events: list[EventInput] = []
p_at = pension_at_unlock(inp)
if p_at > 0 and 0 <= inp.years_to_pension < horizon:
events.append(EventInput(year_start=inp.years_to_pension, one_time_amount_gbp=p_at))
if inp.kids_annual_gbp > 0:
events.append(EventInput(
year_start=inp.kids_start_year,
year_end=inp.kids_end_year,
delta_gbp_per_year=-inp.kids_annual_gbp,
))
if inp.with_home and inp.home_amount_gbp > 0:
events.append(EventInput(year_start=inp.home_year,
one_time_amount_gbp=-inp.home_amount_gbp))
return events_to_cashflow_array(events, horizon)
def _regime(jurisdiction: str) -> RegimeFn:
cls = _JURISDICTION_CONSTRUCTORS.get(jurisdiction)
if cls is None:
raise KeyError(f"Unknown jurisdiction: {jurisdiction!r}")
return constant_regime(cls())
def success_at_nw(
paths: npt.NDArray[np.float64],
initial: float,
inp: TargetInputs,
cashflows: npt.NDArray[np.float64],
) -> float:
"""Success rate of the GK plan seeded at ``initial`` liquid net worth."""
result = simulate(
paths=paths,
initial_portfolio=initial,
spending_target=inp.annual_spend_gbp,
glide=get_glide(inp.glide_name),
strategy=GuytonKlingerStrategy(),
regime=_regime(inp.jurisdiction),
horizon_years=inp.horizon_years,
cashflow_adjustments=cashflows,
)
return result.success_rate
def solve_target_nw(
paths: npt.NDArray[np.float64],
inp: TargetInputs,
*,
lo: float = 0.0,
hi: float = DEFAULT_HI_GBP,
tol: float = 5_000.0,
max_iter: int = 40,
) -> SolveResult:
"""Binary-search the smallest seed NW in ``[lo, hi]`` meeting ``inp.bar``."""
cashflows = build_cashflows(inp, inp.horizon_years)
p_at = pension_at_unlock(inp)
s_hi = success_at_nw(paths, hi, inp, cashflows)
if s_hi < inp.bar:
# Even the ceiling can't reach the bar — report it, flagged.
return SolveResult(hi, s_hi, p_at, reached_bar=False)
if success_at_nw(paths, lo, inp, cashflows) >= inp.bar:
return SolveResult(lo, 1.0, p_at, reached_bar=True)
for _ in range(max_iter):
if hi - lo <= tol:
break
mid = 0.5 * (lo + hi)
if success_at_nw(paths, mid, inp, cashflows) >= inp.bar:
hi = mid
else:
lo = mid
return SolveResult(hi, success_at_nw(paths, hi, inp, cashflows), p_at, reached_bar=True)

29
fire_planner/geo.py Normal file
View file

@ -0,0 +1,29 @@
"""Map a COL city slug to a tax jurisdiction the simulator models.
Cities without a dedicated tax engine fall back to ``nomad`` (a neutral 1%
regulatory-premium regime) a deliberate, documented approximation. Adding a
real regime (e.g. Portugal NHR, Greece, Spain Beckham) is a separate change;
until then their countdown targets carry the nomad assumption.
"""
from __future__ import annotations
# Only slugs whose jurisdiction has a dedicated engine in `tax/`. Everything
# else (Lisbon, Porto, Athens, Tallinn, Tbilisi, Madrid, Valencia, Singapore,
# Taipei, Bali, Medellin, Mexico City, Bucharest, Ho Chi Minh City) -> nomad.
CITY_JURISDICTION: dict[str, str] = {
"sofia": "bulgaria",
"limassol": "cyprus",
"bangkok": "thailand",
"chiang-mai": "thailand",
"kuala-lumpur": "malaysia",
"penang": "malaysia",
"dubai": "uae",
"london": "uk",
}
FALLBACK_JURISDICTION = "nomad"
def jurisdiction_for_city(slug: str) -> str:
"""Return the tax-jurisdiction key for a COL city slug (``nomad`` if none)."""
return CITY_JURISDICTION.get(slug, FALLBACK_JURISDICTION)

View file

@ -8,12 +8,13 @@ from decimal import Decimal
from typing import Any from typing import Any
import numpy as np import numpy as np
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from fire_planner.db import McPath, McRun, ProjectionYearly, Scenario, ScenarioSummary from fire_planner.db import FireTarget, McPath, McRun, ProjectionYearly, Scenario, ScenarioSummary
from fire_planner.fire_target import SolveResult, TargetInputs
from fire_planner.scenarios import ScenarioSpec from fire_planner.scenarios import ScenarioSpec
from fire_planner.simulator import SimulationResult from fire_planner.simulator import SimulationResult
@ -222,3 +223,45 @@ async def _upsert_summary(
}, },
) )
await session.execute(stmt) await session.execute(stmt)
async def upsert_fire_target(
session: AsyncSession,
inp: TargetInputs,
result: SolveResult,
n_paths: int,
) -> None:
"""Upsert one solved FIRE target on (case, country, with_home, bar)."""
insert_ = _dialect_insert(session)
stmt = insert_(FireTarget).values(
case=inp.case.value,
country_slug=inp.country_slug,
country_display=inp.country_display,
jurisdiction=inp.jurisdiction,
with_home=inp.with_home,
bar=_to_dec(inp.bar),
strategy="guyton_klinger",
annual_spend_gbp=_to_dec(inp.annual_spend_gbp),
target_nw_gbp=_to_dec(result.target_nw_gbp),
pension_at_unlock_gbp=_to_dec(result.pension_at_unlock_gbp),
success_at_target=_to_dec(result.success_at_target),
reached_bar=result.reached_bar,
horizon_years=inp.horizon_years,
n_paths=n_paths,
)
stmt = stmt.on_conflict_do_update(
index_elements=["case", "country_slug", "with_home", "bar"],
set_={
"country_display": stmt.excluded.country_display,
"jurisdiction": stmt.excluded.jurisdiction,
"annual_spend_gbp": stmt.excluded.annual_spend_gbp,
"target_nw_gbp": stmt.excluded.target_nw_gbp,
"pension_at_unlock_gbp": stmt.excluded.pension_at_unlock_gbp,
"success_at_target": stmt.excluded.success_at_target,
"reached_bar": stmt.excluded.reached_bar,
"horizon_years": stmt.excluded.horizon_years,
"n_paths": stmt.excluded.n_paths,
"updated_at": func.now(),
},
)
await session.execute(stmt)

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,
)

114
tests/test_fire_target.py Normal file
View file

@ -0,0 +1,114 @@
"""FIRE-number solver: smallest liquid NW where GK reaches the bar.
Uses deterministic fixed-return paths so thresholds are exact step functions and
the ordering properties (pension lowers the target, kids/home raise it) hold
without statistical noise.
"""
from __future__ import annotations
import pytest
from fire_planner.fire_target import (
TargetInputs,
build_cashflows,
pension_at_unlock,
solve_target_nw,
success_at_nw,
)
from fire_planner.spend_model import Case
from tests.test_simulator import fixed_paths
def _paths(n_years: int = 30):
# 2% nominal everything -> 0% real return; clean arithmetic.
return fixed_paths(n_paths=1, n_years=n_years, stock_ret=0.02, bond_ret=0.02, cpi=0.02)
def _inp(**over) -> TargetInputs:
base = dict(
case=Case.SOLO,
country_slug="kuala-lumpur",
country_display="Kuala Lumpur",
jurisdiction="malaysia", # 0% on foreign income -> no tax drag
annual_spend_gbp=40_000.0,
horizon_years=30,
glide_name="static_60_40",
)
base.update(over)
return TargetInputs(**base)
def test_pension_at_unlock_compounds_real_growth() -> None:
inp = _inp(pension_now_gbp=100_000.0, pension_real_growth=0.03, years_to_pension=10)
assert pension_at_unlock(inp) == pytest.approx(100_000 * 1.03 ** 10)
def test_build_cashflows_places_pension_kids_home() -> None:
inp = _inp(
pension_now_gbp=100_000.0, pension_real_growth=0.0, years_to_pension=10,
kids_annual_gbp=10_000.0, kids_start_year=5, kids_end_year=8,
with_home=True, home_amount_gbp=50_000.0, home_year=0,
)
cf = build_cashflows(inp, inp.horizon_years)
assert cf.shape == (30,)
assert cf[10] == pytest.approx(100_000.0 - 0.0) # pension lump (no growth) ...
# ... but home is at year 0 and kids at 5-8, so year 10 is pension only.
assert cf[0] == pytest.approx(-50_000.0) # home outflow
assert cf[5] == pytest.approx(-10_000.0) # kids ramp
assert cf[8] == pytest.approx(-10_000.0)
assert cf[9] == pytest.approx(0.0) # kids ended
def test_success_is_monotone_in_net_worth() -> None:
inp = _inp()
cf = build_cashflows(inp, inp.horizon_years)
s_low = success_at_nw(_paths(), 300_000.0, inp, cf)
s_high = success_at_nw(_paths(), 3_000_000.0, inp, cf)
assert s_low <= s_high
assert s_high == pytest.approx(1.0)
def test_solver_finds_a_threshold() -> None:
inp = _inp()
res = solve_target_nw(_paths(), inp, tol=2_000.0)
assert res.reached_bar
# At the target, the bar is met; just below it, it is not.
cf = build_cashflows(inp, inp.horizon_years)
assert success_at_nw(_paths(), res.target_nw_gbp, inp, cf) >= inp.bar
assert success_at_nw(_paths(), res.target_nw_gbp - 5_000.0, inp, cf) < inp.bar
def test_pension_lowers_target() -> None:
no_pension = solve_target_nw(_paths(), _inp(), tol=2_000.0)
with_pension = solve_target_nw(
_paths(), _inp(pension_now_gbp=200_000.0, pension_real_growth=0.0, years_to_pension=10),
tol=2_000.0,
)
assert with_pension.target_nw_gbp < no_pension.target_nw_gbp
def test_kids_raise_target() -> None:
no_kids = solve_target_nw(_paths(), _inp(), tol=2_000.0)
with_kids = solve_target_nw(
_paths(), _inp(kids_annual_gbp=12_000.0, kids_start_year=5, kids_end_year=22),
tol=2_000.0,
)
assert with_kids.target_nw_gbp > no_kids.target_nw_gbp
def test_home_raises_target_meaningfully() -> None:
no_home = solve_target_nw(_paths(), _inp(), tol=2_000.0)
with_home = solve_target_nw(
_paths(), _inp(with_home=True, home_amount_gbp=100_000.0, home_year=0),
tol=2_000.0,
)
# A home costs money, so the target rises — by a non-trivial amount. The
# increase is < face value because GK anchors its draw rate to the seed and
# absorbs part of a one-time hit via later guardrail cuts.
assert with_home.target_nw_gbp > no_home.target_nw_gbp + 10_000.0
def test_unreachable_bar_returns_not_reached() -> None:
# Spend far above what any NW in range can sustain.
res = solve_target_nw(_paths(), _inp(annual_spend_gbp=2_000_000.0), hi=1_000_000.0, tol=2_000.0)
assert not res.reached_bar

View file

@ -0,0 +1,68 @@
"""upsert_fire_target writes one row per (case, country, with_home, bar)
and updates in place on re-run (idempotent recompute)."""
from __future__ import annotations
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fire_planner.db import FireTarget
from fire_planner.fire_target import SolveResult, TargetInputs
from fire_planner.reporters.pg import upsert_fire_target
from fire_planner.spend_model import Case
def _inp(**over) -> TargetInputs:
base = dict(
case=Case.SOLO,
country_slug="sofia",
country_display="Sofia",
jurisdiction="bulgaria",
annual_spend_gbp=35_000.0,
horizon_years=60,
)
base.update(over)
return TargetInputs(**base)
def _res(target: float, reached: bool = True) -> SolveResult:
return SolveResult(target_nw_gbp=target, success_at_target=0.992,
pension_at_unlock_gbp=120_000.0, reached_bar=reached)
async def test_upsert_inserts_then_updates_in_place(session: AsyncSession) -> None:
await upsert_fire_target(session, _inp(), _res(900_000.0), n_paths=2_000)
await session.commit()
rows = (await session.execute(select(FireTarget))).scalars().all()
assert len(rows) == 1
assert rows[0].target_nw_gbp == Decimal("900000.00")
assert rows[0].case == "solo"
# Re-running the same key updates, doesn't duplicate. expire_all() forces a
# DB read past the identity map (session is expire_on_commit=False).
await upsert_fire_target(session, _inp(), _res(850_000.0), n_paths=5_000)
await session.commit()
session.expire_all()
rows = (await session.execute(select(FireTarget))).scalars().all()
assert len(rows) == 1
assert rows[0].target_nw_gbp == Decimal("850000.00")
assert rows[0].n_paths == 5_000
async def test_with_home_is_a_distinct_row(session: AsyncSession) -> None:
await upsert_fire_target(session, _inp(with_home=False), _res(900_000.0), 2_000)
await upsert_fire_target(session, _inp(with_home=True), _res(1_100_000.0), 2_000)
await session.commit()
rows = (await session.execute(select(FireTarget))).scalars().all()
assert len(rows) == 2
by_home = {r.with_home: r.target_nw_gbp for r in rows}
assert by_home[True] > by_home[False]
async def test_not_reached_bar_is_persisted(session: AsyncSession) -> None:
await upsert_fire_target(
session, _inp(case=Case.FAMILY), _res(5_000_000.0, reached=False), 2_000)
await session.commit()
row = (await session.execute(select(FireTarget))).scalars().one()
assert row.reached_bar is False

View file

@ -0,0 +1,73 @@
"""DB helpers behind `recompute-fire-targets` — latest-snapshot net worth split
and COL lookups. Locks the SQL (the WORKPLACE_PENSION filter especially)."""
from __future__ import annotations
from datetime import UTC, date, datetime
from decimal import Decimal
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from fire_planner.__main__ import (
_all_city_slugs,
_current_liquid_and_pension,
_load_col_latest,
)
from fire_planner.db import AccountSnapshot, ColSnapshot
def _acct(ext: str, d: date, atype: str, gbp: str) -> AccountSnapshot:
return AccountSnapshot(
external_id=ext, snapshot_date=d, account_id=ext, account_name=atype,
account_type=atype, currency="GBP",
market_value=Decimal(gbp), market_value_gbp=Decimal(gbp),
)
def _col(slug: str, disp: str, d: date, no_rent: str, rent: str) -> ColSnapshot:
return ColSnapshot(
city_slug=slug, city_display=disp, country=disp, source_name="baseline",
snapshot_date=d, expires_at=datetime(2027, 1, 1, tzinfo=UTC),
total_no_rent_gbp=Decimal(no_rent), total_with_rent_gbp=Decimal(no_rent),
rent_1bed_center_gbp=Decimal(rent),
)
async def test_liquid_and_pension_use_latest_date_and_split_pension(
session: AsyncSession,
) -> None:
# An older snapshot that must be ignored.
session.add(_acct("old:isa", date(2026, 1, 1), "ISA", "1.00"))
# Latest date: two liquid accounts + one locked pension.
session.add_all([
_acct("gia", date(2026, 6, 20), "GIA", "761000.00"),
_acct("isa", date(2026, 6, 20), "ISA", "231000.00"),
_acct("pension", date(2026, 6, 20), "WORKPLACE_PENSION", "139000.00"),
])
await session.commit()
liquid, pension = await _current_liquid_and_pension(session)
assert liquid == pytest.approx(992_000.0)
assert pension == pytest.approx(139_000.0)
async def test_load_col_latest_picks_most_recent(session: AsyncSession) -> None:
session.add_all([
_col("sofia", "Sofia", date(2025, 1, 1), "600", "500"),
_col("sofia", "Sofia", date(2026, 5, 20), "713", "679"),
])
await session.commit()
row = await _load_col_latest(session, "sofia")
assert row is not None
assert row.total_no_rent_gbp == Decimal("713")
assert await _load_col_latest(session, "atlantis") is None
async def test_all_city_slugs_is_distinct_sorted(session: AsyncSession) -> None:
session.add_all([
_col("sofia", "Sofia", date(2026, 5, 1), "713", "679"),
_col("sofia", "Sofia", date(2026, 5, 20), "713", "679"),
_col("lisbon", "Lisbon", date(2026, 5, 1), "900", "1100"),
])
await session.commit()
assert await _all_city_slugs(session) == ["lisbon", "sofia"]

37
tests/test_geo.py Normal file
View file

@ -0,0 +1,37 @@
"""City -> tax jurisdiction mapping for the countdown solver."""
from __future__ import annotations
import pytest
from fire_planner.geo import jurisdiction_for_city
@pytest.mark.parametrize(
("slug", "expected"),
[
("sofia", "bulgaria"),
("limassol", "cyprus"),
("bangkok", "thailand"),
("chiang-mai", "thailand"),
("kuala-lumpur", "malaysia"),
("penang", "malaysia"),
("dubai", "uae"),
("london", "uk"),
],
)
def test_known_cities_map_to_their_regime(slug: str, expected: str) -> None:
assert jurisdiction_for_city(slug) == expected
@pytest.mark.parametrize("slug", ["lisbon", "porto", "athens", "tbilisi", "atlantis", ""])
def test_unmapped_cities_fall_back_to_nomad(slug: str) -> None:
assert jurisdiction_for_city(slug) == "nomad"
def test_mapping_only_uses_regimes_the_engine_knows() -> None:
from fire_planner.geo import CITY_JURISDICTION
from fire_planner.scenarios import _JURISDICTION_CONSTRUCTORS
known = set(_JURISDICTION_CONSTRUCTORS)
assert set(CITY_JURISDICTION.values()) <= known
assert "nomad" in known

78
tests/test_spend_model.py Normal file
View file

@ -0,0 +1,78 @@
"""Spend model: per-Case real-GBP spend, COL-scaled by country.
London is the identity baseline (ratios = 1.0); cheaper countries scale the
COL-driven buckets (rent, non-rent essentials, kids) down while Holidays stay
fixed. The ×1.5 safety multiplier applies to the whole spend.
"""
from __future__ import annotations
import pytest
from fire_planner.spend_model import (
ANCA,
LONDON_RATIOS,
VIKTOR,
Case,
ColRatios,
case_base_spend,
col_ratios_from_snapshot,
kids_annual_spend,
scaled_person_spend,
)
def test_london_identity_is_raw_sum() -> None:
# Viktor's measured buckets sum to his nominal trailing-12mo spend.
assert scaled_person_spend(VIKTOR, LONDON_RATIOS) == pytest.approx(40_492.0, abs=1.0)
def test_holidays_are_fixed_across_countries() -> None:
cheap = ColRatios(rent_ratio=0.5, non_rent_ratio=0.5)
scaled = scaled_person_spend(VIKTOR, cheap)
# rent + non-rent halve; holidays unchanged.
expected = VIKTOR.rent * 0.5 + VIKTOR.non_rent_usual * 0.5 + VIKTOR.holidays
assert scaled == pytest.approx(expected)
# Holidays floor: spend can never drop below the fixed holiday spend.
assert scaled > VIKTOR.holidays
def test_safety_multiplier_applies_to_case() -> None:
solo = case_base_spend(Case.SOLO, LONDON_RATIOS)
assert solo == pytest.approx(scaled_person_spend(VIKTOR, LONDON_RATIOS) * 1.5)
def test_household_adds_anca() -> None:
hh = case_base_spend(Case.HOUSEHOLD, LONDON_RATIOS)
expected = (scaled_person_spend(VIKTOR, LONDON_RATIOS)
+ scaled_person_spend(ANCA, LONDON_RATIOS)) * 1.5
assert hh == pytest.approx(expected)
# Household ~£82k * 1.5 ≈ £121.6k at London prices.
assert hh == pytest.approx(121_638.0, abs=50.0)
def test_family_base_equals_household_kids_are_separate() -> None:
# Kids are modelled as a cashflow, not folded into the GK spend target.
assert case_base_spend(Case.FAMILY, LONDON_RATIOS) == pytest.approx(
case_base_spend(Case.HOUSEHOLD, LONDON_RATIOS))
def test_kids_are_col_driven_and_safety_scaled() -> None:
assert kids_annual_spend(LONDON_RATIOS) == pytest.approx(15_000 * 1.5)
cheap = ColRatios(rent_ratio=0.3, non_rent_ratio=0.5)
# Kids scale by the non-rent (services) ratio.
assert kids_annual_spend(cheap) == pytest.approx(15_000 * 0.5 * 1.5)
def test_col_ratios_from_snapshot_sofia() -> None:
# Sofia vs London (Numbeo, May 2026): rent 679/2317, no-rent 713/1092.
r = col_ratios_from_snapshot(
city_no_rent=713.0, city_rent_1bed=679.0,
london_no_rent=1092.0, london_rent_1bed=2317.0,
)
assert r.rent_ratio == pytest.approx(679.0 / 2317.0)
assert r.non_rent_ratio == pytest.approx(713.0 / 1092.0)
def test_cheaper_country_lowers_case_spend() -> None:
sofia = col_ratios_from_snapshot(713.0, 679.0, 1092.0, 2317.0)
assert case_base_spend(Case.SOLO, sofia) < case_base_spend(Case.SOLO, LONDON_RATIOS)