feat(fire-target): per-Case FIRE-number solver for the retirement countdown
Some checks are pending
Build and Push / lint-and-test (push) Waiting to run
Build and Push / build (push) Blocked by required conditions
Build and Push / deploy (push) Blocked by required conditions
Build and Push / notify-failure (push) Blocked by required conditions

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:
Viktor Barzin 2026-06-28 11:49:23 +00:00
parent 4bf1aaa96a
commit edb4d11352
15 changed files with 1072 additions and 6 deletions

View file

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

View file

@ -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
View 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
View 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)

View file

@ -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
View 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,
)