From 5b3967c884995b872906675321d0e07d990c6dc9 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Sat, 24 Oct 2026 14:06:01 +0100 Subject: [PATCH 1/3] Project savings interest by interest received per person (#1862) ONS HAXV outturn to 2025, then OBR household financial assets growth times the Table 1.9 deposit rate ratio, net of ons.population growth. Co-Authored-By: Claude Opus 5.5 --- .../1862-savings-interest-per-person.fixed.md | 1 + policyengine_uk/data/uprating_indices.yaml | 5 +- .../gov/economic_assumptions/yoy_growth.yaml | 72 +++++++--- .../code_health/test_uprating_declarations.py | 6 +- .../tests/test_savings_interest_uprating.py | 135 ++++++++++++++++++ .../input/savings_interest_income.py | 9 +- 6 files changed, 202 insertions(+), 26 deletions(-) create mode 100644 changelog.d/1862-savings-interest-per-person.fixed.md create mode 100644 policyengine_uk/tests/test_savings_interest_uprating.py diff --git a/changelog.d/1862-savings-interest-per-person.fixed.md b/changelog.d/1862-savings-interest-per-person.fixed.md new file mode 100644 index 0000000000..7a52480dc5 --- /dev/null +++ b/changelog.d/1862-savings-interest-per-person.fixed.md @@ -0,0 +1 @@ +Project `savings_interest_income` by households' interest received per person: ONS HAXV outturn to 2025 (-2.76% in 2025, not the +3.49% non-labour income proxy), then OBR household financial assets growth times the Table 1.9 deposit rate ratio, net of `ons.population` growth so that growing household weights do not count population twice. diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index 17f363f2cd..95aeb337d9 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -134,7 +134,6 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - property_income - property_rental_income - savings -- savings_interest_income - spousal_maintenance_income - stocks_and_shares_isa - sublet_income @@ -160,5 +159,9 @@ gov.economic_assumptions.yoy_growth.obr.private_pension_index: - private_pension_income gov.economic_assumptions.yoy_growth.obr.lagged_average_earnings: - housing_service_charges +# Interest received per person, as household weights grow by population +# (#1862). +gov.economic_assumptions.yoy_growth.ons.per_capita.household_interest_income: +- savings_interest_income gov.economic_assumptions.yoy_growth.ons.population: - household_weight diff --git a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml index 1f0380a3fb..9c5a9189e6 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml +++ b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml @@ -1098,31 +1098,69 @@ ons: href: https://obr.uk/supplementary-forecast-information-on-long-term-economic-determinants-and-personal-independence-payment-policy-costing/ - title: ONS, "National population projections - 2022-based" (migration category variant, used by the OBR from 2030-31) href: https://www.ons.gov.uk/peoplepopulationandcommunity/populationandmigration/populationprojections/bulletins/nationalpopulationprojections/2022based + # Households' (S.14) interest received, D.41 resources, ONS series HAXV + # (UK Economic Accounts, release of 30 September 2026), calendar years in + # £m. D.41 is interest after the FISIM adjustment, not the unadjusted + # D.41g. Each value is growth over the year before, rounded to 4 decimals. + # + # The OBR does not forecast household interest received. From 2026 the + # series is interest = stock x rate: (1 + growth in household financial + # assets) x (this year's deposit rate / last year's) - 1, both from the OBR + # March 2026 EFO economy tables, calendar-year rows. Financial assets + # (Table 1.11, ONS NNML) are the only household asset stock the OBR + # forecasts; they include pensions and equities, so they stand in for + # deposits. The deposit rate is Table 1.9's, in per cent. + # + # This is an aggregate. Dataset projection grows household weights by + # ons.population, so each person's interest grows by + # per_capita.household_interest_income instead (#1862). household_interest_income: - description: Household interest income year-on-year growth from ONS National Accounts D.41g. + description: Growth in households' total interest received (ONS HAXV, D.41). values: - # Outturn from ONS HAXV - Total household interest received (D.41) - # https://www.ons.gov.uk/economy/grossdomesticproductgdp/timeseries/haxv/ukea - 2020-01-01: 0.000 - 2021-01-01: 0.225 # 19.6/16.0 - 1 - 2022-01-01: 1.210 # 43.3/19.6 - 1 - 2023-01-01: 0.987 # 86.0/43.3 - 1 - 2024-01-01: 0.142 # 98.2/86.0 - 1 - # Forecast: use OBR non-labour income growth as proxy (Table 1.12) - 2025-01-01: 0.0349 - 2026-01-01: 0.0397 - 2027-01-01: 0.0513 - 2028-01-01: 0.0350 - 2029-01-01: 0.0301 - 2030-01-01: 0.0317 + # Outturn (2020-2025), HAXV £m + 2020-01-01: -0.3025 # 15,988 / 22,923 - 1 + 2021-01-01: 0.2238 # 19,566 / 15,988 - 1 + 2022-01-01: 1.2121 # 43,282 / 19,566 - 1 + 2023-01-01: 0.9891 # 86,092 / 43,282 - 1 + 2024-01-01: 0.1405 # 98,189 / 86,092 - 1 + 2025-01-01: -0.0276 # 95,477 / 98,189 - 1 + # Forecast (2026-2030): (assets_t / assets_t-1) x (rate_t / rate_t-1) - 1 + 2026-01-01: -0.0937 # (7,109.48 / 6,858.72) x (1.9997 / 2.2870) - 1 + 2027-01-01: 0.0653 # (7,287.74 / 7,109.48) x (2.0782 / 1.9997) - 1 + 2028-01-01: 0.1123 # (7,460.84 / 7,287.74) x (2.2579 / 2.0782) - 1 + 2029-01-01: 0.0933 # (7,619.20 / 7,460.84) x (2.4172 / 2.2579) - 1 + 2030-01-01: 0.0843 # (7,768.51 / 7,619.20) x (2.5706 / 2.4172) - 1 metadata: unit: /1 label: Household interest income growth reference: - - title: ONS National Accounts D.41g - Households interest received (HAXV series) + - title: ONS, Households (S.14) interest (D.41) resources, current prices, £m, NSA (HAXV), UK Economic Accounts, 30 September 2026 href: https://www.ons.gov.uk/economy/grossdomesticproductgdp/timeseries/haxv/ukea - - title: OBR EFO March 2026 - Non-labour income growth used as proxy for 2025+ (Table 1.12) + - title: OBR EFO March 2026, detailed forecast tables, economy, Table 1.9 (deposit rate) and Table 1.11 (household financial assets) href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ + per_capita: + household_interest_income: + description: Growth in households' interest received per person. + values: + # (1 + household_interest_income) / (1 + population) - 1, both above + 2021-01-01: 0.2189 # 1.2238 / 1.0040 - 1 + 2022-01-01: 1.1917 # 2.2121 / 1.0093 - 1 + 2023-01-01: 0.9634 # 1.9891 / 1.0131 - 1 + 2024-01-01: 0.1284 # 1.1405 / 1.0107 - 1 + 2025-01-01: -0.0346 # 0.9724 / 1.0072 - 1 + 2026-01-01: -0.0971 # 0.9063 / 1.0038 - 1 + 2027-01-01: 0.0614 # 1.0653 / 1.0037 - 1 + 2028-01-01: 0.1079 # 1.1123 / 1.0040 - 1 + 2029-01-01: 0.0885 # 1.0933 / 1.0044 - 1 + 2030-01-01: 0.0794 # 1.0843 / 1.0045 - 1 + metadata: + unit: /1 + label: Household interest income growth per person + reference: + - title: ONS, Households (S.14) interest (D.41) resources, current prices, £m, NSA (HAXV), UK Economic Accounts, 30 September 2026 + href: https://www.ons.gov.uk/economy/grossdomesticproductgdp/timeseries/haxv/ukea + - title: OBR EFO March 2026, detailed forecast tables, economy, Table 1.9 (deposit rate) and Table 1.11 (household financial assets) + href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ private_rental_prices: # Historical values are calendar-year averages from ONS PIPR. Forecast # values back out private-rent growth from the OBR aggregate rent forecast diff --git a/policyengine_uk/tests/code_health/test_uprating_declarations.py b/policyengine_uk/tests/code_health/test_uprating_declarations.py index 0317076069..0a6de401be 100644 --- a/policyengine_uk/tests/code_health/test_uprating_declarations.py +++ b/policyengine_uk/tests/code_health/test_uprating_declarations.py @@ -46,11 +46,7 @@ # Variables the YAML deliberately uprates by an index other than the one they # declare, with the reason. -KNOWN_INDEX_OVERRIDES = { - # Declares ons.household_interest_income; the YAML applies per_capita.gdp. - # Flagged in #1862 for someone with the context to say which is intended. - "savings_interest_income", -} +KNOWN_INDEX_OVERRIDES: set[str] = set() def _listed_indices(): diff --git a/policyengine_uk/tests/test_savings_interest_uprating.py b/policyengine_uk/tests/test_savings_interest_uprating.py new file mode 100644 index 0000000000..518db293b5 --- /dev/null +++ b/policyengine_uk/tests/test_savings_interest_uprating.py @@ -0,0 +1,135 @@ +"""Savings interest is projected by interest received per person (#1862). + +The aggregate is households' (S.14) interest received, D.41 resources, ONS +series HAXV (UK Economic Accounts, release of 30 September 2026), in £m. From +2026 the OBR forecasts no interest received, so the series is the growth in +household financial assets (OBR March 2026 EFO, economy Table 1.11, ONS NNML, +£bn) times the ratio of deposit rates (Table 1.9, per cent), calendar years. + +Household weights grow by ons.population, so each person's interest grows by +the aggregate net of population growth. Growing it by the aggregate itself +would count population growth twice, the defect #2067 fixed for council tax. +""" + +import numpy as np +import pandas as pd +import pytest + +from policyengine_uk import parameters +from policyengine_uk.data import UKSingleYearDataset +from policyengine_uk.data.economic_assumptions import extend_single_year_dataset + +GROWTH = parameters.gov.economic_assumptions.yoy_growth +AGGREGATE = GROWTH.ons.household_interest_income +PER_PERSON = ( + "gov.economic_assumptions.yoy_growth.ons.per_capita.household_interest_income" +) +POPULATION = GROWTH.ons.population + +# ONS HAXV, calendar years, £m (release of 30 September 2026). +HAXV = { + 2019: 22_923, + 2020: 15_988, + 2021: 19_566, + 2022: 43_282, + 2023: 86_092, + 2024: 98_189, + 2025: 95_477, +} +# OBR March 2026 EFO economy tables, calendar-year rows: household financial +# assets (Table 1.11, £bn) and the deposit rate (Table 1.9, per cent). +FINANCIAL_ASSETS = { + 2025: 6_858.721440, + 2026: 7_109.482196, + 2027: 7_287.736448, + 2028: 7_460.840218, + 2029: 7_619.197210, + 2030: 7_768.507487, +} +DEPOSIT_RATE = { + 2025: 2.287040, + 2026: 1.999673, + 2027: 2.078212, + 2028: 2.257936, + 2029: 2.417240, + 2030: 2.570582, +} + + +@pytest.mark.parametrize("year", range(2020, 2026)) +def test_outturn_is_haxv_growth(year): + # 2025: 95,477 / 98,189 - 1 = -0.0276, against +0.0349 before #1862, + # which was OBR non-labour income growth. + expected = HAXV[year] / HAXV[year - 1] - 1 + assert AGGREGATE(f"{year}-01-01") == pytest.approx(expected, abs=5e-5) + + +@pytest.mark.parametrize("year", range(2026, 2031)) +def test_forecast_is_financial_assets_growth_times_the_deposit_rate_ratio(year): + # 2026: (7,109.48 / 6,858.72) x (1.9997 / 2.2870) - 1 = -0.0937. + expected = ( + FINANCIAL_ASSETS[year] + / FINANCIAL_ASSETS[year - 1] + * DEPOSIT_RATE[year] + / DEPOSIT_RATE[year - 1] + - 1 + ) + assert AGGREGATE(f"{year}-01-01") == pytest.approx(expected, abs=5e-5) + + +@pytest.mark.parametrize("year", range(2021, 2031)) +def test_per_person_growth_is_the_aggregate_net_of_population_growth(year): + # 2025: 0.9724 / 1.0072 - 1 = -0.0346. Fails if ons.population is + # refreshed without re-deriving this series. + instant = f"{year}-01-01" + expected = (1 + AGGREGATE(instant)) / (1 + POPULATION(instant)) - 1 + assert parameters.get_child(PER_PERSON)(instant) == pytest.approx( + expected, abs=5e-5 + ) + + +def _dataset(interest): + ids = np.arange(len(interest)) + return UKSingleYearDataset( + person=pd.DataFrame( + { + "person_id": ids, + "person_benunit_id": ids, + "person_household_id": ids, + "age": 40, + "savings_interest_income": interest, + } + ), + benunit=pd.DataFrame({"benunit_id": ids}), + household=pd.DataFrame( + { + "household_id": ids, + "household_weight": 1.0, + "region": "LONDON", + "tenure_type": "OWNED_OUTRIGHT", + "rent": 0.0, + "council_tax": 0.0, + } + ), + fiscal_year=2024, + ) + + +def test_projected_total_interest_tracks_haxv_in_2025(): + # Each person's interest grows by 0.9724 / 1.0072 = 0.9654 and the + # weights by 1.0072, so the weighted total grows by 95,477 / 98,189. + projected = extend_single_year_dataset( + _dataset([1_000.0, 250.0]), parameters, end_year=2025 + ) + base, after = projected.datasets[2024], projected.datasets[2025] + + def total(dataset): + weights = dataset.household["household_weight"].to_numpy() + return (dataset.person["savings_interest_income"].to_numpy() * weights).sum() + + assert after.person["savings_interest_income"].tolist() == pytest.approx( + [965.4, 241.35], rel=1e-4 + ) + assert total(after) / total(base) == pytest.approx( + HAXV[2025] / HAXV[2024], abs=1e-4 + ) diff --git a/policyengine_uk/variables/input/savings_interest_income.py b/policyengine_uk/variables/input/savings_interest_income.py index 959f73cba3..06f4585e7c 100644 --- a/policyengine_uk/variables/input/savings_interest_income.py +++ b/policyengine_uk/variables/input/savings_interest_income.py @@ -10,6 +10,9 @@ class savings_interest_income(Variable): reference = "Income Tax (Trading and Other Income) Act 2005 s. 365(1)(a)" unit = GBP quantity_type = FLOW - # Use ONS household interest income growth (D.41g/HAXV series) for uprating - # as it captures the significant growth due to interest rate changes - uprating = "gov.economic_assumptions.indices.ons.household_interest_income" + # Grown by households' interest received per person (ONS HAXV, D.41, then + # OBR financial assets and deposit rates). Per person, because household + # weights already grow by population (#1862). + uprating = ( + "gov.economic_assumptions.indices.ons.per_capita.household_interest_income" + ) From caba141b530792752d9bcf937656025d5040cedb Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Sun, 25 Oct 2026 23:22:56 +0000 Subject: [PATCH 2/3] Grow ISA interest by the per-person interest index (#1862) All savings interest now uses ons.per_capita.household_interest_income: non_cash_isa_cash_interest (declaration and dataset YAML), the additive ISA-interest total (dataset projection and Core fallback), and gross interest under the legacy contract. #2589 had left these on GDP per capita or the aggregate ONS index pending this PR. Tests that controlled interest through GDP growth now set the interest index; the column test uses different GDP and interest rates so a wrong index fails. Co-Authored-By: Claude Opus 5.5 --- .../1862-savings-interest-per-person.fixed.md | 2 +- docs/book/assumptions/growthfactors.md | 8 ++++- .../isa-inputs-and-capital-flows.md | 18 +++++----- policyengine_uk/data/economic_assumptions.py | 19 +++++----- policyengine_uk/data/isa_income.py | 2 +- policyengine_uk/tests/test_isa_accounting.py | 4 ++- .../tests/test_isa_income_contract.py | 35 ++++++++++++++----- .../hmrc/isa/non_cash_isa_cash_interest.py | 6 +++- 8 files changed, 63 insertions(+), 31 deletions(-) diff --git a/changelog.d/1862-savings-interest-per-person.fixed.md b/changelog.d/1862-savings-interest-per-person.fixed.md index 7a52480dc5..069766ddc8 100644 --- a/changelog.d/1862-savings-interest-per-person.fixed.md +++ b/changelog.d/1862-savings-interest-per-person.fixed.md @@ -1 +1 @@ -Project `savings_interest_income` by households' interest received per person: ONS HAXV outturn to 2025 (-2.76% in 2025, not the +3.49% non-labour income proxy), then OBR household financial assets growth times the Table 1.9 deposit rate ratio, net of `ons.population` growth so that growing household weights do not count population twice. +Project `savings_interest_income` and `non_cash_isa_cash_interest` (and carried-forward ISA interest totals) by households' interest received per person: ONS HAXV outturn to 2025 (-2.76% in 2025, not the +3.49% non-labour income proxy), then OBR household financial assets growth times the Table 1.9 deposit rate ratio, net of `ons.population` growth so that growing household weights do not count population twice. diff --git a/docs/book/assumptions/growthfactors.md b/docs/book/assumptions/growthfactors.md index b26c734edc..ed35148e78 100644 --- a/docs/book/assumptions/growthfactors.md +++ b/docs/book/assumptions/growthfactors.md @@ -103,7 +103,13 @@ We derive these rates as calendar-year nominal GDP growth from the OBR's March 2 |-------------|------|------|------|------|------|------|------|------| | Growth rate | 10.0% | 5.2% | 3.9% | 4.7% | 2.9% | 3.3% | 3.2% | 3.1% | -We use per capita GDP for these variables: `benunit_reported_capital`, `capital_gains`, `capital_gains_badr`, `capital_gains_before_response`, `capital_gains_carried_interest`, `capital_gains_residential_property`, `cash_isa`, `corporate_wealth`, `directly_held_shares`, `dividend_income`, `gross_financial_wealth`, `household_lifetime_isa_balance`, `lifetime_isa_balance`, `lump_sum_income`, `main_residence_value`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `net_financial_wealth`, `non_residential_property_value`, `other_investment_income`, `other_residential_property_value`, `owned_land`, `pension_credit_reported_capital`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `property_rental_income`, `savings`, `savings_interest_income`, `spousal_maintenance_income`, `stocks_and_shares_isa`, `sublet_income`, `uc_reported_capital`, `unit_and_investment_trusts` +We use per capita GDP for these variables: `benunit_reported_capital`, `capital_gains`, `capital_gains_badr`, `capital_gains_before_response`, `capital_gains_carried_interest`, `capital_gains_residential_property`, `cash_isa`, `corporate_wealth`, `directly_held_shares`, `dividend_income`, `gross_financial_wealth`, `household_lifetime_isa_balance`, `lifetime_isa_balance`, `lump_sum_income`, `main_residence_value`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `net_financial_wealth`, `non_residential_property_value`, `other_investment_income`, `other_residential_property_value`, `owned_land`, `pension_credit_reported_capital`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `property_rental_income`, `savings`, `spousal_maintenance_income`, `stocks_and_shares_isa`, `sublet_income`, `uc_reported_capital`, `unit_and_investment_trusts` + +## Interest received per person + +Households' interest received is ONS series HAXV (D.41) to 2025. From 2026 the OBR's March 2026 EFO gives household financial assets (Table 1.11) and the deposit rate (Table 1.9), and interest grows by (1 + asset growth) × (this year's rate / last year's) − 1. Household weights grow with population, so each person's interest grows by (1 + interest growth) / (1 + population growth) − 1. + +We apply this to: `non_cash_isa_cash_interest`, `savings_interest_income` ## Council tax diff --git a/docs/book/assumptions/isa-inputs-and-capital-flows.md b/docs/book/assumptions/isa-inputs-and-capital-flows.md index 5d32927bb1..1ad604ff46 100644 --- a/docs/book/assumptions/isa-inputs-and-capital-flows.md +++ b/docs/book/assumptions/isa-inputs-and-capital-flows.md @@ -48,15 +48,15 @@ income a second time. For household inputs carried into later years, ISA dividends use the same nominal GDP-per-capita index as ordinary `dividend_income`. -`non_cash_isa_cash_interest` uses the same ONS household-interest-income index -as ordinary `savings_interest_income`. An explicit observation for a later -year takes priority and provides the starting amount for subsequent years. -Dataset projections grow stored ISA dividends and non-cash ISA cash interest -by nominal GDP per capita, matching the dataset rules for ordinary dividends -and savings interest. This also applies before 2031. The non-cash interest -detail therefore follows the existing dataset interest rule, while direct -household inputs use the ONS index above. Alignment of the interest indices -is tracked separately in [#2638](https://github.com/PolicyEngine/policyengine-uk/pull/2638). +`non_cash_isa_cash_interest` and an ISA-interest total carried forward under +the additive contract use the same index as ordinary `savings_interest_income`: +households' interest received per person +(`ons.per_capita.household_interest_income`, see +[#2638](https://github.com/PolicyEngine/policyengine-uk/pull/2638)). An +explicit observation for a later year takes priority and provides the starting +amount for subsequent years. Dataset projections grow stored ISA dividends by +nominal GDP per capita, as for ordinary dividends, and stored non-cash ISA cash +interest by the per-person interest index, as for ordinary savings interest. The legacy and additive interest contracts retain their recorded conventions. From 6 April 2027, `isa_cash_interest_flat_rate_charge` applies the savings diff --git a/policyengine_uk/data/economic_assumptions.py b/policyengine_uk/data/economic_assumptions.py index f2050d3720..589efe0a97 100644 --- a/policyengine_uk/data/economic_assumptions.py +++ b/policyengine_uk/data/economic_assumptions.py @@ -32,6 +32,12 @@ # below threshold. _GRADUATE_LOAN_TAKE_UP = 0.4 +# Households' interest received per person (#1862). Gross legacy interest and +# additive ISA interest totals grow by it, as savings_interest_income does. +INTEREST_GROWTH = ( + "gov.economic_assumptions.yoy_growth.ons.per_capita.household_interest_income" +) + _ENGLAND_REGIONS = { "NORTH_EAST", "NORTH_WEST", @@ -373,23 +379,20 @@ def apply_single_year_uprating( current_year.isa_income_contract["projection"] == "legacy_gross" and SAVINGS_INTEREST in current_year.person ): - growth = parameters.get_child( - "gov.economic_assumptions.yoy_growth.obr.per_capita.gdp" - )(current_year.time_period) + growth = parameters.get_child(INTEREST_GROWTH)(current_year.time_period) values = gross_interest(previous_year) * (1 + growth) if current_year.isa_income_contract["representation"] == "additive": values = values - effective_tax_free(current_year.person) current_year.person[SAVINGS_INTEREST] = values - # New additive ISA receipts use the same dataset GDP rule as ordinary - # interest. Legacy receipts retain their former unindexed treatment. + # New additive ISA receipts use the same per-person interest index as + # ordinary interest. Legacy receipts retain their former unindexed + # treatment. if ( current_year.isa_income_contract["projection"] == "additive" and ISA_INTEREST in current_year.person ): - growth = parameters.get_child( - "gov.economic_assumptions.yoy_growth.obr.per_capita.gdp" - )(current_year.time_period) + growth = parameters.get_child(INTEREST_GROWTH)(current_year.time_period) current_year.person[ISA_INTEREST] = previous_year.person[ISA_INTEREST] * ( 1 + growth ) diff --git a/policyengine_uk/data/isa_income.py b/policyengine_uk/data/isa_income.py index 474894cc39..5257bcfe47 100644 --- a/policyengine_uk/data/isa_income.py +++ b/policyengine_uk/data/isa_income.py @@ -91,7 +91,7 @@ def isa_interest_income_default(simulation, period): ): return values index = simulation.tax_benefit_system.parameters.get_child( - "gov.economic_assumptions.indices.ons.household_interest_income" + "gov.economic_assumptions.indices.ons.per_capita.household_interest_income" ) source_index = _uprating_index_value(index, source.start) target_index = _uprating_index_value(index, period.start) diff --git a/policyengine_uk/tests/test_isa_accounting.py b/policyengine_uk/tests/test_isa_accounting.py index 7f733371f9..acd2286d81 100644 --- a/policyengine_uk/tests/test_isa_accounting.py +++ b/policyengine_uk/tests/test_isa_accounting.py @@ -102,7 +102,9 @@ def test_noncash_isa_interest_charge_reaches_tax_and_final_current_income_once() GDP_INDEX = "gov.economic_assumptions.indices.obr.per_capita.gdp" -INTEREST_INDEX = "gov.economic_assumptions.indices.ons.household_interest_income" +INTEREST_INDEX = ( + "gov.economic_assumptions.indices.ons.per_capita.household_interest_income" +) ISA_INCOME_PAIRS = [ ("individual_savings_account_dividend_income", "dividend_income", [100, 125, 150]), ("non_cash_isa_cash_interest", "savings_interest_income", [100, 150, 200]), diff --git a/policyengine_uk/tests/test_isa_income_contract.py b/policyengine_uk/tests/test_isa_income_contract.py index 81f5b62353..72f911a517 100644 --- a/policyengine_uk/tests/test_isa_income_contract.py +++ b/policyengine_uk/tests/test_isa_income_contract.py @@ -30,9 +30,13 @@ NORMALIZED = {"version": 1, "representation": "additive", "projection": "legacy_gross"} ADDITIVE = {"version": 1, "representation": "additive", "projection": "additive"} GDP_GROWTH = "gov.economic_assumptions.yoy_growth.obr.per_capita.gdp" +INTEREST_GROWTH = ( + "gov.economic_assumptions.yoy_growth.ons.per_capita.household_interest_income" +) MACRO = Scenario( parameter_changes={ - GDP_GROWTH: {"year:2026-01-01:1": 0.1, "year:2027-01-01:1": 0.2} + GDP_GROWTH: {"year:2026-01-01:1": 0.1, "year:2027-01-01:1": 0.2}, + INTEREST_GROWTH: {"year:2026-01-01:1": 0.1, "year:2027-01-01:1": 0.2}, }, applied_before_data_load=True, ) @@ -444,7 +448,7 @@ def test_legacy_origin_core_fallback_after_projection_horizon_preserves_gross_an ) ] ) - index = "gov.economic_assumptions.indices.ons.household_interest_income" + index = "gov.economic_assumptions.indices.ons.per_capita.household_interest_income" macro = Scenario( parameter_changes={index: {"year:2032-01-01:1": 100, "year:2033-01-01:1": 200}}, applied_before_data_load=True, @@ -483,7 +487,7 @@ def test_existing_isa_total_keeps_priority_over_its_same_period_of_which_detail( ) ] ) - index = "gov.economic_assumptions.indices.ons.household_interest_income" + index = "gov.economic_assumptions.indices.ons.per_capita.household_interest_income" macro = Scenario( parameter_changes={index: {"year:2032-01-01:1": 100, "year:2033-01-01:1": 200}}, applied_before_data_load=True, @@ -505,7 +509,7 @@ def gross_core_reference(savings_entries, exemptions, isa_interest): reference_system = system.clone() reference_system.parameters = deepcopy(system.parameters) reference_system.parameters.get_child( - "gov.economic_assumptions.indices.ons.household_interest_income" + "gov.economic_assumptions.indices.ons.per_capita.household_interest_income" ).update(period="year:2025:5", value=100) person = { "age": {2026: 35}, @@ -790,6 +794,7 @@ def test_isa_and_energy_contracts_compose_through_reset_export_and_projection( for year, level in {2025: 100, 2026: 125, 2027: 150}.items(): cpi.update(period=str(year), value=level) parameters.get_child(GDP_GROWTH).update(period="2027", value=0.2) + parameters.get_child(INTEREST_GROWTH).update(period="2027", value=0.2) projected = extend_single_year_dataset(reloaded, parameters, end_year=2027) projected_simulation = Simulation(dataset=projected) assert value( @@ -879,6 +884,7 @@ def test_split_bsp_isa_and_energy_share_reset_and_projection_boundaries(): }, "gov.economic_assumptions.yoy_growth.obr.consumer_price_index": {"2027": 0.2}, GDP_GROWTH: {"2027": 0.2}, + INTEREST_GROWTH: {"2027": 0.2}, } parameters = simulation.tax_benefit_system.parameters.clone() for name, periods in changes.items(): @@ -935,6 +941,8 @@ def test_new_isa_columns_follow_ordinary_dataset_projection_in_both_contracts( ): """Both sides of the 2030 map boundary use the ordinary dataset indices. + Dividends grow by GDP per capita (10%) and all interest by interest + received per person (5%), so a column on the wrong index fails. Ordinary interest and ISA interest together start at 2000 on either contract. Legacy projection grows gross interest before subtracting the unchanged 500 exemption; additive projection grows ordinary 1500 and @@ -945,7 +953,10 @@ def test_new_isa_columns_follow_ordinary_dataset_projection_in_both_contracts( """ projection = Scenario( parameter_changes={ - GDP_GROWTH: {f"year:{year}-01-01:1": 0.1 for year in range(2026, 2033)} + GDP_GROWTH: {f"year:{year}-01-01:1": 0.1 for year in range(2026, 2033)}, + INTEREST_GROWTH: { + f"year:{year}-01-01:1": 0.05 for year in range(2026, 2033) + }, }, applied_before_data_load=True, ) @@ -965,14 +976,20 @@ def test_new_isa_columns_follow_ordinary_dataset_projection_in_both_contracts( for year in (2025, 2028, 2030, 2031, 2032): factor = 1.1 ** (year - 2025) + interest_factor = 1.05 ** (year - 2025) + column_factor = ( + interest_factor if ordinary_column == "savings_interest_income" else factor + ) ordinary = value(benchmark, ordinary_column, year) - assert ordinary == pytest.approx(supplied * factor, abs=0.01) + assert ordinary == pytest.approx(supplied * column_factor, abs=0.01) assert value(simulation, isa_column, year) == pytest.approx(ordinary, abs=0.01) if year > 2025: assert ordinary > supplied - exempt = 500 if contract == LEGACY else 500 * factor + exempt = 500 if contract == LEGACY else 500 * interest_factor ordinary_interest = ( - 2_000 * factor - exempt if contract == LEGACY else 1_500 * factor + 2_000 * interest_factor - exempt + if contract == LEGACY + else 1_500 * interest_factor ) assert value( simulation, "individual_savings_account_interest_income", year @@ -990,7 +1007,7 @@ def test_new_isa_columns_follow_ordinary_dataset_projection_in_both_contracts( 200 * factor, abs=0.01 ) assert value(simulation, "capital_income", year) == pytest.approx( - 2_500 * factor, abs=0.01 + 2_000 * interest_factor + 500 * factor, abs=0.01 ) for actual, before in ((source, original), (benchmark_source, benchmark_original)): diff --git a/policyengine_uk/variables/gov/hmrc/isa/non_cash_isa_cash_interest.py b/policyengine_uk/variables/gov/hmrc/isa/non_cash_isa_cash_interest.py index f8ba29d55d..8c27b9998c 100644 --- a/policyengine_uk/variables/gov/hmrc/isa/non_cash_isa_cash_interest.py +++ b/policyengine_uk/variables/gov/hmrc/isa/non_cash_isa_cash_interest.py @@ -10,5 +10,9 @@ class non_cash_isa_cash_interest(Variable): unit = GBP quantity_type = FLOW default_value = 0 - uprating = "gov.economic_assumptions.indices.ons.household_interest_income" + # Same per-person index as savings_interest_income (#1862): household + # weights already grow by population. + uprating = ( + "gov.economic_assumptions.indices.ons.per_capita.household_interest_income" + ) reference = "https://www.legislation.gov.uk/uksi/2026/1018/regulation/15/made" From b1ee29ebe48d19c571729ff4347c0c889cfa1b13 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Mon, 26 Oct 2026 23:34:54 +0000 Subject: [PATCH 3/3] Anchor the per-person interest index at 2020 (#1862) The per-person series starts in 2021, so the index generator set I[2021] = 1 and dropped the 2021 growth. A legacy 2020 observation queried for 2021 then gave 100 on the direct index route against 121.94 from dataset projection. An opt-in `index_base_year` metadata key now anchors the index a year earlier without inventing a 2020 growth value. Adds a 2020 to 2021 regression test comparing both routes, and updates the dataset guide's ISA growth rule. Co-Authored-By: Claude Opus 5.5 --- .../1862-savings-interest-per-person.fixed.md | 2 +- docs/engineering/skills/dataset-sources.md | 4 +- .../create_economic_assumption_indices.py | 14 ++++++ .../gov/economic_assumptions/yoy_growth.yaml | 5 +++ .../tests/test_savings_interest_uprating.py | 44 ++++++++++++++++++- 5 files changed, 64 insertions(+), 5 deletions(-) diff --git a/changelog.d/1862-savings-interest-per-person.fixed.md b/changelog.d/1862-savings-interest-per-person.fixed.md index 069766ddc8..7ad3115d74 100644 --- a/changelog.d/1862-savings-interest-per-person.fixed.md +++ b/changelog.d/1862-savings-interest-per-person.fixed.md @@ -1 +1 @@ -Project `savings_interest_income` and `non_cash_isa_cash_interest` (and carried-forward ISA interest totals) by households' interest received per person: ONS HAXV outturn to 2025 (-2.76% in 2025, not the +3.49% non-labour income proxy), then OBR household financial assets growth times the Table 1.9 deposit rate ratio, net of `ons.population` growth so that growing household weights do not count population twice. +Project `savings_interest_income` and `non_cash_isa_cash_interest` (and carried-forward ISA interest totals) by households' interest received per person: ONS HAXV outturn to 2025 (-2.76% in 2025, not the +3.49% non-labour income proxy), then OBR household financial assets growth times the Table 1.9 deposit rate ratio, net of `ons.population` growth so that growing household weights do not count population twice. The index twin is anchored at 2020, so direct index uprating from 2020 to 2021 includes the 2021 growth, as dataset projection does. diff --git a/docs/engineering/skills/dataset-sources.md b/docs/engineering/skills/dataset-sources.md index cbd3348364..72b193fe92 100644 --- a/docs/engineering/skills/dataset-sources.md +++ b/docs/engineering/skills/dataset-sources.md @@ -69,8 +69,8 @@ and `additive`/`legacy_gross` for normalized legacy data. Representation and projection origin are separate: legacy projection grows the original gross amount before subtracting the effective exemption. It must not grow the already-normalized ordinary amount instead. New additive dataset projections -grow ISA interest alongside ordinary interest by the dataset's GDP-per-capita -rule; outside the stored horizon new inputs use their variable's own index. +grow ISA interest alongside ordinary interest by the per-person interest +series (`yoy_growth.ons.per_capita.household_interest_income`); outside the stored horizon new inputs use their variable's own index. Legacy-origin fallback retains the old gross-index calculation and unindexed ISA carry-over, while explicit inputs and applicable ISA detail formulas take priority. Legacy files containing only an aggregate exemption still have that diff --git a/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py b/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py index 90ecde8187..80d5d0e420 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py +++ b/policyengine_uk/parameters/gov/economic_assumptions/create_economic_assumption_indices.py @@ -38,6 +38,20 @@ def create_economic_assumption_indices( ) else: start_year = int(descendant.values_list[-1].instant_str[:4]) + # By default the first growth year is the index base, so its + # growth is not in the index. A series can set + # `index_base_year` to an earlier year to anchor the index there + # instead, so the first growth step is included. The anchor is + # an index level of 1, not a growth observation. + base_year = (descendant.metadata or {}).get("index_base_year") + if base_year is None: + base_year = start_year + elif base_year >= start_year: + raise ValueError( + f"{full_name}: index_base_year {base_year} must be before " + f"the first growth year {start_year}." + ) + start_year = base_year values = {start_year: 1.0} for year in range(start_year + 1, 2040): diff --git a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml index 0d6f50a86c..a94a9b56a1 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml +++ b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml @@ -1165,6 +1165,11 @@ ons: metadata: unit: /1 label: Household interest income growth per person + # The series starts at 2021 because ons.population does, so 2020 + # growth per person is not defined. Anchor the index at 1 on + # 1 January 2020 so its first step is the 2021 growth, matching + # dataset projection from 2020 to 2021. + index_base_year: 2020 reference: - title: ONS, Households (S.14) interest (D.41) resources, current prices, £m, NSA (HAXV), UK Economic Accounts, 30 September 2026 href: https://www.ons.gov.uk/economy/grossdomesticproductgdp/timeseries/haxv/ukea diff --git a/policyengine_uk/tests/test_savings_interest_uprating.py b/policyengine_uk/tests/test_savings_interest_uprating.py index 3b1845b0de..c44ff55a30 100644 --- a/policyengine_uk/tests/test_savings_interest_uprating.py +++ b/policyengine_uk/tests/test_savings_interest_uprating.py @@ -88,7 +88,7 @@ def test_per_person_growth_is_the_aggregate_net_of_population_growth(year): ) -def _dataset(interest): +def _dataset(interest, fiscal_year=2024, **kwargs): ids = np.arange(len(interest)) return UKSingleYearDataset( person=pd.DataFrame( @@ -111,7 +111,8 @@ def _dataset(interest): "council_tax": 0.0, } ), - fiscal_year=2024, + fiscal_year=fiscal_year, + **kwargs, ) @@ -133,3 +134,42 @@ def total(dataset): assert total(after) / total(base) == pytest.approx( HAXV[2025] / HAXV[2024], abs=1e-4 ) + + +INDEX = "gov.economic_assumptions.indices.ons.per_capita.household_interest_income" +LEGACY = {"version": 1, "representation": "legacy_gross", "projection": "legacy_gross"} + + +def test_index_includes_the_first_growth_year(): + # The per-person series starts at 2021 (ons.population has no 2020 + # value), so the index is anchored at 1 on 1 January 2020 and its + # first step is the 2021 growth: 1.2238 / 1.0036 = 1.2194. + index = parameters.get_child(INDEX) + assert index("2020-01-01") == 1 + assert index("2021-01-01") / index("2020-01-01") == pytest.approx( + 1 + parameters.get_child(PER_PERSON)("2021-01-01"), abs=1e-5 + ) + + +def test_direct_index_route_matches_dataset_projection_from_2020_to_2021(): + # A legacy 2020 observation queried for 2021 without projection uses + # the index ratio (Simulation._run_formula); dataset projection uses the + # 2021 growth rate. Both must give 100 x 1.2194. + from copy import deepcopy + + from policyengine_uk import Simulation + from policyengine_uk.data import UKMultiYearDataset + + def observed(): + return _dataset([100.0], 2020, isa_income_contract=deepcopy(LEGACY)) + + direct = Simulation(dataset=UKMultiYearDataset(datasets=[observed()])) + direct_2021 = float(direct.calculate("savings_interest_income", 2021)[0]) + + projected = extend_single_year_dataset(observed(), parameters, end_year=2021) + projected_2021 = float( + projected.datasets[2021].person["savings_interest_income"].iloc[0] + ) + + assert projected_2021 == pytest.approx(121.94, abs=0.01) + assert direct_2021 == pytest.approx(projected_2021, abs=0.01)