Read HMRC's joint distribution of capital gains by taxable income - #558
Open
MaxGhenis wants to merge 2 commits into
Open
Read HMRC's joint distribution of capital gains by taxable income#558MaxGhenis wants to merge 2 commits into
MaxGhenis wants to merge 2 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part one of #552: the source surface. The imputation stage and band-level
UK_CGT_TARGET_SPECSfacts follow in a second PR built on this one.Why
Populace has no capital gains source stage —
cgt_calibration.pyreadscapital_gainsas 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_incomekeeps 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.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_SPECSfacts 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-qaddopts plus a command-line-qsuppress the count line; the progress lines reach 100% with no failures).ruff checkandruff format --checkpass on the changed files.🤖 Generated with Claude Code