Skip to content

Adopt CenTax’s retention-rate elasticity of 1.0 as the default for UK CGT modelling #602

Open

Description

@juaristi22

Requested behaviour

We have decided to adopt the central elasticity proposed in uk-cgt-reform#10: 1.0 with respect to the net-of-tax retention rate, with CenTax’s 0.5 and 2.0 available as sensitivities. Please make this the canonical default for UK CGT modelling through policyengine.py, so analysts receive the agreed assumption without copying dashboard-specific reform parameters.

For pre-response gains G₀ and baseline/reform marginal rates t₀ and t₁, the response is:

G₁ = G₀ × ((1 − t₁) / (1 − t₀)) ** e
e = 1.0 by default

This is a change in both convention and central assumption. The old marginal-tax-rate value −0.7 is not an interchangeable representation of retention elasticity 1.0 across finite rate changes. For example, moving from 24% to 45% gives a gains multiplier of 0.55/0.76 ≈ 0.723684 at retention elasticity 1.0, rather than (0.45/0.24)^−0.7 ≈ 0.644020.

Current implementation

Inspected on 13 October 2026: policyengine.py main at 48ba01a5, bundle 6.3.3, pins policyengine-uk==2.126.12. In that UK release, retention elasticity and MTR elasticity both default to zero. The wrapper passes the caller’s reform/dynamic values to the country model, so an ordinary CGT reform currently runs statically unless the caller supplies an elasticity.

The retention response already exists in the UK model; this does not require a new response formula. The distinction also needs correcting in the published behavioural-response methodology, which still describes −0.7 as PE’s default UK CGT assumption.

Implementation scope

The companion upstream issue PolicyEngine/policyengine-uk#2396 owns the parameter change, country-model override compatibility, response tests and model release. This .py issue owns adoption of that release and validation of the analysis interface.

  1. Consume one model-owned default. Depend on the upstream release setting gov.simulation.capital_gains_responses.elasticity to 1.0, with mtr_elasticity=0 and separate_badr_elasticity=false. Under the CenTax central case, qualifying BADR/Investors’ Relief gains take the same 1.0 elasticity as other gains, matching [1] - Add docstrings and type hints to all functions #10. Keep the response calculation and default in the country model; do not duplicate them in wrapper-specific code.

  2. Roll the released model into .py and verify every UK entry point. Update the UK dependency pin, lockfile and bundle/provenance metadata, with the required compatibility checks. Cover population Simulation(..., policy=..., dynamic=...), pe.uk.managed_microsimulation(reform=...), and pe.uk.calculate_household(reform=...). The population runner and managed helper are in UK model.py; the household route is in household.py.

  3. Preserve explicit choices and define precedence. Support static calculations explicitly (both main conventions zero and the separate BADR switch off), retention sensitivities 0.5/2.0, custom retention values, and the OBR elasticity comparison (3.6 main, 1.4 qualifying BADR gains with the separate switch on). Explicit caller values must override defaults, including dated values and the existing policy/dynamic precedence. An omitted assumption must select the new central case; it must not silently select static behaviour.

  4. Integrate the upstream legacy-MTR handling. The UK response helper rejects simultaneously nonzero retention and MTR elasticities. The companion UK issue must resolve the interaction between explicit MTR calls and the new retention default. Verify that each .py entry point preserves the released model’s behaviour and exposes its migration guidance. Do not reinterpret −0.7 as retention 1.0: the dashboard’s migration of named cases is not a numerical conversion for Python callers.

  5. Make the assumption reproducible and update the methodology. Record the resolved convention, main/BADR elasticities and source/default-versus-override status in serialisable result/run metadata. Ensure caches and saved outputs distinguish model releases and behavioural assumptions. Document the intentional change from static-by-default Python reform calculations, how to reproduce the previous static result, and how to request the legacy MTR case. Coordinate a correction to the published −0.7 methodology page.

Interpretation and related work

CenTax’s 2024 report, pp. 36–37, uses 1.0 for its medium-term reform package and considers 0.5–2.0. The central value reflects base reforms, including removal of uplift at death and a departure charge, and an adjustment for cross-base income shifting. Document 1.0 as PE’s adopted modelling default; do not describe it as an unconditional estimate for every rates-only reform.

Income shifting is separate work tracked in policyengine-uk#1982 and #1805, with dashboard approaches compared in uk-cgt-reform#11. Do not automatically add an income-tax recapture on top of the CenTax central assumption, which already incorporates a cross-base adjustment. Likewise, label 3.6/1.4 as an OBR elasticity comparison rather than a full replication of the OBR costing, which includes additional tax effects. Adopting the elasticity alone does not make PE’s revenue estimates identical to CenTax’s.

Acceptance criteria

  • An otherwise identical UK CGT reform with no elasticity override matches an explicit retention-1.0 run through all three .py entry points; the central case applies 1.0 to BADR gains too.
  • Wrapper integration tests cover the hand-calculated central factor, explicit static behaviour, sensitivities and the OBR main/BADR split. The upstream issue owns the detailed response-formula, rate-cut and model-state regression suite.
  • Tests cover legacy MTR calls, conflicting explicit conventions, policy/dynamic precedence and dated overrides.
  • Unreformed baseline gains and liabilities remain unchanged through the wrapper; validate bundle compatibility and check the runtime impact on the analysis routes.
  • Resolved assumptions survive JSON/run-record round trips and cannot reuse results from another assumption or model version.
  • The released UK pin/bundle, changelog, examples and methodology describe the same default and migration path.
  • A dashboard-versus-Python parity check uses the same model, data, year and reform specification. Different datasets or package versions must not be treated as an elasticity discrepancy.

Activity

  1. juaristi22 commented on Oct 14, 2026

    @juaristi22
    ContributorAuthor

    Implementation: PRs and merge order

    This checklist tracks the PRs for this issue and PolicyEngine/policyengine-uk#2396. It's edited as they open and merge. Each PR carries the same table and says what it depends on.

  2. vahid-ahmadi commented on Oct 15, 2026

    @vahid-ahmadi
    Contributor

    @juaristi22

    The plan's order is right. Five things need fixing or an owner before step 2 merges: the data version, the release number, the methodology wording, how users hear about the change, and the window between the release and step 5.

    I've posted separate comments on #620 and #621. This one is about the checklist.

    Ordering

    1. must, step 4: the data version. The checklist says the claim goes into the policyengine-uk-data 1.58.0 manifest. Main was certified on 14 Oct against 1.58.1, with policyengine-uk 2.130.1 (manifest.json: HF revision 89be6b73, compatibility_basis from a publisher claim, data built with 2.122.2). The claim should target whatever release main certifies when step 5 opens, and the checklist shouldn't fix a number now. It's also worth checking whether 1.58.1's existing compatible_model_packages specifier already covers the new release. If it's a range, no new claim is needed. If it's exact, one is.

    2. should, step 4: who owns the claim, and on what evidence. validate_release_manifest treats a publisher claim as an assertion "made good only by this repo's test suite passing on the pinned pair". So the claim itself isn't a check. For this release a claim is reasonable: an unreformed run should be unchanged, because the response is zero without a reform. But "should be unchanged" is the thing to show. I'd attach one baseline run of the new release on the target data to the claim: headline receipts, capital_gains and capital_gains_tax, equal to the certified pair within rounding. I'd also name who publishes the claim and records the HF revision on this checklist. Right now step 4 has no owner.

    3. should, step 2: the release number. The #2419 branch reports 2.131.0. PyPI is at 2.132.5 and main pins 2.130.1. So the release from step 2 will be at least 2.133, and step 5's pin move brings every change from 2.130.1 to there, not just the elasticity. The parity check and snapshot refresh in step 5 need to separate the two. One way is to certify the release before the default merges, then the one after it, and diff the two. Otherwise every moved number gets put down to the elasticity.

    4. should, steps 0 and 2: if Measure labour supply responses against the reform's baseline policyengine-uk#2277 stalls. #2419's branch already contains #2277's commits, so merging #2419 first would land them without their own review. #2419 says the "stable across recalculation and order" tests depend on #2277's measurement branches. Is it only those branches, or the labour supply baseline fix as well? If it's only the branches, splitting them into a small PR that #2419 can stack on would decouple the CGT default from the rest of #2277. I haven't checked how separable they are. It would also stop the release from step 2 carrying a second behavioural change (the labour supply baseline fix, changelog.d/fix-lsr-baseline-2268.fixed.md is in #2419's diff), which users would otherwise see at the same moment and couldn't tell apart.

    5. should, the window between steps 2 and 5. Merging #2419 publishes at once. From then on:

      So for a while, the Python package and anything floating on policyengine-uk give different CGT costings for the same reform. Could the checklist give step 5 a target date relative to step 2, and list the consumers that don't pin? I couldn't confirm which of policyengine-api, api-v2, sim-api and the dashboards pin it.

    The −0.7 and the 1.0

    1. must, step 6: the parameters aren't the same kind, and the current page misdescribes the old one. In the model, mtr_elasticity is a constant elasticity with respect to the tax rate. relative_capital_gains_mtr_change is log(t1) − log(t0), so gains scale by (t1/t0)^e. The new parameter is an elasticity with respect to the net-of-tax (retention) rate, so gains scale by ((1−t1)/(1−t0))^e. The methodology page instead calls −0.7 "the percent change in capital gains with respect to a 1p change in the capital gains tax rate". That's a semi-elasticity, which matches neither. The rewrite should state the formula for 1.0, and say plainly that −0.7 and 1.0 aren't conversions of each other. The issue's 24% to 45% example (0.7237 against 0.6440) does that well.

    2. should, the page also says −0.7 is PE's UK default. In policyengine-uk 2.126.12 both elasticities default to 0. So, as Adopt CenTax’s retention-rate elasticity of 1.0 as the default for UK CGT modelling #602 says, Python reform calculations have been static by default. Before the page changes, it's worth confirming what the web app actually applied. If it passed −0.7, its published CGT numbers used the MTR convention. If not, they were static. The note on the page should say which, because it decides what users are comparing the new numbers against.

    Telling users

    1. should, communication beyond the methodology page. The default changes published numbers for any CGT reform, and for other reforms that move a gainer's marginal rate. That includes income tax band and allowance changes, because #2419 measures every reformed simulation. Step 6 corrects one page. I'd add these to the plan:

      • a changelog entry in policyengine.py that names the behaviour change and shows how to get the old static result (the three settings from #2419's migration table);
      • a short note or blog post for web app users when the API pins move;
      • a decision on past posts that quote −0.7 or a static CGT costing. cgt-autumn-budget.md says realisations respond "with an elasticity of −0.7 with respect to the marginal tax rate". My suggestion is to leave the numbers and add a dated note, not rewrite them. That one is an editorial call.
    2. should, step 6 ordering. Step 6 merges after the policyengine.py release and closes Adopt CenTax’s retention-rate elasticity of 1.0 as the default for UK CGT modelling #602. But the web app only applies the new default once policyengine-api and api-v2 bump their pins, and the checklist leaves that open. If the page goes live first, it describes a default the web app doesn't use yet. Either tie step 6 to the API bumps, or have the page say from which version each surface applies 1.0. Those bumps are outside this repo, but they need an owner and a date on the checklist. Otherwise Adopt CenTax’s retention-rate elasticity of 1.0 as the default for UK CGT modelling #602 closes while the web app is still static.

    Reproducibility

    1. should, earlier published results. After step 5 and Record the capital gains realisation response each UK run applies #621, a saved UK output from before the change is recalculated under the new default the next time it's loaded. Nothing in the plan says how to reproduce a number published before the change. I'd add it to step 5's migration docs:

      • pin the earlier policyengine.py release, or keep the current one and set the three static settings;
      • use the run record's model and data versions;
      • keep the old record next to the new one.

      Any Budget dashboard or scorecard that pins policyengine.py or policyengine-uk keeps its old numbers until it bumps. Its owner should know the bump changes CGT lines on purpose, so the change isn't taken for a regression or reported in the same run as other changes.

    2. nit, the acceptance criteria. "Unreformed baseline gains and liabilities remain unchanged through the wrapper" is the claim that justifies step 4. It would be worth ticking with a named run, not left to the step 5 test suite.

    What I checked

    🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions