From 8f4d322beec9340bfd2a41663c995de2af1a853d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:25:00 +0100 Subject: [PATCH 1/5] Add CGT parameters for BADR, residential property and carried interest schedules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gov.hmrc.cgt.badr.rate (10%, 14% from 2025-04-06, 18% from 2026-04-06) and .lifetime_limit (£10m, £1m from 2020-03-11); residential_property and carried_interest basic, higher and additional rates (18/28, residential higher 24% from 2024-04-06; carried interest a flat 32% from 2025-04-06). None of these changed on 30 October 2024, so none is blended across the fiscal year. The additional rates equal the higher rates in law and exist so reforms can mirror the income tax additional-rate band, as the main additional_rate does. Co-Authored-By: Claude Fable 5.1 --- .../parameters/gov/hmrc/cgt/README.md | 12 +++++++++ .../gov/hmrc/cgt/badr/lifetime_limit.yaml | 20 ++++++++++++++ .../parameters/gov/hmrc/cgt/badr/rate.yaml | 27 +++++++++++++++++++ .../cgt/carried_interest/additional_rate.yaml | 18 +++++++++++++ .../hmrc/cgt/carried_interest/basic_rate.yaml | 20 ++++++++++++++ .../cgt/carried_interest/higher_rate.yaml | 20 ++++++++++++++ .../residential_property/additional_rate.yaml | 18 +++++++++++++ .../cgt/residential_property/basic_rate.yaml | 15 +++++++++++ .../cgt/residential_property/higher_rate.yaml | 20 ++++++++++++++ 9 files changed, 170 insertions(+) create mode 100644 policyengine_uk/parameters/gov/hmrc/cgt/badr/lifetime_limit.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/cgt/badr/rate.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/cgt/carried_interest/additional_rate.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/cgt/carried_interest/basic_rate.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/cgt/carried_interest/higher_rate.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/cgt/residential_property/additional_rate.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/cgt/residential_property/basic_rate.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/cgt/residential_property/higher_rate.yaml diff --git a/policyengine_uk/parameters/gov/hmrc/cgt/README.md b/policyengine_uk/parameters/gov/hmrc/cgt/README.md index 76c28448f3..0f60cd1b6c 100644 --- a/policyengine_uk/parameters/gov/hmrc/cgt/README.md +++ b/policyengine_uk/parameters/gov/hmrc/cgt/README.md @@ -1 +1,13 @@ # Capital Gains Tax + +The main rates (`basic_rate`, `higher_rate`, `additional_rate`) apply to gains other than those below; their 2024-25 +values are day-weighted across the 30 October 2024 change. `annual_exempt_amount` is deducted once across all of a +person's gains, in the order most beneficial to the taxpayer (TCGA 1992 s. 1K(5)). + +- `badr/`: rate and lifetime limit for gains qualifying for Business Asset Disposal Relief or Investors' Relief + (TCGA 1992 s. 169N). These gains use the unused basic rate band before any other gains (TCGA 1992 s. 1I(4)-(6)). +- `residential_property/`: rates on UK residential property gains (TCGA 1992 ss. 1H-1I). +- `carried_interest/`: rates on carried interest gains (TCGA 1992 s. 1H; a flat 32% from 6 April 2025). + +The `additional_rate` files in the two schedule folders equal the higher rate in law and exist only so reforms can +mirror the income tax additional-rate band, as the main `additional_rate` does. diff --git a/policyengine_uk/parameters/gov/hmrc/cgt/badr/lifetime_limit.yaml b/policyengine_uk/parameters/gov/hmrc/cgt/badr/lifetime_limit.yaml new file mode 100644 index 0000000000..d144207e03 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/cgt/badr/lifetime_limit.yaml @@ -0,0 +1,20 @@ +description: Lifetime limit on gains qualifying for Business Asset Disposal Relief. The model applies it to the year's qualifying gains as if none of the limit had been used in earlier years, and charges gains above it at the main rates. Investors' Relief kept a separate £10 million limit until 30 October 2024 (Finance Act 2025 s. 10); the merged capital_gains_badr input does not distinguish the two reliefs, so the BADR limit applies to both. +values: + 2015-01-01: + value: 10_000_000 + reference: + - title: Taxation of Chargeable Gains Act 1992 s. 169N(4) + href: https://www.legislation.gov.uk/ukpga/1992/12/section/169N + 2020-03-11: + value: 1_000_000 + reference: + - title: Finance Act 2020 s. 23 and Sch. 3 paras 1-2 + href: https://www.legislation.gov.uk/ukpga/2020/14/schedule/3 +metadata: + unit: currency-GBP + label: BADR lifetime limit + reference: + - title: Taxation of Chargeable Gains Act 1992 s. 169N(4) + href: https://www.legislation.gov.uk/ukpga/1992/12/section/169N + - title: Finance Act 2025 s. 10 (Investors' Relief limit) + href: https://www.legislation.gov.uk/ukpga/2025/8/section/10 diff --git a/policyengine_uk/parameters/gov/hmrc/cgt/badr/rate.yaml b/policyengine_uk/parameters/gov/hmrc/cgt/badr/rate.yaml new file mode 100644 index 0000000000..19e412259c --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/cgt/badr/rate.yaml @@ -0,0 +1,27 @@ +description: Capital gains tax rate on gains qualifying for Business Asset Disposal Relief or Investors' Relief, charged on qualifying gains up to the lifetime limit whatever the taxpayer's income tax band. The rate rose from 10% to 14% for disposals from 6 April 2025 and to 18% from 6 April 2026; it did not change on 30 October 2024, so the fiscal-year value is not blended. +values: + 2015-01-01: + value: 0.1 + reference: + - title: Taxation of Chargeable Gains Act 1992 s. 169N(3) + href: https://www.legislation.gov.uk/ukpga/1992/12/section/169N + - title: GOV.UK | Capital Gains Tax rates and allowances + href: https://www.gov.uk/guidance/capital-gains-tax-rates-and-allowances + 2025-04-06: + value: 0.14 + reference: + - title: Finance Act 2025 s. 8(1)-(3) + href: https://www.legislation.gov.uk/ukpga/2025/8/section/8 + 2026-04-06: + value: 0.18 + reference: + - title: Finance Act 2025 s. 8(4)-(6) + href: https://www.legislation.gov.uk/ukpga/2025/8/section/8 +metadata: + unit: /1 + label: BADR rate + reference: + - title: Taxation of Chargeable Gains Act 1992 s. 169N(3) + href: https://www.legislation.gov.uk/ukpga/1992/12/section/169N + - title: Finance Act 2025 s. 8 + href: https://www.legislation.gov.uk/ukpga/2025/8/section/8 diff --git a/policyengine_uk/parameters/gov/hmrc/cgt/carried_interest/additional_rate.yaml b/policyengine_uk/parameters/gov/hmrc/cgt/carried_interest/additional_rate.yaml new file mode 100644 index 0000000000..58c3b266b9 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/cgt/carried_interest/additional_rate.yaml @@ -0,0 +1,18 @@ +description: Capital gains tax rate on carried interest gains that the model places above the income tax higher rate limit. Capital gains tax has no separate additional rate, so this equals carried_interest.higher_rate at every date; the parameter exists so that a reform can mirror the income tax additional-rate band on carried interest in the same way gov.hmrc.cgt.additional_rate allows for other gains. +values: + 2015-01-01: + value: 0.28 + reference: + - title: Finance Act 2016 s. 83 + href: https://www.legislation.gov.uk/ukpga/2016/24/section/83 + 2025-04-06: + value: 0.32 + reference: + - title: Finance Act 2025 s. 12 + href: https://www.legislation.gov.uk/ukpga/2025/8/section/12 +metadata: + unit: /1 + label: carried interest CGT additional rate + reference: + - title: Taxation of Chargeable Gains Act 1992 s. 1H(4B) + href: https://www.legislation.gov.uk/ukpga/1992/12/section/1H diff --git a/policyengine_uk/parameters/gov/hmrc/cgt/carried_interest/basic_rate.yaml b/policyengine_uk/parameters/gov/hmrc/cgt/carried_interest/basic_rate.yaml new file mode 100644 index 0000000000..a0d09f540c --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/cgt/carried_interest/basic_rate.yaml @@ -0,0 +1,20 @@ +description: Capital gains tax rate on carried interest gains that fall within the taxpayer's unused basic rate band. Finance Act 2016 s. 83 kept carried interest at 18% when the main basic rate fell to 10%. Finance Act 2025 s. 12 charges all carried interest arising from 6 April 2025 at a flat 32% outside the basic rate band, so from that date this equals the higher rate. Carried interest moves into the income tax framework from 6 April 2026, which the model does not represent; the 32% rate stands in for it. +values: + 2015-01-01: + value: 0.18 + reference: + - title: Finance Act 2016 s. 83 + href: https://www.legislation.gov.uk/ukpga/2016/24/section/83 + 2025-04-06: + value: 0.32 + reference: + - title: Finance Act 2025 s. 12 + href: https://www.legislation.gov.uk/ukpga/2025/8/section/12 +metadata: + unit: /1 + label: carried interest CGT basic rate + reference: + - title: Taxation of Chargeable Gains Act 1992 s. 1H + href: https://www.legislation.gov.uk/ukpga/1992/12/section/1H + - title: GOV.UK | Capital Gains Tax rates and allowances + href: https://www.gov.uk/guidance/capital-gains-tax-rates-and-allowances diff --git a/policyengine_uk/parameters/gov/hmrc/cgt/carried_interest/higher_rate.yaml b/policyengine_uk/parameters/gov/hmrc/cgt/carried_interest/higher_rate.yaml new file mode 100644 index 0000000000..194dc7e721 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/cgt/carried_interest/higher_rate.yaml @@ -0,0 +1,20 @@ +description: Capital gains tax rate on carried interest gains of higher or additional rate taxpayers, and on gains above the unused basic rate band. Finance Act 2016 s. 83 kept carried interest at 28% when the main higher rate fell to 20%, and the 30 October 2024 rise did not touch it. Finance Act 2025 s. 12 charges all carried interest arising from 6 April 2025 at a flat 32%. Carried interest moves into the income tax framework from 6 April 2026, which the model does not represent; the 32% rate stands in for it. +values: + 2015-01-01: + value: 0.28 + reference: + - title: Finance Act 2016 s. 83 + href: https://www.legislation.gov.uk/ukpga/2016/24/section/83 + 2025-04-06: + value: 0.32 + reference: + - title: Finance Act 2025 s. 12 + href: https://www.legislation.gov.uk/ukpga/2025/8/section/12 +metadata: + unit: /1 + label: carried interest CGT higher rate + reference: + - title: Taxation of Chargeable Gains Act 1992 s. 1H(4B) + href: https://www.legislation.gov.uk/ukpga/1992/12/section/1H + - title: GOV.UK | Capital Gains Tax rates and allowances + href: https://www.gov.uk/guidance/capital-gains-tax-rates-and-allowances diff --git a/policyengine_uk/parameters/gov/hmrc/cgt/residential_property/additional_rate.yaml b/policyengine_uk/parameters/gov/hmrc/cgt/residential_property/additional_rate.yaml new file mode 100644 index 0000000000..07de310b7c --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/cgt/residential_property/additional_rate.yaml @@ -0,0 +1,18 @@ +description: Capital gains tax rate on UK residential property gains that the model places above the income tax higher rate limit. Capital gains tax has no separate additional rate, so this equals residential_property.higher_rate at every date; the parameter exists so that a reform can mirror the income tax additional-rate band on residential property gains in the same way gov.hmrc.cgt.additional_rate allows for other gains. +values: + 2015-01-01: + value: 0.28 + reference: + - title: Finance Act 2016 s. 83 + href: https://www.legislation.gov.uk/ukpga/2016/24/section/83 + 2024-04-06: + value: 0.24 + reference: + - title: Finance (No. 2) Act 2024 s. 6 + href: https://www.legislation.gov.uk/ukpga/2024/12/section/6 +metadata: + unit: /1 + label: residential property CGT additional rate + reference: + - title: Taxation of Chargeable Gains Act 1992 s. 1I + href: https://www.legislation.gov.uk/ukpga/1992/12/section/1I diff --git a/policyengine_uk/parameters/gov/hmrc/cgt/residential_property/basic_rate.yaml b/policyengine_uk/parameters/gov/hmrc/cgt/residential_property/basic_rate.yaml new file mode 100644 index 0000000000..e5a6eb3178 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/cgt/residential_property/basic_rate.yaml @@ -0,0 +1,15 @@ +description: Capital gains tax rate on UK residential property gains that fall within the taxpayer's unused basic rate band. Finance Act 2016 s. 83 kept residential property at 18% when the main basic rate fell to 10%, and the 30 October 2024 main-rate rise did not touch it, so the fiscal-year value is not blended. +values: + 2015-01-01: + value: 0.18 + reference: + - title: Finance Act 2016 s. 83 + href: https://www.legislation.gov.uk/ukpga/2016/24/section/83 +metadata: + unit: /1 + label: residential property CGT basic rate + reference: + - title: Taxation of Chargeable Gains Act 1992 s. 1H + href: https://www.legislation.gov.uk/ukpga/1992/12/section/1H + - title: GOV.UK | Capital Gains Tax rates and allowances + href: https://www.gov.uk/guidance/capital-gains-tax-rates-and-allowances diff --git a/policyengine_uk/parameters/gov/hmrc/cgt/residential_property/higher_rate.yaml b/policyengine_uk/parameters/gov/hmrc/cgt/residential_property/higher_rate.yaml new file mode 100644 index 0000000000..1b138e9e85 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/cgt/residential_property/higher_rate.yaml @@ -0,0 +1,20 @@ +description: Capital gains tax rate on UK residential property gains of higher or additional rate taxpayers, and on gains above the unused basic rate band. Finance Act 2016 s. 83 kept residential property at 28% when the main higher rate fell to 20%; Finance (No. 2) Act 2024 s. 6 cut it to 24% for disposals from 6 April 2024. It did not change on 30 October 2024, so the fiscal-year value is not blended. +values: + 2015-01-01: + value: 0.28 + reference: + - title: Finance Act 2016 s. 83 + href: https://www.legislation.gov.uk/ukpga/2016/24/section/83 + 2024-04-06: + value: 0.24 + reference: + - title: Finance (No. 2) Act 2024 s. 6 + href: https://www.legislation.gov.uk/ukpga/2024/12/section/6 +metadata: + unit: /1 + label: residential property CGT higher rate + reference: + - title: Taxation of Chargeable Gains Act 1992 s. 1I + href: https://www.legislation.gov.uk/ukpga/1992/12/section/1I + - title: GOV.UK | Capital Gains Tax rates and allowances + href: https://www.gov.uk/guidance/capital-gains-tax-rates-and-allowances From 4a5f7aa1056918b3d3ad6f09f02b7b30d5c9a83f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:25:00 +0100 Subject: [PATCH 2/5] Charge BADR, residential property and carried interest gains at their own CGT schedules Three person-level inputs, capital_gains_badr, capital_gains_residential_property and capital_gains_carried_interest, are components of capital_gains measured before any realisation response. capital_gains_tax scales them by realised over pre-response gains, clips them against the total, and charges the remainder at the main rates. The annual exempt amount goes to the schedule with the highest top rate first (TCGA 1992 s. 1K(5)); relief gains take the unused basic rate band before other gains (s. 1I(4)-(6)) and the rest goes to the schedule with the widest higher-minus-basic gap (s. 1I(7)-(9)). With the inputs at zero the result is bit for bit what the single-schedule formula produced. Co-Authored-By: Claude Fable 5.1 --- policyengine_uk/data/uprating_indices.yaml | 3 + .../capital_gains_tax/capital_gains_badr.py | 21 +++ .../capital_gains_carried_interest.py | 19 +++ .../capital_gains_residential_property.py | 19 +++ .../capital_gains_tax/capital_gains_tax.py | 146 ++++++++++++++++-- .../marginal_tax_rate_on_capital_gains.py | 6 +- 6 files changed, 197 insertions(+), 17 deletions(-) create mode 100644 policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_badr.py create mode 100644 policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_carried_interest.py create mode 100644 policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_residential_property.py diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index b2c39d35cf..1c2f83d85b 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -58,6 +58,9 @@ gov.economic_assumptions.yoy_growth.obr.mortgage_interest: gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - capital_gains - capital_gains_before_response +- capital_gains_badr +- capital_gains_residential_property +- capital_gains_carried_interest - corporate_wealth - dividend_income - gross_financial_wealth diff --git a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_badr.py b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_badr.py new file mode 100644 index 0000000000..3f9da28e02 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_badr.py @@ -0,0 +1,21 @@ +from policyengine_uk.model_api import * + + +class capital_gains_badr(Variable): + label = "capital gains qualifying for Business Asset Disposal Relief" + documentation = ( + "Gains qualifying for Business Asset Disposal Relief or Investors' " + "Relief. A component of capital_gains, measured before any behavioural " + "response, not an addition to it: a value entered without a matching " + "capital_gains total is ignored. capital_gains_tax charges these gains " + "at the relief rate up to the lifetime limit, applied to the year's gains " + "as if none of the limit had been used before, and charges the excess at " + "the main rates." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = FLOW + reference = "Taxation of Chargeable Gains Act 1992 Part 5 Ch. 3, s. 169N" + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" diff --git a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_carried_interest.py b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_carried_interest.py new file mode 100644 index 0000000000..0954b6fef1 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_carried_interest.py @@ -0,0 +1,19 @@ +from policyengine_uk.model_api import * + + +class capital_gains_carried_interest(Variable): + label = "capital gains from carried interest" + documentation = ( + "Carried interest gains chargeable to capital gains tax. A component of " + "capital_gains, measured before any behavioural response, not an " + "addition to it: a value entered without a matching capital_gains total " + "is ignored. capital_gains_tax charges these gains at the carried " + "interest rates, a flat 32% from 6 April 2025." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = FLOW + reference = "Taxation of Chargeable Gains Act 1992 ss. 1H, 103KA" + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" diff --git a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_residential_property.py b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_residential_property.py new file mode 100644 index 0000000000..a9a668d441 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_residential_property.py @@ -0,0 +1,19 @@ +from policyengine_uk.model_api import * + + +class capital_gains_residential_property(Variable): + label = "capital gains on residential property" + documentation = ( + "Net gains on disposals of UK residential property. A component of " + "capital_gains, measured before any behavioural response, not an " + "addition to it: a value entered without a matching capital_gains total " + "is ignored. capital_gains_tax charges these gains at the residential " + "property rates." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = FLOW + reference = "Taxation of Chargeable Gains Act 1992 ss. 1H-1I" + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" diff --git a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py index 16fd1b824a..32edf61da9 100644 --- a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py +++ b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_tax.py @@ -1,13 +1,42 @@ from policyengine_uk.model_api import * -from policyengine_core.holders import Holder + + +def _take(amount, band_left): + """Fill a rate band from one schedule's gains. + + Returns the part inside the band, the part above it and the band left for + the next schedule. The "above" expression subtracts the whole band, as the + single-schedule formula did, so a schedule that is the only one with gains + reproduces that formula bit for bit. + """ + inside = min_(amount, band_left) + above = max_(amount - band_left, 0) + return inside, above, band_left - inside class capital_gains_tax(Variable): label = "capital gains tax" + documentation = ( + "Capital gains tax on the person's gains, charged on four schedules: " + "gains qualifying for Business Asset Disposal Relief or Investors' " + "Relief (capital_gains_badr, a flat rate up to the lifetime limit), " + "residential property gains (capital_gains_residential_property), " + "carried interest (capital_gains_carried_interest) and the remainder of " + "capital_gains at the main rates. The three named inputs are components " + "of capital_gains, scaled by any realisation response, and are clipped so " + "that together they never exceed it. The annual exempt amount is set " + "against the schedule with the highest top rate first (TCGA 1992 " + "s. 1K(5)); the unused basic rate band goes to relief gains first, as " + "s. 1I(4)-(6) requires, and then to the schedule with the widest gap " + "between its higher and basic rates (s. 1I(7)-(9)). Gains above the " + "basic rate band are split at the income tax higher rate limit only so " + "that reforms can set an additional rate; the law has none." + ) entity = Person definition_period = YEAR value_type = float unit = GBP + reference = "Taxation of Chargeable Gains Act 1992 ss. 1H-1K, 169N" def formula(person, period, parameters): hmrc = parameters(period).gov.hmrc @@ -31,27 +60,112 @@ def formula(person, period, parameters): 0, person("adjusted_net_income", period) - allowances_for_cgt_income, ) - gains = max_(0, person("capital_gains", period)) + capital_gains = person("capital_gains", period) + gains = max_(0, capital_gains) aea = cgt.annual_exempt_amount - gains_less_aea = max_(0, gains - aea) basic_rate_limit = it.rates.uk.thresholds[1] + basic_rate_band_extension remaining_basic_rate_band = max_(basic_rate_limit - taxable_income, 0) + higher_rate_limit = it.rates.uk.thresholds[2] + basic_rate_band_extension + higher_rate_band = higher_rate_limit - basic_rate_limit - basic_rate_applicable_cg = min_(gains_less_aea, remaining_basic_rate_band) - higher_and_add_rate_applicable_cg = max_( - gains_less_aea - remaining_basic_rate_band, 0 + # The schedule inputs describe gains before any realisation response. + # Scale them by realised over pre-response gains so a response, or a + # marginal pound added to capital_gains directly, is shared across the + # schedules in proportion to their shares. Then clip each against what + # is left of the realised gains so inconsistent inputs cannot exceed + # the total; the remainder is charged at the main rates. + before_response = person("capital_gains_before_response", period) + has_before_response = before_response > 0 + ratio = where( + has_before_response, + capital_gains / where(has_before_response, before_response, 1), + 1, ) - higher_rate_limit = it.rates.uk.thresholds[2] + basic_rate_band_extension - higher_rate_applicable_cg = min_( - higher_and_add_rate_applicable_cg, - higher_rate_limit - basic_rate_limit, + + def component(variable): + return max_(0, person(variable, period) * ratio) + + badr = min_( + min_(component("capital_gains_badr"), cgt.badr.lifetime_limit), gains + ) + residential = min_( + component("capital_gains_residential_property"), gains - badr ) - add_rate_applicable_cg = max_( - higher_and_add_rate_applicable_cg - higher_rate_applicable_cg, 0 + carried = min_( + component("capital_gains_carried_interest"), + gains - badr - residential, ) + main = gains - badr - residential - carried - basic_rate_tax = basic_rate_applicable_cg * cgt.basic_rate - higher_rate_tax = higher_rate_applicable_cg * cgt.higher_rate - add_rate_tax = add_rate_applicable_cg * cgt.additional_rate + # Band schedules in declaration order, which also breaks ties below. + rates = { + "carried_interest": cgt.carried_interest, + "residential_property": cgt.residential_property, + "main": cgt, + } + amounts = { + "carried_interest": carried, + "residential_property": residential, + "main": main, + "badr": badr, + } + top_rate = {name: rates[name].higher_rate for name in rates} + top_rate["badr"] = cgt.badr.rate - return basic_rate_tax + higher_rate_tax + add_rate_tax + # Annual exempt amount, TCGA 1992 s. 1K(5): deducted in the way most + # beneficial to the taxpayer, which is against the highest top rate + # first. Relief gains carry the lowest rate and come last. + after_aea = {} + aea_left = aea + for name in sorted(amounts, key=lambda name: -top_rate[name]): + used = min_(amounts[name], aea_left) + after_aea[name] = amounts[name] - used + aea_left = aea_left - used + + # Basic rate band. Relief gains take it first, TCGA 1992 s. 1I(4)-(6). + # The taxpayer then allocates the rest, s. 1I(7)-(9), which saves most + # on the schedule with the widest higher-minus-basic gap. Carried + # interest from 2025-26 has no gap and comes last, matching its + # exclusion from the band by Finance Act 2025 s. 12. + basic_order = ["badr"] + sorted( + rates, + key=lambda name: -(rates[name].higher_rate - rates[name].basic_rate), + ) + inside_basic, above_basic = {}, {} + band_left = remaining_basic_rate_band + for name in basic_order: + inside_basic[name], above_basic[name], band_left = _take( + after_aea[name], band_left + ) + + # Gains above the basic rate band are split at the higher rate limit + # so reforms can charge an additional rate; in law the two rates are + # equal. Relief gains are the lowest part of the gains here too. + higher_order = ["badr"] + sorted( + rates, + key=lambda name: -(rates[name].additional_rate - rates[name].higher_rate), + ) + inside_higher, above_higher = {}, {} + band_left = higher_rate_band + for name in higher_order: + inside_higher[name], above_higher[name], band_left = _take( + above_basic[name], band_left + ) + + def schedule_tax(name): + schedule = rates[name] + return ( + inside_basic[name] * schedule.basic_rate + + inside_higher[name] * schedule.higher_rate + + above_higher[name] * schedule.additional_rate + ) + + # Relief gains are charged at their own flat rate whatever the band. + main_tax = schedule_tax("main") + badr_tax = after_aea["badr"] * cgt.badr.rate + return ( + main_tax + + badr_tax + + schedule_tax("residential_property") + + schedule_tax("carried_interest") + ) diff --git a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/marginal_tax_rate_on_capital_gains.py b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/marginal_tax_rate_on_capital_gains.py index e011505a39..6e0f5a1863 100644 --- a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/marginal_tax_rate_on_capital_gains.py +++ b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/marginal_tax_rate_on_capital_gains.py @@ -5,7 +5,11 @@ class marginal_tax_rate_on_capital_gains(Variable): label = "capital gains marginal tax rate" documentation = ( - "Percent of marginal capital gains that do not increase household net income." + "Percent of marginal capital gains that do not increase household net " + "income. The marginal gains are added to capital_gains itself, so they " + "are shared across the person's schedules (main rates, Business Asset " + "Disposal Relief, residential property, carried interest) in proportion " + "to their pre-response shares: this is a share-weighted marginal rate." ) entity = Person definition_period = YEAR From 69cd59397ec18e0257b97c5e403679e4c3b51b32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:25:00 +0100 Subject: [PATCH 3/5] Test the CGT schedules and document them YAML cases for each schedule and their interaction, a grid test that proves capital_gains_tax is bit-identical to the legacy formula when the schedule inputs are zero, response and marginal-rate plumbing tests, and parameter date tests. Baseline-policy and growth-factor docs and the changelog fragment. Co-Authored-By: Claude Fable 5.1 --- changelog.d/1858.added.md | 1 + docs/book/assumptions/growthfactors.md | 2 +- docs/book/policy/model-baseline.md | 6 +- .../capital_gains_tax_components.yaml | 205 ++++++++++++ .../test_capital_gains_tax_components.py | 314 ++++++++++++++++++ 5 files changed, 524 insertions(+), 4 deletions(-) create mode 100644 changelog.d/1858.added.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_components.yaml create mode 100644 policyengine_uk/tests/test_capital_gains_tax_components.py diff --git a/changelog.d/1858.added.md b/changelog.d/1858.added.md new file mode 100644 index 0000000000..8c42b71367 --- /dev/null +++ b/changelog.d/1858.added.md @@ -0,0 +1 @@ +- Add three person-level inputs, `capital_gains_badr`, `capital_gains_residential_property` and `capital_gains_carried_interest`, as components of `capital_gains`, and charge each at its own schedule in `capital_gains_tax`: new parameters `gov.hmrc.cgt.badr.rate` and `.lifetime_limit`, `gov.hmrc.cgt.residential_property.{basic,higher,additional}_rate` and `gov.hmrc.cgt.carried_interest.{basic,higher,additional}_rate`. The annual exempt amount goes to the highest-rate schedule first, relief gains take the unused basic rate band before other gains (TCGA 1992 s. 1I(4)-(6)), and the remaining band goes where it saves most. With the inputs absent or zero the liability is unchanged. diff --git a/docs/book/assumptions/growthfactors.md b/docs/book/assumptions/growthfactors.md index 6a0441da78..a6994f31ed 100644 --- a/docs/book/assumptions/growthfactors.md +++ b/docs/book/assumptions/growthfactors.md @@ -57,7 +57,7 @@ We derive these rates from OBR GDP growth and ONS population projections. |-------------|------|------|------|------|------|------|------|------| | Growth rate | 9.2% | 5.0% | 3.8% | 2.8% | 2.8% | 3.1% | 3.3% | 3.3% | -We use per capita GDP for these variables: `capital_gains`, `capital_gains_before_response`, `corporate_wealth`, `dividend_income`, `gross_financial_wealth`, `lump_sum_income`, `main_residence_value`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `net_financial_wealth`, `non_residential_property_value`, `other_investment_income`, `other_residential_property_value`, `owned_land`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `savings`, `savings_interest_income`, `sublet_income` +We use per capita GDP for these variables: `capital_gains`, `capital_gains_badr`, `capital_gains_before_response`, `capital_gains_carried_interest`, `capital_gains_residential_property`, `corporate_wealth`, `dividend_income`, `gross_financial_wealth`, `lump_sum_income`, `main_residence_value`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `net_financial_wealth`, `non_residential_property_value`, `other_investment_income`, `other_residential_property_value`, `owned_land`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `savings`, `savings_interest_income`, `sublet_income` ## Council tax diff --git a/docs/book/policy/model-baseline.md b/docs/book/policy/model-baseline.md index 6c42fd8dd9..4502318ead 100644 --- a/docs/book/policy/model-baseline.md +++ b/docs/book/policy/model-baseline.md @@ -26,11 +26,11 @@ The government announced further National Insurance cuts, reducing the [employee ### Capital gains tax changes (Autumn Budget 2024) -The government increased capital gains tax rates from 10%/20% to [18% for basic rate taxpayers](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/cgt/basic_rate.yaml#L4) and [24% for higher rate taxpayers](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/cgt/higher_rate.yaml#L4) in fiscal year 2025-26. +The government increased capital gains tax rates from 10%/20% to [18% for basic rate taxpayers](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/cgt/basic_rate.yaml#L4) and [24% for higher rate taxpayers](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/cgt/higher_rate.yaml#L4) in fiscal year 2025-26. Gains qualifying for Business Asset Disposal Relief or Investors' Relief (`capital_gains_badr`) are charged at a [separate rate](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/cgt/badr/rate.yaml) that rises from 10% to 14% in fiscal year 2025-26 and 18% from 2026-27, up to a [£1 million lifetime limit](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/cgt/badr/lifetime_limit.yaml). Carried interest (`capital_gains_carried_interest`) is charged at a [flat 32%](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/cgt/carried_interest/higher_rate.yaml) from fiscal year 2025-26 (Finance Act 2025 s. 12); its move into income tax from 2026-27 is not modelled. ### Spring Budget 2024 -The government increased the [child benefit high income tax charge threshold from £50,000 to £60,000](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/income_tax/charges/CB_HITC/phase_out_start.yaml#L4) in fiscal year 2024-25. +The government increased the [child benefit high income tax charge threshold from £50,000 to £60,000](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/income_tax/charges/CB_HITC/phase_out_start.yaml#L4) in fiscal year 2024-25. The [higher rate of capital gains tax on residential property](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/cgt/residential_property/higher_rate.yaml) fell from 28% to 24% for disposals from 6 April 2024 (Finance (No. 2) Act 2024 s. 6); the basic rate stayed at 18%, and neither changed on 30 October 2024. These rates apply to the `capital_gains_residential_property` component of a person's gains. ### Autumn Statement 2023 (Employee NI cut) @@ -97,7 +97,7 @@ The model baseline includes all reforms above, incorporating the current UK tax Key features: - **Income tax**: 20%/40%/45% rates with personal allowance of £12,570 and higher rate threshold of £50,270 - **National Insurance**: 8% employee rate, 15% employer rate, 6% self-employed rate -- **Capital gains tax**: 18%/24% rates with £3,000 annual exempt amount +- **Capital gains tax**: 18%/24% main rates with £3,000 annual exempt amount; 14% on Business Asset Disposal Relief gains, 18%/24% on residential property gains and a flat 32% on carried interest - **Universal Credit**: 55% taper rate with rebalancing reforms active - **Benefits**: Standard uprating with targeted cost of living support for 2022 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_components.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_components.yaml new file mode 100644 index 0000000000..f7676af409 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_components.yaml @@ -0,0 +1,205 @@ +# Gains on the three schedule inputs (capital_gains_badr, +# capital_gains_residential_property, capital_gains_carried_interest) are +# components of capital_gains and are charged at their own rates; the rest of +# capital_gains is charged at the main rates. The annual exempt amount goes to +# the schedule with the highest top rate first (TCGA 1992 s. 1K(5)); the unused +# basic rate band goes to relief gains first (s. 1I(4)-(6)) and then to the +# schedule with the widest higher-minus-basic gap (s. 1I(7)-(9)). +# +# 2024-25 main rates are the day-weighted blend of the rates before and after +# 30 October 2024: basic 49.14/365 = 0.1346301, higher 79.32/365 = 0.2173151. +# The residential property higher rate is 24% for the whole of 2024-25. + +- name: Residential property 2024, 18% on the unused basic rate band and 24% above, after one AEA. + # adjusted_net_income 42,570 less the 12,570 personal allowance leaves taxable + # income 30,000 and 7,700 of the 37,700 basic rate band. 100,000 - 3,000 AEA = + # 97,000: 7,700 x 0.18 = 1,386 plus 89,300 x 0.24 = 21,432. + period: 2024 + absolute_error_margin: 0.01 + input: + adjusted_net_income: 42_570 + capital_gains: 100_000 + capital_gains_residential_property: 100_000 + output: + capital_gains_tax: 22_818 + +- name: Residential property 2024, the same gains given through capital_gains_before_response. + # capital_gains adds the pre-response gains and a zero response, so the + # component is scaled by exactly 1 and the answer matches the case above. + period: 2024 + absolute_error_margin: 0.01 + input: + adjusted_net_income: 42_570 + capital_gains_before_response: 100_000 + capital_gains_residential_property: 100_000 + output: + capital_gains_tax: 22_818 + +- name: BADR gains 2024 pay 10% whatever the band. + # 500,000 - 3,000 AEA = 497,000 x 0.10. + period: 2024 + input: + adjusted_net_income: 0 + capital_gains: 500_000 + capital_gains_badr: 500_000 + output: + capital_gains_tax: 49_700 + +- name: BADR gains 2025 pay 14%. + # 497,000 x 0.14 (Finance Act 2025 s. 8, disposals from 6 April 2025). + period: 2025 + input: + adjusted_net_income: 0 + capital_gains: 500_000 + capital_gains_badr: 500_000 + output: + capital_gains_tax: 69_580 + +- name: BADR gains 2026 pay 18%. + # 497,000 x 0.18 (Finance Act 2025 s. 8, disposals from 6 April 2026). + period: 2026 + input: + adjusted_net_income: 0 + capital_gains: 500_000 + capital_gains_badr: 500_000 + output: + capital_gains_tax: 89_460 + +- name: BADR gains above the lifetime limit, 2025, the excess is charged at the main rates. + # 1,000,000 x 0.14 = 140,000 on the relieved gains. The 500,000 excess takes + # the AEA (0.24 beats 0.14), leaving 497,000; the relieved gains have used the + # whole basic rate band and the notional higher band first, so the excess is + # charged at 0.24: 119,280. + period: 2025 + absolute_error_margin: 0.1 + input: + adjusted_net_income: 0 + capital_gains: 1_500_000 + capital_gains_badr: 1_500_000 + output: + capital_gains_tax: 259_280 + +- name: BADR gains just above the lifetime limit, 2024, the excess is charged at the blended main rate. + # 1,000,000 x 0.10 = 100,000. The 10,000 excess takes the AEA, leaving 7,000 + # above every band the relieved gains have filled: 7,000 x 0.2173151 = 1,521.21. + period: 2024 + absolute_error_margin: 0.1 + input: + adjusted_net_income: 0 + capital_gains: 1_010_000 + capital_gains_badr: 1_010_000 + output: + capital_gains_tax: 101_521.21 + +- name: Carried interest 2024 pays 18% in the basic rate band and 28% above. + # 1,000,000 - 3,000 AEA = 997,000: 37,700 x 0.18 = 6,786 plus 959,300 x 0.28 + # = 268,604. + period: 2024 + absolute_error_margin: 0.1 + input: + adjusted_net_income: 0 + capital_gains: 1_000_000 + capital_gains_carried_interest: 1_000_000 + output: + capital_gains_tax: 275_390 + +- name: Carried interest 2025 pays a flat 32%. + # 997,000 x 0.32 (Finance Act 2025 s. 12). + period: 2025 + absolute_error_margin: 0.1 + input: + adjusted_net_income: 0 + capital_gains: 1_000_000 + capital_gains_carried_interest: 1_000_000 + output: + capital_gains_tax: 319_040 + +- name: BADR gains take the basic rate band before residential property gains. + # The AEA goes to the residential gains (0.24 beats 0.14): 7,000 left. The + # 37,700 of relieved gains use the whole basic rate band by statute (TCGA + # 1992 s. 1I(4)-(6)), so the residential gains pay 0.24 on all 7,000: 1,680, + # plus 37,700 x 0.14 = 5,278. Had the taxpayer been free to allocate the band, + # the residential gains would have paid 0.18 and the total would be 6,538. + period: 2025 + input: + adjusted_net_income: 0 + capital_gains: 47_700 + capital_gains_badr: 37_700 + capital_gains_residential_property: 10_000 + output: + capital_gains_tax: 6_958 + +- name: Mixed schedules 2024, the AEA and the basic rate band go where they save most. + # Taxable income 20,000 - 12,570 = 7,430 leaves 30,270 of basic rate band. + # Gains 100,000: BADR 20,000, carried interest 10,000, residential 30,000 and + # 40,000 at the main rates. The AEA goes to carried interest (top rate 0.28): + # 7,000 left. Band: BADR 20,000 first, then by higher-minus-basic gap carried + # interest 7,000 (gap 0.10), main 3,270 (gap 0.0827); none is left for the + # residential gains (gap 0.06). Tax: 20,000 x 0.10 = 2,000; 7,000 x 0.18 = + # 1,260; 3,270 x 0.1346301 = 440.24; 36,730 x 0.2173151 = 7,981.98; + # 30,000 x 0.24 = 7,200. + period: 2024 + absolute_error_margin: 0.01 + input: + adjusted_net_income: 20_000 + capital_gains: 100_000 + capital_gains_badr: 20_000 + capital_gains_carried_interest: 10_000 + capital_gains_residential_property: 30_000 + output: + capital_gains_tax: 18_882.22 + +- name: Schedule inputs that exceed capital_gains are clipped in order, leaving nothing at the main rates. + # Residential 40,000 is taken in full; carried interest is clipped to the + # 10,000 left. The AEA goes to carried interest (0.32): 7,000 x 0.32 = 2,240. + # Residential: 37,700 x 0.18 = 6,786 plus 2,300 x 0.24 = 552. + period: 2025 + input: + adjusted_net_income: 0 + capital_gains: 50_000 + capital_gains_residential_property: 40_000 + capital_gains_carried_interest: 30_000 + output: + capital_gains_tax: 9_578 + +- name: Residential property 2018 at the 18% and 28% rates of the time. + # AEA 11,700 leaves 38,300; the 2018-19 basic rate band is 34,500: + # 34,500 x 0.18 = 6,210 plus 3,800 x 0.28 = 1,064. + period: 2018 + input: + adjusted_net_income: 0 + capital_gains: 50_000 + capital_gains_residential_property: 50_000 + output: + capital_gains_tax: 7_274 + +- name: A schedule input without capital_gains is ignored. + period: 2025 + input: + adjusted_net_income: 0 + capital_gains: 0 + capital_gains_residential_property: 50_000 + output: + capital_gains_tax: 0 + +- name: A net loss pays nothing whatever the schedule inputs say. + period: 2025 + input: + adjusted_net_income: 0 + capital_gains: -20_000 + capital_gains_residential_property: 50_000 + output: + capital_gains_tax: 0 + +- name: Schedule inputs of zero reproduce the main-rate result exactly. + # Same as the "Higher rate capital gains tax" case in capital_gains_tax.yaml. + period: 2025 + absolute_error_margin: 0.01 + input: + adjusted_net_income: 50_000 + capital_gains: 50_000 + capital_gains_badr: 0 + capital_gains_residential_property: 0 + capital_gains_carried_interest: 0 + output: + capital_gains_tax: 11_263.80 diff --git a/policyengine_uk/tests/test_capital_gains_tax_components.py b/policyengine_uk/tests/test_capital_gains_tax_components.py new file mode 100644 index 0000000000..ff90667202 --- /dev/null +++ b/policyengine_uk/tests/test_capital_gains_tax_components.py @@ -0,0 +1,314 @@ +"""Tests for the capital gains tax schedules added for issue #1858. + +The schedule inputs (``capital_gains_badr``, ``capital_gains_residential_property``, +``capital_gains_carried_interest``) must leave ``capital_gains_tax`` bit for bit +unchanged when they are zero, and be charged at their own rates when they are +not. The per-case arithmetic lives in the YAML tests next to the variable; this +file proves the identity, the response plumbing and the parameter dates. +""" + +import numpy as np +import pytest + +from policyengine_uk import CountryTaxBenefitSystem, Microsimulation +from policyengine_uk.model_api import Scenario, max_, min_ + +SCHEDULE_INPUTS = ( + "capital_gains_badr", + "capital_gains_residential_property", + "capital_gains_carried_interest", +) + + +def legacy_capital_gains_tax(sim, year): + """The single-schedule formula as it stood before #1858, on the same arrays. + + Copied operation for operation so that any difference in the result is a + difference in the new formula, not in the comparison. + """ + hmrc = sim.tax_benefit_system.parameters(str(year)).gov.hmrc + cgt = hmrc.cgt + it = hmrc.income_tax + + def get(name): + return sim.calculate(name, year).values + + personal_pension_band_extension = min_( + get("personal_pension_contributions"), + get("pension_contributions_relief"), + ) + basic_rate_band_extension = ( + get("gift_aid_grossed_up") + personal_pension_band_extension + ) + allowances_for_cgt_income = max_( + 0, + get("allowances") - get("gift_aid") - personal_pension_band_extension, + ) + taxable_income = max_(0, get("adjusted_net_income") - allowances_for_cgt_income) + gains = max_(0, get("capital_gains")) + aea = cgt.annual_exempt_amount + gains_less_aea = max_(0, gains - aea) + basic_rate_limit = it.rates.uk.thresholds[1] + basic_rate_band_extension + remaining_basic_rate_band = max_(basic_rate_limit - taxable_income, 0) + + basic_rate_applicable_cg = min_(gains_less_aea, remaining_basic_rate_band) + higher_and_add_rate_applicable_cg = max_( + gains_less_aea - remaining_basic_rate_band, 0 + ) + higher_rate_limit = it.rates.uk.thresholds[2] + basic_rate_band_extension + higher_rate_applicable_cg = min_( + higher_and_add_rate_applicable_cg, + higher_rate_limit - basic_rate_limit, + ) + add_rate_applicable_cg = max_( + higher_and_add_rate_applicable_cg - higher_rate_applicable_cg, 0 + ) + + basic_rate_tax = basic_rate_applicable_cg * cgt.basic_rate + higher_rate_tax = higher_rate_applicable_cg * cgt.higher_rate + add_rate_tax = add_rate_applicable_cg * cgt.additional_rate + + return basic_rate_tax + higher_rate_tax + add_rate_tax + + +def assert_bit_identical_where_inputs_are_zero(sim, year): + """The new formula reproduces the old one, bit for bit, at zero inputs.""" + new = sim.calculate("capital_gains_tax", year).values + legacy = legacy_capital_gains_tax(sim, year) + zero_inputs = np.ones_like(new, dtype=bool) + for name in SCHEDULE_INPUTS: + zero_inputs &= sim.calculate(name, year).values == 0 + assert zero_inputs.any() + + assert new.dtype == np.float32 + assert legacy.dtype == np.float32 + assert np.array_equal(new[zero_inputs], legacy[zero_inputs]) + new_bits = np.ascontiguousarray(new[zero_inputs]).view(np.uint32) + legacy_bits = np.ascontiguousarray(legacy[zero_inputs]).view(np.uint32) + assert np.array_equal(new_bits, legacy_bits) + + +INCOMES = [0, 5_000, 12_570, 20_000, 42_570, 50_270, 100_000, 125_140, 150_000, 500_000] +GAINS = [ + -50_000, + -1, + 0, + 1, + 2_999, + 3_000, + 3_001, + 12_300, + 23_000, + 40_000, + 50_000, + 100_000, + 200_000, + 1_000_000, + 5_000_000, +] +YEARS = [2018, 2020, 2022, 2023, 2024, 2025, 2026, 2030] +VARIANTS = { + "plain": {}, + "gift_aid": {"gift_aid": 5_000}, + "pension": {"employment_income": 52_570, "personal_pension_contributions": 10_000}, +} + + +def grid_situation(year, extra=None): + """One single-person household per (income, gains) pair.""" + people, benunits, households = {}, {}, {} + for i, (income, gains) in enumerate( + (income, gains) for income in INCOMES for gains in GAINS + ): + name = f"person_{i}" + person = { + "age": {year: 45}, + "adjusted_net_income": {year: income}, + "capital_gains": {year: gains}, + } + for variable, value in (extra or {}).items(): + person[variable] = {year: value} + people[name] = person + benunits[f"benunit_{i}"] = {"members": [name]} + households[f"household_{i}"] = {"members": [name]} + return {"people": people, "benunits": benunits, "households": households} + + +def single_person(year, **variables): + person = {"age": {year: 45}} + for variable, value in variables.items(): + person[variable] = {year: value} + return { + "people": {"person": person}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, + } + + +class TestZeroInputsAreBitIdentical: + @pytest.mark.parametrize("variant", list(VARIANTS)) + @pytest.mark.parametrize("year", YEARS) + def test_across_years_incomes_and_gains(self, year, variant): + sim = Microsimulation(situation=grid_situation(year, VARIANTS[variant])) + assert_bit_identical_where_inputs_are_zero(sim, year) + + @pytest.mark.parametrize( + "changes", + [ + { + "gov.hmrc.cgt.basic_rate": {"2025": 0.20}, + "gov.hmrc.cgt.higher_rate": {"2025": 0.40}, + "gov.hmrc.cgt.additional_rate": {"2025": 0.45}, + }, + {"gov.hmrc.cgt.annual_exempt_amount": {"2025": 12_300.33}}, + ], + ids=["equalised_rates", "fractional_aea"], + ) + def test_under_reforms(self, changes): + sim = Microsimulation( + situation=grid_situation(2025), + scenario=Scenario(parameter_changes=changes), + ) + assert_bit_identical_where_inputs_are_zero(sim, 2025) + + @pytest.mark.microsimulation + def test_on_the_default_dataset(self): + """The dataset carries no schedule inputs yet, so nothing may move. + + conftest skips microsimulation tests when no default dataset is set. + """ + sim = Microsimulation() + for year in (2024, 2025, 2026): + assert_bit_identical_where_inputs_are_zero(sim, year) + + +class TestResponsePlumbing: + YEAR = 2026 + + def test_residential_rate_reform_moves_only_residential_gains(self): + """The realisation response sees reforms on the new schedules. + + A landlord whose gains are all residential property faces a higher + marginal rate under a residential-rate rise; a person with only + main-rate gains sees no change at all. + """ + year = self.YEAR + situation = { + "people": { + "landlord": { + "age": {year: 45}, + "employment_income": {year: 100_000}, + "capital_gains": {year: 100_000}, + "capital_gains_residential_property": {year: 100_000}, + }, + "trader": { + "age": {year: 45}, + "employment_income": {year: 100_000}, + "capital_gains": {year: 100_000}, + }, + }, + "benunits": { + "landlord_bu": {"members": ["landlord"]}, + "trader_bu": {"members": ["trader"]}, + }, + "households": { + "landlord_hh": {"members": ["landlord"]}, + "trader_hh": {"members": ["trader"]}, + }, + } + changes = { + "gov.hmrc.cgt.residential_property.higher_rate": {str(year): 0.30}, + "gov.hmrc.cgt.residential_property.additional_rate": {str(year): 0.30}, + "gov.simulation.capital_gains_responses.mtr_elasticity": {str(year): -0.5}, + } + sim = Microsimulation( + situation=situation, scenario=Scenario(parameter_changes=changes) + ) + change = sim.calculate("relative_capital_gains_mtr_change", year).values + landlord, trader = change + assert landlord > 0 + assert trader == 0 + + gains = sim.calculate("capital_gains", year).values + assert gains[0] < 100_000 + assert gains[1] == 100_000 + + def test_marginal_rate_is_share_weighted_across_schedules(self): + """Half carried interest at 32% and half main-rate gains at 24%.""" + year = 2025 + sim = Microsimulation( + situation=single_person( + year, + employment_income=150_000, + capital_gains=100_000, + capital_gains_carried_interest=50_000, + ) + ) + mtr = sim.calculate("marginal_tax_rate_on_capital_gains", year).values[0] + assert mtr == pytest.approx(0.5 * 0.32 + 0.5 * 0.24, abs=1e-3) + + def test_response_scales_the_schedule_components(self): + """A response that shrinks capital_gains shrinks the taxed residential gains.""" + year = self.YEAR + situation = single_person( + year, + employment_income=100_000, + capital_gains=100_000, + capital_gains_residential_property=100_000, + ) + changes = { + "gov.hmrc.cgt.residential_property.higher_rate": {str(year): 0.40}, + "gov.hmrc.cgt.residential_property.additional_rate": {str(year): 0.40}, + "gov.simulation.capital_gains_responses.elasticity": {str(year): 1.0}, + } + reformed = Microsimulation( + situation=situation, scenario=Scenario(parameter_changes=changes) + ) + gains = reformed.calculate("capital_gains", year).values[0] + tax = reformed.calculate("capital_gains_tax", year).values[0] + assert 0 < gains < 100_000 + # Every realised pound is residential, so the tax is 40% of the gains + # above the annual exempt amount: the component followed the response. + assert tax == pytest.approx((gains - 3_000) * 0.40, rel=1e-4) + + +class TestScheduleParameters: + """Dates and values of the new gov.hmrc.cgt parameters by fiscal year.""" + + @pytest.mark.parametrize( + "path, expected", + [ + ("badr.rate", {2024: 0.10, 2025: 0.14, 2026: 0.18}), + ("badr.lifetime_limit", {2019: 10_000_000, 2020: 1_000_000}), + ("residential_property.basic_rate", {2023: 0.18, 2024: 0.18}), + ("residential_property.higher_rate", {2023: 0.28, 2024: 0.24}), + ("carried_interest.basic_rate", {2024: 0.18, 2025: 0.32}), + ("carried_interest.higher_rate", {2024: 0.28, 2025: 0.32}), + ], + ) + def test_values_by_fiscal_year(self, path, expected): + system = CountryTaxBenefitSystem() + for year, value in expected.items(): + node = system.get_parameters_at_instant(str(year)).gov.hmrc.cgt + for part in path.split("."): + node = getattr(node, part) + # Exact: none of these is blended across a fiscal year. + assert node == value, (path, year, node) + + def test_new_parameters_are_not_blended(self): + system = CountryTaxBenefitSystem() + cgt = system.parameters.gov.hmrc.cgt + for schedule in (cgt.badr, cgt.residential_property, cgt.carried_interest): + for parameter in schedule.children.values(): + assert not (parameter.metadata or {}).get("fiscal_year_blend"), ( + parameter.name + ) + + @pytest.mark.parametrize("schedule", ["residential_property", "carried_interest"]) + def test_additional_rate_equals_higher_rate_in_law(self, schedule): + system = CountryTaxBenefitSystem() + for year in range(2015, 2031): + node = getattr( + system.get_parameters_at_instant(str(year)).gov.hmrc.cgt, schedule + ) + assert node.additional_rate == node.higher_rate, (schedule, year) From b1e530538f4b4461bb5e96383af7367d4e244f59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:55:36 +0100 Subject: [PATCH 4/5] Open dataset H5 files read-only when loading huggingface_hub 1.32.0 (17 September 2026) stores cached downloads as read-only blobs in a shared blob store. UKSingleYearDataset and UKMultiYearDataset opened the file with pd.HDFStore's default append mode, which PyTables checks for write access, so every dataset-backed simulation on a fresh CI runner failed with "exists but it can not be written". Reading a dataset needs no write access; open it with mode="r". Regression tests save a small dataset, make the file read-only and load it through both dataset classes and the Simulation constructor. Co-Authored-By: Claude Fable 5.1 --- .../dataset-files-open-read-only.fixed.md | 1 + policyengine_uk/data/dataset_schema.py | 6 +- .../tests/test_simulation_dataset_sources.py | 78 +++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 changelog.d/dataset-files-open-read-only.fixed.md diff --git a/changelog.d/dataset-files-open-read-only.fixed.md b/changelog.d/dataset-files-open-read-only.fixed.md new file mode 100644 index 0000000000..8e36c5a610 --- /dev/null +++ b/changelog.d/dataset-files-open-read-only.fixed.md @@ -0,0 +1 @@ +- Open dataset H5 files read-only when loading. huggingface_hub 1.32.0 stores cached downloads as read-only blobs, and the default append mode refused them, so every dataset-backed simulation failed with a PermissionError. diff --git a/policyengine_uk/data/dataset_schema.py b/policyengine_uk/data/dataset_schema.py index d2fa19a748..6f294022b6 100644 --- a/policyengine_uk/data/dataset_schema.py +++ b/policyengine_uk/data/dataset_schema.py @@ -51,7 +51,9 @@ def __init__( file_path = str(file_path) if file_path else None if file_path is not None: self.validate_file_path(file_path) - with pd.HDFStore(file_path) as f: + # Read-only: the file may be a cached download the cache manager + # has made unwritable, and the default append mode would refuse it. + with pd.HDFStore(file_path, mode="r") as f: self._person = f["person"] self._benunit = f["benunit"] self._household = f["household"] @@ -193,7 +195,7 @@ def __init__( if file_path is not None: UKMultiYearDataset.validate_file_path(file_path) - with pd.HDFStore(file_path) as f: + with pd.HDFStore(file_path, mode="r") as f: self.datasets = {} for year in f.keys(): if year.startswith("/person/"): diff --git a/policyengine_uk/tests/test_simulation_dataset_sources.py b/policyengine_uk/tests/test_simulation_dataset_sources.py index 5ce9f846a0..add54a004f 100644 --- a/policyengine_uk/tests/test_simulation_dataset_sources.py +++ b/policyengine_uk/tests/test_simulation_dataset_sources.py @@ -175,3 +175,81 @@ def fake_build_from_multi_year_dataset(dataset): assert simulation_module._url_dataset_cache[cache_key] is built["dataset"] finally: simulation_module._url_dataset_cache.pop(cache_key, None) + + +def _single_person_dataset(): + """A one-person dataset built from frames, as the multi-year tests do.""" + import pandas as pd + + from policyengine_uk.data.dataset_schema import UKSingleYearDataset + + person = pd.DataFrame( + { + "person_id": [1], + "person_benunit_id": [1], + "person_household_id": [1], + "employment_income": [30_000.0], + "age": [40], + } + ) + benunit = pd.DataFrame({"benunit_id": [1]}) + household = pd.DataFrame({"household_id": [1]}) + return UKSingleYearDataset( + person=person, benunit=benunit, household=household, fiscal_year=2025 + ) + + +def _save_read_only(dataset, path: Path) -> str: + """Save a dataset and make the file read-only, as a cached download is.""" + dataset.save(str(path)) + path.chmod(0o444) + return str(path) + + +def test_single_year_dataset_loads_from_a_read_only_file(tmp_path): + """Reading a dataset needs no write access. + + huggingface_hub 1.32.0 stores cached downloads as read-only blobs, and + opening the file in the default append mode refused them, so every + dataset-backed simulation in CI failed with a PermissionError. + """ + from policyengine_uk.data.dataset_schema import UKSingleYearDataset + + path = _save_read_only(_single_person_dataset(), tmp_path / "single.h5") + + loaded = UKSingleYearDataset(path) + + assert loaded.time_period == "2025" + assert float(loaded.person["employment_income"].iloc[0]) == 30_000 + + +def test_multi_year_dataset_loads_from_a_read_only_file(tmp_path): + from policyengine_uk.data.dataset_schema import UKMultiYearDataset + + dataset = UKMultiYearDataset(datasets=[_single_person_dataset()]) + path = _save_read_only(dataset, tmp_path / "multi.h5") + + loaded = UKMultiYearDataset(path) + + assert loaded.years == [2025] + assert float(loaded[2025].person["employment_income"].iloc[0]) == 30_000 + + +def test_simulation_builds_from_a_read_only_dataset_file(monkeypatch, tmp_path): + """The path a cached single-year download takes through Simulation. + + Extending a single year to later years needs a real dataset's columns, so + that step is stubbed; the file open under test happens before it. + """ + from policyengine_uk.data.dataset_schema import UKMultiYearDataset + + monkeypatch.setattr( + simulation_module, + "extend_single_year_dataset", + lambda dataset, parameters: UKMultiYearDataset(datasets=[dataset]), + ) + path = _save_read_only(_single_person_dataset(), tmp_path / "single.h5") + + simulation = Simulation(dataset=path) + + assert float(simulation.calculate("employment_income", 2025)[0]) == 30_000 From 896ea98ec6c11b887f54d137f630660f28caace6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:33:53 +0100 Subject: [PATCH 5/5] Say what a rate-only CGT reform now leaves untouched, and pin it in tests A reform that changes only gov.hmrc.cgt.{basic,higher,additional}_rate no longer reaches residential property, carried interest or relief gains. The changelog fragment says so and what a full equalisation must set (the other two schedules' rates, and badr.lifetime_limit to zero, since the relief rate is band-independent); two tests pin both behaviours at 2025 values. Also corrects the baseline-policy note that dated the main-rate rise to 2025-26: Finance Act 2025 s. 7 applies from 30 October 2024 and the model day-weights 2024-25. Co-Authored-By: Claude Fable 5.1 --- changelog.d/1858.added.md | 2 +- docs/book/policy/model-baseline.md | 2 +- .../test_capital_gains_tax_components.py | 53 +++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/changelog.d/1858.added.md b/changelog.d/1858.added.md index 8c42b71367..9ac01bad58 100644 --- a/changelog.d/1858.added.md +++ b/changelog.d/1858.added.md @@ -1 +1 @@ -- Add three person-level inputs, `capital_gains_badr`, `capital_gains_residential_property` and `capital_gains_carried_interest`, as components of `capital_gains`, and charge each at its own schedule in `capital_gains_tax`: new parameters `gov.hmrc.cgt.badr.rate` and `.lifetime_limit`, `gov.hmrc.cgt.residential_property.{basic,higher,additional}_rate` and `gov.hmrc.cgt.carried_interest.{basic,higher,additional}_rate`. The annual exempt amount goes to the highest-rate schedule first, relief gains take the unused basic rate band before other gains (TCGA 1992 s. 1I(4)-(6)), and the remaining band goes where it saves most. With the inputs absent or zero the liability is unchanged. +- Add three person-level inputs, `capital_gains_badr`, `capital_gains_residential_property` and `capital_gains_carried_interest`, as components of `capital_gains`, and charge each at its own schedule in `capital_gains_tax`: new parameters `gov.hmrc.cgt.badr.rate` and `.lifetime_limit`, `gov.hmrc.cgt.residential_property.{basic,higher,additional}_rate` and `gov.hmrc.cgt.carried_interest.{basic,higher,additional}_rate`. The annual exempt amount goes to the highest-rate schedule first, relief gains take the unused basic rate band before other gains (TCGA 1992 s. 1I(4)-(6)), and the remaining band goes where it saves most. With the inputs absent or zero the liability is unchanged. A reform that changes only `gov.hmrc.cgt.{basic,higher,additional}_rate` no longer reaches residential property, carried interest or relief gains; to tax every gain at income tax rates, set the `residential_property` and `carried_interest` rates too and set `gov.hmrc.cgt.badr.lifetime_limit` to zero. diff --git a/docs/book/policy/model-baseline.md b/docs/book/policy/model-baseline.md index 4502318ead..53ab48307f 100644 --- a/docs/book/policy/model-baseline.md +++ b/docs/book/policy/model-baseline.md @@ -26,7 +26,7 @@ The government announced further National Insurance cuts, reducing the [employee ### Capital gains tax changes (Autumn Budget 2024) -The government increased capital gains tax rates from 10%/20% to [18% for basic rate taxpayers](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/cgt/basic_rate.yaml#L4) and [24% for higher rate taxpayers](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/cgt/higher_rate.yaml#L4) in fiscal year 2025-26. Gains qualifying for Business Asset Disposal Relief or Investors' Relief (`capital_gains_badr`) are charged at a [separate rate](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/cgt/badr/rate.yaml) that rises from 10% to 14% in fiscal year 2025-26 and 18% from 2026-27, up to a [£1 million lifetime limit](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/cgt/badr/lifetime_limit.yaml). Carried interest (`capital_gains_carried_interest`) is charged at a [flat 32%](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/cgt/carried_interest/higher_rate.yaml) from fiscal year 2025-26 (Finance Act 2025 s. 12); its move into income tax from 2026-27 is not modelled. +The government increased capital gains tax rates from 10%/20% to [18% for basic rate taxpayers](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/cgt/basic_rate.yaml#L4) and [24% for higher rate taxpayers](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/cgt/higher_rate.yaml#L4) for disposals from 30 October 2024 (Finance Act 2025 s. 7), part-way through 2024-25; the model day-weights that year between the two schedules and applies the new rates in full from 2025-26. Gains qualifying for Business Asset Disposal Relief or Investors' Relief (`capital_gains_badr`) are charged at a [separate rate](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/cgt/badr/rate.yaml) that rises from 10% to 14% in fiscal year 2025-26 and 18% from 2026-27, up to a [£1 million lifetime limit](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/cgt/badr/lifetime_limit.yaml). Carried interest (`capital_gains_carried_interest`) is charged at a [flat 32%](https://github.com/PolicyEngine/policyengine-uk/blob/master/policyengine_uk/parameters/gov/hmrc/cgt/carried_interest/higher_rate.yaml) from fiscal year 2025-26 (Finance Act 2025 s. 12); its move into income tax from 2026-27 is not modelled. ### Spring Budget 2024 diff --git a/policyengine_uk/tests/test_capital_gains_tax_components.py b/policyengine_uk/tests/test_capital_gains_tax_components.py index ff90667202..6972af3693 100644 --- a/policyengine_uk/tests/test_capital_gains_tax_components.py +++ b/policyengine_uk/tests/test_capital_gains_tax_components.py @@ -272,6 +272,59 @@ def test_response_scales_the_schedule_components(self): assert tax == pytest.approx((gains - 3_000) * 0.40, rel=1e-4) +class TestEqualisationReforms: + """What a reform must set to reach every schedule. + + The main rates no longer cover residential property, carried interest or + relief gains, so a reform that moves only them leaves the other schedules + at current law. Taxing every gain at income tax rates means setting the + other two schedules' rates as well and switching the relief off through + its lifetime limit, since the relief rate is band-independent. + """ + + YEAR = 2025 + MAIN_RATES_ONLY = { + "gov.hmrc.cgt.basic_rate": {"2025": 0.20}, + "gov.hmrc.cgt.higher_rate": {"2025": 0.40}, + "gov.hmrc.cgt.additional_rate": {"2025": 0.45}, + } + EVERY_SCHEDULE = { + **MAIN_RATES_ONLY, + "gov.hmrc.cgt.residential_property.basic_rate": {"2025": 0.20}, + "gov.hmrc.cgt.residential_property.higher_rate": {"2025": 0.40}, + "gov.hmrc.cgt.residential_property.additional_rate": {"2025": 0.45}, + "gov.hmrc.cgt.carried_interest.basic_rate": {"2025": 0.20}, + "gov.hmrc.cgt.carried_interest.higher_rate": {"2025": 0.40}, + "gov.hmrc.cgt.carried_interest.additional_rate": {"2025": 0.45}, + "gov.hmrc.cgt.badr.lifetime_limit": {"2025": 0}, + } + SCHEDULES = (None, *SCHEDULE_INPUTS) + + def tax(self, schedule, changes=None): + variables = {"employment_income": 100_000, "capital_gains": 100_000} + if schedule is not None: + variables[schedule] = 100_000 + kwargs = {"scenario": Scenario(parameter_changes=changes)} if changes else {} + sim = Microsimulation(situation=single_person(self.YEAR, **variables), **kwargs) + return float(sim.calculate("capital_gains_tax", self.YEAR).values[0]) + + def test_main_rate_reform_leaves_the_other_schedules_at_current_law(self): + baseline = {s: self.tax(s) for s in self.SCHEDULES} + reformed = {s: self.tax(s, self.MAIN_RATES_ONLY) for s in self.SCHEDULES} + + # 97,000 above the AEA, all above the basic rate band: 87,440 at 40% + # and 9,560 at 45%. + assert reformed[None] == pytest.approx(39_278) + for schedule in SCHEDULE_INPUTS: + assert reformed[schedule] == baseline[schedule], schedule + + def test_setting_every_schedule_taxes_every_gain_alike(self): + reformed = {s: self.tax(s, self.EVERY_SCHEDULE) for s in self.SCHEDULES} + + for schedule in self.SCHEDULES: + assert reformed[schedule] == pytest.approx(39_278), schedule + + class TestScheduleParameters: """Dates and values of the new gov.hmrc.cgt parameters by fiscal year."""