diff --git a/changelog.d/capital-gains-realisation-response.md b/changelog.d/capital-gains-realisation-response.md new file mode 100644 index 000000000..4e05998c3 --- /dev/null +++ b/changelog.d/capital-gains-realisation-response.md @@ -0,0 +1,4 @@ +- Fixed the capital gains realisation response, which measured the baseline marginal rate on a branch of the reform simulation and so reported no rate change for any reform, leaving the elasticity with no effect on any costing. +- Defined the capital gains elasticity against the retention rate rather than the tax rate, matching how published estimates are reported, and added `relative_capital_gains_retention_rate_change`. +- Hardened the measurement against a pre-created branch of the same name, which shared the simulation's tax-benefit system and let the neutralised response variable replace the live one for every later recalculation. +- Raised the policyengine-core floor to 3.30.1, whose nested-simulation cache fix lets a second household gainer's response compute; a symmetry regression test pins it. diff --git a/policyengine_uk/parameters/gov/simulation/capital_gains_responses/elasticity.yaml b/policyengine_uk/parameters/gov/simulation/capital_gains_responses/elasticity.yaml index c351575c1..7a1c20a26 100644 --- a/policyengine_uk/parameters/gov/simulation/capital_gains_responses/elasticity.yaml +++ b/policyengine_uk/parameters/gov/simulation/capital_gains_responses/elasticity.yaml @@ -1,6 +1,11 @@ -description: Elasticity of capital gains with respect to the capital gains marginal tax rate. +description: Elasticity of capital gains realisations with respect to the capital gains retention rate, the share of a marginal pound of gains kept after tax. Higher values mean a given rate rise reduces realisations by more. Published estimates span roughly 0.5 to 2. values: 2000-01-01: 0 metadata: unit: /1 label: Capital gains elasticity + reference: + - title: Agersnap and Zidar (2021), The tax elasticity of capital gains and revenue-maximizing rates + href: https://www.aeaweb.org/articles?id=10.1257/aeri.20200535 + - title: Advani, Lonsdale and Summers (2024), Reforming Capital Gains Tax + href: https://centax.org.uk/wp-content/uploads/2024/10/AdvaniLonsdaleSummers2024_CGTReform.pdf diff --git a/policyengine_uk/tests/test_capital_gains_responses.py b/policyengine_uk/tests/test_capital_gains_responses.py new file mode 100644 index 000000000..f1486814f --- /dev/null +++ b/policyengine_uk/tests/test_capital_gains_responses.py @@ -0,0 +1,164 @@ +"""Tests for the capital gains realisation response to CGT rate changes.""" + +import pytest + +from policyengine_uk import Microsimulation +from policyengine_uk.model_api import Scenario + +YEAR = 2026 + +SITUATION = { + "people": { + "person": { + "age": {YEAR: 45}, + "employment_income": {YEAR: 100_000}, + "capital_gains": {YEAR: 200_000}, + } + }, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, +} + +EQUALISED_RATES = { + "gov.hmrc.cgt.basic_rate": {str(YEAR): 0.20}, + "gov.hmrc.cgt.higher_rate": {str(YEAR): 0.40}, + "gov.hmrc.cgt.additional_rate": {str(YEAR): 0.45}, +} + + +def simulate(elasticity: float | None = None, rates: bool = True) -> Microsimulation: + changes = dict(EQUALISED_RATES) if rates else {} + if elasticity is not None: + changes["gov.simulation.capital_gains_responses.elasticity"] = { + str(YEAR): elasticity + } + if not changes: + return Microsimulation(situation=SITUATION) + return Microsimulation( + situation=SITUATION, scenario=Scenario(parameter_changes=changes) + ) + + +def test_rate_rise_registers_against_the_baseline(): + """A CGT rate rise registers as a higher rate and a lower retention rate. + + Regression test for measuring the baseline against a branch of the reform + simulation, which reported no rate change for any reform (issue #1319). + """ + sim = simulate(elasticity=1.0) + mtr_change = sim.calculate("relative_capital_gains_mtr_change", YEAR).values[0] + retention_change = sim.calculate( + "relative_capital_gains_retention_rate_change", YEAR + ).values[0] + + assert mtr_change > 0, f"expected a positive log rate change, got {mtr_change}" + assert retention_change < 0, ( + f"expected a negative log retention change, got {retention_change}" + ) + + +def test_realisations_fall_when_rates_rise(): + """Gains fall under a rate rise, by more at a larger elasticity.""" + baseline_gains = simulate(rates=False).calculate("capital_gains", YEAR).sum() + + modest = simulate(elasticity=0.5).calculate("capital_gains", YEAR).sum() + large = simulate(elasticity=1.0).calculate("capital_gains", YEAR).sum() + + assert modest < baseline_gains + assert large < modest + + +def test_revenue_falls_short_of_the_static_estimate(): + """The behavioural response costs revenue relative to a static costing.""" + static = simulate(elasticity=0).calculate("capital_gains_tax", YEAR).sum() + dynamic = simulate(elasticity=1.0).calculate("capital_gains_tax", YEAR).sum() + + assert dynamic < static + assert dynamic > 0 + + +def test_zero_elasticity_leaves_gains_unchanged(): + """The default elasticity of zero keeps costings static.""" + sim = simulate(elasticity=0) + response = sim.calculate("capital_gains_behavioural_response", YEAR).sum() + + assert response == 0 + + +def test_no_reform_produces_no_response(): + """A simulation with no reform reports no realisation response.""" + sim = Microsimulation( + situation=SITUATION, + scenario=Scenario( + parameter_changes={ + "gov.simulation.capital_gains_responses.elasticity": {str(YEAR): 1.0} + } + ), + ) + response = sim.calculate("capital_gains_behavioural_response", YEAR).sum() + + assert response == 0 + + +def test_measurement_leaves_the_response_variable_active(): + """Measuring the rate change does not neutralise the response itself. + + Object identity, not formula presence: a neutralised wrapper still + carries a formula, so the old assertion could not see the damage. + """ + sim = simulate(elasticity=1.0) + before = sim.tax_benefit_system.variables["capital_gains_behavioural_response"] + sim.calculate("relative_capital_gains_mtr_change", YEAR) + after = sim.tax_benefit_system.variables["capital_gains_behavioural_response"] + + assert after is before + + +def test_pre_created_measurement_branch_cannot_poison_the_system(): + """A branch pre-created under the measurement's name shares the parent + system, and get_branch returns it without honouring clone_system — so + neutralising there would disable the response for every later + recalculation. The measurement must sidestep the name instead.""" + sim = simulate(elasticity=1.0) + sim.get_branch("cgr_measurement") + before = sim.tax_benefit_system.variables["capital_gains_behavioural_response"] + + response = sim.calculate("capital_gains_behavioural_response", YEAR).sum() + after = sim.tax_benefit_system.variables["capital_gains_behavioural_response"] + + assert response < 0 + assert after is before + + +def test_two_gainers_in_one_household_respond_symmetrically(): + """Equal gainers get equal responses; the second adult is not dropped.""" + situation = { + "people": { + "first": { + "age": {YEAR: 45}, + "employment_income": {YEAR: 100_000}, + "capital_gains": {YEAR: 200_000}, + }, + "second": { + "age": {YEAR: 44}, + "employment_income": {YEAR: 100_000}, + "capital_gains": {YEAR: 200_000}, + }, + }, + "benunits": {"benunit": {"members": ["first", "second"]}}, + "households": {"household": {"members": ["first", "second"]}}, + } + sim = Microsimulation( + situation=situation, + scenario=Scenario( + parameter_changes={ + **EQUALISED_RATES, + "gov.simulation.capital_gains_responses.elasticity": {str(YEAR): 1.0}, + } + ), + ) + + responses = sim.calculate("capital_gains_behavioural_response", YEAR).values + + assert responses[0] < 0 + assert responses[0] == pytest.approx(responses[1]) diff --git a/policyengine_uk/utils/capital_gains.py b/policyengine_uk/utils/capital_gains.py new file mode 100644 index 000000000..155a701f5 --- /dev/null +++ b/policyengine_uk/utils/capital_gains.py @@ -0,0 +1,61 @@ +"""Measurement of capital gains marginal tax rates against the baseline.""" + +import numpy as np + +from policyengine_core.simulations import Simulation + + +def measure_mtr( + simulation: Simulation, + branch_name: str, + period, + gains: np.ndarray, +) -> np.ndarray: + """Measure the capital gains MTR in a simulation, holding gains fixed. + + The branch clones the tax-benefit system because it neutralises the + behavioural response variable, which would otherwise recurse back into + this measurement. Cloning keeps that neutralisation off the simulation + being measured. + """ + # get_branch returns an existing branch of the requested name without + # honouring clone_system, and neutralising on a shared system would + # permanently disable the response variable for the caller. Take a name + # nothing else holds, then require the clone before touching it. + while branch_name in simulation.branches: + branch_name += "_" + branch = simulation.get_branch(branch_name, clone_system=True) + if branch.tax_benefit_system is simulation.tax_benefit_system: + raise RuntimeError( + "Capital gains MTR measurement requires a cloned tax-benefit " + "system; refusing to neutralise on the simulation's own." + ) + branch.tax_benefit_system.neutralize_variable("capital_gains_behavioural_response") + branch.set_input("capital_gains_before_response", period, gains) + mtr = branch.populations["person"]("marginal_tax_rate_on_capital_gains", period) + del simulation.branches[branch_name] + return mtr + + +def measure_capital_gains_mtrs(person, period) -> tuple[np.ndarray, np.ndarray]: + """Return the reform and baseline capital gains MTRs for each person. + + Both rates are measured at the same level of gains, so the difference + reflects the reform alone. Returns two zero arrays where the simulation + has no baseline to compare against. + + Simulations hold their baseline as a separately constructed simulation + rather than a branch, so the baseline rate has to be measured there. A + branch of the reform simulation carries reform parameters, and reports no + rate change however large the reform. + """ + simulation: Simulation = person.simulation + baseline = simulation.baseline + if baseline is None: + zeros = np.zeros(person.count) + return zeros, zeros + + gains = person("capital_gains_before_response", period) + reform_mtr = measure_mtr(simulation, "cgr_measurement", period, gains) + baseline_mtr = measure_mtr(baseline, "baseline_cgr_measurement", period, gains) + return reform_mtr, baseline_mtr diff --git a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_behavioural_response.py b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_behavioural_response.py index 40829f155..8bbff34df 100644 --- a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_behavioural_response.py +++ b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/capital_gains_behavioural_response.py @@ -6,6 +6,11 @@ class capital_gains_behavioural_response(Variable): value_type = float entity = Person label = "capital gains behavioral response" + documentation = ( + "Change in realised gains under a reform to the taxation of gains, " + "given the assumed elasticity of realisations with respect to the " + "retention rate." + ) unit = GBP definition_period = YEAR @@ -18,11 +23,13 @@ def formula(person, period, parameters): return 0 capital_gains = person("capital_gains_before_response", period) - tax_rate_change = person("relative_capital_gains_mtr_change", period) + retention_rate_change = person( + "relative_capital_gains_retention_rate_change", period + ) elasticity = person("capital_gains_elasticity", period) # Calculate response using log differences - response_factor = np.exp(elasticity * tax_rate_change) - 1 + response_factor = np.exp(elasticity * retention_rate_change) - 1 response = capital_gains * response_factor return response diff --git a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/relative_capital_gains_mtr_change.py b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/relative_capital_gains_mtr_change.py index 4cef0119d..5e097c434 100644 --- a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/relative_capital_gains_mtr_change.py +++ b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/relative_capital_gains_mtr_change.py @@ -1,5 +1,6 @@ from policyengine_uk.model_api import * from policyengine_core.simulations import * +from policyengine_uk.utils.capital_gains import measure_capital_gains_mtrs class relative_capital_gains_mtr_change(Variable): @@ -10,39 +11,7 @@ class relative_capital_gains_mtr_change(Variable): definition_period = YEAR def formula(person, period, parameters): - simulation: Simulation = person.simulation - baseline_branch = simulation.get_branch("baseline").get_branch( - "baseline_cgr_measurement" - ) - baseline_branch.set_input( - "capital_gains_before_response", - period, - person("capital_gains_before_response", period), - ) - baseline_person = baseline_branch.populations["person"] - baseline_branch.tax_benefit_system.neutralize_variable( - "capital_gains_behavioural_response" - ) - baseline_branch.set_input( - "capital_gains_before_response", - period, - person("capital_gains_before_response", period), - ) - baseline_mtr = baseline_person("marginal_tax_rate_on_capital_gains", period) - del simulation.branches["baseline"].branches["baseline_cgr_measurement"] - - measurement_branch = simulation.get_branch("cgr_measurement") - measurement_branch.tax_benefit_system.neutralize_variable( - "capital_gains_behavioural_response" - ) - measurement_branch.set_input( - "capital_gains_before_response", - period, - person("capital_gains_before_response", period), - ) - measurement_person = measurement_branch.populations["person"] - reform_mtr = measurement_person("marginal_tax_rate_on_capital_gains", period) - del simulation.branches["cgr_measurement"] + reform_mtr, baseline_mtr = measure_capital_gains_mtrs(person, period) # Handle zeros in tax rates to prevent log(0) min_rate = 0.001 diff --git a/policyengine_uk/variables/gov/hmrc/capital_gains_tax/relative_capital_gains_retention_rate_change.py b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/relative_capital_gains_retention_rate_change.py new file mode 100644 index 000000000..ee35879bb --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/capital_gains_tax/relative_capital_gains_retention_rate_change.py @@ -0,0 +1,27 @@ +from policyengine_uk.model_api import * +from policyengine_core.simulations import * +from policyengine_uk.utils.capital_gains import measure_capital_gains_mtrs + + +class relative_capital_gains_retention_rate_change(Variable): + value_type = float + entity = Person + label = "relative change in the capital gains retention rate" + documentation = ( + "Log change in the share of a marginal pound of gains kept after tax. " + "The empirical literature estimates realisation elasticities against " + "this retention rate rather than against the tax rate." + ) + unit = "/1" + definition_period = YEAR + + def formula(person, period, parameters): + reform_mtr, baseline_mtr = measure_capital_gains_mtrs(person, period) + + # Floor the retention rate to keep the log defined where a marginal + # pound of gains is taxed away in full. + min_retention_rate = 0.001 + baseline_retention = np.maximum(1 - baseline_mtr, min_retention_rate) + reform_retention = np.maximum(1 - reform_mtr, min_retention_rate) + + return np.log(reform_retention) - np.log(baseline_retention) diff --git a/pyproject.toml b/pyproject.toml index 31d92c18c..09754fc6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ ] requires-python = ">=3.9" dependencies = [ - "policyengine-core>=3.26.0", + "policyengine-core>=3.30.1", "microdf-python>=1.2.1", "pydantic>=2.11.7", "tables>=3.9.2,<3.10.2; python_version < '3.10'", diff --git a/uv.lock b/uv.lock index a1db86a16..b98afea1e 100644 --- a/uv.lock +++ b/uv.lock @@ -1557,7 +1557,7 @@ wheels = [ [[package]] name = "policyengine-core" -version = "3.26.0" +version = "3.30.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dpath", marker = "python_full_version >= '3.11'" }, @@ -1577,14 +1577,14 @@ dependencies = [ { name = "standard-imghdr", marker = "python_full_version >= '3.11'" }, { name = "wheel", marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4e/69/adb6407c97de5260a938344f9eafa9979bf8f97aec8c628538d906ecdec2/policyengine_core-3.26.0.tar.gz", hash = "sha256:a571026ef418653ec18f087463cf37e9be730e90ad4376cb10997f0ddf9f8eda", size = 468190, upload-time = "2026-05-04T19:26:27.707Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/ce/850539b176dfbbb7e8ca4ece80c00cd880600d2fe55cc1718c22600d4df2/policyengine_core-3.30.4.tar.gz", hash = "sha256:6c1573d9486b291f5104bb275bb26c2668f9e604771b36b83e641363c14d3a1a", size = 502330, upload-time = "2026-08-04T13:44:46.299Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/f3/0e98b30d4eb7b309c3f1f1d8c2354595f78319ce2442eda069f02a47f4d1/policyengine_core-3.26.0-py3-none-any.whl", hash = "sha256:d63a4622233b61c4c5fc64d4f65030d65b2564ac63ac87b17d545d63cdf17194", size = 232135, upload-time = "2026-05-04T19:26:25.693Z" }, + { url = "https://files.pythonhosted.org/packages/61/bd/62801802dfbf7244e9d8e53432d4581ce71d2e308742f9109a7c3d9d21a1/policyengine_core-3.30.4-py3-none-any.whl", hash = "sha256:a3edd4c528a048f8d075fee31231866d536d523cbcdeca78ad45d75bc3d5758a", size = 245473, upload-time = "2026-08-04T13:44:44.987Z" }, ] [[package]] name = "policyengine-uk" -version = "2.89.4" +version = "2.90.1" source = { editable = "." } dependencies = [ { name = "microdf-python", marker = "python_full_version >= '3.11'" }, @@ -1615,7 +1615,7 @@ requires-dist = [ { name = "furo", marker = "extra == 'dev'", specifier = "<2023" }, { name = "jupyter-book", marker = "extra == 'dev'", specifier = ">=2.0.0a0" }, { name = "microdf-python", specifier = ">=1.2.1" }, - { name = "policyengine-core", specifier = ">=3.26.0" }, + { name = "policyengine-core", specifier = ">=3.30.1" }, { name = "pydantic", specifier = ">=2.11.7" }, { name = "pytest-cov", marker = "extra == 'dev'" }, { name = "rich", marker = "extra == 'dev'" },