Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog.d/capital-gains-realisation-response.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
164 changes: 164 additions & 0 deletions policyengine_uk/tests/test_capital_gains_responses.py
Original file line number Diff line number Diff line change
@@ -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])
61 changes: 61 additions & 0 deletions policyengine_uk/utils/capital_gains.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
10 changes: 5 additions & 5 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.