From 54fa62d64d28a976df2ac6cc3ce5e79a38d53a5d Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Thu, 15 Oct 2026 15:58:14 +0200 Subject: [PATCH 1/4] 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 000000000..fea695609 --- /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 000000000..fbde35bf2 --- /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 23d5687c9..e15169b74 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 27d62c9d4..545eba8fc 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 31a414c0f..bf8ecc4ce 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 From 750de1345b99e3e2d8c598ba02e2fff016c74a78 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Sun, 18 Oct 2026 16:16:05 +0200 Subject: [PATCH 2/4] Allocate the CGT annual exempt amount at the rate each schedule's top gains reach With gains stacked on taxable income, a schedule's top gains can sit at its additional rate, but the exemption was still ordered by each schedule's higher rate. Under an additional-rate reform that could set the exemption against residential gains at 24% while main gains were charged at 45% (TCGA 1992 s. 1K(5) requires the most beneficial allocation). Order the exemption per person by the rate on each schedule's top gains before the exemption, which leaves current law unchanged because the higher and additional rates are equal. Adds the mixed-schedule regression from review: 200,000 earnings, 50,000 gains of which 25,000 residential, additional rate 45%: 15,900, not 16,530. Co-Authored-By: Claude Opus 5.5 --- .../capital_gains_tax_rate_bands.yaml | 17 ++++ .../test_capital_gains_tax_components.py | 43 ++++++++++ .../capital_gains_tax/capital_gains_tax.py | 85 +++++++++++++------ 3 files changed, 117 insertions(+), 28 deletions(-) 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 index fbde35bf2..26de1e9e2 100644 --- 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 @@ -136,3 +136,20 @@ capital_gains: 50_000 output: capital_gains_tax: 21_150 + +- name: Additional rate reform, main and residential gains, exemption on main gains. + # Taxable income 200,000 is above the higher rate limit, so all gains are + # above the higher rate band. Main gains are at the reformed 45% additional + # rate and residential property gains at their unchanged 24%, so the 3,000 + # annual exempt amount is most beneficial against main gains (TCGA 1992 + # s. 1K(5)): 25,000 x 0.24 = 6,000 and (25,000 - 3,000) x 0.45 = 9,900. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.additional_rate: 0.45 + age: 45 + employment_income: 200_000 + capital_gains: 50_000 + capital_gains_residential_property: 25_000 + output: + capital_gains_tax: 15_900 diff --git a/policyengine_uk/tests/test_capital_gains_tax_components.py b/policyengine_uk/tests/test_capital_gains_tax_components.py index e15169b74..5374c12bc 100644 --- a/policyengine_uk/tests/test_capital_gains_tax_components.py +++ b/policyengine_uk/tests/test_capital_gains_tax_components.py @@ -336,6 +336,49 @@ def test_setting_every_schedule_taxes_every_gain_alike(self): assert reformed[schedule] == pytest.approx(41_764.5), schedule +class TestExemptionAllocation: + """The annual exempt amount goes where it saves most (TCGA 1992 s. 1K(5)). + + Gains stack on taxable income, so a schedule's top gains can be at its + additional rate. The order of the exemption is set for each person from + the rate their top gains are actually charged at. + """ + + YEAR = 2025 + ADDITIONAL_RATE_ONLY = {"gov.hmrc.cgt.additional_rate": {"2025": 0.45}} + + def test_exemption_follows_each_persons_top_rate(self): + people, benunits, households = {}, {}, {} + for i, income in enumerate((200_000, 100_000)): + people[f"person_{i}"] = { + "age": {self.YEAR: 45}, + "employment_income": {self.YEAR: income}, + "capital_gains": {self.YEAR: 50_000}, + "capital_gains_residential_property": {self.YEAR: 25_000}, + } + benunits[f"benunit_{i}"] = {"members": [f"person_{i}"]} + households[f"household_{i}"] = {"members": [f"person_{i}"]} + sim = Microsimulation( + situation={ + "people": people, + "benunits": benunits, + "households": households, + }, + scenario=Scenario(parameter_changes=self.ADDITIONAL_RATE_ONLY), + ) + tax = sim.calculate("capital_gains_tax", self.YEAR).values + + # Taxable income 200,000 is above the higher rate limit, so main + # gains are at the reformed 45% and residential gains at 24%. The + # 3,000 exemption goes against main gains: 25,000 x 0.24 + 22,000 x + # 0.45 = 15,900, not 22,000 x 0.24 + 25,000 x 0.45 = 16,530. + assert tax[0] == pytest.approx(15_900) + # Taxable income 87,430 leaves 37,710 of higher band. Main gains fill + # it first, so none reach the 45% rate and every gain is at 24%: + # 47,000 x 0.24, wherever the exemption goes. + assert tax[1] == pytest.approx(11_280) + + class TestScheduleParameters: """Dates and values of the new gov.hmrc.cgt parameters by fiscal year.""" 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 bf8ecc4ce..37b1db91d 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,3 +1,5 @@ +import numpy as np + from policyengine_uk.model_api import * from policyengine_uk.utils.capital_gains import badr_gains_before_response @@ -29,8 +31,8 @@ class capital_gains_tax(Variable): "other two in proportion with the rest of the gains. All three are " "clipped so that together they never exceed capital_gains. 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 " + "against the schedule whose top gains are charged at the highest 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 " @@ -138,19 +140,6 @@ def component(amount): "main": main, "badr": badr, } - top_rate = {name: rates[name].higher_rate for name in rates} - top_rate["badr"] = cgt.badr.rate - - # 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 @@ -160,13 +149,6 @@ def component(amount): 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, # stacked on top of taxable income, so reforms can charge an # additional rate; in law the two rates are equal. Relief gains are @@ -175,12 +157,59 @@ def component(amount): rates, key=lambda name: -(rates[name].additional_rate - rates[name].higher_rate), ) - inside_higher, above_higher = {}, {} - 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 - ) + + def allocate_bands(schedule_amounts): + inside_basic, above_basic = {}, {} + band_left = remaining_basic_rate_band + for name in basic_order: + inside_basic[name], above_basic[name], band_left = _take( + schedule_amounts[name], band_left + ) + inside_higher, above_higher = {}, {} + 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 + ) + return inside_basic, inside_higher, above_higher + + # Annual exempt amount, TCGA 1992 s. 1K(5): deducted in the way most + # beneficial to the taxpayer, which is against the schedule whose top + # gains are charged at the highest rate first. A schedule's top gains + # are at its additional rate when, before the exemption, any of them + # reach above the higher rate band, which happens whenever taxable + # income is already above the higher rate limit; otherwise they are + # at its higher rate. Relief gains carry their own flat rate. The + # order is set for each person, and ties keep declaration order. + _, _, above_higher_before_aea = allocate_bands(amounts) + names = list(amounts) + top_rate = np.stack( + [ + ( + np.full_like(amounts[name], cgt.badr.rate, dtype=float) + if name == "badr" + else where( + above_higher_before_aea[name] > 0, + rates[name].additional_rate, + rates[name].higher_rate, + ) + ) + for name in names + ] + ) + stacked = np.stack([amounts[name] for name in names]) + order = np.argsort(-top_rate, axis=0, kind="stable") + stacked_after_aea = np.empty_like(stacked) + aea_left = aea + for position in range(len(names)): + index = order[position : position + 1] + amount = np.take_along_axis(stacked, index, axis=0)[0] + used = min_(amount, aea_left) + np.put_along_axis(stacked_after_aea, index, (amount - used)[None], axis=0) + aea_left = aea_left - used + after_aea = {name: stacked_after_aea[i] for i, name in enumerate(names)} + + inside_basic, inside_higher, above_higher = allocate_bands(after_aea) def schedule_tax(name): schedule = rates[name] From a6a975001f332a0017373b00d5ad416e7d19beea Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Sun, 18 Oct 2026 17:00:25 +0200 Subject: [PATCH 3/4] Hold gains fixed in the exemption allocation test Main's default realisation response (#2419) moves gains under a rate reform, so switch it off as the equalisation tests do; the test checks where the exemption goes on fixed gains. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_capital_gains_tax_components.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_capital_gains_tax_components.py b/policyengine_uk/tests/test_capital_gains_tax_components.py index efd4622d9..ff3a08a89 100644 --- a/policyengine_uk/tests/test_capital_gains_tax_components.py +++ b/policyengine_uk/tests/test_capital_gains_tax_components.py @@ -355,7 +355,12 @@ class TestExemptionAllocation: """ YEAR = 2025 - ADDITIONAL_RATE_ONLY = {"gov.hmrc.cgt.additional_rate": {"2025": 0.45}} + # The tax on gains held fixed shows where the exemption goes, so the + # realisation response is switched off. + ADDITIONAL_RATE_ONLY = { + "gov.hmrc.cgt.additional_rate": {"2025": 0.45}, + "gov.simulation.capital_gains_responses.elasticity": {"2025": 0}, + } def test_exemption_follows_each_persons_top_rate(self): people, benunits, households = {}, {}, {} From 508fbcc42806abebe423c660eb4b4f79fc48c930 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Mon, 19 Oct 2026 14:46:54 +0200 Subject: [PATCH 4/4] Set the CGT annual exempt amount pound by pound where it saves most The one-off ranking of schedules by the rate their top gains reach kept exempting a schedule after its top-rate slice was used up. The exemption is now allocated in passes: each puts it against the schedule where a pound saves most tax at the margin, including any band that removing it frees for another schedule, and stops where a schedule's gains cross a band boundary (TCGA 1992 s. 1K(5)). Adds the 124,000 earnings / carried interest boundary case from review as a YAML and a pytest regression. Co-Authored-By: Claude Opus 5.5 --- .../cgt-additional-band-stacking.fixed.md | 2 +- .../capital_gains_tax_rate_bands.yaml | 21 +++ .../test_capital_gains_tax_components.py | 35 ++++- .../capital_gains_tax/capital_gains_tax.py | 123 +++++++++++++----- 4 files changed, 145 insertions(+), 36 deletions(-) diff --git a/changelog.d/cgt-additional-band-stacking.fixed.md b/changelog.d/cgt-additional-band-stacking.fixed.md index fea695609..c14c40830 100644 --- a/changelog.d/cgt-additional-band-stacking.fixed.md +++ b/changelog.d/cgt-additional-band-stacking.fixed.md @@ -1 +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. +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, and set the annual exempt amount against the gains where each pound saves most tax. 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 index 26de1e9e2..9bc38acd2 100644 --- 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 @@ -153,3 +153,24 @@ capital_gains_residential_property: 25_000 output: capital_gains_tax: 15_900 + +- name: Additional rate reform, exemption follows the rate as main gains cross the higher rate limit. + # The personal allowance is 12,570 - (124,000 - 100,000) / 2 = 570, so + # taxable income is 123,430 and 125,140 - 123,430 = 1,710 of higher band is + # left. Main gains fill it first: 1,710 of the 2,000 main gains at 24% and + # 290 at the reformed 45%. Carried interest is at 32% in either band. The + # exemption saves 45% on the top 290 of main gains, then 32% on carried + # interest rather than 24% on the rest of the main gains (TCGA 1992 + # s. 1K(5)): 1,710 x 0.24 + (25,000 - 2,710) x 0.32 = 7,543.20, not 7,680 + # from exempting all the main gains first. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.cgt.additional_rate: 0.45 + gov.simulation.capital_gains_responses.elasticity: 0 + age: 45 + employment_income: 124_000 + capital_gains: 27_000 + capital_gains_carried_interest: 25_000 + output: + capital_gains_tax: 7_543.20 diff --git a/policyengine_uk/tests/test_capital_gains_tax_components.py b/policyengine_uk/tests/test_capital_gains_tax_components.py index ff3a08a89..a74387ba6 100644 --- a/policyengine_uk/tests/test_capital_gains_tax_components.py +++ b/policyengine_uk/tests/test_capital_gains_tax_components.py @@ -350,8 +350,9 @@ class TestExemptionAllocation: """The annual exempt amount goes where it saves most (TCGA 1992 s. 1K(5)). Gains stack on taxable income, so a schedule's top gains can be at its - additional rate. The order of the exemption is set for each person from - the rate their top gains are actually charged at. + additional rate. The exemption is set for each person, pound by pound, + against the gains where it saves most, so it moves to another schedule + once a schedule's gains fall to a lower rate. """ YEAR = 2025 @@ -393,6 +394,36 @@ def test_exemption_follows_each_persons_top_rate(self): # 47,000 x 0.24, wherever the exemption goes. assert tax[1] == pytest.approx(11_280) + def test_exemption_moves_on_when_the_marginal_rate_falls(self): + sim = Microsimulation( + situation={ + "people": { + "person": { + "age": {self.YEAR: 45}, + "employment_income": {self.YEAR: 124_000}, + "capital_gains": {self.YEAR: 27_000}, + "capital_gains_carried_interest": {self.YEAR: 25_000}, + } + }, + "benunits": {"benunit": {"members": ["person"]}}, + "households": { + "household": { + "members": ["person"], + "region": {self.YEAR: "LONDON"}, + } + }, + }, + scenario=Scenario(parameter_changes=self.ADDITIONAL_RATE_ONLY), + ) + tax = sim.calculate("capital_gains_tax", self.YEAR).values + + # Taxable income 123,430 leaves 1,710 of higher band, so 290 of the + # 2,000 main gains are at 45% and the rest at 24%; carried interest + # is at 32%. The exemption takes the 290 at 45%, then 2,710 of + # carried interest: 1,710 x 0.24 + 22,290 x 0.32 = 7,543.20, not + # 7,680 from exempting all the main gains first. + assert tax[0] == pytest.approx(7_543.20) + class TestScheduleParameters: """Dates and values of the new gov.hmrc.cgt parameters by fiscal year.""" 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 37b1db91d..3a6d27647 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 @@ -17,6 +17,12 @@ def _take(amount, band_left): return inside, above, band_left - inside +# Each pass of the exemption allocation ends where a schedule's gains cross a +# band boundary, so a handful is enough; four schedules in three bands give at +# most twelve boundaries. +_MAX_EXEMPTION_PASSES = 16 + + class capital_gains_tax(Variable): label = "capital gains tax" documentation = ( @@ -31,8 +37,8 @@ class capital_gains_tax(Variable): "other two in proportion with the rest of the gains. All three are " "clipped so that together they never exceed capital_gains. The annual " "exempt amount is set " - "against the schedule whose top gains are charged at the highest rate " - "first (TCGA 1992 s. 1K(5)); the unused basic rate band goes to relief gains first, as " + "against the gains where it saves most tax, pound by pound " + "(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 " @@ -174,40 +180,91 @@ def allocate_bands(schedule_amounts): return inside_basic, inside_higher, above_higher # Annual exempt amount, TCGA 1992 s. 1K(5): deducted in the way most - # beneficial to the taxpayer, which is against the schedule whose top - # gains are charged at the highest rate first. A schedule's top gains - # are at its additional rate when, before the exemption, any of them - # reach above the higher rate band, which happens whenever taxable - # income is already above the higher rate limit; otherwise they are - # at its higher rate. Relief gains carry their own flat rate. The - # order is set for each person, and ties keep declaration order. - _, _, above_higher_before_aea = allocate_bands(amounts) + # beneficial to the taxpayer. Each pound saves the tax it would have + # borne at the margin, which depends on the schedule it comes off and, + # where removing it frees band for another schedule, on that schedule + # too. So the exemption is set greedily: in each pass, against the + # schedule where a pound saves most, up to the point where any + # schedule's gains cross a band boundary and the saving can change. + # Ties keep declaration order. Under current law, where the higher and + # additional rates are equal, this gives the same allocation as + # ranking the schedules by rate, apart from float rounding where the + # exemption is split across schedules. names = list(amounts) - top_rate = np.stack( - [ - ( - np.full_like(amounts[name], cgt.badr.rate, dtype=float) - if name == "badr" - else where( - above_higher_before_aea[name] > 0, - rates[name].additional_rate, + flat_rate = {"badr": cgt.badr.rate} + + def slices(schedule_amounts): + inside_basic, inside_higher, above_higher = allocate_bands(schedule_amounts) + result = [] + for name in names: + if name in flat_rate: + band_rates = [flat_rate[name]] * 3 + else: + band_rates = [ + rates[name].basic_rate, rates[name].higher_rate, + rates[name].additional_rate, + ] + parts = [inside_basic[name], inside_higher[name], above_higher[name]] + result += list(zip(parts, band_rates)) + return result + + left = {name: amounts[name].astype(float) for name in names} + used = {name: np.zeros_like(left[name]) for name in names} + aea_left = np.full_like(left["main"], aea) + step = 1e-6 + for _ in range(_MAX_EXEMPTION_PASSES): + if not np.any(aea_left > 0): + break + base = slices(left) + savings, room = [], [] + for name in names: + nudged = dict(left) + nudged[name] = left[name] - step + saving = np.zeros_like(aea_left) + distance = np.full_like(aea_left, np.inf) + for (part, rate), (moved, _) in zip(base, slices(nudged)): + slope = np.round((moved - part) / step) + saving = saving - slope * rate + shrinking = (slope < 0) & (part > 0) + distance = where( + shrinking, + min_(distance, part / where(shrinking, -slope, 1)), + distance, ) - ) - for name in names - ] - ) - stacked = np.stack([amounts[name] for name in names]) - order = np.argsort(-top_rate, axis=0, kind="stable") - stacked_after_aea = np.empty_like(stacked) - aea_left = aea - for position in range(len(names)): - index = order[position : position + 1] - amount = np.take_along_axis(stacked, index, axis=0)[0] - used = min_(amount, aea_left) - np.put_along_axis(stacked_after_aea, index, (amount - used)[None], axis=0) - aea_left = aea_left - used - after_aea = {name: stacked_after_aea[i] for i, name in enumerate(names)} + active = (left[name] > 0) & (aea_left > 0) + savings.append(where(active, np.round(saving, 9), -np.inf)) + room.append(min_(left[name], distance)) + savings = np.stack(savings) + best = np.argmax(savings, axis=0) + can_take = np.max(savings, axis=0) > -np.inf + for i, name in enumerate(names): + taken = where(can_take & (best == i), min_(aea_left, room[i]), 0) + left[name] = left[name] - taken + used[name] = used[name] + taken + aea_left = aea_left - taken + # Any exemption left after the passes, which only happens when gains + # cross more band boundaries than there are passes, goes in + # declaration order. + for name in names: + taken = min_(aea_left, left[name]) + used[name] = used[name] + taken + aea_left = aea_left - taken + # Where the whole exemption went against one schedule, deduct it in a + # single step so that the passes add no rounding of their own. + schedules_used = sum((used[name] > 0).astype(int) for name in names) + after_aea = { + name: where( + schedules_used <= 1, + where( + used[name] > 0, + amounts[name] - min_(amounts[name], aea), + amounts[name], + ), + amounts[name] - used[name], + ).astype(amounts[name].dtype) + for name in names + } inside_basic, inside_higher, above_higher = allocate_bands(after_aea)