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
1 change: 1 addition & 0 deletions changelog.d/602-cgt-default.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Documented and tested policyengine-uk's capital gains realisation response, whose default from policyengine-uk 2.134.0 is a retention-rate elasticity of 1.0 (policyengine-uk#2396). Results don't change until policyengine.py pins 2.134.0 or later. From then on, a UK CGT reform with no elasticity set 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. The default leaves results without a reform unchanged. Reforms that move marginal rates on gains, including some income tax reforms, include the response. The Budget reform contract (2.1.0) adds a case for gains partly qualifying for the relief, which waits on that pin like `cgt_default_response`. 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
105 changes: 105 additions & 0 deletions docs/methodology/uk-capital-gains-responses.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
---
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 calculation.** policyengine-uk raises each person's ratio of
marginal retention rates to the power $e$. CenTax apply one aggregate
elasticity to the CGT base as a whole (pp. 32 and 37), and a person's
marginal rate differs from an aggregate rate at any elasticity. Read
linearly, their elasticity would also give the power's response at 1 but
not at 0.5 or 2. So adopting 1.0 reproduces neither their revenue estimates
nor their sensitivities.

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 2.134.0. 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. Until
[policyengine-uk#2433](https://github.com/PolicyEngine/policyengine-uk/pull/2433)
lands, the model can place gains in the higher band where the law puts
them in the additional band, so a reform to only one of those two rates can
respond where it shouldn't, or not respond where it should. Change both
rates together to avoid it.
- **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.
77 changes: 76 additions & 1 deletion docs/reforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ reform = {
}
```

`Simulation(policy=...)`, `Simulation(dynamic=...)`, and UK `calculate_household(reform=...)` accept these two date formats and reject an interval whose end precedes its start. They reject bare years such as `"2027"` or `2027`, because policyengine-uk reads them as fiscal year 2027 only, while policyengine-core's `Reform.from_dict` reads them as from 2027 onward. Use `"2027-04-06.2028-04-05"` for one UK fiscal year or `"2027-01-01"` for an effective date. The US household route retains its country package's period-key handling.
`Simulation(policy=...)`, `Simulation(dynamic=...)`, and UK `calculate_household(reform=...)` accept these two date formats and reject an interval whose end precedes its start. They reject bare years such as `"2027"` or `2027`, because a bare year means different things in different places. policyengine-uk reads it as fiscal year 2027 only for most parameters, but as calendar year 2027 for the economic growth rates, the indices built from them and the household parameters (from policyengine-uk 2.131.1). policyengine-core's `Reform.from_dict` reads it as from 2027 onward. Use `"2027-04-06.2028-04-05"` for one UK fiscal year or `"2027-01-01"` for an effective date. The US household route retains its country package's period-key handling.

### Multiple changes

Expand Down Expand Up @@ -91,6 +91,81 @@ 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 2.134.0 the default
is a retention-rate elasticity of 1.0, so policyengine.py has it from the
first release that pins 2.134.0 or later. 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. The default leaves
results without a reform 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. policyengine-uk applies it as a power to each person's marginal
retention rate, where CenTax apply one aggregate elasticity to the whole
base, so these don't reproduce CenTax's own sensitivities. 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},
"gov.hmrc.cgt.additional_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
126 changes: 124 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 @@ -86,7 +86,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 2.1.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 @@ -130,7 +132,7 @@
from typing import Any, Optional, Union

CONTRACT_ID = "policyengine-uk/budget-reform-contract"
CONTRACT_VERSION = "2.0.0"
CONTRACT_VERSION = "2.1.0"
PRIOR_LAW_OPTION = "statutory"
# The cos decision under which the prior-law call was taken.
PRIOR_LAW_DECISION = "d1344"
Expand Down Expand Up @@ -173,6 +175,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 @@ -500,6 +505,70 @@ 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)


def _mixed_gains_tax_change(year: int, rate: float) -> Callable[[Context], float]:
"""The change in CGT on the mixed gains when the rate on the main-rate
gains moves to ``rate``, with both shares scaled by the retention ratio.

The annual exempt amount comes off the main-rate gains first, the
allocation that leaves the least tax (TCGA 1992 s. 1H; the relief gains
are charged at the lower rate).
"""

def expected(context: Context) -> float:
ratio = _mixed_retention_ratio(year, rate)(context)
exempt = context.baseline(CGT_ANNUAL_EXEMPT_AMOUNT, year)
relief_rate = context.baseline(CGT_BADR_RATE, year)

def tax(main: float, relief: float, main_rate: float) -> float:
main_exempt = min(exempt, main)
relief_exempt = min(exempt - main_exempt, relief)
return (main - main_exempt) * main_rate + (
relief - relief_exempt
) * relief_rate

main, relief = MIXED_GAINS - RELIEF_GAINS, RELIEF_GAINS
before = tax(main, relief, context.baseline(CGT_HIGHER_RATE, year))
return tax(main * ratio, relief * ratio, rate) - before

return expected


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 @@ -944,6 +1013,59 @@ 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,
),
# The revenue effect, which also fixes which gains the annual
# exempt amount comes off.
Check(
"change",
"capital_gains_tax",
2027,
_mixed_gains_tax_change(2027, 0.45),
MIXED_RESPONSE_ORACLE
+ "; tax at 45% on the main-rate gains after the annual "
"exempt amount, which comes off them first, and the relief "
"rate on the qualifying gains",
0.05,
),
)
),
),
Case(
id="september_cpi_moves_indexed_thresholds",
title="A 5% September 2030 CPI rise indexes the 2031-32 thresholds",
Expand Down
Loading
Loading