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..7ad3115d74 --- /dev/null +++ b/changelog.d/1862-savings-interest-per-person.fixed.md @@ -0,0 +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. 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/book/assumptions/growthfactors.md b/docs/book/assumptions/growthfactors.md index 4a4c717902..3363e4e29e 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 October |-------------|------|------|------|------|------|------|------|------|------|------| | Growth rate | 10.0% | 5.2% | 4.0% | 4.6% | 4.0% | 3.1% | 3.4% | 3.6% | 3.4% | 3.3% | -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`, `cash_isa_balance`, `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`, `stocks_and_shares_isa_balance`, `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`, `cash_isa_balance`, `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`, `stocks_and_shares_isa_balance`, `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 b64209bcd7..22991a6de9 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/docs/engineering/skills/dataset-sources.md b/docs/engineering/skills/dataset-sources.md index 57efacef55..d4baaabf28 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/data/economic_assumptions.py b/policyengine_uk/data/economic_assumptions.py index 6db07617f5..ea2af9a3cd 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", @@ -374,23 +380,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/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index ee591c95de..dfe2fdac99 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -138,8 +138,6 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - property_income - property_rental_income - savings -- savings_interest_income -- non_cash_isa_cash_interest - spousal_maintenance_income - stocks_and_shares_isa - stocks_and_shares_isa_balance @@ -164,6 +162,11 @@ 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). ISA cash interest in non-cash ISAs follows the same index. +gov.economic_assumptions.yoy_growth.ons.per_capita.household_interest_income: +- non_cash_isa_cash_interest +- savings_interest_income # ORR passenger journeys per person, as household weights grow by population # (#1862). gov.economic_assumptions.yoy_growth.orr.per_capita.rail_passenger_journeys: 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 f654017784..efeb5da0e6 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml +++ b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml @@ -1154,33 +1154,74 @@ ons: href: https://obr.uk/supplementary-forecast-information-release-long-term-economic-determinants-march-2026/ - title: ONS, "National population projections - 2024-based" (28 April 2026; principal projection, used by the OBR from 2031-32) href: https://www.ons.gov.uk/peoplepopulationandcommunity/populationandmigration/populationprojections/bulletins/nationalpopulationprojections/2024based + # 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 (2020-2025), ONS HAXV, total household interest received - # (D.41), £m, 30 September 2026 release + # 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-2031): OBR non-labour income growth as a proxy - # (October 2026 EFO, Table 1.12) - 2026-01-01: 0.0302 - 2027-01-01: 0.0468 - 2028-01-01: 0.0419 - 2029-01-01: 0.0409 - 2030-01-01: 0.0382 - 2031-01-01: 0.0332 + # 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, 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 October 2026 - Non-labour income growth used as proxy for 2026+ (Table 1.12) - href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + - 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.2194 # 1.2238 / 1.0036 - 1 + 2022-01-01: 1.1891 # 2.2121 / 1.0105 - 1 + 2023-01-01: 0.9628 # 1.9891 / 1.0134 - 1 + 2024-01-01: 0.1295 # 1.1405 / 1.0097 - 1 + 2025-01-01: -0.0308 # 0.9724 / 1.0033 - 1 + 2026-01-01: -0.0955 # 0.9063 / 1.0020 - 1 + 2027-01-01: 0.0639 # 1.0653 / 1.0013 - 1 + 2028-01-01: 0.1104 # 1.1123 / 1.0017 - 1 + 2029-01-01: 0.0910 # 1.0933 / 1.0021 - 1 + 2030-01-01: 0.0816 # 1.0843 / 1.0025 - 1 + 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 + - 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 (2026-2031) are the OBR's UK private rents forecast, "Private diff --git a/policyengine_uk/tests/code_health/test_uprating_declarations.py b/policyengine_uk/tests/code_health/test_uprating_declarations.py index 24c1c898dd..a7a814cd78 100644 --- a/policyengine_uk/tests/code_health/test_uprating_declarations.py +++ b/policyengine_uk/tests/code_health/test_uprating_declarations.py @@ -41,15 +41,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", - # Like ordinary savings interest, noncash ISA interest declares the ONS - # household index but follows the existing GDP dataset rule. Broader - # alignment of those two surfaces remains separate work in #2638. - "non_cash_isa_cash_interest", -} +KNOWN_INDEX_OVERRIDES: set[str] = set() def _listed_indices(): 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/tests/test_savings_interest_uprating.py b/policyengine_uk/tests/test_savings_interest_uprating.py new file mode 100644 index 0000000000..502559f41a --- /dev/null +++ b/policyengine_uk/tests/test_savings_interest_uprating.py @@ -0,0 +1,175 @@ +"""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.0033 - 1 = -0.0308. 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, fiscal_year=2024, **kwargs): + 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=fiscal_year, + **kwargs, + ) + + +def test_projected_total_interest_tracks_haxv_in_2025(): + # Each person's interest grows by 0.9724 / 1.0033 = 0.9692 and the + # weights by 1.0033, 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( + [969.2, 242.3], rel=1e-4 + ) + 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) 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" diff --git a/policyengine_uk/variables/input/savings_interest_income.py b/policyengine_uk/variables/input/savings_interest_income.py index d5bd3e6442..4837d32062 100644 --- a/policyengine_uk/variables/input/savings_interest_income.py +++ b/policyengine_uk/variables/input/savings_interest_income.py @@ -14,6 +14,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" + )