From edb4d1135244de2560bb6eb500adab288e632b82 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 28 Jun 2026 11:49:23 +0000 Subject: [PATCH] feat(fire-target): per-Case FIRE-number solver for the retirement countdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 3 + CONTEXT.md | 43 +++++ alembic/versions/0007_fire_target.py | 60 +++++++ .../0001-fire-number-monte-carlo-threshold.md | 30 ++++ fire_planner/__main__.py | 168 +++++++++++++++++- fire_planner/db.py | 54 +++++- fire_planner/fire_target.py | 149 ++++++++++++++++ fire_planner/geo.py | 29 +++ fire_planner/reporters/pg.py | 47 ++++- fire_planner/spend_model.py | 125 +++++++++++++ tests/test_fire_target.py | 114 ++++++++++++ tests/test_fire_target_writer.py | 68 +++++++ tests/test_fire_targets_cli_helpers.py | 73 ++++++++ tests/test_geo.py | 37 ++++ tests/test_spend_model.py | 78 ++++++++ 15 files changed, 1072 insertions(+), 6 deletions(-) create mode 100644 CONTEXT.md create mode 100644 alembic/versions/0007_fire_target.py create mode 100644 docs/adr/0001-fire-number-monte-carlo-threshold.md create mode 100644 fire_planner/fire_target.py create mode 100644 fire_planner/geo.py create mode 100644 fire_planner/spend_model.py create mode 100644 tests/test_fire_target.py create mode 100644 tests/test_fire_target_writer.py create mode 100644 tests/test_fire_targets_cli_helpers.py create mode 100644 tests/test_geo.py create mode 100644 tests/test_spend_model.py diff --git a/.gitignore b/.gitignore index 241d831..51e5c61 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ __pycache__/ .ruff_cache/ .hypothesis/ *.egg-info/ + +# agent worktrees +.worktrees/ diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..2cdc480 --- /dev/null +++ b/CONTEXT.md @@ -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. diff --git a/alembic/versions/0007_fire_target.py b/alembic/versions/0007_fire_target.py new file mode 100644 index 0000000..3856ee1 --- /dev/null +++ b/alembic/versions/0007_fire_target.py @@ -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) diff --git a/docs/adr/0001-fire-number-monte-carlo-threshold.md b/docs/adr/0001-fire-number-monte-carlo-threshold.md new file mode 100644 index 0000000..31094f4 --- /dev/null +++ b/docs/adr/0001-fire-number-monte-carlo-threshold.md @@ -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.5–3%, 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. diff --git a/fire_planner/__main__.py b/fire_planner/__main__.py index 77eb3e3..756911d 100644 --- a/fire_planner/__main__.py +++ b/fire_planner/__main__.py @@ -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) diff --git a/fire_planner/db.py b/fire_planner/db.py index cdddcee..30336fa 100644 --- a/fire_planner/db.py +++ b/fire_planner/db.py @@ -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) diff --git a/fire_planner/fire_target.py b/fire_planner/fire_target.py new file mode 100644 index 0000000..0c4797b --- /dev/null +++ b/fire_planner/fire_target.py @@ -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) diff --git a/fire_planner/geo.py b/fire_planner/geo.py new file mode 100644 index 0000000..367eac9 --- /dev/null +++ b/fire_planner/geo.py @@ -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) diff --git a/fire_planner/reporters/pg.py b/fire_planner/reporters/pg.py index bc904f1..1737e69 100644 --- a/fire_planner/reporters/pg.py +++ b/fire_planner/reporters/pg.py @@ -8,12 +8,13 @@ from decimal import Decimal from typing import Any 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.sqlite import insert as sqlite_insert 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.simulator import SimulationResult @@ -222,3 +223,45 @@ async def _upsert_summary( }, ) 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) diff --git a/fire_planner/spend_model.py b/fire_planner/spend_model.py new file mode 100644 index 0000000..5844808 --- /dev/null +++ b/fire_planner/spend_model.py @@ -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, + ) diff --git a/tests/test_fire_target.py b/tests/test_fire_target.py new file mode 100644 index 0000000..2da811f --- /dev/null +++ b/tests/test_fire_target.py @@ -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 diff --git a/tests/test_fire_target_writer.py b/tests/test_fire_target_writer.py new file mode 100644 index 0000000..1caf7f2 --- /dev/null +++ b/tests/test_fire_target_writer.py @@ -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 diff --git a/tests/test_fire_targets_cli_helpers.py b/tests/test_fire_targets_cli_helpers.py new file mode 100644 index 0000000..45d6e5a --- /dev/null +++ b/tests/test_fire_targets_cli_helpers.py @@ -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"] diff --git a/tests/test_geo.py b/tests/test_geo.py new file mode 100644 index 0000000..9a5e60c --- /dev/null +++ b/tests/test_geo.py @@ -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 diff --git a/tests/test_spend_model.py b/tests/test_spend_model.py new file mode 100644 index 0000000..c1eb801 --- /dev/null +++ b/tests/test_spend_model.py @@ -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)