Skip to content

Stack gains on taxable income when splitting the higher and additional CGT bands - #2433

Open
vahid-ahmadi wants to merge 1 commit into
mainfrom
fix/cgt-additional-band-stacking
Open

vahid-ahmadi wants to merge 1 commit into
mainfrom
fix/cgt-additional-band-stacking

Conversation

@vahid-ahmadi

Copy link
Copy Markdown
Collaborator

Fixes #2432

What and why

capital_gains_tax split gains above the basic rate band between the higher and additional CGT bands using the whole higher band (higher_rate_limit - basic_rate_limit, £87,440 in 2025-26) for every gainer. It never subtracted taxable income already above the basic rate limit, so a reform that sets the CGT higher and additional rates apart charged someone with £200,000 of income at the higher rate on their gains.

Gains are the top slice of income (TCGA 1992 ss. 1H(3), 1I). In law CGT has only two rates, 18% and 24%, and the model's additional_rate parameters exist only so reforms can mirror the income tax additional-rate band. For that split to mirror income tax, the gains above the basic rate band start at taxable income or the basic rate limit, whichever is higher:

remaining_higher_rate_band = max_(
    higher_rate_limit - max_(taxable_income, basic_rate_limit), 0
)

The main, residential property, carried interest and relief schedules share one higher-band loop, so the fix covers all four. No values are hard-coded.

£200,000 of earnings and £50,000 of gains, 2025-26:

Reform Main This branch
Current law £11,280 £11,280
CGT additional rate 45% £11,280 £21,150
CGT higher rate 45% £21,150 £11,280

Current law

The higher and additional CGT rates are equal at every date, so current-law CGT is unchanged apart from float32 rounding where gains now straddle the moved split point. Two frozen expectations were updated for this:

  • test_gift_aid.py::test_zero_gift_preserves_main_outputs_bit_for_bit: one row (£101,000 earnings, £40,000 gains) moves from 8880 to 8879.9990234375.
  • test_capital_gains_tax_components.py::legacy_capital_gains_tax: the reference formula behind the bit-identity tests now uses the stacked higher band, so it still matches the new formula bit for bit.

TestEqualisationReforms hard-coded the buggy figure (£39,278: 87,440 at 40% and 9,560 at 45% on £87,430 of taxable income). Its expectation is now £41,764.50: 37,710 at 40% and 59,290 at 45%.

Interaction with #2419

#2419 (default CGT realisation response) answers the rate on the last pound of gains, so the wrong band also drives the wrong response there. Its docs example (£100,000 earnings, £200,000 gains, higher rate raised to 45%) quotes tax rising from £47,280 to £65,642 with no response. After this fix it rises to about £55,199: 37,710 at 45% and 159,290 at 24%. Its other example, with both rates at 45%, does not change.

Test plan

  • New capital_gains_tax_rate_bands.yaml (10 cases, hand-computed): income below the basic rate limit, between the limits and above the higher rate limit under an additional-rate reform; higher-rate reforms; residential property and carried interest additional-rate reforms; current law; and the earnings-based example above. 7 fail on main and all pass here. The 3 that pass on main are the two current-law cases and the below-basic-limit case, where the whole band is correctly left.
  • policyengine-core test policyengine_uk/tests/policy: 3,444 passed.
  • pytest test_capital_gains_tax_components.py test_capital_gains_responses.py test_capital_gains_projection.py test_gift_aid.py and the other tests that touch CGT: pass.
  • Full pytest -n 8 policyengine_uk/tests: 5,966 passed, 102 skipped, 1 xfailed.
  • ruff format --check and ruff check: clean.
  • Changelog fragment in changelog.d/

Reproducer

Reproducer: policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax_rate_bands.yaml, policyengine_uk/tests/test_capital_gains_tax_components.py::TestEqualisationReforms::test_main_rate_reform_leaves_the_other_schedules_at_current_law

axiom: n/a: the additional CGT band is a model-only reform hook; TCGA 1992 ss. 1H-1I as enacted have two rates (18% and 24%) and the higher and additional parameters are equal at every date, so no encoded rule or current-law result changes

🤖 Generated with Claude Code

…l 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 <noreply@anthropic.com>

@juaristi22 juaristi22 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 54fa62d64d28a976df2ac6cc3ce5e79a38d53a5d. The new remaining-band expression matches the declared third-band reform convention in isolation, but it exposes one material interaction with the annual exemption.

C1 — Allocate the exemption at the rates actually reached after stacking. At capital_gains_tax.py:75–77 (consumed at line 179), the higher-band capacity becomes zero for a person already above the higher limit. The unchanged exemption ordering at lines 141–151 still compares only each schedule’s higher_rate, so it can exempt the wrong gains.

Reproducer: one adult aged 45 in 2025, employment income £200,000, total gains £50,000, of which residential-property gains are £25,000; reform only gov.hmrc.cgt.additional_rate to 0.45. No intermediate tax, allowance or eligibility inputs are supplied, and behavioural elasticities retain their zero defaults.

Head charges £16,530 CGT. It assigns the £3,000 exemption to residential gains at 24%, while the remaining main gains are now charged at 45%. Assigning the exemption to main gains instead gives £15,900: £25,000 × 24% + £22,000 × 45%. The resulting £630 excess reaches household tax and net income. An independent same-head simulation using only the exact captured-base CGT formula gives £11,280: under the old whole-higher-band allocation all £47,000 taxable gains in this case remained at 24%, so exemption placement was immaterial. This demonstrates why the changed split exposes the interaction.

TCGA 1992 s.1K(5) preserves the most-beneficial exemption allocation, irrespective of the otherwise applicable rate. The third rate remains a hypothetical reform hook; this finding does not claim it is an enacted general CGT rate. Please preserve beneficial exemption allocation under the new stacked bands and add a mixed-schedule regression. The new YAML cases each use only one taxable gain schedule.

Validation: 243 repository checks and 11 independent diagnostics passed; the added mixed-gain exemption regression failed at £16,530 versus £15,900. One private microsimulation test was deselected. Independent boundary, Gift Aid/pension extension, four-schedule conservation and final household-income checks passed. A 384-household synthetic current-law comparison against the captured-base formula found only float32 rounding differences (maximum CGT £0.0078125, household tax £0.015625; final net income unchanged). Nine official source originals checked. The Axiom n/a explanation and required reproducers/changelog are adequate.

At capture and the mid-review check, four hosted Python shards were still running; other reported checks passed. Review status: COMPLETE. Review severity: REQUEST_CHANGES. Confirmed critical count: 1.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CGT higher and additional band split ignores taxable income above the basic rate limit

2 participants