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