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
|
|
@ -21,10 +21,18 @@ from pathlib import Path
|
|||
|
||||
import click
|
||||
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.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.ingest.wealthfolio import upsert_snapshots
|
||||
from fire_planner.ingest.wealthfolio_pg import (
|
||||
|
|
@ -32,7 +40,7 @@ from fire_planner.ingest.wealthfolio_pg import (
|
|||
read_account_snapshots_from_pg,
|
||||
)
|
||||
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.shiller import load_from_csv, synthetic_returns
|
||||
from fire_planner.scenarios import (
|
||||
|
|
@ -42,6 +50,16 @@ from fire_planner.scenarios import (
|
|||
cartesian_scenarios,
|
||||
)
|
||||
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__)
|
||||
|
||||
|
|
@ -364,6 +382,150 @@ def serve() -> None:
|
|||
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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
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
|
||||
|
||||
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)
|
||||
|
|
|
|||
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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue