Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/2392.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +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.
64 changes: 61 additions & 3 deletions policyengine_uk/data/economic_assumptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand All @@ -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())
)

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -265,6 +283,9 @@ 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.

current_year = uprate_council_tax(current_year, previous_year, parameters)
Expand Down Expand Up @@ -306,6 +327,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,
Expand Down
11 changes: 7 additions & 4 deletions policyengine_uk/data/uprating_indices.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ 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
# 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
Expand All @@ -104,18 +110,15 @@ 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
- net_financial_wealth
- 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.
- 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
60 changes: 43 additions & 17 deletions policyengine_uk/tests/test_projection_horizon.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading