diff --git a/changelog.d/wealth-share-components.added.md b/changelog.d/wealth-share-components.added.md new file mode 100644 index 0000000000..a161d13f25 --- /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`, 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/docs/book/assumptions/growthfactors.md b/docs/book/assumptions/growthfactors.md index 5871432b7b..a7585e29af 100644 --- a/docs/book/assumptions/growthfactors.md +++ b/docs/book/assumptions/growthfactors.md @@ -62,7 +62,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`, `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`, `property_rental_income`, `savings`, `savings_interest_income`, `sublet_income` +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`, `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`, `stocks_and_shares_isa`, `sublet_income`, `uc_reported_capital`, `unit_and_investment_trusts` ## Council tax diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index 7cebef7f9a..942d6e74b2 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -93,7 +93,9 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - capital_gains_residential_property - capital_gains_carried_interest - benunit_reported_capital +- cash_isa - corporate_wealth +- directly_held_shares - dividend_income - gross_financial_wealth - household_lifetime_isa_balance @@ -118,10 +120,12 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - property_rental_income - savings - savings_interest_income +- stocks_and_shares_isa - sublet_income # A formula of benunit_reported_capital; listed so a dataset that stores it # directly is uprated like the input. - uc_reported_capital +- 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/parameters/gov/dwp/ESA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml index 3eb35b83ef..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,21 +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, `corporate_wealth` as the broad available proxy for share-like - financial assets, `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 - 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 @@ -33,6 +37,6 @@ values: 2008-10-27: - savings - owned_land - - 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 6484484ab8..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,22 +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, `corporate_wealth` as the broad available proxy for share-like - financial assets, `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 - 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 @@ -34,6 +38,6 @@ values: 1996-10-07: - savings - owned_land - - 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 e313576158..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,24 +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, - `corporate_wealth` as the broad available proxy for share-like financial - assets such as stocks, shares, unit trusts, and investment ISAs, - `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 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 that countable bucket. 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 @@ -34,14 +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 - - 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 5b28814633..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,6 +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. + `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 @@ -22,6 +26,6 @@ values: 2010-01-01: - savings - owned_land - - 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 0761c1b0f3..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. + (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,4 +29,4 @@ values: - owned_land - other_residential_property_value - non_residential_property_value - - 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 0909fa260e..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,29 +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 - `corporate_wealth` for shares and similar investment assets. `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 - 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. + home, `non_residential_property_value` for other land and premises, and the + 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 @@ -46,4 +52,4 @@ values: - savings - other_residential_property_value - non_residential_property_value - - 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 new file mode 100644 index 0000000000..3194e95198 --- /dev/null +++ b/policyengine_uk/tests/test_capital_share_components.py @@ -0,0 +1,181 @@ +"""share_like_capital replaces corporate_wealth in the means-test capital lists. + +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 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", +) +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", +} +YEAR = 2026 +SETTINGS = settings( + max_examples=25, + deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +system = CountryTaxBenefitSystem() + + +def _parameter(path): + node = system.parameters + for part in path.split("."): + node = getattr(node, part) + return node(f"{YEAR}-01-01") + + +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_share_like_capital_only(variable): + sources = list(_parameter(SOURCE_LISTS[variable])) + assert "share_like_capital" in sources + assert "corporate_wealth" not in sources + assert not set(COMPONENTS) & set(sources) + + +@SETTINGS +@given( + st.lists( + st.tuples(signed_money, st.tuples(money, money, money), st.booleans()), + min_size=1, + max_size=40, + ) +) +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 + + +@SETTINGS +@given( + 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/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/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/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..5fe9fd079f --- /dev/null +++ b/policyengine_uk/variables/input/directly_held_shares.py @@ -0,0 +1,25 @@ +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 (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 + 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..2352d63f49 --- /dev/null +++ b/policyengine_uk/variables/input/unit_and_investment_trusts.py @@ -0,0 +1,23 @@ +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 " + "(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 + value_type = float + unit = GBP + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" + quantity_type = STOCK + default_value = 0