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
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"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue