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
1 change: 1 addition & 0 deletions changelog.d/savings-income-obr-uprating.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Datasets can declare that their savings income was calibrated to grow by the OBR's savings income forecast per person from 2022-23 to 2025-26 (October 2026 EFO, Table 4.5), in the H5 root attribute `policyengine_uk.savings_income_projection`. Dataset projection then grows savings interest, other investment income, interest on cash held in non-cash ISAs and, in datasets on the additive ISA contract, ISA interest by that series in those years and by GDP per head in other years. Datasets on the legacy gross ISA contract grow gross interest and hold its ISA part flat, as before. Datasets that declare nothing project exactly as before, by GDP per head in every year.
17 changes: 16 additions & 1 deletion docs/book/assumptions/growthfactors.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,22 @@ 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_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`

## Savings income

When we project a dataset to later years (up to 2032-33), we grow savings income by the path the dataset says its savings income was calibrated to. A dataset that declares the `obr_savings_income_2022_2025` projection grows it by the OBR's savings income forecast per person from 2022-23 to 2025-26 and by per capita GDP in other years. A dataset that declares nothing grows it by per capita GDP in every year, as before. The OBR's series is the "Savings income" row of its receipts table "Self-employed income streams: growth rates" (October 2026 EFO, Table 4.5, from 2024-25; earlier years from the March 2026 and October 2024 EFOs), divided by population growth.

| Fiscal year | 2022 | 2023 | 2024 | 2025 |
|-------------|------|------|------|------|
| OBR savings income | 50.4% | 129.9% | 35.0% | 0.6% |
| Per person | 48.8% | 126.8% | 33.7% | 0.3% |

From 2023-24 to 2025-26 the OBR's savings income rises 35.8%, where per capita GDP and population growth would give 10.2%. After 2025-26 these variables follow per capita GDP under either rule.

The declaration matters because a dataset's savings income is fitted to targets its builder moved forward along some path. Growing it by a different path would miss those targets. Datasets store the declaration in the H5 root attribute `policyengine_uk.savings_income_projection`.

We apply this to `non_cash_isa_cash_interest`, `other_investment_income` and `savings_interest_income`, and to ISA interest in datasets whose ISA income contract projects it additively. Datasets with the legacy gross contract grow gross interest, ISA interest included, and hold the ISA part flat. Inputs given directly to a calculation, and years after the dataset projection, use the index each variable declares: household interest income (ONS D.41) for interest and per capita GDP for other investment income.

## Council tax

Expand Down
28 changes: 26 additions & 2 deletions docs/engineering/skills/dataset-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,9 @@ 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 dataset's savings income
projection rule (see "Savings income projection" below); 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
Expand Down Expand Up @@ -100,6 +101,29 @@ discard H5 attributes must explicitly preserve this declaration before their
output can be reloaded safely. Test routing and migration on synthetic frames
and small H5 fixtures, without downloading private datasets.

## Savings income projection

A dataset's savings income is fitted to targets that its builder moved to the
dataset year and later along some growth path, so dataset projection must grow
it along the same path. The dataset declares the path as a plain string in the
H5 root attribute `policyengine_uk.savings_income_projection`
(`data/savings_income_projection.py`):

- `gdp_per_head`: GDP per capita in every year. Datasets without the attribute
get this rule, which is how savings income was projected before.
- `obr_savings_income_2022_2025`: the OBR's savings income per person from
2022 to 2025 and GDP per capita in other years.

`growth_rate` in `data/economic_assumptions.py` applies the rule to
`savings_interest_income`, `non_cash_isa_cash_interest`,
`other_investment_income`, gross interest under the legacy contract and ISA
interest under the additive contract. `UKSingleYearDataset` takes
`savings_income_projection` and refuses a value that contradicts its file.
Multi-year datasets hold one rule for every year and refuse mixed rules.
Copies, filtered households, simulation exports and input frames
(`frame.attrs["policyengine_uk.savings_income_projection"]`) keep the rule. A
rule's name fixes its years, so a later window needs a new rule.

## Apprenticeship status and tenure

Dataset producers should export `is_apprentice` and
Expand Down
35 changes: 35 additions & 0 deletions policyengine_uk/data/dataset_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@
read_energy_price_basis,
write_energy_price_basis,
)
from policyengine_uk.data.savings_income_projection import (
SAVINGS_INCOME_PROJECTION_KEY,
read_savings_income_projection,
resolve_savings_income_projection,
uniform_savings_income_projection,
validate_savings_income_projection,
)
from policyengine_uk.data.isa_income import (
ISA_INCOME_CONTRACT_KEY,
MISSING_CONTRACT,
Expand Down Expand Up @@ -78,6 +85,7 @@ def __init__(
fiscal_year: int = None,
energy_price_basis: dict[str, str | int] | EnergyPriceBasis | None = None,
isa_income_contract: dict | None = None,
savings_income_projection: str | None = None,
):
basis = parse_energy_price_basis(energy_price_basis)
file_path = str(file_path) if file_path else None
Expand Down Expand Up @@ -126,6 +134,9 @@ def __init__(
read_h5_contract(file_path),
[int(self.time_period[:4])],
)[int(self.time_period[:4])]
self.savings_income_projection = resolve_savings_income_projection(
savings_income_projection, read_savings_income_projection(file_path)
)
self.data_format = "arrays"
# tables tuple points at the raw (int16) DataFrames used by the
# simulation engine internals - do not decode these.
Expand Down Expand Up @@ -198,6 +209,9 @@ def save(self, file_path: str):
write_energy_price_basis(file_path, self._energy_price_basis)
with h5py.File(file_path, "a") as f:
f.attrs[ISA_INCOME_CONTRACT_KEY] = json.dumps(contract)
f.attrs[SAVINGS_INCOME_PROJECTION_KEY] = validate_savings_income_projection(
self.savings_income_projection
)

def load(self):
data = {}
Expand All @@ -215,6 +229,7 @@ def copy(self):
fiscal_year=self.time_period,
energy_price_basis=self._energy_price_basis,
isa_income_contract=validate_contract(self.isa_income_contract),
savings_income_projection=self.savings_income_projection,
)

def validate(self):
Expand All @@ -229,6 +244,7 @@ def from_simulation(
simulation: "Microsimulation",
fiscal_year: int = 2025,
energy_price_basis: dict[str, str | int] | EnergyPriceBasis | None = None,
savings_income_projection: str | None = None,
):
entity_dfs = {}

Expand All @@ -253,6 +269,11 @@ def from_simulation(
fiscal_year=fiscal_year,
energy_price_basis=energy_price_basis,
isa_income_contract=simulation_contract(simulation, fiscal_year),
savings_income_projection=(
getattr(simulation, "_savings_income_projection", None)
if savings_income_projection is None
else savings_income_projection
),
)


Expand All @@ -272,10 +293,15 @@ def __init__(
)
year = int(dataset.time_period[:4])
self.datasets[year] = dataset
# Projection grows every year by one declared rule.
uniform_savings_income_projection(
dataset.savings_income_projection for dataset in datasets
)

if file_path is not None:
UKMultiYearDataset.validate_file_path(file_path)
stored_contract = read_h5_contract(file_path)
stored_savings_rule = read_savings_income_projection(file_path)
with pd.HDFStore(file_path, mode="r") as f:
self.datasets = {}
years = [
Expand All @@ -296,6 +322,7 @@ def __init__(
household=household_df,
fiscal_year=fiscal_year,
isa_income_contract=contracts[fiscal_year],
savings_income_projection=stored_savings_rule,
)
for year, dataset in self.datasets.items():
dataset.energy_price_basis = read_energy_price_basis(
Expand Down Expand Up @@ -324,11 +351,18 @@ def isa_income_contract(self):
}
)

@property
def savings_income_projection(self) -> str:
return uniform_savings_income_projection(
dataset.savings_income_projection for dataset in self.datasets.values()
)

def __getitem__(self, fiscal_year: int):
return self.get_year(fiscal_year)

def save(self, file_path: str):
contracts = self.isa_income_contract
savings_rule = self.savings_income_projection
Path(file_path).unlink(missing_ok=True) # Remove existing file if it exists
with pd.HDFStore(file_path) as f:
for year, dataset in self.datasets.items():
Expand Down Expand Up @@ -364,6 +398,7 @@ def save(self, file_path: str):

with h5py.File(file_path, "a") as f:
f.attrs[ISA_INCOME_CONTRACT_KEY] = json.dumps(contracts)
f.attrs[SAVINGS_INCOME_PROJECTION_KEY] = savings_rule

def copy(self):
new_datasets = {year: dataset.copy() for year, dataset in self.datasets.items()}
Expand Down
70 changes: 62 additions & 8 deletions policyengine_uk/data/economic_assumptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
CGT_DATASET_INPUTS,
CGT_INDEX_PATH,
)
from policyengine_uk.data.savings_income_projection import (
DEFAULT_SAVINGS_INCOME_PROJECTION,
OBR_SAVINGS_INCOME_2022_2025,
SAVINGS_INCOME_SERIES_YEARS,
uniform_savings_income_projection,
validate_savings_income_projection,
)
from policyengine_uk.data.isa_income import (
ISA_INTEREST,
SAVINGS_INTEREST,
Expand Down Expand Up @@ -101,6 +108,45 @@
# them a second time after LEGACY_PROJECTION_END_YEAR.
COUNTRY_UPRATED_INPUTS = frozenset({"water_and_sewerage_charges"})

# Savings income (savings interest, ISA interest and other investment income)
# grows in dataset projection by the path the dataset declares it was
# calibrated to (savings_income_projection.py). Under the
# obr_savings_income_2022_2025 rule that is the OBR's savings income forecast
# per person from 2022-23 to 2025-26, and GDP per head in other years, like
# dividends and the other capital incomes. The OBR's series rises 35.0% in
# 2024-25 and 0.6% in 2025-26, where GDP per head rises 4.0% and 4.6%. It
# starts in 2022-23, so earlier years keep GDP per head rather than the
# parameter's backdated first value. A dataset that declares no rule grows
# savings income by GDP per head in every year, as before.
SAVINGS_INCOME_GROWTH = (
"gov.economic_assumptions.yoy_growth.obr.per_capita.savings_income"
)
GDP_PER_HEAD_GROWTH = "gov.economic_assumptions.yoy_growth.obr.per_capita.gdp"
SAVINGS_INCOME_GROWTH_FIRST_YEAR, SAVINGS_INCOME_GROWTH_LAST_YEAR = (
SAVINGS_INCOME_SERIES_YEARS[OBR_SAVINGS_INCOME_2022_2025]
)


def growth_rate(
parameters: ParameterNode,
index_name: str,
year,
savings_income_projection: str = DEFAULT_SAVINGS_INCOME_PROJECTION,
) -> float:
"""Growth into ``year`` by the uprating_indices.yaml series ``index_name``.

The savings income series applies only in the years the dataset's
``savings_income_projection`` rule gives it; GDP per head serves the
other years, and every year under the default rule.
"""
if index_name == SAVINGS_INCOME_GROWTH:
years = SAVINGS_INCOME_SERIES_YEARS[
validate_savings_income_projection(savings_income_projection)
]
if years is None or not years[0] <= int(str(year)[:4]) <= years[1]:
index_name = GDP_PER_HEAD_GROWTH
return parameters.get_child(index_name)(year)


# The last year a single-year dataset is projected to: 2032, fiscal year
# 2032-33. An OBR forecast runs five years past the fiscal year it is made in,
Expand Down Expand Up @@ -350,9 +396,17 @@ def apply_single_year_uprating(

# First, apply standard variable-YoY growth based uprating.

savings_rule = uniform_savings_income_projection(
[
previous_year.savings_income_projection,
current_year.savings_income_projection,
]
)
uprating = _uprating_index_map()
for index_name, variables in uprating.items():
index_rel_change = parameters.get_child(index_name)(current_year.time_period)
index_rel_change = growth_rate(
parameters, index_name, current_year.time_period, savings_rule
)
for variable in variables:
for table_name, df in zip(current_year.table_names, current_year.tables):
if variable in df.columns:
Expand All @@ -374,23 +428,23 @@ 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 = growth_rate(
parameters, SAVINGS_INCOME_GROWTH, current_year.time_period, savings_rule
)
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
# New additive ISA receipts use the same dataset rule 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 = growth_rate(
parameters, SAVINGS_INCOME_GROWTH, current_year.time_period, savings_rule
)
current_year.person[ISA_INTEREST] = previous_year.person[ISA_INTEREST] * (
1 + growth
)
Expand Down
1 change: 1 addition & 0 deletions policyengine_uk/data/filter_dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,5 @@ def filter_dataset(
fiscal_year=year,
energy_price_basis=new_dataset.energy_price_basis,
isa_income_contract=new_dataset.isa_income_contract,
savings_income_projection=new_dataset.savings_income_projection,
)
4 changes: 4 additions & 0 deletions policyengine_uk/data/input_dataframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
UKMultiYearDataset,
UKSingleYearDataset,
)
from policyengine_uk.data.savings_income_projection import (
SAVINGS_INCOME_PROJECTION_KEY,
)
from policyengine_uk.data.isa_income import (
ISA_INCOME_CONTRACT_KEY,
MISSING_CONTRACT,
Expand Down Expand Up @@ -447,6 +450,7 @@ def no_tables() -> Dict[str, Dict[str, np.ndarray]]:
),
fiscal_year=fiscal_year,
isa_income_contract=contracts[fiscal_year],
savings_income_projection=df.attrs.get(SAVINGS_INCOME_PROJECTION_KEY),
)
for fiscal_year, tables in sorted(years.items())
]
Expand Down
Loading
Loading