From f7ef7ca4ab68cba92ba0eb7684d32c40ea7386b8 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Thu, 7 May 2026 17:06:19 +0000 Subject: [PATCH] Initial extraction from monorepo --- .gitignore | 8 + .woodpecker.yml | 45 + Dockerfile | 33 + PLAYBOOK_VIKTOR.md | 366 ++++++ README.md | 51 + alembic.ini | 37 + alembic/env.py | 61 + alembic/versions/0001_initial.py | 173 +++ fire_planner/__init__.py | 1 + fire_planner/__main__.py | 259 ++++ fire_planner/app.py | 112 ++ fire_planner/db.py | 165 +++ fire_planner/fx.py | 49 + fire_planner/glide_path.py | 46 + fire_planner/ingest/__init__.py | 1 + fire_planner/ingest/hmrc.py | 25 + fire_planner/ingest/payslip.py | 77 ++ fire_planner/ingest/wealthfolio.py | 126 ++ fire_planner/reporters/__init__.py | 1 + fire_planner/reporters/cli.py | 31 + fire_planner/reporters/pg.py | 224 ++++ fire_planner/returns/__init__.py | 1 + fire_planner/returns/bootstrap.py | 60 + fire_planner/returns/shiller.py | 99 ++ fire_planner/scenarios.py | 129 ++ fire_planner/simulator.py | 231 ++++ fire_planner/strategies/__init__.py | 1 + fire_planner/strategies/base.py | 37 + fire_planner/strategies/guyton_klinger.py | 57 + fire_planner/strategies/trinity.py | 24 + fire_planner/strategies/vpw.py | 83 ++ fire_planner/tax/__init__.py | 1 + fire_planner/tax/base.py | 91 ++ fire_planner/tax/bulgaria.py | 32 + fire_planner/tax/cyprus.py | 49 + fire_planner/tax/malaysia.py | 24 + fire_planner/tax/nomad.py | 31 + fire_planner/tax/thailand.py | 23 + fire_planner/tax/uae.py | 28 + fire_planner/tax/uk.py | 175 +++ poetry.lock | 1464 +++++++++++++++++++++ pyproject.toml | 62 + tests/__init__.py | 0 tests/conftest.py | 36 + tests/test_cli.py | 100 ++ tests/test_db_schema.py | 111 ++ tests/test_e2e.py | 113 ++ tests/test_ingest_wealthfolio.py | 97 ++ tests/test_reporters_pg.py | 93 ++ tests/test_returns.py | 126 ++ tests/test_scenarios.py | 113 ++ tests/test_simulator.py | 259 ++++ tests/test_strategies.py | 155 +++ tests/test_tax_base.py | 70 + tests/test_tax_other_regimes.py | 150 +++ tests/test_tax_uk.py | 147 +++ 56 files changed, 6163 insertions(+) create mode 100644 .gitignore create mode 100644 .woodpecker.yml create mode 100644 Dockerfile create mode 100644 PLAYBOOK_VIKTOR.md create mode 100644 README.md create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/versions/0001_initial.py create mode 100644 fire_planner/__init__.py create mode 100644 fire_planner/__main__.py create mode 100644 fire_planner/app.py create mode 100644 fire_planner/db.py create mode 100644 fire_planner/fx.py create mode 100644 fire_planner/glide_path.py create mode 100644 fire_planner/ingest/__init__.py create mode 100644 fire_planner/ingest/hmrc.py create mode 100644 fire_planner/ingest/payslip.py create mode 100644 fire_planner/ingest/wealthfolio.py create mode 100644 fire_planner/reporters/__init__.py create mode 100644 fire_planner/reporters/cli.py create mode 100644 fire_planner/reporters/pg.py create mode 100644 fire_planner/returns/__init__.py create mode 100644 fire_planner/returns/bootstrap.py create mode 100644 fire_planner/returns/shiller.py create mode 100644 fire_planner/scenarios.py create mode 100644 fire_planner/simulator.py create mode 100644 fire_planner/strategies/__init__.py create mode 100644 fire_planner/strategies/base.py create mode 100644 fire_planner/strategies/guyton_klinger.py create mode 100644 fire_planner/strategies/trinity.py create mode 100644 fire_planner/strategies/vpw.py create mode 100644 fire_planner/tax/__init__.py create mode 100644 fire_planner/tax/base.py create mode 100644 fire_planner/tax/bulgaria.py create mode 100644 fire_planner/tax/cyprus.py create mode 100644 fire_planner/tax/malaysia.py create mode 100644 fire_planner/tax/nomad.py create mode 100644 fire_planner/tax/thailand.py create mode 100644 fire_planner/tax/uae.py create mode 100644 fire_planner/tax/uk.py create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_db_schema.py create mode 100644 tests/test_e2e.py create mode 100644 tests/test_ingest_wealthfolio.py create mode 100644 tests/test_reporters_pg.py create mode 100644 tests/test_returns.py create mode 100644 tests/test_scenarios.py create mode 100644 tests/test_simulator.py create mode 100644 tests/test_strategies.py create mode 100644 tests/test_tax_base.py create mode 100644 tests/test_tax_other_regimes.py create mode 100644 tests/test_tax_uk.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..241d831 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +.venv/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.hypothesis/ +*.egg-info/ diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..6ef1828 --- /dev/null +++ b/.woodpecker.yml @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..737d99c --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/PLAYBOOK_VIKTOR.md b/PLAYBOOK_VIKTOR.md new file mode 100644 index 0000000..0f847a8 --- /dev/null +++ b/PLAYBOOK_VIKTOR.md @@ -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 2027–2028 (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.0–3.5%. This is + bootstrap-with-replacement stringing bad blocks together. +5. **Optimal departure year**: y1 or y2 (2027–2028). Tax drag in UK + is £14–28k/yr; every extra UK year is £14–28k 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.0–3.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.0–3.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 y0–y3; 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 1–5 in Cyprus + +1. **Glide-path execution** — slide stocks from 30% → 70% over + years 1–15 (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.0–3.5% | ⚠️ | Empirical: 2.5%–3.0% on this bootstrap | +| Optimal year identified | ✅ | y1–y2 (2027–2028); 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 + 1–2y runway; a v2 cash-flow refinement is wholly contained. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b6eb946 --- /dev/null +++ b/README.md @@ -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 diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..8be7c72 --- /dev/null +++ b/alembic.ini @@ -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 diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..211507c --- /dev/null +++ b/alembic/env.py @@ -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()) diff --git a/alembic/versions/0001_initial.py b/alembic/versions/0001_initial.py new file mode 100644 index 0000000..d0d8d53 --- /dev/null +++ b/alembic/versions/0001_initial.py @@ -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}") diff --git a/fire_planner/__init__.py b/fire_planner/__init__.py new file mode 100644 index 0000000..f1c771a --- /dev/null +++ b/fire_planner/__init__.py @@ -0,0 +1 @@ +"""Risk-adjusted, tax-minimised FIRE retirement planner.""" diff --git a/fire_planner/__main__.py b/fire_planner/__main__.py new file mode 100644 index 0000000..ce37fff --- /dev/null +++ b/fire_planner/__main__.py @@ -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() diff --git a/fire_planner/app.py b/fire_planner/app.py new file mode 100644 index 0000000..2d3cf70 --- /dev/null +++ b/fire_planner/app.py @@ -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 diff --git a/fire_planner/db.py b/fire_planner/db.py new file mode 100644 index 0000000..650fe0f --- /dev/null +++ b/fire_planner/db.py @@ -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) diff --git a/fire_planner/fx.py b/fire_planner/fx.py new file mode 100644 index 0000000..3b933d6 --- /dev/null +++ b/fire_planner/fx.py @@ -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 diff --git a/fire_planner/glide_path.py b/fire_planner/glide_path.py new file mode 100644 index 0000000..569e43d --- /dev/null +++ b/fire_planner/glide_path.py @@ -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] diff --git a/fire_planner/ingest/__init__.py b/fire_planner/ingest/__init__.py new file mode 100644 index 0000000..3dcd187 --- /dev/null +++ b/fire_planner/ingest/__init__.py @@ -0,0 +1 @@ +"""Ingest layers — Wealthfolio, payslip-ingest, hmrc-sync.""" diff --git a/fire_planner/ingest/hmrc.py b/fire_planner/ingest/hmrc.py new file mode 100644 index 0000000..c8c1b6b --- /dev/null +++ b/fire_planner/ingest/hmrc.py @@ -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") diff --git a/fire_planner/ingest/payslip.py b/fire_planner/ingest/payslip.py new file mode 100644 index 0000000..0c570ec --- /dev/null +++ b/fire_planner/ingest/payslip.py @@ -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), + ) diff --git a/fire_planner/ingest/wealthfolio.py b/fire_planner/ingest/wealthfolio.py new file mode 100644 index 0000000..5567cc6 --- /dev/null +++ b/fire_planner/ingest/wealthfolio.py @@ -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) diff --git a/fire_planner/reporters/__init__.py b/fire_planner/reporters/__init__.py new file mode 100644 index 0000000..f7fe41f --- /dev/null +++ b/fire_planner/reporters/__init__.py @@ -0,0 +1 @@ +"""Result reporters — Postgres + CLI pretty-printer.""" diff --git a/fire_planner/reporters/cli.py b/fire_planner/reporters/cli.py new file mode 100644 index 0000000..140a82b --- /dev/null +++ b/fire_planner/reporters/cli.py @@ -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) diff --git a/fire_planner/reporters/pg.py b/fire_planner/reporters/pg.py new file mode 100644 index 0000000..bc904f1 --- /dev/null +++ b/fire_planner/reporters/pg.py @@ -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) diff --git a/fire_planner/returns/__init__.py b/fire_planner/returns/__init__.py new file mode 100644 index 0000000..da0997c --- /dev/null +++ b/fire_planner/returns/__init__.py @@ -0,0 +1 @@ +"""Historical-returns loaders and bootstrap samplers.""" diff --git a/fire_planner/returns/bootstrap.py b/fire_planner/returns/bootstrap.py new file mode 100644 index 0000000..a0dc5c9 --- /dev/null +++ b/fire_planner/returns/bootstrap.py @@ -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 diff --git a/fire_planner/returns/shiller.py b/fire_planner/returns/shiller.py new file mode 100644 index 0000000..3be19b7 --- /dev/null +++ b/fire_planner/returns/shiller.py @@ -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), + ) diff --git a/fire_planner/scenarios.py b/fire_planner/scenarios.py new file mode 100644 index 0000000..59284d7 --- /dev/null +++ b/fire_planner/scenarios.py @@ -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 diff --git a/fire_planner/simulator.py b/fire_planner/simulator.py new file mode 100644 index 0000000..f7dd89c --- /dev/null +++ b/fire_planner/simulator.py @@ -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, + ) diff --git a/fire_planner/strategies/__init__.py b/fire_planner/strategies/__init__.py new file mode 100644 index 0000000..842903d --- /dev/null +++ b/fire_planner/strategies/__init__.py @@ -0,0 +1 @@ +"""Withdrawal strategies.""" diff --git a/fire_planner/strategies/base.py b/fire_planner/strategies/base.py new file mode 100644 index 0000000..cfa2d28 --- /dev/null +++ b/fire_planner/strategies/base.py @@ -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 diff --git a/fire_planner/strategies/guyton_klinger.py b/fire_planner/strategies/guyton_klinger.py new file mode 100644 index 0000000..1ff3522 --- /dev/null +++ b/fire_planner/strategies/guyton_klinger.py @@ -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 diff --git a/fire_planner/strategies/trinity.py b/fire_planner/strategies/trinity.py new file mode 100644 index 0000000..ea0227a --- /dev/null +++ b/fire_planner/strategies/trinity.py @@ -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 diff --git a/fire_planner/strategies/vpw.py b/fire_planner/strategies/vpw.py new file mode 100644 index 0000000..3ec1091 --- /dev/null +++ b/fire_planner/strategies/vpw.py @@ -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)) diff --git a/fire_planner/tax/__init__.py b/fire_planner/tax/__init__.py new file mode 100644 index 0000000..1a37668 --- /dev/null +++ b/fire_planner/tax/__init__.py @@ -0,0 +1 @@ +"""Per-jurisdiction tax engines.""" diff --git a/fire_planner/tax/base.py b/fire_planner/tax/base.py new file mode 100644 index 0000000..d4735e8 --- /dev/null +++ b/fire_planner/tax/base.py @@ -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 diff --git a/fire_planner/tax/bulgaria.py b/fire_planner/tax/bulgaria.py new file mode 100644 index 0000000..e34d0cf --- /dev/null +++ b/fire_planner/tax/bulgaria.py @@ -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}"), + ) diff --git a/fire_planner/tax/cyprus.py b/fire_planner/tax/cyprus.py new file mode 100644 index 0000000..d565f16 --- /dev/null +++ b/fire_planner/tax/cyprus.py @@ -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}"), + ) diff --git a/fire_planner/tax/malaysia.py b/fire_planner/tax/malaysia.py new file mode 100644 index 0000000..6e7c75e --- /dev/null +++ b/fire_planner/tax/malaysia.py @@ -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")) diff --git a/fire_planner/tax/nomad.py b/fire_planner/tax/nomad.py new file mode 100644 index 0000000..939f70f --- /dev/null +++ b/fire_planner/tax/nomad.py @@ -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}")) diff --git a/fire_planner/tax/thailand.py b/fire_planner/tax/thailand.py new file mode 100644 index 0000000..e454651 --- /dev/null +++ b/fire_planner/tax/thailand.py @@ -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")) diff --git a/fire_planner/tax/uae.py b/fire_planner/tax/uae.py new file mode 100644 index 0000000..f86fc5b --- /dev/null +++ b/fire_planner/tax/uae.py @@ -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")) diff --git a/fire_planner/tax/uk.py b/fire_planner/tax/uk.py new file mode 100644 index 0000000..d8ab191 --- /dev/null +++ b/fire_planner/tax/uk.py @@ -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}"), + ) diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..65919ca --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1464 @@ +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. + +[[package]] +name = "aiosqlite" +version = "0.20.0" +description = "asyncio bridge to the standard sqlite3 module" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "aiosqlite-0.20.0-py3-none-any.whl", hash = "sha256:36a1deaca0cac40ebe32aac9977a6e2bbc7f5189f23f4a54d5908986729e5bd6"}, + {file = "aiosqlite-0.20.0.tar.gz", hash = "sha256:6d35c8c256637f4672f843c31021464090805bf925385ac39473fb16eaaca3d7"}, +] + +[package.dependencies] +typing_extensions = ">=4.0" + +[package.extras] +dev = ["attribution (==1.7.0)", "black (==24.2.0)", "coverage[toml] (==7.4.1)", "flake8 (==7.0.0)", "flake8-bugbear (==24.2.6)", "flit (==3.9.0)", "mypy (==1.8.0)", "ufmt (==2.3.0)", "usort (==1.0.8.post1)"] +docs = ["sphinx (==7.2.6)", "sphinx-mdinclude (==0.5.3)"] + +[[package]] +name = "alembic" +version = "1.18.4" +description = "A database migration tool for SQLAlchemy." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a"}, + {file = "alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc"}, +] + +[package.dependencies] +Mako = "*" +SQLAlchemy = ">=1.4.23" +typing-extensions = ">=4.12" + +[package.extras] +tz = ["tzdata"] + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "anyio" +version = "4.13.0" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.10" +groups = ["main", "dev"] +files = [ + {file = "anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708"}, + {file = "anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc"}, +] + +[package.dependencies] +idna = ">=2.8" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.32.0)"] + +[[package]] +name = "asyncpg" +version = "0.29.0" +description = "An asyncio PostgreSQL driver" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "asyncpg-0.29.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72fd0ef9f00aeed37179c62282a3d14262dbbafb74ec0ba16e1b1864d8a12169"}, + {file = "asyncpg-0.29.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52e8f8f9ff6e21f9b39ca9f8e3e33a5fcdceaf5667a8c5c32bee158e313be385"}, + {file = "asyncpg-0.29.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e6823a7012be8b68301342ba33b4740e5a166f6bbda0aee32bc01638491a22"}, + {file = "asyncpg-0.29.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:746e80d83ad5d5464cfbf94315eb6744222ab00aa4e522b704322fb182b83610"}, + {file = "asyncpg-0.29.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ff8e8109cd6a46ff852a5e6bab8b0a047d7ea42fcb7ca5ae6eaae97d8eacf397"}, + {file = "asyncpg-0.29.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:97eb024685b1d7e72b1972863de527c11ff87960837919dac6e34754768098eb"}, + {file = "asyncpg-0.29.0-cp310-cp310-win32.whl", hash = "sha256:5bbb7f2cafd8d1fa3e65431833de2642f4b2124be61a449fa064e1a08d27e449"}, + {file = "asyncpg-0.29.0-cp310-cp310-win_amd64.whl", hash = "sha256:76c3ac6530904838a4b650b2880f8e7af938ee049e769ec2fba7cd66469d7772"}, + {file = "asyncpg-0.29.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4900ee08e85af01adb207519bb4e14b1cae8fd21e0ccf80fac6aa60b6da37b4"}, + {file = "asyncpg-0.29.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a65c1dcd820d5aea7c7d82a3fdcb70e096f8f70d1a8bf93eb458e49bfad036ac"}, + {file = "asyncpg-0.29.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b52e46f165585fd6af4863f268566668407c76b2c72d366bb8b522fa66f1870"}, + {file = "asyncpg-0.29.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc600ee8ef3dd38b8d67421359779f8ccec30b463e7aec7ed481c8346decf99f"}, + {file = "asyncpg-0.29.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:039a261af4f38f949095e1e780bae84a25ffe3e370175193174eb08d3cecab23"}, + {file = "asyncpg-0.29.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6feaf2d8f9138d190e5ec4390c1715c3e87b37715cd69b2c3dfca616134efd2b"}, + {file = "asyncpg-0.29.0-cp311-cp311-win32.whl", hash = "sha256:1e186427c88225ef730555f5fdda6c1812daa884064bfe6bc462fd3a71c4b675"}, + {file = "asyncpg-0.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:cfe73ffae35f518cfd6e4e5f5abb2618ceb5ef02a2365ce64f132601000587d3"}, + {file = "asyncpg-0.29.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6011b0dc29886ab424dc042bf9eeb507670a3b40aece3439944006aafe023178"}, + {file = "asyncpg-0.29.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b544ffc66b039d5ec5a7454667f855f7fec08e0dfaf5a5490dfafbb7abbd2cfb"}, + {file = "asyncpg-0.29.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d84156d5fb530b06c493f9e7635aa18f518fa1d1395ef240d211cb563c4e2364"}, + {file = "asyncpg-0.29.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54858bc25b49d1114178d65a88e48ad50cb2b6f3e475caa0f0c092d5f527c106"}, + {file = "asyncpg-0.29.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bde17a1861cf10d5afce80a36fca736a86769ab3579532c03e45f83ba8a09c59"}, + {file = "asyncpg-0.29.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:37a2ec1b9ff88d8773d3eb6d3784dc7e3fee7756a5317b67f923172a4748a175"}, + {file = "asyncpg-0.29.0-cp312-cp312-win32.whl", hash = "sha256:bb1292d9fad43112a85e98ecdc2e051602bce97c199920586be83254d9dafc02"}, + {file = "asyncpg-0.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:2245be8ec5047a605e0b454c894e54bf2ec787ac04b1cb7e0d3c67aa1e32f0fe"}, + {file = "asyncpg-0.29.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0009a300cae37b8c525e5b449233d59cd9868fd35431abc470a3e364d2b85cb9"}, + {file = "asyncpg-0.29.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5cad1324dbb33f3ca0cd2074d5114354ed3be2b94d48ddfd88af75ebda7c43cc"}, + {file = "asyncpg-0.29.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:012d01df61e009015944ac7543d6ee30c2dc1eb2f6b10b62a3f598beb6531548"}, + {file = "asyncpg-0.29.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:000c996c53c04770798053e1730d34e30cb645ad95a63265aec82da9093d88e7"}, + {file = "asyncpg-0.29.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e0bfe9c4d3429706cf70d3249089de14d6a01192d617e9093a8e941fea8ee775"}, + {file = "asyncpg-0.29.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:642a36eb41b6313ffa328e8a5c5c2b5bea6ee138546c9c3cf1bffaad8ee36dd9"}, + {file = "asyncpg-0.29.0-cp38-cp38-win32.whl", hash = "sha256:a921372bbd0aa3a5822dd0409da61b4cd50df89ae85150149f8c119f23e8c408"}, + {file = "asyncpg-0.29.0-cp38-cp38-win_amd64.whl", hash = "sha256:103aad2b92d1506700cbf51cd8bb5441e7e72e87a7b3a2ca4e32c840f051a6a3"}, + {file = "asyncpg-0.29.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5340dd515d7e52f4c11ada32171d87c05570479dc01dc66d03ee3e150fb695da"}, + {file = "asyncpg-0.29.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e17b52c6cf83e170d3d865571ba574577ab8e533e7361a2b8ce6157d02c665d3"}, + {file = "asyncpg-0.29.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f100d23f273555f4b19b74a96840aa27b85e99ba4b1f18d4ebff0734e78dc090"}, + {file = "asyncpg-0.29.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48e7c58b516057126b363cec8ca02b804644fd012ef8e6c7e23386b7d5e6ce83"}, + {file = "asyncpg-0.29.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f9ea3f24eb4c49a615573724d88a48bd1b7821c890c2effe04f05382ed9e8810"}, + {file = "asyncpg-0.29.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8d36c7f14a22ec9e928f15f92a48207546ffe68bc412f3be718eedccdf10dc5c"}, + {file = "asyncpg-0.29.0-cp39-cp39-win32.whl", hash = "sha256:797ab8123ebaed304a1fad4d7576d5376c3a006a4100380fb9d517f0b59c1ab2"}, + {file = "asyncpg-0.29.0-cp39-cp39-win_amd64.whl", hash = "sha256:cce08a178858b426ae1aa8409b5cc171def45d4293626e7aa6510696d46decd8"}, + {file = "asyncpg-0.29.0.tar.gz", hash = "sha256:d1c49e1f44fffafd9a55e1a9b101590859d881d639ea2922516f5d9c512d354e"}, +] + +[package.extras] +docs = ["Sphinx (>=5.3.0,<5.4.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["flake8 (>=6.1,<7.0)", "uvloop (>=0.15.3) ; platform_system != \"Windows\" and python_version < \"3.12.0\""] + +[[package]] +name = "certifi" +version = "2026.4.22" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a"}, + {file = "certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"}, +] + +[[package]] +name = "click" +version = "8.3.3" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613"}, + {file = "click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} + +[[package]] +name = "fastapi" +version = "0.115.14" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca"}, + {file = "fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739"}, +] + +[package.dependencies] +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.40.0,<0.47.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] + +[[package]] +name = "greenlet" +version = "3.4.0" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\"" +files = [ + {file = "greenlet-3.4.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:d18eae9a7fb0f499efcd146b8c9750a2e1f6e0e93b5a382b3481875354a430e6"}, + {file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:636d2f95c309e35f650e421c23297d5011716be15d966e6328b367c9fc513a82"}, + {file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:234582c20af9742583c3b2ddfbdbb58a756cfff803763ffaae1ac7990a9fac31"}, + {file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ac6a5f618be581e1e0713aecec8e54093c235e5fa17d6d8eb7ffc487e2300508"}, + {file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:523677e69cd4711b5a014e37bc1fb3a29947c3e3a5bb6a527e1cc50312e5a398"}, + {file = "greenlet-3.4.0-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:d336d46878e486de7d9458653c722875547ac8d36a1cff9ffaf4a74a3c1f62eb"}, + {file = "greenlet-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b45e45fe47a19051a396abb22e19e7836a59ee6c5a90f3be427343c37908d65b"}, + {file = "greenlet-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5434271357be07f3ad0936c312645853b7e689e679e29310e2de09a9ea6c3adf"}, + {file = "greenlet-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:a19093fbad824ed7c0f355b5ff4214bffda5f1a7f35f29b31fcaa240cc0135ab"}, + {file = "greenlet-3.4.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:805bebb4945094acbab757d34d6e1098be6de8966009ab9ca54f06ff492def58"}, + {file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:439fc2f12b9b512d9dfa681c5afe5f6b3232c708d13e6f02c845e0d9f4c2d8c6"}, + {file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a70ed1cb0295bee1df57b63bf7f46b4e56a5c93709eea769c1fec1bb23a95875"}, + {file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c5696c42e6bb5cfb7c6ff4453789081c66b9b91f061e5e9367fa15792644e76"}, + {file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c660bce1940a1acae5f51f0a064f1bc785d07ea16efcb4bc708090afc4d69e83"}, + {file = "greenlet-3.4.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:89995ce5ddcd2896d89615116dd39b9703bfa0c07b583b85b89bf1b5d6eddf81"}, + {file = "greenlet-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee407d4d1ca9dc632265aee1c8732c4a2d60adff848057cdebfe5fe94eb2c8a2"}, + {file = "greenlet-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:956215d5e355fffa7c021d168728321fd4d31fd730ac609b1653b450f6a4bc71"}, + {file = "greenlet-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:5cb614ace7c27571270354e9c9f696554d073f8aa9319079dcba466bbdead711"}, + {file = "greenlet-3.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:04403ac74fe295a361f650818de93be11b5038a78f49ccfb64d3b1be8fbf1267"}, + {file = "greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a"}, + {file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97"}, + {file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996"}, + {file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d"}, + {file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc"}, + {file = "greenlet-3.4.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077"}, + {file = "greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de"}, + {file = "greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08"}, + {file = "greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2"}, + {file = "greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e"}, + {file = "greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1"}, + {file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1"}, + {file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82"}, + {file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f"}, + {file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf"}, + {file = "greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55"}, + {file = "greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729"}, + {file = "greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c"}, + {file = "greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940"}, + {file = "greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a"}, + {file = "greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e"}, + {file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d"}, + {file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615"}, + {file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19"}, + {file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf"}, + {file = "greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd"}, + {file = "greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf"}, + {file = "greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda"}, + {file = "greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d"}, + {file = "greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802"}, + {file = "greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece"}, + {file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8"}, + {file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2"}, + {file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa"}, + {file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed"}, + {file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72"}, + {file = "greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f"}, + {file = "greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a"}, + {file = "greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705"}, + {file = "greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil", "setuptools"] + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.27.2" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, + {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "hypothesis" +version = "6.152.2" +description = "The property-based testing library for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "hypothesis-6.152.2-py3-none-any.whl", hash = "sha256:1ad5b87f0e6c0ab7a9a35b1378cc4963d23eaf0cb1e47e94f1d574b41155907a"}, + {file = "hypothesis-6.152.2.tar.gz", hash = "sha256:11fd5725958fe75597d1b831f703fdf7e636b7cf1f249117f381ad5cee4d888f"}, +] + +[package.dependencies] +sortedcontainers = ">=2.1.0,<3.0.0" + +[package.extras] +all = ["black (>=20.8b0)", "click (>=7.0)", "crosshair-tool (>=0.0.102)", "django (>=4.2)", "dpcontracts (>=0.4)", "hypothesis-crosshair (>=0.0.27)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.21.6)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2026.1) ; sys_platform == \"win32\" or sys_platform == \"emscripten\"", "watchdog (>=4.0.0)"] +cli = ["black (>=20.8b0)", "click (>=7.0)", "rich (>=9.0.0)"] +codemods = ["libcst (>=0.3.16)"] +crosshair = ["crosshair-tool (>=0.0.102)", "hypothesis-crosshair (>=0.0.27)"] +dateutil = ["python-dateutil (>=1.4)"] +django = ["django (>=4.2)"] +dpcontracts = ["dpcontracts (>=0.4)"] +ghostwriter = ["black (>=20.8b0)"] +lark = ["lark (>=0.10.1)"] +numpy = ["numpy (>=1.21.6)"] +pandas = ["pandas (>=1.1)"] +pytest = ["pytest (>=4.6)"] +pytz = ["pytz (>=2014.1)"] +redis = ["redis (>=3.0.0)"] +watchdog = ["watchdog (>=4.0.0)"] +zoneinfo = ["tzdata (>=2026.1) ; sys_platform == \"win32\" or sys_platform == \"emscripten\""] + +[[package]] +name = "idna" +version = "3.13" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3"}, + {file = "idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242"}, +] + +[package.extras] +all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "librt" +version = "0.9.0" +description = "Mypyc runtime library" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "librt-0.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f8e12706dcb8ff6b3ed57514a19e45c49ad00bcd423e87b2b2e4b5f64578443"}, + {file = "librt-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4e3dda8345307fd7306db0ed0cb109a63a2c85ba780eb9dc2d09b2049a931f9c"}, + {file = "librt-0.9.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:de7dac64e3eb832ffc7b840eb8f52f76420cde1b845be51b2a0f6b870890645e"}, + {file = "librt-0.9.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22a904cbdb678f7cb348c90d543d3c52f581663d687992fee47fd566dcbf5285"}, + {file = "librt-0.9.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:224b9727eb8bc188bc3bcf29d969dba0cd61b01d9bac80c41575520cc4baabb2"}, + {file = "librt-0.9.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e94cbc6ad9a6aeea46d775cbb11f361022f778a9cc8cc90af653d3a594b057ce"}, + {file = "librt-0.9.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7bc30ad339f4e1a01d4917d645e522a0bc0030644d8973f6346397c93ba1503f"}, + {file = "librt-0.9.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:56d65b583cf43b8cf4c8fbe1e1da20fa3076cc32a1149a141507af1062718236"}, + {file = "librt-0.9.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0a1be03168b2691ba61927e299b352a6315189199ca18a57b733f86cb3cc8d38"}, + {file = "librt-0.9.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:63c12efcd160e1d14da11af0c46c0217473e1e0d2ae1acbccc83f561ea4c2a7b"}, + {file = "librt-0.9.0-cp310-cp310-win32.whl", hash = "sha256:e9002e98dcb1c0a66723592520decd86238ddcef168b37ff6cfb559200b4b774"}, + {file = "librt-0.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:9fcb461fbf70654a52a7cc670e606f04449e2374c199b1825f754e16dacfedd8"}, + {file = "librt-0.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90904fac73c478f4b83f4ed96c99c8208b75e6f9a8a1910548f69a00f1eaa671"}, + {file = "librt-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:789fff71757facc0738e8d89e3b84e4f0251c1c975e85e81b152cdaca927cc2d"}, + {file = "librt-0.9.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1bf465d1e5b0a27713862441f6467b5ab76385f4ecf8f1f3a44f8aa3c695b4b6"}, + {file = "librt-0.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f819e0c6413e259a17a7c0d49f97f405abadd3c2a316a3b46c6440b7dbbedbb1"}, + {file = "librt-0.9.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0785c2fb4a81e1aece366aa3e2e039f4a4d7d21aaaded5227d7f3c703427882"}, + {file = "librt-0.9.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80b25c7b570a86c03b5da69e665809deb39265476e8e21d96a9328f9762f9990"}, + {file = "librt-0.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d4d16b608a1c43d7e33142099a75cd93af482dadce0bf82421e91cad077157f4"}, + {file = "librt-0.9.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:194fc1a32e1e21fe809d38b5faea66cc65eaa00217c8901fbdb99866938adbdb"}, + {file = "librt-0.9.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8c6bc1384d9738781cfd41d09ad7f6e8af13cfea2c75ece6bd6d2566cdea2076"}, + {file = "librt-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15cb151e52a044f06e54ac7f7b47adbfc89b5c8e2b63e1175a9d587c43e8942a"}, + {file = "librt-0.9.0-cp311-cp311-win32.whl", hash = "sha256:f100bfe2acf8a3689af9d0cc660d89f17286c9c795f9f18f7b62dd1a6b247ae6"}, + {file = "librt-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:0b73e4266307e51c95e09c0750b7ec383c561d2e97d58e473f6f6a209952fbb8"}, + {file = "librt-0.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:bc5518873822d2faa8ebdd2c1a4d7c8ef47b01a058495ab7924cb65bdbf5fc9a"}, + {file = "librt-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b3e3bc363f71bda1639a4ee593cb78f7fbfeacc73411ec0d4c92f00730010a4"}, + {file = "librt-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a09c2f5869649101738653a9b7ab70cf045a1105ac66cbb8f4055e61df78f2d"}, + {file = "librt-0.9.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ca8e133d799c948db2ab1afc081c333a825b5540475164726dcbf73537e5c2f"}, + {file = "librt-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:603138ee838ee1583f1b960b62d5d0007845c5c423feb68e44648b1359014e27"}, + {file = "librt-0.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4003f70c56a5addd6aa0897f200dd59afd3bf7bcd5b3cce46dd21f925743bc2"}, + {file = "librt-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78042f6facfd98ecb25e9829c7e37cce23363d9d7c83bc5f72702c5059eb082b"}, + {file = "librt-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a361c9434a64d70a7dbb771d1de302c0cc9f13c0bffe1cf7e642152814b35265"}, + {file = "librt-0.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:dd2c7e082b0b92e1baa4da28163a808672485617bc855cc22a2fd06978fa9084"}, + {file = "librt-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7e6274fd33fc5b2a14d41c9119629d3ff395849d8bcbc80cf637d9e8d2034da8"}, + {file = "librt-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5093043afb226ecfa1400120d1ebd4442b4f99977783e4f4f7248879009b227f"}, + {file = "librt-0.9.0-cp312-cp312-win32.whl", hash = "sha256:9edcc35d1cae9fd5320171b1a838c7da8a5c968af31e82ecc3dff30b4be0957f"}, + {file = "librt-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc2917258e131ae5f958a4d872e07555b51cb7466a43433218061c74ef33745"}, + {file = "librt-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:90e6d5420fc8a300518d4d2288154ff45005e920425c22cbbfe8330f3f754bd9"}, + {file = "librt-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29b68cd9714531672db62cc54f6e8ff981900f824d13fa0e00749189e13778e"}, + {file = "librt-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d5c8a5929ac325729f6119802070b561f4db793dffc45e9ac750992a4ed4d22"}, + {file = "librt-0.9.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:756775d25ec8345b837ab52effee3ad2f3b2dfd6bbee3e3f029c517bd5d8f05a"}, + {file = "librt-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8f5d00b49818f4e2b1667db994488b045835e0ac16fe2f924f3871bd2b8ac5"}, + {file = "librt-0.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c81aef782380f0f13ead670aae01825eb653b44b046aa0e5ebbb79f76ed4aa11"}, + {file = "librt-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66b58fed90a545328e80d575467244de3741e088c1af928f0b489ebec3ef3858"}, + {file = "librt-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e78fb7419e07d98c2af4b8567b72b3eaf8cb05caad642e9963465569c8b2d87e"}, + {file = "librt-0.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c3786f0f4490a5cd87f1ed6cefae833ad6b1060d52044ce0434a2e85893afd0"}, + {file = "librt-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8494cfc61e03542f2d381e71804990b3931175a29b9278fdb4a5459948778dc2"}, + {file = "librt-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07cf11f769831186eeac424376e6189f20ace4f7263e2134bdb9757340d84d4d"}, + {file = "librt-0.9.0-cp313-cp313-win32.whl", hash = "sha256:850d6d03177e52700af605fd60db7f37dcb89782049a149674d1a9649c2138fd"}, + {file = "librt-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a5af136bfba820d592f86c67affcef9b3ff4d4360ac3255e341e964489b48519"}, + {file = "librt-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:4c4d0440a3a8e31d962340c3e1cc3fc9ee7febd34c8d8f770d06adb947779ea5"}, + {file = "librt-0.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:3f05d145df35dca5056a8bc3838e940efebd893a54b3e19b2dda39ceaa299bcb"}, + {file = "librt-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c587494461ebd42229d0f1739f3aa34237dd9980623ecf1be8d3bcba79f4499"}, + {file = "librt-0.9.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0a2040f801406b93657a70b72fa12311063a319fee72ce98e1524da7200171f"}, + {file = "librt-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f38bc489037eca88d6ebefc9c4d41a4e07c8e8b4de5188a9e6d290273ad7ebb1"}, + {file = "librt-0.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3fd278f5e6bf7c75ccd6d12344eb686cc020712683363b66f46ac79d37c799f"}, + {file = "librt-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fcbdf2a9ca24e87bbebb47f1fe34e531ef06f104f98c9ccfc953a3f3344c567a"}, + {file = "librt-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e306d956cfa027fe041585f02a1602c32bfa6bb8ebea4899d373383295a6c62f"}, + {file = "librt-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:465814ab157986acb9dfa5ccd7df944be5eefc0d08d31ec6e8d88bc71251d845"}, + {file = "librt-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:703f4ae36d6240bfe24f542bac784c7e4194ec49c3ba5a994d02891649e2d85b"}, + {file = "librt-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3be322a15ee5e70b93b7a59cfd074614f22cc8c9ff18bd27f474e79137ea8d3b"}, + {file = "librt-0.9.0-cp314-cp314-win32.whl", hash = "sha256:b8da9f8035bb417770b1e1610526d87ad4fc58a2804dc4d79c53f6d2cf5a6eb9"}, + {file = "librt-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8bd70d5d816566a580d193326912f4a76ec2d28a97dc4cd4cc831c0af8e330e"}, + {file = "librt-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:fc5758e2b7a56532dc33e3c544d78cbaa9ecf0a0f2a2da2df882c1d6b99a317f"}, + {file = "librt-0.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f24b90b0e0c8cc9491fb1693ae91fe17cb7963153a1946395acdbdd5818429a4"}, + {file = "librt-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fe56e80badb66fdcde06bef81bbaa5bfcf6fbd7aefb86222d9e369c38c6b228"}, + {file = "librt-0.9.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:527b5b820b47a09e09829051452bb0d1dd2122261254e2a6f674d12f1d793d54"}, + {file = "librt-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d429bdd4ac0ab17c8e4a8af0ed2a7440b16eba474909ab357131018fe8c7e71"}, + {file = "librt-0.9.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7202bdcac47d3a708271c4304a474a8605a4a9a4a709e954bf2d3241140aa938"}, + {file = "librt-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0d620e74897f8c2613b3c4e2e9c1e422eb46d2ddd07df540784d44117836af3"}, + {file = "librt-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d69fc39e627908f4c03297d5a88d9284b73f4d90b424461e32e8c2485e21c283"}, + {file = "librt-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c2640e23d2b7c98796f123ffd95cf2022c7777aa8a4a3b98b36c570d37e85eee"}, + {file = "librt-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:451daa98463b7695b0a30aa56bf637831ea559e7b8101ac2ef6382e8eb15e29c"}, + {file = "librt-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:928bd06eca2c2bbf4349e5b817f837509b0604342e65a502de1d50a7570afd15"}, + {file = "librt-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:a9c63e04d003bc0fb6a03b348018b9a3002f98268200e22cc80f146beac5dc40"}, + {file = "librt-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f162af66a2ed3f7d1d161a82ca584efd15acd9c1cff190a373458c32f7d42118"}, + {file = "librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61"}, + {file = "librt-0.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5112c2fb7c2eefefaeaf5c97fec81343ef44ee86a30dcfaa8223822fba6467b4"}, + {file = "librt-0.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a81eea9b999b985e4bacc650c4312805ea7008fd5e45e1bf221310176a7bcb3a"}, + {file = "librt-0.9.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eea1b54943475f51698f85fa230c65ccac769f1e603b981be060ac5763d90927"}, + {file = "librt-0.9.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:81107843ed1836874b46b310f9b1816abcb89912af627868522461c3b7333c0f"}, + {file = "librt-0.9.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa95738a68cedd3a6f5492feddc513e2e166b50602958139e47bbdd82da0f5a7"}, + {file = "librt-0.9.0-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6788207daa0c19955d2b668f3294a368d19f67d9b5f274553fd073c1260cbb9f"}, + {file = "librt-0.9.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f48c963a76d71b9d7927eb817b543d0dccd52ab6648b99d37bd54f4cd475d856"}, + {file = "librt-0.9.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:42ff8a962554c350d4a83cf47d9b7b78b0e6ff7943e87df7cdfc97c07f3c016f"}, + {file = "librt-0.9.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:657f8ba7b9eaaa82759a104137aed2a3ef7bc46ccfd43e0d89b04005b3e0a4cc"}, + {file = "librt-0.9.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2d03fa4fd277a7974c1978c92c374c57f44edeee163d147b477b143446ad1bf6"}, + {file = "librt-0.9.0-cp39-cp39-win32.whl", hash = "sha256:d9da80e5b04acce03ced8ba6479a71c2a2edf535c2acc0d09c80d2f80f3bad15"}, + {file = "librt-0.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:54d412e47c21b85865676ed0724e37a89e9593c2eee1e7367adf85bfad56ffb1"}, + {file = "librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d"}, +] + +[[package]] +name = "mako" +version = "1.3.11" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "mako-1.3.11-py3-none-any.whl", hash = "sha256:e372c6e333cf004aa736a15f425087ec977e1fcbd2966aae7f17c8dc1da27a77"}, + {file = "mako-1.3.11.tar.gz", hash = "sha256:071eb4ab4c5010443152255d77db7faa6ce5916f35226eb02dc34479b6858069"}, +] + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["Babel"] +lingua = ["lingua"] +testing = ["pytest"] + +[[package]] +name = "markupsafe" +version = "3.0.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, +] + +[[package]] +name = "mypy" +version = "1.20.2" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4"}, + {file = "mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997"}, + {file = "mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14"}, + {file = "mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99"}, + {file = "mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c"}, + {file = "mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd"}, + {file = "mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2"}, + {file = "mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c"}, + {file = "mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3"}, + {file = "mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254"}, + {file = "mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98"}, + {file = "mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac"}, + {file = "mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67"}, + {file = "mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100"}, + {file = "mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b"}, + {file = "mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4"}, + {file = "mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6"}, + {file = "mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066"}, + {file = "mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102"}, + {file = "mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9"}, + {file = "mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58"}, + {file = "mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026"}, + {file = "mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943"}, + {file = "mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517"}, + {file = "mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15"}, + {file = "mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee"}, + {file = "mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f"}, + {file = "mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330"}, + {file = "mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30"}, + {file = "mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924"}, + {file = "mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb"}, + {file = "mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc"}, + {file = "mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558"}, + {file = "mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8"}, + {file = "mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3"}, + {file = "mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609"}, + {file = "mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2"}, + {file = "mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c"}, + {file = "mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744"}, + {file = "mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6"}, + {file = "mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec"}, + {file = "mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382"}, + {file = "mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563"}, + {file = "mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665"}, +] + +[package.dependencies] +librt = {version = ">=0.8.0", markers = "platform_python_implementation != \"PyPy\""} +mypy_extensions = ">=1.0.0" +pathspec = ">=1.0.0" +typing_extensions = {version = ">=4.6.0", markers = "python_version < \"3.15\""} + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +native-parser = ["ast-serialize (>=0.1.1,<1.0.0)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "numpy" +version = "2.4.4" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.11" +groups = ["main"] +files = [ + {file = "numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db"}, + {file = "numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0"}, + {file = "numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015"}, + {file = "numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40"}, + {file = "numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d"}, + {file = "numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502"}, + {file = "numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd"}, + {file = "numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5"}, + {file = "numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e"}, + {file = "numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e"}, + {file = "numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e"}, + {file = "numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b"}, + {file = "numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e"}, + {file = "numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842"}, + {file = "numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8"}, + {file = "numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121"}, + {file = "numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e"}, + {file = "numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44"}, + {file = "numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d"}, + {file = "numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827"}, + {file = "numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a"}, + {file = "numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec"}, + {file = "numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50"}, + {file = "numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115"}, + {file = "numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af"}, + {file = "numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c"}, + {file = "numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103"}, + {file = "numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83"}, + {file = "numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed"}, + {file = "numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959"}, + {file = "numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed"}, + {file = "numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf"}, + {file = "numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d"}, + {file = "numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5"}, + {file = "numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7"}, + {file = "numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93"}, + {file = "numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e"}, + {file = "numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40"}, + {file = "numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e"}, + {file = "numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392"}, + {file = "numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008"}, + {file = "numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8"}, + {file = "numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233"}, + {file = "numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0"}, + {file = "numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a"}, + {file = "numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a"}, + {file = "numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b"}, + {file = "numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a"}, + {file = "numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d"}, + {file = "numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252"}, + {file = "numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f"}, + {file = "numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc"}, + {file = "numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74"}, + {file = "numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb"}, + {file = "numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e"}, + {file = "numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113"}, + {file = "numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d"}, + {file = "numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d"}, + {file = "numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f"}, + {file = "numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0"}, + {file = "numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150"}, + {file = "numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871"}, + {file = "numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e"}, + {file = "numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7"}, + {file = "numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4"}, + {file = "numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e"}, + {file = "numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c"}, + {file = "numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3"}, + {file = "numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7"}, + {file = "numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f"}, + {file = "numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119"}, + {file = "numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0"}, +] + +[[package]] +name = "packaging" +version = "26.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, + {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, +] + +[[package]] +name = "pandas" +version = "2.3.3" +description = "Powerful data structures for data analysis, time series, and statistics" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c"}, + {file = "pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a"}, + {file = "pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1"}, + {file = "pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838"}, + {file = "pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250"}, + {file = "pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4"}, + {file = "pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826"}, + {file = "pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523"}, + {file = "pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45"}, + {file = "pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66"}, + {file = "pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b"}, + {file = "pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791"}, + {file = "pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151"}, + {file = "pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c"}, + {file = "pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53"}, + {file = "pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35"}, + {file = "pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908"}, + {file = "pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89"}, + {file = "pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98"}, + {file = "pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084"}, + {file = "pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b"}, + {file = "pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713"}, + {file = "pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8"}, + {file = "pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d"}, + {file = "pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac"}, + {file = "pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c"}, + {file = "pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493"}, + {file = "pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee"}, + {file = "pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5"}, + {file = "pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21"}, + {file = "pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78"}, + {file = "pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110"}, + {file = "pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86"}, + {file = "pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc"}, + {file = "pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0"}, + {file = "pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593"}, + {file = "pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c"}, + {file = "pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b"}, + {file = "pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6"}, + {file = "pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3"}, + {file = "pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5"}, + {file = "pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec"}, + {file = "pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7"}, + {file = "pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450"}, + {file = "pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5"}, + {file = "pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788"}, + {file = "pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87"}, + {file = "pandas-2.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c503ba5216814e295f40711470446bc3fd00f0faea8a086cbc688808e26f92a2"}, + {file = "pandas-2.3.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a637c5cdfa04b6d6e2ecedcb81fc52ffb0fd78ce2ebccc9ea964df9f658de8c8"}, + {file = "pandas-2.3.3-cp39-cp39-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:854d00d556406bffe66a4c0802f334c9ad5a96b4f1f868adf036a21b11ef13ff"}, + {file = "pandas-2.3.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf1f8a81d04ca90e32a0aceb819d34dbd378a98bf923b6398b9a3ec0bf44de29"}, + {file = "pandas-2.3.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:23ebd657a4d38268c7dfbdf089fbc31ea709d82e4923c5ffd4fbd5747133ce73"}, + {file = "pandas-2.3.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5554c929ccc317d41a5e3d1234f3be588248e61f08a74dd17c9eabb535777dc9"}, + {file = "pandas-2.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:d3e28b3e83862ccf4d85ff19cf8c20b2ae7e503881711ff2d534dc8f761131aa"}, + {file = "pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b"}, +] + +[package.dependencies] +numpy = {version = ">=1.26.0", markers = "python_version >= \"3.12\""} +python-dateutil = ">=2.8.2" +pytz = ">=2020.1" +tzdata = ">=2022.7" + +[package.extras] +all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)", "beautifulsoup4 (>=4.11.2)", "bottleneck (>=1.3.6)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=2022.12.0)", "fsspec (>=2022.11.0)", "gcsfs (>=2022.11.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.9.2)", "matplotlib (>=3.6.3)", "numba (>=0.56.4)", "numexpr (>=2.8.4)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "pandas-gbq (>=0.19.0)", "psycopg2 (>=2.9.6)", "pyarrow (>=10.0.1)", "pymysql (>=1.0.2)", "pyreadstat (>=1.2.0)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "qtpy (>=2.3.0)", "s3fs (>=2022.11.0)", "scipy (>=1.10.0)", "tables (>=3.8.0)", "tabulate (>=0.9.0)", "xarray (>=2022.12.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)", "zstandard (>=0.19.0)"] +aws = ["s3fs (>=2022.11.0)"] +clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.3.0)"] +compression = ["zstandard (>=0.19.0)"] +computation = ["scipy (>=1.10.0)", "xarray (>=2022.12.0)"] +consortium-standard = ["dataframe-api-compat (>=0.1.7)"] +excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)"] +feather = ["pyarrow (>=10.0.1)"] +fss = ["fsspec (>=2022.11.0)"] +gcp = ["gcsfs (>=2022.11.0)", "pandas-gbq (>=0.19.0)"] +hdf5 = ["tables (>=3.8.0)"] +html = ["beautifulsoup4 (>=4.11.2)", "html5lib (>=1.1)", "lxml (>=4.9.2)"] +mysql = ["SQLAlchemy (>=2.0.0)", "pymysql (>=1.0.2)"] +output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.9.0)"] +parquet = ["pyarrow (>=10.0.1)"] +performance = ["bottleneck (>=1.3.6)", "numba (>=0.56.4)", "numexpr (>=2.8.4)"] +plot = ["matplotlib (>=3.6.3)"] +postgresql = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "psycopg2 (>=2.9.6)"] +pyarrow = ["pyarrow (>=10.0.1)"] +spss = ["pyreadstat (>=1.2.0)"] +sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)"] +test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] +xml = ["lxml (>=4.9.2)"] + +[[package]] +name = "pathspec" +version = "1.1.0" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pathspec-1.1.0-py3-none-any.whl", hash = "sha256:574b128f7456bd899045ccd142dd446af7e6cfd0072d63ad73fbc55fbb4aaa42"}, + {file = "pathspec-1.1.0.tar.gz", hash = "sha256:f5d7c555da02fd8dde3e4a2354b6aba817a89112fa8f333f7917a2a4834dd080"}, +] + +[package.extras] +hyperscan = ["hyperscan (>=0.7)"] +optional = ["typing-extensions (>=4)"] +re2 = ["google-re2 (>=1.1)"] + +[[package]] +name = "platformdirs" +version = "4.9.6" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917"}, + {file = "platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "prometheus-client" +version = "0.25.0" +description = "Python client for the Prometheus monitoring system." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1"}, + {file = "prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28"}, +] + +[package.extras] +aiohttp = ["aiohttp"] +django = ["django"] +twisted = ["twisted"] + +[[package]] +name = "prometheus-fastapi-instrumentator" +version = "7.1.0" +description = "Instrument your FastAPI app with Prometheus metrics" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "prometheus_fastapi_instrumentator-7.1.0-py3-none-any.whl", hash = "sha256:978130f3c0bb7b8ebcc90d35516a6fe13e02d2eb358c8f83887cdef7020c31e9"}, + {file = "prometheus_fastapi_instrumentator-7.1.0.tar.gz", hash = "sha256:be7cd61eeea4e5912aeccb4261c6631b3f227d8924542d79eaf5af3f439cbe5e"}, +] + +[package.dependencies] +prometheus-client = ">=0.8.0,<1.0.0" +starlette = ">=0.30.0,<1.0.0" + +[[package]] +name = "pydantic" +version = "2.13.3" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927"}, + {file = "pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.46.3" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.46.3" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.46.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:1da3786b8018e60349680720158cc19161cc3b4bdd815beb0a321cd5ce1ad5b1"}, + {file = "pydantic_core-2.46.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cc0988cb29d21bf4a9d5cf2ef970b5c0e38d8d8e107a493278c05dc6c1dda69f"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f9067c3bfadd04c55484b89c0d267981b2f3512850f6f66e1e74204a4e4ce3"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a642ac886ecf6402d9882d10c405dcf4b902abeb2972cd5fb4a48c83cd59279a"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79f561438481f28681584b89e2effb22855e2179880314bcddbf5968e935e807"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57a973eae4665352a47cf1a99b4ee864620f2fe663a217d7a8da68a1f3a5bfda"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83d002b97072a53ea150d63e0a3adfae5670cef5aa8a6e490240e482d3b22e57"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b40ddd51e7c44b28cfaef746c9d3c506d658885e0a46f9eeef2ee815cbf8e045"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ac5ec7fb9b87f04ee839af2d53bcadea57ded7d229719f56c0ed895bff987943"}, + {file = "pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a3b11c812f61b3129c4905781a2601dfdfdea5fe1e6c1cfb696b55d14e9c054f"}, + {file = "pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1108da631e602e5b3c38d6d04fe5bb3bfa54349e6918e3ca6cf570b2e2b2f9d4"}, + {file = "pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:de885175515bcfa98ae618c1df7a072f13d179f81376c8007112af20567fd08a"}, + {file = "pydantic_core-2.46.3-cp310-cp310-win32.whl", hash = "sha256:d11058e3201527d41bc6b545c79187c9e4bf85e15a236a6007f0e991518882b7"}, + {file = "pydantic_core-2.46.3-cp310-cp310-win_amd64.whl", hash = "sha256:3612edf65c8ea67ac13616c4d23af12faef1ae435a8a93e5934c2a0cbbdd1fd6"}, + {file = "pydantic_core-2.46.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5"}, + {file = "pydantic_core-2.46.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c"}, + {file = "pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf"}, + {file = "pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b"}, + {file = "pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e"}, + {file = "pydantic_core-2.46.3-cp311-cp311-win32.whl", hash = "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb"}, + {file = "pydantic_core-2.46.3-cp311-cp311-win_amd64.whl", hash = "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346"}, + {file = "pydantic_core-2.46.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6"}, + {file = "pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67"}, + {file = "pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d"}, + {file = "pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca"}, + {file = "pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976"}, + {file = "pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b"}, + {file = "pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4"}, + {file = "pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1"}, + {file = "pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72"}, + {file = "pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37"}, + {file = "pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3"}, + {file = "pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022"}, + {file = "pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23"}, + {file = "pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7"}, + {file = "pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13"}, + {file = "pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0"}, + {file = "pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec"}, + {file = "pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b"}, + {file = "pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f"}, + {file = "pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127"}, + {file = "pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c"}, + {file = "pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1"}, + {file = "pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505"}, + {file = "pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e"}, + {file = "pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374"}, + {file = "pydantic_core-2.46.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:fa3eb7c2995aa443687a825bc30395c8521b7c6ec201966e55debfd1128bcceb"}, + {file = "pydantic_core-2.46.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3d08782c4045f90724b44c95d35ebec0d67edb8a957a2ac81d5a8e4b8a200495"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:831eb19aa789a97356979e94c981e5667759301fb708d1c0d5adf1bc0098b873"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4335e87c7afa436a0dfa899e138d57a72f8aad542e2cf19c36fb428461caabd0"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99421e7684a60f7f3550a1d159ade5fdff1954baedb6bdd407cba6a307c9f27d"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd81f6907932ebac3abbe41378dac64b2380db1287e2aa64d8d88f78d170f51a"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f247596366f4221af52beddd65af1218797771d6989bc891a0b86ccaa019168"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:6dff8cc884679df229ebc6d8eb2321ea6f8e091bc7d4886d4dc2e0e71452843c"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68ef2f623dda6d5a9067ac014e406c020c780b2a358930a7e5c1b73702900720"}, + {file = "pydantic_core-2.46.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d56bdb4af1767cc15b0386b3c581fdfe659bb9ee4a4f776e92c1cd9d074000d6"}, + {file = "pydantic_core-2.46.3-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:91249bcb7c165c2fb2a2f852dbc5c91636e2e218e75d96dfdd517e4078e173dd"}, + {file = "pydantic_core-2.46.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4b068543bdb707f5d935dab765d99227aa2545ef2820935f2e5dd801795c7dbd"}, + {file = "pydantic_core-2.46.3-cp39-cp39-win32.whl", hash = "sha256:dcda6583921c05a40533f982321532f2d8db29326c7b95c4026941fa5074bd79"}, + {file = "pydantic_core-2.46.3-cp39-cp39-win_amd64.whl", hash = "sha256:a35cc284c8dd7edae8a31533713b4d2467dfe7c4f1b5587dd4031f28f90d1d13"}, + {file = "pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:9715525891ed524a0a1eb6d053c74d4d4ad5017677fb00af0b7c2644a31bae46"}, + {file = "pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:9d2f400712a99a013aff420ef1eb9be077f8189a36c1e3ef87660b4e1088a874"}, + {file = "pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2aab0e2e9dc2daf36bd2686c982535d5e7b1d930a1344a7bb6e82baab42a76"}, + {file = "pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e9d76736da5f362fabfeea6a69b13b7f2be405c6d6966f06b2f6bfff7e64531"}, + {file = "pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803"}, + {file = "pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3"}, + {file = "pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5"}, + {file = "pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff"}, + {file = "pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c"}, +] + +[package.dependencies] +typing-extensions = ">=4.14.1" + +[[package]] +name = "pygments" +version = "2.20.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "8.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.23.8" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"}, + {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"}, +] + +[package.dependencies] +pytest = ">=7.0.0,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pytz" +version = "2026.1.post1" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a"}, + {file = "pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1"}, +] + +[[package]] +name = "respx" +version = "0.21.1" +description = "A utility for mocking out the Python HTTPX and HTTP Core libraries." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "respx-0.21.1-py2.py3-none-any.whl", hash = "sha256:05f45de23f0c785862a2c92a3e173916e8ca88e4caad715dd5f68584d6053c20"}, + {file = "respx-0.21.1.tar.gz", hash = "sha256:0bd7fe21bfaa52106caa1223ce61224cf30786985f17c63c5d71eff0307ee8af"}, +] + +[package.dependencies] +httpx = ">=0.21.0" + +[[package]] +name = "ruff" +version = "0.6.9" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.6.9-py3-none-linux_armv6l.whl", hash = "sha256:064df58d84ccc0ac0fcd63bc3090b251d90e2a372558c0f057c3f75ed73e1ccd"}, + {file = "ruff-0.6.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:140d4b5c9f5fc7a7b074908a78ab8d384dd7f6510402267bc76c37195c02a7ec"}, + {file = "ruff-0.6.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53fd8ca5e82bdee8da7f506d7b03a261f24cd43d090ea9db9a1dc59d9313914c"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645d7d8761f915e48a00d4ecc3686969761df69fb561dd914a773c1a8266e14e"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eae02b700763e3847595b9d2891488989cac00214da7f845f4bcf2989007d577"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d5ccc9e58112441de8ad4b29dcb7a86dc25c5f770e3c06a9d57e0e5eba48829"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:417b81aa1c9b60b2f8edc463c58363075412866ae4e2b9ab0f690dc1e87ac1b5"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c866b631f5fbce896a74a6e4383407ba7507b815ccc52bcedabb6810fdb3ef7"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b118afbb3202f5911486ad52da86d1d52305b59e7ef2031cea3425142b97d6f"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67267654edc23c97335586774790cde402fb6bbdb3c2314f1fc087dee320bfa"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3ef0cc774b00fec123f635ce5c547dac263f6ee9fb9cc83437c5904183b55ceb"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:12edd2af0c60fa61ff31cefb90aef4288ac4d372b4962c2864aeea3a1a2460c0"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:55bb01caeaf3a60b2b2bba07308a02fca6ab56233302406ed5245180a05c5625"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:925d26471fa24b0ce5a6cdfab1bb526fb4159952385f386bdcc643813d472039"}, + {file = "ruff-0.6.9-py3-none-win32.whl", hash = "sha256:eb61ec9bdb2506cffd492e05ac40e5bc6284873aceb605503d8494180d6fc84d"}, + {file = "ruff-0.6.9-py3-none-win_amd64.whl", hash = "sha256:785d31851c1ae91f45b3d8fe23b8ae4b5170089021fbb42402d811135f0b7117"}, + {file = "ruff-0.6.9-py3-none-win_arm64.whl", hash = "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93"}, + {file = "ruff-0.6.9.tar.gz", hash = "sha256:b076ef717a8e5bc819514ee1d602bbdca5b4420ae13a9cf61a0c0a4f53a2baa2"}, +] + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.49" +description = "Database Abstraction Library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sqlalchemy-2.0.49-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:42e8804962f9e6f4be2cbaedc0c3718f08f60a16910fa3d86da5a1e3b1bfe60f"}, + {file = "sqlalchemy-2.0.49-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc992c6ed024c8c3c592c5fc9846a03dd68a425674900c70122c77ea16c5fb0b"}, + {file = "sqlalchemy-2.0.49-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eb188b84269f357669b62cb576b5b918de10fb7c728a005fa0ebb0b758adce1"}, + {file = "sqlalchemy-2.0.49-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:62557958002b69699bdb7f5137c6714ca1133f045f97b3903964f47db97ea339"}, + {file = "sqlalchemy-2.0.49-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da9b91bca419dc9b9267ffadde24eae9b1a6bffcd09d0a207e5e3af99a03ce0d"}, + {file = "sqlalchemy-2.0.49-cp310-cp310-win32.whl", hash = "sha256:5e61abbec255be7b122aa461021daa7c3f310f3e743411a67079f9b3cc91ece3"}, + {file = "sqlalchemy-2.0.49-cp310-cp310-win_amd64.whl", hash = "sha256:0c98c59075b890df8abfcc6ad632879540f5791c68baebacb4f833713b510e75"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-win32.whl", hash = "sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-win_amd64.whl", hash = "sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba"}, + {file = "sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e"}, + {file = "sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a"}, + {file = "sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066"}, + {file = "sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187"}, + {file = "sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401"}, + {file = "sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5"}, + {file = "sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5"}, + {file = "sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977"}, + {file = "sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01"}, + {file = "sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61"}, + {file = "sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a"}, + {file = "sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158"}, + {file = "sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7"}, + {file = "sqlalchemy-2.0.49-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8a97ac839c2c6672c4865e48f3cbad7152cee85f4233fb4ca6291d775b9b954a"}, + {file = "sqlalchemy-2.0.49-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c338ec6ec01c0bc8e735c58b9f5d51e75bacb6ff23296658826d7cfdfdb8678a"}, + {file = "sqlalchemy-2.0.49-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:566df36fd0e901625523a5a1835032f1ebdd7f7886c54584143fa6c668b4df3b"}, + {file = "sqlalchemy-2.0.49-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d99945830a6f3e9638d89a28ed130b1eb24c91255e4f24366fbe699b983f29e4"}, + {file = "sqlalchemy-2.0.49-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:01146546d84185f12721a1d2ce0c6673451a7894d1460b592d378ca4871a0c72"}, + {file = "sqlalchemy-2.0.49-cp38-cp38-win32.whl", hash = "sha256:69469ce8ce7a8df4d37620e3163b71238719e1e2e5048d114a1b6ce0fbf8c662"}, + {file = "sqlalchemy-2.0.49-cp38-cp38-win_amd64.whl", hash = "sha256:b95b2f470c1b2683febd2e7eab1d3f0e078c91dbdd0b00e9c645d07a413bb99f"}, + {file = "sqlalchemy-2.0.49-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:43d044780732d9e0381ac8d5316f95d7f02ef04d6e4ef6dc82379f09795d993f"}, + {file = "sqlalchemy-2.0.49-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d6be30b2a75362325176c036d7fb8d19e8846c77e87683ffaa8177b35135613"}, + {file = "sqlalchemy-2.0.49-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d898cc2c76c135ef65517f4ddd7a3512fb41f23087b0650efb3418b8389a3cd1"}, + {file = "sqlalchemy-2.0.49-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:059d7151fff513c53a4638da8778be7fce81a0c4854c7348ebd0c4078ddf28fe"}, + {file = "sqlalchemy-2.0.49-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:334edbcff10514ad1d66e3a70b339c0a29886394892490119dbb669627b17717"}, + {file = "sqlalchemy-2.0.49-cp39-cp39-win32.whl", hash = "sha256:74ab4ee7794d7ed1b0c37e7333640e0f0a626fc7b398c07a7aef52f484fddde3"}, + {file = "sqlalchemy-2.0.49-cp39-cp39-win_amd64.whl", hash = "sha256:88690f4e1f0fbf5339bedbb127e240fec1fd3070e9934c0b7bef83432f779d2f"}, + {file = "sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0"}, + {file = "sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f"}, +] + +[package.dependencies] +greenlet = {version = ">=1", optional = true, markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\" or extra == \"asyncio\""} +typing-extensions = ">=4.6.0" + +[package.extras] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"] +aioodbc = ["aioodbc", "greenlet (>=1)"] +aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (>=1)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=1.0.1)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"] +postgresql-pg8000 = ["pg8000 (>=1.29.1)"] +postgresql-psycopg = ["psycopg (>=3.0.7)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +pymysql = ["pymysql"] +sqlcipher = ["sqlcipher3_binary"] + +[[package]] +name = "starlette" +version = "0.46.2" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35"}, + {file = "starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5"}, +] + +[package.dependencies] +anyio = ">=3.6.2,<5" + +[package.extras] +full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + +[[package]] +name = "tzdata" +version = "2026.2" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["main"] +files = [ + {file = "tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7"}, + {file = "tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10"}, +] + +[[package]] +name = "uvicorn" +version = "0.32.1" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "uvicorn-0.32.1-py3-none-any.whl", hash = "sha256:82ad92fd58da0d12af7482ecdb5f2470a04c9c9a53ced65b9bbb4a205377602e"}, + {file = "uvicorn-0.32.1.tar.gz", hash = "sha256:ee9519c246a72b1c084cea8d3b44ed6026e78a4a309cbedae9c37e4cb9fbb175"}, +] + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" + +[package.extras] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[[package]] +name = "yapf" +version = "0.43.0" +description = "A formatter for Python code" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca"}, + {file = "yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e"}, +] + +[package.dependencies] +platformdirs = ">=3.5.1" + +[metadata] +lock-version = "2.1" +python-versions = ">=3.12,<3.13" +content-hash = "b3e156b729b5bc78ff4913a8e9eeaf63eff599e0b69ab4895277a685a8d25d7d" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bf9466d --- /dev/null +++ b/pyproject.toml @@ -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 "] +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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0a3250d --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..b5cbab6 --- /dev/null +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_db_schema.py b/tests/test_db_schema.py new file mode 100644 index 0000000..fcbe980 --- /dev/null +++ b/tests/test_db_schema.py @@ -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() diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..2ee40eb --- /dev/null +++ b/tests/test_e2e.py @@ -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 diff --git a/tests/test_ingest_wealthfolio.py b/tests/test_ingest_wealthfolio.py new file mode 100644 index 0000000..23ab429 --- /dev/null +++ b/tests/test_ingest_wealthfolio.py @@ -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 diff --git a/tests/test_reporters_pg.py b/tests/test_reporters_pg.py new file mode 100644 index 0000000..4a326a0 --- /dev/null +++ b/tests/test_reporters_pg.py @@ -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 diff --git a/tests/test_returns.py b/tests/test_returns.py new file mode 100644 index 0000000..13af43a --- /dev/null +++ b/tests/test_returns.py @@ -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) diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py new file mode 100644 index 0000000..ceeb88c --- /dev/null +++ b/tests/test_scenarios.py @@ -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", ), + ) diff --git a/tests/test_simulator.py b/tests/test_simulator.py new file mode 100644 index 0000000..456c4a5 --- /dev/null +++ b/tests/test_simulator.py @@ -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 diff --git a/tests/test_strategies.py b/tests/test_strategies.py new file mode 100644 index 0000000..6edec74 --- /dev/null +++ b/tests/test_strategies.py @@ -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 diff --git a/tests/test_tax_base.py b/tests/test_tax_base.py new file mode 100644 index 0000000..a3ed283 --- /dev/null +++ b/tests/test_tax_base.py @@ -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 diff --git a/tests/test_tax_other_regimes.py b/tests/test_tax_other_regimes.py new file mode 100644 index 0000000..cc668dc --- /dev/null +++ b/tests/test_tax_other_regimes.py @@ -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 diff --git a/tests/test_tax_uk.py b/tests/test_tax_uk.py new file mode 100644 index 0000000..7b85d4b --- /dev/null +++ b/tests/test_tax_uk.py @@ -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