From eba18eb993ec0a1dd8579d223bde73aeb8a5439e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:21:53 +0100 Subject: [PATCH 1/5] Uprate residential property values with house prices --- changelog.d/2392.fixed.md | 1 + policyengine_uk/data/uprating_indices.yaml | 7 +- .../high_value_council_tax_surcharge.yaml | 11 ++ .../test_residential_property_uprating.py | 155 ++++++++++++++++++ .../hmrc/high_value_council_tax_surcharge.py | 8 +- .../variables/input/main_residence_value.py | 5 +- .../input/other_residential_property_value.py | 5 +- 7 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 changelog.d/2392.fixed.md create mode 100644 policyengine_uk/tests/test_residential_property_uprating.py diff --git a/changelog.d/2392.fixed.md b/changelog.d/2392.fixed.md new file mode 100644 index 0000000000..3274909f20 --- /dev/null +++ b/changelog.d/2392.fixed.md @@ -0,0 +1 @@ +- Corrected residential property uprating and the High Value Council Tax Surcharge's 2026 valuation conversion to use the OBR house-price index. diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index a489f3c56d..bbba2e93e4 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -86,6 +86,9 @@ gov.economic_assumptions.yoy_growth.obr.diesel_spending_litre_proxy: - diesel_spending gov.economic_assumptions.yoy_growth.obr.petrol_spending_litre_proxy: - petrol_spending +gov.economic_assumptions.yoy_growth.obr.house_prices: +- main_residence_value +- other_residential_property_value gov.economic_assumptions.yoy_growth.obr.mortgage_interest: - mortgage_interest_repayment - property_finance_costs @@ -104,7 +107,6 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - household_lifetime_isa_balance - lifetime_isa_balance - lump_sum_income -- main_residence_value - maintenance_income - miscellaneous_income - mortgage_capital_repayment @@ -112,9 +114,8 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - non_residential_property_value - non_residential_property_secured_debt - other_investment_income -- other_residential_property_value - other_residential_property_secured_debt -# Purchase prices a dataset may impute; uprated like the property stocks. +# Purchase-price inputs retain their existing GDP-per-capita uprating. - additional_residential_property_purchased - non_residential_property_purchased - owned_land diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/council_tax/high_value_council_tax_surcharge.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/council_tax/high_value_council_tax_surcharge.yaml index f0f966504f..6a8640b25c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/council_tax/high_value_council_tax_surcharge.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/council_tax/high_value_council_tax_surcharge.yaml @@ -10,6 +10,17 @@ gov.hmrc.stamp_duty.property_sale_rate: 0 output: high_value_council_tax_surcharge: 0 +- name: A 2028 value just above the first band on the annual 2026 HPI basis is charged + period: 2028 + input: + age: 40 + region: LONDON + tenure_type: OWNED_OUTRIGHT + # £2.01m in 2026 prices, grown by 2027 HPI 2.9% and 2028 HPI 2.7%. + # This annual index is a proxy for the published 2026 valuation basis. + main_residence_value: 2_124_133.83 + output: + high_value_council_tax_surcharge: 2_500 - name: High value council tax surcharge applies in England and flows into aggregates period: 2028 input: diff --git a/policyengine_uk/tests/test_residential_property_uprating.py b/policyengine_uk/tests/test_residential_property_uprating.py new file mode 100644 index 0000000000..432c023a09 --- /dev/null +++ b/policyengine_uk/tests/test_residential_property_uprating.py @@ -0,0 +1,155 @@ +"""Residential stocks and HVCTS use one annual house-price basis (#2392). + +These are synthetic households. Factors below use the model's existing +March 2026 OBR annual HPI assumptions, not a month-specific observation. +The surcharge's published 2026 valuation bands supply the charge oracle. +""" + +import numpy as np +import pandas as pd +import pytest + +from policyengine_uk import Microsimulation, Simulation +from policyengine_uk.data import UKSingleYearDataset +from policyengine_uk.model_api import Scenario + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +ASSETS = ["main_residence_value", "other_residential_property_value"] +HPI_2024_TO_2026 = 1.028 * 1.024 +HPI_2026_TO_2028 = 1.029 * 1.027 +MAIN_VALUES = np.array([1_880_000, 2_360_000, 3_280_000, 4_700_000], dtype=float) +OTHER_VALUES = np.array([125_000, 0, 900_000, 60_000], dtype=float) + + +def property_dataset(): + """One adult in each of four owner households, with no private data.""" + ids = np.arange(1, 5) + return UKSingleYearDataset( + person=pd.DataFrame( + { + "person_id": ids, + "person_benunit_id": ids, + "person_household_id": ids, + "age": 40, + "employment_income": 0.0, + } + ), + 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, + "main_residence_value": MAIN_VALUES, + "other_residential_property_value": OTHER_VALUES, + } + ), + fiscal_year=2024, + ) + + +@pytest.fixture(scope="module") +def projected_properties(cloned_uk_tax_benefit_system): + return Microsimulation(dataset=property_dataset()) + + +@pytest.mark.parametrize("asset", ASSETS) +def test_direct_property_stock_uses_house_price_growth(asset): + sim = Simulation( + situation={ + "people": {"adult": {"age": {2024: 40}}}, + "households": {"home": {"members": ["adult"], asset: {2024: 1_880_000}}}, + } + ) + # Core carries inputs with cumulative index levels, which the model + # rounds to five decimal places. Select the required HPI explicitly; + # the dataset test independently checks the unrounded YoY arithmetic. + hpi = sim.tax_benefit_system.parameters.get_child( + "gov.economic_assumptions.indices.obr.house_prices" + ) + for year in [2024, 2026, 2028]: + assert sim.calculate(asset, year)[0] == pytest.approx( + 1_880_000 * hpi(year) / hpi(2024), abs=0.25 + ) + + +@pytest.mark.parametrize("asset,values", zip(ASSETS, [MAIN_VALUES, OTHER_VALUES])) +def test_dataset_property_stocks_use_house_price_growth( + projected_properties, asset, values +): + for year, factor in [ + (2024, 1), + (2026, HPI_2024_TO_2026), + (2028, HPI_2024_TO_2026 * HPI_2026_TO_2028), + ]: + # Stored dataset arrays take precedence over Core's variable-level + # uprating, so both paths need the same house-price series. + np.testing.assert_allclose( + projected_properties.dataset[year].household[asset], values * factor + ) + np.testing.assert_allclose( + projected_properties.calculate(asset, year), values * factor, atol=0.25 + ) + + +def test_projected_homes_use_2026_house_price_bands(projected_properties): + # HPI places these 2024 homes just below each 2026 band boundary. + # GDP-per-capita growth places all four above the respective boundary. + np.testing.assert_array_equal( + projected_properties.calculate("high_value_council_tax_surcharge", 2028), + [0, 2_500, 3_500, 5_000], + ) + + +def test_later_bill_preserves_a_supplied_2026_property_value(): + sim = Simulation( + situation={ + "people": {"adult": {"age": {2026: 40}}}, + "households": { + "home": { + "members": ["adult"], + "region": "LONDON", + "tenure_type": "OWNED_OUTRIGHT", + "main_residence_value": {2026: 2_010_000}, + } + }, + } + ) + assert sim.calculate("high_value_council_tax_surcharge", 2028)[0] == 2_500 + + +def test_house_price_valuation_reaches_final_tax_totals(): + # Supply a 2028 value corresponding to £2.01m on the annual 2026 basis. + # The old GDP reversal instead puts it below £2m and charges nothing. + situation = { + "people": {"adult": {"age": {2028: 40}, "employment_income": 0}}, + "households": { + "home": { + "members": ["adult"], + "region": "LONDON", + "tenure_type": "OWNED_OUTRIGHT", + "main_residence_value": {2028: 2_010_000 * HPI_2026_TO_2028}, + "council_tax": 0, + "household_owns_tv": False, + "property_purchased": False, + } + }, + } + sim = Simulation( + situation=situation, + scenario=Scenario( + parameter_changes={"gov.contrib.abolish_council_tax": {"2028": True}} + ), + ) + assert sim.baseline.calculate("high_value_council_tax_surcharge", 2028)[0] == 2_500 + # The abolition removes this charge from the tax aggregates. Compare the + # difference so unrelated existing transaction-tax heads are not forced + # to zero or mistaken for surcharge revenue. + for variable in ["household_tax", "gov_tax"]: + np.testing.assert_allclose( + sim.baseline.calculate(variable, 2028) - sim.calculate(variable, 2028), + [2_500], + ) diff --git a/policyengine_uk/variables/gov/hmrc/high_value_council_tax_surcharge.py b/policyengine_uk/variables/gov/hmrc/high_value_council_tax_surcharge.py index e9c4913355..8086f437cf 100644 --- a/policyengine_uk/variables/gov/hmrc/high_value_council_tax_surcharge.py +++ b/policyengine_uk/variables/gov/hmrc/high_value_council_tax_surcharge.py @@ -10,7 +10,9 @@ class high_value_council_tax_surcharge(Variable): "worth at least £2 million in 2026 prices. Owners, not occupiers, are " "liable, so the household is charged on its main residence only where it " "owns that home (owned outright or with a mortgage). Let property and " - "second homes are not modelled." + "second homes are not modelled. The annual OBR house-price index " + "provides a proxy for converting simulated values to the 2026 " + "valuation basis." ) definition_period = YEAR unit = GBP @@ -36,10 +38,10 @@ def formula(household, period, parameters): p = parameters(period) property_value = household("main_residence_value", period) - current_index = p.gov.economic_assumptions.indices.obr.per_capita.gdp + current_index = p.gov.economic_assumptions.indices.obr.house_prices baseline_index = parameters( "2026" - ).gov.economic_assumptions.indices.obr.per_capita.gdp + ).gov.economic_assumptions.indices.obr.house_prices value_2026_prices = property_value / (current_index / baseline_index) surcharge = p.gov.hmrc.council_tax.high_value_surcharge.amount.calc( diff --git a/policyengine_uk/variables/input/main_residence_value.py b/policyengine_uk/variables/input/main_residence_value.py index a7ba0078e5..56e5f7e522 100644 --- a/policyengine_uk/variables/input/main_residence_value.py +++ b/policyengine_uk/variables/input/main_residence_value.py @@ -6,11 +6,12 @@ class main_residence_value(Variable): documentation = ( "Total value of the main residence. Set tenure_type to an owner tenure " "for owner-only charges such as the High Value Council Tax Surcharge. " - "Excluded from Universal Credit capital under Schedule 10 paragraph 1." + "Excluded from Universal Credit capital under Schedule 10 paragraph 1. " + "Values carried forward use the model's annual OBR house-price index." ) entity = Household definition_period = YEAR value_type = float unit = GBP quantity_type = STOCK - uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" + uprating = "gov.economic_assumptions.indices.obr.house_prices" diff --git a/policyengine_uk/variables/input/other_residential_property_value.py b/policyengine_uk/variables/input/other_residential_property_value.py index f9009e683a..0b4d315817 100644 --- a/policyengine_uk/variables/input/other_residential_property_value.py +++ b/policyengine_uk/variables/input/other_residential_property_value.py @@ -9,11 +9,12 @@ class other_residential_property_value(Variable): "Universal Credit values this in the assessment year, assuming sale " "expenses and no additional Schedule " "10 disregards within this broad asset class that are not separately " - "split out in the current dataset" + "split out in the current dataset. Values carried forward use the " + "model's annual OBR house-price index." ) entity = Household definition_period = YEAR value_type = float unit = GBP quantity_type = STOCK - uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" + uprating = "gov.economic_assumptions.indices.obr.house_prices" From 2afdc5407583b4cc5847d3d1fb916ab802dda0e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:52:10 +0100 Subject: [PATCH 2/5] Preserve residential valuation bands across dataset projections --- policyengine_uk/data/economic_assumptions.py | 63 ++++++++++- .../test_residential_property_uprating.py | 101 +++++++++++++++--- 2 files changed, 146 insertions(+), 18 deletions(-) diff --git a/policyengine_uk/data/economic_assumptions.py b/policyengine_uk/data/economic_assumptions.py index c5d70372e3..143856b62d 100644 --- a/policyengine_uk/data/economic_assumptions.py +++ b/policyengine_uk/data/economic_assumptions.py @@ -108,6 +108,11 @@ def _unmapped_uprating_indices(uprating): COUNCIL_TAX_GROWTH = "gov.economic_assumptions.yoy_growth.obr.council_tax" SOCIAL_RENT_GROWTH = "gov.economic_assumptions.yoy_growth.obr.social_rent" PRIVATE_RENT_GROWTH = "gov.economic_assumptions.yoy_growth.ons.private_rental_prices" +RESIDENTIAL_PROPERTY_INDEX = "gov.economic_assumptions.indices.obr.house_prices" +RESIDENTIAL_PROPERTY_INPUTS = ( + "main_residence_value", + "other_residential_property_value", +) def _uprating_index_map() -> dict: @@ -120,7 +125,8 @@ def uprating_input_paths() -> list: ``apply_single_year_uprating`` and the functions it calls read these, and no other parameters: the growth rates in ``uprating_indices.yaml``, the - council tax and rent series, the indices of the input variables that + council tax and rent series, the cumulative residential property index, + the indices of the input variables that file leaves out (after ``LEGACY_PROJECTION_END_YEAR``) and the capital gains projection's metadata (``CGT_INDEX_PATH``). A path may name a node (the regional private rent series), which covers its parameters. @@ -130,7 +136,12 @@ def uprating_input_paths() -> list: return sorted( set(uprating) | {f"{COUNCIL_TAX_GROWTH}.{name}" for name in series} - | {SOCIAL_RENT_GROWTH, PRIVATE_RENT_GROWTH, CGT_INDEX_PATH} + | { + SOCIAL_RENT_GROWTH, + PRIVATE_RENT_GROWTH, + CGT_INDEX_PATH, + RESIDENTIAL_PROPERTY_INDEX, + } | set(_unmapped_uprating_indices(uprating).values()) ) @@ -214,14 +225,21 @@ def apply_uprating( if copy: dataset = dataset.copy() + first_year = min(dataset.datasets.keys()) for year in dataset.datasets.keys(): - if year == min(dataset.datasets.keys()): + if year == first_year: continue # Don't uprate the first year current_year = dataset.datasets[year] prev_year = dataset.datasets[year - 1] apply_single_year_uprating( current_year, prev_year, tax_benefit_system_parameters ) + # Core projects from the supplied observation in one multiplication. + # Chaining annual stock projections can lose an exact band boundary + # through intermediate float32 rounding, even with the same index. + uprate_residential_property( + current_year, dataset.datasets[first_year], tax_benefit_system_parameters + ) return dataset @@ -265,6 +283,8 @@ def apply_single_year_uprating( # Next, apply custom uprating. + current_year = uprate_residential_property(current_year, previous_year, parameters) + # Council Tax is uprated by OBR forecasts/outturns by country. current_year = uprate_council_tax(current_year, previous_year, parameters) @@ -306,6 +326,43 @@ def apply_single_year_uprating( return current_year +def uprate_residential_property( + current_year: UKSingleYearDataset, + previous_year: UKSingleYearDataset, + parameters: ParameterNode, +): + """Keep residential stocks on Core and HVCTS's cumulative HPI basis. + + Cumulative index levels are rounded when generated. Use one ratio from + the supplied observation, at the variable's Core storage precision, so + forward projection and inverse band conversion share the same basis. + """ + from policyengine_uk.system import system + + assets = [ + name + for name in RESIDENTIAL_PROPERTY_INPUTS + if name in current_year.household.columns + ] + if not assets: + return current_year + index = parameters.get_child(RESIDENTIAL_PROPERTY_INDEX) + previous_index = _uprating_index_value(index, f"{previous_year.time_period}-01-01") + current_index = _uprating_index_value(index, f"{current_year.time_period}-01-01") + # Preserve Core's handling of missing, zero and terminated indices. + ratio = ( + current_index / previous_index + if previous_index not in (None, 0) and current_index is not None + else 1 + ) + for asset in assets: + observed = previous_year.household[asset].to_numpy( + dtype=system.variables[asset].dtype + ) + current_year.household[asset] = observed * ratio + return current_year + + def uprate_council_tax( current_year: UKSingleYearDataset, previous_year: UKSingleYearDataset, diff --git a/policyengine_uk/tests/test_residential_property_uprating.py b/policyengine_uk/tests/test_residential_property_uprating.py index 432c023a09..f11f1c1a69 100644 --- a/policyengine_uk/tests/test_residential_property_uprating.py +++ b/policyengine_uk/tests/test_residential_property_uprating.py @@ -11,19 +11,25 @@ from policyengine_uk import Microsimulation, Simulation from policyengine_uk.data import UKSingleYearDataset +from policyengine_uk.data.economic_assumptions import ( + extend_single_year_dataset, + uprating_input_fingerprint, +) from policyengine_uk.model_api import Scenario +from policyengine_uk.scenarios import no_economic_assumptions pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") ASSETS = ["main_residence_value", "other_residential_property_value"] -HPI_2024_TO_2026 = 1.028 * 1.024 HPI_2026_TO_2028 = 1.029 * 1.027 MAIN_VALUES = np.array([1_880_000, 2_360_000, 3_280_000, 4_700_000], dtype=float) OTHER_VALUES = np.array([125_000, 0, 900_000, 60_000], dtype=float) -def property_dataset(): - """One adult in each of four owner households, with no private data.""" - ids = np.arange(1, 5) +def property_dataset( + main_values=MAIN_VALUES, other_values=OTHER_VALUES, fiscal_year=2024 +): + """One adult per synthetic owner household, with no private data.""" + ids = np.arange(1, len(main_values) + 1) return UKSingleYearDataset( person=pd.DataFrame( { @@ -43,11 +49,11 @@ def property_dataset(): "tenure_type": "OWNED_OUTRIGHT", "rent": 0.0, "council_tax": 0.0, - "main_residence_value": MAIN_VALUES, - "other_residential_property_value": OTHER_VALUES, + "main_residence_value": main_values, + "other_residential_property_value": other_values, } ), - fiscal_year=2024, + fiscal_year=fiscal_year, ) @@ -66,7 +72,7 @@ def test_direct_property_stock_uses_house_price_growth(asset): ) # Core carries inputs with cumulative index levels, which the model # rounds to five decimal places. Select the required HPI explicitly; - # the dataset test independently checks the unrounded YoY arithmetic. + # dataset stocks need that same cumulative price basis. hpi = sim.tax_benefit_system.parameters.get_child( "gov.economic_assumptions.indices.obr.house_prices" ) @@ -80,18 +86,20 @@ def test_direct_property_stock_uses_house_price_growth(asset): def test_dataset_property_stocks_use_house_price_growth( projected_properties, asset, values ): - for year, factor in [ - (2024, 1), - (2026, HPI_2024_TO_2026), - (2028, HPI_2024_TO_2026 * HPI_2026_TO_2028), - ]: + hpi = projected_properties.tax_benefit_system.parameters.get_child( + "gov.economic_assumptions.indices.obr.house_prices" + ) + for year in [2024, 2026, 2028]: + factor = hpi(year) / hpi(2024) + # Match Core's input storage type before the single ratio is applied. + expected = values.astype(np.float32) * factor # Stored dataset arrays take precedence over Core's variable-level # uprating, so both paths need the same house-price series. np.testing.assert_allclose( - projected_properties.dataset[year].household[asset], values * factor + projected_properties.dataset[year].household[asset], expected ) np.testing.assert_allclose( - projected_properties.calculate(asset, year), values * factor, atol=0.25 + projected_properties.calculate(asset, year), expected, atol=0.25 ) @@ -153,3 +161,66 @@ def test_house_price_valuation_reaches_final_tax_totals(): sim.baseline.calculate(variable, 2028) - sim.calculate(variable, 2028), [2_500], ) + + +@pytest.mark.parametrize("source", ["dataset", "direct"]) +def test_projection_preserves_supplied_2026_band_boundaries(source): + # Official 2026 valuation bands, at each threshold and £1 below it. + # Forward projection alone must not move a 2026 observation to another + # 2026 band when the surcharge converts it back from the simulated year. + values = np.array( + [ + 1_999_999, + 2_000_000, + 2_499_999, + 2_500_000, + 3_499_999, + 3_500_000, + 4_999_999, + 5_000_000, + ], + dtype=float, + ) + if source == "dataset": + sim = Microsimulation( + dataset=property_dataset(values, np.zeros(len(values)), fiscal_year=2026) + ) + else: + people = {f"adult{i}": {"age": {2026: 40}} for i in range(len(values))} + homes = { + f"home{i}": { + "members": [f"adult{i}"], + "region": "LONDON", + "tenure_type": "OWNED_OUTRIGHT", + "main_residence_value": {2026: float(value)}, + } + for i, value in enumerate(values) + } + sim = Simulation(situation={"people": people, "households": homes}) + np.testing.assert_array_equal( + sim.calculate("high_value_council_tax_surcharge", 2028), + [0, 2_500, 2_500, 3_500, 3_500, 5_000, 5_000, 7_500], + ) + + +def test_projection_fingerprints_cumulative_house_price_overrides(projected_properties): + # The dataset cache key must cover the effective cumulative index now + # read by residential projection, even if the raw YoY series is unchanged. + parameters = projected_properties.tax_benefit_system.parameters.clone() + baseline = uprating_input_fingerprint(parameters) + hpi = parameters.get_child("gov.economic_assumptions.indices.obr.house_prices") + changed_level = hpi(2027) * 1.1 + hpi.update(period="2027", value=changed_level) + assert uprating_input_fingerprint(parameters) != baseline + projected = extend_single_year_dataset( + property_dataset(fiscal_year=2026), parameters, end_year=2027 + ) + factor = changed_level / hpi(2026) + for asset, values in zip(ASSETS, [MAIN_VALUES, OTHER_VALUES]): + np.testing.assert_allclose(projected[2027].household[asset], values * factor) + + +def test_frozen_assumptions_keep_residential_stocks_at_observed_values(): + sim = Microsimulation(dataset=property_dataset(), scenario=no_economic_assumptions) + for asset, values in zip(ASSETS, [MAIN_VALUES, OTHER_VALUES]): + np.testing.assert_array_equal(sim.calculate(asset, 2028), values) From 3f5df2532d4eaab9d3878c1cbba44bb568866703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:49:17 +0100 Subject: [PATCH 3/5] Fix issues from review: align residential projection test oracles --- .../tests/test_projection_horizon.py | 60 +++++++++++++------ 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/policyengine_uk/tests/test_projection_horizon.py b/policyengine_uk/tests/test_projection_horizon.py index 7d6f77375e..7abdfd7e02 100644 --- a/policyengine_uk/tests/test_projection_horizon.py +++ b/policyengine_uk/tests/test_projection_horizon.py @@ -1,8 +1,9 @@ """How far a single-year dataset is projected, and what serves later years. A single-year dataset is projected to ``PROJECTION_END_YEAR``: each year up to -it is a dataset year whose inputs ``apply_single_year_uprating`` grows from the -year before. After it, policyengine-core serves each stored input from the last +it is a dataset year. Most indexed inputs grow from the year before; residential +stocks use the original observation and cumulative house-price index at the +model's storage precision. After it, policyengine-core serves each input from the last projected year: a variable with a formula is calculated by it, one with an ``uprating`` attribute is scaled by that index, and any other is held at its last projected value. Rent and council tax have no ``uprating`` attribute, so @@ -138,6 +139,19 @@ def _council_tax_growth(region, year): return 0.0 if series is None else GROWTH.obr.council_tax.get_child(series)(year) +RESIDENTIAL_STOCKS = ( + "main_residence_value", + "other_residential_property_value", +) + + +def _residential_stock_level(observed, variable, year): + """Independent level oracle from the original observation and explicit HPI.""" + hpi = parameters.get_child("gov.economic_assumptions.indices.obr.house_prices") + observed = np.asarray(observed, dtype=system.variables[variable].dtype) + return observed * (hpi(year) / hpi(BASE_YEAR)) + + @pytest.fixture(scope="module") def projected(): return extend_single_year_dataset(_dataset(), parameters) @@ -162,16 +176,20 @@ def test_every_year_to_the_horizon_is_a_dataset_year(projected, sim): def test_each_singly_indexed_input_grows_by_its_index_every_year(projected): """For every variable uprating_indices.yaml lists and every projected - year, each record's value is last year's times one plus the index.""" + year, check its index: residential stocks use the original observation + and cumulative HPI; other inputs grow from the previous year's raw YoY.""" + observed = _dataset() for year in range(BASE_YEAR + 1, PROJECTION_END_YEAR + 1): for variable, index in INDEX_OF.items(): - previous = _table(projected[year - 1], variable)[variable].to_numpy() current = _table(projected[year], variable)[variable].to_numpy() - growth = parameters.get_child(index)(year) - assert current == pytest.approx(previous * (1 + growth)), ( - variable, - year, - ) + if variable in RESIDENTIAL_STOCKS: + original = _table(observed, variable)[variable].to_numpy() + expected = _residential_stock_level(original, variable, year) + else: + previous = _table(projected[year - 1], variable)[variable].to_numpy() + growth = parameters.get_child(index)(year) + expected = previous * (1 + growth) + assert current == pytest.approx(expected), (variable, year) def test_unmapped_inputs_preserve_legacy_years_and_grow_after_2030(projected): @@ -258,17 +276,25 @@ def test_rent_and_council_tax_grow_every_projected_year(projected): ) def test_growth_ratio_is_one_plus_the_index(amounts, year, variable): """For any positive amounts, any projected year and any singly indexed - variable, value / last year's value = 1 + the index, record by record: - the growth depends on neither the amount nor the other records.""" + variable, check its annual growth record by record. Residential ratios + come from original-observation cumulative HPI levels at storage precision; + every other variable retains its raw one-plus-YoY growth expectation.""" projected = extend_single_year_dataset( _dataset(np.array(amounts)), parameters, end_year=year ) - ratio = ( - _table(projected[year], variable)[variable].to_numpy() - / _table(projected[year - 1], variable)[variable].to_numpy() - ) - index = parameters.get_child(INDEX_OF[variable])(year) - assert ratio == pytest.approx(np.full(len(amounts), 1 + index)) + current = _table(projected[year], variable)[variable].to_numpy() + previous = _table(projected[year - 1], variable)[variable].to_numpy() + ratio = current / previous + if variable in RESIDENTIAL_STOCKS: + expected_current = _residential_stock_level(amounts, variable, year) + expected_previous = _residential_stock_level(amounts, variable, year - 1) + assert current == pytest.approx(expected_current) + assert previous == pytest.approx(expected_previous) + expected_ratio = expected_current / expected_previous + else: + index = parameters.get_child(INDEX_OF[variable])(year) + expected_ratio = np.full(len(amounts), 1 + index) + assert ratio == pytest.approx(expected_ratio) @settings(max_examples=10, deadline=None, derandomize=True) From 5c54f1db2ce39e71b465dc628f0737035bb82d42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:52:19 +0100 Subject: [PATCH 4/5] Fix issues from review: align additional purchase price uprating --- changelog.d/2392.fixed.md | 1 + policyengine_uk/data/economic_assumptions.py | 1 + policyengine_uk/data/uprating_indices.yaml | 6 +- .../test_residential_property_uprating.py | 105 ++++++++++++++++++ 4 files changed, 111 insertions(+), 2 deletions(-) diff --git a/changelog.d/2392.fixed.md b/changelog.d/2392.fixed.md index 3274909f20..54fc4ed49d 100644 --- a/changelog.d/2392.fixed.md +++ b/changelog.d/2392.fixed.md @@ -1 +1,2 @@ - Corrected residential property uprating and the High Value Council Tax Surcharge's 2026 valuation conversion to use the OBR house-price index. +- Aligned projected additional-dwelling purchase prices with residential house-price growth. diff --git a/policyengine_uk/data/economic_assumptions.py b/policyengine_uk/data/economic_assumptions.py index 143856b62d..ce9419b944 100644 --- a/policyengine_uk/data/economic_assumptions.py +++ b/policyengine_uk/data/economic_assumptions.py @@ -283,6 +283,7 @@ def apply_single_year_uprating( # Next, apply custom uprating. + # apply_uprating replaces these chained stocks from the original observation. current_year = uprate_residential_property(current_year, previous_year, parameters) # Council Tax is uprated by OBR forecasts/outturns by country. diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index bbba2e93e4..ed2d334e12 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -89,6 +89,9 @@ gov.economic_assumptions.yoy_growth.obr.petrol_spending_litre_proxy: gov.economic_assumptions.yoy_growth.obr.house_prices: - main_residence_value - other_residential_property_value +# Imputed additional-dwelling prices share the home-price forecast, +# holding the imputed purchase quantity fixed. +- additional_residential_property_purchased gov.economic_assumptions.yoy_growth.obr.mortgage_interest: - mortgage_interest_repayment - property_finance_costs @@ -115,8 +118,7 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - non_residential_property_secured_debt - other_investment_income - other_residential_property_secured_debt -# Purchase-price inputs retain their existing GDP-per-capita uprating. -- additional_residential_property_purchased +# Non-residential purchase prices and land retain GDP-per-capita growth. - non_residential_property_purchased - owned_land # A formula of benunit_reported_capital; listed so a dataset that stores it diff --git a/policyengine_uk/tests/test_residential_property_uprating.py b/policyengine_uk/tests/test_residential_property_uprating.py index f11f1c1a69..e8fe7b5a32 100644 --- a/policyengine_uk/tests/test_residential_property_uprating.py +++ b/policyengine_uk/tests/test_residential_property_uprating.py @@ -224,3 +224,108 @@ def test_frozen_assumptions_keep_residential_stocks_at_observed_values(): sim = Microsimulation(dataset=property_dataset(), scenario=no_economic_assumptions) for asset, values in zip(ASSETS, [MAIN_VALUES, OTHER_VALUES]): np.testing.assert_array_equal(sim.calculate(asset, 2028), values) + + +def purchase_projection_dataset(region="LONDON"): + """Synthetic single-dwelling prices, including a no-purchase household.""" + dataset = property_dataset(np.zeros(2), np.zeros(2)) + dataset.household["region"] = region + dataset.household["property_purchased"] = False + dataset.household["additional_residential_property_purchased"] = [300_000.0, 0.0] + dataset.household["non_residential_property_purchased"] = [200_000.0, 0.0] + dataset.household["owned_land"] = [400_000.0, 0.0] + return dataset + + +def raw_purchase_price_growth(parameters, series, year): + """Multiply raw YoY assumptions independently of the projection mapping.""" + growth = parameters.get_child(f"gov.economic_assumptions.yoy_growth.obr.{series}") + # OBR's March 2026 forecast ends in 2030. Later years repeat its last + # rate under the existing model convention, not an official forecast. + return np.prod([1 + growth(str(y)) for y in range(2025, year + 1)]) + + +@pytest.mark.parametrize("year", [2030, 2032]) +def test_dataset_additional_purchase_prices_use_house_price_growth(year): + sim = Microsimulation(dataset=purchase_projection_dataset()) + parameters = sim.tax_benefit_system.parameters + expected = np.array([300_000.0, 0.0]) * raw_purchase_price_growth( + parameters, "house_prices", year + ) + gdp_price = 300_000 * raw_purchase_price_growth(parameters, "per_capita.gdp", year) + assert not np.isclose(expected[0], gdp_price) + np.testing.assert_allclose( + sim.dataset[year].household["additional_residential_property_purchased"], + expected, + rtol=1e-7, + atol=0.01, + ) + np.testing.assert_allclose( + sim.calculate("additional_residential_property_purchased", year), + expected, + rtol=1e-7, + atol=0.01, + ) + + +@pytest.mark.parametrize( + "region,tax", + [ + ("LONDON", "stamp_duty_land_tax"), + ("WALES", "land_transaction_tax"), + ("SCOTLAND", "land_and_buildings_transaction_tax"), + ], +) +def test_projected_additional_purchase_prices_reach_transaction_taxes(region, tax): + year = 2028 + dataset = purchase_projection_dataset(region) + # Isolate the additional dwelling from other transaction-tax bases. + dataset.household["non_residential_property_purchased"] = 0.0 + projected = Microsimulation(dataset=dataset) + prices = np.array([300_000.0, 0.0]) * raw_purchase_price_growth( + projected.tax_benefit_system.parameters, "house_prices", year + ) + direct = Simulation( + situation={ + "people": {f"adult{i}": {"age": {year: 40}} for i in range(2)}, + "households": { + f"home{i}": { + "members": [f"adult{i}"], + "region": region, + "property_purchased": False, + "additional_residential_property_purchased": {year: float(price)}, + } + for i, price in enumerate(prices) + }, + } + ) + # A directly supplied target-year purchase remains that exact input. + np.testing.assert_array_equal( + direct.calculate("additional_residential_property_purchased", year), + prices.astype(np.float32), + ) + expected_tax = direct.calculate(tax, year) + assert expected_tax[0] > 0 + assert expected_tax[1] == 0 + np.testing.assert_allclose( + projected.calculate(tax, year), expected_tax, rtol=0, atol=0.01 + ) + + +def test_purchase_projection_preserves_other_indices_and_zero(): + year = 2032 + sim = Microsimulation(dataset=purchase_projection_dataset()) + factor = raw_purchase_price_growth( + sim.tax_benefit_system.parameters, "per_capita.gdp", year + ) + for variable, observed in [ + ("non_residential_property_purchased", 200_000), + ("owned_land", 400_000), + ]: + np.testing.assert_allclose( + sim.dataset[year].household[variable], + np.array([observed, 0]) * factor, + rtol=1e-7, + atol=0.01, + ) + assert sim.calculate("additional_residential_property_purchased", year)[1] == 0 From bd41642d3a26b4ce83feaff72fd514e1c091086c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:40:54 +0100 Subject: [PATCH 5/5] Fix review: preserve later-year surcharge band boundaries --- changelog.d/2392.fixed.md | 1 + .../test_residential_property_uprating.py | 128 ++++++++++++++++++ .../hmrc/high_value_council_tax_surcharge.py | 15 +- 3 files changed, 139 insertions(+), 5 deletions(-) diff --git a/changelog.d/2392.fixed.md b/changelog.d/2392.fixed.md index 54fc4ed49d..44cb0fcc22 100644 --- a/changelog.d/2392.fixed.md +++ b/changelog.d/2392.fixed.md @@ -1,2 +1,3 @@ - Corrected residential property uprating and the High Value Council Tax Surcharge's 2026 valuation conversion to use the OBR house-price index. - Aligned projected additional-dwelling purchase prices with residential house-price growth. +- Preserved exact 2026 surcharge-band classifications in later-year property projections without rounding household valuations. diff --git a/policyengine_uk/tests/test_residential_property_uprating.py b/policyengine_uk/tests/test_residential_property_uprating.py index e8fe7b5a32..ecd42b4f67 100644 --- a/policyengine_uk/tests/test_residential_property_uprating.py +++ b/policyengine_uk/tests/test_residential_property_uprating.py @@ -329,3 +329,131 @@ def test_purchase_projection_preserves_other_indices_and_zero(): atol=0.01, ) assert sim.calculate("additional_residential_property_purchased", year)[1] == 0 + + +# Later-year C1 controls use the published 2026 classifications independently +# of the formula's numerical conversion. Values at a boundary are liable. +C1_2026_VALUES = np.array( + [ + 1_999_999, + 2_000_000, + 2_499_999, + 2_500_000, + 3_499_999, + 3_500_000, + 4_999_999, + 5_000_000, + ], + dtype=np.float32, +) +C1_PUBLISHED_BANDS = np.array([0, 1, 1, 2, 2, 3, 3, 4]) + + +def c1_direct_properties(values, observed_year=2026, overrides=None): + people = {f"adult{i}": {"age": {observed_year: 40}} for i in range(len(values))} + homes = {} + for i, value in enumerate(values): + inputs = {observed_year: float(value)} + if overrides is not None: + year, later_values = overrides + inputs[year] = float(later_values[i]) + homes[f"home{i}"] = { + "members": [f"adult{i}"], + "region": "LONDON", + "tenure_type": "OWNED_OUTRIGHT", + "main_residence_value": inputs, + } + return Simulation(situation={"people": people, "households": homes}) + + +def c1_current_amounts_and_table(sim, year): + scale = sim.tax_benefit_system.parameters( + str(year) + ).gov.hmrc.council_tax.high_value_surcharge.amount + # Only unchanged current-year charge amounts supply the money oracle. + # Classification comes from the literal published band labels above. + assert scale.thresholds == [0, 2_000_000, 2_500_000, 3_500_000, 5_000_000] + return np.array(scale.amounts), (list(scale.thresholds), list(scale.amounts)) + + +@pytest.mark.parametrize("source", ["direct", "dataset"]) +@pytest.mark.parametrize("year", [2029, 2032]) +def test_c1_later_year_preserves_all_published_2026_bands(source, year): + if source == "dataset": + sim = Microsimulation( + dataset=property_dataset(C1_2026_VALUES, np.zeros(8), fiscal_year=2026) + ) + else: + sim = c1_direct_properties(C1_2026_VALUES) + amounts, before = c1_current_amounts_and_table(sim, year) + actual = sim.calculate("high_value_council_tax_surcharge", year) + np.testing.assert_allclose(actual, amounts[C1_PUBLISHED_BANDS], rtol=0, atol=0.01) + assert [ + int(np.flatnonzero(np.isclose(amounts, x, rtol=0, atol=0.01))[0]) + for x in actual + ] == C1_PUBLISHED_BANDS.tolist() + assert c1_current_amounts_and_table(sim, year)[1] == before + + +@pytest.mark.parametrize("year", [2029, 2032]) +def test_c1_later_explicit_property_value_overrides_2026(year): + # Both directions matter: the later explicit input can remove or add a bill. + sim = c1_direct_properties( + [6_000_000, 1_000_000], overrides=(year, [1_000_000, 8_000_000]) + ) + amounts, before = c1_current_amounts_and_table(sim, year) + np.testing.assert_allclose( + sim.calculate("high_value_council_tax_surcharge", year), + amounts[[0, 4]], + rtol=0, + atol=0.01, + ) + np.testing.assert_array_equal( + sim.calculate("main_residence_value", year), [1_000_000, 8_000_000] + ) + assert c1_current_amounts_and_table(sim, year)[1] == before + + +@pytest.mark.parametrize("year", [2029, 2032]) +def test_c1_one_float32_step_below_current_price_boundary_is_not_snapped(year): + parameters = c1_direct_properties([1_000_000]).tax_benefit_system.parameters + hpi = parameters.get_child("gov.economic_assumptions.indices.obr.house_prices") + # This is the storage representation of each fixed published boundary, + # not a neighbourhood or pound-rounding allowance. + boundaries = np.array( + [2_000_000, 2_500_000, 3_500_000, 5_000_000], dtype=np.float32 + ) + boundaries = boundaries * (hpi(str(year)) / hpi("2026")) + below = np.nextafter(boundaries, np.float32(-np.inf)) + assert np.all(below < boundaries) + values = np.column_stack([below, boundaries]).ravel() + sim = c1_direct_properties(values, observed_year=year) + amounts, before = c1_current_amounts_and_table(sim, year) + actual = sim.calculate("high_value_council_tax_surcharge", year) + np.testing.assert_allclose(actual, amounts[C1_PUBLISHED_BANDS], rtol=0, atol=0.01) + assert c1_current_amounts_and_table(sim, year)[1] == before + + +def test_c1_cross_year_query_order_preserves_parameter_tables(): + forward = c1_direct_properties(C1_2026_VALUES) + reverse = c1_direct_properties(C1_2026_VALUES) + before = { + year: c1_current_amounts_and_table(forward, year)[1] + for year in [2028, 2029, 2032] + } + forward_results = { + year: forward.calculate("high_value_council_tax_surcharge", year) + for year in [2028, 2029, 2032] + } + reverse_results = { + year: reverse.calculate("high_value_council_tax_surcharge", year) + for year in [2032, 2029, 2028] + } + for year in [2028, 2029, 2032]: + amounts, after = c1_current_amounts_and_table(forward, year) + assert after == before[year] + assert c1_current_amounts_and_table(reverse, year)[1] == before[year] + np.testing.assert_array_equal(forward_results[year], reverse_results[year]) + np.testing.assert_allclose( + forward_results[year], amounts[C1_PUBLISHED_BANDS], rtol=0, atol=0.01 + ) diff --git a/policyengine_uk/variables/gov/hmrc/high_value_council_tax_surcharge.py b/policyengine_uk/variables/gov/hmrc/high_value_council_tax_surcharge.py index 8086f437cf..a226213340 100644 --- a/policyengine_uk/variables/gov/hmrc/high_value_council_tax_surcharge.py +++ b/policyengine_uk/variables/gov/hmrc/high_value_council_tax_surcharge.py @@ -1,3 +1,5 @@ +import numpy as np + from policyengine_uk.model_api import * @@ -42,9 +44,12 @@ def formula(household, period, parameters): baseline_index = parameters( "2026" ).gov.economic_assumptions.indices.obr.house_prices - value_2026_prices = property_value / (current_index / baseline_index) - - surcharge = p.gov.hmrc.council_tax.high_value_surcharge.amount.calc( - value_2026_prices - ) + charge_scale = p.gov.hmrc.council_tax.high_value_surcharge.amount.copy() + # Compare prices and fixed 2026 bands in the same stored precision; + # inverse division can put a projected exact boundary in a lower band. + charge_scale.thresholds = ( + np.array(charge_scale.thresholds, dtype=property_value.dtype) + * (current_index / baseline_index) + ).tolist() + surcharge = charge_scale.calc(property_value) return where(in_england & owns_home, surcharge, 0)