Skip to content

Fix the capital gains realisation response - #1803

Merged
MaxGhenis merged 6 commits into
mainfrom
cgt-elasticity-wiring
Aug 13, 2026
Merged

Fix the capital gains realisation response#1803
MaxGhenis merged 6 commits into
mainfrom
cgt-elasticity-wiring

Conversation

@MaxGhenis

Copy link
Copy Markdown
Collaborator

Why

The capital gains realisation response has never affected a costing. Setting gov.simulation.capital_gains_responses.elasticity to any value leaves revenue unchanged, because relative_capital_gains_mtr_change measured the baseline marginal rate on simulation.get_branch("baseline"). Simulations hold their baseline as a separately constructed simulation rather than a branch, so that call clones the reform simulation and both sides of the comparison carry reform parameters. The measured rate change is exactly zero for every reform, for every taxpayer.

Verified on 2.89.3 before the fix — equalising CGT rates with income tax rates on the enhanced FRS returns £36.97bn of CGT at elasticities 0, 1.0, 1.5 and 2.0 alike, with a £0.00bn response in all four runs. Same result via scenario= and reform=.

#1319 reported this symptom and fixed the input plumbing it identified (move_values for capital_gains_before_response, which does now populate). The measurement itself still reported no change, so the symptom survived the fix.

What changed

  • Measure the baseline rate in simulation.baseline rather than in a branch of the reform simulation. Extracted to policyengine_uk/utils/capital_gains.py, shared by both change variables.
  • Clone the tax-benefit system for the measurement branches. They neutralise capital_gains_behavioural_response to stop the measurement recursing, and cloning keeps that neutralisation off the simulation being measured.
  • Define the elasticity against the retention rate rather than the tax rate, and add relative_capital_gains_retention_rate_change. Every published estimate — Agersnap and Zidar, Lavecchia and Tazhitdinova, the CenTax package — is reported against the retention rate, so a published value can now be entered as published. Under the previous convention a positive elasticity raised realisations when rates rose; nothing could depend on that, since no elasticity had any effect.

The default elasticity stays 0, so every existing costing is unchanged.

Effect

Equalising CGT rates with income tax rates (20/40/45), enhanced FRS, 2026:

Elasticity Gains CGT vs baseline vs static
0 (static) £89.6bn £36.97bn +£15.73bn
0.5 £76.9bn £31.40bn +£10.17bn −£5.57bn
1.0 £66.0bn £26.66bn +£5.42bn −£10.31bn
1.5 £56.7bn £22.62bn +£1.39bn −£14.35bn
2.0 £48.7bn £19.20bn −£2.04bn −£17.78bn

Read the shape rather than the levels: the imputed gains distribution has almost no top tail (99 taxpayers above £2m against roughly 5,000 in HMRC's 2023-24 statistics, and none above £5m), so the level of any CGT costing is understated where most of the tax sits. That is a data issue, tracked separately.

Tests

New policyengine_uk/tests/test_capital_gains_responses.py, six situation-based tests that run without dataset access: the rate change registers against the baseline, realisations fall further at higher elasticities, dynamic revenue lands below static, a zero elasticity keeps costings static, a no-reform simulation reports no response, and the measurement leaves the response variable active.

pytest -m "not microsimulation": 128 passed, 15 skipped. pytest -m microsimulation on a local enhanced FRS: 18 passed, 8 failed — an identical failure set to origin/main on the same dataset (salary sacrifice columns and a VAT reform target), so unrelated to this change.

🤖 Generated with Claude Code

The response measured the baseline marginal rate on a branch of the reform
simulation. Simulations hold their baseline as a separately constructed
simulation rather than a branch, so both sides of the comparison carried
reform parameters and the measured rate change was zero for every reform,
leaving the elasticity with no effect on any costing. Issue #1319 fixed the
input plumbing for this; the measurement itself still reported no change.

Measure the baseline rate in the baseline simulation, and clone the
tax-benefit system for the measurement branches so neutralising the response
variable cannot reach the simulation being measured.

Define the elasticity against the retention rate rather than the tax rate,
which is how the empirical estimates are reported, so a published value can
be entered as published. Under the previous convention a positive elasticity
raised realisations when rates rose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Lint fails here for a reason unrelated to this change: uvx ruff format --check . resolves the current ruff release and four docs pages have drifted out of format on main, so the check is red on every branch cut from it. #1804 fixes that on its own; this PR goes green once it lands.

MaxGhenis and others added 5 commits August 12, 2026 17:03
Adversarial review found that a branch pre-created under the measurement's
name shares the simulation's own tax-benefit system, and get_branch returns
it without honouring clone_system — so the measurement's neutralise swapped
the live response variable for a neutralised wrapper on the parent, zeroing
every later recalculation. The measurement now sidesteps any existing name
and refuses outright if the branch system is not a clone. The
variable-stays-active test asserts object identity rather than formula
presence, which a neutralised wrapper also carries.

The review also reported a second household gainer losing its response.
That does not reproduce independently — two equal gainers respond
symmetrically — and is consistent with observing recalculations after the
neutralisation leak above. A regression test pins the symmetry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The second household gainer's response computed as zero under the locked
core 3.26.0 while newer environments passed: bisection lands the change at
core 3.30.1, whose changelog entry is "Fixed nested simulation cache
deletion so perturbation branches recompute inherited values without
mutating parent storage" — exactly the per-adult perturbation reuse the
review reported. Raise the floor to 3.30.1 and relock (resolves 3.30.4),
so the committed environment and any fresh resolution both carry the fix,
and the symmetry test guards it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The merge from main moved the project version to 2.90.1 while the lock
still recorded 2.90.0, so a locked sync refused the tip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis merged commit 22a2d34 into main Aug 13, 2026
8 checks passed
@MaxGhenis
MaxGhenis deleted the cgt-elasticity-wiring branch August 13, 2026 05:28
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