diff --git a/changelog.d/cgt-rate-effective-dates.md b/changelog.d/cgt-rate-effective-dates.md new file mode 100644 index 000000000..e3c8eb1f6 --- /dev/null +++ b/changelog.d/cgt-rate-effective-dates.md @@ -0,0 +1,3 @@ +- Corrected the effective date of the capital gains tax rate rise to 30 October 2024, as Finance Act 2025 s.7 provides, from 6 April 2025. +- Added opt-in day-weighted annualisation for parameters that change part-way through a fiscal year, and applied it to the capital gains rates, so 2024-25 reflects both rate schedules rather than only the one in force at the start of the year. +- Annual Scenario parameter changes now cover the UK fiscal year they name, so an override applies at full strength instead of blending with prior law over its final five months. diff --git a/policyengine_uk/parameters/gov/hmrc/cgt/additional_rate.yaml b/policyengine_uk/parameters/gov/hmrc/cgt/additional_rate.yaml index c21423eb4..da9684b0d 100644 --- a/policyengine_uk/parameters/gov/hmrc/cgt/additional_rate.yaml +++ b/policyengine_uk/parameters/gov/hmrc/cgt/additional_rate.yaml @@ -1,16 +1,17 @@ -description: Capital gains tax rate on additional rate taxpayers. This parameter is under active development and reforms including it should not be cited. +description: Capital gains tax rate on additional rate taxpayers. The rates changed part-way through 2024-25, so the value for that year is the day-weighted average of the rates in force before and after 30 October 2024. values: 2015-01-01: value: 0.2 reference: - title: Finance Act 2016 s. 83 href: https://www.legislation.gov.uk/ukpga/2016/24/section/83 - 2025-04-06: + 2024-10-30: value: 0.24 reference: - title: Finance Act 2025 s. 7 href: https://www.legislation.gov.uk/ukpga/2025/8/section/7 metadata: + fiscal_year_blend: true unit: /1 label: Capital Gains Tax additional rate reference: diff --git a/policyengine_uk/parameters/gov/hmrc/cgt/basic_rate.yaml b/policyengine_uk/parameters/gov/hmrc/cgt/basic_rate.yaml index e584d7696..0be9dffce 100644 --- a/policyengine_uk/parameters/gov/hmrc/cgt/basic_rate.yaml +++ b/policyengine_uk/parameters/gov/hmrc/cgt/basic_rate.yaml @@ -1,16 +1,17 @@ -description: Capital gains tax rate on basic rate taxpayers. This parameter is under active development and reforms including it should not be cited. +description: Capital gains tax rate on basic rate taxpayers. The rates changed part-way through 2024-25, so the value for that year is the day-weighted average of the rates in force before and after 30 October 2024. values: 2015-01-01: value: 0.1 reference: - title: Finance Act 2016 s. 83 href: https://www.legislation.gov.uk/ukpga/2016/24/section/83 - 2025-04-06: + 2024-10-30: value: 0.18 reference: - title: Finance Act 2025 s. 7 href: https://www.legislation.gov.uk/ukpga/2025/8/section/7 metadata: + fiscal_year_blend: true unit: /1 label: Capital Gains Tax basic rate reference: diff --git a/policyengine_uk/parameters/gov/hmrc/cgt/higher_rate.yaml b/policyengine_uk/parameters/gov/hmrc/cgt/higher_rate.yaml index f4606a6f5..c1c850971 100644 --- a/policyengine_uk/parameters/gov/hmrc/cgt/higher_rate.yaml +++ b/policyengine_uk/parameters/gov/hmrc/cgt/higher_rate.yaml @@ -1,16 +1,17 @@ -description: Capital gains tax rate on higher rate taxpayers. This parameter is under active development and reforms including it should not be cited. +description: Capital gains tax rate on higher rate taxpayers. The rates changed part-way through 2024-25, so the value for that year is the day-weighted average of the rates in force before and after 30 October 2024. values: 2015-01-01: value: 0.2 reference: - title: Finance Act 2016 s. 83 href: https://www.legislation.gov.uk/ukpga/2016/24/section/83 - 2025-04-06: + 2024-10-30: value: 0.24 reference: - title: Finance Act 2025 s. 7 href: https://www.legislation.gov.uk/ukpga/2025/8/section/7 metadata: + fiscal_year_blend: true unit: /1 label: Capital Gains Tax higher rate reference: diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index 75bb1569b..9200e6579 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -16,6 +16,8 @@ from policyengine_core.tools.hugging_face import download_huggingface_dataset from policyengine_core.tracers import FullTracer, SimpleTracer +from policyengine_uk.utils.parameters import uk_fiscal_year_period + # PolicyEngine UK imports from policyengine_uk.data.dataset_schema import ( UKMultiYearDataset, @@ -236,10 +238,12 @@ def apply_parameter_changes(self, changes: dict): canonical_parameter ) if isinstance(changes[parameter], dict): - # Time-period specific changes + # Time-period specific changes. A bare year names the UK + # fiscal year, so the override covers 6 April to 5 April and + # survives fiscal-year conversion at full strength. for time_period in changes[parameter]: p.update( - period=time_period, + period=uk_fiscal_year_period(time_period), value=changes[parameter][time_period], ) else: diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax.yaml index d9e206467..635a946d7 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/capital_gains_tax/capital_gains_tax.yaml @@ -1,5 +1,10 @@ +# Rate mechanics are tested in 2025-26, where one set of rates applies to the +# whole year. The rates rose part-way through 2024-25, so that year carries a +# day-weighted average of the rates before and after 30 October 2024, tested +# separately at the end of this file. + - name: No capital gains - period: 2024 + period: 2025 input: adjusted_net_income: 0 capital_gains: 0 @@ -7,7 +12,7 @@ capital_gains_tax: 0 - name: Capital gains below annual exempt amount - period: 2024 + period: 2025 input: adjusted_net_income: 0 capital_gains: 2_000 @@ -15,63 +20,66 @@ capital_gains_tax: 0 - name: Capital gains just above annual exempt amount - period: 2024 + period: 2025 input: adjusted_net_income: 0 capital_gains: 3_500 output: - capital_gains_tax: 50 + capital_gains_tax: 90 - name: Basic rate capital gains tax - period: 2024 + period: 2025 input: adjusted_net_income: 20_000 capital_gains: 23_000 output: - capital_gains_tax: 2_000 + capital_gains_tax: 3_600 - name: Higher rate capital gains tax - period: 2024 + period: 2025 + absolute_error_margin: 0.01 input: adjusted_net_income: 50_000 capital_gains: 50_000 output: - capital_gains_tax: 9_373 + capital_gains_tax: 11_263.80 - name: Additional rate capital gains tax - period: 2024 + period: 2025 input: adjusted_net_income: 150_000 capital_gains: 100_000 output: - capital_gains_tax: 19_400 + capital_gains_tax: 23_280 - name: Mixed rates capital gains tax - period: 2024 + period: 2025 + absolute_error_margin: 0.01 input: adjusted_net_income: 35_000 capital_gains: 40_000 output: - capital_gains_tax: 5_873 + capital_gains_tax: 7_963.80 - name: High capital gains, low income - period: 2024 + period: 2025 input: adjusted_net_income: 10_000 capital_gains: 200_000 output: - capital_gains_tax: 35_630 + capital_gains_tax: 45_018 - name: Very high capital gains and income - period: 2024 + period: 2025 + absolute_error_margin: 0.1 input: adjusted_net_income: 500_000 capital_gains: 1_000_000 output: - capital_gains_tax: 199_400 + capital_gains_tax: 239_280 - name: Edge case, capital gains exactly equal to annual exempt amount - period: 2024 + period: 2025 input: adjusted_net_income: 0 capital_gains: 3_000 @@ -132,3 +140,26 @@ capital_gains: 32_942.86 output: capital_gains_tax: 5_389.71 + +# Finance Act 2025 s.7 raised the main rates for disposals from 30 October +# 2024, part-way through 2024-25. An annual model cannot place a disposal +# before or after that date, so the year takes the day-weighted average of the +# two schedules: 207 days at 10% and 20%, then 158 days at 18% and 24%. + +- name: Case 16, the split 2024-25 year taxes basic rate gains at the blended rate. + period: 2024 + absolute_error_margin: 0.01 + input: + adjusted_net_income: 20_000 + capital_gains: 23_000 + output: + capital_gains_tax: 2_692.60 + +- name: Case 17, the split 2024-25 year taxes higher rate gains at the blended rate. + period: 2024 + absolute_error_margin: 0.01 + input: + adjusted_net_income: 150_000 + capital_gains: 100_000 + output: + capital_gains_tax: 21_079.56 diff --git a/policyengine_uk/tests/test_fiscal_year_parameters.py b/policyengine_uk/tests/test_fiscal_year_parameters.py index 08992a266..2dffb4c6d 100644 --- a/policyengine_uk/tests/test_fiscal_year_parameters.py +++ b/policyengine_uk/tests/test_fiscal_year_parameters.py @@ -102,6 +102,121 @@ def test_december_2025_returns_fiscal_year_2025_value(self, uk_system): assert child_limit == 2 +class TestMidYearRateChanges: + """Tests for changes taking effect part-way through a fiscal year. + + Sampling one date per year drops any change taking effect after it. + Parameters carrying `fiscal_year_blend: true` are day-weighted across + the year instead. + """ + + # Fiscal year 2024-25 runs 6 April 2024 to 5 April 2025: 207 days at the + # rates in force before 30 October 2024, then 158 days at the rates after. + DAYS_BEFORE = 207 + DAYS_AFTER = 158 + TOTAL_DAYS = DAYS_BEFORE + DAYS_AFTER + + def blended(self, before, after): + return (before * self.DAYS_BEFORE + after * self.DAYS_AFTER) / self.TOTAL_DAYS + + @pytest.mark.parametrize( + "rate_name, before, after", + [("basic_rate", 0.10, 0.18), ("higher_rate", 0.20, 0.24)], + ) + def test_cgt_rates_blend_across_the_split_year(self, rate_name, before, after): + """2024-25 carries both statutory rates, so the year takes their average.""" + system = CountryTaxBenefitSystem() + rates = { + year: getattr( + system.get_parameters_at_instant(str(year)).gov.hmrc.cgt, rate_name + ) + for year in (2023, 2024, 2025) + } + + assert rates[2023] == pytest.approx(before) + assert rates[2024] == pytest.approx(self.blended(before, after)) + assert rates[2025] == pytest.approx(after) + + def test_cgt_rate_rise_takes_effect_from_the_statutory_date(self): + """Finance Act 2025 s.7 applies to disposals from 30 October 2024. + + Read before fiscal year conversion, which is what the statutory dates + feed. Regression test for dating the rise to 6 April 2025, which moved + a change the legislation makes in 2024-25 into the following year. + """ + import policyengine_uk + from pathlib import Path + from policyengine_core.parameters import ParameterNode + + parameters = ParameterNode( + directory_path=str(Path(policyengine_uk.__file__).parent / "parameters") + ) + higher_rate = parameters.gov.hmrc.cgt.higher_rate + + assert higher_rate("2024-10-29") == pytest.approx(0.20) + assert higher_rate("2024-10-30") == pytest.approx(0.24) + + def test_blending_is_opt_in(self, uk_system): + """Parameters without the flag keep one sampled value for the year. + + National Insurance rates fell part-way through 2022-23, and the year + still takes the rate in force at its start rather than an average. + Whether to blend is a decision per parameter: a transaction is taxed + at the rate in force on its date, not at a yearly average. + """ + parameters = uk_system.get_parameters_at_instant("2022") + employee_rates = parameters.gov.hmrc.national_insurance.class_1.rates.employee + + assert employee_rates.main == pytest.approx(0.1325) + + +class TestScenarioOverrides: + """Annual Scenario overrides name UK fiscal years. + + Written as calendar years, an override's final five months fell back to + prior law and fiscal-year conversion blended the two: an override of 0.5 + on a blended CGT rate resolved to 0.42. The override now covers 6 April + to 5 April. + """ + + def _reformed(self): + from policyengine_uk import Microsimulation + from policyengine_uk.model_api import Scenario + + return Microsimulation( + situation={ + "people": { + "person": { + "age": {2025: 45}, + "adjusted_net_income": {2025: 20_000}, + "capital_gains": {2025: 23_000}, + } + }, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, + }, + scenario=Scenario( + parameter_changes={"gov.hmrc.cgt.basic_rate": {"2025": 0.5}} + ), + ) + + def test_override_applies_at_full_strength(self): + sim = self._reformed() + rate = sim.tax_benefit_system.parameters.gov.hmrc.cgt.basic_rate + + assert rate("2025") == pytest.approx(0.5) + cgt = sim.calculate("capital_gains_tax", 2025).values[0] + assert cgt == pytest.approx(10_000.0) + + def test_override_leaves_other_years_on_law(self): + """The split 2024-25 blend and the 2026 reversion both survive.""" + sim = self._reformed() + rate = sim.tax_benefit_system.parameters.gov.hmrc.cgt.basic_rate + + assert rate("2024") == pytest.approx((0.10 * 207 + 0.18 * 158) / 365) + assert rate("2026") == pytest.approx(0.18) + + class TestFiscalYearCoverage: """Tests to verify fiscal year conversion covers all needed years.""" diff --git a/policyengine_uk/utils/parameters.py b/policyengine_uk/utils/parameters.py index 8eb08a2b0..74dae09fc 100644 --- a/policyengine_uk/utils/parameters.py +++ b/policyengine_uk/utils/parameters.py @@ -1,3 +1,5 @@ +import datetime + from policyengine_core.model_api import * from policyengine_core import periods @@ -23,6 +25,56 @@ def backdate_parameters(root: str = None, first_instant: str = "2021-01-01") -> return root +def fiscal_year_average(param, year: int): + """Day-weighted average of a parameter across a UK fiscal year. + + Returns None where any value in the year is missing or non-numeric, so + the caller can fall back to sampling a single date. + """ + start = datetime.date(year, 4, 6) + end = datetime.date(year + 1, 4, 6) + + changes = [] + for value_at_instant in param.values_list: + try: + instant = datetime.date.fromisoformat(value_at_instant.instant_str) + except ValueError: + return None + if start < instant < end: + changes.append(instant) + + boundaries = [start, *sorted(changes), end] + total = 0.0 + for segment_start, segment_end in zip(boundaries, boundaries[1:]): + value = param(segment_start.isoformat()) + if not isinstance(value, (int, float)) or isinstance(value, bool): + return None + total += value * (segment_end - segment_start).days + + return total / (end - start).days + + +def uk_fiscal_year_period(time_period): + """Translate a bare year into the UK fiscal year it names. + + A UK parameter change for "2025" means the fiscal year starting 6 April + 2025. Writing it as the calendar year leaves January to April of 2026 + on prior law, which fiscal-year conversion then blends into the value — + an annual override of 0.5 came out as 0.42. Non-year periods pass + through untouched. + """ + year = None + if isinstance(time_period, int): + year = time_period + elif isinstance(time_period, str) and time_period.strip().isdigit(): + year = int(time_period.strip()) + if year is None or not 1900 < year < 2200: + return time_period + start = periods.Instant((year, 4, 6)) + days = (datetime.date(year + 1, 4, 6) - datetime.date(year, 4, 6)).days + return periods.Period(("day", start, days)) + + def convert_to_fiscal_year_parameters(parameters): """ Convert parameters to use UK fiscal year values. @@ -33,15 +85,31 @@ def convert_to_fiscal_year_parameters(parameters): This function samples each parameter at April 30 of each year and sets that as the value for the entire year period. + + Sampling a single date drops any change taking effect later in the fiscal + year. Parameters carrying `fiscal_year_blend: true` in their metadata are + day-weighted across the year instead, which is the right annualisation for + a rate or threshold that applies to a flow spread over the year. Leave the + flag off where the value at a point in time is what applies, as for a tax + charged on a transaction at the rate in force on its date. + + Values are computed for every year before any are written, so that + rewriting one year cannot affect the reading of another. """ # Cover years from 2015 through 2040 for long-term projections YEARS = list(range(2015, 2041)) for param in parameters.get_descendants(): if isinstance(param, Parameter): + blend = (param.metadata or {}).get("fiscal_year_blend", False) + values = {} for year in YEARS: - value_mid_year = param(f"{year}-04-30") + value = fiscal_year_average(param, year) if blend else None + if value is None: + value = param(f"{year}-04-30") + values[year] = value + for year, value in values.items(): param.update( period=f"{year}", - value=value_mid_year, + value=value, ) return parameters