diff --git a/changelog.d/2729.fixed.md b/changelog.d/2729.fixed.md new file mode 100644 index 0000000000..65c113faa5 --- /dev/null +++ b/changelog.d/2729.fixed.md @@ -0,0 +1 @@ +The model now counts each person's cash ISA and stocks and shares ISA balances in their own benefit unit for Universal Credit, Housing Benefit, Pension Credit, Income Support, income-based JSA and income-related ESA, so a lodger's or an adult child's ISA no longer adds to the main family's capital. diff --git a/docs/book/assumptions/isa-inputs-and-capital-flows.md b/docs/book/assumptions/isa-inputs-and-capital-flows.md index ce1d7c2c18..c6e0836479 100644 --- a/docs/book/assumptions/isa-inputs-and-capital-flows.md +++ b/docs/book/assumptions/isa-inputs-and-capital-flows.md @@ -107,9 +107,33 @@ respectively, including years after the dataset projection ends, unless the dataset holds the person balances for a later year than the total, or a reform changes what the total sums or stops a person balance reading its inputs. A household input in a situation applies only to its own year; the person -balances carry the variable-level uprating to later years. The means tests read -the household totals, so they pool a person's balance with the household's -other financial capital rather than attribute it to their own benefit unit. +balances carry the variable-level uprating to later years. + +Each means-test formula counts the claimant's and partner's cash and stocks and +shares ISA balances in their own benefit unit, through +`cash_isa_countable_capital` and `stocks_and_shares_isa_countable_capital` in +its `person_sources`, and excludes a dependant's balance, a child's junior ISA +among them. The formulas subtract a household's counted balances from `cash_isa` +and `share_like_capital` before they allocate the remainder between benefit +units. Where the balances sum to less than the total the model holds for the +year, the remainder stays in the pool. Where their stored sum exceeds the total, +the model scales them to sum to the total exactly: it rounds each proportional +share down to a whole step of the total's float32 spacing and gives the steps +left over, one each, to the members whose shares lost most in rounding. The pool +then keeps nothing of that total; for totals under £268 million, each holding +passes a capital limit only where its proportional share does; and a sole holder +whose balance reaches the total counts exactly the total. For stocks and shares +ISAs the model takes the smaller of `stocks_and_shares_isa` and +`share_like_capital` as the total. Data that store only household totals hold no +balances, so the pool keeps the whole total. The formulas count a balance only +while the programme's `sources` lists the household source that includes it, so +a reform that drops `cash_isa` or `share_like_capital` from `sources` drops the +balances with it. For a Universal Credit unit that reports its capital +(`benunit_reported_capital` or `uc_reported_capital`, zero or more), the formula +leaves the unit's claimant's and partner's ISAs in the pool and subtracts the +report, as it does for their company holdings. A nonnegative +`pension_credit_reported_capital` replaces the unit's calculated Pension Credit +capital. Ordinary accessible cash ISA balances enter the capital sources for Universal Credit, Housing Benefit, Pension Credit, Income Support, income-based JSA and diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml index 3addab21fc..b3c563348f 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/person_sources.yaml @@ -1,14 +1,26 @@ description: > Person-level model variables counted as income-related Employment and - Support Allowance capital of the person's own benefit unit. They are summed - over the benefit unit's claimant and partner (`is_claimant_or_partner`), so - they need none of the household proxy that the household `sources` use, and - a dependant's holding is left out: regulation 83 adds only the partner's - capital to the claimant's. `lifetime_isa_countable_capital` is a Lifetime - ISA at its surrender value: regulation 113 values capital at its current - market or surrender value, and DMG 52665 counts a Lifetime ISA at 75% of its - value under 60 and in full from 60. Lifetime ISAs exist from 6 April 2017, - and the list is empty before then. + Support Allowance capital of the person's own benefit unit. The formulas sum + these sources over the benefit unit's claimant and partner + (`is_claimant_or_partner`) and exclude dependants: DWP excludes children's + capital under regulation 83(2). `lifetime_isa_countable_capital` values a + Lifetime ISA at its surrender value: DWP values capital at its current + market or surrender value under regulation 113, and DMG 52665 counts a + Lifetime ISA at 75% of its value under 60 and in full from 60. Lifetime ISAs + exist from 6 April 2017, and the formulas count them from then. + `cash_isa_countable_capital` and `stocks_and_shares_isa_countable_capital` + hold a member's cash and stocks and shares ISA balances, bounded by the + household total. The account investor beneficially owns an ordinary ISA's + investments, and the named child owns a junior ISA's investments (Individual + Savings Account Regulations 1998 reg. 4(6)(a) and reg. 2B(c)). The formulas + subtract each bounded ISA balance from its household source (`cash_isa`, + `share_like_capital`) and count it in the holder's unit. When a household's + balances exceed its total, the model scales them to sum to the total + exactly, rounding each share down or up to a whole float32 step of the + total. When a reform removes a household source from `sources`, the formulas + exclude the corresponding ISA person source. With household totals alone, + the formulas leave the whole total in the pool. The ISA Regulations came + into force for all other purposes on 6 April 1999 (reg. 1). metadata: label: Income-related ESA person-level capital sources period: year @@ -20,7 +32,15 @@ metadata: href: https://www.legislation.gov.uk/uksi/2008/794/regulation/113 - title: Decision Makers' Guide, Chapter 52 (Capital), para. 52665 href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf + - title: The Individual Savings Account Regulations 1998 reg. 4 + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/4 + - title: The Individual Savings Account Regulations 1998 reg. 2B + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/2B values: - 2008-10-27: [] + 2008-10-27: + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_capital 2017-04-06: - lifetime_isa_countable_capital + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_capital 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 5892c52c74..8665590684 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml @@ -1,6 +1,10 @@ description: > - Ordinary cash ISA balances are separate from savings and are included at - their accessible cash value, without a generic cash sale-cost deduction. + The formulas count ordinary accessible cash ISAs separately from savings, + at their cash value, without a generic cash sale-cost deduction. + Where the data tie a cash ISA or stocks and shares ISA balance to a member, + the formulas count it through `person_sources` in the holder's own benefit + unit and subtract it from `cash_isa` or `share_like_capital` before they + allocate the rest. Model variables currently mapped to countable income-related Employment and Support Allowance capital. The Employment and Support Allowance Regulations 2008 treat capital as assessable unless it is disregarded under Schedule 9. @@ -19,11 +23,11 @@ description: > 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 + dataset does not isolate those states or asset holdings. For capital whose + owner the data do not identify, the model allocates the full household + remainder to benunits with a reported income-related ESA award and falls + back to claimant and partner shares when nobody in the household reports + such an award. That allocation is a data approximation, not a legislated ownership rule. metadata: label: Income-related ESA countable capital sources diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml index ff5a7f2459..fdbd6f8d02 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/person_sources.yaml @@ -1,15 +1,27 @@ description: > Person-level model variables counted as income-based Jobseeker's Allowance - capital of the person's own benefit unit. They are summed over the benefit - unit's claimant and partner (`is_claimant_or_partner`), so they need none of - the household proxy that the household `sources` use, and a dependant's - holding is left out: regulation 109 kept a child's or young person's capital - out of the claimant's, and since children moved to child tax credit they are - outside the assessment altogether. `lifetime_isa_countable_capital` is a - Lifetime ISA at its surrender value: regulation 111 values capital at its - current market or surrender value, and DMG 29665 counts a Lifetime ISA at - 75% of its value under 60 and in full from 60. Lifetime ISAs exist from 6 - April 2017, and the list is empty before then. + capital of the person's own benefit unit. The formulas sum these sources + over the benefit unit's claimant and partner (`is_claimant_or_partner`) and + exclude dependants: DWP excludes children's capital under regulation 109 + until SI 2003/455 applies, and regulation 88(2) from then. + `lifetime_isa_countable_capital` values a Lifetime ISA at its surrender + value: DWP values capital at its current market or surrender value under + regulation 111, and DMG 29665 counts a Lifetime ISA at 75% of its value + under 60 and in full from 60. Lifetime ISAs exist from 6 April 2017, and the + formulas count them from then. `cash_isa_countable_capital` and + `stocks_and_shares_isa_countable_capital` hold a member's cash and stocks + and shares ISA balances, bounded by the household total. The account + investor beneficially owns an ordinary ISA's investments, and the named + child owns a junior ISA's investments (Individual Savings Account + Regulations 1998 reg. 4(6)(a) and reg. 2B(c)). The formulas subtract each + bounded ISA balance from its household source (`cash_isa`, + `share_like_capital`) and count it in the holder's unit. When a household's + balances exceed its total, the model scales them to sum to the total + exactly, rounding each share down or up to a whole float32 step of the + total. When a reform removes a household source from `sources`, the formulas + exclude the corresponding ISA person source. With household totals alone, + the formulas leave the whole total in the pool. The ISA Regulations came + into force for all other purposes on 6 April 1999 (reg. 1). metadata: label: Income-based JSA person-level capital sources period: year @@ -21,7 +33,16 @@ metadata: href: https://www.legislation.gov.uk/uksi/1996/207/regulation/111 - title: Decision Makers' Guide, Chapter 29 (Capital), para. 29665 href: https://assets.publishing.service.gov.uk/media/6894a69d3080e72710b2e237/dmg-ch-29.pdf + - title: The Individual Savings Account Regulations 1998 reg. 4 + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/4 + - title: The Individual Savings Account Regulations 1998 reg. 2B + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/2B values: 1996-10-07: [] + 1999-04-06: + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_capital 2017-04-06: - lifetime_isa_countable_capital + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_capital 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 163e3aa1a9..d30ee0a503 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml @@ -1,6 +1,10 @@ description: > - Ordinary cash ISA balances are separate from savings and are included at - their accessible cash value, without a generic cash sale-cost deduction. + The formulas count ordinary accessible cash ISAs separately from savings, + at their cash value, without a generic cash sale-cost deduction. + Where the data tie a cash ISA or stocks and shares ISA balance to a member, + the formulas count it through `person_sources` in the holder's own benefit + unit and subtract it from `cash_isa` or `share_like_capital` before they + allocate the rest. Model variables currently mapped to countable income-based Jobseeker's Allowance capital. The Jobseeker's Allowance Regulations 1996 treat capital as assessable unless it is disregarded under Schedule 8. The current @@ -20,10 +24,10 @@ description: > 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 + states or asset holdings. For capital whose owner the data do not identify, + the model allocates the full household remainder to benunits with a + reported income-based JSA award and falls back to claimant and partner + shares when nobody in the household reports such an award. That allocation is a data approximation, not a legislated ownership rule. metadata: label: Income-based JSA countable capital sources diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml index f7d5c3e439..9a019c1541 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/person_sources.yaml @@ -1,15 +1,32 @@ description: > Person-level model variables counted as Housing Benefit capital of the - person's own benefit unit. They are summed over the benefit unit's claimant - and partner (`is_claimant_or_partner`), so they need none of the household - proxy that the household `sources` use, and a dependant's holding is left - out: regulation 45 says the capital of a child or young person in the - claimant's family is not the claimant's. `lifetime_isa_countable_capital` is - a Lifetime ISA at its surrender value: regulation 47 (regulation 45 of the - pension-age regulations) values capital at its current market or surrender - value, in the words of the DWP benefits' regulations, for which DWP guidance - counts a Lifetime ISA at 75% of its value under 60 and in full from 60. - Lifetime ISAs exist from 6 April 2017, and the list is empty before then. + person's own benefit unit. The formulas sum these sources over the benefit + unit's claimant and partner (`is_claimant_or_partner`) and exclude + dependants: local authorities exclude children's capital under regulation + 45 (regulation 23(3) of the pension-age regulations). + `lifetime_isa_countable_capital` values a Lifetime ISA at its surrender + value: local authorities value capital at its current market or surrender + value under regulation 47 (regulation 45 of the pension-age regulations), + whose words match the DWP benefits' valuation rules, and DWP guidance on + those rules counts a Lifetime ISA at 75% of its value under 60 and in full + from 60. Lifetime ISAs exist from 6 April 2017, and the formulas count them + from then. `cash_isa_countable_capital` and + `stocks_and_shares_isa_countable_capital` hold a member's cash and stocks + and shares ISA balances, bounded by the household total. The account + investor beneficially owns an ordinary ISA's investments, and the named + child owns a junior ISA's investments (Individual Savings Account + Regulations 1998 reg. 4(6)(a) and reg. 2B(c)). In the ordinary calculation, + the formula subtracts each bounded ISA balance from its household source + (`cash_isa`, `share_like_capital`) and counts it in the holder's unit. For a + family on a passporting benefit, or at pension age on a guarantee credit, + the formula returns zero capital, and for a savings-credit-only award it + uses the Pension Credit capital assessment. When a household's balances + exceed its total, the model scales them to sum to the total exactly, + rounding each share down or up to a whole float32 step of the total. When a + reform removes a household source from `sources`, the formulas exclude the + corresponding ISA person source. With household totals alone, the formulas + leave the whole total in the pool. The ISA Regulations came into force for + all other purposes on 6 April 1999 (reg. 1). metadata: label: Housing Benefit person-level capital sources period: year @@ -23,7 +40,15 @@ metadata: href: https://www.legislation.gov.uk/uksi/2006/214/regulation/45 - title: Decision Makers' Guide, Chapter 29 (Capital), para. 29665 (Income Support and JSA, whose valuation rule has the same words) href: https://assets.publishing.service.gov.uk/media/6894a69d3080e72710b2e237/dmg-ch-29.pdf + - title: The Individual Savings Account Regulations 1998 reg. 4 + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/4 + - title: The Individual Savings Account Regulations 1998 reg. 2B + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/2B values: - 2006-04-10: [] + 2006-04-10: + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_capital 2017-04-06: - lifetime_isa_countable_capital + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_capital 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 eddb604c4b..bd01c601ac 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,6 +1,10 @@ description: > - Ordinary cash ISA balances are separate from savings and are included at - their accessible cash value, without a generic cash sale-cost deduction. + The formulas count ordinary accessible cash ISAs separately from savings, + at their cash value, without a generic cash sale-cost deduction. + Where the data tie a cash ISA or stocks and shares ISA balance to a member, + the formulas count it through `person_sources` in the holder's own benefit + unit and subtract it from `cash_isa` or `share_like_capital` before they + allocate the rest. 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 @@ -22,8 +26,8 @@ description: > 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 + classes are not yet split out separately. For capital whose owner the data + do not identify, the model allocates the household remainder across benunits using an adult-share proxy. metadata: label: Housing Benefit countable capital sources diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml index b5042804a5..2fad42f98d 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/person_sources.yaml @@ -1,15 +1,27 @@ description: > Person-level model variables counted as Income Support capital of the - person's own benefit unit. They are summed over the benefit unit's claimant - and partner (`is_claimant_or_partner`), so they need none of the household - proxy that the household `sources` use, and a dependant's holding is left - out: regulation 47 kept a child's or young person's capital out of the - claimant's, and since children moved to child tax credit they are outside - the assessment altogether. `lifetime_isa_countable_capital` is a Lifetime - ISA at its surrender value: regulation 49 values capital at its current - market or surrender value, and DMG 29665 counts a Lifetime ISA at 75% of its - value under 60 and in full from 60. Lifetime ISAs exist from 6 April 2017, - and the list is empty before then. + person's own benefit unit. The formulas sum these sources over the benefit + unit's claimant and partner (`is_claimant_or_partner`) and exclude + dependants: DWP excludes children's capital under regulation 47 until SI + 2003/455 applies, and regulation 23(2) from then. + `lifetime_isa_countable_capital` values a Lifetime ISA at its surrender + value: DWP values capital at its current market or surrender value under + regulation 49, and DMG 29665 counts a Lifetime ISA at 75% of its value under + 60 and in full from 60. Lifetime ISAs exist from 6 April 2017, and the + formulas count them from then. + `cash_isa_countable_capital` and `stocks_and_shares_isa_countable_capital` + hold a member's cash and stocks and shares ISA balances, bounded by the + household total. The account investor beneficially owns an ordinary ISA's + investments, and the named child owns a junior ISA's investments (Individual + Savings Account Regulations 1998 reg. 4(6)(a) and reg. 2B(c)). The formulas + subtract each bounded ISA balance from its household source (`cash_isa`, + `share_like_capital`) and count it in the holder's unit. When a household's + balances exceed its total, the model scales them to sum to the total + exactly, rounding each share down or up to a whole float32 step of the + total. When a reform removes a household source from `sources`, the formulas + exclude the corresponding ISA person source. With household totals alone, + the formulas leave the whole total in the pool. The ISA Regulations came + into force for all other purposes on 6 April 1999 (reg. 1). metadata: label: Income Support person-level capital sources period: year @@ -21,7 +33,15 @@ metadata: href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/49 - title: Decision Makers' Guide, Chapter 29 (Capital), para. 29665 href: https://assets.publishing.service.gov.uk/media/6894a69d3080e72710b2e237/dmg-ch-29.pdf + - title: The Individual Savings Account Regulations 1998 reg. 4 + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/4 + - title: The Individual Savings Account Regulations 1998 reg. 2B + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/2B values: - 2010-01-01: [] + 2010-01-01: + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_capital 2017-04-06: - lifetime_isa_countable_capital + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_capital 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 4db2982412..e4ea10e98b 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 @@ -1,6 +1,10 @@ description: > - Ordinary cash ISA balances are separate from savings and are included at - their accessible cash value, without a generic cash sale-cost deduction. + The formulas count ordinary accessible cash ISAs separately from savings, + at their cash value, without a generic cash sale-cost deduction. + Where the data tie a cash ISA or stocks and shares ISA balance to a member, + the formulas count it through `person_sources` in the holder's own benefit + unit and subtract it from `cash_isa` or `share_like_capital` before they + allocate the rest. Capital sources included in the Income Support capital test. `private_pension_wealth` is excluded because Schedule 10 paragraphs 23 and 23A disregard the value of the right to receive an occupational or personal diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml index 06b93fc22f..194718020a 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/person_sources.yaml @@ -1,15 +1,29 @@ description: > Person-level model variables counted as Pension Credit capital of the - person's own benefit unit. They are summed over the benefit unit's claimant - and partner (`is_claimant_or_partner`), so they need none of the household - proxy that the household `sources` use, and a dependant's holding is left - out: section 5 of the State Pension Credit Act 2002 adds only the partner's - capital to the claimant's. `lifetime_isa_countable_capital` is a Lifetime - ISA at its surrender value: regulation 19 values capital at its current - market or surrender value, and DMG 84751 counts a Lifetime ISA at 75% of its - value under 60 and in full from 60, so a claimant's own Lifetime ISA counts - in full. Lifetime ISAs exist from 6 April 2017, and the list is empty before - then. + person's own benefit unit. The formulas sum these sources over the benefit + unit's claimant and partner (`is_claimant_or_partner`) and exclude + dependants: DWP treats the partner's capital as the claimant's under + section 5 of the State Pension Credit Act 2002. + `lifetime_isa_countable_capital` values a Lifetime ISA at its surrender + value: DWP values capital at its current market or surrender value under + regulation 19, and DMG 84751 counts a Lifetime ISA at 75% of its value under + 60 and in full from 60, so a claimant's own Lifetime ISA counts in full. + Lifetime ISAs exist from 6 April 2017, and the formulas count them from + then. `cash_isa_countable_capital` and + `stocks_and_shares_isa_countable_capital` hold a member's cash and stocks + and shares ISA balances, bounded by the household total. The account + investor beneficially owns an ordinary ISA's investments, and the named + child owns a junior ISA's investments (Individual Savings Account + Regulations 1998 reg. 4(6)(a) and reg. 2B(c)). The formula subtracts each + bounded ISA balance from its household source (`cash_isa`, + `share_like_capital`) and counts it in the holder's unit. A nonnegative + `pension_credit_reported_capital` replaces that unit's calculated capital. + When a household's balances exceed its total, the model scales them to sum + to the total exactly, rounding each share down or up to a whole float32 step + of the total. When a reform removes a household source from `sources`, the + formulas exclude the corresponding ISA person source. With household totals + alone, the formulas leave the whole total in the pool. The ISA Regulations + came into force for all other purposes on 6 April 1999 (reg. 1). metadata: label: Pension Credit person-level capital sources period: year @@ -21,7 +35,15 @@ metadata: href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/19 - title: Decision Makers' Guide, Chapter 84 (Deemed weekly income from capital), para. 84751 href: https://assets.publishing.service.gov.uk/media/69d8f133daa863aa5db665a5/dmg-Ch-84.pdf + - title: The Individual Savings Account Regulations 1998 reg. 4 + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/4 + - title: The Individual Savings Account Regulations 1998 reg. 2B + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/2B values: - 2003-10-06: [] + 2003-10-06: + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_capital 2017-04-06: - lifetime_isa_countable_capital + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_capital 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 60c43a3138..6c5a9bd5ca 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 @@ -1,6 +1,10 @@ description: > - Ordinary cash ISA balances are separate from savings and are included at - their accessible cash value, without a generic cash sale-cost deduction. + The formulas count ordinary accessible cash ISAs separately from savings, + at their cash value, without a generic cash sale-cost deduction. + Where the data tie a cash ISA or stocks and shares ISA balance to a member, + the formulas count it through `person_sources` in the holder's own benefit + unit and subtract it from `cash_isa` or `share_like_capital` before they + allocate the rest. PolicyEngine approximation of Pension Credit capital sources that count toward deemed income from capital, excluding the claimant's main home. `private_pension_wealth` is excluded because Schedule V paragraphs 22 and 23 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml index dc6970037a..d0eb32a853 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/person_sources.yaml @@ -1,18 +1,32 @@ description: > Person-level model variables counted as Universal Credit capital of the - person's own benefit unit. They are summed over the benefit unit's claimant - and partner (`is_uc_claimant`), so they need none of the household proxy - that the household `sources` use, and a dependant's holding is left out: - section 5 of the Welfare Reform Act 2012 tests the claimant's, or the joint - claimants' combined, capital. `lifetime_isa_countable_capital` is a - Lifetime ISA at its surrender value: regulation 49(1) values capital at its - current market or surrender value, and ADM H1659 counts a Lifetime ISA at - 75% of its value under 60 and in full from 60. Lifetime ISAs exist from 6 - April 2017, and the list is empty before then. A nonnegative financial - observation (benunit_reported_capital) replaces these covered financial - sources to avoid counting a Lifetime ISA twice. A complete UC override - replaces them together with property and the household financial proxy, - before the existing regulation 77 company substitution. + person's own benefit unit. The formulas sum these sources over the benefit + unit's claimant and partner (`is_uc_claimant`) and exclude dependants: DWP + tests the claimant's, or the joint claimants' combined, capital under + section 5 of the Welfare Reform Act 2012. `lifetime_isa_countable_capital` + values a Lifetime ISA at its surrender value: DWP values capital at its + current market or surrender value under regulation 49(1), and ADM H1659 + counts a Lifetime ISA at 75% of its value under 60 and in full from 60. + Lifetime ISAs exist from 6 April 2017, and the formulas count them from + then. A nonnegative `benunit_reported_capital` replaces these financial + sources for its unit, so the formula counts no ISA twice, and a complete + `uc_reported_capital` override replaces them together with property and the + household financial proxy, before the regulation 77 company substitution. + `cash_isa_countable_capital` and `stocks_and_shares_isa_countable_capital` + hold a member's cash and stocks and shares ISA balances, bounded by the + household total. The account investor beneficially owns an ordinary ISA's + investments, and the named child owns a junior ISA's investments (Individual + Savings Account Regulations 1998 reg. 4(6)(a) and reg. 2B(c)). Where no unit + reports capital, the formula subtracts each bounded ISA balance from its + household source and counts it in the holder's unit. For a reporting UC + unit, the formula leaves its claimant's and partner's ISAs in the pool and + subtracts the report. When a household's balances exceed its total, the + model scales them to sum to the total exactly, rounding each share down or + up to a whole float32 step of the total. When a reform removes a household + source from `sources`, the formulas exclude the corresponding ISA person + source. With household totals alone, the formulas leave the whole total in + the pool. The ISA Regulations came into force for all other purposes on 6 + April 1999 (reg. 1). metadata: label: Universal Credit person-level capital sources period: year @@ -24,7 +38,15 @@ metadata: href: https://www.legislation.gov.uk/uksi/2013/376/regulation/49/1 - title: Advice for Decision Making, Chapter H1 (Capital), para. H1659 href: https://assets.publishing.service.gov.uk/media/693ace15c72b0f8ccf33d609/adm-ch-H1.pdf + - title: The Individual Savings Account Regulations 1998 reg. 4 + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/4 + - title: The Individual Savings Account Regulations 1998 reg. 2B + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/2B values: - 2013-04-29: [] + 2013-04-29: + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_capital 2017-04-06: - lifetime_isa_countable_capital + - cash_isa_countable_capital + - stocks_and_shares_isa_countable_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 9e3e5dbcbc..a7a116c5f3 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,6 +1,10 @@ description: > - Ordinary cash ISA balances are separate from savings and are included at - their accessible cash value, without a generic cash sale-cost deduction. + The formulas count ordinary accessible cash ISAs separately from savings, + at their cash value, without a generic cash sale-cost deduction. + Where the data tie a cash ISA or stocks and shares ISA balance to a member, + the formulas count it through `person_sources` in the holder's own benefit + unit and subtract it from `cash_isa` or `share_like_capital` before they + allocate the rest. 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`, diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/cash_isa_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/cash_isa_capital.yaml index 3b858806e2..2a2fa18f4a 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/cash_isa_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/cash_isa_capital.yaml @@ -100,7 +100,7 @@ output: pension_credit_assessable_capital: [0, 5_000] -- name: A person's cash ISA balance enters capital through the household total +- name: A person's cash ISA balance counts as their own benefit unit's capital period: 2026 absolute_error_margin: 0.01 input: diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml new file mode 100644 index 0000000000..2b88881ea0 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml @@ -0,0 +1,695 @@ +# Cash and stocks and shares ISA balances count in the holder's own benefit +# unit (#2729). The account investor beneficially owns an ordinary ISA's +# investments, and the named child owns a junior ISA's investments (ISA +# Regulations 1998 reg 4(6)(a) and reg 2B(c)). DWP and local authorities count +# the claimant's and partner's capital (WRA 2012 s 5; HB Regs 2006 reg 25; SPCA +# 2002 s 5; IS Regs 1987 reg 23; JSA Regs 1996 reg 88; ESA Regs 2008 reg 83), +# and not a child's or young person's (HB reg 25(3) and 45; IS reg 23(2); JSA +# reg 88(2); ESA reg 83(2)). The formula subtracts the identified ISA balances +# from the household's capital, then allocates the remainder by claimant and +# partner counts, between pension-age adults for Pension Credit, or wholly to +# a unit on the IS, JSA or ESA claim path. + +- name: Case 1, a lodger's cash ISA stays out of the main benefit unit's capital. + period: 2025 + absolute_error_margin: 0 + input: + people: + tenant: + age: 30 + lodger: + age: 30 + cash_isa_balance: 10_000 + benunits: + tenant_unit: + members: tenant + would_claim_uc: false # not on Universal Credit, which passports HB + lodger_unit: + members: lodger + would_claim_uc: false + households: + household: + members: [tenant, lodger] + savings: 2_000 + output: + cash_isa: 10_000 + cash_isa_countable_capital: [0, 10_000] + # The formula subtracts the lodger's 10,000 from savings 2,000 plus + # cash_isa 10,000 and allocates the remaining 2,000 by claimant counts, + # 1,000 to each unit; the lodger's unit adds the 10,000 ISA. + uc_assessable_capital: [1_000, 11_000] + housing_benefit_assessable_capital: [1_000, 11_000] + +- name: Case 2, an adult child's stocks and shares ISA stays out of the parents' capital. + period: 2025 + absolute_error_margin: 0 + input: + people: + parent_1: + age: 50 + parent_2: + age: 48 + adult_child: + age: 25 + stocks_and_shares_isa_balance: 6_000 + benunits: + parents: + members: [parent_1, parent_2] + would_claim_uc: false + child: + members: adult_child + would_claim_uc: false + households: + household: + members: [parent_1, parent_2, adult_child] + savings: 9_000 + output: + # share_like_capital sums the itemised 6,000, all of it the child's ISA. + share_like_capital: 6_000 + stocks_and_shares_isa_countable_capital: [0, 0, 6_000] + # The formula subtracts the child's 6,000 from savings 9,000 plus + # share_like_capital 6,000 and allocates the remaining 9,000 by claimant + # counts: 6,000 to the parents and 3,000 to the child's unit, which adds + # the 6,000 ISA. + uc_assessable_capital: [6_000, 9_000] + housing_benefit_assessable_capital: [6_000, 9_000] + +- name: Case 3, an adult child's ISA inside corporate wealth stays out of the parents' capital. + period: 2025 + absolute_error_margin: 0 + input: + people: + parent_1: + age: 50 + parent_2: + age: 48 + adult_child: + age: 25 + stocks_and_shares_isa_balance: 6_000 + benunits: + parents: + members: [parent_1, parent_2] + would_claim_uc: false + child: + members: adult_child + would_claim_uc: false + households: + household: + members: [parent_1, parent_2, adult_child] + # 3,000 of directly held shares and the child's 6,000 ISA. + corporate_wealth: 9_000 + output: + share_like_capital: 9_000 + # The formula subtracts the child's 6,000 ISA from corporate wealth + # 9,000 and allocates the remaining 3,000: 2,000 to the parents and 1,000 + # to the child's unit. + uc_assessable_capital: [2_000, 7_000] + housing_benefit_assessable_capital: [2_000, 7_000] + +- name: Case 4, a parent's Income Support claim does not count an adult child's cash ISA. + period: 2025 + absolute_error_margin: 0 + input: + people: + parent: + age: 45 + income_support_reported: true + child: + age: 4 + adult_child: + age: 22 + cash_isa_balance: 5_000 + benunits: + parent_and_child: + members: [parent, child] + would_claim_IS: true + adult_child: + members: adult_child + households: + household: + members: [parent, child, adult_child] + savings: 2_000 + output: + # The formula subtracts the adult child's 5,000 from savings 2,000 plus + # cash_isa 5,000 and gives the claiming benunit the whole 2,000 + # remainder; the ISA counts in its holder's unit. + income_support_assessable_capital: [2_000, 5_000] + +- name: Case 5, a parent's income-based JSA claim does not count an adult child's cash ISA. + period: 2025 + absolute_error_margin: 0 + input: + people: + parent: + age: 45 + jsa_income_reported: 3_000 + adult_child: + age: 22 + cash_isa_balance: 5_000 + benunits: + parent: + members: parent + adult_child: + members: adult_child + households: + household: + members: [parent, adult_child] + savings: 2_000 + output: + jsa_income_assessable_capital: [2_000, 5_000] + +- name: Case 6, a parent's income-related ESA claim does not count an adult child's stocks and shares ISA. + period: 2025 + absolute_error_margin: 0 + input: + people: + parent: + age: 45 + esa_income_reported: 3_000 + adult_child: + age: 22 + stocks_and_shares_isa_balance: 5_000 + benunits: + parent: + members: parent + would_claim_uc: false + adult_child: + members: adult_child + households: + household: + members: [parent, adult_child] + savings: 2_000 + output: + esa_income_assessable_capital: [2_000, 5_000] + +- name: Case 7, a pensioner couple's Pension Credit counts their own ISAs and not a lodger's. + period: 2025 + absolute_error_margin: 0 + input: + people: + pensioner_1: + age: 70 + cash_isa_balance: 3_000 + pensioner_2: + age: 68 + stocks_and_shares_isa_balance: 2_000 + lodger: + age: 30 + cash_isa_balance: 6_000 + benunits: + pensioners: + members: [pensioner_1, pensioner_2] + lodger: + members: lodger + households: + household: + members: [pensioner_1, pensioner_2, lodger] + savings: 4_000 + output: + # The formula subtracts all three balances from savings 4,000, cash_isa + # 9,000 and share_like_capital 2,000, and allocates the remaining 4,000 + # between the two pension-age adults. The couple's unit adds their own + # 3,000 and 2,000. + pension_credit_assessable_capital: [9_000, 0] + +- name: Case 8, a dependent child's junior ISA counts in no benefit unit. + period: 2025 + absolute_error_margin: 0 + input: + people: + parent: + age: 40 + child: + age: 10 + cash_isa_balance: 3_000 + stocks_and_shares_isa_balance: 1_500 + benunits: + family: + members: [parent, child] + would_claim_uc: false + households: + household: + members: [parent, child] + savings: 1_000 + output: + is_claimant_or_partner: [true, false] + # The formula subtracts the child's 3,000 and 1,500, leaving savings of + # 1,000 in the pool. + uc_assessable_capital: 1_000 + housing_benefit_assessable_capital: 1_000 + income_support_assessable_capital: 1_000 + jsa_income_assessable_capital: 1_000 + esa_income_assessable_capital: 1_000 + +- name: Case 9, a couple's own ISAs count together in their benefit unit. + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 35 + cash_isa_balance: 4_000 + partner: + age: 33 + stocks_and_shares_isa_balance: 3_000 + lodger: + age: 30 + benunits: + couple: + members: [claimant, partner] + would_claim_uc: false + lodger: + members: lodger + would_claim_uc: false + households: + household: + members: [claimant, partner, lodger] + savings: 6_000 + output: + # Joint claimants' combined capital (WRA 2012 s 5(2)(a)): the formula + # allocates savings of 6,000 by claimant counts, 4,000 to the couple and + # 2,000 to the lodger, and the couple's unit adds their 4,000 and 3,000. + uc_assessable_capital: [11_000, 2_000] + housing_benefit_assessable_capital: [11_000, 2_000] + +- name: Case 10, a household total above the balances keeps the rest in the pool. + period: 2025 + absolute_error_margin: 0 + input: + people: + tenant: + age: 30 + lodger: + age: 30 + cash_isa_balance: 10_000 + benunits: + tenant_unit: + members: tenant + would_claim_uc: false + lodger_unit: + members: lodger + would_claim_uc: false + households: + household: + members: [tenant, lodger] + cash_isa: 15_000 + output: + cash_isa: 15_000 + cash_isa_countable_capital: [0, 10_000] + # The formula allocates the 5,000 of the total that no balance explains + # by claimant counts, 2,500 to each unit. + uc_assessable_capital: [2_500, 12_500] + housing_benefit_assessable_capital: [2_500, 12_500] + +- name: Case 11, the model scales balances above the household total down to it. + period: 2025 + absolute_error_margin: 0 + input: + people: + tenant: + age: 30 + cash_isa_balance: 3_000 + lodger: + age: 30 + cash_isa_balance: 9_000 + benunits: + tenant_unit: + members: tenant + would_claim_uc: false + lodger_unit: + members: lodger + would_claim_uc: false + households: + household: + members: [tenant, lodger] + cash_isa: 6_000 + output: + # The balances sum to 12,000, twice the 6,000 total, so the model counts + # half of each, and the formula leaves nothing in the pool. + cash_isa_countable_capital: [1_500, 4_500] + uc_assessable_capital: [1_500, 4_500] + housing_benefit_assessable_capital: [1_500, 4_500] + +- name: Case 12, a Universal Credit report covers its own claimant's ISA. + period: 2025 + absolute_error_margin: 0 + input: + people: + parent_1: + age: 50 + parent_2: + age: 48 + adult_child: + age: 25 + cash_isa_balance: 10_000 + benunits: + parents: + members: [parent_1, parent_2] + child: + members: adult_child + # The child's own financial capital: the 10,000 ISA and 2,000 of + # the household's savings. + benunit_reported_capital: 12_000 + households: + household: + members: [parent_1, parent_2, adult_child] + savings: 9_000 + output: + # The formula subtracts the 12,000 report from the 19,000 pool, ISA + # included, and allocates the other 7,000 to the parents. + uc_assessable_capital: [7_000, 12_000] + +- name: Case 13, a stocks and shares ISA above the UC capital limit counts for its holder only. + period: 2025 + absolute_error_margin: 0 + input: + people: + tenant: + age: 30 + lodger: + age: 30 + stocks_and_shares_isa_balance: 34_000 + benunits: + tenant_unit: + members: tenant + lodger_unit: + members: lodger + households: + household: + members: [tenant, lodger] + output: + # Pooling this ISA would give each claimant 17,000, above reg 18's + # 16,000 limit. The formula attributes it to its holder. + uc_assessable_capital: [0, 34_000] + +# Cases 14 to 17: where balances exceed a household total, the model counts +# exactly the total, so a holder at a capital limit or a tariff threshold +# stays on its boundary. +# The model stores amounts in float32 (spacing 0.0009765625 at 16,000), and +# absolute_error_margin 0 fails on one unit in the last place. + +- name: Case 14, an ISA above its household total counts exactly 16,000 at UC's capital limit. + period: 2025 + absolute_error_margin: 0 + input: + people: + cash_holder: + age: 30 + cash_isa_balance: 16_790 + stocks_holder: + age: 30 + stocks_and_shares_isa_balance: 16_790 + benunits: + cash_unit: + members: cash_holder + would_claim_uc: false + would_claim_IS: false + stocks_unit: + members: stocks_holder + would_claim_uc: false + would_claim_IS: false + households: + cash_home: + members: cash_holder + cash_isa: 16_000 + stocks_home: + members: stocks_holder + stocks_and_shares_isa: 16_000 + output: + cash_isa_countable_capital: [16_000, 0] + stocks_and_shares_isa_countable_capital: [0, 16_000] + # WRA 2012 s 5(1)(a) requires capital not greater than the amount reg + # 18(1)(a) prescribes, 16,000, so capital of exactly 16,000 keeps + # entitlement. Reg 72(1) deems 4.35 a month for each 250 above 6,000: + # 40 steps, 2,088 a year. + uc_assessable_capital: [16_000, 16_000] + is_uc_eligible: [true, true] + uc_tariff_income: [2_088, 2_088] + +- name: Case 15, an ISA above its household total counts exactly 6,000 at UC's tariff threshold. + period: 2025 + absolute_error_margin: 0 + input: + people: + cash_holder: + age: 30 + cash_isa_balance: 8_341 + stocks_holder: + age: 30 + stocks_and_shares_isa_balance: 8_341 + benunits: + cash_unit: + members: cash_holder + would_claim_uc: false + would_claim_IS: false + stocks_unit: + members: stocks_holder + would_claim_uc: false + would_claim_IS: false + households: + cash_home: + members: cash_holder + cash_isa: 6_000 + stocks_home: + members: stocks_holder + stocks_and_shares_isa: 6_000 + output: + cash_isa_countable_capital: [6_000, 0] + stocks_and_shares_isa_countable_capital: [0, 6_000] + # Reg 72(1) deems no income from capital of 6,000 or less. + uc_assessable_capital: [6_000, 6_000] + uc_tariff_income: [0, 0] + +- name: Case 16, a holder at the total leaves an unrelated couple nothing in every working-age test. + period: 2025 + absolute_error_margin: 0 + input: + people: + holder: + age: 30 + cash_isa_balance: 16_790 + partner_1: + age: 30 + partner_2: + age: 30 + benunits: + holder_unit: + members: holder + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + in_receipt_of_guarantee_credit: false + in_receipt_of_savings_credit_only: false + housing_benefit_on_passporting_benefit: false + couple: + members: [partner_1, partner_2] + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + in_receipt_of_guarantee_credit: false + in_receipt_of_savings_credit_only: false + housing_benefit_on_passporting_benefit: false + households: + household: + members: [holder, partner_1, partner_2] + cash_isa: 16_000 + output: + # The holder counts the whole 16,000, so the formula leaves exactly + # nothing in the pool for the couple. + cash_isa_countable_capital: [16_000, 0, 0] + uc_assessable_capital: [16_000, 0] + housing_benefit_assessable_capital: [16_000, 0] + income_support_assessable_capital: [16_000, 0] + jsa_income_assessable_capital: [16_000, 0] + esa_income_assessable_capital: [16_000, 0] + +- name: Case 17, a pension-age holder at the total counts exactly 10,000 in Pension Credit. + period: 2025 + absolute_error_margin: 0 + input: + people: + holder: + age: 70 + cash_isa_balance: 16_450 + partner_1: + age: 70 + partner_2: + age: 70 + benunits: + holder_unit: + members: holder + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + in_receipt_of_guarantee_credit: false + in_receipt_of_savings_credit_only: false + housing_benefit_on_passporting_benefit: false + couple: + members: [partner_1, partner_2] + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + in_receipt_of_guarantee_credit: false + in_receipt_of_savings_credit_only: false + housing_benefit_on_passporting_benefit: false + households: + household: + members: [holder, partner_1, partner_2] + cash_isa: 10_000 + output: + cash_isa_countable_capital: [10_000, 0, 0] + pension_credit_assessable_capital: [10_000, 0] + # SPC Regs 2002 reg 15(6) deems 1 a week for each 500 above 10,000, so + # capital of exactly 10,000 deems nothing. + pension_credit_deemed_income: [0, 0] + housing_benefit_assessable_capital: [10_000, 0] + +- name: Case 18, a stocks and shares ISA counts no more than the share-like capital that includes it. + period: 2025 + absolute_error_margin: 0 + input: + people: + holder: + age: 30 + stocks_and_shares_isa_balance: 1_000 + benunits: + holder_unit: + members: holder + would_claim_uc: false + would_claim_IS: false + households: + household: + members: holder + stocks_and_shares_isa: 1_000 + # The formulas count share-like capital, and an input or reform can + # set it below the ISA total. + share_like_capital: 100 + output: + stocks_and_shares_isa_countable_capital: 100 + uc_assessable_capital: 100 + +- name: Case 19, a child's ISA counts in no unit when the parent reports the family's capital. + period: 2025 + absolute_error_margin: 0 + input: + people: + parent: + age: 40 + child: + age: 10 + cash_isa_balance: 10_000 + lodger: + age: 30 + benunits: + family: + members: [parent, child] + # The parent's own capital: nothing. + benunit_reported_capital: 0 + lodger_unit: + members: lodger + households: + household: + members: [parent, child, lodger] + output: + # The formula takes the report as the claimant's and partner's capital + # and subtracts the child's 10,000 ISA from the pool, so the lodger's unit + # counts none of it. + uc_assessable_capital: [0, 0] + +- name: Case 20, a UC report keeps the inherited override, so the part of a reporter's ISA above the report stays in the pool. + period: 2025 + absolute_error_margin: 0 + input: + people: + holder_1: + age: 30 + cash_isa_balance: 1 + other_1: + age: 30 + holder_2: + age: 30 + cash_isa_balance: 1 + other_2: + age: 30 + holder_3: + age: 30 + cash_isa_balance: 10_000 + other_3: + age: 30 + holder_4: + age: 30 + cash_isa_balance: 10_000 + other_4: + age: 30 + benunits: + holder_unit_1: + members: holder_1 + benunit_reported_capital: 0 + other_unit_1: + members: other_1 + holder_unit_2: + members: holder_2 + uc_reported_capital: 0 + other_unit_2: + members: other_2 + holder_unit_3: + members: holder_3 + benunit_reported_capital: 0 + other_unit_3: + members: other_3 + holder_unit_4: + members: holder_4 + benunit_reported_capital: 6_000 + other_unit_4: + members: other_4 + households: + household_1: + members: [holder_1, other_1] + household_2: + members: [holder_2, other_2] + household_3: + members: [holder_3, other_3] + household_4: + members: [holder_4, other_4] + output: + # The formula leaves a reporting unit's own ISA in the pool and + # subtracts only the reported amount, so the unit without a report + # counts the rest of the household's capital. + uc_assessable_capital: [0, 1, 0, 1, 0, 10_000, 6_000, 4_000] + +- name: Case 21, a Pension Credit report replaces the reporting unit's capital and shares none of it. + period: 2025 + absolute_error_margin: 0 + input: + people: + holder_1: + age: 70 + cash_isa_balance: 10_000 + other_1: + age: 70 + holder_2: + age: 70 + cash_isa_balance: 10_000 + other_2: + age: 70 + benunits: + holder_unit_1: + members: holder_1 + pension_credit_reported_capital: 0 + other_unit_1: + members: other_1 + holder_unit_2: + members: holder_2 + pension_credit_reported_capital: 6_000 + other_unit_2: + members: other_2 + households: + household_1: + members: [holder_1, other_1] + household_2: + members: [holder_2, other_2] + output: + # The formula subtracts the holder's ISA from the pool and replaces the + # holder's unit's capital with the report, so the other pension-age unit + # counts none of the ISA. + pension_credit_assessable_capital: [0, 0, 6_000, 0] diff --git a/policyengine_uk/tests/shard_durations.toml b/policyengine_uk/tests/shard_durations.toml index 2c52eb6701..5e04dd9d37 100644 --- a/policyengine_uk/tests/shard_durations.toml +++ b/policyengine_uk/tests/shard_durations.toml @@ -579,3 +579,13 @@ "policyengine_uk/tests/test_disable_simulated_benefits_winter_heating.py::test_a_household_that_moves_is_paid_under_each_years_scheme[LONDON-SCOTLAND]" = 20 "policyengine_uk/tests/test_capital_gains_responses.py::test_revenue_falls_short_of_the_static_estimate" = 20 "policyengine_uk/tests/test_capital_gains_response_default.py::test_employer_cost_branches_hold_the_realisation_response" = 20 +# test_person_isa_capital.py (#2729): local timings on 1 November 2026, one +# test at a time at load 40 to 90, and the threshold and first Pension Credit +# nodes in one run of the file at load 90 to 165, until CI logs replace them. +"policyengine_uk/tests/test_person_isa_capital.py::test_balances_alone_and_totals_alone_give_one_unit_the_same_capital" = 221 +"policyengine_uk/tests/test_person_isa_capital.py::test_isas_over_a_total_leave_an_unrelated_adult_exactly_at_a_threshold" = 109 +"policyengine_uk/tests/test_person_isa_capital.py::test_household_only_data_keep_todays_capital" = 73 +"policyengine_uk/tests/test_person_isa_capital.py::test_a_pound_counts_once_in_pension_credit[households0]" = 65 +"policyengine_uk/tests/test_person_isa_capital.py::test_a_pound_counts_once_in_working_age_means_tests" = 60 +"policyengine_uk/tests/test_person_isa_capital.py::test_both_forms_in_one_dataset_add_up_to_the_stored_total" = 38 +"policyengine_uk/tests/test_person_isa_capital.py::test_person_balances_without_household_totals_count_with_their_holders" = 27 diff --git a/policyengine_uk/tests/test_dated_reform_routes.py b/policyengine_uk/tests/test_dated_reform_routes.py index dec77f72fe..8112cdfcf5 100644 --- a/policyengine_uk/tests/test_dated_reform_routes.py +++ b/policyengine_uk/tests/test_dated_reform_routes.py @@ -1955,7 +1955,7 @@ def restatements(draw): "gov.dwp.universal_credit.means_test.capital.person_sources", datetime.date(2013, 4, 29), datetime.date(2013, 4, 29), - [], + ["cash_isa_countable_capital", "stocks_and_shares_isa_countable_capital"], "reform_dict", ) ) diff --git a/policyengine_uk/tests/test_person_isa_capital.py b/policyengine_uk/tests/test_person_isa_capital.py new file mode 100644 index 0000000000..9b105fd5b4 --- /dev/null +++ b/policyengine_uk/tests/test_person_isa_capital.py @@ -0,0 +1,1074 @@ +"""Person cash and stocks and shares ISAs count in the holder's benefit unit (#2729). + +Each means-test formula counts cash_isa_countable_capital and +stocks_and_shares_isa_countable_capital through person_sources, for the +claimant and partner. The formulas subtract them from the household source +that includes them (cash_isa and share_like_capital) and allocate the +remainder between benefit units. + +For the households below, with no reported capital, the formulas allocate the +remainder by claimant weights or, for Pension Credit, pension-age adult +counts. For a UC unit that reports its capital, the formula leaves its +claimant's and partner's ISAs in the pool, and a Pension Credit report +replaces the unit's calculated capital; the YAML cases cover both. The +invariants: + +1. A pound counts once. The capital the household's benefit units count, + plus the ISAs of members no unit counts (dependants, and for Pension + Credit holders outside a pension-age unit), equals savings, cash_isa and + share_like_capital together, up to float32 rounding of the remainder's + shares. Where balances exceed a total, the holdings sum to it exactly, so + that source leaves nothing in the remainder: an adult with no ISA counts + exactly the capital they count without the other members' ISAs. +2. Where the balances' stored sum exceeds the total, the holdings sum to + the total exactly, each in proportion to its balance within the step + invariant 7 gives; otherwise each member counts their balance exactly. +3. With no person balances, every means test gives what the lists without + the ISA person sources give, in 2025, 2032, 2033 and 2040. +4. Differential: person balances alone and the household totals alone give + the same capital to a one-benefit-unit household. +5. A dataset that stores a total and smaller balances counts the balances in + their holders' units and the rest in the pool, adding up to the stored + total #2781 keeps, in every year. +6. A reform that drops the household source drops the person source with it, + and one that empties person_sources returns the ISAs to the pool. +7. The allocation arithmetic (bounded_holdings), for every float32 balance + and finite total: no holding exceeds the total or falls below zero; where + the balances' stored sum exceeds the total, the holdings' float64 sum + equals it; where it doesn't, each holding equals the balance; a sole + holder whose balance reaches the total holds exactly the total; each + holding equals its proportional share rounded down or up to a whole step, + the total's float32 spacing (at most 2**104, the largest spacing of a + finite float32), allowing for float64 sum and division rounding, so it + lies within one step of the share and passes a capital limit only where + the share does; a holding exceeds its balance by less than one step, and + only where the balance falls between steps; and the same input gives the + same output. +""" + +import math +from contextlib import contextmanager +from fractions import Fraction + +import numpy as np +import pandas as pd +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st +from policyengine_core.populations import GroupPopulation, Population + +from policyengine_uk import Simulation +from policyengine_uk.data.dataset_schema import UKSingleYearDataset +from policyengine_uk.data.economic_assumptions import PROJECTION_END_YEAR +from policyengine_uk.entities import Household, Person +from policyengine_uk.system import system +from policyengine_uk.variables.gov.dwp._person_capital_pool import ( + HOUSEHOLD_SOURCE_OF, + bounded_holdings, +) + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2025 +YEARS = (YEAR, PROJECTION_END_YEAR, PROJECTION_END_YEAR + 1, 2040) +CAPITAL = { + "uc_assessable_capital": "gov.dwp.universal_credit.means_test.capital", + "housing_benefit_assessable_capital": "gov.dwp.housing_benefit.means_test.capital", + "pension_credit_assessable_capital": "gov.dwp.pension_credit.income.capital", + "income_support_assessable_capital": "gov.dwp.income_support.means_test.capital", + "jsa_income_assessable_capital": "gov.dwp.JSA.income.capital", + "esa_income_assessable_capital": "gov.dwp.ESA.income.capital", +} +MEANS_TEST_CAPITAL = (*CAPITAL, "council_tax_reduction_assessable_capital") +ISA_SOURCES = tuple(HOUSEHOLD_SOURCE_OF) +BALANCE = { + "cash_isa_countable_capital": "cash_isa_balance", + "stocks_and_shares_isa_countable_capital": "stocks_and_shares_isa_balance", +} +# The person_sources lists before #2729: the Lifetime ISA alone. +WITHOUT_ISA_SOURCES = { + f"{path}.person_sources": { + "2017-04-06.2100-12-31": ["lifetime_isa_countable_capital"] + } + for path in CAPITAL.values() +} +SETTINGS = settings( + max_examples=6, + deadline=None, + derandomize=True, + database=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def _node(path): + node = system.parameters + for part in path.split("."): + node = getattr(node, part) + return node + + +def _dataset(households, year=YEAR, claims=True): + """A dataset with one household per entry. + + Each household is a dict with ``units``, a list of benefit units, each a + list of people as ``(age, person columns)``, and household columns. + Without ``claims``, no benefit unit claims Universal Credit or Income + Support, so Housing Benefit assesses capital rather than passporting it. + """ + people, benunits = [], [] + for h, household in enumerate(households, start=1): + for unit in household["units"]: + benunits.append(len(benunits) + 1) + for age, columns in unit: + people.append( + { + "person_id": len(people) + 1, + "person_benunit_id": benunits[-1], + "person_household_id": h, + "age": age, + **columns, + } + ) + people = pd.DataFrame(people).fillna(0.0) + columns = sorted({name for h in households for name in h if name != "units"}) + count = len(households) + benunit = pd.DataFrame({"benunit_id": benunits}) + if not claims: + benunit["would_claim_uc"] = False + benunit["would_claim_IS"] = False + return UKSingleYearDataset( + person=people, + benunit=benunit, + household=pd.DataFrame( + { + "household_id": range(1, count + 1), + "household_weight": [1.0] * count, + "region": ["LONDON"] * count, + "tenure_type": ["RENT_PRIVATELY"] * count, + "council_tax": [1_500.0] * count, + "rent": [12_000.0] * count, + **{ + name: [float(h.get(name, 0.0)) for h in households] + for name in columns + }, + } + ), + fiscal_year=year, + ) + + +def _core_household(household_of): + """Core's person population, and its household, for the given households. + + Builds Core's own populations by hand, with no Simulation, so the + allocation properties run in milliseconds. + """ + person = Population(Person) + person.count = len(household_of) + household = GroupPopulation(Household, person) + household.members_entity_id = np.asarray(household_of, dtype=np.intp) + household.count = int(max(household_of)) + 1 + return person, household + + +def _core_household_sum(household_of): + """Core's person.household.sum, for people in the given households.""" + _, household = _core_household(household_of) + return lambda array: household.project(household.sum(array)) + + +def _household_rank(household_of): + """Each member's rank in their household, lowest first, ties in member order. + + Plain numpy, for the allocation properties; Core's + person.get_rank(person.household, ...) gives the same ranks. + """ + household_of = np.asarray(household_of) + index = np.arange(household_of.size) + + def rank(criteria): + # Sort by household, then criteria, then member order, and count each + # member's place from their household's first place. + order = np.lexsort((index, np.asarray(criteria), household_of)) + grouped = household_of[order] + first = np.r_[True, grouped[1:] != grouped[:-1]] + place = np.arange(order.size) + ranks = np.empty(order.size, dtype=int) + ranks[order] = place - np.maximum.accumulate(np.where(first, place, 0)) + return ranks + + return rank + + +ANY_FLOAT32 = st.floats(width=32) +FINITE_FLOAT32 = st.floats(width=32, allow_nan=False, allow_infinity=False) +POUNDS = st.integers(min_value=0, max_value=4_000_000).map( + lambda pence: float(np.float32(pence / 100)) +) +# Up to three households of up to six people; amounts anywhere in float32 or +# in pounds and pence, where ISAs and capital limits sit. +ALLOCATIONS = st.integers(min_value=1, max_value=3).flatmap( + lambda count: st.tuples( + st.lists(st.one_of(POUNDS, FINITE_FLOAT32), min_size=count, max_size=count), + st.lists( + st.tuples( + st.integers(min_value=0, max_value=count - 1), + st.one_of(POUNDS, ANY_FLOAT32), + ), + min_size=1, + max_size=6, + ), + ) +) +ALLOCATION_SETTINGS = settings(SETTINGS, max_examples=300) + + +@ALLOCATION_SETTINGS +@given(allocation=ALLOCATIONS) +# A sole holder over the total at UC's capital limit, its tariff threshold +# and Pension Credit's tariff threshold, alone and beside two others. +@example(allocation=([16_000.0], [(0, 16_790.0)])) +@example(allocation=([6_000.0], [(0, 8_341.0)])) +@example(allocation=([10_000.0], [(0, 16_450.0), (0, 0.0), (0, 0.0)])) +# A couple over the total beside an adult with no ISA, whose shares round +# down to one step short of it at the UC tariff threshold and limit. +@example(allocation=([32_000.0], [(0, 0.0), (0, 12_000.0), (0, 24_000.0)])) +@example(allocation=([64_000.0], [(0, 0.0), (0, 24_000.0), (0, 48_000.0)])) +# Three equal balances with steps left over for some of them. +@example(allocation=([10.0], [(0, 3.0), (0, 3.0), (0, 3.0)])) +# Three holders of exactly 16,000 and of 6,000 under totals one step short +# of their sum: each share falls just short of the balance, and no holding +# may pass it. +@example(allocation=([47_999.99609375], [(0, 16_000.0)] * 3)) +@example(allocation=([17_999.998046875], [(0, 6_000.0)] * 3)) +# Shares that round to the nearest float32 overshoot the total. +@example( + allocation=( + [46_000.98046875], + [(0, 10_000.0), (0, 10_000.0), (0, 16_001.0), (0, 10_000.0)], + ) +) +# Balances whose float64 sum exceeds the total but rounds to it in float32. +@example(allocation=([16_000.0], [(0, 8_000.00048828125), (0, 8_000.0)])) +# A balance too small to change the float64 sum, beside one over the total. +@example(allocation=([1.0], [(0, 1e-30), (0, 3.0)])) +# Balances whose sum exceeds float32's range. +@example(allocation=([3e38], [(0, 3e38), (0, 3e38)])) +# A total of float32's largest value, whose next float32 is infinite. +@example(allocation=([3.4028235e38], [(0, 3.4028235e38), (0, 3.4028235e38)])) +# Shares above 2**127, where every float32 lies on a grid of 2**104. +@example(allocation=([3e38], [(0, 3e38), (0, 2e38)])) +# Infinite, missing and negative balances, and a negative total. +@example( + allocation=( + [5.0, -7.0], + [(0, float("inf")), (1, float("nan")), (1, -float("inf")), (0, -3.0)], + ) +) +def test_holdings_stay_within_the_household_total(allocation): + """Invariant 7: the arithmetic every ISA person source shares.""" + totals, members = allocation + household_of = np.array([h for h, _ in members]) + household_sum = _core_household_sum(household_of) + household_rank = _household_rank(household_of) + balance = np.array([b for _, b in members], np.float32) + total = np.array(totals, np.float32)[household_of] + holding = bounded_holdings(balance, total, household_sum, household_rank) + assert holding.dtype == np.float32 + + cap = np.maximum(np.float32(0), total) + held = np.where(np.isposinf(balance), cap, np.where(balance > 0, balance, 0)) + held = held.astype(np.float32) + # The plain numpy rank agrees with Core's get_rank. + person, household = _core_household(household_of) + assert np.array_equal(household_rank(-held), person.get_rank(household, -held)) + # No holding falls below zero or exceeds the household total. + assert ((0 <= holding) & (holding <= cap)).all() + with np.errstate(over="ignore"): + scales = household_sum(held).astype(np.float32) > cap + # Over a total, the holdings' float64 sum equals it exactly, so each + # means test nets the whole total from the household source and leaves + # nothing of it in the pool. + assert np.array_equal(household_sum(holding)[scales], cap[scales]) + # Balances whose stored sum doesn't exceed the total keep their exact values. + assert np.array_equal(holding[~scales], held[~scales]) + # A sole holder whose balance reaches the total holds exactly the total. + holders = household_sum((held > 0).astype(np.float32)) + sole = (held > 0) & (holders == 1) & (held >= cap) + assert np.array_equal(holding[sole], cap[sole]) + # Over a total, each holding equals its exact proportional share rounded + # down or up to a whole step, the total's float32 spacing, allowing for + # float64 sum and division rounding. So it lies within one step of the + # share and passes a multiple of the step, such as a capital limit, only + # where the share does. A holding exceeds its balance by less than one + # step, and only where the balance falls between steps. + for i in np.flatnonzero(scales): + same = np.flatnonzero(household_of == household_of[i]) + exact = ( + Fraction(float(held[i])) + * Fraction(float(cap[i])) + / sum(Fraction(float(held[j])) for j in same) + ) + step = Fraction(2) ** max(math.frexp(float(cap[i]))[1] - 24, -149) + slack = exact / 2**48 + below = math.floor((exact - slack) / step) * step + above = math.ceil((exact + slack) / step) * step + assert below <= Fraction(float(holding[i])) <= above + excess = Fraction(float(holding[i])) - Fraction(float(held[i])) + assert excess < step + if (Fraction(float(held[i])) / step).denominator == 1: + assert excess <= 0 + # The same input gives the same output, and one household's holdings + # never depend on another's. + again = bounded_holdings( + balance.copy(), total.copy(), household_sum, household_rank + ) + assert np.array_equal(holding, again) + for h in np.unique(household_of): + alone = household_of == h + own_household = np.zeros(alone.sum(), dtype=int) + result = bounded_holdings( + balance[alone], + total[alone], + _core_household_sum(own_household), + _household_rank(own_household), + ) + assert np.array_equal(result, holding[alone]) + + +@pytest.mark.parametrize("limit", [6_000, 10_000, 16_000]) +def test_a_sole_holder_over_a_limit_holds_exactly_the_limit(limit): + """Invariant 7, exhaustively near three capital limits. + + Whole-pound balances from the limit up to, but excluding, four times it, + plus 1,000 adjacent float32 values starting at the limit, each a sole + holder under a household total equal to the limit. + """ + above = np.nextafter(np.float32(limit), np.float32(np.inf)) + steps = np.arange(1_000, dtype=np.float32) * (above - np.float32(limit)) + balance = np.concatenate( + [np.arange(limit, 4 * limit, dtype=np.float32), np.float32(limit) + steps] + ) + total = np.full_like(balance, limit) + households = np.arange(balance.size) + holding = bounded_holdings( + balance, total, _core_household_sum(households), _household_rank(households) + ) + assert (holding == np.float32(limit)).all() + + +# A couple and a lodger; pensioners and their adult son; a parent and child. +HOUSEHOLD_ONLY = [ + { + "units": [[(30, {}), (28, {})], [(40, {})]], + "savings": 3_000, + "cash_isa": 4_000, + "stocks_and_shares_isa": 2_000, + "corporate_wealth": 5_000, + }, + { + "units": [[(70, {}), (68, {})], [(30, {})]], + "savings": 12_000, + "cash_isa": 6_000, + }, + { + "units": [[(35, {}), (8, {})]], + "savings": 500, + "cash_isa": 1_000, + }, +] + + +def test_household_only_data_keep_todays_capital(): + """Invariant 3: no balances leave the household totals in the pool.""" + data = _dataset(HOUSEHOLD_ONLY) + simulation = Simulation(dataset=data) + before = Simulation(dataset=data, reform=WITHOUT_ISA_SOURCES) + for year in YEARS: + for name in ISA_SOURCES: + assert not simulation.calculate(name, year).any(), (name, year) + for name in MEANS_TEST_CAPITAL: + assert np.array_equal( + simulation.calculate(name, year), before.calculate(name, year) + ), (name, year) + # The first household's pool: savings 3,000, cash_isa 4,000 and + # corporate_wealth 5,000 (which holds the 2,000 ISA), shared by three + # claimants, two of them the couple. + uc = simulation.calculate("uc_assessable_capital", YEAR) + assert list(uc[:2]) == [8_000, 4_000] + + +def test_both_forms_in_one_dataset_add_up_to_the_stored_total(): + """Invariant 5: a stored total above the balances keeps the rest pooled.""" + households = [ + { + "units": [ + [(30, {"cash_isa_balance": 0.0}), (28, {})], + [(40, {"cash_isa_balance": 10_000.0})], + ], + "savings": 4_000, + "cash_isa": 15_000, + }, + # The dataset's cash_isa column stores 0 here, and #2781 keeps a + # stored zero over the same year's balance, so the balance counts for + # nothing. + { + "units": [[(35, {"cash_isa_balance": 2_000.0})], [(45, {})]], + "savings": 1_000, + }, + ] + simulation = Simulation(dataset=_dataset(households, claims=False)) + benunit_household = np.array([0, 0, 1, 1]) + for year in YEARS: + balances = simulation.calculate("cash_isa_balance", year) + counted = simulation.calculate("cash_isa_countable_capital", year) + assert counted == pytest.approx(balances * [1, 1, 1, 0, 0], rel=1e-6) + holdings = simulation.calculate("cash_isa", year) + simulation.calculate( + "savings", year + ) + uc = simulation.calculate("uc_assessable_capital", year) + hb = simulation.calculate("housing_benefit_assessable_capital", year) + for capital in (uc, hb): + by_household = np.bincount(benunit_household, weights=capital) + assert by_household == pytest.approx(holdings, rel=1e-6), year + # The lodger counts their own balance on top of a third of the pool. + pool = holdings[0] - counted[2] + assert uc[1] == pytest.approx(pool / 3 + counted[2], rel=1e-6), year + assert uc[0] == pytest.approx(2 * pool / 3, rel=1e-6), year + # #2781 keeps the stored 15,000, so the formula allocates savings of 4,000 + # and the unexplained 5,000 as 6,000 and 3,000. The second household's + # pool holds only its savings. + assert list(simulation.calculate("cash_isa", YEAR)) == [15_000, 0] + assert list(simulation.calculate("uc_assessable_capital", YEAR)) == [ + 6_000, + 13_000, + 500, + 500, + ] + + +# The shape Microcosm's producer writes (microcosm main b979d46b): adults' +# cash and stocks and shares ISA balances, and a household corporate_wealth +# that sums directly_held_shares, unit_and_investment_trusts and the stocks +# and shares ISAs, with no household cash_isa or stocks_and_shares_isa. +PRODUCER_SHAPE = [ + { + "units": [ + [ + (30, {"cash_isa_balance": 3_000.0}), + (28, {"stocks_and_shares_isa_balance": 2_000.0}), + ], + [ + ( + 40, + { + "cash_isa_balance": 5_000.0, + "stocks_and_shares_isa_balance": 1_000.0, + }, + ) + ], + ], + "savings": 7_000, + "directly_held_shares": 1_500, + "unit_and_investment_trusts": 500, + "corporate_wealth": 5_000, + }, + { + "units": [ + [(70, {"cash_isa_balance": 4_000.0}), (68, {})], + [(30, {"stocks_and_shares_isa_balance": 6_000.0})], + ], + "savings": 12_000, + "unit_and_investment_trusts": 3_000, + "corporate_wealth": 9_000, + }, +] + + +def _household_capital(simulation, year): + return sum( + simulation.calculate(name, year) + for name in ("savings", "cash_isa", "share_like_capital") + ) + + +def test_person_balances_without_household_totals_count_with_their_holders(): + """Core sums the balances into each total, so every balance counts.""" + simulation = Simulation(dataset=_dataset(PRODUCER_SHAPE, claims=False)) + person_household = np.array([0, 0, 0, 1, 1, 1]) + benunit_household = np.array([0, 0, 1, 1]) + for year in (YEAR, PROJECTION_END_YEAR + 1): + for name, balance_name in BALANCE.items(): + balance = simulation.calculate(balance_name, year) + assert np.array_equal(simulation.calculate(name, year), balance) + for total, balance_name in ( + ("cash_isa", "cash_isa_balance"), + ("stocks_and_shares_isa", "stocks_and_shares_isa_balance"), + ): + sums = np.bincount( + person_household, weights=simulation.calculate(balance_name, year) + ) + assert simulation.calculate(total, year) == pytest.approx(sums, rel=1e-6) + # The itemised shares add up to corporate_wealth, which stays the pool. + assert np.array_equal( + simulation.calculate("share_like_capital", year), + simulation.calculate("corporate_wealth", year), + ) + holdings = _household_capital(simulation, year) + uc = simulation.calculate("uc_assessable_capital", year) + assert np.bincount(benunit_household, weights=uc) == pytest.approx( + holdings, rel=1e-6 + ) + # Savings 7,000 and 2,000 of non-ISA shares make the first pool, 3,000 for + # each claimant, and each unit adds its members' 5,000 and 6,000 of ISAs. + # The second pool, savings 12,000 and 3,000 of trusts, gives the parents + # 10,000 and their son 5,000 in UC, and the pension-age parents all + # 15,000 in Pension Credit. + expected_uc = [11_000, 9_000, 14_000, 11_000] + assert list(simulation.calculate("uc_assessable_capital", YEAR)) == expected_uc + for name in CAPITAL: + if name != "pension_credit_assessable_capital": + capital = simulation.calculate(name, YEAR) + assert list(capital[:2]) == expected_uc[:2], name + assert list(simulation.calculate("pension_credit_assessable_capital", YEAR)) == [ + 0, + 0, + 19_000, + 0, + ] + + +# One benefit unit each, with a child: the adults' cash and stocks and shares +# ISA balances, other shares and savings. +ONE_UNIT_CASES = [ + # Nothing at all. + ([(0.0, 0.0)], 0.0, 0.0), + # A cash ISA alone, beside savings. + ([(5_000.0, 0.0)], 0.0, 2_000.0), + # A stocks and shares ISA alone, beside other shares. + ([(0.0, 7_000.0)], 1_500.0, 0.0), + # A couple's ISAs of both kinds, beside savings and other shares. + ([(3_000.0, 2_000.0), (4_000.25, 1_000.75)], 2_500.0, 6_000.0), + # A couple's balances whose float64 sum, 16,000.00048828125, Core stores + # as a 16,000 total, in both kinds. + ([(8_000.00048828125, 8_000.00048828125), (8_000.0, 8_000.0)], 0.0, 0.0), +] + + +def test_balances_alone_and_totals_alone_give_one_unit_the_same_capital(): + """Invariant 4, differential: the two forms of the same holdings.""" + balances, totals = [], [] + for adults, other_shares, savings in ONE_UNIT_CASES: + shares = sum(amounts[1] for amounts in adults) + common = {"savings": savings, "corporate_wealth": other_shares + shares} + people = [ + (40 + i, {"cash_isa_balance": c, "stocks_and_shares_isa_balance": s}) + for i, (c, s) in enumerate(adults) + ] + balances.append({"units": [people + [(8, {})]], **common}) + totals.append( + { + "units": [[(40 + i, {}) for i in range(len(adults))] + [(8, {})]], + "cash_isa": sum(amounts[0] for amounts in adults), + "stocks_and_shares_isa": shares, + **common, + } + ) + balances_only = Simulation(dataset=_dataset(balances)) + totals_only = Simulation(dataset=_dataset(totals)) + for year in (YEAR, PROJECTION_END_YEAR + 1): + for name in MEANS_TEST_CAPITAL: + # Float32 sums in either order round alike to within a penny. + assert balances_only.calculate(name, year) == pytest.approx( + totals_only.calculate(name, year), rel=1e-6, abs=0.01 + ), (name, year) + # The couple at the rounding boundary keep their exact balances. + assert list(balances_only.calculate("cash_isa_countable_capital", YEAR)[-3:]) == [ + 8_000.00048828125, + 8_000.0, + 0.0, + ] + + +money = st.integers(min_value=0, max_value=2_000_000).map(lambda pence: pence / 100) +SITUATION_SETTINGS = settings(SETTINGS, max_examples=4) + + +def _situation(units, assets, claim_uc=False): + """One household of the given benefit units of ``(age, cash, shares)``.""" + people, benunits = {}, {} + for u, unit in enumerate(units): + members = [] + for i, (age, cash, shares) in enumerate(unit): + name = f"p{u}_{i}" + members.append(name) + people[name] = { + "age": {YEAR: age}, + "cash_isa_balance": {YEAR: cash}, + "stocks_and_shares_isa_balance": {YEAR: shares}, + } + benunits[f"b{u}"] = { + "members": members, + "would_claim_uc": {YEAR: claim_uc}, + "would_claim_IS": {YEAR: False}, + } + household = {"members": list(people)} + household |= {name: {YEAR: value} for name, value in assets.items()} + return {"people": people, "benunits": benunits, "households": {"h": household}} + + +def _unit(adult_ages): + """A benefit unit of one or two adults and up to two children.""" + adults = st.lists(st.sampled_from(adult_ages), min_size=1, max_size=2) + children = st.lists(st.just(10), min_size=0, max_size=2) + return st.tuples(adults, children).flatmap( + lambda ages: st.tuples( + *[st.tuples(st.just(age), money, money) for age in ages[0] + ages[1]] + ) + ) + + +HOLDINGS = st.fixed_dictionaries( + { + "savings": money, + "other_shares": money, + "cash_extra": money, + "shares_extra": money, + "stored": st.sampled_from(["none", "above", "below"]), + } +) + + +def _assets(units, holdings): + """Household inputs: stored totals above the balances, below them, or none.""" + people = [person for unit in units for person in unit] + cash = sum(person[1] for person in people) + shares = sum(person[2] for person in people) + assets = {"savings": holdings["savings"]} + assets["corporate_wealth"] = holdings["other_shares"] + shares + if holdings["stored"] == "above": + assets["cash_isa"] = cash + holdings["cash_extra"] + assets["stocks_and_shares_isa"] = shares + holdings["shares_extra"] + assets["corporate_wealth"] += holdings["shares_extra"] + elif holdings["stored"] == "below": + assets["cash_isa"] = cash / 2 + assets["stocks_and_shares_isa"] = shares / 2 + return assets + + +def _by_household(simulation, entity, array): + """Sum a person or benefit unit array over each household, in float64.""" + person_household = simulation.populations["household"].members_entity_id + if entity == "person": + index = person_household + else: + index = np.zeros(simulation.populations["benunit"].count, dtype=int) + index[simulation.populations["benunit"].members_entity_id] = person_household + return np.bincount( + index, + weights=np.asarray(array, np.float64), + minlength=simulation.populations["household"].count, + ) + + +def _check_counted_isas(simulation): + """Invariant 2, in each household of the simulation. + + Where the balances' stored sum exceeds the total, the holdings sum to it + exactly, each within one step of the total of its proportional share; + otherwise each member counts their balance exactly. + """ + totals = { + "cash_isa_countable_capital": simulation.calculate("cash_isa", YEAR), + "stocks_and_shares_isa_countable_capital": np.minimum( + simulation.calculate("stocks_and_shares_isa", YEAR), + simulation.calculate("share_like_capital", YEAR), + ), + } + person_household = simulation.populations["household"].members_entity_id + counted = {} + for name, total in totals.items(): + balance = simulation.calculate(BALANCE[name], YEAR) + counted[name] = simulation.calculate(name, YEAR) + total = np.maximum(np.float32(0), total) + scales = _by_household(simulation, "person", balance).astype(np.float32) > total + assert np.array_equal( + _by_household(simulation, "person", counted[name])[scales], + total[scales].astype(np.float64), + ), name + member_scales = scales[person_household] + step = np.spacing(total)[person_household] + sums = _by_household(simulation, "person", balance)[person_household] + share = ( + balance + * total[person_household].astype(np.float64) + / np.where(member_scales, sums, 1) + ) + assert (counted[name] >= 0).all(), name + assert (abs(counted[name] - share) <= step)[member_scales].all(), name + assert (counted[name] <= balance + step).all(), name + assert np.array_equal(counted[name][~member_scales], balance[~member_scales]), ( + name + ) + return counted + + +def _holdings(simulation): + return sum( + simulation.calculate(name, YEAR) + for name in ("savings", "cash_isa", "share_like_capital") + ) + + +@SITUATION_SETTINGS +@given( + units=st.lists(_unit([30, 38]), min_size=1, max_size=3), + holdings=HOLDINGS, +) +def test_a_pound_counts_once_in_working_age_means_tests(units, holdings): + """Invariant 1 for UC, HB, IS, JSA and ESA, nobody on a claim path.""" + assets = _assets(units, holdings) + simulation = Simulation(situation=_situation(units, assets)) + counted = _check_counted_isas(simulation) + dependants = ~simulation.calculate("is_claimant_or_partner", YEAR) + uncounted = sum((amount * dependants).sum() for amount in counted.values()) + for name in ( + "uc_assessable_capital", + "housing_benefit_assessable_capital", + "income_support_assessable_capital", + "jsa_income_assessable_capital", + "esa_income_assessable_capital", + ): + capital = simulation.calculate(name, YEAR) + # Float32 shares of the remainder round to within a few pence. + assert capital.sum() + uncounted == pytest.approx( + _holdings(simulation)[0], rel=1e-5, abs=0.05 + ), name + # Each unit counts at least its own claimant's and partner's ISAs. + own = sum( + simulation.map_result(amount * ~dependants, "person", "benunit", how="sum") + for amount in counted.values() + ) + assert (capital >= own * (1 - 1e-6) - 0.01).all(), name + + +# Households for Pension Credit's pool: each a list of benefit units of +# (age, person columns), with household columns. _dataset gives every +# household each column any household has, and #2781 keeps a stored zero +# over the balances, so households with no stored totals take a dataset of +# their own. +NO_STORED_TOTALS = [ + # A pensioner couple, both with ISAs, and an unrelated working-age holder. + { + "units": [ + [ + ( + 70, + { + "cash_isa_balance": 3_000.0, + "stocks_and_shares_isa_balance": 1_000.0, + }, + ), + (68, {"cash_isa_balance": 2_000.0}), + ], + [ + ( + 30, + { + "cash_isa_balance": 4_000.0, + "stocks_and_shares_isa_balance": 500.0, + }, + ) + ], + ], + "savings": 5_000, + "corporate_wealth": 2_500 + 1_500, + }, +] +STORED_TOTALS = [ + # Totals above the balances: a pensioner with a child who holds a junior + # ISA, and a separate pension-age unit. + { + "units": [ + [ + ( + 75, + { + "cash_isa_balance": 1_000.0, + "stocks_and_shares_isa_balance": 2_000.0, + }, + ), + (10, {"cash_isa_balance": 700.0}), + ], + [(72, {"stocks_and_shares_isa_balance": 3_000.0})], + ], + "savings": 4_000, + "cash_isa": 1_700 + 2_500, + "stocks_and_shares_isa": 5_000 + 1_500, + "corporate_wealth": 2_000 + 5_000 + 1_500, + }, + # Totals below the balances, so the formulas scale them: two separate + # pension-age units and an unrelated working-age holder. + { + "units": [ + [ + ( + 70, + { + "cash_isa_balance": 6_000.0, + "stocks_and_shares_isa_balance": 4_000.0, + }, + ) + ], + [(71, {"cash_isa_balance": 3_000.0})], + [ + ( + 30, + { + "cash_isa_balance": 2_000.0, + "stocks_and_shares_isa_balance": 2_000.0, + }, + ) + ], + ], + "savings": 1_000, + "cash_isa": 11_000 / 3, + "stocks_and_shares_isa": 2_000, + "corporate_wealth": 6_000 + 500, + }, +] + + +@pytest.mark.parametrize("households", [NO_STORED_TOTALS, STORED_TOTALS]) +def test_a_pound_counts_once_in_pension_credit(households): + """Invariant 1 for Pension Credit, shared by pension-age adults only.""" + simulation = Simulation(dataset=_dataset(households, claims=False)) + counted = _check_counted_isas(simulation) + pension_age = simulation.calculate( + "has_attained_state_pension_credit_qualifying_age", YEAR + ) + in_pension_unit = ( + simulation.map_result( + simulation.map_result(pension_age, "person", "benunit", how="sum") > 0, + "benunit", + "person", + ) + > 0 + ) + counts = simulation.calculate("is_claimant_or_partner", YEAR) & in_pension_unit + uncounted = sum( + _by_household(simulation, "person", amount * ~counts) + for amount in counted.values() + ) + capital = simulation.calculate("pension_credit_assessable_capital", YEAR) + # Float32 shares of the remainder round to within a few pence. + assert _by_household(simulation, "benunit", capital) + uncounted == pytest.approx( + _holdings(simulation), rel=1e-5, abs=0.05 + ) + if households is STORED_TOTALS: + # The last household's ISA holdings sum exactly to its totals, so its + # pool keeps savings of 1,000 and the 4,500 of share_like_capital + # beyond its 2,000 of ISAs: 2,750 for each pension-age adult. + own = sum(counted.values()) + assert list(capital[-3:]) == pytest.approx( + [2_750 + own[-3], 2_750 + own[-2], 0], rel=1e-6 + ) + + +# An adult with no ISA beside a couple whose cash ISAs exceed the household's +# cash_isa: (adults' age, cash_isa, the couple's balances, savings). The +# savings leave the adult exactly at a threshold: UC's tariff threshold of +# 6,000 and capital limit of 16,000, and Pension Credit's deemed income +# threshold of 10,000. +THRESHOLDS = { + "uc_tariff": (30, 32_000, (12_000, 24_000), 18_000), + "uc_limit": (30, 64_000, (24_000, 48_000), 48_000), + "pension_credit_deemed_income": (70, 32_000, (12_000, 24_000), 30_000), +} +THRESHOLD_OUTPUTS = ( + "uc_assessable_capital", + "uc_tariff_income", + "is_uc_eligible", + "pension_credit_assessable_capital", + "pension_credit_deemed_income", + "is_pension_credit_eligible", +) +NO_CLAIMS = { + name: {YEAR: False} + for name in ( + "would_claim_uc", + "would_claim_IS", + "would_claim_pc", + "housing_benefit_on_passporting_benefit", + ) +} + + +def _threshold_situation(): + """Each threshold household, then the same without the couple's ISAs. + + A last household holds three single adults, each with a 16,000 cash ISA, + under a cash_isa one float32 step short of their 48,000. + """ + people, benunits, households = {}, {}, {} + for case, (age, total, balances, savings) in THRESHOLDS.items(): + for form, keeps_isas in (("isas", True), ("reference", False)): + home = f"{case}_{form}" + names = [f"{home}_adult", f"{home}_holder_a", f"{home}_holder_b"] + for name, balance in zip(names, (0, *balances)): + people[name] = { + "age": {YEAR: age}, + "cash_isa_balance": {YEAR: balance if keeps_isas else 0}, + } + benunits[f"{home}_adult"] = {"members": names[:1], **NO_CLAIMS} + benunits[f"{home}_couple"] = {"members": names[1:], **NO_CLAIMS} + households[home] = { + "members": names, + "savings": {YEAR: savings}, + "cash_isa": {YEAR: total if keeps_isas else 0}, + } + names = [f"holder_{i}" for i in range(3)] + for name in names: + people[name] = {"age": {YEAR: 30}, "cash_isa_balance": {YEAR: 16_000}} + benunits[name] = {"members": [name], **NO_CLAIMS} + households["holders"] = { + "members": names, + "cash_isa": {YEAR: float(np.nextafter(np.float32(48_000), np.float32(0)))}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def test_isas_over_a_total_leave_an_unrelated_adult_exactly_at_a_threshold(): + """Invariant 1, exactly, at three thresholds. + + Rounding each scaled holding down would leave a remainder that the pool + shares with the adult: UC capital of 6,000.00048828125 and annual tariff + income of 52.20 rather than 0. The holdings sum exactly to cash_isa, so + the adult counts exactly what they count without the couple's ISAs. + """ + simulation = Simulation(situation=_threshold_situation()) + outputs = {name: simulation.calculate(name, YEAR) for name in THRESHOLD_OUTPUTS} + cash = simulation.calculate("cash_isa_countable_capital", YEAR) + # Each case takes four units (adult and couple, with ISAs and without) + # and six people. + differences = { + (case, name): (values[4 * c], values[4 * c + 2]) + for c, case in enumerate(THRESHOLDS) + for name, values in outputs.items() + if values[4 * c] != values[4 * c + 2] + } + assert not differences + for c, (case, (age, total, balances, savings)) in enumerate(THRESHOLDS.items()): + capital = ( + "pension_credit_assessable_capital" + if age >= 66 + else "uc_assessable_capital" + ) + assert outputs[capital][4 * c] == savings / 3, case + couple = cash[6 * c + 1 : 6 * c + 3] + assert couple.sum(dtype=np.float64) == total, case + assert outputs["uc_tariff_income"][0] == 0 + assert outputs["is_uc_eligible"][4] + assert outputs["pension_credit_deemed_income"][8] == 0 + # The three holders' shares come to 15,999.9987 each. Each holding equals + # that share rounded down or up to a step of 1/256, so none passes the + # 16,000 limit its share stays under, and the three sum to cash_isa. + holders = cash[-3:] + assert list(holders) == [16_000, 16_000, 16_000 - 2**-8] + assert holders.sum(dtype=np.float64) == np.nextafter( + np.float32(48_000), np.float32(0) + ) + assert outputs["is_uc_eligible"][-3:].all() + + +LODGER = { + "people": { + "tenant": {"age": {YEAR: 30}}, + "lodger": { + "age": {YEAR: 30}, + "cash_isa_balance": {YEAR: 10_000}, + "stocks_and_shares_isa_balance": {YEAR: 4_000}, + }, + }, + "benunits": { + "tenant_unit": {"members": ["tenant"], "would_claim_uc": {YEAR: False}}, + "lodger_unit": {"members": ["lodger"], "would_claim_uc": {YEAR: False}}, + }, + "households": { + "household": {"members": ["tenant", "lodger"], "savings": {YEAR: 2_000}} + }, +} + + +@contextmanager +def _law(simulation, path, value): + """Change one parameter in place for YEAR, then restore it.""" + parameters = simulation.tax_benefit_system.parameters + original = list(parameters.get_child(path)(f"{YEAR}-01-01")) + + def update(new): + parameters.get_child(path).update(period=str(YEAR), value=new) + simulation.tax_benefit_system.reset_parameter_caches() + simulation.reset_calculations() + + update(value) + try: + yield + finally: + update(original) + + +def test_reforms_to_either_list_keep_each_isa_with_its_household_source(): + """Invariant 6, on one simulation whose law changes in place.""" + simulation = Simulation(situation=LODGER) + uc_path = CAPITAL["uc_assessable_capital"] + + def capital(name): + return list(simulation.calculate(name, YEAR)) + + assert capital("uc_assessable_capital") == [1_000, 15_000] + assert capital("housing_benefit_assessable_capital") == [1_000, 15_000] + sources = list(_node(uc_path + ".sources")(f"{YEAR}-01-01")) + # Dropping cash_isa from UC's sources: savings 2,000 shared, and the + # lodger keeps their 4,000 stocks and shares ISA. Dropping + # share_like_capital: the 10,000 cash ISA stays theirs. + for dropped, expected in ( + ("cash_isa", [1_000, 5_000]), + ("share_like_capital", [1_000, 11_000]), + ): + kept = [name for name in sources if name != dropped] + with _law(simulation, uc_path + ".sources", kept): + assert capital("uc_assessable_capital") == expected, dropped + # Emptying UC's person_sources returns both ISAs to the pool: savings + # 2,000 and the ISAs, 16,000 in all, shared by two claimants. Housing + # Benefit keeps its own lists. + with _law(simulation, uc_path + ".person_sources", []): + assert capital("uc_assessable_capital") == [8_000, 8_000] + assert capital("housing_benefit_assessable_capital") == [1_000, 15_000] + assert capital("uc_assessable_capital") == [1_000, 15_000] + + +@pytest.mark.parametrize("variable", CAPITAL) +@pytest.mark.parametrize("date", ["2016-04-05", "2025-01-01", "2040-01-01"]) +def test_lists_count_each_isa_once(variable, date): + """Each person ISA source sits beside the household source that holds it.""" + node = _node(CAPITAL[variable]) + person_sources = list(node.person_sources(date)) + sources = list(node.sources(date)) + assert [name for name in person_sources if name in ISA_SOURCES] == list(ISA_SOURCES) + assert ("lifetime_isa_countable_capital" in person_sources) == ( + date >= "2017-04-06" + ) + for name in ISA_SOURCES: + assert HOUSEHOLD_SOURCE_OF[name] in sources + assert name in system.variables + # The household pool never names the balances or the ISA total itself. + assert not {*BALANCE.values(), "stocks_and_shares_isa"} & set(sources) + + +def test_jsa_counts_isas_from_when_they_existed(): + """ISAs exist from 6 April 1999 (SI 1998/1870 reg 1).""" + node = _node(CAPITAL["jsa_income_assessable_capital"]) + assert list(node.person_sources("1999-04-05")) == [] + assert list(node.person_sources("1999-04-06")) == list(ISA_SOURCES) diff --git a/policyengine_uk/variables/gov/dwp/_person_capital_pool.py b/policyengine_uk/variables/gov/dwp/_person_capital_pool.py new file mode 100644 index 0000000000..94683e99d6 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/_person_capital_pool.py @@ -0,0 +1,147 @@ +"""Person-level capital that a household capital source already includes.""" + +from policyengine_uk.model_api import * + +# The formulas map each person source to the household source that already +# includes it, subtract its stored household sum, and allocate the remainder. +# cash_isa includes each cash ISA balance, and share_like_capital each stocks +# and shares ISA balance (through stocks_and_shares_isa, inside +# corporate_wealth or the itemised sum). No household source includes a person +# source missing here, such as a Lifetime ISA. +HOUSEHOLD_SOURCE_OF = { + "cash_isa_countable_capital": "cash_isa", + "stocks_and_shares_isa_countable_capital": "share_like_capital", +} + + +def counted_person_sources(capital) -> list: + """The person sources in ``capital.person_sources`` the formulas count. + + The formulas count a person source that a household source includes only + while ``capital.sources`` lists that household source. They move the part + of the household source the data tie to a person into the holder's own + benefit unit, so when the list drops the household source they count + neither. + """ + sources = set(capital.sources) + return [ + source + for source in capital.person_sources + if source not in HOUSEHOLD_SOURCE_OF or HOUSEHOLD_SOURCE_OF[source] in sources + ] + + +def pooled_person_capital(benunit, source, period, capital, leaves_pool=None): + """The part of the household ``source`` that counted person sources make up. + + The household sum, for each benefit unit, of the counted person sources + in ``capital.person_sources`` that ``source`` includes, over the members + ``leaves_pool`` selects (all members by default), as Core stores a total. + The formulas subtract it from ``source`` before they allocate the + remainder between benefit units, so they count each of those pounds only + in its holder's own unit. + """ + person = benunit.members + amount = 0 + for person_source in counted_person_sources(capital): + if HOUSEHOLD_SOURCE_OF.get(person_source) == source: + held = person(person_source, period) + if leaves_pool is not None: + held = held * leaves_pool + amount = amount + person.household.sum(held) + if isinstance(amount, int): + return 0 + return _as_stored(benunit.max(amount)) + + +def bounded_person_holding(person, balance, household_total): + """Each member's ``balance``, bounded by ``household_total``. + + ``household_total`` gives the household's total for each member. See + ``bounded_holdings``, which this calls with Core's household sum and + household rank. + """ + return bounded_holdings( + balance, + household_total, + person.household.sum, + lambda criteria: person.get_rank(person.household, criteria), + ) + + +def bounded_holdings(balance, household_total, household_sum, household_rank): + """Each member's holding: their ``balance``, bounded by the household total. + + ``household_total`` gives the household's total for each member. + ``household_sum`` maps a member array to each member's household sum, as + Core's ``person.household.sum`` does, and ``household_rank`` maps one to + each member's rank in their household, lowest first and ties in member + order, as Core's ``person.get_rank(person.household, ...)`` does. + + A negative or missing balance holds nothing, and an infinite one counts + as the whole total before scaling. Where the household's balances, summed + as Core stores a total, come to the nonnegative total or less, each member + holds their balance exactly, and the formulas leave the rest of the total + in the pool. Where they come to more, the function scales the balances to + the total in whole steps of the total's float32 spacing, which it caps at + 2**104, the largest spacing of a finite float32. It rounds each + proportional share down to a step, then gives the steps left over one + each to the members whose shares lost the most in rounding, the first of + them on a tie. Float32 represents every multiple of the step up to the + total exactly, so the holdings sum to the total exactly. For every + float32 balance and finite total: + + - no holding exceeds the household total, and none falls below zero; + - where the balances' stored sum exceeds the total, the holdings' float64 + sum equals the total, so the pool keeps nothing of it; + - where it doesn't, each holding equals the balance; + - a sole holder whose balance reaches the total holds exactly the total; + - each holding equals its proportional share, computed in float64, rounded + down or up to a whole step, so it lies within one step of that share + and passes a multiple of the step, such as a whole-pound capital + limit, only where the share does; + - a holding exceeds its balance by less than one step, and only where the + balance falls between steps; + - the same input gives the same output. + """ + total = np.maximum(np.float32(0), np.asarray(household_total, np.float32)) + balance = np.asarray(balance, np.float32) + finite = np.where(np.isfinite(balance), balance, np.where(balance > 0, total, 0)) + held = np.maximum(np.float32(0), finite).astype(np.float32) + held_sum = np.asarray(household_sum(held), np.float64) + over = _as_stored(held_sum) > total + if not over.any(): + return held + # A household that doesn't scale takes a total of 0 and a step of 1 here, + # so an infinite total there stays quiet. Elsewhere the step equals the + # total's own float32 spacing, as float64. At float32's largest value the + # next float32 is infinite, so np.spacing gives infinity there and warns + # of an overflow, which the formula silences. The cap at 2**104, the + # spacing of every float32 from 2**127 up, keeps that step finite and + # still divides the total. + scaled = np.where(over, total, np.float32(0)) + with np.errstate(over="ignore"): + step = np.minimum( + np.spacing(np.where(over, total, np.float32(1))), np.float32(2.0**104) + ).astype(np.float64) + # Float64 holds the product of two float32 amounts exactly, so each share + # rounds once, in the division, and never exceeds the total. + share = held * scaled.astype(np.float64) / np.where(over, held_sum, 1) + steps = np.floor(share / step) + lost = share / step - steps + # The total over its spacing gives a whole number, and the shares' whole + # steps never sum above it, so the steps left over come to a whole number + # at or above zero, and no more than the members whose shares lost part + # of a step. + left_over = scaled / step - household_sum(steps) + takes_a_step = over & (household_rank(-lost) < left_over) + return np.where(over, ((steps + takes_a_step) * step).astype(np.float32), held) + + +def _as_stored(amount): + # Core holds float variables in float32 but sums and maximises members in + # float64. Round the result as Core stores a household total such as + # cash_isa, so balances that make up a total net it to exactly zero and + # the pool keeps its float32 arithmetic. + with np.errstate(over="ignore"): + return np.asarray(amount).astype(np.float32) diff --git a/policyengine_uk/variables/gov/dwp/cash_isa_countable_capital.py b/policyengine_uk/variables/gov/dwp/cash_isa_countable_capital.py new file mode 100644 index 0000000000..7f3f66c465 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/cash_isa_countable_capital.py @@ -0,0 +1,37 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp._person_capital_pool import ( + bounded_person_holding, +) + + +class cash_isa_countable_capital(Variable): + label = "Cash ISA value counted as means-tested capital" + documentation = ( + "The person's cash ISA balance (cash_isa_balance) as the means tests count it, " + "in the holder's own benefit unit. The account investor beneficially owns an " + "ordinary ISA's investments, and the named child owns a junior ISA's " + "investments. The household's cash_isa already includes the balance, so each " + "means test subtracts it from the household pool before allocating the pool " + "between benefit units. The formula bounds the person's balance by cash_isa, " + "the household total. When the household's stored balance sum exceeds that " + "total, it scales the balances to sum to the total exactly, rounding each " + "proportional share down or up to a whole float32 step of the total. When the " + "balances sum to less, the rest of cash_isa stays in the pool, and a household " + "with no balances keeps all of cash_isa there." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = STOCK + reference = [ + "https://www.legislation.gov.uk/uksi/1998/1870/regulation/4", + "https://www.legislation.gov.uk/uksi/1998/1870/regulation/2B", + ] + + def formula(person, period, parameters): + return bounded_person_holding( + person, + person("cash_isa_balance", period), + person.household("cash_isa", period), + ) diff --git a/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py b/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py index 5487fc42a9..c4d0991e28 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py @@ -1,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp._person_capital_pool import ( + counted_person_sources, + pooled_person_capital, +) class esa_income_assessable_capital(Variable): @@ -6,17 +10,17 @@ class esa_income_assessable_capital(Variable): entity = BenUnit label = "Assessable capital for income-related ESA" documentation = ( - "Household capital apportioned to the benefit unit for the income-related " - "ESA capital test. Because the dataset only stores these stocks at " - "household level, the model allocates full household capital to any " - "benunit with a reported income-related ESA award and only falls back to " - "a claimant-and-partner share when nobody in the household is on that " - "reported claim path. This allocation is a PolicyEngine convention; " - "the data cannot identify ownership of capital. " - "Person-level sources, such as a Lifetime ISA, count only for the " - "holder's own benunit, and only when the holder is its claimant or " - "partner (is_claimant_or_partner): a dependant's capital is not the " - "claimant's." + "Household capital apportioned to the benefit unit for the income-related ESA " + "capital test. For capital whose owner the data do not identify, the model " + "allocates the full household remainder to any benunit with a reported " + "income-related ESA award and falls back to a claimant-and-partner share when " + "nobody in the household reports such an award. No regulation sets that " + "allocation; it approximates ownership the data don't record. The formula " + "counts person-level sources, such as a Lifetime ISA and the cash and stocks " + "and shares ISAs, only in the holder's own benunit and only for its claimant " + "or partner (is_claimant_or_partner), and excludes dependants' capital. It " + "subtracts the ISA balances from the household capital before allocating the " + "remainder." ) definition_period = YEAR unit = GBP @@ -25,12 +29,13 @@ class esa_income_assessable_capital(Variable): def formula(benunit, period, parameters): ESA = parameters(period).gov.dwp.ESA.income - sources = ESA.capital.sources person = benunit.members claiming_esa_income = add(benunit, period, ["esa_income_reported"]) > 0 household_capital = sum( - benunit.max(person.household(source, period)) for source in sources + benunit.max(person.household(source, period)) + - pooled_person_capital(benunit, source, period, ESA.capital) + for source in ESA.capital.sources ) # Regulation 83(2) excludes children's and young persons' capital. # The claimant/partner weights approximate otherwise unobserved ownership. @@ -51,7 +56,7 @@ def formula(benunit, period, parameters): claimant_or_partner = person("is_claimant_or_partner", period) person_capital = sum( benunit.sum(person(source, period) * claimant_or_partner) - for source in ESA.capital.person_sources + for source in counted_person_sources(ESA.capital) ) household_share = where( household_reporting_claimants > 0, claiming_proxy, fallback_proxy diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py index 2af26611c4..b8e4fdfd59 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py @@ -1,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp._person_capital_pool import ( + counted_person_sources, + pooled_person_capital, +) class housing_benefit_assessable_capital(Variable): @@ -6,20 +10,19 @@ class housing_benefit_assessable_capital(Variable): entity = BenUnit label = "Housing Benefit assessable capital" documentation = ( - "Housing Benefit capital counted from the configured capital sources. " - "Household sources are allocated across benunits in proportion to their " - "claimants and partners, a PolicyEngine convention because household " - "capital data cannot identify ownership; children and young persons add " - "no weight. Person-level sources, such as a Lifetime ISA, count only for " - "the holder's own benunit, and only when the holder is its claimant or " - "partner (is_claimant_or_partner): a dependant's capital is not the " - "claimant's. Where the Pension Credit award is savings credit only, the " - "Pension Credit assessment of capital is used instead. It is nil for a " - "family on Universal Credit, Income Support, income-based Jobseeker's " - "Allowance or income-related Employment and Support Allowance " - "(housing_benefit_on_passporting_benefit), and for a pension-age " - "family receiving a guarantee credit (the guarantee credit " - "passport)." + "Housing Benefit capital counted from the configured capital sources. The " + "formula counts person-level sources, such as a Lifetime ISA and the cash and " + "stocks and shares ISAs, only in the holder's own benunit and only for its " + "claimant or partner (is_claimant_or_partner), and excludes dependants' " + "capital. It subtracts the ISA balances from the household capital before " + "allocating the remainder. The formula allocates the unidentified household " + "remainder by claimant and partner counts; children and young persons add no " + "weight. For savings credit alone, it uses the Pension Credit capital " + "assessment. For a family on Universal Credit, Income Support, income-based " + "Jobseeker's Allowance or income-related Employment and Support Allowance " + "(housing_benefit_on_passporting_benefit), and for a pension-age family " + "receiving a guarantee credit (the guarantee credit passport), it returns " + "zero." ) definition_period = YEAR unit = GBP @@ -42,11 +45,15 @@ def formula(benunit, period, parameters): "housing_benefit_pension_age_regulations_apply", period ) p = parameters(period).gov.dwp.housing_benefit.means_test.capital - household_capital = sum(household(source, period) for source in p.sources) + household_capital = sum( + household(source, period) + - pooled_person_capital(benunit, source, period, p) + for source in p.sources + ) claimant_or_partner = person("is_claimant_or_partner", period) person_capital = sum( benunit.sum(person(source, period) * claimant_or_partner) - for source in p.person_sources + for source in counted_person_sources(p) ) benunit_claimants_and_partners = add( benunit, period, ["is_claimant_or_partner"] diff --git a/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py b/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py index 55c6f2aad1..82fd5a0cfe 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py @@ -1,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp._person_capital_pool import ( + counted_person_sources, + pooled_person_capital, +) class income_support_assessable_capital(Variable): @@ -7,15 +11,15 @@ class income_support_assessable_capital(Variable): label = "Assessable capital for Income Support" documentation = ( "Household capital apportioned to the benefit unit for the Income Support " - "capital test. Because the dataset only stores these stocks at household " - "level, the model allocates full household capital to any benunit on the " - "IS claim path and only falls back to a claimant-and-partner share when " - "nobody in the household is on that path. This allocation is a " - "PolicyEngine convention; the data cannot identify ownership of capital. " - "Person-level sources, such as a Lifetime ISA, count only for the " - "holder's own benunit, and only when the holder is its claimant or " - "partner (is_claimant_or_partner): a dependant's capital is not the " - "claimant's." + "capital test. For capital whose owner the data do not identify, the model " + "allocates the full household remainder to any benunit on the IS claim path " + "and falls back to a claimant-and-partner share when nobody in the household " + "claims on that path. No regulation sets that allocation; it approximates " + "ownership the data don't record. The formula counts person-level sources, " + "such as a Lifetime ISA and the cash and stocks and shares ISAs, only in the " + "holder's own benunit and only for its claimant or partner " + "(is_claimant_or_partner), and excludes dependants' capital. It subtracts the " + "ISA balances from the household capital before allocating the remainder." ) definition_period = YEAR unit = GBP @@ -24,7 +28,7 @@ class income_support_assessable_capital(Variable): def formula(benunit, period, parameters): IS = parameters(period).gov.dwp.income_support - sources = IS.means_test.capital.sources + capital = IS.means_test.capital person = benunit.members would_claim_is = benunit("would_claim_IS", period) @@ -35,7 +39,9 @@ def formula(benunit, period, parameters): # weights. Regulation 23 excludes children's and young persons' capital; # the household allocation itself is a modelling convention. household_capital = sum( - benunit.max(person.household(source, period)) for source in sources + benunit.max(person.household(source, period)) + - pooled_person_capital(benunit, source, period, capital) + for source in capital.sources ) benunit_claimants_and_partners = add( benunit, period, ["is_claimant_or_partner"] @@ -57,7 +63,7 @@ def formula(benunit, period, parameters): claimant_or_partner = person("is_claimant_or_partner", period) person_capital = sum( benunit.sum(person(source, period) * claimant_or_partner) - for source in IS.means_test.capital.person_sources + for source in counted_person_sources(capital) ) household_share = where( household_claiming_members > 0, claiming_proxy, fallback_proxy diff --git a/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py b/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py index ec3a6631ee..2cf466c410 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py @@ -1,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp._person_capital_pool import ( + counted_person_sources, + pooled_person_capital, +) class jsa_income_assessable_capital(Variable): @@ -6,17 +10,17 @@ class jsa_income_assessable_capital(Variable): entity = BenUnit label = "Assessable capital for income-based JSA" documentation = ( - "Household capital apportioned to the benefit unit for the income-based " - "JSA capital test. Because the dataset only stores these stocks at " - "household level, the model allocates full household capital to any " - "benunit with a reported income-based JSA award and only falls back to " - "a claimant-and-partner share when nobody in the household is on that " - "reported claim path. This allocation is a PolicyEngine convention; " - "the data cannot identify ownership of capital. " - "Person-level sources, such as a Lifetime ISA, count only for the " - "holder's own benunit, and only when the holder is its claimant or " - "partner (is_claimant_or_partner): a dependant's capital is not the " - "claimant's." + "Household capital apportioned to the benefit unit for the income-based JSA " + "capital test. For capital whose owner the data do not identify, the model " + "allocates the full household remainder to any benunit with a reported " + "income-based JSA award and falls back to a claimant-and-partner share when " + "nobody in the household reports such an award. No regulation sets that " + "allocation; it approximates ownership the data don't record. The formula " + "counts person-level sources, such as a Lifetime ISA and the cash and stocks " + "and shares ISAs, only in the holder's own benunit and only for its claimant " + "or partner (is_claimant_or_partner), and excludes dependants' capital. It " + "subtracts the ISA balances from the household capital before allocating the " + "remainder." ) definition_period = YEAR unit = GBP @@ -25,12 +29,13 @@ class jsa_income_assessable_capital(Variable): def formula(benunit, period, parameters): JSA = parameters(period).gov.dwp.JSA.income - sources = JSA.capital.sources person = benunit.members claiming_jsa_income = add(benunit, period, ["jsa_income_reported"]) > 0 household_capital = sum( - benunit.max(person.household(source, period)) for source in sources + benunit.max(person.household(source, period)) + - pooled_person_capital(benunit, source, period, JSA.capital) + for source in JSA.capital.sources ) # Regulation 88(2) excludes children's and young persons' capital. # The claimant/partner weights approximate otherwise unobserved ownership. @@ -51,7 +56,7 @@ def formula(benunit, period, parameters): claimant_or_partner = person("is_claimant_or_partner", period) person_capital = sum( benunit.sum(person(source, period) * claimant_or_partner) - for source in JSA.capital.person_sources + for source in counted_person_sources(JSA.capital) ) household_share = where( household_reporting_claimants > 0, claiming_proxy, fallback_proxy diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py index 5803c56af2..1e009aa3a3 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py @@ -1,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp._person_capital_pool import ( + counted_person_sources, + pooled_person_capital, +) class pension_credit_assessable_capital(Variable): @@ -6,19 +10,18 @@ class pension_credit_assessable_capital(Variable): entity = BenUnit label = "Pension Credit assessable capital" documentation = ( - "Pension Credit capital counted from the configured capital sources, " - "split only across pension-age adults in the household so pensioner " - "couples pool capital together without dilution by unrelated working-" - "age adults. Person-level sources, such as a Lifetime ISA, count " - "only for the holder's own benunit, and only when the holder is its " - "claimant or partner (is_claimant_or_partner): a dependant's capital " - "is not the claimant's. Where `pension_credit_reported_capital` records " - "the benefit unit's own capital (0 or more), it replaces the " - "household proxy and the person-level sources. Unlike " - "`uc_assessable_capital`, another benefit unit's recorded capital is " - "not subtracted from the household capital that an unrecorded unit's " - "proxy shares out: the recorded figure and the household sources come " - "from different measures, so the proxy is left as it was." + "Pension Credit capital counted from the configured capital sources. The " + "formula allocates the household remainder only across the household's " + "pension-age adults, so unrelated working-age adults leave a pensioner " + "couple's share undiluted. The formula counts person-level sources, such as a " + "Lifetime ISA and the cash and stocks and shares ISAs, only in the holder's " + "own benunit and only for its claimant or partner (is_claimant_or_partner), " + "and excludes dependants' capital. It subtracts the ISA balances from the " + "household capital before allocating the remainder. A nonnegative " + "`pension_credit_reported_capital` replaces the unit's calculated capital. The " + "formula does not subtract another unit's capital report from the household " + "remainder before allocating that remainder. The report and household sources " + "measure different amounts." ) definition_period = YEAR unit = GBP @@ -28,11 +31,15 @@ def formula(benunit, period, parameters): household = benunit.household person = benunit.members p = parameters(period).gov.dwp.pension_credit.income.capital - household_capital = sum(household(source, period) for source in p.sources) + household_capital = sum( + household(source, period) + - pooled_person_capital(benunit, source, period, p) + for source in p.sources + ) claimant_or_partner = person("is_claimant_or_partner", period) person_capital = sum( benunit.sum(person(source, period) * claimant_or_partner) - for source in p.person_sources + for source in counted_person_sources(p) ) any_pension_age = benunit.any( person("has_attained_state_pension_credit_qualifying_age", period) diff --git a/policyengine_uk/variables/gov/dwp/stocks_and_shares_isa_countable_capital.py b/policyengine_uk/variables/gov/dwp/stocks_and_shares_isa_countable_capital.py new file mode 100644 index 0000000000..38e90fab4d --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/stocks_and_shares_isa_countable_capital.py @@ -0,0 +1,44 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp._person_capital_pool import ( + bounded_person_holding, +) + + +class stocks_and_shares_isa_countable_capital(Variable): + label = "Stocks and shares ISA value counted as means-tested capital" + documentation = ( + "The person's stocks and shares ISA balance (stocks_and_shares_isa_balance) as " + "the means tests count it, in the holder's own benefit unit. The account " + "investor beneficially owns an ordinary ISA's investments, and the named child " + "owns a junior ISA's investments. The household's share_like_capital already " + "includes the balance, through stocks_and_shares_isa within corporate_wealth " + "or the itemised sum, so each means test subtracts it from the household pool " + "before allocating the pool between benefit units. The formula bounds the " + "person's balance by the smaller of stocks_and_shares_isa and " + "share_like_capital, so the household never counts more stocks and shares ISA " + "than it holds. When the household's stored balance sum exceeds that total, it " + "scales the balances to sum to the total exactly, rounding each proportional " + "share down or up to a whole float32 step of the total. When the balances sum " + "to less, the rest stays in the pool, and a household with no balances keeps " + "all of share_like_capital there." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = STOCK + reference = [ + "https://www.legislation.gov.uk/uksi/1998/1870/regulation/4", + "https://www.legislation.gov.uk/uksi/1998/1870/regulation/2B", + ] + + def formula(person, period, parameters): + household_total = min_( + person.household("stocks_and_shares_isa", period), + person.household("share_like_capital", period), + ) + return bounded_person_holding( + person, + person("stocks_and_shares_isa_balance", period), + household_total, + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py index 564f64588d..11ca5eac23 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py @@ -1,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp._person_capital_pool import ( + counted_person_sources, + pooled_person_capital, +) class uc_assessable_capital(Variable): @@ -7,26 +11,26 @@ class uc_assessable_capital(Variable): label = "Universal Credit assessable capital" documentation = ( "Countable capital of the claimant and partner. An explicit nonnegative " - "uc_reported_capital, including zero, replaces ordinary capital sources " - "before the existing regulation 77 company substitution. Otherwise " - "a nonnegative benunit_reported_capital replaces financial household " - "and person-level proxies only; it does not replace countable property. " - "Complete overrides, otherwise observed financial totals, reduce the " - "household financial pool before " - "its residual is allocated by claimant/partner counts to units with " - "neither report. Under this inherited financial convention, another " - "unit's financial report or complete override can change a " - "missing-report unit's capital. Subtracting a complete override is " - "a pool-allocation convention, not an observed asset breakdown. Property " - "is valued separately in the assessment year and allocated across ALL " - "claimant/partner adults, including non-UC units and complete-override " - "units, whose shares are consumed rather than redistributed. Property " - "allocation depends only on claimant/partner adult counts and is " - "independent of other units' financial reports. These " - "allocations are assumptions about unobserved ownership, not law. " - "Person-level proxies count only claimants/partners, not dependants. " - "Under regulation 77, disregarded company holdings are replaced by " - "the claimant/partner's share of company capital." + "uc_reported_capital, including zero, replaces ordinary capital sources before " + "the regulation 77 company substitution. Otherwise a nonnegative " + "benunit_reported_capital replaces financial household and person-level " + "proxies only; it does not replace countable property. The formula subtracts " + "complete overrides, otherwise observed financial totals, from the household " + "financial pool, and allocates the financial remainder by claimant and partner " + "counts among units without either report. Under this inherited convention, " + "another unit's financial report or complete override can change a " + "missing-report unit's capital. The formula values property separately in the " + "assessment year and allocates it across all claimant and partner adults, " + "including non-UC units and complete-override units, whose shares it doesn't " + "redistribute; other units' financial reports leave that allocation unchanged. " + "These allocations assume ownership the data don't record, and no regulation " + "sets them. Person-level proxies count only claimants and partners, not " + "dependants. The formula subtracts each bounded cash and stocks and shares ISA " + "balance from its household source and counts it in the holder's unit, except " + "for the claimant and partner of a unit that reports its capital: it leaves " + "their ISAs in the pool and subtracts the report, as for their company " + "holdings. Under regulation 77, it replaces disregarded company holdings with " + "the claimant's or partner's share of company capital." ) definition_period = YEAR unit = GBP @@ -41,14 +45,24 @@ class uc_assessable_capital(Variable): def formula(benunit, period, parameters): household = benunit.household p = parameters(period).gov.dwp.universal_credit.means_test.capital + person = benunit.members + claimant_or_partner = person("is_uc_claimant", period) + # For a unit with a report or complete override, leave its claimant's + # and partner's ISAs in the pool and subtract the report, as for their + # company holdings. Subtract every other person's ISA here. + covered = claimant_or_partner & ( + (person.benunit("benunit_reported_capital", period) >= 0) + | (person.benunit("uc_reported_capital", period) >= 0) + ) household_financial_capital = sum( - household(source, period) for source in p.sources + household(source, period) + - pooled_person_capital(benunit, source, period, p, ~covered) + for source in p.sources ) - claimant_or_partner = benunit.members("is_uc_claimant", period) benunit_claimants = benunit.sum(claimant_or_partner) person_capital = sum( - benunit.sum(benunit.members(source, period) * claimant_or_partner) - for source in p.person_sources + benunit.sum(person(source, period) * claimant_or_partner) + for source in counted_person_sources(p) ) financial_report = benunit("benunit_reported_capital", period) complete_override = benunit("uc_reported_capital", period) diff --git a/policyengine_uk/variables/input/cash_isa_balance.py b/policyengine_uk/variables/input/cash_isa_balance.py index 152cbde845..f63b25458e 100644 --- a/policyengine_uk/variables/input/cash_isa_balance.py +++ b/policyengine_uk/variables/input/cash_isa_balance.py @@ -8,7 +8,9 @@ class cash_isa_balance(Variable): "(lifetime_isa_balance). The household's cash_isa sums it over its " "members, so a dataset that stores cash_isa itself keeps that stored " "total instead. It is separate from savings, the household's ordinary " - "savings." + "savings. A child owns their own junior cash ISA. The means-test formulas " + "count the balance in its holder's benefit unit, for the claimant and " + "partner only (cash_isa_countable_capital)." ) entity = Person definition_period = YEAR diff --git a/policyengine_uk/variables/input/stocks_and_shares_isa_balance.py b/policyengine_uk/variables/input/stocks_and_shares_isa_balance.py index 23f0d1a8fd..b6907ada4e 100644 --- a/policyengine_uk/variables/input/stocks_and_shares_isa_balance.py +++ b/policyengine_uk/variables/input/stocks_and_shares_isa_balance.py @@ -8,8 +8,11 @@ class stocks_and_shares_isa_balance(Variable): "Lifetime ISAs excluded (lifetime_isa_balance). The household's " "stocks_and_shares_isa sums it over its members, so a dataset that " "stores stocks_and_shares_isa itself keeps that stored total instead. " - "Like that total, it is part of corporate_wealth, so the two must " - "never be summed." + "corporate_wealth includes it, as it includes that total, so no " + "formula may add the two. A child owns their own junior stocks and shares " + "ISA. The means-test formulas count the balance in its holder's " + "benefit unit, for the claimant and partner only " + "(stocks_and_shares_isa_countable_capital)." ) entity = Person definition_period = YEAR