Initial extraction from monorepo

This commit is contained in:
Viktor Barzin 2026-05-07 17:06:19 +00:00
commit f7ef7ca4ab
56 changed files with 6163 additions and 0 deletions

1
fire_planner/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Risk-adjusted, tax-minimised FIRE retirement planner."""

259
fire_planner/__main__.py Normal file
View file

@ -0,0 +1,259 @@
"""click CLI entrypoint.
Sub-commands:
- migrate alembic upgrade head
- ingest [wealthfolio] load wealthfolio sqlite into account_snapshot
- simulate run a single scenario, pretty-print
- recompute-all run the 120-scenario Cartesian, persist all
- serve run the FastAPI on-demand /recompute server
"""
from __future__ import annotations
import asyncio
import logging
import os
import subprocess
import sys
import time
from datetime import date
from decimal import Decimal
from pathlib import Path
import click
import numpy as np
from fire_planner.db import create_engine_from_env, make_session_factory
from fire_planner.glide_path import get as get_glide
from fire_planner.ingest import wealthfolio as wf_ingest
from fire_planner.reporters.cli import format_scenario
from fire_planner.reporters.pg import 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 (
ScenarioSpec,
build_regime_schedule,
build_strategy,
cartesian_scenarios,
)
from fire_planner.simulator import simulate
log = logging.getLogger(__name__)
@click.group()
def cli() -> None:
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"))
@cli.command()
def migrate() -> None:
"""Run `alembic upgrade head`."""
rc = subprocess.run(["alembic", "upgrade", "head"], check=False)
sys.exit(rc.returncode)
@cli.command("ingest")
@click.option("--source",
type=click.Choice(["wealthfolio"]),
default="wealthfolio",
help="Data source — currently only wealthfolio is wired.")
@click.option("--db-path",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
required=False,
help="Local sqlite path (after kubectl exec). Required for --source=wealthfolio.")
@click.option("--as-of",
type=click.DateTime(formats=["%Y-%m-%d"]),
default=None,
help="Snapshot date to read; defaults to MAX(snapshot_date) in the sqlite.")
def ingest(source: str, db_path: Path | None, as_of: date | None) -> None:
"""Pull external state into fire_planner.account_snapshot."""
if source == "wealthfolio":
if db_path is None:
raise click.UsageError("--db-path is required for --source=wealthfolio")
asyncio.run(_ingest_wealthfolio(db_path, as_of))
async def _ingest_wealthfolio(db_path: Path, as_of: date | None) -> None:
rows = wf_ingest.read_account_snapshots(db_path, as_of=as_of)
if not rows:
click.echo("warning: no rows read — wealthfolio sqlite empty or schema unrecognised",
err=True)
engine = create_engine_from_env()
factory = make_session_factory(engine)
try:
async with factory() as sess:
n = await wf_ingest.upsert_snapshots(sess, rows)
await sess.commit()
click.echo(f"wealthfolio ingest: {n} rows upserted")
finally:
await engine.dispose()
def _build_paths(seed: int, n_paths: int, n_years: int, returns_csv: Path | None) -> np.ndarray:
"""Load returns from CSV (production) or synthetic (smoke tests)."""
if returns_csv and returns_csv.exists():
bundle = load_from_csv(returns_csv)
else:
bundle = synthetic_returns(seed=42)
rng = np.random.default_rng(seed)
return block_bootstrap(bundle, n_paths=n_paths, n_years=n_years, block_size=5, rng=rng)
@cli.command("simulate")
@click.option("--scenario",
required=True,
help="external_id, e.g. cyprus-vpw-leave-y3-glide-rising")
@click.option("--n-paths", type=int, default=10_000)
@click.option("--horizon", type=int, default=60)
@click.option("--spending", type=float, default=100_000.0)
@click.option("--nw-seed", type=float, default=1_000_000.0)
@click.option("--savings", type=float, default=0.0)
@click.option("--floor",
type=float,
default=None,
help="Real-GBP floor for vpw_floor strategy (e.g. 40000).")
@click.option("--returns-csv", type=click.Path(path_type=Path), default=None)
@click.option("--seed", type=int, default=42)
@click.option("--write-db/--no-write-db", default=False, help="Persist results to fire_planner DB.")
def simulate_cmd(
scenario: str,
n_paths: int,
horizon: int,
spending: float,
nw_seed: float,
savings: float,
floor: float | None,
returns_csv: Path | None,
seed: int,
write_db: bool,
) -> None:
"""Run one scenario by external_id and pretty-print the result."""
parts = scenario.split("-")
if len(parts) < 6 or parts[2] != "leave" or parts[4] != "glide":
raise click.UsageError(f"bad scenario id: {scenario!r} "
"(expected jurisdiction-strategy-leave-yN-glide-NAME)")
jurisdiction = parts[0]
# strategy may include underscore (e.g. guyton_klinger), so rebuild
strategy_end = scenario.index("-leave-")
strategy_name = scenario[len(jurisdiction) + 1:strategy_end]
leave_year = int(parts[parts.index("leave") + 1].lstrip("y"))
glide_name = scenario.split("-glide-")[1]
spec = ScenarioSpec(
jurisdiction=jurisdiction,
strategy=strategy_name,
leave_uk_year=leave_year,
glide_path=glide_name,
spending_gbp=Decimal(str(spending)),
nw_seed_gbp=Decimal(str(nw_seed)),
horizon_years=horizon,
savings_per_year_gbp=Decimal(str(savings)),
)
paths = _build_paths(seed, n_paths, horizon, returns_csv)
annual_savings = (np.full(horizon, savings, dtype=np.float64) if savings else None)
started = time.perf_counter()
result = simulate(
paths=paths,
initial_portfolio=nw_seed,
spending_target=spending,
glide=get_glide(glide_name),
strategy=build_strategy(strategy_name, floor=floor),
regime=build_regime_schedule(jurisdiction, leave_year),
horizon_years=horizon,
annual_savings=annual_savings,
)
elapsed = time.perf_counter() - started
click.echo(format_scenario(spec, result))
if write_db:
asyncio.run(_persist(spec, result, seed=seed, elapsed_seconds=elapsed))
click.echo(f"simulate: elapsed={elapsed:.2f}s")
async def _persist(spec: ScenarioSpec, result: object, *, seed: int,
elapsed_seconds: float) -> None:
engine = create_engine_from_env()
factory = make_session_factory(engine)
try:
async with factory() as sess:
from fire_planner.simulator import SimulationResult # local to avoid cycle
assert isinstance(result, SimulationResult)
await write_run(sess, spec, result, seed=seed, elapsed_seconds=elapsed_seconds)
await sess.commit()
finally:
await engine.dispose()
@cli.command("recompute-all")
@click.option("--n-paths", type=int, default=10_000)
@click.option("--horizon", type=int, default=60)
@click.option("--spending", type=float, default=100_000.0)
@click.option("--nw-seed", type=float, default=1_000_000.0)
@click.option("--savings", type=float, default=0.0)
@click.option("--floor",
type=float,
default=None,
help="Real-GBP floor — applied to vpw_floor scenarios in the sweep.")
@click.option("--returns-csv", type=click.Path(path_type=Path), default=None)
@click.option("--seed", type=int, default=42)
def recompute_all(n_paths: int, horizon: int, spending: float, nw_seed: float, savings: float,
floor: float | None, returns_csv: Path | None, seed: int) -> None:
"""Run the full Cartesian (default 120 scenarios) and persist."""
asyncio.run(
_recompute_all(n_paths, horizon, spending, nw_seed, savings, floor, returns_csv, seed))
async def _recompute_all(
n_paths: int,
horizon: int,
spending: float,
nw_seed: float,
savings: float,
floor: float | None,
returns_csv: Path | None,
seed: int,
) -> None:
paths = _build_paths(seed, n_paths, horizon, returns_csv)
specs = cartesian_scenarios(
spending_gbp=Decimal(str(spending)),
nw_seed_gbp=Decimal(str(nw_seed)),
savings_per_year_gbp=Decimal(str(savings)),
horizon_years=horizon,
)
annual_savings = (np.full(horizon, savings, dtype=np.float64) if savings else None)
engine = create_engine_from_env()
factory = make_session_factory(engine)
successes = 0
try:
async with factory() as sess:
for spec in specs:
started = time.perf_counter()
result = simulate(
paths=paths,
initial_portfolio=nw_seed,
spending_target=spending,
glide=get_glide(spec.glide_path),
strategy=build_strategy(spec.strategy, floor=floor),
regime=build_regime_schedule(spec.jurisdiction, spec.leave_uk_year),
horizon_years=horizon,
annual_savings=annual_savings,
)
elapsed = time.perf_counter() - started
await write_run(sess, spec, result, seed=seed, elapsed_seconds=elapsed)
successes += 1
click.echo(f"{spec.external_id}: success={result.success_rate*100:.1f}% "
f"elapsed={elapsed:.2f}s")
await sess.commit()
finally:
await engine.dispose()
click.echo(f"recompute-all done: {successes}/{len(specs)} scenarios written")
@cli.command()
def serve() -> None:
"""Run the FastAPI on-demand /recompute server."""
import uvicorn
uvicorn.run("fire_planner.app:app", host="0.0.0.0", port=8080)
if __name__ == "__main__":
cli()

112
fire_planner/app.py Normal file
View file

@ -0,0 +1,112 @@
"""FastAPI on-demand /recompute endpoint.
Single deployment. Bearer-token auth (matches payslip-ingest pattern).
The endpoint kicks the full 120-scenario Cartesian recompute against
whatever the latest Wealthfolio snapshot is in `account_snapshot`.
For dev / smoke tests, a `/healthz` endpoint reports queue depth.
"""
from __future__ import annotations
import asyncio
import contextlib
import hmac
import logging
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, Header, HTTPException, status
from prometheus_fastapi_instrumentator import Instrumentator
log = logging.getLogger(__name__)
REQUIRED_ENV = ["DB_CONNECTION_STRING", "RECOMPUTE_BEARER_TOKEN"]
def _verify_env() -> None:
missing = [k for k in REQUIRED_ENV if not os.environ.get(k)]
if missing:
raise RuntimeError(f"Missing required env vars: {', '.join(missing)}")
def _verify_bearer(authorization: str | None, expected: str) -> None:
if not expected:
raise HTTPException(status_code=401, detail="Service unauthenticated")
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing bearer token")
token = authorization.removeprefix("Bearer ")
if not hmac.compare_digest(token, expected):
raise HTTPException(status_code=401, detail="Invalid token")
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
_verify_env()
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
app.state.queue = queue
yield
app = FastAPI(title="fire-planner", lifespan=lifespan)
Instrumentator().instrument(app).expose(app, endpoint="/metrics")
@app.post("/recompute", status_code=status.HTTP_202_ACCEPTED)
async def recompute(
payload: dict[str, Any] | None = None,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
_verify_bearer(authorization, os.environ.get("RECOMPUTE_BEARER_TOKEN", ""))
queue: asyncio.Queue[dict[str, Any]] = app.state.queue
body = payload or {}
await queue.put(body)
return {"status": "accepted", "depth": queue.qsize()}
@app.get("/healthz")
async def healthz() -> dict[str, Any]:
queue = getattr(app.state, "queue", None)
depth = queue.qsize() if queue is not None else 0
return {"status": "ok", "queue_depth": depth}
@app.on_event("startup")
async def _drain_loop() -> None:
"""Background task to drain the recompute queue. Each item kicks
a full Cartesian recompute. Errors get logged but don't crash."""
queue: asyncio.Queue[dict[str, Any]] = app.state.queue
async def worker() -> None:
while True:
item = await queue.get()
try:
# Avoid heavy import unless we actually have work.
from fire_planner.__main__ import _recompute_all
await _recompute_all(
n_paths=int(item.get("n_paths", 10_000)),
horizon=int(item.get("horizon", 60)),
spending=float(item.get("spending", 100_000.0)),
nw_seed=float(item.get("nw_seed", 1_000_000.0)),
savings=float(item.get("savings", 0.0)),
floor=(float(item["floor"]) if item.get("floor") is not None else None),
returns_csv=item.get("returns_csv"),
seed=int(item.get("seed", 42)),
)
except Exception:
log.exception("recompute failed")
finally:
queue.task_done()
task = asyncio.create_task(worker())
app.state._worker = task
@app.on_event("shutdown")
async def _stop_worker() -> None:
task = getattr(app.state, "_worker", None)
if task is not None:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task

165
fire_planner/db.py Normal file
View file

@ -0,0 +1,165 @@
import os
from datetime import date, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import JSON, TIMESTAMP, Date, Integer, Numeric, String, 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
SCHEMA_NAME = "fire_planner"
class Base(DeclarativeBase):
pass
# JSONB on Postgres, plain JSON on SQLite — tests use SQLite, prod uses Postgres.
JSON_TYPE = JSONB().with_variant(JSON(), "sqlite")
class AccountSnapshot(Base):
"""Daily NW per account from Wealthfolio (filled by ingest).
`external_id` is `wealthfolio:{account_id}:{date}` so re-runs on the same
day are idempotent Wealthfolio keeps one snapshot per account per day.
"""
__tablename__ = "account_snapshot"
__table_args__ = {"schema": SCHEMA_NAME} # noqa: RUF012
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
external_id: Mapped[str] = mapped_column(String, unique=True, nullable=False)
snapshot_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
account_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
account_name: Mapped[str] = mapped_column(String, nullable=False)
account_type: Mapped[str] = mapped_column(String, nullable=False)
currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default="GBP")
market_value: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False)
market_value_gbp: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False)
cost_basis_gbp: Mapped[Decimal | None] = mapped_column(Numeric(14, 2), nullable=True)
raw_extraction: Mapped[dict[str, Any] | None] = mapped_column(JSON_TYPE, nullable=True)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True),
nullable=False,
server_default=func.now())
class Scenario(Base):
"""A simulation scenario — Cartesian point in (jurisdiction × strategy ×
leave_year × glide × spending) space. The Cartesian product is rebuilt
from `scenarios.py` every recompute; rows are upserted on `external_id`.
"""
__tablename__ = "scenario"
__table_args__ = {"schema": SCHEMA_NAME} # noqa: RUF012
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
external_id: Mapped[str] = mapped_column(String, unique=True, nullable=False)
jurisdiction: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
strategy: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
leave_uk_year: Mapped[int] = mapped_column(Integer, nullable=False)
glide_path: Mapped[str] = mapped_column(String(32), nullable=False)
spending_gbp: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
horizon_years: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("60"))
nw_seed_gbp: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False)
savings_per_year_gbp: Mapped[Decimal] = mapped_column(Numeric(12, 2),
nullable=False,
server_default=text("0"))
config_json: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, nullable=False)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True),
nullable=False,
server_default=func.now())
class McRun(Base):
"""One MC execution per (scenario, run_at). Stores execution metadata +
summary statistics enough to populate a Grafana cell without touching
the per-path tables."""
__tablename__ = "mc_run"
__table_args__ = {"schema": SCHEMA_NAME} # noqa: RUF012
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
scenario_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
run_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True),
nullable=False,
server_default=func.now())
n_paths: Mapped[int] = mapped_column(Integer, nullable=False)
seed: Mapped[int] = mapped_column(Integer, nullable=False)
success_rate: Mapped[Decimal] = mapped_column(Numeric(6, 4), nullable=False)
p10_ending_gbp: Mapped[Decimal] = mapped_column(Numeric(16, 2), nullable=False)
p50_ending_gbp: Mapped[Decimal] = mapped_column(Numeric(16, 2), nullable=False)
p90_ending_gbp: Mapped[Decimal] = mapped_column(Numeric(16, 2), nullable=False)
median_lifetime_tax_gbp: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False)
median_years_to_ruin: Mapped[Decimal | None] = mapped_column(Numeric(6, 2), nullable=True)
elapsed_seconds: Mapped[Decimal] = mapped_column(Numeric(8, 3), nullable=False)
sequence_risk_correlation: Mapped[Decimal | None] = mapped_column(Numeric(6, 4), nullable=True)
extra: Mapped[dict[str, Any] | None] = mapped_column(JSON_TYPE, nullable=True)
class McPath(Base):
"""Sparse per-path storage: top decile, bottom decile, and median paths
fully stored enough for a fan chart, not 10k×60 600k rows."""
__tablename__ = "mc_path"
__table_args__ = {"schema": SCHEMA_NAME} # noqa: RUF012
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
mc_run_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
path_idx: Mapped[int] = mapped_column(Integer, nullable=False)
bucket: Mapped[str] = mapped_column(String(16), nullable=False)
year_idx: Mapped[int] = mapped_column(Integer, nullable=False)
portfolio_gbp: Mapped[Decimal] = mapped_column(Numeric(16, 2), nullable=False)
withdrawal_gbp: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
tax_paid_gbp: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
real_portfolio_gbp: Mapped[Decimal] = mapped_column(Numeric(16, 2), nullable=False)
class ProjectionYearly(Base):
"""Deterministic point projection per scenario — per-year point estimates
that drive fan charts and the per-year Grafana table. One row per
(scenario, year)."""
__tablename__ = "projection_yearly"
__table_args__ = {"schema": SCHEMA_NAME} # noqa: RUF012
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
mc_run_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
year_idx: Mapped[int] = mapped_column(Integer, nullable=False)
p10_portfolio_gbp: Mapped[Decimal] = mapped_column(Numeric(16, 2), nullable=False)
p25_portfolio_gbp: Mapped[Decimal] = mapped_column(Numeric(16, 2), nullable=False)
p50_portfolio_gbp: Mapped[Decimal] = mapped_column(Numeric(16, 2), nullable=False)
p75_portfolio_gbp: Mapped[Decimal] = mapped_column(Numeric(16, 2), nullable=False)
p90_portfolio_gbp: Mapped[Decimal] = mapped_column(Numeric(16, 2), nullable=False)
p50_withdrawal_gbp: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
p50_tax_gbp: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
survival_rate: Mapped[Decimal] = mapped_column(Numeric(6, 4), nullable=False)
class ScenarioSummary(Base):
"""Denormalised fast-read for Grafana — one row per (scenario, latest run)."""
__tablename__ = "scenario_summary"
__table_args__ = {"schema": SCHEMA_NAME} # noqa: RUF012
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
scenario_id: Mapped[int] = mapped_column(Integer, unique=True, nullable=False)
mc_run_id: Mapped[int] = mapped_column(Integer, nullable=False)
jurisdiction: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
strategy: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
leave_uk_year: Mapped[int] = mapped_column(Integer, nullable=False)
glide_path: Mapped[str] = mapped_column(String(32), nullable=False)
spending_gbp: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
success_rate: Mapped[Decimal] = mapped_column(Numeric(6, 4), nullable=False)
p10_ending_gbp: Mapped[Decimal] = mapped_column(Numeric(16, 2), nullable=False)
p50_ending_gbp: Mapped[Decimal] = mapped_column(Numeric(16, 2), nullable=False)
p90_ending_gbp: Mapped[Decimal] = mapped_column(Numeric(16, 2), nullable=False)
median_lifetime_tax_gbp: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False)
median_years_to_ruin: Mapped[Decimal | None] = mapped_column(Numeric(6, 2), nullable=True)
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)
def make_session_factory(engine: AsyncEngine) -> async_sessionmaker[Any]:
return async_sessionmaker(engine, expire_on_commit=False)

49
fire_planner/fx.py Normal file
View file

@ -0,0 +1,49 @@
"""Thin shim around `job_hunter.fx` (Frankfurter-backed) so callers
inside fire-planner have a single import. Re-exports the public API.
The job-hunter package isn't a hard dependency — when it isn't on
the Python path (e.g. running `fire-planner` outside the monorepo),
fall back to a tiny inline implementation that hits Frankfurter
directly with no DB caching.
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from typing import Any
import httpx
FRANKFURTER_URL = "https://api.frankfurter.dev/v1/{date}"
async def fetch_rates(as_of: date, client: httpx.AsyncClient | None = None) -> dict[str, Decimal]:
"""Return GBP-base rates for `as_of` — `{currency: rate_to_gbp}`.
rate_to_gbp[X] = "how much GBP one unit of X is worth", so
`gbp_amount = foreign_amount * rate_to_gbp[foreign]`.
"""
owns = client is None
if client is None:
client = httpx.AsyncClient(timeout=httpx.Timeout(20.0))
try:
resp = await client.get(
FRANKFURTER_URL.format(date=as_of.isoformat()),
params={"base": "GBP"},
follow_redirects=True,
)
resp.raise_for_status()
payload: dict[str, Any] = resp.json()
finally:
if owns:
await client.aclose()
rates = payload.get("rates") or {}
out: dict[str, Decimal] = {"GBP": Decimal("1")}
for currency, rate in rates.items():
if not rate:
continue
try:
out[currency] = Decimal("1") / Decimal(str(rate))
except (ArithmeticError, ValueError):
continue
return out

View file

@ -0,0 +1,46 @@
"""Glide-path functions — stock-allocation as a function of years
since retirement.
Pfau & Kitces (2014) showed that *rising* equity glide paths
(starting low and rising) reduce sequence-of-returns risk in the
critical first decade of retirement. We default to that, with a
classic static 60/40 also available.
Each glide returns a fraction in [0, 1] for stock allocation the
remainder is bonds.
"""
from __future__ import annotations
from collections.abc import Callable
GlideFn = Callable[[int], float]
def rising_equity(start: float = 0.30, end: float = 0.70, ramp_years: int = 15) -> GlideFn:
"""Linear interpolation from `start` to `end` over `ramp_years`,
then constant at `end`."""
span = end - start
def fn(year: int) -> float:
if year >= ramp_years:
return end
return start + span * (year / ramp_years)
return fn
def static(allocation: float) -> GlideFn:
"""Constant allocation, e.g. 60/40 = static(0.60)."""
return lambda _year: allocation
GLIDE_PATHS: dict[str, GlideFn] = {
"rising": rising_equity(),
"static_60_40": static(0.60),
}
def get(name: str) -> GlideFn:
if name not in GLIDE_PATHS:
raise KeyError(f"Unknown glide path: {name!r}. Known: {sorted(GLIDE_PATHS)}")
return GLIDE_PATHS[name]

View file

@ -0,0 +1 @@
"""Ingest layers — Wealthfolio, payslip-ingest, hmrc-sync."""

View file

@ -0,0 +1,25 @@
"""HMRC sync read-only consumer (placeholder).
`hmrc-sync` is in flight (per project memory id=1106) prod credentials
hadn't landed at the time of writing fire-planner. When they do, this
module reads `hmrc_sync.income_record` (or whatever the final schema is)
to corroborate payslip-derived income and tax against HMRC ground truth.
For v1 this is a stub. The CLI's `ingest --source=hmrc` command exits
0 with a `pending` log line.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class HmrcStatus:
available: bool
note: str
def status() -> HmrcStatus:
"""Return whether the HMRC sync data is available. v1 always
reports `pending`."""
return HmrcStatus(available=False, note="hmrc-sync prod creds pending — see memory id=1106")

View file

@ -0,0 +1,77 @@
"""Read the deployed payslip-ingest schema for income + RSU vest cadence.
Read-only: we never write to `payslip_ingest.*`. The DB role
`pg-fire-planner` only needs SELECT on payslip_ingest.payslip and
payslip_ingest.rsu_vest_events.
Outputs feed scenario calibration:
- savings_per_year_gbp: median monthly net_pay × 12 less the £100k
baseline spend (the planner allocates the surplus to portfolio).
- annual_rsu_gross_gbp: median annual RSU vest value, used to validate
the savings rate against expected gross compensation.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
@dataclass(frozen=True)
class IncomeSummary:
median_monthly_net_pay_gbp: Decimal
median_annual_rsu_gbp: Decimal
earliest_date: date | None
latest_date: date | None
payslip_count: int
rsu_count: int
async def read_income_summary(session: AsyncSession, months: int = 24) -> IncomeSummary:
"""Aggregate the most-recent `months` of payslips + RSU vests."""
payslip_rows = (await session.execute(
text(
"""
SELECT pay_date, net_pay
FROM payslip_ingest.payslip
WHERE pay_date >= CURRENT_DATE - (:months || ' months')::interval
ORDER BY pay_date DESC
""", ),
{"months": months},
)).all()
rsu_rows = (await session.execute(
text(
"""
SELECT vest_date, gross_value_gbp
FROM payslip_ingest.rsu_vest_events
WHERE vest_date >= CURRENT_DATE - (:months || ' months')::interval
ORDER BY vest_date DESC
""", ),
{"months": months},
)).all()
monthly_nets = sorted(Decimal(str(r[1] or 0)) for r in payslip_rows)
median_monthly_net = (monthly_nets[len(monthly_nets) // 2] if monthly_nets else Decimal("0"))
rsu_total_gbp = sum((Decimal(str(r[1] or 0)) for r in rsu_rows), start=Decimal("0"))
months_span = max(1, months)
annual_rsu = rsu_total_gbp * 12 / months_span
pay_dates = [r[0] for r in payslip_rows]
rsu_dates = [r[0] for r in rsu_rows]
all_dates = pay_dates + rsu_dates
earliest = min(all_dates) if all_dates else None
latest = max(all_dates) if all_dates else None
return IncomeSummary(
median_monthly_net_pay_gbp=median_monthly_net,
median_annual_rsu_gbp=annual_rsu,
earliest_date=earliest,
latest_date=latest,
payslip_count=len(payslip_rows),
rsu_count=len(rsu_rows),
)

View file

@ -0,0 +1,126 @@
"""Wealthfolio ingest — kubectl exec into the wealthfolio pod, read the
SQLite DB read-only, parse account snapshots, upsert into
`fire_planner.account_snapshot`.
Wealthfolio stores every account's NW + holdings in
`/data/app.db` (SQLite). The published schema (post-2025) keeps a
`holdings_snapshot` table per (account_id, date). For the planner we
fold to total NW per account per day.
Phase 0 prerequisite: `wealthfolio-sync` must record a snapshot for
every active account every day. Until that lands the Schwab and
InvestEngine accounts read as stale snapshots from years ago and the
planner anchors on £154k instead of the real ~£1M. See
`fire-planner/README.md` and the parent CLAUDE.md project memory.
This module does NOT shell out to kubectl that's the operator's job.
Instead, callers pass an already-fetched local SQLite file path
(typically `/tmp/wealthfolio.db`). The CLI wraps the kubectl exec.
"""
from __future__ import annotations
import sqlite3
from datetime import date
from decimal import Decimal
from pathlib import Path
from typing import Any
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 AccountSnapshot
def _dialect_insert(session: AsyncSession) -> Any:
bind = session.get_bind()
if bind.dialect.name == "sqlite":
return sqlite_insert
return pg_insert
def read_account_snapshots(db_path: str | Path, as_of: date | None = None) -> list[dict[str, Any]]:
"""Read the latest snapshot row per account.
Returns a list of dicts ready for upsert into `account_snapshot`.
Each dict has: external_id, snapshot_date, account_id, account_name,
account_type, currency, market_value, market_value_gbp.
"""
db_path = Path(db_path)
if not db_path.exists():
raise FileNotFoundError(f"Wealthfolio sqlite db not found: {db_path}")
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
try:
rows = list(_query_snapshots(conn, as_of))
finally:
conn.close()
return rows
def _query_snapshots(conn: sqlite3.Connection, as_of: date | None) -> list[dict[str, Any]]:
"""Wealthfolio's actual schema is opaque (different versions ship
different tables). We try the v1 layout first (`accounts` +
`holdings_snapshot`); if that fails, return empty and let the CLI
surface the error to the operator.
"""
cur = conn.cursor()
try:
if as_of is None:
cur.execute("SELECT MAX(snapshot_date) FROM holdings_snapshot", )
row = cur.fetchone()
as_of_str = row[0] if row and row[0] else date.today().isoformat()
else:
as_of_str = as_of.isoformat()
cur.execute(
"""
SELECT a.id AS account_id,
a.name AS account_name,
a.type AS account_type,
a.currency AS currency,
SUM(h.market_value) AS market_value,
SUM(h.market_value_gbp) AS market_value_gbp,
? AS snapshot_date
FROM holdings_snapshot h
JOIN accounts a ON a.id = h.account_id
WHERE h.snapshot_date = ?
GROUP BY a.id
""",
(as_of_str, as_of_str),
)
except sqlite3.OperationalError:
# Fallback: empty list — the operator should run wealthfolio-sync
# to populate snapshots and try again.
return []
rows = []
for row in cur.fetchall():
snap_date = date.fromisoformat(row["snapshot_date"])
rows.append({
"external_id": f"wealthfolio:{row['account_id']}:{row['snapshot_date']}",
"snapshot_date": snap_date,
"account_id": str(row["account_id"]),
"account_name": row["account_name"] or "",
"account_type": row["account_type"] or "unknown",
"currency": row["currency"] or "GBP",
"market_value": Decimal(str(row["market_value"] or 0)),
"market_value_gbp": Decimal(str(row["market_value_gbp"] or 0)),
})
return rows
async def upsert_snapshots(session: AsyncSession, rows: list[dict[str, Any]]) -> int:
if not rows:
return 0
insert_ = _dialect_insert(session)
stmt = insert_(AccountSnapshot).values(rows)
update_cols = {
"market_value": stmt.excluded.market_value,
"market_value_gbp": stmt.excluded.market_value_gbp,
"snapshot_date": stmt.excluded.snapshot_date,
"account_name": stmt.excluded.account_name,
"account_type": stmt.excluded.account_type,
}
stmt = stmt.on_conflict_do_update(index_elements=["external_id"], set_=update_cols)
await session.execute(stmt)
return len(rows)

View file

@ -0,0 +1 @@
"""Result reporters — Postgres + CLI pretty-printer."""

View file

@ -0,0 +1,31 @@
"""Pretty terminal output for `fire-planner simulate`."""
from __future__ import annotations
from fire_planner.scenarios import ScenarioSpec
from fire_planner.simulator import SimulationResult
def format_scenario(spec: ScenarioSpec, result: SimulationResult) -> str:
"""Return a multi-line string summarising one scenario's MC output."""
lines = [
f"Scenario: {spec.external_id}",
f" jurisdiction = {spec.jurisdiction}",
f" strategy = {spec.strategy}",
f" leave_uk_year = {spec.leave_uk_year}",
f" glide_path = {spec.glide_path}",
f" starting_nw_gbp = {spec.nw_seed_gbp:>12,.0f}",
f" spending_target = {spec.spending_gbp:>12,.0f}",
f" horizon_years = {spec.horizon_years}",
" ----",
f" paths = {result.n_paths:>12,}",
f" success_rate = {result.success_rate*100:>11.2f}%",
f" p10_ending_gbp = {result.ending_percentile(10):>12,.0f}",
f" p50_ending_gbp = {result.ending_percentile(50):>12,.0f}",
f" p90_ending_gbp = {result.ending_percentile(90):>12,.0f}",
f" median_lifetime_tax = {result.median_lifetime_tax():>12,.0f}",
]
ytr = result.median_years_to_ruin()
if ytr is not None:
lines.append(f" median_years_to_ruin= {ytr:>12.1f}")
lines.append(f" seq_risk_correlation= {result.sequence_risk_correlation():>12.4f}")
return "\n".join(lines)

View file

@ -0,0 +1,224 @@
"""Postgres reporter — write MC results into `mc_run`,
`projection_yearly`, `mc_path` (sparse), `scenario_summary`."""
from __future__ import annotations
import time
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
import numpy as np
from sqlalchemy import 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.scenarios import ScenarioSpec
from fire_planner.simulator import SimulationResult
def _dialect_insert(session: AsyncSession) -> Any:
bind = session.get_bind()
if bind.dialect.name == "sqlite":
return sqlite_insert
return pg_insert
@dataclass(frozen=True)
class WriteSummary:
scenario_id: int
mc_run_id: int
elapsed_seconds: float
success_rate: float
def _to_dec(x: float | int) -> Decimal:
return Decimal(str(round(float(x), 4)))
async def upsert_scenario(session: AsyncSession, spec: ScenarioSpec) -> int:
insert_ = _dialect_insert(session)
stmt = insert_(Scenario).values(
external_id=spec.external_id,
jurisdiction=spec.jurisdiction,
strategy=spec.strategy,
leave_uk_year=spec.leave_uk_year,
glide_path=spec.glide_path,
spending_gbp=spec.spending_gbp,
horizon_years=spec.horizon_years,
nw_seed_gbp=spec.nw_seed_gbp,
savings_per_year_gbp=spec.savings_per_year_gbp,
config_json=spec.config or {},
)
stmt = stmt.on_conflict_do_update(
index_elements=["external_id"],
set_={
"spending_gbp": stmt.excluded.spending_gbp,
"horizon_years": stmt.excluded.horizon_years,
"nw_seed_gbp": stmt.excluded.nw_seed_gbp,
"savings_per_year_gbp": stmt.excluded.savings_per_year_gbp,
"config_json": stmt.excluded.config_json,
},
)
await session.execute(stmt)
await session.flush()
row = await session.execute(select(Scenario.id).where(Scenario.external_id == spec.external_id))
scenario_id = row.scalar_one()
return int(scenario_id)
async def write_run(
session: AsyncSession,
spec: ScenarioSpec,
result: SimulationResult,
*,
seed: int,
elapsed_seconds: float,
bucket_quantiles: tuple[int, int, int] = (10, 50, 90),
) -> WriteSummary:
"""Upsert scenario, append a new mc_run, persist projection_yearly,
save sparse mc_path rows, and refresh scenario_summary.
"""
started = time.perf_counter()
scenario_id = await upsert_scenario(session, spec)
success_rate = result.success_rate
p10, p50, p90 = (result.ending_percentile(p) for p in bucket_quantiles)
median_tax = result.median_lifetime_tax()
years_to_ruin = result.median_years_to_ruin()
seq_corr = result.sequence_risk_correlation()
run_row = McRun(
scenario_id=scenario_id,
n_paths=result.n_paths,
seed=seed,
success_rate=_to_dec(success_rate),
p10_ending_gbp=_to_dec(p10),
p50_ending_gbp=_to_dec(p50),
p90_ending_gbp=_to_dec(p90),
median_lifetime_tax_gbp=_to_dec(median_tax),
median_years_to_ruin=_to_dec(years_to_ruin) if years_to_ruin is not None else None,
elapsed_seconds=_to_dec(elapsed_seconds),
sequence_risk_correlation=_to_dec(seq_corr),
)
session.add(run_row)
await session.flush()
mc_run_id = int(run_row.id)
await _write_projection(session, mc_run_id, result)
await _write_sparse_paths(session, mc_run_id, result)
await _upsert_summary(session, scenario_id, mc_run_id, spec, result)
await session.flush()
write_elapsed = time.perf_counter() - started
del write_elapsed # surface via tracing if needed
return WriteSummary(
scenario_id=scenario_id,
mc_run_id=mc_run_id,
elapsed_seconds=elapsed_seconds,
success_rate=success_rate,
)
async def _write_projection(session: AsyncSession, mc_run_id: int,
result: SimulationResult) -> None:
n_years = result.n_years
portfolios = result.portfolio_real # (n_paths, n_years+1)
p10 = np.percentile(portfolios, 10, axis=0)
p25 = np.percentile(portfolios, 25, axis=0)
p50 = np.percentile(portfolios, 50, axis=0)
p75 = np.percentile(portfolios, 75, axis=0)
p90 = np.percentile(portfolios, 90, axis=0)
withdrawals = result.withdrawal_real
taxes = result.tax_real
survival = (portfolios[:, 1:] > 0).mean(axis=0)
rows = []
for y in range(n_years):
rows.append(
ProjectionYearly(
mc_run_id=mc_run_id,
year_idx=y,
p10_portfolio_gbp=_to_dec(p10[y + 1]),
p25_portfolio_gbp=_to_dec(p25[y + 1]),
p50_portfolio_gbp=_to_dec(p50[y + 1]),
p75_portfolio_gbp=_to_dec(p75[y + 1]),
p90_portfolio_gbp=_to_dec(p90[y + 1]),
p50_withdrawal_gbp=_to_dec(np.median(withdrawals[:, y])),
p50_tax_gbp=_to_dec(np.median(taxes[:, y])),
survival_rate=_to_dec(float(survival[y])),
))
session.add_all(rows)
async def _write_sparse_paths(session: AsyncSession, mc_run_id: int,
result: SimulationResult) -> None:
"""Persist top-decile, bottom-decile, and median path indices.
Picks 3 representative path indices per bucket to keep storage low.
"""
ending = result.portfolio_real[:, -1]
order = np.argsort(ending)
n = len(order)
buckets = {
"bottom": order[:max(3, n // 20)][:3],
"median": order[n // 2:n // 2 + 3],
"top": order[-max(3, n // 20):][:3],
}
rows: list[McPath] = []
for bucket_name, idxs in buckets.items():
for path_idx in idxs:
for y in range(result.n_years):
rows.append(
McPath(
mc_run_id=mc_run_id,
path_idx=int(path_idx),
bucket=bucket_name,
year_idx=y,
portfolio_gbp=_to_dec(result.portfolio_real[path_idx, y + 1]),
withdrawal_gbp=_to_dec(result.withdrawal_real[path_idx, y]),
tax_paid_gbp=_to_dec(result.tax_real[path_idx, y]),
real_portfolio_gbp=_to_dec(result.portfolio_real[path_idx, y + 1]),
))
session.add_all(rows)
async def _upsert_summary(
session: AsyncSession,
scenario_id: int,
mc_run_id: int,
spec: ScenarioSpec,
result: SimulationResult,
) -> None:
insert_ = _dialect_insert(session)
stmt = insert_(ScenarioSummary).values(
scenario_id=scenario_id,
mc_run_id=mc_run_id,
jurisdiction=spec.jurisdiction,
strategy=spec.strategy,
leave_uk_year=spec.leave_uk_year,
glide_path=spec.glide_path,
spending_gbp=spec.spending_gbp,
success_rate=_to_dec(result.success_rate),
p10_ending_gbp=_to_dec(result.ending_percentile(10)),
p50_ending_gbp=_to_dec(result.ending_percentile(50)),
p90_ending_gbp=_to_dec(result.ending_percentile(90)),
median_lifetime_tax_gbp=_to_dec(result.median_lifetime_tax()),
median_years_to_ruin=(_to_dec(ytr) if
(ytr := result.median_years_to_ruin()) is not None else None),
)
stmt = stmt.on_conflict_do_update(
index_elements=["scenario_id"],
set_={
"mc_run_id": stmt.excluded.mc_run_id,
"success_rate": stmt.excluded.success_rate,
"p10_ending_gbp": stmt.excluded.p10_ending_gbp,
"p50_ending_gbp": stmt.excluded.p50_ending_gbp,
"p90_ending_gbp": stmt.excluded.p90_ending_gbp,
"median_lifetime_tax_gbp": stmt.excluded.median_lifetime_tax_gbp,
"median_years_to_ruin": stmt.excluded.median_years_to_ruin,
},
)
await session.execute(stmt)

View file

@ -0,0 +1 @@
"""Historical-returns loaders and bootstrap samplers."""

View file

@ -0,0 +1,60 @@
"""Vectorised block bootstrap.
Block bootstrap preserves serial correlation in returns and inflation
critical for a FIRE planner where sequence-of-returns risk is the
dominant failure mode. Drawing IID-shuffled returns understates left
tails because Depression-era runs of bad years would be impossible.
Default block_size is 5 years per Politis & Romano (1994) guidance for
asset-return series with multi-year mean-reversion. Block start indices
are sampled uniformly with replacement from the legal range i.e.,
overlapping blocks are allowed, last block extends past series end is
NOT allowed (we wrap with circular bootstrap).
"""
from __future__ import annotations
import numpy as np
import numpy.typing as npt
from fire_planner.returns.shiller import ReturnsBundle
def block_bootstrap(
bundle: ReturnsBundle,
n_paths: int,
n_years: int,
block_size: int = 5,
rng: np.random.Generator | None = None,
) -> npt.NDArray[np.float64]:
"""Return an ndarray of shape (n_paths, n_years, 3).
The third axis is (stock_nominal, bond_nominal, cpi).
Implementation: pick `ceil(n_years / block_size)` block starts per
path uniformly with replacement from the historical series; index
each block circularly; concatenate; truncate to n_years. The whole
op is vectorised no Python-level loops over paths.
"""
if rng is None:
rng = np.random.default_rng()
if block_size <= 0:
raise ValueError("block_size must be positive")
src = np.stack([bundle.stock_nominal, bundle.bond_nominal, bundle.cpi], axis=-1)
src_n = src.shape[0]
n_blocks = (n_years + block_size - 1) // block_size
# (n_paths, n_blocks) of block start indices in [0, src_n)
starts = rng.integers(0, src_n, size=(n_paths, n_blocks))
# Offsets within each block, broadcast: (1, 1, block_size)
offsets = np.arange(block_size).reshape(1, 1, block_size)
# (n_paths, n_blocks, block_size) of source indices, mod src_n
idx = (starts[:, :, None] + offsets) % src_n
# Flatten the inner two axes to (n_paths, n_blocks * block_size)
flat_idx = idx.reshape(n_paths, -1)
# Trim to exactly n_years
flat_idx = flat_idx[:, :n_years]
# Gather: result is (n_paths, n_years, 3). Explicit cast — numpy's
# advanced-indexing stubs return ndarray[Any], which trips strict mypy.
out: npt.NDArray[np.float64] = src[flat_idx]
return out

View file

@ -0,0 +1,99 @@
"""Shiller historical-returns loader.
Robert Shiller's `ie_data.xls` (http://www.econ.yale.edu/~shiller/data.htm)
provides monthly S&P 500 prices + dividends, 10-year Treasury rates, and
CPI from 1871. We fold to annual: stock total return (price + reinvested
dividends), bond total return (rate + price effect approximation), and
CPI growth.
The data file isn't shipped — call `load_from_csv` with a path the
operator has fetched, or use `synthetic_returns()` for tests. The CLI's
`ingest --source=shiller` command fetches the latest XLS, derives the
CSV, and caches under `fire_planner/returns/_cache/shiller.csv`.
Expected CSV columns:
year, stock_nominal_return, bond_nominal_return, cpi_inflation
Each numeric column is a fraction (0.05 = 5% return), not basis points.
"""
from __future__ import annotations
import csv
from dataclasses import dataclass
from pathlib import Path
import numpy as np
import numpy.typing as npt
@dataclass(frozen=True)
class ReturnsBundle:
"""Aligned historical annual series.
All four arrays have identical length `n` and share the index axis
`years[i]` is the calendar year of element `i` in the other arrays.
"""
years: npt.NDArray[np.int32]
stock_nominal: npt.NDArray[np.float64]
bond_nominal: npt.NDArray[np.float64]
cpi: npt.NDArray[np.float64]
def __post_init__(self) -> None:
n = len(self.years)
if not (len(self.stock_nominal) == n == len(self.bond_nominal) == len(self.cpi)):
raise ValueError("ReturnsBundle arrays must share length")
if n == 0:
raise ValueError("ReturnsBundle cannot be empty")
@property
def n_years(self) -> int:
return len(self.years)
def stock_real(self) -> npt.NDArray[np.float64]:
"""Real (CPI-adjusted) annual stock return."""
return (1 + self.stock_nominal) / (1 + self.cpi) - 1
def bond_real(self) -> npt.NDArray[np.float64]:
return (1 + self.bond_nominal) / (1 + self.cpi) - 1
def load_from_csv(path: str | Path) -> ReturnsBundle:
p = Path(path)
years: list[int] = []
stocks: list[float] = []
bonds: list[float] = []
cpis: list[float] = []
with p.open() as f:
reader = csv.DictReader(f)
for row in reader:
years.append(int(row["year"]))
stocks.append(float(row["stock_nominal_return"]))
bonds.append(float(row["bond_nominal_return"]))
cpis.append(float(row["cpi_inflation"]))
return ReturnsBundle(
years=np.array(years, dtype=np.int32),
stock_nominal=np.array(stocks, dtype=np.float64),
bond_nominal=np.array(bonds, dtype=np.float64),
cpi=np.array(cpis, dtype=np.float64),
)
def synthetic_returns(seed: int = 42, n_years: int = 150) -> ReturnsBundle:
"""Deterministic synthetic returns for tests and bootstrap-conver-
gence experiments. Calibrated to roughly match Shiller's long-run
moments: stocks ~9.5% nominal / 17% vol, bonds ~5% / 8% vol, CPI
~3% / 4% vol.
NOT for production use `load_from_csv` is.
"""
rng = np.random.default_rng(seed)
stock = rng.normal(0.095, 0.17, n_years)
bond = rng.normal(0.05, 0.08, n_years)
cpi = rng.normal(0.03, 0.04, n_years)
years = np.arange(1871, 1871 + n_years, dtype=np.int32)
return ReturnsBundle(
years=years,
stock_nominal=stock.astype(np.float64),
bond_nominal=bond.astype(np.float64),
cpi=cpi.astype(np.float64),
)

129
fire_planner/scenarios.py Normal file
View file

@ -0,0 +1,129 @@
"""Cartesian-product scenario generator.
Default counts:
4 jurisdictions × 3 strategies × 5 leave-UK years × 2 glides = 120
Jurisdictions modelled by default: uk, nomad, cyprus, bulgaria.
Malaysia and Thailand are essentially equivalent in our tax engine
(both 0% on foreign income); pick one and document. Cyprus is
included because GeSY is non-trivial; Bulgaria for its 10% flat tax.
UK-stay scenarios duplicate across leave_uk_year (since you don't
leave) kept in the product so the dashboard can present a uniform
heatmap; the simulator effectively ignores leave_year for UK.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any
from fire_planner.glide_path import GLIDE_PATHS
from fire_planner.simulator import RegimeFn, constant_regime, jurisdiction_schedule
from fire_planner.strategies.base import WithdrawalStrategy
from fire_planner.strategies.guyton_klinger import GuytonKlingerStrategy
from fire_planner.strategies.trinity import TrinityStrategy
from fire_planner.strategies.vpw import VpwStrategy, VpwWithFloorStrategy
from fire_planner.tax.base import TaxRegime
from fire_planner.tax.bulgaria import BulgariaTaxRegime
from fire_planner.tax.cyprus import CyprusTaxRegime
from fire_planner.tax.malaysia import MalaysiaTaxRegime
from fire_planner.tax.nomad import NomadTaxRegime
from fire_planner.tax.thailand import ThailandTaxRegime
from fire_planner.tax.uae import UaeTaxRegime
from fire_planner.tax.uk import UkTaxRegime
DEFAULT_JURISDICTIONS = ("uk", "nomad", "cyprus", "bulgaria")
DEFAULT_STRATEGIES = ("trinity", "guyton_klinger", "vpw")
DEFAULT_LEAVE_YEARS = (1, 2, 3, 4, 5)
DEFAULT_GLIDES = ("rising", "static_60_40")
@dataclass(frozen=True)
class ScenarioSpec:
"""One scenario in the Cartesian product."""
jurisdiction: str
strategy: str
leave_uk_year: int
glide_path: str
spending_gbp: Decimal
nw_seed_gbp: Decimal
horizon_years: int = 60
savings_per_year_gbp: Decimal = Decimal("0")
config: dict[str, Any] = field(default_factory=dict)
@property
def external_id(self) -> str:
return (f"{self.jurisdiction}-{self.strategy}-leave-y{self.leave_uk_year}-"
f"glide-{self.glide_path}")
def build_strategy(name: str, floor: float | None = None) -> WithdrawalStrategy:
if name == "trinity":
return TrinityStrategy()
if name == "guyton_klinger":
return GuytonKlingerStrategy()
if name == "vpw":
return VpwStrategy()
if name == "vpw_floor":
if floor is None:
raise ValueError("vpw_floor strategy requires a `floor` value (real GBP)")
return VpwWithFloorStrategy(floor=floor)
raise KeyError(f"Unknown strategy: {name!r}")
_JURISDICTION_CONSTRUCTORS: dict[str, type[TaxRegime]] = {
"uk": UkTaxRegime,
"nomad": NomadTaxRegime,
"malaysia": MalaysiaTaxRegime,
"thailand": ThailandTaxRegime,
"cyprus": CyprusTaxRegime,
"bulgaria": BulgariaTaxRegime,
"uae": UaeTaxRegime,
}
def build_regime_schedule(jurisdiction: str, leave_uk_year: int) -> RegimeFn:
"""For UK-stay, returns a constant UK regime ignoring leave_year.
For other jurisdictions, UK pre-departure and the target after."""
if jurisdiction == "uk":
return constant_regime(UkTaxRegime())
cls = _JURISDICTION_CONSTRUCTORS.get(jurisdiction)
if cls is None:
raise KeyError(f"Unknown jurisdiction: {jurisdiction!r}")
return jurisdiction_schedule(
pre_departure=UkTaxRegime(),
post_departure=cls(),
leave_year=leave_uk_year,
)
def cartesian_scenarios(
spending_gbp: Decimal,
nw_seed_gbp: Decimal,
savings_per_year_gbp: Decimal = Decimal("0"),
horizon_years: int = 60,
jurisdictions: tuple[str, ...] = DEFAULT_JURISDICTIONS,
strategies: tuple[str, ...] = DEFAULT_STRATEGIES,
leave_years: tuple[int, ...] = DEFAULT_LEAVE_YEARS,
glides: tuple[str, ...] = DEFAULT_GLIDES,
) -> list[ScenarioSpec]:
out: list[ScenarioSpec] = []
for jur in jurisdictions:
for strat in strategies:
for leave_y in leave_years:
for glide in glides:
if glide not in GLIDE_PATHS:
raise KeyError(f"Unknown glide path: {glide!r}")
out.append(
ScenarioSpec(
jurisdiction=jur,
strategy=strat,
leave_uk_year=leave_y,
glide_path=glide,
spending_gbp=spending_gbp,
nw_seed_gbp=nw_seed_gbp,
horizon_years=horizon_years,
savings_per_year_gbp=savings_per_year_gbp,
))
return out

231
fire_planner/simulator.py Normal file
View file

@ -0,0 +1,231 @@
"""Monte Carlo simulator — the core of fire-planner.
Inputs:
- a `(n_paths, n_years, 3)` bootstrap of returns + CPI (`returns/`)
- a withdrawal strategy (`strategies/`)
- a glide-path function (`glide_path`)
- a tax regime (`tax/`)
- starting portfolio + spending target
Per path the simulator runs a 60-year (or whatever horizon) lifecycle:
for each year y in 0..H:
asset_alloc = glide(y)
portfolio = portfolio * (1 + alloc·stock + (1-alloc)·bond)
withdrawal = strategy.propose(state)
tax = regime.compute_tax(...)
portfolio -= (withdrawal + tax_in_addition)
if portfolio < 0: failed_path
We work entirely in REAL GBP convert returns to real (return/inflation
factor each year). Tax brackets are assumed to inflate with CPI (a v2
follow-up on fiscal drag).
Annual savings (during the accumulation phase) get added at year start
and earn the year's return.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from decimal import Decimal
import numpy as np
import numpy.typing as npt
from fire_planner.glide_path import GlideFn
from fire_planner.strategies.base import StrategyState, WithdrawalStrategy
from fire_planner.tax.base import TaxInputs, TaxRegime
# Stock idx, bond idx, cpi idx in the bootstrap output.
STOCK = 0
BOND = 1
CPI = 2
@dataclass(frozen=True)
class SimulationResult:
"""Per-path arrays + scalar summaries.
All money in REAL GBP (today's pounds). `portfolio_real[:, 0]` is
the seed; `portfolio_real[:, k]` is end-of-year-k.
"""
portfolio_real: npt.NDArray[np.float64] # (n_paths, n_years+1)
withdrawal_real: npt.NDArray[np.float64] # (n_paths, n_years)
tax_real: npt.NDArray[np.float64] # (n_paths, n_years)
success_mask: npt.NDArray[np.bool_] # (n_paths,)
@property
def n_paths(self) -> int:
return int(self.portfolio_real.shape[0])
@property
def n_years(self) -> int:
return int(self.withdrawal_real.shape[1])
@property
def success_rate(self) -> float:
return float(np.mean(self.success_mask))
def ending_percentile(self, p: int) -> float:
return float(np.percentile(self.portfolio_real[:, -1], p))
def median_lifetime_tax(self) -> float:
return float(np.median(self.tax_real.sum(axis=1)))
def median_years_to_ruin(self) -> float | None:
"""Among failing paths, the median year-to-ruin (1-indexed).
Returns None if every path survives."""
failing = ~self.success_mask
if not failing.any():
return None
portfolios = self.portfolio_real[failing, 1:]
# First year-end where portfolio == 0 (or below)
ruin_year = np.argmax(portfolios <= 0, axis=1) + 1
return float(np.median(ruin_year))
def sequence_risk_correlation(self) -> float:
"""Pearson correlation between year-1 drawdown and total
success (1 if survived, 0 if not). Year-1 drawdown =
(initial - portfolio_after_year1) / initial.
Returns 0.0 when either variable has zero variance e.g. all
paths share the same year-1 returns (fixed_paths fixture) or
every path succeeds.
"""
initial = self.portfolio_real[:, 0]
after_y1 = self.portfolio_real[:, 1]
drawdown = (initial - after_y1) / initial
success = self.success_mask.astype(np.float64)
if np.var(drawdown) < 1e-12 or np.var(success) < 1e-12:
return 0.0
with np.errstate(invalid="ignore"):
corr = np.corrcoef(drawdown, success)[0, 1]
return 0.0 if np.isnan(corr) else float(corr)
def fan_quantiles(self, p: int) -> npt.NDArray[np.float64]:
"""Per-year cross-path percentile of portfolio_real."""
out: npt.NDArray[np.float64] = np.percentile(self.portfolio_real, p, axis=0)
return out
# Default split of withdrawal across tax-treated buckets. The simulator
# treats withdrawals as "capital gains" by default since most accounts
# we model are taxable brokerage; ISA wraps don't tax at all and SIPP
# withdrawals are 25%-tax-free + ordinary income.
_BucketSplit = Callable[[float, int], TaxInputs]
def default_bucket_split(real_withdrawal: float, year_idx: int) -> TaxInputs:
"""Treat the entire withdrawal as long-term capital gains.
Override via `bucket_split` to reflect ISA / SIPP / divs balances."""
del year_idx
return TaxInputs(capital_gains=Decimal(str(round(real_withdrawal, 2))))
RegimeFn = Callable[[int], TaxRegime]
def constant_regime(regime: TaxRegime) -> RegimeFn:
return lambda _y: regime
def jurisdiction_schedule(
pre_departure: TaxRegime,
post_departure: TaxRegime,
leave_year: int,
) -> RegimeFn:
"""While `year < leave_year` apply `pre_departure`; from `leave_year`
onwards apply `post_departure`. Used to model "live in UK for N more
years then move to Cyprus/Bulgaria/etc."
"""
def fn(year: int) -> TaxRegime:
return pre_departure if year < leave_year else post_departure
return fn
def simulate(
paths: npt.NDArray[np.float64],
initial_portfolio: float,
spending_target: float,
glide: GlideFn,
strategy: WithdrawalStrategy,
regime: TaxRegime | RegimeFn,
horizon_years: int | None = None,
annual_savings: npt.NDArray[np.float64] | None = None,
bucket_split: _BucketSplit = default_bucket_split,
) -> SimulationResult:
"""Run the MC simulation. `paths` shape: (n_paths, n_years, 3).
`spending_target` is the year-0 real GBP draw; subsequent years are
decided by the strategy. `annual_savings`, if given, is a (n_years,)
real-GBP array added at the start of each year while accumulating.
`regime` may be a single `TaxRegime` (constant for all years) or a
callable `(year_idx) -> TaxRegime` to model jurisdiction switches
e.g. UK for years 0..N-1, then Cyprus from year N onward.
"""
regime_at: RegimeFn = (regime if callable(regime) else constant_regime(regime))
n_paths, n_years, _ = paths.shape
if horizon_years is None:
horizon_years = n_years
portfolio = np.full(n_paths, float(initial_portfolio), dtype=np.float64)
portfolio_history = np.zeros((n_paths, n_years + 1), dtype=np.float64)
portfolio_history[:, 0] = portfolio
withdrawal_hist = np.zeros((n_paths, n_years), dtype=np.float64)
tax_hist = np.zeros((n_paths, n_years), dtype=np.float64)
last_withdrawal = np.full(n_paths, float(spending_target), dtype=np.float64)
if annual_savings is None:
annual_savings = np.zeros(n_years, dtype=np.float64)
for y in range(n_years):
alloc = glide(y)
# Real returns for this year, all paths: shape (n_paths,)
nominal_stock = paths[:, y, STOCK]
nominal_bond = paths[:, y, BOND]
cpi = paths[:, y, CPI]
real_stock = (1 + nominal_stock) / (1 + cpi) - 1
real_bond = (1 + nominal_bond) / (1 + cpi) - 1
port_return = alloc * real_stock + (1 - alloc) * real_bond
# Add savings at year start, then apply year's return.
portfolio = (portfolio + annual_savings[y]) * (1 + port_return)
# Strategy is per-path Python — 600k iterations at 60y × 10k paths.
# Profiled: ~3 seconds for the full Trinity / GK / VPW set.
for p in range(n_paths):
state = StrategyState(
portfolio=float(portfolio[p]),
initial_portfolio=float(initial_portfolio),
initial_withdrawal=float(spending_target),
year_idx=y,
horizon_years=horizon_years,
last_withdrawal=float(last_withdrawal[p]),
)
w = strategy.propose_withdrawal(state)
# Strategies are real-GBP; clip to portfolio.
w = max(0.0, min(w, float(portfolio[p])))
tax_breakdown = regime_at(y).compute_tax(bucket_split(w, y))
t = float(tax_breakdown.total)
portfolio[p] = portfolio[p] - w
withdrawal_hist[p, y] = w
tax_hist[p, y] = t
last_withdrawal[p] = w
portfolio_history[:, y + 1] = np.clip(portfolio, a_min=0.0, a_max=None)
portfolio = portfolio_history[:, y + 1]
# Success = portfolio stayed positive through every interim year.
# Excludes the very last year-end because VPW deliberately drains
# to ~0 at the horizon boundary by construction; that's not a failure.
success_mask = portfolio_history[:, 1:-1].min(axis=1) > 0.0
return SimulationResult(
portfolio_real=portfolio_history,
withdrawal_real=withdrawal_hist,
tax_real=tax_hist,
success_mask=success_mask,
)

View file

@ -0,0 +1 @@
"""Withdrawal strategies."""

View file

@ -0,0 +1,37 @@
"""Withdrawal-strategy abstract base.
All strategies operate in REAL GBP terms the simulator deflates by
the cumulative CPI index before calling. Brackets inside the tax
engines are also assumed to inflate with CPI (simplifying assumption
that tax thresholds keep pace with inflation fiscal drag is a
documented v2 follow-up).
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass(frozen=True)
class StrategyState:
"""Inputs to a strategy's per-year decision. Real GBP throughout."""
portfolio: float
initial_portfolio: float
initial_withdrawal: float
year_idx: int
horizon_years: int
last_withdrawal: float
expected_real_return: float = 0.04
class WithdrawalStrategy(ABC):
name: str
@abstractmethod
def propose_withdrawal(self, state: StrategyState) -> float:
"""Return the proposed withdrawal in real GBP for this year.
The simulator may clip downward if the portfolio is exhausted
strategies can request more than the portfolio holds.
"""
raise NotImplementedError

View file

@ -0,0 +1,57 @@
"""Guyton-Klinger 4-rule guardrails (FPA Journal, 2006).
Decision rules applied each year, in order:
1. **Portfolio-Management Rule** choose which asset class to draw from
(we delegate to the simulator's rebalance logic; ignored here).
2. **Inflation Rule** skip the inflation uplift on the prior year's
withdrawal if both:
a. the prior year's nominal portfolio return was negative, AND
b. the current withdrawal rate would exceed the initial rate.
3. **Capital-Preservation Rule** cut the withdrawal by 10% if the
current rate exceeds 120% of the initial rate AND there are more
than 15 years left in the horizon.
4. **Prosperity Rule** increase the withdrawal by 10% if the current
rate is below 80% of the initial rate.
This implementation operates in real GBP, so the inflation-skip rule
has no effect (real values don't drift with inflation). The other three
rules apply normally. Trade-off: simplifies the math at the cost of
slightly under-cutting in nominal-stress scenarios.
Initial rate baseline: 5.5% of starting portfolio (per Guyton-Klinger
paper, allows higher sustainable spend than Trinity by tolerating
guardrail cuts).
"""
from __future__ import annotations
from fire_planner.strategies.base import StrategyState, WithdrawalStrategy
DEFAULT_INITIAL_RATE = 0.055
CAPITAL_PRESERVATION_RATIO = 1.20
PROSPERITY_RATIO = 0.80
ADJUSTMENT = 0.10
MIN_HORIZON_FOR_CUT = 15
class GuytonKlingerStrategy(WithdrawalStrategy):
name = "guyton_klinger"
def __init__(self, initial_rate: float = DEFAULT_INITIAL_RATE) -> None:
self.initial_rate = initial_rate
def propose_withdrawal(self, state: StrategyState) -> float:
if state.year_idx == 0:
return state.initial_portfolio * self.initial_rate
if state.portfolio <= 0:
return 0.0
last_w = state.last_withdrawal
current_rate = last_w / state.portfolio
years_left = state.horizon_years - state.year_idx
# Capital-preservation cut: only if more than 15 years remain.
if (current_rate > self.initial_rate * CAPITAL_PRESERVATION_RATIO
and years_left > MIN_HORIZON_FOR_CUT):
return last_w * (1 - ADJUSTMENT)
if current_rate < self.initial_rate * PROSPERITY_RATIO:
return last_w * (1 + ADJUSTMENT)
return last_w

View file

@ -0,0 +1,24 @@
"""Trinity 4% Safe Withdrawal Rate.
Bengen's seminal 1994 paper + the Trinity Study (Cooley/Hubbard/Walz,
1998) withdraw 4% of the starting balance in year 1, then keep the
real withdrawal constant for the rest of retirement. In our real-GBP
internal frame this is just "the same number every year".
"""
from __future__ import annotations
from fire_planner.strategies.base import StrategyState, WithdrawalStrategy
DEFAULT_INITIAL_RATE = 0.04
class TrinityStrategy(WithdrawalStrategy):
name = "trinity"
def __init__(self, initial_rate: float = DEFAULT_INITIAL_RATE) -> None:
self.initial_rate = initial_rate
def propose_withdrawal(self, state: StrategyState) -> float:
if state.year_idx == 0:
return state.initial_portfolio * self.initial_rate
return state.last_withdrawal

View file

@ -0,0 +1,83 @@
"""VPW — Variable Percentage Withdrawal (Bogleheads).
Withdrawal rate is the standard PMT (annuity-payment) formula given a
target real return and the years remaining:
rate(n, r) = r / (1 - (1 + r)^-n)
At year `y` of an `H`-year horizon, withdraw
`portfolio * rate(H - y, expected_real_return)`. The withdrawal scales
with the portfolio bear markets cut spending immediately, bull
markets allow more eliminating ruin risk at the cost of variable
income.
Bogleheads VPW table values (60% stocks, 40% bonds, 5% real expected):
- Age 35, 60y horizon: 5.30%
- Age 50, 45y horizon: 5.86%
- Age 65, 30y horizon: 7.09%
- Age 80, 15y horizon: 11.42%
Default `expected_real_return=0.05` matches Bogleheads' 60/40 assumption.
"""
from __future__ import annotations
from fire_planner.strategies.base import StrategyState, WithdrawalStrategy
DEFAULT_EXPECTED_REAL_RETURN = 0.05
def pmt_rate(years_remaining: int, real_rate: float) -> float:
"""PMT formula, capped at 1.0.
Ordinary-annuity convention matches Bogleheads' VPW table for
horizons > 1y. At 1y remaining the textbook formula returns
`(1+r)` because of end-of-period interest accrual, which would
propose a withdrawal larger than the portfolio Bogleheads caps
at 100% in that case, and so do we.
"""
if years_remaining <= 0:
return 1.0
if abs(real_rate) < 1e-9:
return 1.0 / years_remaining
return min(1.0, real_rate / (1.0 - (1.0 + real_rate)**-years_remaining))
class VpwStrategy(WithdrawalStrategy):
name = "vpw"
def __init__(self, expected_real_return: float = DEFAULT_EXPECTED_REAL_RETURN) -> None:
self.expected_real_return = expected_real_return
def propose_withdrawal(self, state: StrategyState) -> float:
if state.portfolio <= 0:
return 0.0
years_left = state.horizon_years - state.year_idx
rate = pmt_rate(years_left, self.expected_real_return)
return state.portfolio * rate
class VpwWithFloorStrategy(WithdrawalStrategy):
"""VPW with a real-GBP floor — the never-drop-below safety net.
Each year propose `max(floor, vpw_proposed)`, then clip to portfolio
so we cannot withdraw more than exists. The floor is the binding
constraint in bad sequences; in good sequences VPW dominates and
spending scales up. The simulator's success_mask uses the
portfolio-positive-through-interim-years check, so a floor that
drains the portfolio early is still penalised the right way.
"""
name = "vpw_floor"
def __init__(self,
floor: float,
expected_real_return: float = DEFAULT_EXPECTED_REAL_RETURN) -> None:
self.floor = floor
self.expected_real_return = expected_real_return
def propose_withdrawal(self, state: StrategyState) -> float:
if state.portfolio <= 0:
return 0.0
years_left = state.horizon_years - state.year_idx
rate = pmt_rate(years_left, self.expected_real_return)
vpw_proposed = state.portfolio * rate
return min(state.portfolio, max(self.floor, vpw_proposed))

View file

@ -0,0 +1 @@
"""Per-jurisdiction tax engines."""

91
fire_planner/tax/base.py Normal file
View file

@ -0,0 +1,91 @@
"""Tax-regime abstract base — every jurisdiction implements this.
Inputs are split by income source because each source carries different
tax treatment (e.g. ISA withdrawals are always 0%, capital gains may be
exempt in some jurisdictions, pension withdrawals are partially tax-free
in the UK). The regime decides how to combine them.
Outputs are split per tax type so we can attribute lifetime tax the
Grafana panel shows e.g. "lifetime CGT paid" separately from "lifetime
income tax".
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from decimal import Decimal
@dataclass(frozen=True)
class TaxInputs:
"""Annual gross flows for a single tax year. All amounts in GBP, all
non-negative withdrawals are absolute values.
`years_since_uk_departure` lets the UK regime apply the 5-year
Temporary Non-Residence claw-back: gains realised abroad get clawed
back if you return within 5y. Non-UK regimes ignore it.
"""
earned_income: Decimal = Decimal("0")
pension_withdrawal: Decimal = Decimal("0")
capital_gains: Decimal = Decimal("0")
dividends: Decimal = Decimal("0")
isa_withdrawals: Decimal = Decimal("0")
interest: Decimal = Decimal("0")
years_since_uk_departure: int = 0
@dataclass(frozen=True)
class TaxBreakdown:
"""Tax due, split by category. `total` is the sum — every regime
must keep `total == sum of categories` for the integrity check.
"""
income_tax: Decimal = Decimal("0")
national_insurance: Decimal = Decimal("0")
capital_gains_tax: Decimal = Decimal("0")
dividend_tax: Decimal = Decimal("0")
healthcare_levy: Decimal = Decimal("0")
other: Decimal = Decimal("0")
notes: tuple[str, ...] = field(default_factory=tuple)
@property
def total(self) -> Decimal:
return (self.income_tax + self.national_insurance + self.capital_gains_tax +
self.dividend_tax + self.healthcare_levy + self.other)
class TaxRegime(ABC):
"""Per-jurisdiction tax engine. Stateless — every call gets fresh
inputs. Sub-classes set `name` for the scenario key.
"""
name: str
@abstractmethod
def compute_tax(self, inputs: TaxInputs) -> TaxBreakdown:
"""Return the year's tax due given gross income/gains/dividends."""
raise NotImplementedError
def apply_brackets(amount: Decimal, brackets: list[tuple[Decimal, Decimal]]) -> Decimal:
"""Apply a progressive bracket schedule to `amount`.
`brackets` is a list of (band_top, marginal_rate) band_top is the
upper bound of the band (use Decimal('Infinity') for the last band).
Bands are evaluated in order from lowest to highest.
Example UK PAYE 2026/27 above the personal allowance:
[(50_270 - 12_570, Decimal("0.20")),
(125_140 - 12_570, Decimal("0.40")),
(Decimal("Infinity"), Decimal("0.45"))]
where `amount` is taxable income net of the allowance.
"""
if amount <= 0:
return Decimal("0")
tax = Decimal("0")
prev_top = Decimal("0")
for band_top, rate in brackets:
if amount <= prev_top:
break
slice_top = min(amount, band_top)
tax += (slice_top - prev_top) * rate
prev_top = band_top
return tax

View file

@ -0,0 +1,32 @@
"""Bulgaria regime — 10% flat tax on worldwide income.
Article 48 of the Personal Income Tax Act sets a flat 10% on
worldwide income for residents. Capital gains on EU/EEA-listed
securities held over the relevant holding period are exempt
(Art 13(1)(3)) most of our portfolio qualifies. We approximate
all capital gains as 10% to be conservative (CGT on US-listed
ETFs from a Bulgarian resident is contested terrain; many funds
the planner holds are Irish UCITS so the EU exemption likely
applies, but we don't optimise for that here).
"""
from __future__ import annotations
from decimal import Decimal
from fire_planner.tax.base import TaxBreakdown, TaxInputs, TaxRegime
FLAT_RATE = Decimal("0.10")
class BulgariaTaxRegime(TaxRegime):
name = "bulgaria"
def compute_tax(self, inputs: TaxInputs) -> TaxBreakdown:
chargeable = (inputs.earned_income + inputs.pension_withdrawal + inputs.capital_gains +
inputs.dividends + inputs.interest)
return TaxBreakdown(
income_tax=(inputs.earned_income + inputs.pension_withdrawal) * FLAT_RATE,
capital_gains_tax=inputs.capital_gains * FLAT_RATE,
dividend_tax=(inputs.dividends + inputs.interest) * FLAT_RATE,
notes=("bulgaria-flat", f"chargeable={chargeable}"),
)

View file

@ -0,0 +1,49 @@
"""Cyprus regime — non-dom 17-year exemption on foreign dividends +
interest, plus 2.65% GeSY healthcare levy capped at 180k.
The non-dom regime (Art 8(20)/(20A) Income Tax Law 118(I)/2002) gives
17 years of full exemption from SDC (Special Defence Contribution) on
foreign dividends and interest. Capital gains on shares are exempt
under standard CGT rules (only Cypriot real estate is taxed). Earned
income from employment is taxed under standard PIT bands irrelevant
for our retirement scenarios.
GeSY (Γε.Σ.Υ. General Healthcare System) levies 2.65% on worldwide
income with an annual cap of 180,000 of contributing income. We
convert the 180k cap to GBP via the FX rate at scenario time;
default = 0.86 GBP/EUR £154,800.
"""
from __future__ import annotations
from decimal import Decimal
from fire_planner.tax.base import TaxBreakdown, TaxInputs, TaxRegime
DEFAULT_GESY_RATE = Decimal("0.0265")
DEFAULT_GESY_CAP_EUR = Decimal("180000")
class CyprusTaxRegime(TaxRegime):
name = "cyprus"
def __init__(
self,
gesy_rate: Decimal = DEFAULT_GESY_RATE,
gesy_cap_gbp: Decimal | None = None,
gbp_per_eur: Decimal = Decimal("0.86"),
) -> None:
self.gesy_rate = gesy_rate
self.gesy_cap_gbp = (gesy_cap_gbp if gesy_cap_gbp is not None else DEFAULT_GESY_CAP_EUR *
gbp_per_eur)
def compute_tax(self, inputs: TaxInputs) -> TaxBreakdown:
# Foreign divs/interest exempt under non-dom (assumed within 17y window).
# Foreign capital gains exempt unless the underlying is Cypriot real estate.
chargeable = (inputs.earned_income + inputs.pension_withdrawal + inputs.capital_gains +
inputs.dividends + inputs.interest)
capped = min(chargeable, self.gesy_cap_gbp)
return TaxBreakdown(
healthcare_levy=capped * self.gesy_rate,
notes=("cyprus-non-dom", f"gesy_rate={self.gesy_rate}",
f"gesy_cap_gbp={self.gesy_cap_gbp}"),
)

View file

@ -0,0 +1,24 @@
"""Malaysia regime — 0% on foreign-sourced income for individuals.
Under the Income Tax Act 1967 s.3 + para 28 sched 6, foreign-sourced
income received by an individual is exempt extended to 2036 by the
Finance Act 2022. Our portfolio is wholly foreign (US/UK ETFs, GBP
brokerage, RSU vests already taxed at source), so all flows fall
outside Malaysian tax.
We do NOT model the MM2H visa fee, healthcare, or property purchase
costs those belong in the spending budget, not the tax engine.
"""
from __future__ import annotations
from decimal import Decimal
from fire_planner.tax.base import TaxBreakdown, TaxInputs, TaxRegime
class MalaysiaTaxRegime(TaxRegime):
name = "malaysia"
def compute_tax(self, inputs: TaxInputs) -> TaxBreakdown:
del inputs # all foreign income is exempt
return TaxBreakdown(notes=("malaysia-foreign-exempt", ), other=Decimal("0"))

31
fire_planner/tax/nomad.py Normal file
View file

@ -0,0 +1,31 @@
"""Perpetual-traveller / nomad regime — 0% income tax + 1% regulatory
risk premium on all flows.
The 1% premium captures the real-world risk that a "no tax residence"
posture eventually attracts adverse rulings, the OECD CRS net tightens,
or a destination starts taxing previously-exempt foreign income (e.g.
Thailand 2024 remittance rule). We don't try to model the actual
mechanism it's a Bayesian fudge factor. Tunable via the constructor.
"""
from __future__ import annotations
from decimal import Decimal
from fire_planner.tax.base import TaxBreakdown, TaxInputs, TaxRegime
class NomadTaxRegime(TaxRegime):
name = "nomad"
def __init__(self, regulatory_premium_rate: Decimal = Decimal("0.01")) -> None:
self.regulatory_premium_rate = regulatory_premium_rate
def compute_tax(self, inputs: TaxInputs) -> TaxBreakdown:
# ISA withdrawals are tax-free in the UK; for a nomad they're
# just cash. The risk premium applies to cash that flows
# *outside* a UK wrapper because that's the boundary the
# premium is hedging.
chargeable = (inputs.earned_income + inputs.pension_withdrawal + inputs.capital_gains +
inputs.dividends + inputs.interest)
return TaxBreakdown(other=chargeable * self.regulatory_premium_rate,
notes=("nomad", f"premium_rate={self.regulatory_premium_rate}"))

View file

@ -0,0 +1,23 @@
"""Thailand regime — 0% on foreign-sourced income, with a caveat.
Thailand's 2024 remittance rule (Por 162/2566) made foreign income
*remitted* into Thailand in the year earned (or the next) taxable.
Money kept abroad is still untouched, and we assume the planner
holds investments in offshore custody (IBKR US/UK, Schwab) and
remits only the £100k spend. The 2024 rule does mean some of that
remittance could be taxable; for v1 we mirror the Malaysian
"foreign exempt" treatment and revisit when prod data lands.
"""
from __future__ import annotations
from decimal import Decimal
from fire_planner.tax.base import TaxBreakdown, TaxInputs, TaxRegime
class ThailandTaxRegime(TaxRegime):
name = "thailand"
def compute_tax(self, inputs: TaxInputs) -> TaxBreakdown:
del inputs
return TaxBreakdown(notes=("thailand-foreign-exempt-v1", ), other=Decimal("0"))

28
fire_planner/tax/uae.py Normal file
View file

@ -0,0 +1,28 @@
"""UAE regime — true 0% personal income tax with no equivalent levy.
The UAE has no personal income tax, no capital gains tax, no dividend
tax, and no inheritance tax for individuals. The 9% federal corporate
tax (effective 2023) applies only to in-country business profits over
AED 375k irrelevant to a passive investor drawing down a foreign
brokerage account.
Unlike `NomadTaxRegime`, we do NOT apply a regulatory-risk premium:
the UAE is a real tax residence with an extensive double-tax-treaty
network (UK DTT in force; tax-residence certificates issued by the
FTA). The downside of UAE is high cost of living and visa overhead,
not tax uncertainty those costs sit in the spending budget, not
the tax engine.
"""
from __future__ import annotations
from decimal import Decimal
from fire_planner.tax.base import TaxBreakdown, TaxInputs, TaxRegime
class UaeTaxRegime(TaxRegime):
name = "uae"
def compute_tax(self, inputs: TaxInputs) -> TaxBreakdown:
del inputs # 0% on all personal income flows
return TaxBreakdown(notes=("uae-zero-pit", ), other=Decimal("0"))

175
fire_planner/tax/uk.py Normal file
View file

@ -0,0 +1,175 @@
"""UK tax regime — 2026/27 PAYE/NI/CGT/dividend rules.
Rates are baked-in for 2026/27 and held in module-level constants so
they can be patched per-test or upgraded for a future tax year. Only
the *income* side is modelled at year resolution pension wrapper
contributions and accumulation-phase tax-relief are handled by the
simulator's ISA/SIPP bucket plumbing, not here.
Sources:
- HMRC rates and thresholds 2026-27 (gov.uk/income-tax-rates).
- TCGA 1992 s.10A 5-year temporary non-residence claw-back.
"""
from __future__ import annotations
from decimal import Decimal
from fire_planner.tax.base import TaxBreakdown, TaxInputs, TaxRegime, apply_brackets
INF = Decimal("Infinity")
# 2026/27 thresholds — frozen by Treasury until at least 2028-04 per the
# autumn 2024 budget. PA tapers above £100k at £1 per £2 → 0 at £125,140.
PERSONAL_ALLOWANCE = Decimal("12570")
PA_TAPER_FLOOR = Decimal("100000")
PA_TAPER_CEILING = Decimal("125140")
BASIC_RATE_BAND = Decimal("37700")
ADDITIONAL_RATE_THRESHOLD = Decimal("125140")
# PAYE income-tax brackets, applied to TAXABLE income (after PA).
# HMRC defines the additional-rate threshold at £125,140 of *taxable*
# income — independent of the PA-taper outcome — so the higher-rate
# band width depends on PA. With full PA the 40% band runs from
# £37,701 to £125,140 (width £87,440); with PA fully tapered the 40%
# band still runs to £125,140 of taxable, just from a lower starting
# gross.
INCOME_TAX_BRACKETS: list[tuple[Decimal, Decimal]] = [
(BASIC_RATE_BAND, Decimal("0.20")),
(ADDITIONAL_RATE_THRESHOLD, Decimal("0.40")),
(INF, Decimal("0.45")),
]
# NI Class 1 employee 2026/27 — annualised. Real-world NI is calculated
# per-period but for retirement modelling annual approximation is fine.
NI_PRIMARY_THRESHOLD = Decimal("12570")
NI_UPPER_EARNINGS_LIMIT = Decimal("50270")
NI_BRACKETS: list[tuple[Decimal, Decimal]] = [
(NI_UPPER_EARNINGS_LIMIT - NI_PRIMARY_THRESHOLD, Decimal("0.08")),
(INF, Decimal("0.02")),
]
# Capital gains — Autumn Budget 2024 equalised property + non-property
# rates within each band. £3,000 annual exempt amount.
CGT_ANNUAL_EXEMPTION = Decimal("3000")
CGT_BASIC_RATE = Decimal("0.18")
CGT_HIGHER_RATE = Decimal("0.24")
# Dividend tax 2026/27 — £500 allowance, then 8.75 / 33.75 / 39.35.
DIVIDEND_ALLOWANCE = Decimal("500")
DIVIDEND_BASIC = Decimal("0.0875")
DIVIDEND_HIGHER = Decimal("0.3375")
DIVIDEND_ADDITIONAL = Decimal("0.3935")
# Personal Savings Allowance — only basic and higher rate get any.
PSA_BASIC = Decimal("1000")
PSA_HIGHER = Decimal("500")
# Pension withdrawal — 25% tax-free up to the lump-sum allowance, rest
# taxed as ordinary income. Cap is per-lifetime; we apply it per-year
# because the simulator doesn't track cumulative PCLS yet (all
# withdrawals stay below the cap on the £100k spend assumption).
PCLS_FRACTION = Decimal("0.25")
def taper_personal_allowance(adjusted_net_income: Decimal) -> Decimal:
"""Apply the PA taper: 0 above £125,140, full PA below £100k."""
if adjusted_net_income <= PA_TAPER_FLOOR:
return PERSONAL_ALLOWANCE
if adjusted_net_income >= PA_TAPER_CEILING:
return Decimal("0")
reduction = (adjusted_net_income - PA_TAPER_FLOOR) / Decimal("2")
return max(Decimal("0"), PERSONAL_ALLOWANCE - reduction)
def _income_tax(taxable_income: Decimal) -> Decimal:
return apply_brackets(taxable_income, INCOME_TAX_BRACKETS)
def _ni(earned_income: Decimal) -> Decimal:
above_threshold = max(Decimal("0"), earned_income - NI_PRIMARY_THRESHOLD)
return apply_brackets(above_threshold, NI_BRACKETS)
def _cgt(gains: Decimal, taxable_non_gains_income: Decimal) -> Decimal:
"""Apply CGT — fills the unused basic rate band first, then 24%."""
after_exemption = max(Decimal("0"), gains - CGT_ANNUAL_EXEMPTION)
if after_exemption == 0:
return Decimal("0")
basic_band_remaining = max(Decimal("0"), BASIC_RATE_BAND - taxable_non_gains_income)
in_basic = min(after_exemption, basic_band_remaining)
in_higher = after_exemption - in_basic
return in_basic * CGT_BASIC_RATE + in_higher * CGT_HIGHER_RATE
def _dividend_tax(dividends: Decimal, taxable_non_div_income: Decimal) -> Decimal:
"""Dividends are stacked on top of other income, so the band
boundaries depend on what's already used. £500 allowance off the top.
"""
after_allowance = max(Decimal("0"), dividends - DIVIDEND_ALLOWANCE)
if after_allowance == 0:
return Decimal("0")
basic_band_remaining = max(Decimal("0"), BASIC_RATE_BAND - taxable_non_div_income)
higher_band_remaining = max(
Decimal("0"), ADDITIONAL_RATE_THRESHOLD - max(taxable_non_div_income, BASIC_RATE_BAND))
in_basic = min(after_allowance, basic_band_remaining)
rest = after_allowance - in_basic
in_higher = min(rest, higher_band_remaining)
in_additional = rest - in_higher
return (in_basic * DIVIDEND_BASIC + in_higher * DIVIDEND_HIGHER +
in_additional * DIVIDEND_ADDITIONAL)
def _psa_for_band(taxable_non_savings_income: Decimal) -> Decimal:
"""Personal Savings Allowance scales by tax band:
- basic rate (income within £37,700): £1,000
- higher rate: £500
- additional rate (above £125,140 net of PA): £0
"""
if taxable_non_savings_income <= BASIC_RATE_BAND:
return PSA_BASIC
if taxable_non_savings_income <= ADDITIONAL_RATE_THRESHOLD:
return PSA_HIGHER
return Decimal("0")
class UkTaxRegime(TaxRegime):
"""UK 2026/27. ISA withdrawals are pre-filtered out (always 0%);
pension withdrawals get 25% tax-free, the rest is added to earned
income for PAYE.
The 5-year Temporary Non-Residence claw-back is the simulator's
job: when a path returns to the UK within 5y of departure, it
sums the non-UK regime's pre-tax flows for those years and runs
them through this regime to compute the recapture. This class
just computes "tax in a single UK year".
"""
name = "uk"
def compute_tax(self, inputs: TaxInputs) -> TaxBreakdown:
# 25% PCLS, rest taxed as income.
pcls_tax_free = inputs.pension_withdrawal * PCLS_FRACTION
pension_taxable = inputs.pension_withdrawal - pcls_tax_free
ordinary_income = inputs.earned_income + pension_taxable
adjusted_net_income = ordinary_income + inputs.dividends + inputs.interest
pa = taper_personal_allowance(adjusted_net_income)
taxable_ordinary = max(Decimal("0"), ordinary_income - pa)
income_tax = _income_tax(taxable_ordinary)
ni = _ni(inputs.earned_income)
psa = _psa_for_band(taxable_ordinary)
taxable_interest = max(Decimal("0"), inputs.interest - psa)
income_tax += apply_brackets(taxable_interest, INCOME_TAX_BRACKETS)
cgt = _cgt(inputs.capital_gains, taxable_ordinary)
div_tax = _dividend_tax(inputs.dividends, taxable_ordinary)
return TaxBreakdown(
income_tax=income_tax,
national_insurance=ni,
capital_gains_tax=cgt,
dividend_tax=div_tax,
notes=("uk-2026-27", f"pcls_tax_free={pcls_tax_free}", f"pa_used={pa}"),
)