Skip to content

Add HMRC capital gains targets to constrain the gains distribution#440

Merged
vahid-ahmadi merged 3 commits into
mainfrom
targets/hmrc-cgt-distribution
Jul 19, 2026
Merged

Add HMRC capital gains targets to constrain the gains distribution#440
vahid-ahmadi merged 3 commits into
mainfrom
targets/hmrc-cgt-distribution

Conversation

@vahid-ahmadi

Copy link
Copy Markdown
Collaborator

Problem

The Enhanced FRS imputes capital gains, but nothing in the calibration constrains how those gains are distributed. Measured on the stock weights for 2026 (reproduced from a real Microsimulation via _SimContext):

Enhanced FRS (2026 stock weights) HMRC (2023-24 outturn)
CGT taxpayers 1.29m 378k
Total chargeable gains £112bn £65.9bn
Mean gain £87,158 ~£174,000

3.4x the taxpayer count with a mean gain about half the administrative figure — gains spread across far too many households in amounts individually too small.

This is distributional error, not a level shift, so a revenue target does not fix it. #439 restores the silently-dropped obr/capital_gains_tax revenue target, which constrains what CGT raises; these targets constrain how gains are distributed. The two are complementary, not duplicative.

In a CGT reform analysis, adding these two targets moved the share of people affected from 5.0% to 1.65% — a factor of three.

What this adds

policyengine_uk_data/targets/sources/hmrc_cgt.py (auto-discovered by targets/registry.py, no registration needed):

  • hmrc/capital_gains_total — £65.9bn, variable="capital_gains", Unit.GBP
  • hmrc/cgt_taxpayers — 378,000, is_count=True

Both use a custom_compute. The default count path (build_loss_matrix._compute_simple_countctx.pe_count) counts people with capital_gains > 0, which is not HMRC's concept: HMRC counts CGT taxpayers, i.e. people whose gains exceed the annual exempt amount (AEA).

The amount target is gated on the same AEA condition, and the module docstring records why: HMRC's £65.9bn is the gains of CGT-liable taxpayers, not all gains realised. Gating only the count would target a mean gain that is a ratio of two different populations, and would let the optimiser satisfy the amount with sub-threshold gains HMRC never counts.

The AEA is read from PolicyEngine's own parameter tree (gov.hmrc.cgt.annual_exempt_amount) for the simulation year — verified to return £12,300 (2022), £6,000 (2023), £3,000 (2024 onward) — with an explicit module-level mapping as fallback. A single hard-coded threshold would silently change meaning across years.

Also adds the HMRC CGT statistics URL to the hmrc: section of sources.yaml and a changelog.d/ fragment.

Year handling

get_all_targets(year=...) filters on exact year membership, and build_loss_matrix._resolve_value only falls back to a past year within 3 years, with no uprating for non-VOA sources. Supplying the 2023-24 outturn year alone would therefore constrain 2024-2027 with a flat nominal value and nothing thereafter — materially limiting usefulness for the projection years the calibration actually runs.

So: base year 2024 (tax year 2023-24 mapped to calendar 2024, following hmrc_spi._SPI_YEAR), with the gains amount projected to 2029 using the repo's existing utils/uprating.uprate_values with PolicyEngine's capital_gains factors: £65.9bn (2024) → £69.7bn (2026) → £76.6bn (2029).

The taxpayer count is held flat across 2024-2029. There is no administrative basis in this repo for forecasting CGT taxpayer numbers, and the uprating factors are nominal-income indices that should not be applied to a headcount.

Documented caveat (also in the module docstring): the 2023-24 outturn was realised under a £6,000 AEA, while the gate in later years uses the £3,000 AEA then in force. The targets are approximate for projection years; a fixed £6,000 gate would be wrong in a less transparent way, since it would describe no year's actual policy.

Verification

policyengine_uk_data/tests/test_hmrc_cgt_targets.py — 6 tests, all passing (plus test_target_registry.py, 21 total):

  • sub-AEA people excluded from the count
  • amount gated on the same threshold
  • AEA tracks the year (a £5,000 gain is a taxpayer in 2024, not in 2023 — a hard-coded threshold fails one of these)
  • targets load with the expected base-year values
  • gains target is projected through 2029 and monotonically increasing
  • both targets are discovered by get_all_targets(year=2026)

Beyond the stubbed-context unit tests, both custom_compute callables were run against a real Microsimulation for 2026 through the actual _SimContext, returning correctly-shaped household arrays (53,508) and reproducing the headline table above exactly (1,285,101 taxpayers, £112.0bn, £87,158 mean).

🤖 Generated with Claude Code

vahid-ahmadi and others added 2 commits July 19, 2026 14:59
The Enhanced FRS imputes capital gains but nothing constrains them to
administrative totals. On the 2026 stock weights the microdata carries
1.29m CGT taxpayers holding £112bn of gains (£87,158 mean) against
HMRC's 378k, £65.9bn and ~£174,000.

Adds hmrc/capital_gains_total and hmrc/cgt_taxpayers, both gated on the
annual exempt amount read from PolicyEngine's parameter tree for the
simulation year, so they match HMRC's CGT-taxpayer concept rather than
"anyone with a positive gain".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The existing tests drive the compute functions through a _dummy_ctx
stub, which would keep passing if pe_person or household_from_person
were renamed on the real _SimContext. Add coverage that binds to the
real integration points and pins the properties the module docstring
claims:

- build a real _SimContext against a Microsimulation and run both
  compute functions (marked slow, skips when the enhanced FRS is
  absent), plus a dataset-free introspection guard on _SimContext so a
  rename fails even when the data test skips
- assert both targets survive registry year filtering across the
  projection range and keep custom_compute, which is Field(exclude=True)
- pin the AEA gate boundary: gains exactly at the AEA do not count
  (strict >), one pound above does, and the boundary moves with the year
- pin that the amount gate and the count gate select the same people
- pin that losses contribute nothing, and that gated gains can exceed
  ungated household-mapped gains because losses are dropped
- pin the flat taxpayer count as a deliberate, documented choice

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vahid-ahmadi

Copy link
Copy Markdown
Collaborator Author

Flagging one methodological choice in this PR that I don't think should be settled without a second opinion, since it's a data-sourcing question rather than an implementation one.

The taxpayer count is held flat at 378,000 across 2024-2029, but the gate applied in those years is not the gate that produced 378,000.

HMRC's 378,000 is the 2023-24 outturn, realised under a £6,000 annual exempt amount. From 2024-25 the AEA is £3,000, and _annual_exempt_amount correctly tracks that — so in the projection years the target says "reweight until 378,000 people have gains above £3,000", when 378,000 was the number of people with gains above £6,000.

Halving the allowance pulls materially more people into CGT, so the true count under the £3,000 regime should be well above 378,000. The OBR's own receipts step up sharply over the same window (£13.7bn in 2024-25 to £21.8bn in 2025-26 in the March 2026 EFO, reflecting both the rate rises and the allowance cut). Targeting a flat 378,000 therefore asks the calibration to squeeze the taxpayer count below the level the policy implies, and because the gains total is uprated while the count is not, the implied mean gain drifts upward year on year to absorb the difference (roughly £174k in 2024 to £203k by 2029).

The PR documents this as a caveat and I think the reasoning given — that a fixed £6,000 gate "would not describe any year's actual policy" — is right as far as it goes. The issue is that the current combination isn't obviously better: it pairs a correct, year-tracking threshold with a count that belongs to a different threshold.

Three options, roughly in order of preference:

  1. Source a projected taxpayer count under the £3,000 regime. HMRC's CGT statistics commentary or OBR supplementary tables may give a forecast headcount; that would make both targets internally consistent. Best outcome if the number exists.
  2. Restrict the count target to 2024 only and let the uprated gains total carry the projection years. Loses distributional pinning in later years, but never states something known to be wrong.
  3. Keep it as is with the caveat, if the view is that an approximately-right count beats no count at all.

I don't have a strong enough view on UK target sourcing to pick unilaterally — deferring to whoever owns this area. Happy to implement whichever way it goes.

For context on why the count matters at all: the motivation for these targets is that the stock weights carry 1.29m CGT taxpayers against HMRC's 378k, and the count target is the thing doing most of the work in fixing that. Getting its level wrong in the projection years is a smaller error than having no target, but it is the one number here I'd want a second pair of eyes on.

@vahid-ahmadi vahid-ahmadi self-assigned this Jul 19, 2026
CI measured 5.18m weighted taxpayers against the band's 3m ceiling. The
count depends on which dataset build the test runs against - a fresh
in-CI build and the published projection differ by millions - so the
band failed for reasons unrelated to what the test exists to check. The
structural and relative assertions around it still cover the integration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vahid-ahmadi
vahid-ahmadi merged commit a5af236 into main Jul 19, 2026
4 checks passed
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.

1 participant