From 54fa62d64d28a976df2ac6cc3ce5e79a38d53a5d Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Thu, 15 Oct 2026 15:58:14 +0200 Subject: [PATCH] Stack gains on taxable income when splitting the higher and additional CGT bands The split of gains above the basic rate band gave every gainer the whole higher band (higher rate limit less basic rate limit), never subtracting taxable income already above the basic rate limit. Gains are the top slice of income (TCGA 1992 s. 1H), so the band left for them is the higher rate limit less taxable income or the basic rate limit, whichever is higher. Current law charges the higher and additional CGT rates alike, so only reforms that set them apart change; current-law results move by float32 rounding where gains straddle the new split point. Co-Authored-By: Claude Opus 5.5 --- .../cgt-additional-band-stacking.fixed.md | 1 + .../capital_gains_tax_rate_bands.yaml | 138 ++++++++++++++++++ .../test_capital_gains_tax_components.py | 18 ++- policyengine_uk/tests/test_gift_aid.py | 12 +- .../capital_gains_tax/capital_gains_tax.py | 17 ++- 5 files changed, 174 insertions(+), 12 deletions(-) create mode 100644 changelog.d/cgt-additional-band-stacking.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_rate_bands.yaml diff --git a/changelog.d/cgt-additional-band-stacking.fixed.md b/changelog.d/cgt-additional-band-stacking.fixed.md new file mode 100644 index 0000000000..fea6956098 --- /dev/null +++ b/changelog.d/cgt-additional-band-stacking.fixed.md @@ -0,0 +1 @@ +Stack capital gains on top of taxable income when splitting them between the higher and additional CGT bands, so reforms to either rate no longer give every gainer the whole higher band. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_rate_bands.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_rate_bands.yaml new file mode 100644 index 0000000000..fbde35bf2a --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_rate_bands.yaml @@ -0,0 +1,138 @@ +# Gains are the top slice of income (TCGA 1992 s. 1H). The model splits gains +# above the basic rate band at the income tax higher rate limit so reforms can +# set an additional CGT rate; the law has none, and the two rates are equal. +# The split stacks gains on top of taxable income, as income tax does: the +# higher band left for gains is the higher rate limit less taxable income or +# the basic rate limit, whichever is higher. +# +# 2025-26: basic rate limit 37,700, higher rate limit 125,140, annual exempt +# amount 3,000, CGT rates 18% and 24%. allowances is set to 0 so taxable income +# equals net_income. + +- name: Additional rate reform, income below the basic rate limit. + # Taxable income 20,000 leaves 17,700 of basic rate band. 150,000 - 3,000 = + # 147,000: 17,700 x 0.18 = 3,186; the higher band left is 125,140 - 37,700 + # = 87,440, x 0.24 = 20,985.60; the rest, 41,860 x 0.45 = 18,837. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.additional_rate: 0.45 + net_income: 20_000 + allowances: 0 + capital_gains: 150_000 + output: + capital_gains_tax: 43_008.60 + +- name: Additional rate reform, income between the basic and higher rate limits. + # Taxable income 100,000 uses the basic rate band. The higher band left is + # 125,140 - 100,000 = 25,140, x 0.24 = 6,033.60; the rest of the 97,000, + # 71,860 x 0.45 = 32,337. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.additional_rate: 0.45 + net_income: 100_000 + allowances: 0 + capital_gains: 100_000 + output: + capital_gains_tax: 38_370.60 + +- name: Additional rate reform, income above the higher rate limit. + # Taxable income 200,000 is above 125,140, so all 47,000 of gains after the + # annual exempt amount are in the additional band: 47,000 x 0.45. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.additional_rate: 0.45 + net_income: 200_000 + allowances: 0 + capital_gains: 50_000 + output: + capital_gains_tax: 21_150 + +- name: Higher rate reform, income above the higher rate limit. + # No higher band is left, so a higher CGT rate of 45% does not apply: + # 47,000 x 0.24 at the unchanged additional rate. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.higher_rate: 0.45 + net_income: 200_000 + allowances: 0 + capital_gains: 50_000 + output: + capital_gains_tax: 11_280 + +- name: Higher rate reform, income between the basic and higher rate limits. + # 25,140 x 0.45 = 11,313 in the higher band left; 71,860 x 0.24 = 17,246.40. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.higher_rate: 0.45 + net_income: 100_000 + allowances: 0 + capital_gains: 100_000 + output: + capital_gains_tax: 28_559.40 + +- name: Residential property additional rate reform, income between the limits. + # The same split applies to each schedule: 25,140 x 0.24 = 6,033.60 and + # 71,860 x 0.45 = 32,337. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.residential_property.additional_rate: 0.45 + net_income: 100_000 + allowances: 0 + capital_gains: 100_000 + capital_gains_residential_property: 100_000 + output: + capital_gains_tax: 38_370.60 + +- name: Carried interest additional rate reform, income between the limits. + # Carried interest is 32% in both bands from 2025-26: 25,140 x 0.32 = + # 8,044.80 in the higher band left and 71,860 x 0.45 = 32,337 above it. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.carried_interest.additional_rate: 0.45 + net_income: 100_000 + allowances: 0 + capital_gains: 100_000 + capital_gains_carried_interest: 100_000 + output: + capital_gains_tax: 40_381.80 + +- name: Current law, income above the higher rate limit. + # The higher and additional rates are both 24%: 47,000 x 0.24. + period: 2025 + absolute_error_margin: 0.01 + input: + net_income: 200_000 + allowances: 0 + capital_gains: 50_000 + output: + capital_gains_tax: 11_280 + +- name: Current law, income between the basic and higher rate limits. + # 97,000 x 0.24 whatever the split. + period: 2025 + absolute_error_margin: 0.01 + input: + net_income: 100_000 + allowances: 0 + capital_gains: 100_000 + output: + capital_gains_tax: 23_280 + +- name: Additional rate reform, 200,000 of earnings and 50,000 of gains. + # The personal allowance is fully withdrawn at this income, so taxable + # income is 200,000, above the higher rate limit: 47,000 x 0.45. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.additional_rate: 0.45 + employment_income: 200_000 + capital_gains: 50_000 + output: + capital_gains_tax: 21_150 diff --git a/policyengine_uk/tests/test_capital_gains_tax_components.py b/policyengine_uk/tests/test_capital_gains_tax_components.py index 23d5687c94..e15169b740 100644 --- a/policyengine_uk/tests/test_capital_gains_tax_components.py +++ b/policyengine_uk/tests/test_capital_gains_tax_components.py @@ -27,7 +27,9 @@ def legacy_capital_gains_tax(sim, year): there is a difference in the new formula. Its income and allowance inputs follow the current model (#2253 moved Gift Aid out of the allowances), so it checks how gains are allocated to the schedules, not equivalence with - any earlier treatment of Gift Aid. + any earlier treatment of Gift Aid. Its higher band likewise follows the + current model, which stacks gains on top of taxable income, rather than + giving every gainer the whole band as the formula before #1858 did. """ hmrc = sim.tax_benefit_system.parameters(str(year)).gov.hmrc cgt = hmrc.cgt @@ -61,9 +63,11 @@ def get(name): gains_less_aea - remaining_basic_rate_band, 0 ) higher_rate_limit = it.rates.uk.thresholds[2] + basic_rate_band_extension + # Gains stack on top of taxable income (TCGA 1992 s. 1H), so the higher + # band left for them starts at taxable income or the basic rate limit. higher_rate_applicable_cg = min_( higher_and_add_rate_applicable_cg, - higher_rate_limit - basic_rate_limit, + max_(higher_rate_limit - max_(taxable_income, basic_rate_limit), 0), ) add_rate_applicable_cg = max_( higher_and_add_rate_applicable_cg - higher_rate_applicable_cg, 0 @@ -317,9 +321,11 @@ 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) + # Taxable income is 100,000 - 12,570 = 87,430, so the 97,000 above the + # AEA is all above the basic rate band. The higher band left on top of + # that income is 125,140 - 87,430 = 37,710: 37,710 at 40% and 59,290 + # at 45%. + assert reformed[None] == pytest.approx(41_764.5) for schedule in SCHEDULE_INPUTS: assert reformed[schedule] == baseline[schedule], schedule @@ -327,7 +333,7 @@ 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 + assert reformed[schedule] == pytest.approx(41_764.5), schedule class TestScheduleParameters: diff --git a/policyengine_uk/tests/test_gift_aid.py b/policyengine_uk/tests/test_gift_aid.py index 27d62c9d4c..545eba8fc7 100644 --- a/policyengine_uk/tests/test_gift_aid.py +++ b/policyengine_uk/tests/test_gift_aid.py @@ -341,7 +341,17 @@ def test_zero_gift_preserves_main_outputs_bit_for_bit(): 3307.25, 31407.05078125, ], - "capital_gains_tax": [7663.7998046875, 8880, 8880, 8880, 8263.7998046875, 8880] + # The last row's 37,000 of gains is split between the higher band + # left on top of its 88,930 taxable income and the band above it, at + # equal rates; float32 rounds the two parts' sum a thousandth low. + "capital_gains_tax": [ + 7663.7998046875, + 8880, + 8880, + 8880, + 8263.7998046875, + 8879.9990234375, + ] * 2, } for variable, values in expected.items(): 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 31a414c0fb..bf8ecc4ce4 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 @@ -68,7 +68,13 @@ def formula(person, period, parameters): 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 + # Gains are the top slice of income (TCGA 1992 s. 1H), so the gains + # above the basic rate band start at the basic rate limit or at + # taxable income, whichever is higher. Only the rest of the band up to + # the higher rate limit is left for them. + remaining_higher_rate_band = max_( + higher_rate_limit - max_(taxable_income, basic_rate_limit), 0 + ) # The schedule inputs describe gains before any realisation response. # Relief gains move by their own response, which has its own @@ -161,15 +167,16 @@ def component(amount): 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. + # Gains above the basic rate band are split at the higher rate limit, + # stacked on top of taxable income, 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 + band_left = remaining_higher_rate_band for name in higher_order: inside_higher[name], above_higher[name], band_left = _take( above_basic[name], band_left