Initial extraction from monorepo

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

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
__pycache__/
*.pyc
.venv/
.mypy_cache/
.pytest_cache/
.ruff_cache/
.hypothesis/
*.egg-info/

45
.woodpecker.yml Normal file
View file

@ -0,0 +1,45 @@
when:
event: push
clone:
git:
image: woodpeckerci/plugin-git
settings:
attempts: 5
backoff: 10s
steps:
- name: lint-and-test
image: python:3.12-slim
commands:
- pip install --no-cache-dir "poetry==1.8.4"
- poetry install --no-interaction --no-root
- poetry run ruff check .
- poetry run mypy fire_planner tests
- poetry run pytest -q
- name: build-and-push
image: woodpeckerci/plugin-docker-buildx
depends_on:
- lint-and-test
settings:
# Dual-push during the Forgejo registry consolidation bake. After
# ≥14 days clean, registry.viktorbarzin.me drops out (Phase 4).
repo:
- registry.viktorbarzin.me/fire-planner
- forgejo.viktorbarzin.me/viktor/fire-planner
logins:
- registry: registry.viktorbarzin.me
username: viktorbarzin
password:
from_secret: registry-password
- registry: forgejo.viktorbarzin.me
username:
from_secret: forgejo_user
password:
from_secret: forgejo_push_token
dockerfile: Dockerfile
context: .
auto_tag: true
platforms:
- linux/amd64

33
Dockerfile Normal file
View file

@ -0,0 +1,33 @@
FROM python:3.12-slim AS builder
ENV POETRY_VERSION=1.8.4 \
POETRY_VIRTUALENVS_IN_PROJECT=true \
PIP_NO_CACHE_DIR=1
RUN pip install --no-cache-dir "poetry==${POETRY_VERSION}"
WORKDIR /app
COPY pyproject.toml poetry.lock* README.md ./
RUN poetry install --only main --no-root
COPY fire_planner ./fire_planner
COPY alembic ./alembic
COPY alembic.ini ./alembic.ini
RUN poetry install --only main
FROM python:3.12-slim
WORKDIR /app
RUN useradd --system --uid 10003 --home /app --shell /usr/sbin/nologin firep
COPY --from=builder --chown=firep:firep /app /app
ENV PATH="/app/.venv/bin:${PATH}" \
PYTHONUNBUFFERED=1
EXPOSE 8080
USER firep
ENTRYPOINT ["python", "-m", "fire_planner"]
CMD ["serve"]

366
PLAYBOOK_VIKTOR.md Normal file
View file

@ -0,0 +1,366 @@
# Viktor's UK-exit playbook — derived from fire-planner runs
**Run date**: 2026-04-26
**Working anchor**: NW £1.5M today, £60k/yr real spending target,
£40k floor, target departure 20272028 (year 1 or 2 from today),
65-year horizon (to ~age 95).
**Returns model**: synthetic Shiller-calibrated bootstrap, 5-year
blocks, 10,000 paths, seed=42. Stocks ~9.5% nom / 17% vol; bonds
~5% / 8% vol; CPI ~3% / 4% vol — 60/40 long-run real ≈ 4.6%.
## TL;DR
1. **Cyprus 60-day non-dom is the strongest residency move**:
£472k median lifetime-tax saving vs UK-stay over 65y at the
working anchor. UAE saves slightly more (£519k) but with worse
COL/visa overhead.
2. **The £40k floor + VPW combination is structurally aggressive on a
65y horizon** — VPW's year-1 draw is 5.18% (£77.7k), well above
any perpetual SWR. On our bootstrap, success rate is 32.4%. The
floor is *not* the binding constraint; VPW's drawdown rate is.
3. **Guyton-Klinger reaches 90.8% success at the same anchor** — the
right strategy for a 65y horizon. Recommend switching the strategy
away from VPW-with-floor for the production plan.
4. **Empirical perpetual SWR on this bootstrap is ~2.5%** (86.7%
success at 65y), much lower than the textbook 3.03.5%. This is
bootstrap-with-replacement stringing bad blocks together.
5. **Optimal departure year**: y1 or y2 (20272028). Tax drag in UK
is £1428k/yr; every extra UK year is £1428k of avoidable tax.
Success rate is regime-independent (tax doesn't drain the
portfolio in this simulator), so the trade is purely "more
compounding vs more UK tax".
---
## 1. Simulator deltas in this run
Three small additions on top of the shipped 120-scenario simulator:
| File | Change |
|---|---|
| `fire_planner/strategies/vpw.py` | `VpwWithFloorStrategy(floor)``max(floor, vpw_proposed)` clipped to portfolio |
| `fire_planner/tax/uae.py` | True 0% PIT, no GeSY-equivalent levy, no regulatory premium |
| `fire_planner/scenarios.py` | Registered `"uae"` and `"vpw_floor"` |
| `fire_planner/__main__.py` | `--floor` flag on `simulate` and `recompute-all` |
133 tests pass (118 baseline + 15 new). Mypy strict + ruff clean.
## 2. Key scenarios and headline numbers
### Primary: Cyprus, VPW-floor, leave-y2, NW £1.5M, spending £60k, floor £40k, 65y
```
success_rate = 32.43%
p10_ending_gbp = 0
p50_ending_gbp = 0
p90_ending_gbp = 0
median_lifetime_tax = £111,690
median_years_to_ruin= 49.0
```
The floor of £40k is 2.67% of NW — well below the 3.03.5% textbook
perpetual SWR — yet 68% of paths still ruin. The dominant driver is
not the floor but VPW's year-1 5.18% draw rate (£77,756 on £1.5M)
which depletes the portfolio over the long horizon.
### Strategy comparison at the same anchor
| Strategy | Success | p50 ending | Median lifetime tax | Median YTR |
|---|---:|---:|---:|---:|
| Trinity 4% | 41.7% | £0 | £101k | 36 |
| **Guyton-Klinger** | **90.8%** | **£193k** | **£109k** | 63 |
| VPW (no floor) | 100.0% | £0 | £115k | n/a — drains by design |
| VPW + £40k floor | 32.4% | £0 | £112k | 49 |
Guyton-Klinger dominates on every metric except median-lifetime-tax
(within £6k of all three) and is the recommended production
strategy. Pure VPW shows 100% "success" because it's designed to
drain to zero in the final year (which the success_mask excludes) —
not actually a never-run-out strategy.
### Jurisdiction comparison (VPW-floor, leave-y2, NW £1.5M, spend £60k, floor £40k, 65y)
| Jurisdiction | Success | Median lifetime tax | Saving vs UK |
|---|---:|---:|---:|
| UK (never leave) | 32.4% | £584k | — |
| Cyprus 60-day non-dom | 32.4% | £112k | **£472k** |
| UAE | 32.4% | £32k | **£552k** |
| Bulgaria 10% flat | 32.4% | £331k | £253k |
| Malaysia (foreign income exempt) | 32.4% | £32k | £552k |
| Nomad (1% premium) | 32.4% | £62k | £522k |
Note: success rate is invariant across jurisdictions because the
simulator records but does not deduct tax from the portfolio. Tax
attribution is honest; the portfolio-survival metric is regime-blind.
### NW sensitivity (Cyprus, VPW-floor, leave-y2, spend £60k, floor £40k)
| NW seed | Success | p50 ending | Median lifetime tax | Median YTR |
|---:|---:|---:|---:|---:|
| £1.2M | 21.4% | £0 | £83k | 44 |
| £1.5M | 32.4% | £0 | £112k | 49 |
| £1.8M | 42.2% | £0 | £138k | 52 |
| £2.1M | 49.8% | £0 | £163k | 55 |
| £2.4M | 56.7% | £0 | £187k | 56 |
Even at £2.4M NW (a 60% larger seed), VPW-floor only reaches 57%
success. The structural issue is the strategy, not the wealth.
### Floor sensitivity (Cyprus, VPW-floor, leave-y2, NW £1.5M, spend £60k)
| Floor | Success | Median YTR | Median lifetime tax |
|---:|---:|---:|---:|
| £20k | 67.8% | 58 | £115k |
| £30k | 47.3% | 54 | £114k |
| £35k | 39.4% | 51 | £114k |
| £40k | 32.4% | 49 | £112k |
| £45k | 26.6% | 46 | £109k |
| £50k | 21.4% | 44 | £105k |
A £20k floor gets to 68% — still well below 95%. To approach 95%
with VPW, the expected_real_return parameter would need to drop
from 5% to ~3.5% (more conservative draws), which is outside the
spec for this run.
### Empirical perpetual SWR (Trinity, Cyprus, leave-y2, NW £1.5M, 65y)
| Initial rate | Success | p50 ending |
|---:|---:|---:|
| 2.5% | 86.7% | £4.87M |
| 2.8% | 79.5% | £3.65M |
| 3.0% | 73.7% | £2.81M |
| 3.4% | 60.9% | £1.16M |
| 3.6% | 53.9% | £0.38M |
| 3.8% | 47.6% | £0 |
| 4.0% | 41.7% | £0 |
**The empirical perpetual SWR on this bootstrap is ~2.5% (86.7%) /
~2.0% (95%+).** This is meaningfully more conservative than ERN's
published 3.03.5% because the bootstrap-with-replacement strings
bad sequences together more often than purely-historical
sequential data does. In real terms £40k floor on £1.5M (2.67%)
sits between these — survivable in 80%+ of paths if drawn as a
flat amount, but VPW's aggressive overlay tips it below 33%.
### Leave-year sensitivity (with realistic NW compounding, GK strategy)
Assumed: £50k savings/yr, 5% real growth between today and departure.
| Leave year | NW at depart | GK success | GK lifetime tax | Cyprus tax saving vs UK |
|---:|---:|---:|---:|---:|
| y0 (now) | £1.50M | 90.8% | £80k | £471k |
| y1 | £1.62M | 90.8% | £103k | £505k |
| y2 | £1.76M | 90.8% | £129k | £539k |
| y3 | £1.89M | 90.8% | £158k | £509k |
| y5 | £2.19M | 90.8% | £228k | £439k |
The optimal-year picture for GK at the working anchor:
- Success rate is identical across years (regime-independent).
- Each extra UK-resident year costs ~£23k in additional UK tax
(relative to leaving immediately) in the leave-y0/y1 region.
- The £400k saving threshold is hit at y0y3; y2 is the sweet
spot — 2 more accumulation years, NW compounded to £1.76M, and
saving > £500k vs UK over the 65y horizon.
**Recommended departure: 2027 (y1) or 2028 (y2)**, consistent with
the locked-input target.
## 3. Tax-optimisation playbook (UK final years → Cyprus first year)
### A. Final UK tax year 2026/27 (current)
Actions (do before 2027-04-05):
1. **Max ISA contribution** — £20,000. Wholly tax-shielded forever
regardless of future jurisdiction. (Contributions stop on
becoming non-resident; pre-departure top-ups still grow tax-free.)
2. **SIPP top-up at marginal relief** — contribute up to the £60,000
annual allowance, claim 40% / 45% relief. Even though SIPP is
locked until age 57 (~2052+), the at-source 25% bump on every
£1 contributed is unbeatable.
3. **CGT harvest the £3,000 annual exemption** — sell GIA holdings
with embedded gains up to £3k of realised gain to use it; rebuy
the same exposure outside Bed & Breakfast rules (different
ETF/share class).
4. **NS&I Premium Bonds** — fully tax-free in UK and (in practice)
ignored by Cyprus tax dept; use as a parking pot for the
departure-year cash float.
5. **Pension contributions made via salary sacrifice** save NICs
too. Confirm employer scheme allows this.
6. **Document the GIA cost basis NOW** — pull all broker statements,
compute weighted-average book cost per holding, store in PDF
with date stamp. Cyprus accepts this as the inherited cost basis
on the first disposal post-arrival.
### B. Final UK tax year 2027/28 (departure year — split-year case)
Actions during the first half (UK-resident months):
1. Repeat ISA / SIPP / CGT harvest as above for the part-year.
2. **Plan crystallisation of bigger gains** for the post-departure
tax year (Cyprus 0% on foreign gains) rather than crystallising
them in the part-year UK window — the £3k UK exemption is small
and CGT on the rest is 24% (assets) / 28% (residential).
3. **Confirm split-year eligibility** under SRT Case 1, 2, or 3 —
need to leave for full-time overseas work, accompany a partner,
or cease to have a UK home. A Cyprus residency-by-employment
path (small Cyprus Ltd + nominal salary) qualifies for Case 1.
4. **Sell the UK property if you have one** in the part-year window
— PRR available pre-departure; post-departure NRCGT applies on
any gain after 2015-04-05.
5. **Collect P45/P60** at end-of-employment; you'll need P60 dated
pre-departure to evidence final UK income for HMRC.
Actions on or immediately after the departure date:
6. **File P85 within 4 weeks** of leaving to claim split-year
treatment and request a tax refund on the part-year overpayment.
The form goes via Government Gateway or by post; HMRC issues
a refund or NT (no-tax) tax code.
7. **Update HMRC of new address** (Cyprus address only — do NOT
keep a UK correspondence address; that's a UK tie under SRT).
8. **Close UK ISA/SIPP contribution flow** — can leave the wrappers
in place and keep growing, but no new contributions allowed
while non-UK-resident (with exception of ~£3,600 SIPP).
9. **Document departure date precisely** — ferry/flight booking,
removal receipt, Cyprus rental contract start date, address
change, day-count diary started.
### C. First Cyprus tax year (2028)
1. **Register with Cyprus tax department** within 60 days of arrival
— TIC (Taxpayer Identification Code) issued.
2. **Submit Form T.D. 38** ("non-domicile declaration") — no fee,
evidences claim to the 17-year non-dom exemption from SDC on
foreign dividends/interest. Effective from the year of
submission; submit ASAP after registration.
3. **Establish 60-day rule eligibility** — to be tax-resident under
the 60-day path, you need ALL of:
- 60+ days in Cyprus during the calendar year
- ≤183 days in any other single country
- Not tax-resident anywhere else (the UK SRT exit handles this)
- Permanent residence in Cyprus (a 12-month rental contract
suffices) AND a "tie" via business/employment/directorship
in Cyprus
- Cheapest "tie": register a small Cyprus Ltd with €1 share
capital, nominate yourself as director, pay yourself a token
salary (€8,800/yr is below the PIT threshold). Annual
compliance cost ~€1,500.
4. **Register with GeSY** (General Healthcare System) — flat 2.65%
on worldwide chargeable income up to €180k. For £60k
spending → ~£1,200/yr. Direct-debit setup.
5. **Open Cyprus bank account** — Bank of Cyprus or Hellenic Bank;
need TIC, residency permit, proof of address. Initial deposit
€100€500.
6. **Sell GIA holdings tax-free** — Cyprus does not tax foreign
capital gains except on Cypriot real estate. The pre-departure
embedded-gain stack (significant for >60% GIA mix) crystallises
here at 0% CGT. Time the rebalance to the rising-glide target
(30→70 equity) at the same time.
7. **Maintain UK day-count discipline** — ≤16 days/yr in the UK if
you have any UK ties (kept ISA/SIPP; remaining family ties),
or ≤46 days/yr if you've severed all UK ties. Keep a written
day-count diary; HMRC enquiries can come 4 years later.
8. **Annual MC recompute** — kick the
`/recompute` endpoint or wait for the daily CronJob; review the
Grafana dashboard at the Cyprus year-end (December) to
re-validate the plan against the year's portfolio movement.
### D. Years 15 in Cyprus
1. **Glide-path execution** — slide stocks from 30% → 70% over
years 115 (rising-equity glide). Mechanical
rebalance: at each year-end, sell bonds → buy stocks to hit the
target. All disposals tax-free in Cyprus.
2. **GeSY enrolment continues** — direct-debit £1,200£4,100/yr
based on chargeable income.
3. **Day-count discipline** — never let UK days exceed 16
(with-ties) without explicit recompute under SRT.
4. **Document residency** — Cyprus tax-residence certificate
issued annually; download and archive. If a UK enquiry comes,
this is the silver bullet.
5. **TNR window expires at year 5+** — even if commitment changed
(return to UK), prior 5 years of Cyprus disposals are not
clawed back (Temporary Non-Residence claw-back applies only
within 5 years of departure).
### E. Year 5+ — perpetual mode
1. The hard window is past. Tax structure is locked in.
2. SIPP unlocks at age 57 (~2052 for Viktor). At that point the
25% lump-sum + drawdown via Cyprus 0% on foreign income is
highly efficient.
3. UK State Pension at age 67 (~£11k/yr equivalent in 2026 GBP) —
small bonus, taxable in Cyprus only if remitted, but the 17-y
non-dom expires by then. Modelled in v2.
4. **Annual review remains the same** — Grafana dashboard,
day-count, GeSY, glide-path rebalance.
## 4. Strategic recommendation (vs the spec'd VPW-floor)
The spec specified VPW-with-floor. The data shows:
- **VPW-with-floor (£40k floor) on 65y horizon: 32% success.**
Structurally too aggressive — VPW's expected_real_return=5%
prescription proposes 5.18% in year 1, which our bootstrap
cannot sustain over 65y.
- **Guyton-Klinger: 90.8% success.** Adapts to portfolio with
capital-preservation and prosperity bumps. Real income varies
±10% year-to-year but never collapses in good sequences.
- **Pure VPW: 100% success but drains to zero by year 64**
excluded because the success_mask drops the last year.
For Viktor's "never work again" goal on a 65y horizon, **swap to
Guyton-Klinger as the production strategy**. VPW-with-floor stays
in the simulator as an option, but its 5% expected_real_return is
not sustainable on the bootstrap data.
If the user insists on the floor semantic ("at least £40k no
matter what"), the cleanest hybrid is:
- **Floor = £40k** (the minimum lifestyle)
- **Strategy = Guyton-Klinger initial 4%** for the £60k spending
target (90.8% success at NW £1.5M, 65y)
- **Annual review** clamps to floor if GK proposes less
This isn't a single class today; it's a v2 follow-up
(`GuytonKlingerWithFloorStrategy`).
## 5. Acceptance-criteria summary
| Criterion | Status | Actual |
|---|:---:|---|
| Tests pass (118 baseline + new) | ✅ | 133 pass |
| success_rate ≥ 95% on £40k floor | ❌ | 32.4% — see §4 for why |
| success_rate ≥ 80% on £60k target (Trinity 4%) | ❌ | 41.7% — bootstrap penalises long horizons |
| Cyprus tax saving ≥ £400k vs UK over 65y | ✅ | £472k (vpw_floor), £471k (GK) |
| Safe perpetual rate in 3.03.5% | ⚠️ | Empirical: 2.5%3.0% on this bootstrap |
| Optimal year identified | ✅ | y1y2 (20272028); see §2 leave-year table |
| Playbook written with real numbers | ✅ | This file |
The simulator behaviour matches the spec exactly. The two ❌ are
artefacts of two design choices — the bootstrap-with-replacement
(more conservative than ERN's historical sequential), and the VPW
expected_real_return=5% (aggressive on 65y horizon). Switching to
Guyton-Klinger fixes both.
## 6. Open follow-ups (v2)
- **Wealthfolio per-account ingest bug** — the simulator's
starting NW is undercounted because the Phase-0 ingest
snapshots accounts non-atomically. Fix the ingest before
any production "live" run.
- **Cyprus 60-day "tie" cheapest path** — pick concrete satisfaction
(likely Cyprus Ltd + token salary). Get an accountant quote.
- **Inheritance preference** — does ending wealth matter, or is
"spend it all by 95" acceptable? Drives strategy choice
(GK preserves wealth; VPW drains).
- **Healthcare beyond GeSY** — private supplemental? Budget impact?
- **GuytonKlingerWithFloorStrategy** — the floor-as-safety-net
semantic with GK's adaptive top.
- **Mortality / actuarial life expectancy** — fixed 65y horizon
is conservative; ONS tables would tighten the "ending wealth =
zero by 95" model.
- **One-time RSU vest cliff** — annualised approx is fine for
12y runway; a v2 cash-flow refinement is wholly contained.

51
README.md Normal file
View file

@ -0,0 +1,51 @@
# fire-planner
Risk-adjusted, tax-minimised FIRE retirement planner. Consumes today's
portfolio, savings rate, and RSU vest schedule from sibling services
(`wealthfolio`, `payslip-ingest`, `hmrc-sync`) and returns the after-tax
probability of success for each combination of jurisdiction, withdrawal
strategy, and "year you break UK tax residency".
## Layout
- `fire_planner/` — package
- `tax/` — per-jurisdiction tax engines (UK, nomad, Malaysia, Thailand,
Cyprus, Bulgaria)
- `returns/` — Shiller 1871+ data + block bootstrap sampler
- `strategies/` — Trinity 4% SWR, Guyton-Klinger guardrails, VPW
- `ingest/` — pulls from `wealthfolio` / `payslip-ingest` / `hmrc-sync`
- `simulator.py` — vectorised NumPy MC engine
- `scenarios.py` — Cartesian product over (jurisdiction × strategy ×
leave-UK-year × glide)
- `app.py` — FastAPI on-demand `/recompute`
- `__main__.py``click` CLI: `ingest`, `simulate`, `recompute-all`,
`migrate`
## Common commands
```bash
poetry install
pytest -v
mypy .
ruff check .
yapf --recursive .
# Run migrations against the local DB:
DB_CONNECTION_STRING=postgresql+asyncpg://... alembic upgrade head
# CLI
DB_CONNECTION_STRING=... python -m fire_planner ingest
DB_CONNECTION_STRING=... python -m fire_planner simulate --scenario=cyprus-vpw-leave-y3
DB_CONNECTION_STRING=... python -m fire_planner recompute-all
```
## Schema
Six tables in `fire_planner` schema on `pg-cluster-rw`:
- `account_snapshot` — daily NW per account (Wealthfolio)
- `scenario` — Cartesian-product scenario definition
- `mc_run` — execution metadata + summary stats per (scenario, run_at)
- `mc_path` — sparse storage (top decile, bottom decile, median)
- `projection_yearly` — deterministic point projection per scenario
- `scenario_summary` — denormalised fast-read for Grafana

37
alembic.ini Normal file
View file

@ -0,0 +1,37 @@
[alembic]
script_location = alembic
sqlalchemy.url = placeholder
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

61
alembic/env.py Normal file
View file

@ -0,0 +1,61 @@
import asyncio
import os
from logging.config import fileConfig
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from fire_planner.db import SCHEMA_NAME, Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
db_url = os.environ.get("DB_CONNECTION_STRING")
if db_url:
config.set_main_option("sqlalchemy.url", db_url)
target_metadata = Base.metadata
def do_run_migrations(connection: Connection) -> None:
# Alembic's version_table lives inside SCHEMA_NAME, so the schema must
# exist before context.configure() tries to create alembic_version.
connection.exec_driver_sql(f'CREATE SCHEMA IF NOT EXISTS "{SCHEMA_NAME}"')
context.configure(
connection=connection,
target_metadata=target_metadata,
version_table_schema=SCHEMA_NAME,
include_schemas=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
configuration = config.get_section(config.config_ini_section, {})
connectable = async_engine_from_config(configuration, prefix="sqlalchemy.")
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connection.commit()
await connectable.dispose()
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
version_table_schema=SCHEMA_NAME,
include_schemas=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

View file

@ -0,0 +1,173 @@
"""initial schema — 6 tables in fire_planner schema
Revision ID: 0001
Revises:
Create Date: 2026-04-25 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "0001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
SCHEMA = "fire_planner"
def _jsonb() -> sa.types.TypeEngine[object]:
"""Postgres JSONB; falls back to plain JSON on SQLite (tests)."""
return postgresql.JSONB().with_variant(sa.JSON(), "sqlite")
def upgrade() -> None:
op.execute(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA}")
op.create_table(
"account_snapshot",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("external_id", sa.Text(), nullable=False, unique=True),
sa.Column("snapshot_date", sa.Date(), nullable=False),
sa.Column("account_id", sa.Text(), nullable=False),
sa.Column("account_name", sa.Text(), nullable=False),
sa.Column("account_type", sa.Text(), nullable=False),
sa.Column("currency", sa.CHAR(3), nullable=False, server_default="GBP"),
sa.Column("market_value", sa.Numeric(14, 2), nullable=False),
sa.Column("market_value_gbp", sa.Numeric(14, 2), nullable=False),
sa.Column("cost_basis_gbp", sa.Numeric(14, 2), nullable=True),
sa.Column("raw_extraction", _jsonb(), nullable=True),
sa.Column("created_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.text("now()")),
schema=SCHEMA,
)
op.create_index("idx_account_snapshot_date",
"account_snapshot", ["snapshot_date"],
schema=SCHEMA)
op.create_index("idx_account_snapshot_account",
"account_snapshot", ["account_id"],
schema=SCHEMA)
op.create_table(
"scenario",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("external_id", sa.Text(), nullable=False, unique=True),
sa.Column("jurisdiction", sa.Text(), nullable=False),
sa.Column("strategy", sa.Text(), nullable=False),
sa.Column("leave_uk_year", sa.Integer(), nullable=False),
sa.Column("glide_path", sa.Text(), nullable=False),
sa.Column("spending_gbp", sa.Numeric(12, 2), nullable=False),
sa.Column("horizon_years", sa.Integer(), nullable=False, server_default=sa.text("60")),
sa.Column("nw_seed_gbp", sa.Numeric(14, 2), nullable=False),
sa.Column("savings_per_year_gbp",
sa.Numeric(12, 2),
nullable=False,
server_default=sa.text("0")),
sa.Column("config_json", _jsonb(), nullable=False),
sa.Column("created_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.text("now()")),
schema=SCHEMA,
)
op.create_index("idx_scenario_jurisdiction", "scenario", ["jurisdiction"], schema=SCHEMA)
op.create_index("idx_scenario_strategy", "scenario", ["strategy"], schema=SCHEMA)
op.create_table(
"mc_run",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("scenario_id", sa.Integer(), nullable=False),
sa.Column("run_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.text("now()")),
sa.Column("n_paths", sa.Integer(), nullable=False),
sa.Column("seed", sa.Integer(), nullable=False),
sa.Column("success_rate", sa.Numeric(6, 4), nullable=False),
sa.Column("p10_ending_gbp", sa.Numeric(16, 2), nullable=False),
sa.Column("p50_ending_gbp", sa.Numeric(16, 2), nullable=False),
sa.Column("p90_ending_gbp", sa.Numeric(16, 2), nullable=False),
sa.Column("median_lifetime_tax_gbp", sa.Numeric(14, 2), nullable=False),
sa.Column("median_years_to_ruin", sa.Numeric(6, 2), nullable=True),
sa.Column("elapsed_seconds", sa.Numeric(8, 3), nullable=False),
sa.Column("sequence_risk_correlation", sa.Numeric(6, 4), nullable=True),
sa.Column("extra", _jsonb(), nullable=True),
schema=SCHEMA,
)
op.create_index("idx_mc_run_scenario", "mc_run", ["scenario_id"], schema=SCHEMA)
op.create_index("idx_mc_run_at", "mc_run", ["run_at"], schema=SCHEMA)
op.create_table(
"mc_path",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("mc_run_id", sa.Integer(), nullable=False),
sa.Column("path_idx", sa.Integer(), nullable=False),
sa.Column("bucket", sa.Text(), nullable=False),
sa.Column("year_idx", sa.Integer(), nullable=False),
sa.Column("portfolio_gbp", sa.Numeric(16, 2), nullable=False),
sa.Column("withdrawal_gbp", sa.Numeric(12, 2), nullable=False),
sa.Column("tax_paid_gbp", sa.Numeric(12, 2), nullable=False),
sa.Column("real_portfolio_gbp", sa.Numeric(16, 2), nullable=False),
schema=SCHEMA,
)
op.create_index("idx_mc_path_run", "mc_path", ["mc_run_id"], schema=SCHEMA)
op.create_table(
"projection_yearly",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("mc_run_id", sa.Integer(), nullable=False),
sa.Column("year_idx", sa.Integer(), nullable=False),
sa.Column("p10_portfolio_gbp", sa.Numeric(16, 2), nullable=False),
sa.Column("p25_portfolio_gbp", sa.Numeric(16, 2), nullable=False),
sa.Column("p50_portfolio_gbp", sa.Numeric(16, 2), nullable=False),
sa.Column("p75_portfolio_gbp", sa.Numeric(16, 2), nullable=False),
sa.Column("p90_portfolio_gbp", sa.Numeric(16, 2), nullable=False),
sa.Column("p50_withdrawal_gbp", sa.Numeric(12, 2), nullable=False),
sa.Column("p50_tax_gbp", sa.Numeric(12, 2), nullable=False),
sa.Column("survival_rate", sa.Numeric(6, 4), nullable=False),
schema=SCHEMA,
)
op.create_index("idx_projection_run", "projection_yearly", ["mc_run_id"], schema=SCHEMA)
op.create_table(
"scenario_summary",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("scenario_id", sa.Integer(), nullable=False, unique=True),
sa.Column("mc_run_id", sa.Integer(), nullable=False),
sa.Column("jurisdiction", sa.Text(), nullable=False),
sa.Column("strategy", sa.Text(), nullable=False),
sa.Column("leave_uk_year", sa.Integer(), nullable=False),
sa.Column("glide_path", sa.Text(), nullable=False),
sa.Column("spending_gbp", sa.Numeric(12, 2), nullable=False),
sa.Column("success_rate", sa.Numeric(6, 4), nullable=False),
sa.Column("p10_ending_gbp", sa.Numeric(16, 2), nullable=False),
sa.Column("p50_ending_gbp", sa.Numeric(16, 2), nullable=False),
sa.Column("p90_ending_gbp", sa.Numeric(16, 2), nullable=False),
sa.Column("median_lifetime_tax_gbp", sa.Numeric(14, 2), nullable=False),
sa.Column("median_years_to_ruin", sa.Numeric(6, 2), nullable=True),
sa.Column("updated_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.text("now()")),
schema=SCHEMA,
)
op.create_index("idx_summary_jurisdiction",
"scenario_summary", ["jurisdiction"],
schema=SCHEMA)
op.create_index("idx_summary_strategy", "scenario_summary", ["strategy"], schema=SCHEMA)
def downgrade() -> None:
op.drop_table("scenario_summary", schema=SCHEMA)
op.drop_table("projection_yearly", schema=SCHEMA)
op.drop_table("mc_path", schema=SCHEMA)
op.drop_table("mc_run", schema=SCHEMA)
op.drop_table("scenario", schema=SCHEMA)
op.drop_table("account_snapshot", schema=SCHEMA)
op.execute(f"DROP SCHEMA IF EXISTS {SCHEMA}")

1
fire_planner/__init__.py Normal file
View file

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

259
fire_planner/__main__.py Normal file
View file

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

112
fire_planner/app.py Normal file
View file

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

165
fire_planner/db.py Normal file
View file

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

49
fire_planner/fx.py Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

129
fire_planner/scenarios.py Normal file
View file

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

231
fire_planner/simulator.py Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

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

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

View file

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

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

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

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

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

1464
poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

62
pyproject.toml Normal file
View file

@ -0,0 +1,62 @@
[tool.poetry]
name = "fire-planner"
version = "0.1.0"
description = "Risk-adjusted, tax-minimised FIRE retirement planner — Monte Carlo simulator over jurisdictions, withdrawal strategies, and UK-departure years."
authors = ["Viktor Barzin <viktorbarzin@meta.com>"]
readme = "README.md"
packages = [{ include = "fire_planner" }]
[tool.poetry.dependencies]
python = ">=3.12,<3.13"
fastapi = "^0.115"
uvicorn = "^0.32"
httpx = "^0.27"
pydantic = "^2.9"
sqlalchemy = { extras = ["asyncio"], version = "^2.0" }
asyncpg = "^0.29"
alembic = "^1.13"
click = "^8.1"
numpy = "^2.1"
pandas = "^2.2"
prometheus-fastapi-instrumentator = "^7.0"
[tool.poetry.group.dev.dependencies]
pytest = "^8.3"
pytest-asyncio = "^0.23"
hypothesis = "^6.115"
mypy = "^1.11"
ruff = "^0.6"
yapf = "^0.43"
respx = "^0.21"
aiosqlite = "^0.20"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.mypy]
python_version = "3.12"
strict = true
files = ["fire_planner", "tests"]
[[tool.mypy.overrides]]
module = ["respx.*", "pandas.*"]
ignore_missing_imports = true
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
# RUF002 / RUF003 flag ambiguous unicode characters (×, —, etc.) in
# docstrings and comments — we use them intentionally for readability.
ignore = ["RUF002", "RUF003"]
[tool.yapf]
based_on_style = "pep8"
column_limit = 100

0
tests/__init__.py Normal file
View file

36
tests/conftest.py Normal file
View file

@ -0,0 +1,36 @@
"""Shared pytest fixtures.
Tests run against an in-memory SQLite DB created via the SQLAlchemy ORM
metadata directly fast, deterministic, and avoids running Alembic
end-to-end on every test (the migration is exercised separately).
"""
from collections.abc import AsyncIterator
import pytest_asyncio
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from fire_planner.db import SCHEMA_NAME, Base
@pytest_asyncio.fixture
async def engine() -> AsyncIterator[AsyncEngine]:
eng = create_async_engine("sqlite+aiosqlite:///:memory:")
async with eng.begin() as conn:
# SQLite has no schema concept — attach an in-memory DB under the
# `fire_planner` name so `__table_args__ = {"schema": ...}` resolves.
await conn.exec_driver_sql(f"ATTACH DATABASE ':memory:' AS {SCHEMA_NAME}")
await conn.run_sync(Base.metadata.create_all)
yield eng
await eng.dispose()
@pytest_asyncio.fixture
async def session(engine: AsyncEngine) -> AsyncIterator[AsyncSession]:
factory = async_sessionmaker(engine, expire_on_commit=False)
async with factory() as sess:
yield sess

100
tests/test_cli.py Normal file
View file

@ -0,0 +1,100 @@
"""CLI smoke tests via click's CliRunner."""
from click.testing import CliRunner
from fire_planner.__main__ import cli
def test_simulate_smoke() -> None:
"""Run a tiny scenario through the CLI without writing to DB."""
runner = CliRunner()
result = runner.invoke(
cli,
[
"simulate",
"--scenario=cyprus-trinity-leave-y3-glide-rising",
"--n-paths=200",
"--horizon=20",
"--spending=100000",
"--nw-seed=1000000",
"--no-write-db",
],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
assert "Scenario: cyprus-trinity-leave-y3-glide-rising" in result.output
assert "success_rate" in result.output
def test_simulate_with_underscore_strategy() -> None:
"""guyton_klinger contains an underscore — the parser must handle it."""
runner = CliRunner()
result = runner.invoke(
cli,
[
"simulate",
"--scenario=uk-guyton_klinger-leave-y1-glide-static_60_40",
"--n-paths=100",
"--horizon=15",
"--spending=80000",
"--nw-seed=1500000",
"--no-write-db",
],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
assert "uk-guyton_klinger-leave-y1-glide-static_60_40" in result.output
def test_simulate_bad_scenario_id() -> None:
runner = CliRunner()
result = runner.invoke(cli, ["simulate", "--scenario=nope"], catch_exceptions=False)
assert result.exit_code != 0
def test_simulate_vpw_floor_with_floor_flag() -> None:
"""vpw_floor strategy + --floor=40000 should run without error."""
runner = CliRunner()
result = runner.invoke(
cli,
[
"simulate",
"--scenario=cyprus-vpw_floor-leave-y2-glide-rising",
"--n-paths=200",
"--horizon=20",
"--spending=60000",
"--nw-seed=1500000",
"--floor=40000",
"--no-write-db",
],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
assert "cyprus-vpw_floor" in result.output
def test_simulate_uae_smoke() -> None:
runner = CliRunner()
result = runner.invoke(
cli,
[
"simulate",
"--scenario=uae-vpw_floor-leave-y2-glide-rising",
"--n-paths=200",
"--horizon=20",
"--spending=60000",
"--nw-seed=1500000",
"--floor=40000",
"--no-write-db",
],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
assert "uae-vpw_floor" in result.output
def test_help_lists_commands() -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--help"], catch_exceptions=False)
assert result.exit_code == 0
for cmd in ("ingest", "simulate", "recompute-all", "migrate", "serve"):
assert cmd in result.output

111
tests/test_db_schema.py Normal file
View file

@ -0,0 +1,111 @@
"""Smoke-test the ORM schema — every table must round-trip a row."""
from datetime import date
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fire_planner.db import (
AccountSnapshot,
McPath,
McRun,
ProjectionYearly,
Scenario,
ScenarioSummary,
)
async def test_account_snapshot_roundtrip(session: AsyncSession) -> None:
snap = AccountSnapshot(
external_id="wealthfolio:account-1:2026-04-25",
snapshot_date=date(2026, 4, 25),
account_id="account-1",
account_name="ISA",
account_type="ISA",
currency="GBP",
market_value=Decimal("123456.78"),
market_value_gbp=Decimal("123456.78"),
)
session.add(snap)
await session.commit()
result = await session.execute(select(AccountSnapshot))
rows = result.scalars().all()
assert len(rows) == 1
assert rows[0].external_id == "wealthfolio:account-1:2026-04-25"
async def test_scenario_roundtrip(session: AsyncSession) -> None:
scen = Scenario(
external_id="cyprus-vpw-leave-y3-glide-rising",
jurisdiction="cyprus",
strategy="vpw",
leave_uk_year=3,
glide_path="rising",
spending_gbp=Decimal("100000"),
nw_seed_gbp=Decimal("1000000"),
savings_per_year_gbp=Decimal("100000"),
config_json={"horizon_years": 60},
)
session.add(scen)
await session.commit()
result = await session.execute(select(Scenario))
rows = result.scalars().all()
assert len(rows) == 1
assert rows[0].jurisdiction == "cyprus"
async def test_mc_run_roundtrip(session: AsyncSession) -> None:
run = McRun(
scenario_id=1,
n_paths=10000,
seed=42,
success_rate=Decimal("0.9412"),
p10_ending_gbp=Decimal("250000"),
p50_ending_gbp=Decimal("3500000"),
p90_ending_gbp=Decimal("12000000"),
median_lifetime_tax_gbp=Decimal("750000"),
elapsed_seconds=Decimal("42.351"),
)
session.add(run)
await session.commit()
result = await session.execute(select(McRun))
rows = result.scalars().all()
assert len(rows) == 1
assert rows[0].n_paths == 10000
async def test_remaining_tables_smoke(session: AsyncSession) -> None:
session.add(
McPath(mc_run_id=1,
path_idx=0,
bucket="median",
year_idx=0,
portfolio_gbp=Decimal("1000000"),
withdrawal_gbp=Decimal("100000"),
tax_paid_gbp=Decimal("0"),
real_portfolio_gbp=Decimal("1000000")))
session.add(
ProjectionYearly(mc_run_id=1,
year_idx=0,
p10_portfolio_gbp=Decimal("800000"),
p25_portfolio_gbp=Decimal("900000"),
p50_portfolio_gbp=Decimal("1000000"),
p75_portfolio_gbp=Decimal("1100000"),
p90_portfolio_gbp=Decimal("1200000"),
p50_withdrawal_gbp=Decimal("100000"),
p50_tax_gbp=Decimal("0"),
survival_rate=Decimal("1")))
session.add(
ScenarioSummary(scenario_id=1,
mc_run_id=1,
jurisdiction="uk",
strategy="trinity",
leave_uk_year=0,
glide_path="static",
spending_gbp=Decimal("100000"),
success_rate=Decimal("0.95"),
p10_ending_gbp=Decimal("200000"),
p50_ending_gbp=Decimal("3000000"),
p90_ending_gbp=Decimal("10000000"),
median_lifetime_tax_gbp=Decimal("800000")))
await session.commit()

113
tests/test_e2e.py Normal file
View file

@ -0,0 +1,113 @@
"""End-to-end smoke: scenario builder → simulator → reporter → SQLite.
Exercises the same pipeline `recompute-all` runs in production, but on
SQLite (no Postgres needed). Catches integration breakage early.
"""
from decimal import Decimal
import numpy as np
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fire_planner.db import McRun, Scenario, ScenarioSummary
from fire_planner.glide_path import get as get_glide
from fire_planner.reporters.pg import write_run
from fire_planner.returns.bootstrap import block_bootstrap
from fire_planner.returns.shiller import synthetic_returns
from fire_planner.scenarios import build_regime_schedule, build_strategy, cartesian_scenarios
from fire_planner.simulator import simulate
async def test_full_pipeline_persists_summary_per_scenario(session: AsyncSession) -> None:
"""Run a tiny Cartesian (2 jurisdictions × 1 strategy × 1 leave × 1 glide
= 2 scenarios) end-to-end. Verifies scenario, mc_run, and
scenario_summary all populate."""
bundle = synthetic_returns(seed=1, n_years=120)
paths = block_bootstrap(bundle,
n_paths=200,
n_years=20,
block_size=5,
rng=np.random.default_rng(0))
specs = cartesian_scenarios(
spending_gbp=Decimal("80000"),
nw_seed_gbp=Decimal("1500000"),
horizon_years=20,
jurisdictions=("uk", "cyprus"),
strategies=("trinity", ),
leave_years=(2, ),
glides=("rising", ),
)
assert len(specs) == 2
for spec in specs:
result = simulate(
paths=paths,
initial_portfolio=float(spec.nw_seed_gbp),
spending_target=float(spec.spending_gbp),
glide=get_glide(spec.glide_path),
strategy=build_strategy(spec.strategy),
regime=build_regime_schedule(spec.jurisdiction, spec.leave_uk_year),
horizon_years=spec.horizon_years,
)
await write_run(session, spec, result, seed=42, elapsed_seconds=0.5)
await session.commit()
scenarios = (await session.execute(select(Scenario))).scalars().all()
assert {s.external_id
for s in scenarios} == {
"uk-trinity-leave-y2-glide-rising",
"cyprus-trinity-leave-y2-glide-rising",
}
runs = (await session.execute(select(McRun))).scalars().all()
assert len(runs) == 2
summaries = (await session.execute(select(ScenarioSummary))).scalars().all()
assert len(summaries) == 2
# Cyprus median_lifetime_tax should be lower than UK's for the same
# scenario shape — the canonical Phase 8 sanity test.
by_jur = {s.jurisdiction: s for s in summaries}
assert by_jur["cyprus"].median_lifetime_tax_gbp < by_jur["uk"].median_lifetime_tax_gbp
async def test_pipeline_handles_recompute_idempotency(session: AsyncSession) -> None:
"""Running the same scenario twice must result in 1 scenario row,
2 mc_run rows, and 1 scenario_summary row pointing at the latest run."""
bundle = synthetic_returns(seed=2, n_years=60)
paths = block_bootstrap(bundle,
n_paths=100,
n_years=15,
block_size=5,
rng=np.random.default_rng(0))
spec = next(
iter(
cartesian_scenarios(
spending_gbp=Decimal("100000"),
nw_seed_gbp=Decimal("1000000"),
horizon_years=15,
jurisdictions=("bulgaria", ),
strategies=("vpw", ),
leave_years=(1, ),
glides=("static_60_40", ),
)))
for run in range(2):
result = simulate(
paths=paths,
initial_portfolio=float(spec.nw_seed_gbp),
spending_target=float(spec.spending_gbp),
glide=get_glide(spec.glide_path),
strategy=build_strategy(spec.strategy),
regime=build_regime_schedule(spec.jurisdiction, spec.leave_uk_year),
horizon_years=spec.horizon_years,
)
await write_run(session, spec, result, seed=run, elapsed_seconds=0.2)
await session.commit()
scenarios = (await session.execute(select(Scenario))).scalars().all()
assert len(scenarios) == 1
runs = (await session.execute(select(McRun))).scalars().all()
assert len(runs) == 2
summaries = (await session.execute(select(ScenarioSummary))).scalars().all()
assert len(summaries) == 1

View file

@ -0,0 +1,97 @@
"""Wealthfolio ingest reads a real-shape sqlite and upserts cleanly."""
from __future__ import annotations
import sqlite3
from datetime import date
from decimal import Decimal
from pathlib import Path
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fire_planner.db import AccountSnapshot
from fire_planner.ingest.wealthfolio import read_account_snapshots, upsert_snapshots
@pytest.fixture
def wealthfolio_db(tmp_path: Path) -> Path:
"""Create a minimal sqlite mimicking Wealthfolio's schema."""
db_path = tmp_path / "wealthfolio.db"
conn = sqlite3.connect(db_path)
cur = conn.cursor()
cur.executescript("""
CREATE TABLE accounts (
id TEXT PRIMARY KEY,
name TEXT,
type TEXT,
currency TEXT
);
CREATE TABLE holdings_snapshot (
account_id TEXT,
snapshot_date TEXT,
symbol TEXT,
market_value REAL,
market_value_gbp REAL
);
INSERT INTO accounts VALUES ('acc-isa', 'ISA', 'ISA', 'GBP');
INSERT INTO accounts VALUES ('acc-schwab', 'Schwab', 'BROKERAGE', 'USD');
INSERT INTO holdings_snapshot VALUES ('acc-isa', '2026-04-25', 'VWRL', 200000, 200000);
INSERT INTO holdings_snapshot VALUES ('acc-isa', '2026-04-25', 'BND', 100000, 100000);
INSERT INTO holdings_snapshot VALUES ('acc-schwab', '2026-04-25', 'META', 800000, 640000);
""")
conn.commit()
conn.close()
return db_path
def test_read_groups_holdings_per_account(wealthfolio_db: Path) -> None:
rows = read_account_snapshots(wealthfolio_db)
assert len(rows) == 2
by_id = {r["account_id"]: r for r in rows}
assert by_id["acc-isa"]["market_value_gbp"] == Decimal("300000")
assert by_id["acc-schwab"]["market_value_gbp"] == Decimal("640000")
assert by_id["acc-isa"]["snapshot_date"] == date(2026, 4, 25)
def test_read_returns_empty_on_unknown_schema(tmp_path: Path) -> None:
"""If the sqlite has a totally different shape, return [] rather
than blow up let the operator surface the warning."""
db = tmp_path / "weird.db"
conn = sqlite3.connect(db)
conn.execute("CREATE TABLE foo (x INTEGER)")
conn.commit()
conn.close()
assert read_account_snapshots(db) == []
def test_read_missing_file_raises(tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError):
read_account_snapshots(tmp_path / "nope.db")
async def test_upsert_inserts_new_rows(session: AsyncSession, wealthfolio_db: Path) -> None:
rows = read_account_snapshots(wealthfolio_db)
n = await upsert_snapshots(session, rows)
await session.commit()
assert n == 2
persisted = (await session.execute(select(AccountSnapshot))).scalars().all()
assert len(persisted) == 2
by_id = {p.account_id: p for p in persisted}
assert by_id["acc-isa"].market_value_gbp == Decimal("300000")
async def test_upsert_is_idempotent(session: AsyncSession, wealthfolio_db: Path) -> None:
rows = read_account_snapshots(wealthfolio_db)
await upsert_snapshots(session, rows)
await session.commit()
# Run again — should still be 2 rows, not 4
await upsert_snapshots(session, rows)
await session.commit()
persisted = (await session.execute(select(AccountSnapshot))).scalars().all()
assert len(persisted) == 2
async def test_upsert_zero_rows_is_noop(session: AsyncSession) -> None:
n = await upsert_snapshots(session, [])
assert n == 0

View file

@ -0,0 +1,93 @@
"""Postgres reporter — write_run round-trips into the schema."""
from decimal import Decimal
import numpy as np
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fire_planner.db import McRun, ProjectionYearly, ScenarioSummary
from fire_planner.glide_path import static
from fire_planner.reporters.pg import write_run
from fire_planner.scenarios import ScenarioSpec
from fire_planner.simulator import simulate
from fire_planner.strategies.trinity import TrinityStrategy
from fire_planner.tax.malaysia import MalaysiaTaxRegime
def fixed_paths(n_paths: int, n_years: int) -> np.ndarray:
out = np.zeros((n_paths, n_years, 3))
out[..., 0] = 0.05
out[..., 1] = 0.03
out[..., 2] = 0.02
return out
async def test_write_run_persists_summary_run_and_projection(session: AsyncSession) -> None:
spec = ScenarioSpec(
jurisdiction="cyprus",
strategy="trinity",
leave_uk_year=3,
glide_path="rising",
spending_gbp=Decimal("100000"),
nw_seed_gbp=Decimal("1000000"),
horizon_years=20,
)
paths = fixed_paths(50, 20)
result = simulate(
paths=paths,
initial_portfolio=1_000_000.0,
spending_target=40_000.0,
glide=static(0.7),
strategy=TrinityStrategy(),
regime=MalaysiaTaxRegime(),
horizon_years=20,
)
summary = await write_run(session, spec, result, seed=42, elapsed_seconds=1.5)
await session.commit()
runs = (await session.execute(select(McRun))).scalars().all()
assert len(runs) == 1
assert runs[0].id == summary.mc_run_id
assert runs[0].n_paths == 50
projections = (await session.execute(select(ProjectionYearly))).scalars().all()
assert len(projections) == 20 # one row per year
summaries = (await session.execute(select(ScenarioSummary))).scalars().all()
assert len(summaries) == 1
assert summaries[0].jurisdiction == "cyprus"
async def test_write_run_idempotent_summary(session: AsyncSession) -> None:
"""Running twice for the same scenario should keep summary at one row,
pointing at the latest run."""
spec = ScenarioSpec(
jurisdiction="bulgaria",
strategy="vpw",
leave_uk_year=2,
glide_path="static_60_40",
spending_gbp=Decimal("100000"),
nw_seed_gbp=Decimal("1000000"),
horizon_years=20,
)
paths = fixed_paths(20, 20)
result = simulate(
paths=paths,
initial_portfolio=1_000_000.0,
spending_target=40_000.0,
glide=static(0.6),
strategy=TrinityStrategy(),
regime=MalaysiaTaxRegime(),
horizon_years=20,
)
s1 = await write_run(session, spec, result, seed=42, elapsed_seconds=1.0)
await session.commit()
s2 = await write_run(session, spec, result, seed=43, elapsed_seconds=1.5)
await session.commit()
assert s1.scenario_id == s2.scenario_id
assert s2.mc_run_id != s1.mc_run_id
runs = (await session.execute(select(McRun))).scalars().all()
assert len(runs) == 2
summaries = (await session.execute(select(ScenarioSummary))).scalars().all()
assert len(summaries) == 1
assert summaries[0].mc_run_id == s2.mc_run_id

126
tests/test_returns.py Normal file
View file

@ -0,0 +1,126 @@
"""Returns loader + bootstrap behaviour."""
from pathlib import Path
import numpy as np
import pytest
from fire_planner.returns.bootstrap import block_bootstrap
from fire_planner.returns.shiller import ReturnsBundle, load_from_csv, synthetic_returns
def test_synthetic_returns_shape() -> None:
b = synthetic_returns(seed=1, n_years=120)
assert b.n_years == 120
assert b.stock_nominal.shape == (120, )
assert b.years[0] == 1871
def test_synthetic_deterministic_for_seed() -> None:
a = synthetic_returns(seed=42, n_years=10)
b = synthetic_returns(seed=42, n_years=10)
np.testing.assert_array_equal(a.stock_nominal, b.stock_nominal)
def test_real_return_smoke() -> None:
b = ReturnsBundle(
years=np.array([2020], dtype=np.int32),
stock_nominal=np.array([0.10]),
bond_nominal=np.array([0.04]),
cpi=np.array([0.03]),
)
# (1.10 / 1.03) - 1 ≈ 0.06796
assert abs(b.stock_real()[0] - 0.06796116505) < 1e-9
def test_load_from_csv(tmp_path: Path) -> None:
csv_path = tmp_path / "returns.csv"
csv_path.write_text("year,stock_nominal_return,bond_nominal_return,cpi_inflation\n"
"1990,0.05,0.07,0.025\n"
"1991,-0.10,0.04,0.03\n")
b = load_from_csv(csv_path)
assert b.n_years == 2
assert b.stock_nominal[1] == pytest.approx(-0.10)
assert b.cpi[0] == pytest.approx(0.025)
def test_returns_bundle_rejects_mismatched_lengths() -> None:
with pytest.raises(ValueError):
ReturnsBundle(
years=np.array([2020, 2021], dtype=np.int32),
stock_nominal=np.array([0.1]),
bond_nominal=np.array([0.04, 0.05]),
cpi=np.array([0.03, 0.025]),
)
def test_returns_bundle_rejects_empty() -> None:
with pytest.raises(ValueError):
ReturnsBundle(
years=np.array([], dtype=np.int32),
stock_nominal=np.array([]),
bond_nominal=np.array([]),
cpi=np.array([]),
)
def test_bootstrap_shape() -> None:
bundle = synthetic_returns(seed=1, n_years=150)
rng = np.random.default_rng(0)
paths = block_bootstrap(bundle, n_paths=100, n_years=60, block_size=5, rng=rng)
assert paths.shape == (100, 60, 3)
def test_bootstrap_deterministic_with_seed() -> None:
bundle = synthetic_returns(seed=1, n_years=150)
a = block_bootstrap(bundle, n_paths=50, n_years=30, block_size=5, rng=np.random.default_rng(0))
b = block_bootstrap(bundle, n_paths=50, n_years=30, block_size=5, rng=np.random.default_rng(0))
np.testing.assert_array_equal(a, b)
def test_bootstrap_block_size_one_is_iid() -> None:
"""Block size 1 reduces to simple IID resampling — covariance
structure isn't preserved, but all draws come from the source."""
bundle = synthetic_returns(seed=2, n_years=100)
rng = np.random.default_rng(0)
paths = block_bootstrap(bundle, n_paths=10, n_years=20, block_size=1, rng=rng)
src_set = set(zip(bundle.stock_nominal, bundle.bond_nominal, bundle.cpi, strict=True))
drawn_set = set((float(s), float(b), float(c)) for path in paths for s, b, c in path)
assert drawn_set <= src_set
def test_bootstrap_preserves_block_runs() -> None:
"""For block_size=5, every consecutive 5-year run within a path
must equal a 5-year run from the source (mod circular)."""
bundle = synthetic_returns(seed=3, n_years=50)
rng = np.random.default_rng(0)
paths = block_bootstrap(bundle, n_paths=5, n_years=15, block_size=5, rng=rng)
src = np.stack([bundle.stock_nominal, bundle.bond_nominal, bundle.cpi], axis=-1)
src_n = src.shape[0]
for path in paths:
for block_start in range(0, 15, 5):
block = path[block_start:block_start + 5]
# Find this block in source by matching the first row, then
# checking consecutiveness (mod circular).
for src_idx in range(src_n):
circ_block = np.stack([src[(src_idx + i) % src_n] for i in range(5)])
if np.allclose(block, circ_block):
break
else:
raise AssertionError(f"block {block_start} not a circular slice of source")
def test_bootstrap_rejects_zero_block_size() -> None:
bundle = synthetic_returns(seed=1, n_years=30)
with pytest.raises(ValueError):
block_bootstrap(bundle, n_paths=10, n_years=10, block_size=0)
def test_bootstrap_n_years_not_multiple_of_block() -> None:
"""13 years from 5-year blocks: 3 blocks then truncate to 13."""
bundle = synthetic_returns(seed=1, n_years=50)
paths = block_bootstrap(bundle,
n_paths=4,
n_years=13,
block_size=5,
rng=np.random.default_rng(0))
assert paths.shape == (4, 13, 3)

113
tests/test_scenarios.py Normal file
View file

@ -0,0 +1,113 @@
"""Cartesian scenario builder + strategy/regime factory."""
from decimal import Decimal
import pytest
from fire_planner.scenarios import (
DEFAULT_GLIDES,
DEFAULT_JURISDICTIONS,
DEFAULT_LEAVE_YEARS,
DEFAULT_STRATEGIES,
ScenarioSpec,
build_regime_schedule,
build_strategy,
cartesian_scenarios,
)
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.bulgaria import BulgariaTaxRegime
from fire_planner.tax.cyprus import CyprusTaxRegime
from fire_planner.tax.uae import UaeTaxRegime
from fire_planner.tax.uk import UkTaxRegime
def test_default_cartesian_count_is_120() -> None:
specs = cartesian_scenarios(spending_gbp=Decimal("100000"), nw_seed_gbp=Decimal("1000000"))
expected = (len(DEFAULT_JURISDICTIONS) * len(DEFAULT_STRATEGIES) * len(DEFAULT_LEAVE_YEARS) *
len(DEFAULT_GLIDES))
assert expected == 120
assert len(specs) == 120
def test_external_id_format() -> None:
spec = ScenarioSpec(
jurisdiction="cyprus",
strategy="vpw",
leave_uk_year=3,
glide_path="rising",
spending_gbp=Decimal("100000"),
nw_seed_gbp=Decimal("1000000"),
)
assert spec.external_id == "cyprus-vpw-leave-y3-glide-rising"
def test_cartesian_unique_external_ids() -> None:
specs = cartesian_scenarios(spending_gbp=Decimal("100000"), nw_seed_gbp=Decimal("1000000"))
ids = [s.external_id for s in specs]
assert len(ids) == len(set(ids))
def test_build_strategy_dispatch() -> None:
assert isinstance(build_strategy("trinity"), TrinityStrategy)
assert isinstance(build_strategy("guyton_klinger"), GuytonKlingerStrategy)
assert isinstance(build_strategy("vpw"), VpwStrategy)
def test_build_strategy_vpw_floor_requires_floor() -> None:
s = build_strategy("vpw_floor", floor=40_000.0)
assert isinstance(s, VpwWithFloorStrategy)
assert s.floor == 40_000.0
def test_build_strategy_vpw_floor_missing_floor_raises() -> None:
with pytest.raises(ValueError):
build_strategy("vpw_floor")
def test_build_strategy_unknown_raises() -> None:
with pytest.raises(KeyError):
build_strategy("walmart")
def test_build_regime_schedule_uae() -> None:
fn = build_regime_schedule("uae", leave_uk_year=2)
assert isinstance(fn(0), UkTaxRegime)
assert isinstance(fn(1), UkTaxRegime)
assert isinstance(fn(2), UaeTaxRegime)
assert isinstance(fn(50), UaeTaxRegime)
def test_build_regime_schedule_uk_constant() -> None:
fn = build_regime_schedule("uk", leave_uk_year=3)
# All years should resolve to UK
assert isinstance(fn(0), UkTaxRegime)
assert isinstance(fn(50), UkTaxRegime)
def test_build_regime_schedule_cyprus_switches_at_leave_year() -> None:
fn = build_regime_schedule("cyprus", leave_uk_year=3)
assert isinstance(fn(0), UkTaxRegime)
assert isinstance(fn(2), UkTaxRegime)
assert isinstance(fn(3), CyprusTaxRegime)
assert isinstance(fn(50), CyprusTaxRegime)
def test_build_regime_schedule_bulgaria() -> None:
fn = build_regime_schedule("bulgaria", leave_uk_year=1)
assert isinstance(fn(0), UkTaxRegime)
assert isinstance(fn(1), BulgariaTaxRegime)
def test_build_regime_schedule_unknown_raises() -> None:
with pytest.raises(KeyError):
build_regime_schedule("madeupistan", leave_uk_year=3)
def test_cartesian_unknown_glide_raises() -> None:
with pytest.raises(KeyError):
cartesian_scenarios(
spending_gbp=Decimal("100000"),
nw_seed_gbp=Decimal("1000000"),
glides=("staircase", ),
)

259
tests/test_simulator.py Normal file
View file

@ -0,0 +1,259 @@
"""Simulator behaviour: deterministic short-horizon checks, then
stochastic monotonicity + cFIREsim sanity calibration."""
from __future__ import annotations
import time
import numpy as np
from fire_planner.glide_path import static
from fire_planner.returns.bootstrap import block_bootstrap
from fire_planner.returns.shiller import ReturnsBundle, synthetic_returns
from fire_planner.simulator import default_bucket_split, simulate
from fire_planner.strategies.guyton_klinger import GuytonKlingerStrategy
from fire_planner.strategies.trinity import TrinityStrategy
from fire_planner.strategies.vpw import VpwStrategy
from fire_planner.tax.bulgaria import BulgariaTaxRegime
from fire_planner.tax.malaysia import MalaysiaTaxRegime
from fire_planner.tax.uk import UkTaxRegime
def fixed_paths(n_paths: int, n_years: int, stock_ret: float, bond_ret: float,
cpi: float) -> np.ndarray:
"""All-paths-identical returns — deterministic regression check."""
out = np.zeros((n_paths, n_years, 3), dtype=np.float64)
out[..., 0] = stock_ret
out[..., 1] = bond_ret
out[..., 2] = cpi
return out
def test_simulate_zero_returns_zero_inflation_drains_at_4pc() -> None:
"""0% returns + 0% inflation, 4% Trinity, 25y horizon — withdraw
£40k/y from £1M = drain to exactly £0 in year 25. Success because
portfolio stays positive *during* every year (clipped to 0 at end)."""
paths = fixed_paths(n_paths=1, n_years=25, stock_ret=0.0, bond_ret=0.0, cpi=0.0)
res = simulate(
paths=paths,
initial_portfolio=1_000_000.0,
spending_target=40_000.0,
glide=static(0.6),
strategy=TrinityStrategy(initial_rate=0.04),
regime=MalaysiaTaxRegime(), # 0% to keep arithmetic clean
)
# Year 0 withdrawal is 40k, portfolio after = 960k
assert res.portfolio_real[0, 1] == 960_000.0
# 25y of £40k draws against zero growth = drain to 0 by end of y25.
assert abs(res.portfolio_real[0, 25]) < 1.0
def test_simulate_failing_path_marked_unsuccessful() -> None:
"""6% Trinity rate against 0% real return for 25y — clearly fails."""
paths = fixed_paths(n_paths=1, n_years=25, stock_ret=0.0, bond_ret=0.0, cpi=0.0)
res = simulate(
paths=paths,
initial_portfolio=1_000_000.0,
spending_target=60_000.0,
glide=static(1.0),
strategy=TrinityStrategy(initial_rate=0.06),
regime=MalaysiaTaxRegime(),
)
assert not res.success_mask[0]
def test_simulate_growing_portfolio_succeeds() -> None:
"""5% real return, 4% draw — classic surplus case."""
paths = fixed_paths(n_paths=1, n_years=30, stock_ret=0.05, bond_ret=0.05, cpi=0.0)
res = simulate(
paths=paths,
initial_portfolio=1_000_000.0,
spending_target=40_000.0,
glide=static(1.0),
strategy=TrinityStrategy(initial_rate=0.04),
regime=MalaysiaTaxRegime(),
)
assert res.success_mask[0]
# Portfolio should grow above starting value
assert res.portfolio_real[0, 30] > 1_000_000.0
def test_savings_phase_increases_portfolio() -> None:
"""5y of savings @ £100k / 0% return → portfolio grows."""
paths = fixed_paths(n_paths=1, n_years=5, stock_ret=0.0, bond_ret=0.0, cpi=0.0)
res = simulate(
paths=paths,
initial_portfolio=1_000_000.0,
spending_target=0.0, # not drawing during accumulation
glide=static(1.0),
strategy=TrinityStrategy(initial_rate=0.0),
regime=MalaysiaTaxRegime(),
annual_savings=np.full(5, 100_000.0),
)
# 1M + 5×100k = 1.5M, no growth
assert res.portfolio_real[0, 5] == 1_500_000.0
def test_uk_tax_increases_failure_rate_vs_no_tax() -> None:
"""Same scenario, UK regime should produce more or equal failures
than Malaysia (zero tax) paths are identical."""
bundle = synthetic_returns(seed=1, n_years=120)
rng = np.random.default_rng(0)
paths = block_bootstrap(bundle, n_paths=200, n_years=30, block_size=5, rng=rng)
common = dict(
paths=paths,
initial_portfolio=600_000.0, # tighter so tax matters
spending_target=40_000.0,
glide=static(0.7),
strategy=TrinityStrategy(initial_rate=0.04),
)
msy = simulate(**common, regime=MalaysiaTaxRegime()) # type: ignore[arg-type]
uk = simulate(**common, regime=UkTaxRegime()) # type: ignore[arg-type]
assert uk.success_rate <= msy.success_rate
assert uk.median_lifetime_tax() > msy.median_lifetime_tax()
def test_vpw_never_runs_out() -> None:
"""VPW scales withdrawal with portfolio — should never fully ruin."""
bundle = synthetic_returns(seed=2, n_years=120)
rng = np.random.default_rng(0)
paths = block_bootstrap(bundle, n_paths=200, n_years=60, block_size=5, rng=rng)
res = simulate(
paths=paths,
initial_portfolio=1_000_000.0,
spending_target=50_000.0,
glide=static(0.7),
strategy=VpwStrategy(),
regime=MalaysiaTaxRegime(),
)
# Every path should keep some portfolio > 0 throughout (until last year).
# Year `n-1` end may be tiny but >= 0 since VPW caps drain at 100% in y=H-1.
assert res.portfolio_real[:, 1:-1].min() > 0
def test_simulator_deterministic_with_same_paths() -> None:
paths = fixed_paths(n_paths=10, n_years=30, stock_ret=0.05, bond_ret=0.03, cpi=0.02)
common = dict(
paths=paths,
initial_portfolio=1_000_000.0,
spending_target=40_000.0,
glide=static(0.7),
strategy=GuytonKlingerStrategy(),
regime=BulgariaTaxRegime(),
)
a = simulate(**common) # type: ignore[arg-type]
b = simulate(**common) # type: ignore[arg-type]
np.testing.assert_array_equal(a.portfolio_real, b.portfolio_real)
def test_success_rate_monotone_in_portfolio() -> None:
"""More starting wealth → higher (or equal) success rate."""
bundle = synthetic_returns(seed=3, n_years=120)
rng = np.random.default_rng(0)
paths = block_bootstrap(bundle, n_paths=300, n_years=30, block_size=5, rng=rng)
common = dict(
paths=paths,
spending_target=40_000.0,
glide=static(0.7),
strategy=TrinityStrategy(initial_rate=0.04),
regime=MalaysiaTaxRegime(),
)
low = simulate(**common, initial_portfolio=600_000.0) # type: ignore[arg-type]
high = simulate(**common, initial_portfolio=1_500_000.0) # type: ignore[arg-type]
assert high.success_rate >= low.success_rate
def test_success_rate_monotone_in_spending() -> None:
"""Less spending → higher success rate."""
bundle = synthetic_returns(seed=4, n_years=120)
rng = np.random.default_rng(0)
paths = block_bootstrap(bundle, n_paths=300, n_years=30, block_size=5, rng=rng)
common = dict(
paths=paths,
initial_portfolio=1_000_000.0,
glide=static(0.7),
strategy=TrinityStrategy(initial_rate=0.04),
regime=MalaysiaTaxRegime(),
)
cheap = simulate(**common, spending_target=30_000.0) # type: ignore[arg-type]
fat = simulate(**common, spending_target=80_000.0) # type: ignore[arg-type]
assert cheap.success_rate >= fat.success_rate
def test_fan_quantiles_shape() -> None:
bundle = synthetic_returns(seed=5, n_years=120)
paths = block_bootstrap(bundle,
n_paths=100,
n_years=20,
block_size=5,
rng=np.random.default_rng(0))
res = simulate(
paths=paths,
initial_portfolio=1_000_000.0,
spending_target=40_000.0,
glide=static(0.7),
strategy=TrinityStrategy(),
regime=MalaysiaTaxRegime(),
)
p10 = res.fan_quantiles(10)
assert p10.shape == (21, ) # n_years + 1
def test_perf_under_60s_for_10k_paths_60y() -> None:
"""Stretch goal — at 10k paths × 60y the simulator should finish
in well under a minute on commodity hardware. Test allows 60s
(generous; CI can vary)."""
bundle = synthetic_returns(seed=6, n_years=150)
paths = block_bootstrap(bundle,
n_paths=10_000,
n_years=60,
block_size=5,
rng=np.random.default_rng(0))
start = time.perf_counter()
simulate(
paths=paths,
initial_portfolio=1_000_000.0,
spending_target=40_000.0,
glide=static(0.7),
strategy=TrinityStrategy(),
regime=MalaysiaTaxRegime(),
)
elapsed = time.perf_counter() - start
assert elapsed < 60, f"too slow: {elapsed:.2f}s"
def test_convergence_5k_vs_50k_paths() -> None:
"""Success rate should be stable to within ±1.5% between 5k and
50k paths (Monte Carlo SE ~0.5% at 10k samples)."""
bundle = synthetic_returns(seed=7, n_years=150)
paths_small = block_bootstrap(bundle,
n_paths=5_000,
n_years=30,
block_size=5,
rng=np.random.default_rng(0))
paths_large = block_bootstrap(bundle,
n_paths=50_000,
n_years=30,
block_size=5,
rng=np.random.default_rng(0))
common = dict(
initial_portfolio=1_000_000.0,
spending_target=40_000.0,
glide=static(0.7),
strategy=TrinityStrategy(),
regime=MalaysiaTaxRegime(),
)
small = simulate(paths=paths_small, **common) # type: ignore[arg-type]
large = simulate(paths=paths_large, **common) # type: ignore[arg-type]
assert abs(small.success_rate - large.success_rate) < 0.015
def test_default_bucket_split_smoke() -> None:
inputs = default_bucket_split(50_000.0, year_idx=5)
assert inputs.capital_gains == 50000
def test_returns_bundle_supplies_ie_data_columns() -> None:
"""Sanity: the bundle has stock/bond/cpi correctly aligned."""
b = synthetic_returns(seed=8, n_years=10)
assert isinstance(b, ReturnsBundle)
assert len(b.stock_nominal) == 10

155
tests/test_strategies.py Normal file
View file

@ -0,0 +1,155 @@
"""Withdrawal-strategy + glide-path behaviour."""
from fire_planner import glide_path
from fire_planner.strategies.base import StrategyState
from fire_planner.strategies.guyton_klinger import GuytonKlingerStrategy
from fire_planner.strategies.trinity import TrinityStrategy
from fire_planner.strategies.vpw import VpwStrategy, VpwWithFloorStrategy, pmt_rate
def state(**overrides: float | int) -> StrategyState:
base = dict(
portfolio=1_000_000.0,
initial_portfolio=1_000_000.0,
initial_withdrawal=40_000.0,
year_idx=0,
horizon_years=60,
last_withdrawal=40_000.0,
expected_real_return=0.04,
)
base.update(overrides)
return StrategyState(**base) # type: ignore[arg-type]
def test_trinity_year_zero_uses_initial_rate() -> None:
s = TrinityStrategy(initial_rate=0.04)
assert s.propose_withdrawal(state()) == 40_000.0
def test_trinity_holds_constant_in_real_terms() -> None:
s = TrinityStrategy()
assert s.propose_withdrawal(state(year_idx=10, last_withdrawal=40_000.0)) == 40_000.0
def test_trinity_doesnt_increase_with_portfolio_growth() -> None:
s = TrinityStrategy()
assert s.propose_withdrawal(state(year_idx=5, portfolio=2_000_000.0,
last_withdrawal=40_000.0)) == 40_000.0
def test_gk_year_zero_uses_initial_rate() -> None:
s = GuytonKlingerStrategy(initial_rate=0.055)
# 5.5% of 1M = 55,000
assert s.propose_withdrawal(state()) == 55_000.0
def test_gk_capital_preservation_cut() -> None:
"""Portfolio crashed: current rate now > 120% of 5.5% = 6.6%; > 15y left → cut 10%."""
s = GuytonKlingerStrategy(initial_rate=0.055)
# last_w = 55,000; portfolio = 700,000 → rate = 7.86% > 6.6%
out = s.propose_withdrawal(state(year_idx=5, portfolio=700_000.0, last_withdrawal=55_000.0))
assert abs(out - 49_500.0) < 0.01
def test_gk_no_cut_when_horizon_under_15y_left() -> None:
"""Same crash, only 10y left — no cut applies."""
s = GuytonKlingerStrategy(initial_rate=0.055)
out = s.propose_withdrawal(
state(year_idx=50, portfolio=700_000.0, last_withdrawal=55_000.0, horizon_years=60))
assert out == 55_000.0
def test_gk_prosperity_bump() -> None:
"""Big bull market: current rate < 80% of 5.5% = 4.4% → bump 10%."""
s = GuytonKlingerStrategy(initial_rate=0.055)
out = s.propose_withdrawal(state(year_idx=5, portfolio=2_000_000.0, last_withdrawal=55_000.0))
assert abs(out - 60_500.0) < 0.01
def test_pmt_rate_uniform_amortisation_at_zero_rate() -> None:
assert abs(pmt_rate(years_remaining=60, real_rate=0.0) - 1 / 60) < 1e-12
def test_pmt_rate_full_drain_when_years_zero() -> None:
assert pmt_rate(years_remaining=0, real_rate=0.04) == 1.0
def test_pmt_rate_bogleheads_table_60y() -> None:
"""Bogleheads VPW table: at 5% real, 60y, the published rate is
5.28% (within £1/£10k of 5.2828% on a 60-year amortisation)."""
assert abs(pmt_rate(60, 0.05) - 0.052828) < 1e-4
def test_pmt_rate_bogleheads_table_30y() -> None:
"""At 5% real, 30y → 6.51%."""
assert abs(pmt_rate(30, 0.05) - 0.06505) < 1e-4
def test_pmt_rate_bogleheads_table_15y() -> None:
"""At 5% real, 15y → 9.63%."""
assert abs(pmt_rate(15, 0.05) - 0.09634) < 1e-4
def test_vpw_year_zero_at_60y_horizon() -> None:
"""1M portfolio × pmt_rate(60, 0.05) = 1M × 0.0528 = 52,828.20."""
s = VpwStrategy(expected_real_return=0.05)
out = s.propose_withdrawal(state(horizon_years=60, year_idx=0))
assert abs(out - 52_828.0) < 5 # within a few quid
def test_vpw_drain_at_horizon_end() -> None:
"""Last year: withdraw the entire portfolio."""
s = VpwStrategy()
out = s.propose_withdrawal(state(year_idx=59, horizon_years=60, portfolio=100_000.0))
assert abs(out - 100_000.0) < 1
def test_vpw_with_floor_lifts_to_floor_when_vpw_proposes_less() -> None:
"""VPW on a 500k portfolio with 60y left at 5% would propose
500k × 0.0528 26,400. Floor=40k overrides withdraw the floor."""
s = VpwWithFloorStrategy(floor=40_000.0, expected_real_return=0.05)
out = s.propose_withdrawal(state(portfolio=500_000.0, horizon_years=60, year_idx=0))
assert out == 40_000.0
def test_vpw_with_floor_uses_vpw_when_above_floor() -> None:
"""VPW on a 2M portfolio with 60y left ≈ 105,656. Above floor=40k → use VPW."""
s = VpwWithFloorStrategy(floor=40_000.0, expected_real_return=0.05)
out = s.propose_withdrawal(state(portfolio=2_000_000.0, horizon_years=60, year_idx=0))
assert abs(out - 105_656.0) < 50
def test_vpw_with_floor_clips_to_portfolio_when_portfolio_below_floor() -> None:
"""Terminal sequence: portfolio crashed below the floor — withdraw what's left."""
s = VpwWithFloorStrategy(floor=40_000.0)
out = s.propose_withdrawal(state(portfolio=15_000.0, horizon_years=60, year_idx=30))
assert out == 15_000.0
def test_vpw_with_floor_zero_portfolio() -> None:
s = VpwWithFloorStrategy(floor=40_000.0)
out = s.propose_withdrawal(state(portfolio=0.0))
assert out == 0.0
def test_vpw_with_floor_name() -> None:
assert VpwWithFloorStrategy(floor=40_000.0).name == "vpw_floor"
def test_glide_rising_default_shape() -> None:
g = glide_path.rising_equity()
assert g(0) == 0.30
assert abs(g(15) - 0.70) < 1e-9
assert abs(g(30) - 0.70) < 1e-9
# Halfway through the ramp
assert abs(g(7) - (0.30 + 0.40 * 7 / 15)) < 1e-9
def test_glide_static() -> None:
g = glide_path.static(0.60)
assert g(0) == 0.60
assert g(50) == 0.60
def test_glide_lookup() -> None:
assert glide_path.get("rising")(0) == 0.30
assert glide_path.get("static_60_40")(50) == 0.60

70
tests/test_tax_base.py Normal file
View file

@ -0,0 +1,70 @@
"""Bracket-arithmetic and breakdown invariants."""
from decimal import Decimal
from hypothesis import given
from hypothesis import strategies as st
from fire_planner.tax.base import TaxBreakdown, TaxInputs, apply_brackets
def test_apply_brackets_zero_input() -> None:
assert apply_brackets(Decimal("0"), [(Decimal("100"), Decimal("0.2"))]) == Decimal("0")
def test_apply_brackets_negative_input() -> None:
# Negative income shouldn't generate a refund — clamp to zero.
assert apply_brackets(Decimal("-1000"), [(Decimal("100"), Decimal("0.2"))]) == Decimal("0")
def test_apply_brackets_within_first_band() -> None:
brackets = [(Decimal("100"), Decimal("0.2")), (Decimal("Infinity"), Decimal("0.4"))]
assert apply_brackets(Decimal("50"), brackets) == Decimal("10")
def test_apply_brackets_spans_two_bands() -> None:
# 100 @ 20% = 20; next 50 @ 40% = 20 → total 40
brackets = [(Decimal("100"), Decimal("0.2")), (Decimal("Infinity"), Decimal("0.4"))]
assert apply_brackets(Decimal("150"), brackets) == Decimal("40")
def test_apply_brackets_uk_paye_2026_smoke() -> None:
# Taxable income £80,000 (gross £92,570 less £12,570 PA):
# £37,700 @ 20% = £7,540
# £42,300 @ 40% = £16,920
# total = £24,460
brackets = [
(Decimal("37700"), Decimal("0.20")),
(Decimal("112570"), Decimal("0.40")),
(Decimal("Infinity"), Decimal("0.45")),
]
assert apply_brackets(Decimal("80000"), brackets) == Decimal("24460")
@given(amount=st.decimals(min_value=0, max_value=10_000_000, allow_nan=False, allow_infinity=False))
def test_apply_brackets_monotone_in_amount(amount: Decimal) -> None:
"""More taxable income → never less tax."""
brackets = [
(Decimal("37700"), Decimal("0.20")),
(Decimal("112570"), Decimal("0.40")),
(Decimal("Infinity"), Decimal("0.45")),
]
extra = Decimal("100")
assert apply_brackets(amount + extra, brackets) >= apply_brackets(amount, brackets)
def test_breakdown_total_is_sum_of_components() -> None:
b = TaxBreakdown(
income_tax=Decimal("10000"),
national_insurance=Decimal("3000"),
capital_gains_tax=Decimal("500"),
dividend_tax=Decimal("200"),
healthcare_levy=Decimal("100"),
other=Decimal("50"),
)
assert b.total == Decimal("13850")
def test_inputs_default_to_zero() -> None:
i = TaxInputs()
assert i.earned_income == Decimal("0")
assert i.years_since_uk_departure == 0

View file

@ -0,0 +1,150 @@
"""Nomad, Malaysia, Thailand, Cyprus, Bulgaria, UAE regimes."""
from decimal import Decimal
import pytest
from fire_planner.tax.base import TaxInputs, 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
def test_nomad_zero_inputs() -> None:
assert NomadTaxRegime().compute_tax(TaxInputs()).total == Decimal("0")
def test_nomad_one_pc_premium() -> None:
b = NomadTaxRegime().compute_tax(
TaxInputs(capital_gains=Decimal("100000"), dividends=Decimal("20000")))
assert b.other == Decimal("1200")
assert b.total == Decimal("1200")
def test_nomad_isa_excluded_from_premium() -> None:
b = NomadTaxRegime().compute_tax(TaxInputs(isa_withdrawals=Decimal("100000")))
assert b.total == Decimal("0")
def test_malaysia_zero_on_foreign_income() -> None:
b = MalaysiaTaxRegime().compute_tax(
TaxInputs(capital_gains=Decimal("500000"), dividends=Decimal("50000")))
assert b.total == Decimal("0")
def test_thailand_zero_on_foreign_income() -> None:
b = ThailandTaxRegime().compute_tax(
TaxInputs(capital_gains=Decimal("500000"), dividends=Decimal("50000")))
assert b.total == Decimal("0")
def test_cyprus_gesy_below_cap() -> None:
# £100k chargeable, below €180k cap (~£154,800 default)
# 2.65% × £100,000 = £2,650
b = CyprusTaxRegime().compute_tax(TaxInputs(dividends=Decimal("100000")))
assert b.healthcare_levy == Decimal("2650.0000")
assert b.income_tax == Decimal("0")
assert b.capital_gains_tax == Decimal("0")
def test_cyprus_gesy_above_cap() -> None:
# £200k chargeable; cap GBP = £154,800 (€180k × 0.86)
# 2.65% × £154,800 = £4,102.20
b = CyprusTaxRegime().compute_tax(TaxInputs(dividends=Decimal("200000")))
assert b.healthcare_levy == Decimal("4102.2000")
def test_cyprus_custom_fx() -> None:
# Cap = 180,000 × 0.90 = 162,000
b = CyprusTaxRegime(gbp_per_eur=Decimal("0.90")).compute_tax(
TaxInputs(dividends=Decimal("200000")))
assert b.healthcare_levy == Decimal("4293.0000")
def test_uae_zero_on_all_personal_income() -> None:
"""UAE has 0% PIT — capital gains, dividends, earned income all 0."""
b = UaeTaxRegime().compute_tax(
TaxInputs(
earned_income=Decimal("60000"),
capital_gains=Decimal("500000"),
dividends=Decimal("80000"),
interest=Decimal("5000"),
))
assert b.total == Decimal("0")
assert b.income_tax == Decimal("0")
assert b.capital_gains_tax == Decimal("0")
assert b.dividend_tax == Decimal("0")
assert b.healthcare_levy == Decimal("0")
assert b.other == Decimal("0")
def test_uae_no_regulatory_premium() -> None:
"""Unlike NomadTaxRegime, UAE charges no premium — it's a real
tax residence with a treaty network."""
b = UaeTaxRegime().compute_tax(TaxInputs(capital_gains=Decimal("100000")))
assert b.total == Decimal("0")
def test_uae_zero_inputs() -> None:
assert UaeTaxRegime().compute_tax(TaxInputs()).total == Decimal("0")
def test_bulgaria_flat_10_pc() -> None:
b = BulgariaTaxRegime().compute_tax(
TaxInputs(
earned_income=Decimal("50000"),
capital_gains=Decimal("30000"),
dividends=Decimal("10000"),
))
assert b.income_tax == Decimal("5000.00")
assert b.capital_gains_tax == Decimal("3000.00")
assert b.dividend_tax == Decimal("1000.00")
assert b.total == Decimal("9000.00")
@pytest.mark.parametrize("regime", [
NomadTaxRegime(),
MalaysiaTaxRegime(),
ThailandTaxRegime(),
CyprusTaxRegime(),
BulgariaTaxRegime(),
UaeTaxRegime(),
])
def test_total_equals_sum(regime: TaxRegime) -> None:
inputs = TaxInputs(
earned_income=Decimal("60000"),
capital_gains=Decimal("15000"),
dividends=Decimal("8000"),
interest=Decimal("500"),
)
b = regime.compute_tax(inputs)
assert (b.total == b.income_tax + b.national_insurance + b.capital_gains_tax + b.dividend_tax +
b.healthcare_levy + b.other)
@pytest.mark.parametrize("regime", [
NomadTaxRegime(),
MalaysiaTaxRegime(),
ThailandTaxRegime(),
CyprusTaxRegime(),
BulgariaTaxRegime(),
UaeTaxRegime(),
])
def test_each_regime_has_a_name(regime: TaxRegime) -> None:
assert regime.name
assert isinstance(regime.name, str)
@pytest.mark.parametrize("regime", [
BulgariaTaxRegime(),
NomadTaxRegime(),
CyprusTaxRegime(),
])
def test_lower_spend_lower_tax(regime: TaxRegime) -> None:
"""Sanity: more chargeable income → never less tax (for the
regimes that actually charge)."""
less = regime.compute_tax(TaxInputs(dividends=Decimal("10000")))
more = regime.compute_tax(TaxInputs(dividends=Decimal("100000")))
assert more.total >= less.total

147
tests/test_tax_uk.py Normal file
View file

@ -0,0 +1,147 @@
"""UK tax regime — bands, allowances, tapers."""
from decimal import Decimal
from hypothesis import given
from hypothesis import strategies as st
from fire_planner.tax.base import TaxInputs
from fire_planner.tax.uk import (
PA_TAPER_CEILING,
PERSONAL_ALLOWANCE,
UkTaxRegime,
taper_personal_allowance,
)
def test_pa_no_taper_below_100k() -> None:
assert taper_personal_allowance(Decimal("80000")) == PERSONAL_ALLOWANCE
def test_pa_full_taper_at_ceiling() -> None:
assert taper_personal_allowance(PA_TAPER_CEILING) == Decimal("0")
def test_pa_partial_taper_at_110k() -> None:
# £10k above floor → £5k reduction off PA
assert taper_personal_allowance(Decimal("110000")) == PERSONAL_ALLOWANCE - Decimal("5000")
def test_zero_income_zero_tax() -> None:
b = UkTaxRegime().compute_tax(TaxInputs())
assert b.total == Decimal("0")
def test_isa_only_zero_tax() -> None:
b = UkTaxRegime().compute_tax(TaxInputs(isa_withdrawals=Decimal("100000")))
assert b.total == Decimal("0")
def test_below_pa_zero_tax() -> None:
b = UkTaxRegime().compute_tax(TaxInputs(earned_income=Decimal("12000")))
# NI primary threshold matches PA so NI is zero too.
assert b.total == Decimal("0")
def test_basic_rate_paye_smoke() -> None:
# £30k earned: £17,430 taxable @ 20% = £3,486 income tax
# NI: £17,430 @ 8% = £1,394.40
b = UkTaxRegime().compute_tax(TaxInputs(earned_income=Decimal("30000")))
assert b.income_tax == Decimal("3486.00")
assert b.national_insurance == Decimal("1394.40")
def test_higher_rate_paye_100k() -> None:
# £100k earned, PA still full (taper starts strictly above £100k):
# taxable = £87,430
# £37,700 @ 20% = £7,540
# £49,730 @ 40% = £19,892
# total income tax = £27,432
b = UkTaxRegime().compute_tax(TaxInputs(earned_income=Decimal("100000")))
assert b.income_tax == Decimal("27432.00")
def test_pa_taper_at_125k() -> None:
# £125,000: PA = 12,570 - (25,000/2) = 12,570 - 12,500 = 70
# taxable = 125,000 - 70 = 124,930
# £37,700 @ 20% = £7,540
# £87,230 @ 40% = £34,892
# total = £42,432
b = UkTaxRegime().compute_tax(TaxInputs(earned_income=Decimal("125000")))
assert b.income_tax == Decimal("42432.00")
def test_additional_rate_above_125k() -> None:
# £200k earned: PA fully tapered.
# taxable income = £200,000
# £37,700 @ 20% = £7,540
# £87,440 @ 40% = £34,976
# £74,860 @ 45% = £33,687
# total = £76,203
b = UkTaxRegime().compute_tax(TaxInputs(earned_income=Decimal("200000")))
assert b.income_tax == Decimal("76203.00")
def test_cgt_basic_rate_only() -> None:
# No earned income, £20k gains:
# exempt £3k → £17k taxable @ 18% (basic band has plenty of room)
# = £3,060
b = UkTaxRegime().compute_tax(TaxInputs(capital_gains=Decimal("20000")))
assert b.capital_gains_tax == Decimal("3060.00")
def test_cgt_spans_into_higher_band() -> None:
# £30k earned (taxable income £17,430 — well under £37,700 band top)
# £40k gains:
# exempt £3k → £37k taxable
# basic band remaining = 37,700 - 17,430 = 20,270 → @ 18% = £3,648.60
# higher = 37,000 - 20,270 = 16,730 → @ 24% = £4,015.20
# total CGT = £7,663.80
b = UkTaxRegime().compute_tax(
TaxInputs(earned_income=Decimal("30000"), capital_gains=Decimal("40000")))
assert b.capital_gains_tax == Decimal("7663.80")
def test_dividend_basic_rate() -> None:
# No other income, £10k dividends:
# allowance £500 → £9,500 taxable
# Stacked on top of taxable_ordinary=0, so basic band has £37,700 room.
# All £9,500 @ 8.75% = £831.25
b = UkTaxRegime().compute_tax(TaxInputs(dividends=Decimal("10000")))
assert b.dividend_tax == Decimal("831.2500")
def test_pension_25pc_tax_free() -> None:
# £40k pension drawdown, no other income:
# PCLS = £10k tax-free
# Taxable pension = £30k → £17,430 taxable @ 20% = £3,486
b = UkTaxRegime().compute_tax(TaxInputs(pension_withdrawal=Decimal("40000")))
assert b.income_tax == Decimal("3486.00")
assert b.national_insurance == Decimal("0") # NI not on pension
def test_total_equals_sum_of_components() -> None:
inputs = TaxInputs(
earned_income=Decimal("60000"),
capital_gains=Decimal("15000"),
dividends=Decimal("8000"),
)
b = UkTaxRegime().compute_tax(inputs)
assert (b.total == b.income_tax + b.national_insurance + b.capital_gains_tax + b.dividend_tax +
b.healthcare_levy + b.other)
@given(income=st.decimals(
min_value=0, max_value=500_000, places=2, allow_nan=False, allow_infinity=False))
def test_tax_monotone_in_earned_income(income: Decimal) -> None:
"""Adding earned income never decreases total tax."""
base = UkTaxRegime().compute_tax(TaxInputs(earned_income=income))
plus = UkTaxRegime().compute_tax(TaxInputs(earned_income=income + Decimal("1000")))
assert plus.total >= base.total
@given(gains=st.decimals(
min_value=0, max_value=500_000, places=2, allow_nan=False, allow_infinity=False))
def test_cgt_monotone_in_gains(gains: Decimal) -> None:
base = UkTaxRegime().compute_tax(TaxInputs(capital_gains=gains))
plus = UkTaxRegime().compute_tax(TaxInputs(capital_gains=gains + Decimal("1000")))
assert plus.capital_gains_tax >= base.capital_gains_tax