Skip to content

Declare UK capital gains target facts (populace-uk has none; taxpayer count is 3.9x HMRC)#460

Open
vahid-ahmadi wants to merge 2 commits into
mainfrom
feat/uk-cgt-targets
Open

Declare UK capital gains target facts (populace-uk has none; taxpayer count is 3.9x HMRC)#460
vahid-ahmadi wants to merge 2 commits into
mainfrom
feat/uk-cgt-targets

Conversation

@vahid-ahmadi

Copy link
Copy Markdown

Closes #459 (partly — see "What this does not do").

Problem

The published populace-uk release (populace-uk-2023-dd68c73-4aa4b14) carries 149 calibration targets and none of them constrain capital gains. The only CGT-adjacent entry in the target surface is the column household.household_is_capital_gains_clone, which is a feature flag from the imputation rather than a target.

With nothing pulling the gains distribution toward administrative data, it drifts badly. Reading capital_gains weighted by household_weight straight out of populace_uk_2023.h5:

Statistic populace-uk 2023 Enhanced FRS (2026) HMRC 2023-24
CGT taxpayers (gains > AEA) 1.47m 1.29m ~378k
Gains of those taxpayers £96.0bn £112.0bn ~£65.9bn
Mean gain per taxpayer £65,374 £87,158 ~£174,000

The calibrated dataset is 3.9x HMRC's taxpayer count with a mean gain 62% below the administrative figure — and on the two statistics that matter most for CGT work it is further from HMRC than the stock Enhanced FRS it improves on elsewhere. The shape is what you would expect from an unconstrained imputation: gains spread across far too many households in amounts that are individually far too small.

This is distributional error, not a level shift, so it is not fixed by correcting revenue. (Separately, PolicyEngine/policyengine-uk-data#439 restores an OBR CGT revenue target that was being silently dropped; that constrains what CGT raises, not how gains are distributed. The two are complementary.)

It matters in practice: calibrating a CGT reform analysis with these two targets added moved the share of people affected from 5.0% to 1.65% — a factor of three.

What this adds

uk_runtime/fiscal_targets.py, following us_runtime/fiscal_targets.py:

  • hmrc/capital_gains_total — £65.9bn, HMRC CGT statistics 2023-24
  • hmrc/cgt_taxpayers — 378,000, same source
  • a TargetCoverageRequirement so a build that drops either fact fails the gate rather than passing quietly, which is the failure mode that let this go unnoticed
  • UK_CGT_REQUIRED_COLUMNS, naming the prepared household columns the measures read

Values are HMRC's published outturn at period=2023 rather than uprated approximations, on the principle that the registry declares facts and the build ages them.

Both facts are household-grain to match the UK weights, so both need prepared columns — the registry refuses callables so it can serialize, and count-like facts are documented to use prepared indicator/count columns. Two caveats are recorded in notes:

  • The taxpayer count is policy-dependent. The AEA moved £12,300 → £6,000 → £3,000 across 2022-23 to 2024-25, so the indicator must use the allowance in force for the period or the target silently means different things across years.
  • Gains net of losses can be negative per household even though the total is positive. Given the net-STCG incident cited in the registry docstring is this same variable on the US side, the sign handling deserves an explicit decision from whoever wires up the column.

What this does not do

These specs are not yet consumed by anything. I could not find the UK national build in this repo — no enhanced_frs reference in any Python file, no UK release workflow (only test.yml), and the UK-side code here is local/geography plus a populace-data registry entry pointing at the HF artifact. So I can declare the facts and prove they compile, but I cannot show the UK build picking them up. Pointers very welcome; wiring is the obvious follow-up and I am happy to do it.

Two open questions for reviewers:

  1. Where should the prepared columns be defined? capital_gains as a household sum and cgt_taxpayer_count as an above-AEA count.
  2. Inline values or Ledger-sourced? us_runtime/fiscal_targets.py has moved to value-free references with values arriving from an external Ledger artifact at build time. These specs carry inline values with citations, matching the TargetSpec contract as documented ("a value, optionally a standard error, and always a citation"). If the UK should follow the US onto Ledger, this is the shape to migrate and I will redo it that way.

Raised with @MaxGhenis, who confirmed UK targets should move to populace-native TargetSpecs.

Tests

Seven tests pin the declared facts, their provenance, the household grain, the prepared-column contract, the content-addressed registry, and the coverage requirement. ruff check and ruff format clean.

The published populace-uk release carries 149 targets and none constrain
capital gains, so the calibrated weights leave the gains distribution
unanchored. Reading capital_gains weighted by household_weight straight out of
populace_uk_2023.h5 gives 1.47m CGT taxpayers holding GBP 96.0bn at a GBP
65,374 mean, against HMRC's 378k, GBP 65.9bn and ~GBP 174,000 - 3.9x the
taxpayer count with a mean gain 62% below the administrative figure.

That is distributional error rather than a level shift, so it survives any
revenue-side correction: on an uncalibrated baseline the share of people
affected by a CGT rate reform comes out roughly three times too high.

Declares hmrc/capital_gains_total and hmrc/cgt_taxpayers as household-grain
facts with HMRC citations, following us_runtime.fiscal_targets, plus a coverage
requirement so dropping either fails the gate instead of passing quietly. Both
measures are prepared columns (the registry refuses callables); the taxpayer
count must track the annual exempt amount in force for the period, which moved
GBP 12,300 -> 6,000 -> 3,000 across 2022-23 to 2024-25.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vahid-ahmadi
vahid-ahmadi force-pushed the feat/uk-cgt-targets branch from 6337f51 to 5242b99 Compare July 19, 2026 13:29
@vahid-ahmadi
vahid-ahmadi requested a review from MaxGhenis July 19, 2026 13:30
@vahid-ahmadi vahid-ahmadi self-assigned this Jul 19, 2026
The declared CGT facts were person-measures wearing a household entity, and
nothing built the columns they compile against. Correct both specs to
entity="person" — matching the uk_runtime.hmrc_calibration convention where
constraint rows are person-grain and the calibrated mass stays on household
Weights — and rename their measures to the prepared columns
uk_cgt_measure_gains_amount and uk_cgt_measure_taxpayer_count.

Add uk_runtime.cgt_calibration.materialize_uk_cgt_calibration_frame, which
prepares those columns from the persisted person-level capital_gains input (no
simulation needed), assembles the person+household Frame with household
Weights and the dataset mass log, and returns the declared facts as a
TargetRegistry.

The CGT taxpayer indicator is capital_gains > annual exempt amount, and the
AEA is policy-dependent (12,300 -> 6,000 -> 3,000 across 2022-23 to 2024-25).
The default is derived from dataset.time_period through an explicit mapping and
an unmapped period raises: silently defaulting would change what "CGT
taxpayer" means without changing the declared target value. Missing
capital_gains and zero positive-mass support both fail closed rather than
calibrating the positive HMRC facts to zero rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

UK calibration has no capital gains targets; CGT distribution is ~3.9x HMRC taxpayer count

1 participant