Initial extraction from monorepo
This commit is contained in:
commit
f7ef7ca4ab
56 changed files with 6163 additions and 0 deletions
1
fire_planner/__init__.py
Normal file
1
fire_planner/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Risk-adjusted, tax-minimised FIRE retirement planner."""
|
||||
259
fire_planner/__main__.py
Normal file
259
fire_planner/__main__.py
Normal 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
112
fire_planner/app.py
Normal 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
165
fire_planner/db.py
Normal 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
49
fire_planner/fx.py
Normal 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
|
||||
46
fire_planner/glide_path.py
Normal file
46
fire_planner/glide_path.py
Normal 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]
|
||||
1
fire_planner/ingest/__init__.py
Normal file
1
fire_planner/ingest/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Ingest layers — Wealthfolio, payslip-ingest, hmrc-sync."""
|
||||
25
fire_planner/ingest/hmrc.py
Normal file
25
fire_planner/ingest/hmrc.py
Normal 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")
|
||||
77
fire_planner/ingest/payslip.py
Normal file
77
fire_planner/ingest/payslip.py
Normal 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),
|
||||
)
|
||||
126
fire_planner/ingest/wealthfolio.py
Normal file
126
fire_planner/ingest/wealthfolio.py
Normal 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)
|
||||
1
fire_planner/reporters/__init__.py
Normal file
1
fire_planner/reporters/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Result reporters — Postgres + CLI pretty-printer."""
|
||||
31
fire_planner/reporters/cli.py
Normal file
31
fire_planner/reporters/cli.py
Normal 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)
|
||||
224
fire_planner/reporters/pg.py
Normal file
224
fire_planner/reporters/pg.py
Normal 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)
|
||||
1
fire_planner/returns/__init__.py
Normal file
1
fire_planner/returns/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Historical-returns loaders and bootstrap samplers."""
|
||||
60
fire_planner/returns/bootstrap.py
Normal file
60
fire_planner/returns/bootstrap.py
Normal 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
|
||||
99
fire_planner/returns/shiller.py
Normal file
99
fire_planner/returns/shiller.py
Normal 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
129
fire_planner/scenarios.py
Normal 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
231
fire_planner/simulator.py
Normal 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,
|
||||
)
|
||||
1
fire_planner/strategies/__init__.py
Normal file
1
fire_planner/strategies/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Withdrawal strategies."""
|
||||
37
fire_planner/strategies/base.py
Normal file
37
fire_planner/strategies/base.py
Normal 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
|
||||
57
fire_planner/strategies/guyton_klinger.py
Normal file
57
fire_planner/strategies/guyton_klinger.py
Normal 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
|
||||
24
fire_planner/strategies/trinity.py
Normal file
24
fire_planner/strategies/trinity.py
Normal 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
|
||||
83
fire_planner/strategies/vpw.py
Normal file
83
fire_planner/strategies/vpw.py
Normal 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))
|
||||
1
fire_planner/tax/__init__.py
Normal file
1
fire_planner/tax/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Per-jurisdiction tax engines."""
|
||||
91
fire_planner/tax/base.py
Normal file
91
fire_planner/tax/base.py
Normal 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
|
||||
32
fire_planner/tax/bulgaria.py
Normal file
32
fire_planner/tax/bulgaria.py
Normal 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}"),
|
||||
)
|
||||
49
fire_planner/tax/cyprus.py
Normal file
49
fire_planner/tax/cyprus.py
Normal 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}"),
|
||||
)
|
||||
24
fire_planner/tax/malaysia.py
Normal file
24
fire_planner/tax/malaysia.py
Normal 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
31
fire_planner/tax/nomad.py
Normal 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}"))
|
||||
23
fire_planner/tax/thailand.py
Normal file
23
fire_planner/tax/thailand.py
Normal 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
28
fire_planner/tax/uae.py
Normal 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
175
fire_planner/tax/uk.py
Normal 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}"),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue