From e47246421de7e728113d6f38b75b96424db072c6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 25 Jul 2026 13:24:00 -0400 Subject: [PATCH 1/2] Correct the capital gains rate effective date and annualise the split year MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finance Act 2025 s.7 has effect for disposals made on or after 30 October 2024, but the rate parameters cited that section while dating the rise to 6 April 2025, placing a change the legislation makes in 2024-25 into the following year. Re-dating alone changes nothing, because fiscal year conversion samples each parameter at 30 April and writes that value across the year, so any change taking effect later in the year is dropped. Add opt-in day-weighted annualisation for parameters where a within-year average is the right annual figure, and apply it to the capital gains rates: 2024-25 becomes 207 days at the old rates and 158 days at the new. Blending stays opt-in because it is wrong for much of the model. A tax charged on a transaction applies at the rate in force on the date of the transaction, so stamp duty thresholds that changed mid-year must not be averaged. 93 parameters carry values in the dropped window and each needs its own decision. Rate mechanics move to 2025-26, where one schedule applies to the whole year, with the blended year covered by its own cases. CGT revenue on the enhanced FRS moves from £15.72bn to £17.34bn for 2024; 2023 and 2025 are unchanged, as is the rest of the YAML policy suite. Co-Authored-By: Claude Opus 5 --- changelog.d/cgt-rate-effective-dates.md | 2 + .../gov/hmrc/cgt/additional_rate.yaml | 5 +- .../parameters/gov/hmrc/cgt/basic_rate.yaml | 5 +- .../parameters/gov/hmrc/cgt/higher_rate.yaml | 5 +- .../capital_gains_tax/capital_gains_tax.yaml | 65 +++++++++++++----- .../tests/test_fiscal_year_parameters.py | 68 +++++++++++++++++++ policyengine_uk/utils/parameters.py | 51 +++++++++++++- 7 files changed, 176 insertions(+), 25 deletions(-) create mode 100644 changelog.d/cgt-rate-effective-dates.md diff --git a/changelog.d/cgt-rate-effective-dates.md b/changelog.d/cgt-rate-effective-dates.md new file mode 100644 index 000000000..29cc16dfe --- /dev/null +++ b/changelog.d/cgt-rate-effective-dates.md @@ -0,0 +1,2 @@ +- 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. 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/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..8ea16496b 100644 --- a/policyengine_uk/tests/test_fiscal_year_parameters.py +++ b/policyengine_uk/tests/test_fiscal_year_parameters.py @@ -102,6 +102,74 @@ 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 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..f0153b848 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,35 @@ 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 convert_to_fiscal_year_parameters(parameters): """ Convert parameters to use UK fiscal year values. @@ -33,15 +64,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 From ce7b7bc35331f2fee4d5abc870f5499bf6803b4c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 12 Aug 2026 17:17:02 -0400 Subject: [PATCH 2/2] Annual Scenario overrides name UK fiscal years MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review found day-weighted annualisation corrupting annual Scenario overrides of blended parameters: an override written for calendar 2025 leaves January to April 2026 on prior law, and conversion blends the two, resolving basic_rate 0.5 to 0.4167 and computing £8,334 of tax where £10,000 follows. Bare-year periods in parameter changes now cover 6 April to 5 April, the fiscal year the key names, so the override survives conversion at full strength, the split-year statutory blend still applies to years the override does not touch, and reversion after the override follows current law. Point-sampled parameters see no change: 30 April sits inside the shifted window either way. Co-Authored-By: Claude Fable 5 --- changelog.d/cgt-rate-effective-dates.md | 1 + policyengine_uk/simulation.py | 8 +++- .../tests/test_fiscal_year_parameters.py | 47 +++++++++++++++++++ policyengine_uk/utils/parameters.py | 21 +++++++++ 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/changelog.d/cgt-rate-effective-dates.md b/changelog.d/cgt-rate-effective-dates.md index 29cc16dfe..e3c8eb1f6 100644 --- a/changelog.d/cgt-rate-effective-dates.md +++ b/changelog.d/cgt-rate-effective-dates.md @@ -1,2 +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/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/test_fiscal_year_parameters.py b/policyengine_uk/tests/test_fiscal_year_parameters.py index 8ea16496b..2dffb4c6d 100644 --- a/policyengine_uk/tests/test_fiscal_year_parameters.py +++ b/policyengine_uk/tests/test_fiscal_year_parameters.py @@ -170,6 +170,53 @@ def test_blending_is_opt_in(self, uk_system): 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 f0153b848..74dae09fc 100644 --- a/policyengine_uk/utils/parameters.py +++ b/policyengine_uk/utils/parameters.py @@ -54,6 +54,27 @@ def fiscal_year_average(param, year: int): 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.