Initial extraction from monorepo

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

View file

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

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

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

View file

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

View file

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

View file

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

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

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

View file

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

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

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

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

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