diff --git a/changelog.d/wealth-share-components.added.md b/changelog.d/wealth-share-components.added.md new file mode 100644 index 0000000000..4a97f30c03 --- /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 the components exceed it beyond float32 rounding, their sum (`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 3e8b4e1700..663b17a901 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 e82333b1a6..77e960f1fc 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -111,7 +111,9 @@ gov.economic_assumptions.yoy_growth.capital_gains: - capital_gains_carried_interest gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - benunit_reported_capital +- cash_isa - corporate_wealth +- directly_held_shares - dividend_income - gross_financial_wealth - household_lifetime_isa_balance @@ -138,6 +140,7 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - property_rental_income - savings - savings_interest_income +- stocks_and_shares_isa - sublet_income - cash_isa - consumer_debt @@ -151,6 +154,7 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: # An explicit complete UC override stored in a dataset is uprated like the # financial input; its default no longer derives from that input. - uc_reported_capital +- unit_and_investment_trusts gov.economic_assumptions.yoy_growth.obr.per_capita.mixed_income: - self_employment_income - self_employment_gross_receipts 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..c9fd7d6e3f 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,26 @@ 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 they exceed it beyond float32 rounding (see + `share_like_capital`), 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 +38,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..ab257cc8ce 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 they exceed it beyond float32 rounding (see + `share_like_capital`), 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..3bc1850dce 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,28 @@ 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. + 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 they exceed it + beyond float32 rounding (see `share_like_capital`), 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. `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. + 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 + `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 +38,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..f2e8658444 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,11 @@ 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 they exceed it beyond float32 rounding (see + `share_like_capital`), 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 +27,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..93343e1586 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 they exceed it + beyond float32 rounding (see `share_like_capital`), 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 e9d973704b..e94238bb32 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,32 +1,36 @@ description: > - Household financial proxies for Universal Credit: savings and corporate - financial wealth. A nonnegative benunit_reported_capital replaces these - financial proxies and covered person-level sources, including a Lifetime ISA. - Each benefit unit's complete uc_reported_capital override, otherwise its - observed financial total, reduces this financial pool. Subtracting complete - overrides retains the inherited allocation convention. An override precedes - regulation 77 company substitution and includes its claimant's and partner's - holdings: consume at least their disregarded holdings after applying one - household corporate_wealth budget without removing them again from the pool. - First clip each person's holding to [0, max(0, corporate_wealth)], with - positive infinity mapped to the upper bound and negative infinity to zero. - If total bounded holdings exceed nonnegative corporate_wealth, scale each - person's bounded holding proportionally before override floors and separate - pool deductions. - This is a defensive rule for inconsistent inputs, not a statutory determination; - regulation 77 substitution in the owner's own assessment is unchanged. - The residual is allocated by - claimant/partner counts to benefit units with neither a financial observation - nor a complete override, - including units that do not receive UC. This allocation is an assumption - about unobserved ownership, not a statutory rule. Countable property is - valued and shared independently through property_sources across ALL - claimant/partner adults, with complete-override units consuming their shares. - Main-home property and the named private_pension_wealth input are excluded - under Schedule 10 paragraphs 1 and 10. For missing-report households, the - legacy corporate_wealth proxy can still contain non-DB pension wealth that - is not separately identified and removed. Asset-specific Schedule 10 disregards - within these broad financial classes are not separately observed. + Household financial proxies for Universal Credit: savings and share-like + financial wealth. `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 they exceed it + beyond float32 rounding, so a household given only the components has them + counted and no holding counts twice. A nonnegative benunit_reported_capital + replaces these financial proxies and covered person-level sources, including + a Lifetime ISA. Each benefit unit's complete uc_reported_capital override, + otherwise its observed financial total, reduces this financial pool. + Subtracting complete overrides retains the inherited allocation convention. + An override precedes regulation 77 company substitution and includes its + claimant's and partner's holdings: consume at least their disregarded + holdings after applying one household share_like_capital budget without + removing them again from the pool. First clip each person's holding to [0, + max(0, share_like_capital)], with positive infinity mapped to the upper + bound and negative infinity to zero. If total bounded holdings exceed + nonnegative share_like_capital, scale each person's bounded holding + proportionally before override floors and separate pool deductions. This is + a defensive rule for inconsistent inputs, not a statutory determination; + regulation 77 substitution in the owner's own assessment is unchanged. The + residual is allocated by claimant/partner counts to benefit units with + neither a financial observation nor a complete override, including units + that do not receive UC. This allocation is an assumption about unobserved + ownership, not a statutory rule. Countable property is valued and shared + independently through property_sources across ALL claimant/partner adults, + with complete-override units consuming their shares. Main-home property and + the named private_pension_wealth input are excluded under Schedule 10 + paragraphs 1 and 10. For missing-report households, the legacy + corporate_wealth proxy, and so share_like_capital, can still contain non-DB + pension wealth that is not separately identified and removed. Asset-specific + Schedule 10 disregards within these broad financial classes are not + separately observed. metadata: label: Universal Credit financial capital proxy sources period: year @@ -41,4 +45,4 @@ metadata: values: 2013-04-29: - savings - - 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..608aa1e94d --- /dev/null +++ b/policyengine_uk/tests/test_capital_share_components.py @@ -0,0 +1,285 @@ +"""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 are positive and exceed it by more than the + float32 rounding tolerance, in which case it is exactly their sum. +2. Differential: for identical inputs that carry no components, or components + consistent with corporate_wealth, every programme's assessable capital, and + UC eligibility, equal those under the original lists (corporate_wealth), + exactly, annually and monthly. +3. A household given only the components has them counted, down to a penny. +4. A disregarded company holding (UC reg 77(2)) is budgeted against, and + leaves the UC financial pool from, share_like_capital, whether it was + entered within corporate_wealth or as an itemised component. +""" + +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 ( + 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 _node(path): + node = system.parameters + for part in path.split("."): + node = getattr(node, part) + return node + + +def _parameter(path): + return _node(path)(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: {f"{YEAR}-01-01.{YEAR + 4}-12-31": sources} + for path, sources in ORIGINAL_LISTS.items() +} + +# Pounds and pence, as a dataset stores them in float64; the engine rounds +# each to float32, which is the rounding the tolerance has to absorb. +money = st.integers(min_value=0, max_value=20_000_000).map(lambda pence: pence / 100) +signed_money = st.integers(min_value=-500_000, max_value=20_000_000).map( + lambda pence: pence / 100 +) + + +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): + # A projected year as well, where the inputs are uprated. + for period in (YEAR, f"{YEAR}-01", YEAR + 1, f"{YEAR + 1}-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) + # Where a programme lists the sources whose sale incurs expenses, it names + # the same share-like variable, or shares would silently lose the + # deduction. + node = _node(SOURCE_LISTS[variable].rsplit(".", 1)[0]) + if "sale_expenses" in node.children: + sale = list(node.sale_expenses.sources(f"{YEAR}-01-01")) + assert not {"corporate_wealth", *COMPONENTS} & set(sale) + + +@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(0) + for component in COMPONENTS: + itemised = np.float32(itemised + np.float32(assets.get(component, 0))) + tolerance = RELATIVE_TOLERANCE * max(abs(cw), itemised) + if itemised > 0 and itemised - cw > tolerance: + assert value == itemised # exactly the components' sum + else: + 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_one_unit_of_float32_rounding_above_a_power_of_two_keeps_the_aggregate(): + """Pence-valued components whose float32 sum lands one unit in the last place + (£0.00195) above £16,384.01: more than 1e-7 of the value, less than 1e-6.""" + households = [ + { + "savings": 0, + "corporate_wealth": 16_384.01, + "directly_held_shares": 16_015.98, + "unit_and_investment_trusts": 286.98, + "stocks_and_shares_isa": 81.05, + } + ] + itemised = np.float32( + np.float32(np.float32(16_015.98) + np.float32(286.98)) + np.float32(81.05) + ) + assert itemised - np.float32(16_384.01) == np.float32(0.001953125) + assert _results(households)["share_like_capital"][0] == np.float32(16_384.01) + _assert_same_as_original(households) + + +def test_a_component_penny_at_the_limit_counts(): + """A penny of shares with no corporate_wealth counts like a penny of it.""" + as_component = [{"savings": 16_000, "directly_held_shares": 0.01}] + as_aggregate = [{"savings": 16_000, "corporate_wealth": 0.01}] + for period in (YEAR, f"{YEAR}-01"): + component = _results(as_component, period=period) + aggregate = _results(as_aggregate, period=period) + assert component["share_like_capital"][0] == np.float32(0.01) + for variable in (*SOURCE_LISTS, "is_uc_eligible"): + assert np.array_equal(component[variable], aggregate[variable]), variable + assert not component["is_uc_eligible"][0] + + +def test_a_genuine_excess_over_the_aggregate_counts(): + """Ten pence of components above the aggregate is a holding, not rounding.""" + households = [ + { + "corporate_wealth": 16_000, + "directly_held_shares": 10_000.10, + "stocks_and_shares_isa": 6_000, + } + ] + assert _results(households)["share_like_capital"][0] == np.float32(16_000.10) + + +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 + + +def test_a_disregarded_company_holding_leaves_the_capital_that_counts_it(): + """UC reg 77(2): a holding entered as an itemised component leaves the + pool as one entered within corporate_wealth does. Two unreported adults + share £10,000 of savings once the owner's £30,000 holding is disregarded.""" + from policyengine_uk.variables.gov.dwp.universal_credit.household_uc_company_holding_pool_scale import ( + HOLDINGS_SOURCE, + ) + + assert HOLDINGS_SOURCE in _parameter(SOURCE_LISTS["uc_assessable_capital"]) + for shares in ({"corporate_wealth": 30_000}, {"directly_held_shares": 30_000}): + situation = { + "people": { + "owner": { + "age": {YEAR: 40}, + "stands_as_sole_owner_or_partner_of_company": {YEAR: True}, + "owned_company_carries_on_trade": {YEAR: True}, + "owned_company_holding_value": {YEAR: 30_000}, + }, + "lodger": {"age": {YEAR: 30}}, + }, + "benunits": { + "owner_unit": {"members": ["owner"]}, + "lodger_unit": {"members": ["lodger"]}, + }, + "households": { + "household": { + "members": ["owner", "lodger"], + "savings": {YEAR: 10_000}, + **{name: {YEAR: value} for name, value in shares.items()}, + } + }, + } + sim = Simulation(situation=situation) + assert sim.calculate("household_uc_company_holding_pool_scale", YEAR)[0] == 1 + assert sim.calculate("household_uc_pool_company_holdings", YEAR)[0] == 30_000 + assert list(sim.calculate("uc_assessable_capital", YEAR)) == [5_000, 5_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..489d6ac0f8 --- /dev/null +++ b/policyengine_uk/tests/test_wealth_components.py @@ -0,0 +1,47 @@ +"""corporate_wealth's components share its entity, quantity type and uprating +metadata (class uprating and uprating_indices.yaml index), the condition for a +dataset's identity corporate_wealth == sum of components to survive 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/gov/dwp/pension_credit/pension_credit_reported_capital.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py index 536c490645..58fcbcf789 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py @@ -13,7 +13,7 @@ class pension_credit_reported_capital(Variable): "the benefit unit's recorded capital (benunit_reported_capital) unless " "set directly. When this is 0 or more it replaces every capital source " "in Pension Credit assessable capital: the household proxy (savings, " - "land, property and corporate_wealth) and the person-level sources such " + "land, property and share_like_capital) and the person-level sources such " "as a Lifetime ISA. The model applies neither the Schedule V disregards " "nor the reg. 19 valuation to it, so it must already be the countable " "figure, and reforms to the capital source parameters do not reach a " diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_company_holding_pool_scale.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_company_holding_pool_scale.py index 79a152ab34..c208ec85f1 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_company_holding_pool_scale.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_company_holding_pool_scale.py @@ -1,5 +1,9 @@ from policyengine_uk.model_api import * +# The capital source that holds a disregarded company holding: the share-like +# capital the UC financial pool counts, so holdings are budgeted against it. +HOLDINGS_SOURCE = "share_like_capital" + class household_uc_company_holding_pool_scale(Variable): value_type = float @@ -8,10 +12,11 @@ class household_uc_company_holding_pool_scale(Variable): documentation = ( "Common proportional scale for disregarded company holdings when " "deducting them from the household financial pool. Before budgeting, " - "clip each person's holding to [0, max(0, corporate_wealth)], mapping " + "clip each person's holding to [0, max(0, share_like_capital)], mapping " "positive infinity to the upper bound and negative infinity to zero. " "Total bounded holdings " - "share one budget of nonnegative corporate_wealth across override " + "share one budget of nonnegative share_like_capital (the share-like " + "capital the pool counts) across override " "floors and separate pool deductions. This allocation is independent " "of row order and treats holders equally. It is a defensive rule for " "inconsistent inputs, not a statutory determination. It does not " @@ -21,16 +26,16 @@ class household_uc_company_holding_pool_scale(Variable): unit = "/1" def formula(household, period, parameters): - corporate_wealth = max_(0, household("corporate_wealth", period)) + share_like = max_(0, household(HOLDINGS_SOURCE, period)) person = household.members holding = person("uc_company_holding_disregard", period) - upper_bound = max_(0, person.household("corporate_wealth", period)) + upper_bound = max_(0, person.household(HOLDINGS_SOURCE, period)) finite_holding = np.where( np.isfinite(holding), holding, np.where(holding > 0, upper_bound, 0) ) holdings = household.sum(np.clip(finite_holding, 0, upper_bound)) return where( holdings > 0, - min_(1, corporate_wealth / where(holdings > 0, holdings, 1)), + min_(1, share_like / where(holdings > 0, holdings, 1)), 1, ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py index a59ed03df3..fe96f39a8f 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.universal_credit.household_uc_company_holding_pool_scale import ( + HOLDINGS_SOURCE, +) class household_uc_pool_company_holdings(Variable): @@ -18,18 +21,18 @@ class household_uc_pool_company_holdings(Variable): "company substitution, so it includes its claimant's and partner's " "holdings. household_uc_reported_financial_capital consumes the override " "or, if larger, their effective holdings after the shared household " - "corporate-wealth budget is applied. Removing " + "share-like capital budget is applied. Removing " "those holdings again here would count the same disregard twice. " "All other holdings leave the pool here, including a dependant's " "holding even when their benunit supplies a report or override. " "When financial reports and holdings are compatible, no benunit counts " "any part of a disregarded holding. An inconsistent financial report " "that omits a known holding can leave it in the residual pool. A holding " - "is shares, held within the household's corporate_wealth. Each " - "person's holding is first clipped to [0, max(0, corporate_wealth)], " + "is shares, held within the household's share_like_capital. Each " + "person's holding is first clipped to [0, max(0, share_like_capital)], " "with positive infinity mapped to the upper bound and negative infinity " "to zero before any multiplication. All household holdings then share " - "one nonnegative corporate_wealth budget " + "one nonnegative share_like_capital budget " "through household_uc_company_holding_pool_scale: when their total " "exceeds it, scale each person's holding proportionally before " "either the override floor or this deduction. This is a defensive " @@ -52,7 +55,7 @@ def formula(household, period, parameters): has_financial_report | has_complete_override ) & claimant_or_partner holding = person("uc_company_holding_disregard", period) - upper_bound = max_(0, person.household("corporate_wealth", period)) + upper_bound = max_(0, person.household(HOLDINGS_SOURCE, period)) finite_holding = np.where( np.isfinite(holding), holding, np.where(holding > 0, upper_bound, 0) ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py index 19deb365f2..eaaae275f0 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.universal_credit.household_uc_company_holding_pool_scale import ( + HOLDINGS_SOURCE, +) class household_uc_reported_financial_capital(Variable): @@ -14,10 +17,10 @@ class household_uc_reported_financial_capital(Variable): "so it includes its claimant's and partner's disregarded holdings. " "Consume the larger of the override and those holdings after applying " "household_uc_company_holding_pool_scale. Each person's holding is " - "first clipped to [0, max(0, corporate_wealth)], with positive infinity " + "first clipped to [0, max(0, share_like_capital)], with positive infinity " "mapped to the upper bound and negative infinity to zero before any " "multiplication. All household holdings share " - "one nonnegative corporate_wealth budget: if their total exceeds it, " + "one nonnegative share_like_capital budget: if their total exceeds it, " "scale each person's holding proportionally before either this " "override floor or the separate pool deduction. This is a defensive " "rule for inconsistent inputs, not a statutory determination. " @@ -38,7 +41,7 @@ def formula(household, period, parameters): financial_capital = person.benunit("benunit_reported_capital", period) complete_override = person.benunit("uc_reported_capital", period) holding = person("uc_company_holding_disregard", period) - upper_bound = max_(0, person.household("corporate_wealth", period)) + upper_bound = max_(0, person.household(HOLDINGS_SOURCE, period)) finite_holding = np.where( np.isfinite(holding), holding, np.where(holding > 0, upper_bound, 0) ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py index b508828d55..c59e6dfb3a 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py @@ -22,7 +22,7 @@ class uc_reported_capital(Variable): "override can change a missing-report unit's capital. The complete " "override consumes its amount, or its claimant's and partner's " "disregarded company holdings after their proportional share of the " - "household corporate_wealth budget if larger, " + "household share_like_capital budget if larger, " "from the financial pool, taking " "precedence over the same unit's financial report. " "This variable does not derive from the financial-only FRS total. " 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..96c6eabb72 --- /dev/null +++ b/policyengine_uk/variables/household/wealth/share_like_capital.py @@ -0,0 +1,55 @@ +from policyengine_uk.model_api import * + +# Itemised holdings within this share of the larger of corporate_wealth and +# their sum are the same holdings rounded differently, not extra wealth. +# Simulations hold each value as float32 (24-bit significand, spacing 2^-23 of +# the value): a dataset that builds corporate_wealth as the float64 sum of +# three components and stores all four rounds each once, and the engine adds +# the three components in float32, so the two totals differ by at most about +# 2.5 units in the last place, 3e-7 of the value. 1e-6 leaves a margin of more +# than three times that and still counts any genuine excess above a millionth. +# The bound assumes the dataset stores float64 tables, as policyengine-uk-data +# and microcosm do; float32 tables would add a rounding per side for each year +# a dataset is projected forward. +RELATIVE_TOLERANCE = 1e-6 + + +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) are positive and add up to more than it by more " + "than a millionth of the larger of the two, in which case their sum. " + "Datasets that carry only corporate_wealth, or build it as the " + "components' sum, therefore count exactly corporate_wealth (any sign): " + "the millionth absorbs float32 rounding of the sum. A household given " + "only components, with no corporate_wealth, has them counted in full. " + "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 * max_(abs(corporate_wealth), itemised) + # Compare the difference, which float32 computes exactly for close + # values, rather than adding the tolerance to corporate_wealth, which + # would round it up to a whole unit in the last place. + exceeds = (itemised > 0) & (itemised - corporate_wealth > tolerance) + return where(exceeds, itemised, corporate_wealth) diff --git a/policyengine_uk/variables/household/wealth/stocks_and_shares_isa.py b/policyengine_uk/variables/household/wealth/stocks_and_shares_isa.py index 5c9b4d6ccb..caa743d61e 100644 --- a/policyengine_uk/variables/household/wealth/stocks_and_shares_isa.py +++ b/policyengine_uk/variables/household/wealth/stocks_and_shares_isa.py @@ -4,7 +4,8 @@ class stocks_and_shares_isa(Variable): label = "stocks and shares ISA holdings" documentation = ( - "Amount held in stocks and shares (investment) Individual Savings Accounts" + "Amount held in stocks and shares (investment) Individual Savings " + "Accounts. Part of corporate_wealth, so the two must never be summed." ) entity = Household definition_period = YEAR 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/owned_company/owned_company_holding_value.py b/policyengine_uk/variables/input/owned_company/owned_company_holding_value.py index 2624e2cc7e..bf9700e36e 100644 --- a/policyengine_uk/variables/input/owned_company/owned_company_holding_value.py +++ b/policyengine_uk/variables/input/owned_company/owned_company_holding_value.py @@ -8,8 +8,9 @@ class owned_company_holding_value(Variable): documentation = ( "The value of the person's holding (their shares) in the company in " "which they stand as sole owner or partner, to the extent that it is " - "already counted in the household's capital (for example within " - "corporate_wealth) or in uc_reported_capital." + "already counted in the household's capital (within its share-like " + "capital: corporate_wealth or its itemised components) or in " + "uc_reported_capital." ) definition_period = YEAR unit = GBP 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