From 54f4c7da480f79963967dc6689af022e82673636 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 22:19:41 -0400 Subject: [PATCH 1/3] Add share-like wealth component inputs corporate_wealth bundles three assets the means tests value differently: quoted shares less 10% for the expenses of sale (ADM H1665), unit trusts at the manager's withdrawal price with no deduction (H1673-H1674), and stocks and shares ISAs at their withdrawal value (H1656). Add household inputs for the two parts not yet modelled, directly_held_shares (UK shares and employee shares and options) and unit_and_investment_trusts, and uprate them, cash_isa and stocks_and_shares_isa with per-capita GDP like corporate_wealth, so a dataset's identity corporate_wealth == sum of components survives projection. The inputs have no consumers yet. Co-Authored-By: Claude Opus 5.5 --- changelog.d/wealth-share-components.added.md | 1 + docs/book/assumptions/growthfactors.md | 2 +- policyengine_uk/data/uprating_indices.yaml | 4 ++ .../tests/test_wealth_components.py | 46 +++++++++++++++++++ .../variables/input/corporate_wealth.py | 10 ++-- .../variables/input/directly_held_shares.py | 22 +++++++++ .../input/unit_and_investment_trusts.py | 21 +++++++++ 7 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 changelog.d/wealth-share-components.added.md create mode 100644 policyengine_uk/tests/test_wealth_components.py create mode 100644 policyengine_uk/variables/input/directly_held_shares.py create mode 100644 policyengine_uk/variables/input/unit_and_investment_trusts.py diff --git a/changelog.d/wealth-share-components.added.md b/changelog.d/wealth-share-components.added.md new file mode 100644 index 0000000000..78001b6ac6 --- /dev/null +++ b/changelog.d/wealth-share-components.added.md @@ -0,0 +1 @@ +Add `directly_held_shares` and `unit_and_investment_trusts` household inputs for the share-like components of `corporate_wealth`, and uprate them and the ISA balances with per-capita GDP. diff --git a/docs/book/assumptions/growthfactors.md b/docs/book/assumptions/growthfactors.md index 1b093f7b61..3bab5902dd 100644 --- a/docs/book/assumptions/growthfactors.md +++ b/docs/book/assumptions/growthfactors.md @@ -60,7 +60,7 @@ We derive these rates from OBR GDP growth and ONS population projections. |-------------|------|------|------|------|------|------|------|------| | Growth rate | 9.2% | 5.0% | 3.8% | 2.8% | 2.8% | 3.1% | 3.3% | 3.3% | -We use per capita GDP for these variables: `capital_gains`, `capital_gains_badr`, `capital_gains_before_response`, `capital_gains_carried_interest`, `capital_gains_residential_property`, `corporate_wealth`, `dividend_income`, `gross_financial_wealth`, `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_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `savings`, `savings_interest_income`, `sublet_income` +We use per capita GDP for these variables: `capital_gains`, `capital_gains_badr`, `capital_gains_before_response`, `capital_gains_carried_interest`, `capital_gains_residential_property`, `cash_isa`, `corporate_wealth`, `directly_held_shares`, `dividend_income`, `gross_financial_wealth`, `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_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `savings`, `savings_interest_income`, `stocks_and_shares_isa`, `sublet_income`, `unit_and_investment_trusts` ## Council tax diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index 4fbc8bc819..5e962bb401 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -83,7 +83,9 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - capital_gains_badr - capital_gains_residential_property - capital_gains_carried_interest +- cash_isa - corporate_wealth +- directly_held_shares - dividend_income - gross_financial_wealth - lump_sum_income @@ -102,7 +104,9 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - property_income - savings - savings_interest_income +- stocks_and_shares_isa - sublet_income +- unit_and_investment_trusts gov.economic_assumptions.yoy_growth.obr.per_capita.mixed_income: - self_employment_income gov.economic_assumptions.yoy_growth.obr.private_pension_index: diff --git a/policyengine_uk/tests/test_wealth_components.py b/policyengine_uk/tests/test_wealth_components.py new file mode 100644 index 0000000000..98e4799477 --- /dev/null +++ b/policyengine_uk/tests/test_wealth_components.py @@ -0,0 +1,46 @@ +"""corporate_wealth's components share its entity, quantity type and uprating, so +a dataset's identity corporate_wealth == sum of components survives projection.""" + +from pathlib import Path + +import yaml + +from policyengine_uk import CountryTaxBenefitSystem + +COMPONENTS = ( + "directly_held_shares", + "unit_and_investment_trusts", + "stocks_and_shares_isa", +) + +system = CountryTaxBenefitSystem() +UPRATING_INDICES = yaml.safe_load( + (Path(__file__).parents[1] / "data" / "uprating_indices.yaml").read_text() +) + + +def _uprating_index(variable): + return [index for index, names in UPRATING_INDICES.items() if variable in names] + + +def test_components_match_corporate_wealth(): + corporate_wealth = system.variables["corporate_wealth"] + for name in COMPONENTS: + variable = system.variables[name] + assert variable.is_input_variable() + assert variable.entity.key == corporate_wealth.entity.key + assert variable.quantity_type == corporate_wealth.quantity_type + assert variable.uprating == corporate_wealth.uprating + assert _uprating_index(name) == _uprating_index("corporate_wealth") + + +def test_cash_isa_is_uprated_with_savings(): + assert system.variables["cash_isa"].uprating == system.variables["savings"].uprating + assert _uprating_index("cash_isa") == _uprating_index("savings") + + +def test_components_are_not_part_of_total_wealth(): + """total_wealth adds corporate_wealth, which already holds the components.""" + adds = system.variables["total_wealth"].adds + assert "corporate_wealth" in adds + assert not set(COMPONENTS) & set(adds) diff --git a/policyengine_uk/variables/input/corporate_wealth.py b/policyengine_uk/variables/input/corporate_wealth.py index e1dabe6b62..c86bda946e 100644 --- a/policyengine_uk/variables/input/corporate_wealth.py +++ b/policyengine_uk/variables/input/corporate_wealth.py @@ -4,11 +4,11 @@ class corporate_wealth(Variable): label = "corporate wealth" documentation = ( - "Wealth held in corporations directly or through investment funds: UK " - "shares, employee shares and options, unit and investment trusts, and " - "stocks and shares ISAs, imputed from the Wealth and Assets Survey. The " - "stocks and shares ISA component is also exported on its own as " - "stocks_and_shares_isa, so the two must never be summed. Private pension " + "Wealth held in corporations directly or through investment funds, " + "outside pensions: directly_held_shares (UK shares and employee shares " + "and options), unit_and_investment_trusts and stocks_and_shares_isa. " + "Datasets that carry those components build this variable as their " + "exact sum, so it must never be summed with any of them. Private pension " "wealth is carried separately in private_pension_wealth; datasets built " "before that split folded it into this variable." ) diff --git a/policyengine_uk/variables/input/directly_held_shares.py b/policyengine_uk/variables/input/directly_held_shares.py new file mode 100644 index 0000000000..71f82217f4 --- /dev/null +++ b/policyengine_uk/variables/input/directly_held_shares.py @@ -0,0 +1,22 @@ +from policyengine_uk.model_api import * + + +class directly_held_shares(Variable): + label = "directly held shares" + documentation = ( + "Value of shares the household holds directly, outside ISAs and pooled " + "funds: shares in UK companies, listed or not, and employee shares and " + "share options. Part of corporate_wealth, so the two must never be " + "summed. The Enhanced FRS imputes it from the Wealth and Assets Survey " + "(UK shares plus employee shares and options); overseas shares are not " + "yet imputed. Quoted shares are valued for the means tests at their " + "price less 10% for the expenses of sale (Advice for Decision Making " + "H1665)." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" + quantity_type = STOCK + default_value = 0 diff --git a/policyengine_uk/variables/input/unit_and_investment_trusts.py b/policyengine_uk/variables/input/unit_and_investment_trusts.py new file mode 100644 index 0000000000..07e59aaee1 --- /dev/null +++ b/policyengine_uk/variables/input/unit_and_investment_trusts.py @@ -0,0 +1,21 @@ +from policyengine_uk.model_api import * + + +class unit_and_investment_trusts(Variable): + label = "unit and investment trusts" + documentation = ( + "Value of the household's holdings of unit trusts and investment trusts " + "outside ISAs. The Wealth and Assets Survey asks about both in one " + "question, so the Enhanced FRS cannot separate them. Part of " + "corporate_wealth, so the two must never be summed. Unit trusts are " + "valued for the means tests at the price the manager pays on " + "withdrawal, with no deduction for expenses of sale (Advice for " + "Decision Making H1673-H1674)." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" + quantity_type = STOCK + default_value = 0 From 21412b6a177cfacfb8974ecdec962e7a9a32be85 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 06:40:47 -0400 Subject: [PATCH 2/3] Count corporate_wealth's components in every means-test capital list The six capital sources lists (UC, HB, IS, JSA, ESA, PC) name directly_held_shares, unit_and_investment_trusts and stocks_and_shares_isa in place of corporate_wealth, plus a new formula variable, unitemised_corporate_wealth = max(0, corporate_wealth - components), for whatever a dataset does not itemise. Datasets without the components keep counting corporate_wealth in full; datasets that build corporate_wealth as the components' exact sum count the same total, now itemised, so each component can carry its own valuation rule (the sale-expense deduction stacked on #1969 exempts unit trusts and ISAs). No dataset's capital changes: a differential property test checks that every programme's assessable capital equals the same holdings entered as one corporate_wealth. Review nits: the growth-factor sentence now matches uprating_indices.yaml exactly; the variable docs cite the DMG for the legacy benefits, the expert valuation of unquoted shares and the quoted-share rule for investment trusts. Co-Authored-By: Claude Opus 5.5 --- changelog.d/wealth-share-components.added.md | 2 +- docs/book/assumptions/growthfactors.md | 2 +- .../gov/dwp/ESA/income/capital/sources.yaml | 10 +- .../gov/dwp/JSA/income/capital/sources.yaml | 10 +- .../means_test/capital/sources.yaml | 12 +- .../means_test/capital/sources.yaml | 6 +- .../income/capital/sources.yaml | 7 +- .../means_test/capital/sources.yaml | 13 ++- .../tests/test_capital_share_components.py | 110 ++++++++++++++++++ .../wealth/unitemised_corporate_wealth.py | 34 ++++++ .../variables/input/directly_held_shares.py | 17 +-- .../input/unit_and_investment_trusts.py | 12 +- 12 files changed, 201 insertions(+), 34 deletions(-) create mode 100644 policyengine_uk/tests/test_capital_share_components.py create mode 100644 policyengine_uk/variables/household/wealth/unitemised_corporate_wealth.py diff --git a/changelog.d/wealth-share-components.added.md b/changelog.d/wealth-share-components.added.md index 78001b6ac6..aa4202f98b 100644 --- a/changelog.d/wealth-share-components.added.md +++ b/changelog.d/wealth-share-components.added.md @@ -1 +1 @@ -Add `directly_held_shares` and `unit_and_investment_trusts` household inputs for the share-like components of `corporate_wealth`, and uprate them and the ISA balances with per-capita GDP. +- Add `directly_held_shares` and `unit_and_investment_trusts` household inputs for the share-like components of `corporate_wealth`, count them and the stocks and shares ISA in place of `corporate_wealth` in every means-test capital sources list (with `unitemised_corporate_wealth` carrying any part a dataset does not itemise), and uprate them and the ISA balances with per-capita GDP. diff --git a/docs/book/assumptions/growthfactors.md b/docs/book/assumptions/growthfactors.md index 5a90a2e171..626965f0d6 100644 --- a/docs/book/assumptions/growthfactors.md +++ b/docs/book/assumptions/growthfactors.md @@ -60,7 +60,7 @@ We derive these rates from OBR GDP growth and ONS population projections. |-------------|------|------|------|------|------|------|------|------| | Growth rate | 9.2% | 5.0% | 3.8% | 2.8% | 2.8% | 3.1% | 3.3% | 3.3% | -We use per capita GDP for these variables: `capital_gains`, `capital_gains_badr`, `capital_gains_before_response`, `capital_gains_carried_interest`, `capital_gains_residential_property`, `cash_isa`, `corporate_wealth`, `directly_held_shares`, `dividend_income`, `gross_financial_wealth`, `household_lifetime_isa_balance`, `lifetime_isa_balance`, `lump_sum_income`, `main_residence_value`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `net_financial_wealth`, `non_residential_property_value`, `other_investment_income`, `other_residential_property_value`, `owned_land`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `savings`, `savings_interest_income`, `stocks_and_shares_isa`, `sublet_income`, `unit_and_investment_trusts` +We use per capita GDP for these variables: `capital_gains`, `capital_gains_badr`, `capital_gains_before_response`, `capital_gains_carried_interest`, `capital_gains_residential_property`, `cash_isa`, `corporate_wealth`, `directly_held_shares`, `dividend_income`, `gross_financial_wealth`, `household_lifetime_isa_balance`, `lifetime_isa_balance`, `lump_sum_income`, `main_residence_value`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `net_financial_wealth`, `non_residential_property_value`, `other_investment_income`, `other_residential_property_value`, `owned_land`, `pension_credit_reported_capital`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `savings`, `savings_interest_income`, `stocks_and_shares_isa`, `sublet_income`, `unit_and_investment_trusts` ## Council tax diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml index 3eb35b83ef..e0bd6f429d 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml @@ -3,10 +3,9 @@ description: > Support Allowance capital. The Employment and Support Allowance Regulations 2008 treat capital as assessable unless it is disregarded under Schedule 9. The current mappings are `savings` for liquid capital, `owned_land` for land - held directly, `corporate_wealth` as the broad available proxy for share-like - financial assets, `other_residential_property_value` for residential + held directly, the share-like sources, `other_residential_property_value` for residential property other than the home, and `non_residential_property_value` for other - land and premises. `main_residence_value` is excluded because Schedule 9 + land and premises. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. `main_residence_value` is excluded because Schedule 9 paragraph 1 disregards premises occupied as the claimant's home. `private_pension_wealth` is excluded because Schedule 9 paragraphs 28 and 29 disregard the value of the right to receive an occupational or personal @@ -33,6 +32,9 @@ values: 2008-10-27: - savings - owned_land - - corporate_wealth + - directly_held_shares + - unit_and_investment_trusts + - stocks_and_shares_isa + - unitemised_corporate_wealth - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml index 6484484ab8..e6e2f55294 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml @@ -3,10 +3,9 @@ description: > Allowance capital. The Jobseeker's Allowance Regulations 1996 treat capital as assessable unless it is disregarded under Schedule 8. The current mappings are `savings` for liquid capital, `owned_land` for land held - directly, `corporate_wealth` as the broad available proxy for share-like - financial assets, `other_residential_property_value` for residential + directly, the share-like sources, `other_residential_property_value` for residential property other than the home, and `non_residential_property_value` for other - land and premises. `main_residence_value` is excluded because Schedule 8 + land and premises. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. `main_residence_value` is excluded because Schedule 8 paragraph 1 disregards premises occupied as the claimant's home. `private_pension_wealth` is excluded because Schedule 8 paragraphs 28 and 29 disregard the value of the right to receive an occupational or personal @@ -34,6 +33,9 @@ values: 1996-10-07: - savings - owned_land - - corporate_wealth + - directly_held_shares + - unit_and_investment_trusts + - stocks_and_shares_isa + - unitemised_corporate_wealth - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml index e313576158..d4522d2197 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml @@ -3,10 +3,9 @@ description: > The Housing Benefit Regulations 2006 treat the whole of a claimant's capital as assessable unless it is disregarded under Schedule 6. The current mappings are `savings` for liquid capital, `owned_land` for land held directly, - `corporate_wealth` as the broad available proxy for share-like financial - assets such as stocks, shares, unit trusts, and investment ISAs, + the share-like sources, `other_residential_property_value` for residential property other than the - home, and `non_residential_property_value` for other land and premises. + home, and `non_residential_property_value` for other land and premises. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. `main_residence_value` is excluded because Schedule 6 paragraph 1 disregards premises occupied as the claimant's home. `private_pension_wealth` is excluded because Schedule 6 paragraphs 31 and 32 disregard the value of the @@ -15,7 +14,7 @@ description: > pension-age regulations and paragraphs 32 and 33 of Schedule 7 to the Northern Ireland regulations make the same disregard). Datasets built before private pension wealth was split out of `corporate_wealth` still carry it - inside that countable bucket. Other Schedule 6 disregards within these broad + inside `unitemised_corporate_wealth`. Other Schedule 6 disregards within these broad asset classes are not yet split out separately. Because the dataset only provides household-level capital stocks, the model allocates them across benunits using an adult-share proxy. @@ -42,6 +41,9 @@ values: 2006-04-10: - savings - owned_land - - corporate_wealth + - directly_held_shares + - unit_and_investment_trusts + - stocks_and_shares_isa + - unitemised_corporate_wealth - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml index 5b28814633..336efd6e3f 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml @@ -3,6 +3,7 @@ description: > `private_pension_wealth` is excluded because Schedule 10 paragraphs 23 and 23A disregard the value of the right to receive an occupational or personal pension and the value of funds held under a personal pension scheme. + `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. metadata: label: Income Support capital sources period: year @@ -22,6 +23,9 @@ values: 2010-01-01: - savings - owned_land - - corporate_wealth + - directly_held_shares + - unit_and_investment_trusts + - stocks_and_shares_isa + - unitemised_corporate_wealth - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml index 0761c1b0f3..d5fb5b4388 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml @@ -5,7 +5,7 @@ description: > (Part I) disregard the value of the right to receive income from an occupational or personal pension scheme or under a retirement annuity contract; the notional income rule for undrawn money purchase pots - (regulation 18) is not modelled. + (regulation 18) is not modelled. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. metadata: label: Pension Credit capital sources unit: list @@ -25,4 +25,7 @@ values: - owned_land - other_residential_property_value - non_residential_property_value - - corporate_wealth + - directly_held_shares + - unit_and_investment_trusts + - stocks_and_shares_isa + - unitemised_corporate_wealth diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml index f64474cebb..82af9db7cb 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml @@ -4,15 +4,17 @@ description: > treated as income or disregarded under regulation 48 and Schedule 10. The current mappings are `savings` for liquid capital, `other_residential_property_value` for residential property other than the - home, `non_residential_property_value` for other land and premises, and - `corporate_wealth` for shares and similar investment assets. `main_residence_value` + home, `non_residential_property_value` for other land and premises, and the + share-like sources. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. `main_residence_value` is excluded because Schedule 10 paragraph 1 disregards premises occupied as the person's home. `private_pension_wealth` is excluded because Schedule 10 paragraph 10 disregards the value of any right to receive a pension under an occupational or personal pension scheme (or any other scheme registered under section 153 of the Finance Act 2004; paragraph 10 of Schedule 10 to the Northern Ireland regulations makes the same disregard); pension income - actually drawn counts as unearned income instead. Other Schedule 10 disregards within these broad asset + actually drawn counts as unearned income instead; datasets built before it + was split out of `corporate_wealth` still carry it inside + `unitemised_corporate_wealth`. Other Schedule 10 disregards within these broad asset classes are not yet split out separately. Where only household-level capital is available for a multi-benunit household, the model uses a household-wide adult-share proxy for residual capital because the dataset does not identify @@ -45,4 +47,7 @@ values: - savings - other_residential_property_value - non_residential_property_value - - corporate_wealth + - directly_held_shares + - unit_and_investment_trusts + - stocks_and_shares_isa + - unitemised_corporate_wealth diff --git a/policyengine_uk/tests/test_capital_share_components.py b/policyengine_uk/tests/test_capital_share_components.py new file mode 100644 index 0000000000..8d4a5201ce --- /dev/null +++ b/policyengine_uk/tests/test_capital_share_components.py @@ -0,0 +1,110 @@ +"""Itemising corporate_wealth never changes a household's capital total. + +The means-test capital sources list corporate_wealth's components and +unitemised_corporate_wealth (the part a dataset does not itemise) instead of +corporate_wealth. With no valuation rule that tells the components apart, every +programme's assessable capital must equal what it is when the same holdings are +entered as one unitemised corporate_wealth. +""" + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine_uk import CountryTaxBenefitSystem, Simulation + +COMPONENTS = ( + "directly_held_shares", + "unit_and_investment_trusts", + "stocks_and_shares_isa", +) +SHARE_LIKE_SOURCES = (*COMPONENTS, "unitemised_corporate_wealth") +SOURCE_LISTS = { + "uc_assessable_capital": "gov.dwp.universal_credit.means_test.capital.sources", + "housing_benefit_assessable_capital": "gov.dwp.housing_benefit.means_test.capital.sources", + "income_support_assessable_capital": "gov.dwp.income_support.means_test.capital.sources", + "jsa_income_assessable_capital": "gov.dwp.JSA.income.capital.sources", + "esa_income_assessable_capital": "gov.dwp.ESA.income.capital.sources", + "pension_credit_assessable_capital": "gov.dwp.pension_credit.income.capital.sources", +} +AGE = {"pension_credit_assessable_capital": 70} +YEAR = 2026 + +system = CountryTaxBenefitSystem() +amount = st.integers(min_value=0, max_value=60_000) + + +def _parameter(path): + node = system.parameters + for part in path.split("."): + node = getattr(node, part) + return node(f"{YEAR}-01-01") + + +def _capital(variable, household): + situation = { + "people": {"adult": {"age": {YEAR: AGE.get(variable, 30)}}}, + "benunits": {"benunit": {"members": ["adult"], "would_claim_uc": {YEAR: True}}}, + "households": { + "household": { + "members": ["adult"], + **{name: {YEAR: value} for name, value in household.items()}, + } + }, + } + return Simulation(situation=situation).calculate(variable, YEAR)[0] + + +@pytest.mark.parametrize("variable", SOURCE_LISTS) +def test_lists_name_the_components_not_corporate_wealth(variable): + sources = list(_parameter(SOURCE_LISTS[variable])) + assert "corporate_wealth" not in sources + assert set(SHARE_LIKE_SOURCES) <= set(sources) + + +@settings(max_examples=40, deadline=None) +@given( + savings=amount, + corporate_wealth=amount, + components=st.tuples(amount, amount, amount), +) +def test_unitemised_corporate_wealth_is_the_floored_remainder( + savings, corporate_wealth, components +): + household = {"savings": savings, "corporate_wealth": corporate_wealth} + household.update(dict(zip(COMPONENTS, components))) + situation = { + "people": {"adult": {"age": {YEAR: 30}}}, + "households": { + "household": { + "members": ["adult"], + **{name: {YEAR: value} for name, value in household.items()}, + } + }, + } + residual = Simulation(situation=situation).calculate( + "unitemised_corporate_wealth", YEAR + )[0] + assert residual == max(0, corporate_wealth - sum(components)) + + +@pytest.mark.parametrize("variable", SOURCE_LISTS) +@settings(max_examples=15, deadline=None) +@given( + savings=amount, + corporate_wealth=amount, + components=st.tuples(amount, amount, amount), +) +def test_itemising_never_changes_capital( + variable, savings, corporate_wealth, components +): + """Split holdings count exactly as max(corporate_wealth, their sum) does.""" + itemised = {"savings": savings, "corporate_wealth": corporate_wealth} + itemised.update(dict(zip(COMPONENTS, components))) + unitemised = { + "savings": savings, + "corporate_wealth": max(corporate_wealth, sum(components)), + } + assert _capital(variable, itemised) == pytest.approx( + _capital(variable, unitemised), abs=1e-2 + ) diff --git a/policyengine_uk/variables/household/wealth/unitemised_corporate_wealth.py b/policyengine_uk/variables/household/wealth/unitemised_corporate_wealth.py new file mode 100644 index 0000000000..1d7fcd56a1 --- /dev/null +++ b/policyengine_uk/variables/household/wealth/unitemised_corporate_wealth.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * + + +class unitemised_corporate_wealth(Variable): + label = "corporate wealth not itemised into components" + documentation = ( + "The part of corporate_wealth not carried in its itemised components " + "directly_held_shares, unit_and_investment_trusts and " + "stocks_and_shares_isa, floored at nil. On datasets without the " + "components it equals corporate_wealth (including the private pension " + "wealth that datasets built before that split fold into it); on datasets " + "that build corporate_wealth as the exact sum of the components it is " + "about nil. The means-test capital sources list the components and this " + "residual instead of corporate_wealth, so each itemised asset can carry " + "its own valuation rule while datasets without the split keep counting " + "corporate_wealth as before, and no holding is counted twice." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = STOCK + + def formula(household, period, parameters): + itemised = add( + household, + period, + [ + "directly_held_shares", + "unit_and_investment_trusts", + "stocks_and_shares_isa", + ], + ) + return max_(0, household("corporate_wealth", period) - itemised) diff --git a/policyengine_uk/variables/input/directly_held_shares.py b/policyengine_uk/variables/input/directly_held_shares.py index 71f82217f4..5fe9fd079f 100644 --- a/policyengine_uk/variables/input/directly_held_shares.py +++ b/policyengine_uk/variables/input/directly_held_shares.py @@ -5,13 +5,16 @@ class directly_held_shares(Variable): label = "directly held shares" documentation = ( "Value of shares the household holds directly, outside ISAs and pooled " - "funds: shares in UK companies, listed or not, and employee shares and " - "share options. Part of corporate_wealth, so the two must never be " - "summed. The Enhanced FRS imputes it from the Wealth and Assets Survey " - "(UK shares plus employee shares and options); overseas shares are not " - "yet imputed. Quoted shares are valued for the means tests at their " - "price less 10% for the expenses of sale (Advice for Decision Making " - "H1665)." + "funds: shares in UK companies, listed or not (the Wealth and Assets " + "Survey question), and employee shares and share options. Part of " + "corporate_wealth, so the two must never be summed. Imputed for the " + "Enhanced FRS from the Wealth and Assets Survey's UK shares and " + "employee shares and options (policyengine-uk-data#501); overseas " + "shares are not imputed. Quoted shares are valued at their price less " + "10% for the expenses of sale (Advice for Decision Making H1665 for " + "Universal Credit; Decision Makers' Guide 29671, 52671 and 84763 for " + "Income Support and JSA, ESA and Pension Credit); unquoted shares need " + "an expert valuation (ADM H1679-H1681)." ) entity = Household definition_period = YEAR diff --git a/policyengine_uk/variables/input/unit_and_investment_trusts.py b/policyengine_uk/variables/input/unit_and_investment_trusts.py index 07e59aaee1..2352d63f49 100644 --- a/policyengine_uk/variables/input/unit_and_investment_trusts.py +++ b/policyengine_uk/variables/input/unit_and_investment_trusts.py @@ -6,11 +6,13 @@ class unit_and_investment_trusts(Variable): documentation = ( "Value of the household's holdings of unit trusts and investment trusts " "outside ISAs. The Wealth and Assets Survey asks about both in one " - "question, so the Enhanced FRS cannot separate them. Part of " - "corporate_wealth, so the two must never be summed. Unit trusts are " - "valued for the means tests at the price the manager pays on " - "withdrawal, with no deduction for expenses of sale (Advice for " - "Decision Making H1673-H1674)." + "question, so the Enhanced FRS cannot separate them " + "(policyengine-uk-data#501). Part of corporate_wealth, so the two must " + "never be summed. Unit trusts are valued at the bid price with no " + "deduction for expenses of sale, because the holder withdraws through " + "the manager (Advice for Decision Making H1673-H1674; Decision Makers' " + "Guide 29680-29681). Investment trusts are companies whose shares are " + "quoted, so they fall under the quoted-share rule (ADM H1665)." ) entity = Household definition_period = YEAR From 7784193a9d7d309e3370553bebf6b76768b6d329 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 10:20:37 -0400 Subject: [PATCH 3/3] Count share-like capital as one block that keeps the supplied aggregate The independent review of 21412b6a found that reconstructing corporate_wealth as components plus a floored residual could tip a household across the capital limit on float32 rounding (components summing to 16,000.0009 against a supplied 16,000), and changed unsplit inputs with a negative corporate_wealth. The six capital lists now name one formula variable, share_like_capital: corporate_wealth as supplied, unless the itemised components add up to more than it beyond a rounding tolerance (1e-5 relative plus a penny), in which case their sum. Every dataset that carries only corporate_wealth, or builds it as the components' sum, counts exactly corporate_wealth; a household given only components has them counted. Tests: a differential against the original corporate_wealth lists on identical inputs (unsplit with negative and fractional aggregates, and consistent splits), annual and monthly, asserting capital and UC eligibility equal exactly; the float32 limit case and the negative aggregate; and the block's definition as a property. Replacing the formula with the old floored residual fails the float32 test. Also: the pension-age HB home disregard is Schedule 6 paragraph 26 of SI 2006/214, not paragraph 1 (review finding, predates this PR). Co-Authored-By: Claude Opus 5.5 --- changelog.d/wealth-share-components.added.md | 2 +- .../gov/dwp/ESA/income/capital/sources.yaml | 38 ++-- .../gov/dwp/JSA/income/capital/sources.yaml | 40 ++-- .../means_test/capital/sources.yaml | 47 +++-- .../means_test/capital/sources.yaml | 10 +- .../income/capital/sources.yaml | 11 +- .../means_test/capital/sources.yaml | 55 ++--- .../tests/test_capital_share_components.py | 199 ++++++++++++------ .../household/wealth/share_like_capital.py | 43 ++++ .../wealth/unitemised_corporate_wealth.py | 34 --- 10 files changed, 283 insertions(+), 196 deletions(-) create mode 100644 policyengine_uk/variables/household/wealth/share_like_capital.py delete mode 100644 policyengine_uk/variables/household/wealth/unitemised_corporate_wealth.py diff --git a/changelog.d/wealth-share-components.added.md b/changelog.d/wealth-share-components.added.md index aa4202f98b..a161d13f25 100644 --- a/changelog.d/wealth-share-components.added.md +++ b/changelog.d/wealth-share-components.added.md @@ -1 +1 @@ -- Add `directly_held_shares` and `unit_and_investment_trusts` household inputs for the share-like components of `corporate_wealth`, count them and the stocks and shares ISA in place of `corporate_wealth` in every means-test capital sources list (with `unitemised_corporate_wealth` carrying any part a dataset does not itemise), and uprate them and the ISA balances with per-capita GDP. +- Add `directly_held_shares` and `unit_and_investment_trusts` household inputs for the share-like components of `corporate_wealth`, count share-like capital in every means test as `corporate_wealth` or, where larger, the sum of its components (`share_like_capital`), and uprate the components and the ISA balances with per-capita GDP. diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml index e0bd6f429d..a5de71cb3e 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml @@ -3,20 +3,25 @@ description: > Support Allowance capital. The Employment and Support Allowance Regulations 2008 treat capital as assessable unless it is disregarded under Schedule 9. The current mappings are `savings` for liquid capital, `owned_land` for land - held directly, the share-like sources, `other_residential_property_value` for residential - property other than the home, and `non_residential_property_value` for other - land and premises. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. `main_residence_value` is excluded because Schedule 9 - paragraph 1 disregards premises occupied as the claimant's home. - `private_pension_wealth` is excluded because Schedule 9 paragraphs 28 and 29 - disregard the value of the right to receive an occupational or personal - pension and the value of funds held under a personal pension scheme. Other - Schedule 9 disregards and the higher residential-care capital disregard are - not yet modeled because the current dataset does not isolate those states or - asset holdings. Because the dataset only provides household-level capital - stocks, the model allocates the full household total to benunits with a - reported income-related ESA award and only falls back to an adult-share - proxy when nobody in the household is on that reported claim path. That - allocation is a data approximation, not a legislated ownership rule. + held directly, the share-like sources, `other_residential_property_value` + for residential property other than the home, and + `non_residential_property_value` for other land and premises. + `share_like_capital` is `corporate_wealth` as supplied, or the sum of its + itemised components (`directly_held_shares`, `unit_and_investment_trusts`, + `stocks_and_shares_isa`) where that is larger, so a household given only the + components has them counted and no holding counts twice. + `main_residence_value` is excluded because Schedule 9 paragraph 1 disregards + premises occupied as the claimant's home. `private_pension_wealth` is + excluded because Schedule 9 paragraphs 28 and 29 disregard the value of the + right to receive an occupational or personal pension and the value of funds + held under a personal pension scheme. Other Schedule 9 disregards and the + higher residential-care capital disregard are not yet modeled because the + current dataset does not isolate those states or asset holdings. Because the + dataset only provides household-level capital stocks, the model allocates + the full household total to benunits with a reported income-related ESA + award and only falls back to an adult-share proxy when nobody in the + household is on that reported claim path. That allocation is a data + approximation, not a legislated ownership rule. metadata: label: Income-related ESA countable capital sources period: year @@ -32,9 +37,6 @@ values: 2008-10-27: - savings - owned_land - - directly_held_shares - - unit_and_investment_trusts - - stocks_and_shares_isa - - unitemised_corporate_wealth + - share_like_capital - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml index e6e2f55294..7c5262e767 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml @@ -3,21 +3,26 @@ description: > Allowance capital. The Jobseeker's Allowance Regulations 1996 treat capital as assessable unless it is disregarded under Schedule 8. The current mappings are `savings` for liquid capital, `owned_land` for land held - directly, the share-like sources, `other_residential_property_value` for residential - property other than the home, and `non_residential_property_value` for other - land and premises. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. `main_residence_value` is excluded because Schedule 8 - paragraph 1 disregards premises occupied as the claimant's home. - `private_pension_wealth` is excluded because Schedule 8 paragraphs 28 and 29 - disregard the value of the right to receive an occupational or personal - pension and the value of funds held under a personal pension scheme. Other - Schedule 8 disregards, the higher residential-care capital disregard, and - the separate child-capital disregard are not yet modeled because the current - dataset does not isolate those states or asset holdings. Because the dataset - only provides household-level capital stocks, the model allocates the full - household total to benunits with a reported income-based JSA award and only - falls back to an adult-share proxy when nobody in the household is on that - reported claim path. That allocation is a data approximation, not a - legislated ownership rule. + directly, the share-like sources, `other_residential_property_value` for + residential property other than the home, and + `non_residential_property_value` for other land and premises. + `share_like_capital` is `corporate_wealth` as supplied, or the sum of its + itemised components (`directly_held_shares`, `unit_and_investment_trusts`, + `stocks_and_shares_isa`) where that is larger, so a household given only the + components has them counted and no holding counts twice. + `main_residence_value` is excluded because Schedule 8 paragraph 1 disregards + premises occupied as the claimant's home. `private_pension_wealth` is + excluded because Schedule 8 paragraphs 28 and 29 disregard the value of the + right to receive an occupational or personal pension and the value of funds + held under a personal pension scheme. Other Schedule 8 disregards, the + higher residential-care capital disregard, and the separate child-capital + disregard are not yet modeled because the current dataset does not isolate + those states or asset holdings. Because the dataset only provides + household-level capital stocks, the model allocates the full household total + to benunits with a reported income-based JSA award and only falls back to an + adult-share proxy when nobody in the household is on that reported claim + path. That allocation is a data approximation, not a legislated ownership + rule. metadata: label: Income-based JSA countable capital sources period: year @@ -33,9 +38,6 @@ values: 1996-10-07: - savings - owned_land - - directly_held_shares - - unit_and_investment_trusts - - stocks_and_shares_isa - - unitemised_corporate_wealth + - share_like_capital - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml index d4522d2197..dbf6a86c0b 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml @@ -1,23 +1,27 @@ description: > - Model variables currently mapped to countable Housing Benefit capital. - The Housing Benefit Regulations 2006 treat the whole of a claimant's capital - as assessable unless it is disregarded under Schedule 6. The current mappings - are `savings` for liquid capital, `owned_land` for land held directly, - the share-like sources, - `other_residential_property_value` for residential property other than the - home, and `non_residential_property_value` for other land and premises. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. - `main_residence_value` is excluded because Schedule 6 paragraph 1 disregards - premises occupied as the claimant's home. `private_pension_wealth` is - excluded because Schedule 6 paragraphs 31 and 32 disregard the value of the - right to receive an occupational or personal pension and the value of funds - held under a personal pension scheme (paragraph 24 of Schedule 6 to the - pension-age regulations and paragraphs 32 and 33 of Schedule 7 to the + Model variables currently mapped to countable Housing Benefit capital. The + Housing Benefit Regulations 2006 treat the whole of a claimant's capital as + assessable unless it is disregarded under Schedule 6. The current mappings + are `savings` for liquid capital, `owned_land` for land held directly, the + share-like sources, `other_residential_property_value` for residential + property other than the home, and `non_residential_property_value` for other + land and premises. `share_like_capital` is `corporate_wealth` as supplied, + or the sum of its itemised components (`directly_held_shares`, + `unit_and_investment_trusts`, `stocks_and_shares_isa`) where that is larger, + so a household given only the components has them counted and no holding + counts twice. `main_residence_value` is excluded because Schedule 6 + paragraph 1 disregards premises occupied as the claimant's home (paragraph + 26 of Schedule 6 to the pension-age regulations). `private_pension_wealth` + is excluded because Schedule 6 paragraphs 31 and 32 disregard the value of + the right to receive an occupational or personal pension and the value of + funds held under a personal pension scheme (paragraph 24 of Schedule 6 to + the pension-age regulations and paragraphs 32 and 33 of Schedule 7 to the Northern Ireland regulations make the same disregard). Datasets built before private pension wealth was split out of `corporate_wealth` still carry it - inside `unitemised_corporate_wealth`. Other Schedule 6 disregards within these broad - asset classes are not yet split out separately. Because the - dataset only provides household-level capital stocks, the model allocates - them across benunits using an adult-share proxy. + inside `share_like_capital`. Other Schedule 6 disregards within these broad + asset classes are not yet split out separately. Because the dataset only + provides household-level capital stocks, the model allocates them across + benunits using an adult-share proxy. metadata: label: Housing Benefit countable capital sources period: year @@ -33,17 +37,14 @@ metadata: href: https://www.legislation.gov.uk/nisr/2006/405/schedule/7/paragraph/32 - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg. 44(1) href: https://www.legislation.gov.uk/uksi/2006/214/regulation/44 - - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 Schedule 6 para. 1 - href: https://www.legislation.gov.uk/uksi/2006/214/schedule/6/paragraph/1 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 Schedule 6 para. 26 + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/6/paragraph/26 - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 Schedule 6 para. 24 href: https://www.legislation.gov.uk/uksi/2006/214/schedule/6/paragraph/24 values: 2006-04-10: - savings - owned_land - - directly_held_shares - - unit_and_investment_trusts - - stocks_and_shares_isa - - unitemised_corporate_wealth + - share_like_capital - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml index 336efd6e3f..971341aa06 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml @@ -3,7 +3,10 @@ description: > `private_pension_wealth` is excluded because Schedule 10 paragraphs 23 and 23A disregard the value of the right to receive an occupational or personal pension and the value of funds held under a personal pension scheme. - `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. + `share_like_capital` is `corporate_wealth` as supplied, or the sum of its + itemised components (`directly_held_shares`, `unit_and_investment_trusts`, + `stocks_and_shares_isa`) where that is larger, so a household given only the + components has them counted and no holding counts twice. metadata: label: Income Support capital sources period: year @@ -23,9 +26,6 @@ values: 2010-01-01: - savings - owned_land - - directly_held_shares - - unit_and_investment_trusts - - stocks_and_shares_isa - - unitemised_corporate_wealth + - share_like_capital - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml index d5fb5b4388..b567570949 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml @@ -5,7 +5,11 @@ description: > (Part I) disregard the value of the right to receive income from an occupational or personal pension scheme or under a retirement annuity contract; the notional income rule for undrawn money purchase pots - (regulation 18) is not modelled. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. + (regulation 18) is not modelled. `share_like_capital` is `corporate_wealth` + as supplied, or the sum of its itemised components (`directly_held_shares`, + `unit_and_investment_trusts`, `stocks_and_shares_isa`) where that is larger, + so a household given only the components has them counted and no holding + counts twice. metadata: label: Pension Credit capital sources unit: list @@ -25,7 +29,4 @@ values: - owned_land - other_residential_property_value - non_residential_property_value - - directly_held_shares - - unit_and_investment_trusts - - stocks_and_shares_isa - - unitemised_corporate_wealth + - share_like_capital diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml index 63919f2699..c4615ccf9c 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml @@ -1,31 +1,35 @@ description: > Model variables currently mapped to countable Universal Credit capital. - Regulation 46 takes the whole of a person's capital into account unless it is - treated as income or disregarded under regulation 48 and Schedule 10. The + Regulation 46 takes the whole of a person's capital into account unless it + is treated as income or disregarded under regulation 48 and Schedule 10. The current mappings are `savings` for liquid capital, `other_residential_property_value` for residential property other than the home, `non_residential_property_value` for other land and premises, and the - share-like sources. `directly_held_shares`, `unit_and_investment_trusts` and `stocks_and_shares_isa` hold the share-like assets the means tests value separately, and `unitemised_corporate_wealth` holds any part of `corporate_wealth` a dataset does not itemise into them: all of it on datasets without the components, and nil on datasets that build `corporate_wealth` as their exact sum. `corporate_wealth` itself is not listed, so no holding counts twice. `main_residence_value` - is excluded because Schedule 10 paragraph 1 disregards premises occupied as - the person's home. `private_pension_wealth` is excluded because Schedule 10 - paragraph 10 disregards the value of any right to receive a pension under an - occupational or personal pension scheme (or any other scheme registered under - section 153 of the Finance Act 2004; paragraph 10 of Schedule 10 to the - Northern Ireland regulations makes the same disregard); pension income - actually drawn counts as unearned income instead; datasets built before it - was split out of `corporate_wealth` still carry it inside - `unitemised_corporate_wealth`. Other Schedule 10 disregards within these broad asset - classes are not yet split out separately. Where only household-level capital - is available for a multi-benunit household, the model uses a household-wide - adult-share proxy for residual capital because the dataset does not identify - which adults hold the beneficial interest in each capital asset. The - denominator is all unreported adults in the household, including adults in - benunits that are not themselves UC claimants. That proxy is a data - approximation, not a legislated allocation rule. Regulation 47 is cited only - as related context on jointly held capital. If some benunits provide - `uc_reported_capital` (by default the benefit unit's recorded capital, - `benunit_reported_capital`), the proxy is applied only to the residual - household capital for benunits that remain unreported. + share-like sources. `share_like_capital` is `corporate_wealth` as supplied, + or the sum of its itemised components (`directly_held_shares`, + `unit_and_investment_trusts`, `stocks_and_shares_isa`) where that is larger, + so a household given only the components has them counted and no holding + counts twice. `main_residence_value` is excluded because Schedule 10 + paragraph 1 disregards premises occupied as the person's home. + `private_pension_wealth` is excluded because Schedule 10 paragraph 10 + disregards the value of any right to receive a pension under an occupational + or personal pension scheme (or any other scheme registered under section 153 + of the Finance Act 2004; paragraph 10 of Schedule 10 to the Northern Ireland + regulations makes the same disregard); pension income actually drawn counts + as unearned income instead; datasets built before it was split out of + `corporate_wealth` still carry it inside `share_like_capital`. Other + Schedule 10 disregards within these broad asset classes are not yet split + out separately. Where only household-level capital is available for a + multi-benunit household, the model uses a household-wide adult-share proxy + for residual capital because the dataset does not identify which adults hold + the beneficial interest in each capital asset. The denominator is all + unreported adults in the household, including adults in benunits that are + not themselves UC claimants. That proxy is a data approximation, not a + legislated allocation rule. Regulation 47 is cited only as related context + on jointly held capital. If some benunits provide `uc_reported_capital` (by + default the benefit unit's recorded capital, `benunit_reported_capital`), + the proxy is applied only to the residual household capital for benunits + that remain unreported. metadata: label: Universal Credit countable capital sources period: year @@ -48,7 +52,4 @@ values: - savings - other_residential_property_value - non_residential_property_value - - directly_held_shares - - unit_and_investment_trusts - - stocks_and_shares_isa - - unitemised_corporate_wealth + - share_like_capital diff --git a/policyengine_uk/tests/test_capital_share_components.py b/policyengine_uk/tests/test_capital_share_components.py index 8d4a5201ce..3194e95198 100644 --- a/policyengine_uk/tests/test_capital_share_components.py +++ b/policyengine_uk/tests/test_capital_share_components.py @@ -1,24 +1,32 @@ -"""Itemising corporate_wealth never changes a household's capital total. +"""share_like_capital replaces corporate_wealth in the means-test capital lists. -The means-test capital sources list corporate_wealth's components and -unitemised_corporate_wealth (the part a dataset does not itemise) instead of -corporate_wealth. With no valuation rule that tells the components apart, every -programme's assessable capital must equal what it is when the same holdings are -entered as one unitemised corporate_wealth. +Invariants: +1. share_like_capital is corporate_wealth as supplied (any sign, any fraction) + unless the itemised components add up to more than it by more than the + rounding tolerance, in which case it is their sum. +2. Differential: for identical inputs that carry no components, or components + consistent with corporate_wealth, every programme's assessable capital and + eligibility equal those under the original lists (corporate_wealth), + exactly, annually and monthly. +3. A household given only the components has them counted. """ +import numpy as np import pytest -from hypothesis import given, settings +from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st from policyengine_uk import CountryTaxBenefitSystem, Simulation +from policyengine_uk.variables.household.wealth.share_like_capital import ( + ABSOLUTE_TOLERANCE, + RELATIVE_TOLERANCE, +) COMPONENTS = ( "directly_held_shares", "unit_and_investment_trusts", "stocks_and_shares_isa", ) -SHARE_LIKE_SOURCES = (*COMPONENTS, "unitemised_corporate_wealth") SOURCE_LISTS = { "uc_assessable_capital": "gov.dwp.universal_credit.means_test.capital.sources", "housing_benefit_assessable_capital": "gov.dwp.housing_benefit.means_test.capital.sources", @@ -27,11 +35,14 @@ "esa_income_assessable_capital": "gov.dwp.ESA.income.capital.sources", "pension_credit_assessable_capital": "gov.dwp.pension_credit.income.capital.sources", } -AGE = {"pension_credit_assessable_capital": 70} YEAR = 2026 +SETTINGS = settings( + max_examples=25, + deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) system = CountryTaxBenefitSystem() -amount = st.integers(min_value=0, max_value=60_000) def _parameter(path): @@ -41,70 +52,130 @@ def _parameter(path): return node(f"{YEAR}-01-01") -def _capital(variable, household): - situation = { - "people": {"adult": {"age": {YEAR: AGE.get(variable, 30)}}}, - "benunits": {"benunit": {"members": ["adult"], "would_claim_uc": {YEAR: True}}}, - "households": { - "household": { - "members": ["adult"], - **{name: {YEAR: value} for name, value in household.items()}, - } - }, - } - return Simulation(situation=situation).calculate(variable, YEAR)[0] +ORIGINAL_LISTS = { + path: [ + "corporate_wealth" if source == "share_like_capital" else source + for source in _parameter(path) + ] + for path in SOURCE_LISTS.values() +} +ORIGINAL_REFORM = { + path: {str(YEAR): sources} for path, sources in ORIGINAL_LISTS.items() +} + +money = st.floats(min_value=0, max_value=200_000, allow_nan=False, width=32) +signed_money = st.floats(min_value=-5_000, max_value=200_000, allow_nan=False, width=32) + + +def _situation(households): + """One single-adult household per entry; ages alternate 30 and 70.""" + people, benunits, hhs = {}, {}, {} + for i, assets in enumerate(households): + people[f"p{i}"] = {"age": {YEAR: 70 if i % 2 else 30}} + benunits[f"b{i}"] = {"members": [f"p{i}"], "would_claim_uc": {YEAR: True}} + hhs[f"h{i}"] = { + "members": [f"p{i}"], + **{name: {YEAR: float(value)} for name, value in assets.items()}, + } + return {"people": people, "benunits": benunits, "households": hhs} + + +def _results(households, reform=None, period=YEAR): + sim = Simulation(situation=_situation(households), reform=reform) + out = {v: sim.calculate(v, period) for v in SOURCE_LISTS} + out["is_uc_eligible"] = sim.calculate("is_uc_eligible", period) + out["share_like_capital"] = sim.calculate("share_like_capital", YEAR) + return out + + +def _assert_same_as_original(households): + for period in (YEAR, f"{YEAR}-01"): + new = _results(households, period=period) + old = _results(households, reform=ORIGINAL_REFORM, period=period) + for variable in (*SOURCE_LISTS, "is_uc_eligible"): + assert np.array_equal(new[variable], old[variable]), (variable, period) @pytest.mark.parametrize("variable", SOURCE_LISTS) -def test_lists_name_the_components_not_corporate_wealth(variable): +def test_lists_name_share_like_capital_only(variable): sources = list(_parameter(SOURCE_LISTS[variable])) + assert "share_like_capital" in sources assert "corporate_wealth" not in sources - assert set(SHARE_LIKE_SOURCES) <= set(sources) + assert not set(COMPONENTS) & set(sources) -@settings(max_examples=40, deadline=None) +@SETTINGS @given( - savings=amount, - corporate_wealth=amount, - components=st.tuples(amount, amount, amount), + st.lists( + st.tuples(signed_money, st.tuples(money, money, money), st.booleans()), + min_size=1, + max_size=40, + ) ) -def test_unitemised_corporate_wealth_is_the_floored_remainder( - savings, corporate_wealth, components -): - household = {"savings": savings, "corporate_wealth": corporate_wealth} - household.update(dict(zip(COMPONENTS, components))) - situation = { - "people": {"adult": {"age": {YEAR: 30}}}, - "households": { - "household": { - "members": ["adult"], - **{name: {YEAR: value} for name, value in household.items()}, - } - }, - } - residual = Simulation(situation=situation).calculate( - "unitemised_corporate_wealth", YEAR - )[0] - assert residual == max(0, corporate_wealth - sum(components)) +def test_share_like_capital_keeps_the_supplied_aggregate(rows): + households = [] + for corporate_wealth, components, itemise in rows: + assets = {"corporate_wealth": corporate_wealth} + if itemise: + assets |= dict(zip(COMPONENTS, components)) + households.append(assets) + got = _results(households)["share_like_capital"] + for value, assets in zip(got, households): + cw = np.float32(assets["corporate_wealth"]) + itemised = np.float32(sum(np.float32(assets.get(c, 0)) for c in COMPONENTS)) + tolerance = RELATIVE_TOLERANCE * abs(cw) + ABSOLUTE_TOLERANCE + expected = itemised if itemised > 0 and itemised > cw + tolerance else cw + assert value == pytest.approx(expected, rel=1e-6, abs=1e-3) + if not (itemised > 0 and itemised > cw + tolerance): + assert value == cw # exactly the supplied aggregate -@pytest.mark.parametrize("variable", SOURCE_LISTS) -@settings(max_examples=15, deadline=None) +@SETTINGS @given( - savings=amount, - corporate_wealth=amount, - components=st.tuples(amount, amount, amount), -) -def test_itemising_never_changes_capital( - variable, savings, corporate_wealth, components -): - """Split holdings count exactly as max(corporate_wealth, their sum) does.""" - itemised = {"savings": savings, "corporate_wealth": corporate_wealth} - itemised.update(dict(zip(COMPONENTS, components))) - unitemised = { - "savings": savings, - "corporate_wealth": max(corporate_wealth, sum(components)), - } - assert _capital(variable, itemised) == pytest.approx( - _capital(variable, unitemised), abs=1e-2 + st.lists( + st.tuples(money, signed_money, st.tuples(money, money, money), st.booleans()), + min_size=1, + max_size=30, ) +) +def test_capital_matches_the_original_lists(rows): + """Unsplit, or split consistently: identical to listing corporate_wealth.""" + households = [] + for savings, corporate_wealth, components, split in rows: + assets = {"savings": savings, "corporate_wealth": corporate_wealth} + if split: + assets |= dict(zip(COMPONENTS, components)) + assets["corporate_wealth"] = float(sum(components)) + households.append(assets) + _assert_same_as_original(households) + + +def test_float32_rounding_at_the_limit_keeps_eligibility(): + """Components whose float32 sum overshoots £16,000 by a fraction of a penny.""" + households = [ + { + "savings": 0, + "corporate_wealth": 16_000, + "directly_held_shares": 6_744.81, + "unit_and_investment_trusts": 6_581.24, + "stocks_and_shares_isa": 2_673.95, + }, + {"savings": 6_001, "corporate_wealth": -2}, + {"savings": 15_999.99, "corporate_wealth": 0.01}, + ] + _assert_same_as_original(households) + assert _results(households)["share_like_capital"][0] == 16_000 + + +def test_components_alone_are_counted(): + households = [ + { + "savings": 1_000, + "directly_held_shares": 4_000, + "unit_and_investment_trusts": 3_000, + "stocks_and_shares_isa": 5_000, + } + ] + got = _results(households) + assert got["share_like_capital"][0] == 12_000 + assert got["uc_assessable_capital"][0] == 13_000 diff --git a/policyengine_uk/variables/household/wealth/share_like_capital.py b/policyengine_uk/variables/household/wealth/share_like_capital.py new file mode 100644 index 0000000000..967be954db --- /dev/null +++ b/policyengine_uk/variables/household/wealth/share_like_capital.py @@ -0,0 +1,43 @@ +from policyengine_uk.model_api import * + +# Itemised holdings within this much of corporate_wealth are the same holdings +# rounded differently (float32 sums of the components), not extra wealth. +RELATIVE_TOLERANCE = 1e-5 +ABSOLUTE_TOLERANCE = 0.01 + + +class share_like_capital(Variable): + label = "share-like capital" + documentation = ( + "Share-like holdings counted by the means-test capital tests: " + "corporate_wealth as supplied, unless its itemised components " + "(directly_held_shares, unit_and_investment_trusts and " + "stocks_and_shares_isa) add up to more than it, in which case their sum. " + "So datasets that carry only corporate_wealth, or build it as the " + "components' sum, count exactly corporate_wealth (any sign), and a " + "household given only the components has them counted. A gap within " + "a hundred-thousandth of corporate_wealth plus a penny is treated as " + "rounding, so the supplied aggregate is kept. Private pension wealth is " + "not part of it; datasets built before private pension wealth was " + "split out of corporate_wealth still carry it here." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = STOCK + + def formula(household, period, parameters): + corporate_wealth = household("corporate_wealth", period) + itemised = add( + household, + period, + [ + "directly_held_shares", + "unit_and_investment_trusts", + "stocks_and_shares_isa", + ], + ) + tolerance = RELATIVE_TOLERANCE * abs(corporate_wealth) + ABSOLUTE_TOLERANCE + exceeds = (itemised > 0) & (itemised > corporate_wealth + tolerance) + return where(exceeds, itemised, corporate_wealth) diff --git a/policyengine_uk/variables/household/wealth/unitemised_corporate_wealth.py b/policyengine_uk/variables/household/wealth/unitemised_corporate_wealth.py deleted file mode 100644 index 1d7fcd56a1..0000000000 --- a/policyengine_uk/variables/household/wealth/unitemised_corporate_wealth.py +++ /dev/null @@ -1,34 +0,0 @@ -from policyengine_uk.model_api import * - - -class unitemised_corporate_wealth(Variable): - label = "corporate wealth not itemised into components" - documentation = ( - "The part of corporate_wealth not carried in its itemised components " - "directly_held_shares, unit_and_investment_trusts and " - "stocks_and_shares_isa, floored at nil. On datasets without the " - "components it equals corporate_wealth (including the private pension " - "wealth that datasets built before that split fold into it); on datasets " - "that build corporate_wealth as the exact sum of the components it is " - "about nil. The means-test capital sources list the components and this " - "residual instead of corporate_wealth, so each itemised asset can carry " - "its own valuation rule while datasets without the split keep counting " - "corporate_wealth as before, and no holding is counted twice." - ) - entity = Household - definition_period = YEAR - value_type = float - unit = GBP - quantity_type = STOCK - - def formula(household, period, parameters): - itemised = add( - household, - period, - [ - "directly_held_shares", - "unit_and_investment_trusts", - "stocks_and_shares_isa", - ], - ) - return max_(0, household("corporate_wealth", period) - itemised)