feat(fire-target): per-Case FIRE-number solver for the retirement countdown
Some checks are pending
Some checks are pending
Add a Monte-Carlo "FIRE number" solver so the wealth dashboard can show a £ countdown to retirement across life-stage cases, in today's money. Viktor wants to see, per country, how far his net worth is from being able to retire for good under three cases — Solo (his spend ×1.5), Household (+Anca ×1.5), Family (+2 kids) — with cost-of-living re-scaling per country and a 99% Guyton-Klinger success bar. - spend_model: per-Case real-GBP spend, COL-scaled (rent + non-rent essentials scale by country; Holidays fixed), ×1.5 safety. Constants sourced live from actualbudget (Viktor) / on-record (Anca). - geo: city -> tax jurisdiction (nomad fallback). - fire_target: binary-search the smallest LIQUID net worth where GK reaches the bar; pension modelled as a tranche unlocking at ~57, kids ramp + optional home as cashflows. New fire_target table (migration 0007) + idempotent upsert. - recompute-fire-targets CLI: solve every Case x country and persist for Grafana. - CONTEXT.md glossary + ADR-0001 (why MC-threshold on liquid NW, not 25x spend). Reuses the existing simulator unchanged (its cashflow hooks already supported pension/kids/home). 345 tests pass; mypy + ruff clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4bf1aaa96a
commit
edb4d11352
15 changed files with 1072 additions and 6 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -6,3 +6,6 @@ __pycache__/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
.hypothesis/
|
.hypothesis/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
|
||||||
|
# agent worktrees
|
||||||
|
.worktrees/
|
||||||
|
|
|
||||||
43
CONTEXT.md
Normal file
43
CONTEXT.md
Normal 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.
|
||||||
60
alembic/versions/0007_fire_target.py
Normal file
60
alembic/versions/0007_fire_target.py
Normal 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)
|
||||||
30
docs/adr/0001-fire-number-monte-carlo-threshold.md
Normal file
30
docs/adr/0001-fire-number-monte-carlo-threshold.md
Normal 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.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.
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
149
fire_planner/fire_target.py
Normal 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
29
fire_planner/geo.py
Normal 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)
|
||||||
|
|
@ -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
125
fire_planner/spend_model.py
Normal 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
114
tests/test_fire_target.py
Normal 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
|
||||||
68
tests/test_fire_target_writer.py
Normal file
68
tests/test_fire_target_writer.py
Normal 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
|
||||||
73
tests/test_fire_targets_cli_helpers.py
Normal file
73
tests/test_fire_targets_cli_helpers.py
Normal 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
37
tests/test_geo.py
Normal 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
78
tests/test_spend_model.py
Normal 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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue