Skip to content

Read HMRC's joint distribution of capital gains by taxable income - #558

Open
MaxGhenis wants to merge 2 commits into
mainfrom
uk-cgt-source-stage
Open

Read HMRC's joint distribution of capital gains by taxable income#558
MaxGhenis wants to merge 2 commits into
mainfrom
uk-cgt-source-stage

Conversation

@MaxGhenis

Copy link
Copy Markdown
Contributor

Part one of #552: the source surface. The imputation stage and band-level UK_CGT_TARGET_SPECS facts follow in a second PR built on this one.

Why

Populace has no capital gains source stage — cgt_calibration.py reads capital_gains as a persisted input that arrives with the certified candidate, and the two declared HMRC facts are aggregates (total gains, taxpayer count). The distribution underneath was imputed from a percentile table that stops at the 99th income percentile with a p95 of £1.16m, so it holds effectively nothing above £2m — against roughly 5,000 taxpayers there in HMRC's statistics, including 2,000 above £5m holding £22.7bn of gains. Measured on the current candidate, the two top bands carry 54.6% of UK CGT and 0.2% of the modelled tax.

Populace builds from raw sources, so the stage gets written once, from the primary publication. HMRC table 3 gives the joint distribution directly — individuals and gains by size of gain crossed with taxable income, eleven gain bands to £5m+ by six income bands — which is the surface an income-conditioned imputation needs, and it reconciles with table 2.1a at 359,000 individuals and £62.9bn for 2023-24. Four published years (2020-21 through 2023-24), so the stage can fit per year rather than aging one forward. The top two bands hold 50.7%, 52.2%, 54.8% and 54.9% of gains across those years — the concentration is not a one-year artifact.

What this adds

  • uk_runtime/ods_tables.py — stdlib ODS reader taking the artifact label as an argument, so errors name their source. Repeat-run expansion is bounded, and suppressed cells arrive as their marker text rather than being coerced or dropped. hmrc_income keeps its own reader with SPI-specific messages; migrating it onto this is deliberately out of scope so a certified surface does not move inside a feature.
  • uk_runtime/hmrc_capital_gains.py — the joint distribution as typed cells with pinned provenance (URL, sha256, size), checked before parsing so a wrong artifact fails on its identity. Counts convert from thousands, amounts from £ millions.
  • 27 tests: synthetic sheets for the parsing logic, and a skipif class against the real pinned ODS (inputs/hmrc/, an optional local input like the SPI one) covering the fingerprint, the published totals, all four years, and the top-tail presence that motivates the module.

What the published data forced

Two things assumption would have gotten wrong. Amounts are suppressed as well as counts — 2022-23 withholds one — so both come back as None, with a suppressed count meaning "fewer than 500 people" rather than zero, and the result reports how much published mass sits inside suppressed cells (±£4m across the four years: rounding, not real mass). And the number of note rows above the header varies by year, so the header is located by its column heading rather than a fixed row index.

Basis notes for the follow-up stage

Gains are after losses and attributed gains but before the AEA; taxable income is after reliefs and the personal allowance; trusts are excluded; and only individuals with a CGT liability appear. The existing two UK_CGT_TARGET_SPECS facts come from table 1, which includes trusts (378,000 / £65.9bn), so band facts from this surface need declaring on the individuals-only basis rather than mixed with them. Sub-AEA gainers are outside this table entirely and need a separate decision.

Tests

uv run pytest packages/populace-build/tests: exit 0, all green (the repo's -q addopts plus a command-line -q suppress the count line; the progress lines reach 100% with no failures). ruff check and ruff format --check pass on the changed files.

🤖 Generated with Claude Code

The FRS does not ask about capital gains, so a UK build imputes them, and
what it imputes against decides whether the result can answer a capital gains
question at all. The tax sits with very few people — roughly 2,000 taxpayers
above £5m of gains hold £22.7bn of them — so a source that cannot reach those
bands yields a population whose total is right and whose shape is wrong.

HMRC table 3 publishes the joint distribution of size of gain against taxable
income, which is the surface an imputation conditioned on income needs, and it
carries the top bands. Its totals reconcile with table 2.1a at 359,000
individuals and £62.9bn of gains for 2023-24.

Add a source module that reads it: provenance pinned and checked before the
parse, so a wrong artifact fails on its identity rather than inside a parse;
counts converted from thousands and amounts from £ millions; and all four
published years available, so a stage can fit each year rather than aging one
forward.

Two things the published data required rather than assumption. Amounts are
suppressed as well as counts — 2022-23 withholds one — so both come back as
None rather than zero, and the result reports how much published mass sits
inside suppressed cells: ±£4m across the four years, which is rounding. The
number of note rows above the header varies by year, so the header is located
by its column heading rather than by a fixed index.

The ODS reader is separate and takes the artifact label as an argument.
`hmrc_income` keeps its own reader with SPI-specific messages; migrating it
onto this belongs in its own change rather than moving a certified surface
inside a feature.

Part of the capital gains source stage for #552. The
imputation itself and the band-level calibration targets follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Counts publish in thousands, so a suppressed count means fewer than 1,000
people, not the 500 the docstrings claimed. The allocation convention that
consumes suppression is downstream; here only the documented meaning moves.

Capture the published all-incomes pair per band row as band totals. Row
totals cover suppressed cells, so a band containing one keeps its full
published total and a defined mean — which the imputation needs as its
fallback where a cell's own count is withheld, since dividing a published
amount by an assumed count can put the implied mean outside the band
entirely. gains_by_band now reads the row totals rather than summing cells.

Co-Authored-By: Claude Opus 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.

1 participant