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