Skip to content
Open
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
1 change: 1 addition & 0 deletions changelog.d/602-cgt-default.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Adopted policyengine-uk {X}, whose capital gains realisation response defaults to a retention-rate elasticity of 1.0 (policyengine-uk#2396): a UK CGT reform with no elasticity set now realises gains at that elasticity on every entry point, where it was costed statically, and gains qualifying for Business Asset Disposal Relief take the same 1.0. Results without a reform are unchanged. Reforms that move marginal rates on gains, including some income tax reforms, now include the response. The Budget reform contract (1.2.0) checks the default on every route, and each UK run records the resolved assumption. See the Reforms guide for the static settings, the sensitivities and the marginal tax rate convention.
1 change: 1 addition & 0 deletions docs/_quarto.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ website:
- methodology/index.md
- methodology/model-architecture.md
- methodology/us-health-costs.md
- methodology/uk-capital-gains-responses.md
- section: "Programs"
contents:
- programs/index.md
Expand Down
1 change: 1 addition & 0 deletions docs/methodology/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ The long-term goal is that generated pages come from the country model and data

- [Model architecture](model-architecture.md)
- [US health coverage and costs](us-health-costs.md)
- [UK capital gains realisation response](uk-capital-gains-responses.md)

## Program pages

Expand Down
95 changes: 95 additions & 0 deletions docs/methodology/uk-capital-gains-responses.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
title: "UK capital gains realisation response"
---

People choose when to realise capital gains, so a change in the tax on gains
changes the gains realised. policyengine-uk models this for every reform, and
policyengine.py uses the model's response as it is; it adds no response of its
own.

## The response

A reform scales each person's realised gains $G_0$ to

$$
G_1 = G_0 \left(\frac{1 - t_1}{1 - t_0}\right)^{e}
$$

where $t_0$ and $t_1$ are the person's marginal rates on gains under the
baseline's and the reform's rules, and $e$ is the elasticity with respect to
the retention rate $1 - t$.

- **How the rates are measured.** Both are measured at the person's gains
before any response, with every behavioural response switched off, so their
difference reflects the reform alone.
- **Schedules.** Each rate is share-weighted across the person's schedules
(main rates, Business Asset Disposal Relief, residential property, carried
interest).
- **Relief gains.** Gains qualifying for the relief respond at their own
elasticity, the main one unless `separate_badr_elasticity` is set.

## The default

The default elasticity is 1.0, PolicyEngine's central assumption. It is the
value Advani, Lonsdale and Summers (2024, pp. 36–37) use for the medium-term
(five-year) effect of their reform package, and the range they consider, 0.5
to 2.0, gives the sensitivities. Its limits:

- **For rate changes alone it is low.** They reach 1.0 from about 1.5 by
adjusting for their package's base changes (removing the uplift at death,
charging gains on emigration) and for income shifted back into income tax.
They expect a reform without those changes to have a higher elasticity,
and so lower revenue (p. 37). policyengine-uk adds no income tax back, so
for a change to rates alone the default overstates what a rate rise
raises.
- **It is a medium-term value.** Responses soon after a reform are larger,
and forestalling isn't modelled, so a first-year score is likely to
understate the response.
- **Large rate rises.** Revenue from a single rate on gains peaks at
$1 / (1 + e)$: 50% at the default, 40% at 1.5, 33% at 2.0 and about 22% at
3.6. So the elasticity can decide whether a large rise raises or loses
revenue, and the default can give the opposite sign to official costings.
- **Not their revenue.** Adopting 1.0 does not reproduce their revenue
estimates.

The OBR's elasticities (3.6 for main-rate gains, 1.4 for relief gains) can be
set for comparison. They then compare elasticities, not the OBR's full
costing. The response scales the gains in the data, so a comparison in
pounds also mixes in any gap in the level of gains.

The model's default is 1.0 from policyengine-uk {X}. Before, it was 0, so
reforms were costed statically unless a caller set an elasticity.

## When there is no response

- **No reform.** A simulation without a reform, and a reform's own baseline,
apply their baseline's rules. Their rates would be equal, so policyengine-uk
does not measure them and the response is zero.
- **No rate change.** A reform that doesn't change a person's marginal rate
on gains leaves their gains alone. That includes a change to one band's
rate for someone whose last pound of gains falls in another band.
- **Marginal tax rates.** Marginal tax rates on earnings hold realised gains
fixed: a pay rise is not a reform for the response to answer.

## Choosing another assumption

See [Reforms](../reforms.md#behavioural-responses-uk-capital-gains) for the
settings: static, sensitivities, the OBR comparison and the marginal tax rate
convention. Every UK run records the response it applied, so a result shows
the assumption it was calculated under (see
[The UK capital gains realisation response](../microsim.md#the-uk-capital-gains-realisation-response)).

## Interpreting results

- **Revenue.** Realised gains are part of household income, so with a
response the change in household net income no longer equals the change in
revenue. Read revenue from `gov_balance` or the taxes themselves.
- **Distributional results.** Household net income counts gains no longer
realised as income lost, although they are deferred, not lost. So
per-household impacts, income deciles and winners and losers show a CGT
rise costing households more with the response than without it. Whether
distributional measures should use gains before the response is an open
methodology question.
- **Cross-effects.** Reforms to income tax bands or allowances can move
marginal rates on gains, and so realised gains, even when CGT rates don't
change.
70 changes: 70 additions & 0 deletions docs/reforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,76 @@ reformed = Simulation(
)
```

## Behavioural responses: UK capital gains

policyengine-uk realises capital gains in response to any reform that changes
a person's marginal rate on gains. From policyengine-uk {X}, pinned by
policyengine.py {Y}, the default is a retention-rate elasticity of 1.0. Each
person's realised gains scale by $((1 - t_1) / (1 - t_0))^{e}$, where $t_0$
and $t_1$ are their marginal rates on gains without and with the reform. Gains
qualifying for Business Asset Disposal Relief take the same 1.0. Before, the
elasticity was 0, so a CGT reform was costed statically unless the caller set
an elasticity.

For a marginal rate on gains rising from 24% to 45%, realised gains scale by
$0.55 / 0.76 \approx 0.7237$. A marginal tax rate elasticity of $-0.7$, a
different convention, gives $(0.45 / 0.24)^{-0.7} \approx 0.6440$: the two
are not interchangeable.

- **Who is affected.** Any reform that moves someone's marginal rate on gains
is affected. That includes CGT rates, and also income tax bands and
allowances, because gains are taxed on top of income. Results without a
reform are unchanged.
- **The static costing.** Set the elasticities to zero for the years you run,
as below:
- on `Simulation`, pass them as `dynamic=`;
- on `calculate_household`, merge them into `reform=`;
- on `managed_microsimulation`, merge them into `reform=`, whose keys
policyengine-uk reads itself.
- **Sensitivities.** The elasticity can be 0.5 or 2.0, the range CenTax
consider. For the OBR's elasticities, set it to 3.6 and
`separate_badr_elasticity` to true; `badr_elasticity` stays 1.4. That
compares elasticities, not the OBR's full costing.
- **The marginal tax rate convention.** An `mtr_elasticity` set on its own
applies alone in its periods, with a `CapitalGainsResponseWarning`. Setting
`elasticity` to 0 for the same periods silences it. Setting both to nonzero
values raises an error.
- **Precedence.** A dynamic's settings win over a policy's. With a policy
modifier, the dynamic's values are written after the modifier, onto the
annualised parameters, where a value dated after 1 January misses that year.
Date them from 1 January.
- **Both sides of a comparison.** Set the same `dynamic` on the baseline and
the reform. A response set on the reform side only also answers the
baseline's policy, since each simulation's response is measured against
current law.
- **The assumption's limits.** CenTax's 1.0 is a medium-term value for a
package that also reforms the base and adjusts for income shifted between
taxes. For a change to rates alone their reasoning points higher, and a
first-year response is likely larger, so the default can overstate what a
rate rise raises. Income tax on shifted income is not added back. See
[the methodology page](methodology/uk-capital-gains-responses.md).

```python
RESPONSES = "gov.simulation.capital_gains_responses"
EVERY_YEAR = "2000-01-01.2100-12-31"
static = {
f"{RESPONSES}.elasticity": {EVERY_YEAR: 0},
f"{RESPONSES}.mtr_elasticity": {EVERY_YEAR: 0},
f"{RESPONSES}.separate_badr_elasticity": {EVERY_YEAR: False},
}
static_costing = Simulation(
dataset=dataset,
tax_benefit_model_version=pe.uk.model,
policy={"gov.hmrc.cgt.higher_rate": {"2027-04-06": 0.45}},
dynamic=static,
)
```

Every UK run records the response it applied (see
[The UK capital gains realisation response](microsim.md#the-uk-capital-gains-realisation-response)),
and [the methodology page](methodology/uk-capital-gains-responses.md)
explains how the response is measured.

## Structural reforms

For rule changes that can't be expressed as a parameter change — swapping a formula, adding a variable, neutralising a program — drop down to the underlying country package. Both `policyengine_us` and `policyengine_uk` expose `Reform.from_dict(...)` and class-based reforms with overridable formulas; use them directly and run the simulation via `managed_microsimulation` (or by constructing a country-package `Microsimulation` yourself).
Expand Down
7 changes: 6 additions & 1 deletion src/policyengine/core/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,12 @@ class Simulation(BaseModel):
)
dynamic: Optional[Union[Dynamic, dict[str, Any]]] = Field(
default=None,
description=("Behavioural-response overlay. Same dict shape as ``policy``."),
description=(
"Behavioural-response overlay. Same dict shape as ``policy``. "
"``None`` keeps the country model's defaults, which need not be "
"static: policyengine-uk realises capital gains at a "
"retention-rate elasticity of 1 by default."
),
)
dataset: Dataset = None

Expand Down
85 changes: 83 additions & 2 deletions src/policyengine/tax_benefit_models/uk/budget_reform_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@
added both, and a case with no response settings that shows the default
response policyengine-uk applies. Its checks expect the retention-rate
elasticity of 1 that policyengine-uk#2396 makes the default, and carry
``pending`` until policyengine.py pins a release with it.
``pending`` until policyengine.py pins a release with it. Contract 1.2.0
adds a second default case, for gains partly qualifying for Business Asset
Disposal Relief, which respond at the same elasticity.

Report
------
Expand Down Expand Up @@ -111,7 +113,7 @@
from typing import Any, Optional, Union

CONTRACT_ID = "policyengine-uk/budget-reform-contract"
CONTRACT_VERSION = "1.1.0"
CONTRACT_VERSION = "1.2.0"
PRIOR_LAW_OPTION = "projected"
PRIOR_LAW_DECISION = "d1344"
# The capital gains response default: policyengine-uk#2396 makes it a
Expand Down Expand Up @@ -150,6 +152,9 @@
UC_SINGLE_OLD = "gov.dwp.universal_credit.standard_allowance.amount.SINGLE_OLD"
NEW_STATE_PENSION = "gov.dwp.state_pension.new_state_pension.amount"
CGT_BASIC_RATE = "gov.hmrc.cgt.basic_rate"
CGT_HIGHER_RATE = "gov.hmrc.cgt.higher_rate"
CGT_ADDITIONAL_RATE = "gov.hmrc.cgt.additional_rate"
CGT_BADR_RATE = "gov.hmrc.cgt.badr.rate"
CGT_ANNUAL_EXEMPT_AMOUNT = "gov.hmrc.cgt.annual_exempt_amount"
CGT_RESPONSES = "gov.simulation.capital_gains_responses"
CGT_ELASTICITY = f"{CGT_RESPONSES}.elasticity"
Expand Down Expand Up @@ -444,6 +449,42 @@ def expected(context: Context) -> float:
"the exempt amount and current rate are read from current law"
)

MIXED_GAINS = 50_000
RELIEF_GAINS = 20_000


def _mixed_retention_ratio(year: int, rate: float) -> Callable[[Context], float]:
"""The retention ratio for gains partly qualifying for the relief, when
the rate on the rest moves to ``rate``.

The marginal rate on gains is share-weighted across the schedules: the
relief rate on the qualifying share, the higher rate on the rest. Both
shares respond to the one change in that rate.
"""

def expected(context: Context) -> float:
relief_share = RELIEF_GAINS / MIXED_GAINS
relief_rate = context.baseline(CGT_BADR_RATE, year)
before = relief_share * relief_rate + (1 - relief_share) * (
context.baseline(CGT_HIGHER_RATE, year)
)
after = relief_share * relief_rate + (1 - relief_share) * rate
return (1 - after) / (1 - before)

return expected


def _scaled(amount: float, ratio, offset: float = 0.0) -> Callable[[Context], float]:
return lambda context: amount * (ratio(context) + offset)


MIXED_RESPONSE_ORACLE = (
"£50,000 of gains, £20,000 qualifying for the relief, scaled by "
"(1 - t1) / (1 - t0), where t0 and t1 are 40% x the relief rate + 60% x "
"the higher rate (24% now, 45% under the reform); the relief and higher "
"rates are read from current law"
)

DIVIDENDS = 10_000


Expand Down Expand Up @@ -881,6 +922,46 @@ def expected(context: Context) -> float:
)
),
),
Case(
id="cgt_default_response_relief_gains",
title=(
"CGT higher and additional rates 45% from 6 April 2027, for gains "
"partly qualifying for Business Asset Disposal Relief, at the "
"default response"
),
changes=(
Change(CGT_HIGHER_RATE, _date("2027-04-06"), 0.45),
Change(CGT_ADDITIONAL_RATE, _date("2027-04-06"), 0.45),
),
# £80,000 of earnings puts all the gains above the basic rate band;
# £20,000 of the £50,000 qualify for the relief, whose rate the reform
# leaves alone. Pending, as cgt_default_response, until the pin.
household=adult(
80_000, capital_gains=MIXED_GAINS, capital_gains_badr=RELIEF_GAINS
),
checks=tuple(
replace(check, pending=DEFAULT_RESPONSE_DECISION)
for check in (
Check(
"variable",
"capital_gains",
2027,
_scaled(MIXED_GAINS, _mixed_retention_ratio(2027, 0.45)),
MIXED_RESPONSE_ORACLE,
0.05,
),
# The qualifying gains respond at the same elasticity of 1.
Check(
"variable",
"capital_gains_badr_behavioural_response",
2027,
_scaled(RELIEF_GAINS, _mixed_retention_ratio(2027, 0.45), -1.0),
MIXED_RESPONSE_ORACLE + "; the response on the qualifying £20,000",
0.05,
),
)
),
),
Case(
id="september_cpi_moves_indexed_thresholds",
title="A 5% September 2030 CPI rise indexes the 2031-32 thresholds",
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/uk_contract/evaluate_case_rows.json

Large diffs are not rendered by default.

55 changes: 38 additions & 17 deletions tests/test_uk_budget_reform_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -560,31 +560,52 @@ def test_the_capital_gains_cases_hold_on_every_in_process_route():
assert passed == set(routes)


DEFAULT_CASES = ("cgt_default_response", "cgt_default_response_relief_gains")


def test_the_default_response_is_reported_without_gating():
"""The default case's checks all wait on policyengine-uk#2396, so it
reports the default response on every route without failing a run. The
golden rows cover its rows on every route under fake routes; one real
route checks that it runs and reports pending there."""
"""The default cases' checks all wait on policyengine-uk#2396, so they
report the default response on every route without failing a run. The
golden rows cover their rows on every route under fake routes; one real
route checks that they run and report pending there."""
pytest.importorskip("policyengine_uk.utils.dated_reforms")
case = next(case for case in CASES if case.id == "cgt_default_response")
assert {check.pending for check in case.checks} == {
contract.DEFAULT_RESPONSE_DECISION
}
# No response settings: the case runs on whatever default is installed.
assert not any(change.path.startswith("gov.simulation.") for change in case.changes)
cases = [case for case in CASES if case.id in DEFAULT_CASES]
assert [case.id for case in cases] == list(DEFAULT_CASES)
for case in cases:
assert {check.pending for check in case.checks} == {
contract.DEFAULT_RESPONSE_DECISION
}
# No response settings: the case runs on whatever default is installed.
assert not any(
change.path.startswith("gov.simulation.") for change in case.changes
)

report = run_budget_reform_contract(
routes=["country.reform_dict"],
cases=["cgt_default_response"],
cases=list(DEFAULT_CASES),
max_examples=0,
)
rows = [row for row in report["cases"] if row["case_id"] == case.id]
rows = [row for row in report["cases"] if row["case_id"] in DEFAULT_CASES]
assert {row["status"] for row in rows} <= {"pass", "pending"}
(response,) = (
row for row in rows if row["check"] == "change:capital_gains_tax:2027"
)
assert response["status"] == "pending"
assert response["decision_id"] == contract.DEFAULT_RESPONSE_DECISION
responses = {
(row["case_id"], row["check"]): row
for row in rows
if row["check"]
in (
"change:capital_gains_tax:2027",
"variable:capital_gains_badr_behavioural_response:2027",
)
}
assert set(responses) == {
("cgt_default_response", "change:capital_gains_tax:2027"),
(
"cgt_default_response_relief_gains",
"variable:capital_gains_badr_behavioural_response:2027",
),
}
for row in responses.values():
assert row["status"] == "pending"
assert row["decision_id"] == contract.DEFAULT_RESPONSE_DECISION


def test_a_parameter_modifier_writes_the_annualised_parameters():
Expand Down
Loading
Loading