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