diff --git a/changelog.d/uc-capital-sale-expenses.fixed.md b/changelog.d/uc-capital-sale-expenses.fixed.md new file mode 100644 index 0000000000..02d0588143 --- /dev/null +++ b/changelog.d/uc-capital-sale-expenses.fixed.md @@ -0,0 +1 @@ +Value Universal Credit, Housing Benefit, Income Support, income-based JSA, income-related ESA and Pension Credit capital at market value less 10% where a sale would incur expenses and less any debt secured on the asset, as UC Regs 2013 reg. 49(1) and its legacy equivalents require. The 10% applies to land, property and share-like capital (share_like_capital), not to cash savings; share-like capital also holds unit trusts, which have no costs of sale, and in datasets built before the private pension split it holds pension wealth, so the deduction reaches those too until the data separate them. Adds secured-debt inputs for other residential property, non-residential property and land, and the council tax reduction formula deducts the debt secured on land from the land on its own household route. Each programme rounds a fraction of a penny in its capital calculations as its regulations require: to the nearest penny for Universal Credit (UC Regs 2013 reg. 6(1)) and Housing Benefit, and down for the legacy benefits and Pension Credit. The model works out a Lifetime ISA's surrender value from its balance and rounds it under the same rule, and keeps explicit Lifetime ISA capital inputs and reformed valuation formulas. diff --git a/docs/book/assumptions/growthfactors.md b/docs/book/assumptions/growthfactors.md index 501dd5c9e2..2be060c349 100644 --- a/docs/book/assumptions/growthfactors.md +++ b/docs/book/assumptions/growthfactors.md @@ -106,7 +106,7 @@ We derive these rates as calendar-year nominal GDP growth from the OBR's October |-------------|------|------|------|------|------|------|------|------|------|------| | Growth rate | 10.0% | 5.2% | 4.0% | 4.6% | 4.0% | 3.1% | 3.4% | 3.6% | 3.4% | 3.3% | -We use per capita GDP for these variables: `benunit_reported_capital`, `cash_isa`, `cash_isa_balance`, `consumer_debt`, `corporate_wealth`, `directly_held_shares`, `dividend_income`, `gross_financial_wealth`, `household_lifetime_isa_balance`, `income_not_otherwise_charged`, `individual_savings_account_dividend_income`, `lifetime_isa_balance`, `lump_sum_income`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `mortgage_debt`, `net_financial_wealth`, `non_residential_property_purchased`, `non_residential_property_secured_debt`, `non_residential_property_value`, `other_residential_property_secured_debt`, `owned_company_capital`, `owned_company_holding_value`, `owned_company_trade_assets`, `owned_land`, `pension_credit_reported_capital`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `property_rental_income`, `rent_paid_as_boarder`, `rent_paid_as_lodger`, `savings`, `spousal_maintenance_income`, `stocks_and_shares_isa`, `stocks_and_shares_isa_balance`, `sublet_income`, `uc_reported_capital`, `unit_and_investment_trusts` +We use per capita GDP for these variables: `benunit_reported_capital`, `cash_isa`, `cash_isa_balance`, `consumer_debt`, `corporate_wealth`, `directly_held_shares`, `dividend_income`, `gross_financial_wealth`, `household_lifetime_isa_balance`, `income_not_otherwise_charged`, `individual_savings_account_dividend_income`, `lifetime_isa_balance`, `lump_sum_income`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `mortgage_debt`, `net_financial_wealth`, `non_residential_property_purchased`, `non_residential_property_secured_debt`, `non_residential_property_value`, `other_residential_property_secured_debt`, `owned_company_capital`, `owned_company_holding_value`, `owned_company_trade_assets`, `owned_land`, `owned_land_secured_debt`, `pension_credit_reported_capital`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `property_rental_income`, `rent_paid_as_boarder`, `rent_paid_as_lodger`, `savings`, `spousal_maintenance_income`, `stocks_and_shares_isa`, `stocks_and_shares_isa_balance`, `sublet_income`, `uc_reported_capital`, `unit_and_investment_trusts` ## Capital gains diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index c8d64451d8..dedf12a914 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -137,6 +137,7 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: # Non-residential purchase prices and land retain GDP-per-capita growth. - non_residential_property_purchased - owned_land +- owned_land_secured_debt # A formula of benunit_reported_capital; listed so a dataset that stores it # directly is uprated like the input. - pension_credit_reported_capital diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/round_down.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/round_down.yaml new file mode 100644 index 0000000000..2a17ce9a84 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/round_down.yaml @@ -0,0 +1,17 @@ +description: > + Whether a fraction of a penny in an income-related Employment and Support + Allowance capital calculation is disregarded (true) rather than rounded to + the nearest penny (false). A fraction of a penny is treated as a penny + only where that is to the claimant's advantage, and is otherwise + disregarded; for capital that means rounding down. +values: + 2008-10-27: true +metadata: + label: Income-related ESA capital rounded down to the penny + period: year + unit: bool + reference: + - title: The Employment and Support Allowance Regulations 2008 reg. 3(a) + href: https://www.legislation.gov.uk/uksi/2008/794/regulation/3 + - title: The Employment and Support Allowance Regulations (Northern Ireland) 2008 reg. 3(a) + href: https://www.legislation.gov.uk/nisr/2008/280/regulation/3 diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/rate.yaml new file mode 100644 index 0000000000..e360c3ff9a --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/rate.yaml @@ -0,0 +1,18 @@ +description: > + Share of the current market or surrender value deducted from income-related + ESA capital where there would be expenses attributable to sale. It is a flat + 10% whatever the actual costs, taken from the gross value before any + encumbrance secured on the asset is deducted. +values: + 2008-10-27: 0.1 +metadata: + label: Income-related ESA capital sale expense deduction + period: year + unit: /1 + reference: + - title: Employment and Support Allowance Regulations 2008 reg. 113(a) + href: https://www.legislation.gov.uk/uksi/2008/794/regulation/113 + - title: Employment and Support Allowance Regulations (Northern Ireland) 2008 reg. 113(a) + href: https://www.legislation.gov.uk/nisr/2008/280/regulation/113 + - title: Decision Makers' Guide chapter 52, para. 52624 + href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=116 diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml new file mode 100644 index 0000000000..1f973f433f --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml @@ -0,0 +1,40 @@ +description: > + Capital sources whose sale would incur expenses, so that income-related ESA + values them at 90% of market value. Costs of sale normally arise where an + agent, broker, auctioneer, solicitor or accountant is needed, and real + property always has them (DMG 52622), so `owned_land`, + `other_residential_property_value` and `non_residential_property_value` are + listed. `savings` is not listed: a bank or building society account is + valued at its balance, and the costs of withdrawing money are not costs of + sale (DMG 52620-52623 and 52685). `share_like_capital` (`corporate_wealth`, + or its itemised components where they exceed it) is listed because shares + are valued less 10% for the costs of selling them (DMG 52671). It is a mixed + variable, though: it also holds unit trusts, which have no costs of sale + (DMG 52681), stocks and shares ISAs and employee share options, and datasets + built before private pension wealth was split out also fold + non-defined-benefit pension wealth into it, which is not capital at all. The + deduction reaches all of these until the data separate them. Sources not + counted as income-related ESA capital are ignored. +metadata: + label: Income-related ESA capital sources with sale expenses + period: year + unit: list + reference: + - title: Employment and Support Allowance Regulations 2008 reg. 113(a) + href: https://www.legislation.gov.uk/uksi/2008/794/regulation/113 + - title: Employment and Support Allowance Regulations (Northern Ireland) 2008 reg. 113(a) + href: https://www.legislation.gov.uk/nisr/2008/280/regulation/113 + - title: Decision Makers' Guide chapter 52, paras. 52620-52622 + href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=115 + - title: Decision Makers' Guide chapter 52, para. 52623 + href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=116 + - title: Decision Makers' Guide chapter 52, para. 52671 + href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=126 + - title: Decision Makers' Guide chapter 52, paras. 52681 and 52685 + href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=128 +values: + 2008-10-27: + - owned_land + - other_residential_property_value + - non_residential_property_value + - share_like_capital diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/round_down.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/round_down.yaml new file mode 100644 index 0000000000..262e1207b7 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/round_down.yaml @@ -0,0 +1,17 @@ +description: > + Whether a fraction of a penny in an income-based Jobseeker's Allowance + capital calculation is disregarded (true) rather than rounded to the + nearest penny (false). A fraction of a penny is treated as a penny only + where that is to the claimant's advantage, and is otherwise disregarded; + for capital that means rounding down. +values: + 1996-10-07: true +metadata: + label: Income-based JSA capital rounded down to the penny + period: year + unit: bool + reference: + - title: The Jobseeker's Allowance Regulations 1996 reg. 92 + href: https://www.legislation.gov.uk/uksi/1996/207/regulation/92 + - title: The Jobseeker's Allowance Regulations (Northern Ireland) 1996 reg. 92 + href: https://www.legislation.gov.uk/nisr/1996/198/regulation/92 diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/rate.yaml new file mode 100644 index 0000000000..e67f83c653 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/rate.yaml @@ -0,0 +1,21 @@ +description: > + Share of the current market or surrender value deducted from income-based + JSA capital where there would be expenses attributable to sale. It is a flat + 10% whatever the actual costs, taken from the gross value before any + encumbrance secured on the asset is deducted. Before 1 October 2007 the same + deduction sat in reg. 111(a)(i) alongside a separate valuation for National + Savings Certificates; SI 2007/2618 substituted the regulation in its current + form. +values: + 1996-10-07: 0.1 +metadata: + label: Income-based JSA capital sale expense deduction + period: year + unit: /1 + reference: + - title: Jobseeker's Allowance Regulations 1996 reg. 111(a) + href: https://www.legislation.gov.uk/uksi/1996/207/regulation/111 + - title: Jobseeker's Allowance Regulations (Northern Ireland) 1996 reg. 111(a), as substituted by S.R. 2007/396 reg. 7(11) + href: https://www.legislation.gov.uk/nisr/2007/396/regulation/7 + - title: Decision Makers' Guide chapter 29, para. 29624 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=125 diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml new file mode 100644 index 0000000000..f193f938cf --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml @@ -0,0 +1,38 @@ +description: > + Capital sources whose sale would incur expenses, so that income-based JSA + values them at 90% of market value. Costs of sale normally arise where an + agent, broker, auctioneer, solicitor or accountant is needed, and real + property always has them (DMG 29622), so `owned_land`, + `other_residential_property_value` and `non_residential_property_value` are + listed. `savings` is not listed: a bank or building society account is + valued at its balance, and the costs of withdrawing money are not costs of + sale (DMG 29620-29623 and 29685). `share_like_capital` (`corporate_wealth`, + or its itemised components where they exceed it) is listed because shares + are valued less 10% for the costs of selling them (DMG 29671). It is a mixed + variable, though: it also holds unit trusts, which have no costs of sale + (DMG 29681), stocks and shares ISAs and employee share options, and datasets + built before private pension wealth was split out also fold non- + defined-benefit pension wealth into it, which is not capital at all. The + deduction reaches all of these until the data separate them. Sources not + counted as income-based JSA capital are ignored. +metadata: + label: Income-based JSA capital sources with sale expenses + period: year + unit: list + reference: + - title: Jobseeker's Allowance Regulations 1996 reg. 111(a) + href: https://www.legislation.gov.uk/uksi/1996/207/regulation/111 + - title: Jobseeker's Allowance Regulations (Northern Ireland) 1996 reg. 111(a), as substituted by S.R. 2007/396 reg. 7(11) + href: https://www.legislation.gov.uk/nisr/2007/396/regulation/7 + - title: Decision Makers' Guide chapter 29, paras. 29620-29623 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=124 + - title: Decision Makers' Guide chapter 29, para. 29671 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=137 + - title: Decision Makers' Guide chapter 29, paras. 29681 and 29685 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=139 +values: + 1996-10-07: + - owned_land + - other_residential_property_value + - non_residential_property_value + - share_like_capital diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/round_down.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/round_down.yaml new file mode 100644 index 0000000000..30153e66fb --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/round_down.yaml @@ -0,0 +1,22 @@ +description: > + Whether a fraction of a penny in a Housing Benefit capital calculation is + disregarded (true) rather than rounded to the nearest penny (false). Any + amount determined under the regulations may, if appropriate, be rounded to + the nearest whole penny, disregarding less than half a penny and treating + half a penny or more as a penny. That is the regulations' only rounding + rule, and the model applies it. +values: + 2006-04-10: false +metadata: + label: Housing Benefit capital rounded down to the penny + period: year + unit: bool + reference: + - title: The Housing Benefit Regulations 2006 reg. 80 + href: https://www.legislation.gov.uk/uksi/2006/213/regulation/80 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg. 61(6) + href: https://www.legislation.gov.uk/uksi/2006/214/regulation/61 + - title: The Housing Benefit Regulations (Northern Ireland) 2006 reg. 78(8) + href: https://www.legislation.gov.uk/nisr/2006/405/regulation/78 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations (Northern Ireland) 2006 reg. 59(7) + href: https://www.legislation.gov.uk/nisr/2006/406/regulation/59 diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/rate.yaml new file mode 100644 index 0000000000..45b58092df --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/rate.yaml @@ -0,0 +1,27 @@ +description: > + Share of the current market or surrender value deducted from Housing Benefit + capital where there would be expenses attributable to sale. It is a flat 10% + whatever the actual costs, taken from the gross value before any encumbrance + secured on the asset is deducted. Before 1 October 2007 the same deduction + sat in reg. 47(a)(i) (reg. 45(a)(i) at pension age) alongside a separate + valuation for National Savings Certificates; SI 2007/2618 substituted the + regulation in its current form. +values: + 2006-04-10: 0.1 +metadata: + label: Housing Benefit capital sale expense deduction + period: year + unit: /1 + reference: + - title: The Housing Benefit Regulations 2006 reg. 47(a) + href: https://www.legislation.gov.uk/uksi/2006/213/regulation/47 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg. 45(a) + href: https://www.legislation.gov.uk/uksi/2006/214/regulation/45 + - title: The Housing Benefit Regulations (Northern Ireland) 2006 reg. 44(a) + href: https://www.legislation.gov.uk/nisr/2006/405/regulation/44 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations (Northern Ireland) 2006 reg. 43(a) + href: https://www.legislation.gov.uk/nisr/2006/406/regulation/43 + - title: Housing Benefit Guidance Manual BW1, para. W1.430 + href: https://assets.publishing.service.gov.uk/media/5a749328ed915d0e8e3994c0/hbgm-bw1-assessment-of-capital.pdf#page=36 + - title: MH v London Borough of Waltham Forest (HB) [2016] UKUT 161 (AAC), para. 43 + href: https://www.gov.uk/administrative-appeals-tribunal-decisions/mh-v-london-borough-of-waltham-forest-hb-2016-ukut-161-aac diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml new file mode 100644 index 0000000000..2211212721 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml @@ -0,0 +1,53 @@ +description: > + Capital sources whose sale would incur expenses, so that Housing Benefit + values them at 90% of market value. Costs of sale normally arise where an + agent, broker, auctioneer, solicitor or accountant is needed, and real + property always has them (R(IS) 21/93, as DMG 29622 applies it; HBGM W1.430 + states the general 10% rule), so `owned_land`, + `other_residential_property_value` and `non_residential_property_value` are + listed. `savings` is not listed: a bank or building society account is + valued at its balance, and the costs of withdrawing money are not costs of + sale (HBGM BW1 Annex F and BP1 F2.03). `share_like_capital` + (`corporate_wealth`, or its itemised components where they exceed it) is + listed because shares are valued less 10% for the costs of selling them + (HBGM W1.513). It is a mixed variable, though: it also holds unit trusts, + which have no costs of sale (HBGM W1.530-W1.531), stocks and shares ISAs and + employee share options, and datasets built before private pension wealth was + split out also fold non-defined-benefit pension wealth into it, which is not + capital at all. The deduction reaches all of these until the data separate + them. Sources not counted as Housing Benefit capital are ignored. +metadata: + label: Housing Benefit capital sources with sale expenses + period: year + unit: list + reference: + - title: The Housing Benefit Regulations 2006 reg. 47(a) + href: https://www.legislation.gov.uk/uksi/2006/213/regulation/47 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg. 45(a) + href: https://www.legislation.gov.uk/uksi/2006/214/regulation/45 + - title: The Housing Benefit Regulations (Northern Ireland) 2006 reg. 44(a) + href: https://www.legislation.gov.uk/nisr/2006/405/regulation/44 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations (Northern Ireland) 2006 reg. 43(a) + href: https://www.legislation.gov.uk/nisr/2006/406/regulation/43 + - title: Housing Benefit Guidance Manual BW1, para. W1.430 + href: https://assets.publishing.service.gov.uk/media/5a749328ed915d0e8e3994c0/hbgm-bw1-assessment-of-capital.pdf#page=36 + - title: Housing Benefit Guidance Manual BP1, para. P1.430 + href: https://assets.publishing.service.gov.uk/media/5a7ec5d840f0b6230268b71f/hbgm-bp1-assessment-of-capital.pdf#page=31 + - title: Decision Makers' Guide chapter 29, para. 29622 (real property, R(IS) 21/93) + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=124 + - title: Housing Benefit Guidance Manual BW1, para. W1.513 + href: https://assets.publishing.service.gov.uk/media/5a749328ed915d0e8e3994c0/hbgm-bw1-assessment-of-capital.pdf#page=43 + - title: Housing Benefit Guidance Manual BW1, paras. W1.530-W1.531 + href: https://assets.publishing.service.gov.uk/media/5a749328ed915d0e8e3994c0/hbgm-bw1-assessment-of-capital.pdf#page=43 + - title: Housing Benefit Guidance Manual BP1, paras. P1.513 and P1.530-P1.531 + href: https://assets.publishing.service.gov.uk/media/5a7ec5d840f0b6230268b71f/hbgm-bp1-assessment-of-capital.pdf#page=38 + - title: Housing Benefit Guidance Manual BW1, Annex F + href: https://assets.publishing.service.gov.uk/media/5a749328ed915d0e8e3994c0/hbgm-bw1-assessment-of-capital.pdf#page=93 + - title: Housing Benefit Guidance Manual BP1, paras. F2.00-F2.03 + href: https://assets.publishing.service.gov.uk/media/5a7ec5d840f0b6230268b71f/hbgm-bp1-assessment-of-capital.pdf#page=83 +values: + 2006-04-10: + - owned_land + - other_residential_property_value + - non_residential_property_value + - share_like_capital diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/round_down.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/round_down.yaml new file mode 100644 index 0000000000..b628746c46 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/round_down.yaml @@ -0,0 +1,18 @@ +description: > + Whether a fraction of a penny in an Income Support capital calculation is + disregarded (true) rather than rounded to the nearest penny (false). A + fraction of a penny is treated as a penny only where that is to the + claimant's advantage, and is otherwise disregarded; for capital that means + rounding down. The Northern Ireland regulation disregards any fraction of + a penny in capital. +values: + 2010-01-01: true +metadata: + label: Income Support capital rounded down to the penny + period: year + unit: bool + reference: + - title: The Income Support (General) Regulations 1987 reg. 27 + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/27 + - title: The Income Support (General) Regulations (Northern Ireland) 1987 reg. 27 + href: https://www.legislation.gov.uk/nisr/1987/459/regulation/27 diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/rate.yaml new file mode 100644 index 0000000000..dd572337df --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/rate.yaml @@ -0,0 +1,20 @@ +description: > + Share of the current market or surrender value deducted from Income Support + capital where there would be expenses attributable to sale. It is a flat 10% + whatever the actual costs, taken from the gross value before any encumbrance + secured on the asset is deducted. Before 1 October 2007 the same deduction + sat in reg. 49(a)(i) alongside a separate valuation for National Savings + Certificates; SI 2007/2618 substituted the regulation in its current form. +values: + 2010-01-01: 0.1 +metadata: + label: Income Support capital sale expense deduction + period: year + unit: /1 + reference: + - title: Income Support (General) Regulations 1987 reg. 49(a) + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/49 + - title: Income Support (General) Regulations (Northern Ireland) 1987 reg. 49(a), as substituted by S.R. 2007/396 reg. 4(10) + href: https://www.legislation.gov.uk/nisr/2007/396/regulation/4 + - title: Decision Makers' Guide chapter 29, para. 29624 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=125 diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml new file mode 100644 index 0000000000..182aa6ad60 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml @@ -0,0 +1,38 @@ +description: > + Capital sources whose sale would incur expenses, so that Income Support + values them at 90% of market value. Costs of sale normally arise where an + agent, broker, auctioneer, solicitor or accountant is needed, and real + property always has them (DMG 29622), so `owned_land`, + `other_residential_property_value` and `non_residential_property_value` are + listed. `savings` is not listed: a bank or building society account is + valued at its balance, and the costs of withdrawing money are not costs of + sale (DMG 29620-29623 and 29685). `share_like_capital` (`corporate_wealth`, + or its itemised components where they exceed it) is listed because shares + are valued less 10% for the costs of selling them (DMG 29671). It is a mixed + variable, though: it also holds unit trusts, which have no costs of sale + (DMG 29681), stocks and shares ISAs and employee share options, and datasets + built before private pension wealth was split out also fold non- + defined-benefit pension wealth into it, which is not capital at all. The + deduction reaches all of these until the data separate them. Sources not + counted as Income Support capital are ignored. +metadata: + label: Income Support capital sources with sale expenses + period: year + unit: list + reference: + - title: Income Support (General) Regulations 1987 reg. 49(a) + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/49 + - title: Income Support (General) Regulations (Northern Ireland) 1987 reg. 49(a), as substituted by S.R. 2007/396 reg. 4(10) + href: https://www.legislation.gov.uk/nisr/2007/396/regulation/4 + - title: Decision Makers' Guide chapter 29, paras. 29620-29623 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=124 + - title: Decision Makers' Guide chapter 29, para. 29671 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=137 + - title: Decision Makers' Guide chapter 29, paras. 29681 and 29685 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=139 +values: + 2010-01-01: + - owned_land + - other_residential_property_value + - non_residential_property_value + - share_like_capital diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/round_down.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/round_down.yaml new file mode 100644 index 0000000000..4170770dd6 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/round_down.yaml @@ -0,0 +1,17 @@ +description: > + Whether a fraction of a penny in a Pension Credit capital calculation is + disregarded (true) rather than rounded to the nearest penny (false). A + fraction of a penny is treated as a penny only where that is to the + claimant's advantage, and is otherwise disregarded; for capital that means + rounding down. DMG 84763 values shares the same way. +values: + 2003-10-06: true +metadata: + label: Pension Credit capital rounded down to the penny + period: year + unit: bool + reference: + - title: State Pension Credit Regulations 2002 reg. 24A + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/24A + - title: State Pension Credit Regulations (Northern Ireland) 2003 reg. 24A + href: https://www.legislation.gov.uk/nisr/2003/28/regulation/24A diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/rate.yaml new file mode 100644 index 0000000000..09c5550767 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/rate.yaml @@ -0,0 +1,20 @@ +description: > + Share of the current market or surrender value deducted from Pension Credit + capital where there would be expenses attributable to sale. It is a flat 10% + whatever the actual costs, taken from the gross value before any encumbrance + secured on the asset is deducted. Before 1 October 2007 the same deduction + sat in reg. 19(a)(i) alongside a separate valuation for National Savings + Certificates; SI 2007/2618 substituted the regulation in its current form. +values: + 2003-10-06: 0.1 +metadata: + label: Pension Credit capital sale expense deduction + period: year + unit: /1 + reference: + - title: State Pension Credit Regulations 2002 reg. 19(a) + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/19 + - title: State Pension Credit Regulations (Northern Ireland) 2003 reg. 19(a) + href: https://www.legislation.gov.uk/nisr/2003/28/regulation/19 + - title: Decision Makers' Guide chapter 84, paras. 84712-84716 + href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=99 diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml new file mode 100644 index 0000000000..58ffe0c298 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml @@ -0,0 +1,40 @@ +description: > + Capital sources whose sale would incur expenses, so that Pension Credit + values them at 90% of market value. Costs of sale normally arise where an + agent, broker, auctioneer, solicitor or accountant is needed, and real + property always has them (DMG 84714), so `owned_land`, + `other_residential_property_value` and `non_residential_property_value` are + listed. `savings` is not listed: a bank or building society account is + valued at its balance, and the costs of withdrawing money are not costs of + sale (DMG 84712-84715 and 84731). `share_like_capital` (`corporate_wealth`, + or its itemised components where they exceed it) is listed because shares + are valued less 10% for the costs of selling them (DMG 84763). It is a mixed + variable, though: it also holds unit trusts, which have no costs of sale + (DMG 84772), stocks and shares ISAs and employee share options, and datasets + built before private pension wealth was split out also fold non- + defined-benefit pension wealth into it, which is not capital at all. The + deduction reaches all of these until the data separate them. Sources not + counted as Pension Credit capital are ignored. +metadata: + label: Pension Credit capital sources with sale expenses + period: year + unit: list + reference: + - title: State Pension Credit Regulations 2002 reg. 19(a) + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/19 + - title: State Pension Credit Regulations (Northern Ireland) 2003 reg. 19(a) + href: https://www.legislation.gov.uk/nisr/2003/28/regulation/19 + - title: Decision Makers' Guide chapter 84, paras. 84712-84715 + href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=99 + - title: Decision Makers' Guide chapter 84, para. 84731 + href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=102 + - title: Decision Makers' Guide chapter 84, para. 84763 + href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=107 + - title: Decision Makers' Guide chapter 84, para. 84772 + href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=109 +values: + 2003-10-06: + - owned_land + - other_residential_property_value + - non_residential_property_value + - share_like_capital diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/property_sale_cost_rate.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/property_sale_cost_rate.yaml deleted file mode 100644 index 9cd29f27b4..0000000000 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/property_sale_cost_rate.yaml +++ /dev/null @@ -1,14 +0,0 @@ -description: > - Proportion deducted from current property market value where sale entails - expenses, before subtracting secured encumbrances (regulation 49). Broad - household property inputs are assumed to incur sale expenses. Cash financial - proxies do not receive this deduction. -values: - 2013-04-29: 0.1 -metadata: - label: Universal Credit property sale expense deduction - unit: /1 - period: year - reference: - - title: The Universal Credit Regulations 2013 reg. 49(1) - href: https://www.legislation.gov.uk/uksi/2013/376/regulation/49/1 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/round_down.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/round_down.yaml new file mode 100644 index 0000000000..6b34443074 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/round_down.yaml @@ -0,0 +1,19 @@ +description: > + Whether a fraction of a penny in a Universal Credit capital calculation is + disregarded (true) rather than rounded to the nearest penny (false). + Regulation 6(1) disregards a fraction of less than half a penny and treats + any other fraction as a penny, for every amount calculated under the + regulations, including the reg. 49 valuation and the capital tested + against the limit. ADM H1665 rounds a share valuation down in the + claimant's favour, but the regulation governs. +values: + 2013-04-29: false +metadata: + label: Universal Credit capital rounded down to the penny + period: year + unit: bool + reference: + - title: The Universal Credit Regulations 2013 reg. 6(1) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/6 + - title: The Universal Credit Regulations (Northern Ireland) 2016 reg. 7(1) + href: https://www.legislation.gov.uk/nisr/2016/216/regulation/7 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml new file mode 100644 index 0000000000..394de65fac --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml @@ -0,0 +1,21 @@ +description: > + Share of the current market or surrender value deducted from Universal + Credit capital where there would be expenses attributable to sale. It is a + flat 10% whatever the actual costs, taken from the gross value before any + encumbrance secured on the asset is deducted. It applies to the countable + property in property_sources (other residential and non-residential + property, assumed to have costs of sale) and to the financial sources listed + in sale_expenses.sources. +values: + 2013-04-29: 0.1 +metadata: + label: Universal Credit capital sale expense deduction + period: year + unit: /1 + reference: + - title: The Universal Credit Regulations 2013 reg. 49(1)(a) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/49 + - title: The Universal Credit Regulations (Northern Ireland) 2016 reg. 49(1)(a) + href: https://www.legislation.gov.uk/nisr/2016/216/regulation/49 + - title: Advice for Decision Making chapter H1, paras. H1602-H1608 + href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=44 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml new file mode 100644 index 0000000000..644d64294b --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml @@ -0,0 +1,36 @@ +description: > + Universal Credit financial capital sources (capital.sources) whose sale + would incur expenses, so that they are valued at 90% of market value. + `share_like_capital` (`corporate_wealth`, or its itemised components where + they exceed it) is listed because shares are valued less 10% for the costs + of selling them (ADM H1665). It is a mixed variable, though: it also holds + unit trusts, which have no costs of sale (H1674), stocks and shares ISAs, + valued at their withdrawal value (H1656), and employee share options, and + datasets built before private pension wealth was split out also fold + non-defined-benefit pension wealth into it, which is not capital at all. The + deduction reaches all of these until the data separate them. `savings` is + not listed: the 10% applies where selling normally needs an agent, broker, + solicitor or accountant (H1604-H1605), the costs of withdrawing money are + not costs of sale (H1607), and an account is valued at its balance (H1675). + Countable property is valued through property_sources with the same rate, + and real property always has costs of sale (H1606). +metadata: + label: Universal Credit capital sources with sale expenses + period: year + unit: list + reference: + - title: The Universal Credit Regulations 2013 reg. 49(1)(a) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/49 + - title: The Universal Credit Regulations (Northern Ireland) 2016 reg. 49(1)(a) + href: https://www.legislation.gov.uk/nisr/2016/216/regulation/49 + - title: Advice for Decision Making chapter H1, paras. H1604-H1607 + href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=44 + - title: Advice for Decision Making chapter H1, para. H1656 + href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=55 + - title: Advice for Decision Making chapter H1, para. H1665 + href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=57 + - title: Advice for Decision Making chapter H1, paras. H1673-H1675 + href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=59 +values: + 2013-04-29: + - share_like_capital diff --git a/policyengine_uk/parameters/gov/local_authorities/council_tax_reduction/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/local_authorities/council_tax_reduction/capital/sale_expenses/rate.yaml index 692911d156..de2f6e2484 100644 --- a/policyengine_uk/parameters/gov/local_authorities/council_tax_reduction/capital/sale_expenses/rate.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/council_tax_reduction/capital/sale_expenses/rate.yaml @@ -4,8 +4,8 @@ description: > a flat 10% whatever the actual costs, taken from the market value before any encumbrance secured on the asset is deducted (DMG 29624). The encumbrance is then deducted where an asset-specific secured-debt input - exists (other residential and non-residential property); those inputs - default to nil because the survey does not record them. + exists (land, other residential property and non-residential property); + those inputs default to nil because the survey does not record them. values: 2013-04-01: 0.1 metadata: diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml index 8a01aa7799..7692a965fb 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml @@ -20,7 +20,9 @@ other_residential_property_value: 3_000 non_residential_property_value: 4_000 output: - esa_income_assessable_capital: 10_500 + # Savings at face value; land, shares and property less 10% for the + # expenses of sale (ESA Regs 2008 reg. 113(a)): 1,000 + 90% of 9,500. + esa_income_assessable_capital: 9_550 - name: Income-related ESA has no tariff income at the lower capital threshold period: 2026 @@ -222,4 +224,6 @@ corporate_wealth: 2_000 private_pension_wealth: 150_000 output: - esa_income_assessable_capital: 3_000 + # Savings at face value; shares less 10% for the expenses of sale + # (ESA Regs 2008 reg. 113(a)): 1,000 + 90% of 2,000. + esa_income_assessable_capital: 2_800 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_capital.yaml index d6eee69d34..5c10a19a8d 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_capital.yaml @@ -89,7 +89,7 @@ - name: Housing Benefit counts direct land holdings as capital period: 2025 - absolute_error_margin: 0 + absolute_error_margin: 0.01 input: people: person: @@ -103,11 +103,36 @@ households: household: members: person - owned_land: 16_001 + # 90% of 17,780 is 16,002, over the 16,000 limit after the 10% + # deduction for the expenses of sale (HB Regs 2006 reg. 47(a)). + owned_land: 17_780 output: - housing_benefit_assessable_capital: 16_001 + housing_benefit_assessable_capital: 16_002 housing_benefit_eligible: false +- name: Housing Benefit values land less 10% for the expenses of sale + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + in_social_housing: true + housing_benefit_reported: 1 + benunits: + benunit: + members: person + would_claim_uc: false + households: + household: + members: person + # Land worth 16,001 is valued at 14,400.90 (HB Regs 2006 reg. 47(a)), + # within the limit. + owned_land: 16_001 + output: + housing_benefit_assessable_capital: 14_400.9 + housing_benefit_eligible: true + - name: Housing Benefit counts corporate wealth as a broad financial-capital proxy period: 2025 absolute_error_margin: 0 @@ -126,7 +151,9 @@ members: person corporate_wealth: 50_000 output: - housing_benefit_assessable_capital: 50_000 + # Market value less 10% for the expenses of sale (HB Regs 2006 + # reg. 47(a)). + housing_benefit_assessable_capital: 45_000 housing_benefit_eligible: false - name: Mixed benunits split household Housing Benefit capital by claimant and partner share diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_capital.yaml index 7173dd1ede..9043a46422 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_capital.yaml @@ -22,7 +22,9 @@ other_residential_property_value: 3000 non_residential_property_value: 4000 output: - income_support_assessable_capital: 10500 + # Savings at face value; land, shares and property less 10% for the + # expenses of sale (IS Regs 1987 reg. 49(a)): 1,000 + 90% of 9,500. + income_support_assessable_capital: 9550 - name: Income Support has no tariff income at the lower capital threshold period: 2021 @@ -231,4 +233,6 @@ corporate_wealth: 2000 private_pension_wealth: 150000 output: - income_support_assessable_capital: 3000 + # Savings at face value; shares less 10% for the expenses of sale + # (IS Regs 1987 reg. 49(a)): 1,000 + 90% of 2,000. + income_support_assessable_capital: 2800 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_capital.yaml index 024036709e..aec8eaf01f 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_capital.yaml @@ -19,7 +19,9 @@ other_residential_property_value: 3_000 non_residential_property_value: 4_000 output: - jsa_income_assessable_capital: 10_500 + # Savings at face value; land, shares and property less 10% for the + # expenses of sale (JSA Regs 1996 reg. 111(a)): 1,000 + 90% of 9,500. + jsa_income_assessable_capital: 9_550 - name: Income-based JSA has no tariff income at the lower capital threshold period: 2025 @@ -184,4 +186,6 @@ corporate_wealth: 2_000 private_pension_wealth: 150_000 output: - jsa_income_assessable_capital: 3_000 + # Savings at face value; shares less 10% for the expenses of sale + # (JSA Regs 1996 reg. 111(a)): 1,000 + 90% of 2,000. + jsa_income_assessable_capital: 2_800 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml index be74f2340d..aad23c70e5 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml @@ -242,7 +242,9 @@ uc_mif_applies: [false] uc_company_capital: [0] uc_company_holding_disregard: [0] - uc_assessable_capital: 25_000 + # Outside reg. 77 the holding is ordinary shares, valued at 90% for the + # expenses of sale (reg. 49(1)(a)). + uc_assessable_capital: 22_500 - name: An investor in a company with many shareholders is not treated as its owner # H4364: such a shareholder has no influence over the running of the @@ -270,7 +272,8 @@ output: uc_company_owner_treatment_applies: [false] uc_individual_earned_income: [0] - uc_assessable_capital: 12_000 + # Ordinary shares, valued at 90% for the expenses of sale (reg. 49(1)(a)). + uc_assessable_capital: 10_800 - name: A company carrying on neither a trade nor a property business is outside the rule # Reg. 77(1) needs a company "carrying on a trade or a property business". @@ -292,7 +295,8 @@ corporate_wealth: 10_000 output: uc_company_owner_treatment_applies: [false] - uc_assessable_capital: 10_000 + # Ordinary shares, valued at 90% for the expenses of sale (reg. 49(1)(a)). + uc_assessable_capital: 9_000 - name: Each partner's company is attributed to the couple's capital # Joint claimants' capital is combined against one limit (Welfare Reform @@ -483,7 +487,9 @@ savings: 10_000 corporate_wealth: 30_000 output: - household_uc_pool_company_holdings: 30_000 + # The 30,000 holding leaves the pool at the 90% at which corporate_wealth + # is counted (reg. 49(1)(a)). + household_uc_pool_company_holdings: 27_000 uc_assessable_capital: [5_000, 5_000] - name: A benunit that reports its capital has its own holding taken off what it reports @@ -507,7 +513,10 @@ household: members: person output: - uc_assessable_capital: 35_000 + # The complete override is a countable figure, so the holding inside it + # is counted at 90% (reg. 49(1)(a)) and comes off at that value: + # 50,000 - 27,000 + 15,000 of company capital. + uc_assessable_capital: 38_000 - name: A dependant's company earnings are not the claimant's earned income # Reg. 22(1)(b) deducts "the claimant's earned income (or, in the case of @@ -647,5 +656,7 @@ savings: 8_000 corporate_wealth: 12_000 output: - household_uc_pool_company_holdings: 12_000 + # The 12,000 holding leaves the pool at the 90% at which corporate_wealth + # is counted (reg. 49(1)(a)). + household_uc_pool_company_holdings: 10_800 uc_assessable_capital: [0, 8_000] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_UC_eligible.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_UC_eligible.yaml index efa082474e..c1b2ab85ae 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_UC_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_UC_eligible.yaml @@ -297,7 +297,9 @@ members: person corporate_wealth: 50_000 output: - uc_assessable_capital: 50_000 + # Market value less 10% for the expenses of sale (UC Regs 2013 + # reg. 49(1)(a)). + uc_assessable_capital: 45_000 is_uc_eligible: false - name: Main residence value is excluded from Universal Credit capital diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/test_uc_capital_allocation.py b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/test_uc_capital_allocation.py index fe82f6aa46..bfc742c641 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/test_uc_capital_allocation.py +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/test_uc_capital_allocation.py @@ -1,8 +1,16 @@ -"""The financial proxy conserves residual capital after financial observations.""" +"""The financial proxy conserves residual capital after financial observations. + +Each unreported unit's share of the residual is its own amount, rounded to the +nearest penny with half a penny rounded up (UC Regs 2013 reg. 6(1)), so the +shares add up to the residual to within half a penny per unit: 1.00 shared +three ways is 0.33 each. +""" + +from decimal import ROUND_HALF_UP, Decimal import numpy as np import pytest -from hypothesis import HealthCheck, given, settings, strategies as st +from hypothesis import HealthCheck, example, given, settings, strategies as st from policyengine_core.simulations import Simulation from policyengine_uk.system import system @@ -29,6 +37,10 @@ def household_capital_situations(draw): max_size=3, ) ) + return build_situation(household_specs) + + +def build_situation(household_specs): people, benunits, households, expected = {}, {}, {}, [] for household_index, (capital, unit_specs) in enumerate(household_specs): household_members = [] @@ -56,7 +68,7 @@ def household_capital_situations(draw): if has_reported: reported_values[len(benunits)] = reported_capital else: - unreported_indices.append(len(benunits)) + unreported_indices.append((len(benunits), claimant_count)) benunits[unit_id] = { "members": members, "benunit_reported_capital": { @@ -78,6 +90,10 @@ def household_capital_situations(draw): return {"people": people, "benunits": benunits, "households": households}, expected +def nearest_penny(amount): + return float(amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) + + # Test conservation independently of machine-dependent generation timings. @settings( max_examples=30, @@ -85,6 +101,8 @@ def household_capital_situations(draw): suppress_health_check=[HealthCheck.too_slow], ) @given(household_capital_situations()) +# 1.00 of savings shared by three single claimants is 0.33 each, 0.99 in all. +@example(build_situation([(1, [(1, 0, False, 0)] * 3)])) def test_unreported_capital_shares_sum_to_each_households_residual(case): situation, expected = case # Reuse baseline rules; each example still gets fresh people and inputs. @@ -92,7 +110,16 @@ def test_unreported_capital_shares_sum_to_each_households_residual(case): assessed_capital = simulation.calculate("uc_assessable_capital", 2025) assert np.isfinite(assessed_capital).all() - for unreported_indices, reported_values, residual in expected: - assert assessed_capital[unreported_indices].sum() == pytest.approx(residual) + for unreported_units, reported_values, residual in expected: + claimants = sum(count for _, count in unreported_units) + for index, count in unreported_units: + share = Decimal(residual) * count / claimants + assert assessed_capital[index] == pytest.approx( + nearest_penny(share), abs=0.001 + ) + indices = [index for index, _ in unreported_units] + assert assessed_capital[indices].sum() == pytest.approx( + residual, abs=0.005 * len(indices) + 0.001 + ) for index, reported_capital in reported_values.items(): assert assessed_capital[index] == pytest.approx(reported_capital) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml new file mode 100644 index 0000000000..32701e2a25 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml @@ -0,0 +1,644 @@ +# Capital is valued at market value less 10% where a sale would incur +# expenses, and less any encumbrance secured on it: UC Regs 2013 reg. 49(1); +# HB Regs 2006 reg. 47; IS Regs 1987 reg. 49; JSA Regs 1996 reg. 111; ESA Regs +# 2008 reg. 113; SPC Regs 2002 reg. 19. A bank or building society account is +# valued at its balance (ADM H1604-H1607, H1675); land and property always have +# costs of sale (H1606) and shares are valued less 10% (H1665). +# Expected values are worked by hand from those provisions. + +- name: UC values a mortgaged holiday home as in ADM H1602 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # 125,000 - 10% (12,500) - 100,500 mortgage = 12,000. + other_residential_property_value: 125_000 + other_residential_property_secured_debt: 100_500 + output: + uc_assessable_capital: 12_000 + is_uc_eligible: true + # 24 steps of 250 above 6,000 at 4.35 a month: 24 * 4.35 * 12. + uc_tariff_income: 1_252.8 + +- name: UC counts cash savings at face value + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + savings: 15_000 + output: + uc_assessable_capital: 15_000 + is_uc_eligible: true + +- name: UC shares valued less sale expenses bring capital within the limit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # 5,000 + 90% of 12,000 = 15,800, within the 16,000 limit; at full + # value the household would hold 17,000. + savings: 5_000 + corporate_wealth: 12_000 + output: + uc_assessable_capital: 15_800 + is_uc_eligible: true + # 9,800 above 6,000 is 40 steps of 250 (rounded up): 40 * 4.35 * 12. + uc_tariff_income: 2_088 + +- name: UC negative equity in a second home does not reduce savings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # The mortgage is deducted only from the property it is secured on + # (ADM H1615-H1616), so the property is valued at nil. + savings: 10_000 + other_residential_property_value: 100_000 + other_residential_property_secured_debt: 150_000 + output: + uc_assessable_capital: 10_000 + is_uc_eligible: true + +- name: UC deducts sale expenses before the encumbrance on business premises + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # (90% of 40,000 - 20,000) + 90% of 5,000 = 16,000 + 4,500. + non_residential_property_value: 40_000 + non_residential_property_secured_debt: 20_000 + corporate_wealth: 5_000 + output: + uc_assessable_capital: 20_500 + is_uc_eligible: false + +- name: Housing Benefit values encumbered land less sale expenses + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + in_social_housing: true + housing_benefit_reported: 1 + benunits: + benunit: + members: person + would_claim_uc: false + households: + household: + members: person + # 1,000 + (90% of 20,000 - 5,000) = 14,000 (HB Regs 2006 reg. 47). + savings: 1_000 + owned_land: 20_000 + owned_land_secured_debt: 5_000 + output: + housing_benefit_assessable_capital: 14_000 + # 32 steps of 250 above 6,000 at 1 a week: 32 * 52. + housing_benefit_tariff_income: 1_664 + housing_benefit_eligible: true + +- name: Income Support values encumbered land less sale expenses + period: 2021 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # 90% of 20,000 - 5,000 = 13,000 (IS Regs 1987 reg. 49). + owned_land: 20_000 + owned_land_secured_debt: 5_000 + output: + income_support_assessable_capital: 13_000 + +- name: Income-based JSA values a mortgaged second home less sale expenses + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # 2,000 + (90% of 30,000 - 25,000) = 4,000 (JSA Regs 1996 reg. 111). + savings: 2_000 + other_residential_property_value: 30_000 + other_residential_property_secured_debt: 25_000 + output: + jsa_income_assessable_capital: 4_000 + +- name: Income-related ESA values shares less sale expenses + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: person + households: + household: + members: person + # 3,000 + 90% of 10,000 = 12,000 (ESA Regs 2008 reg. 113). + savings: 3_000 + corporate_wealth: 10_000 + output: + esa_income_assessable_capital: 12_000 + +- name: Pension Credit values mortgaged business premises less sale expenses + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 70 + benunits: + benunit: + members: person + households: + household: + members: person + # 2,000 + (90% of 30,000 - 10,000) = 19,000 (SPC Regs 2002 reg. 19). + savings: 2_000 + non_residential_property_value: 30_000 + non_residential_property_secured_debt: 10_000 + output: + pension_credit_assessable_capital: 19_000 + # 18 steps of 500 above 10,000 at 1 a week: 18 * 52. + pension_credit_deemed_income: 936 + +- name: UC floors each encumbered source at nil on its own + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # The second home is in negative equity (90,000 - 150,000), so it is + # valued at nil; the business premises keep 90% of 10,000. Netting the + # debts across both would wrongly give nil. + other_residential_property_value: 100_000 + other_residential_property_secured_debt: 150_000 + non_residential_property_value: 10_000 + output: + uc_assessable_capital: 9_000 + +- name: Housing Benefit values mortgaged second homes and premises less sale expenses + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + in_social_housing: true + housing_benefit_reported: 1 + benunits: + benunit: + members: person + would_claim_uc: false + households: + household: + members: person + # 1,000 + (90% of 50,000 - 30,000) + max(0, 90% of 20,000 - 25,000) + # = 1,000 + 15,000 + 0 = 16,000, at the limit (HB Regs 2006 reg. 47). + savings: 1_000 + other_residential_property_value: 50_000 + other_residential_property_secured_debt: 30_000 + non_residential_property_value: 20_000 + non_residential_property_secured_debt: 25_000 + output: + housing_benefit_assessable_capital: 16_000 + # 40 steps of 250 above 6,000 at 1 a week: 40 * 52. + housing_benefit_tariff_income: 2_080 + housing_benefit_eligible: true + +- name: Pension-age Housing Benefit values encumbered land less sale expenses + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 70 + benunits: + benunit: + members: person + households: + household: + members: person + # 90% of 200,000 - 50,000 = 130,000 (HB (SPC) Regs 2006 reg. 45). Its + # Pension Credit deemed income, 240 steps of 500 above 10,000 at 1 a + # week (12,480 a year), exceeds the 12,376 guarantee, so there is no + # guarantee credit to disregard the capital. + owned_land: 200_000 + owned_land_secured_debt: 50_000 + output: + pension_credit_assessable_capital: 130_000 + pension_credit_deemed_income: 12_480 + guarantee_credit: 0 + housing_benefit_assessable_capital: 130_000 + +- name: UC rounds the share valuation to the nearest penny, at the capital limit + period: 2026 + absolute_error_margin: 0 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # 90% of 16,666.67 is 15,000.003, rounded to the nearest penny (UC Regs + # 2013 reg. 6(1)): 15,000.00. With 1,000 of savings the capital is + # exactly the 16,000 limit, which does not exceed it (WRA 2012 s. + # 5(1)(a)). + savings: 1_000 + corporate_wealth: 16_666.67 + output: + uc_assessable_capital: 16_000 + is_uc_eligible: true + +- name: UC values property and shares less sale expenses to the penny + period: 2026 + absolute_error_margin: 0.005 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # 90% of 16,001 is 14,400.90 and 90% of 1,000.10 is 900.09, exactly: + # 15,300.99. Float32 arithmetic would give 14,400.8994 and 900.0899. + other_residential_property_value: 16_001 + corporate_wealth: 1_000.10 + output: + uc_assessable_capital: 15_300.99 + is_uc_eligible: true + +- name: UC rounds half a penny up, so the capital is a penny over the limit + period: 2026 + absolute_error_margin: 0 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # 90% of 16,666.65 is 14,999.985. UC Regs 2013 reg. 6(1) treats a + # fraction of half a penny or more as a penny: 14,999.99, so the capital + # is 1,000.02 + 14,999.99 = 16,000.01, over the 16,000 limit. Rounding + # down, as ADM H1665 does, would give exactly 16,000.00; the regulation + # governs the guidance. + savings: 1_000.02 + corporate_wealth: 16_666.65 + output: + uc_assessable_capital: 16_000.01 + is_uc_eligible: false + +- name: Pension Credit values shares as in the ADM and DMG worked example + period: 2026 + absolute_error_margin: 0.001 + input: + people: + person: + age: 70 + benunits: + benunit: + members: person + households: + household: + members: person + # 250 shares at 4.1375 are 1,034.375 (ADM H1665; DMG 84763). Less 10% + # for the expenses of sale that is 930.9375, and the fraction of a + # penny is disregarded because counting it would not be to the + # claimant's advantage (SPC Regs 2002 reg. 24A): 930.93. + corporate_wealth: 1_034.375 + output: + pension_credit_assessable_capital: 930.93 + +- name: UC values the worked-example shares to the nearest penny + period: 2026 + absolute_error_margin: 0.001 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # The same 930.9375 is 930.94 to the nearest penny (UC Regs 2013 reg. + # 6(1)), where ADM H1665 gives 930.93. + corporate_wealth: 1_034.375 + output: + uc_assessable_capital: 930.94 + +- name: Income Support rounds a shared capital down to the penny + period: 2021 + absolute_error_margin: 0.001 + input: + people: + first: + age: 30 + is_claimant_or_partner: true + second: + age: 30 + is_claimant_or_partner: true + third: + age: 30 + is_claimant_or_partner: true + benunits: + first_unit: + members: [first] + would_claim_IS: false + second_unit: + members: [second] + would_claim_IS: false + third_unit: + members: [third] + would_claim_IS: false + households: + household: + members: [first, second, third] + # With no Income Support claim in the household, 10,000.01 of savings + # is shared by the three adults: 3,333.3366 each, rounded down at the + # last stage (IS Regs 1987 reg. 27): 3,333.33. + savings: 10_000.01 + output: + income_support_assessable_capital: [3_333.33, 3_333.33, 3_333.33] + +- name: Legacy benefits count 16,000.01 of savings in full, over the limit + period: 2026 + absolute_error_margin: 0.004 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # Stored in float32 as 16,000.0098, which is the penny 16,000.01: + # rounding down at the last stage (IS Regs 1987 reg. 27; JSA Regs 1996 + # reg. 92; ESA Regs 2008 reg. 3(a)) must not turn the storage error + # into a lost penny, which would put the capital at the 16,000 limit. + savings: 16_000.01 + output: + income_support_assessable_capital: 16_000.01 + jsa_income_assessable_capital: 16_000.01 + esa_income_assessable_capital: 16_000.01 + +- name: A Lifetime ISA counts at 75% of its balance to the penny + period: 2026 + absolute_error_margin: 0.004 + input: + people: + person: + age: 30 + lifetime_isa_balance: 1_000.04 + benunits: + benunit: + members: person + households: + household: + members: person + output: + # 75% of 1,000.04 is 750.03, its surrender value below age 60. + lifetime_isa_countable_capital: 750.03 + income_support_assessable_capital: 750.03 + esa_income_assessable_capital: 750.03 + uc_assessable_capital: 750.03 + +- name: UC rounds a Lifetime ISA's half penny up, a penny over the limit + period: 2026 + absolute_error_margin: 0.004 + input: + people: + person: + age: 30 + # Below age 60 the surrender value is 75% of the balance: 0.195, half + # a penny, which float32 would store as 0.19499999. UC Regs 2013 reg. + # 6(1) treats it as a penny, so the capital is 15,999.81 + 0.20 = + # 16,000.01, over the 16,000 limit. The legacy benefits disregard it + # (IS Regs 1987 reg. 27; JSA Regs 1996 reg. 92; ESA Regs 2008 reg. + # 3(a)): 15,999.81 + 0.19 = 16,000.00. + lifetime_isa_balance: 0.26 + benunits: + benunit: + members: person + households: + household: + members: person + savings: 15_999.81 + output: + uc_assessable_capital: 16_000.01 + is_uc_eligible: false + income_support_assessable_capital: 16_000 + jsa_income_assessable_capital: 16_000 + esa_income_assessable_capital: 16_000 + +- name: Each partner's Lifetime ISA is valued to the penny on its own + period: 2026 + absolute_error_margin: 0.004 + input: + people: + first: + age: 30 + lifetime_isa_balance: 0.26 + second: + age: 30 + lifetime_isa_balance: 0.26 + benunits: + benunit: + members: [first, second] + households: + household: + members: [first, second] + # Each surrender value is 0.195, a calculation of its own: 0.20 to the + # nearest penny (UC Regs 2013 reg. 6(1)), so 15,999.62 + 0.40 = + # 16,000.02; 0.19 rounded down (IS Regs 1987 reg. 27; JSA Regs 1996 + # reg. 92; ESA Regs 2008 reg. 3(a)), so 15,999.62 + 0.38 = 16,000.00. + savings: 15_999.62 + output: + uc_assessable_capital: 16_000.02 + income_support_assessable_capital: 16_000 + jsa_income_assessable_capital: 16_000 + esa_income_assessable_capital: 16_000 + +- name: A Lifetime ISA value given as an input is counted as given + period: 2026 + absolute_error_margin: 0.004 + input: + people: + person: + age: 30 + # The balance's surrender value would be 0.195, but the value counted + # as capital is given directly, so every means test counts that 0.20: + # 15,999.81 + 0.20 = 16,000.01. + lifetime_isa_balance: 0.26 + lifetime_isa_countable_capital: 0.20 + benunits: + benunit: + members: person + households: + household: + members: person + savings: 15_999.81 + output: + uc_assessable_capital: 16_000.01 + is_uc_eligible: false + income_support_assessable_capital: 16_000.01 + jsa_income_assessable_capital: 16_000.01 + esa_income_assessable_capital: 16_000.01 + +- name: A Lifetime ISA input is preserved when its float32 value matches the formula + period: 2026 + absolute_error_margin: 0.004 + input: + people: + person: + age: 30 + # The formula gives 65,536.005. That and the explicit whole-penny + # value 65,536.01 both store as 65,536.0078125, so equality cannot + # tell a formula result from an input. Counting the input as given + # preserves 65,536.01 even under a programme that rounds down. + lifetime_isa_balance: 87_381.34 + lifetime_isa_countable_capital: 65_536.01 + benunits: + benunit: + members: person + housing_benefit_on_passporting_benefit: false + households: + household: + members: person + output: + uc_assessable_capital: 65_536.01 + housing_benefit_assessable_capital: 65_536.01 + income_support_assessable_capital: 65_536.01 + jsa_income_assessable_capital: 65_536.01 + esa_income_assessable_capital: 65_536.01 + +- name: Pension Credit preserves an explicit Lifetime ISA value + period: 2026 + absolute_error_margin: 0.004 + input: + people: + person: + age: 70 + lifetime_isa_balance: 0.26 + lifetime_isa_countable_capital: 0.20 + benunits: + benunit: + members: person + households: + household: + members: person + output: + pension_credit_assessable_capital: 0.20 + +- name: Every means test counts a cash ISA and savings at face value and a mortgaged second home less sale expenses and the mortgage + period: 2026 + absolute_error_margin: 0 + input: + people: + person: + age: 70 + cash_isa_balance: 4_000 + benunits: + benunit: + members: person + would_claim_uc: false + would_claim_IS: false + in_receipt_of_guarantee_credit: false + in_receipt_of_savings_credit_only: false + housing_benefit_on_passporting_benefit: false + households: + household: + members: person + savings: 3_000 + other_residential_property_value: 100_000 + other_residential_property_secured_debt: 60_000 + output: + # The formulas move the 4,000 cash ISA out of the household's cash_isa + # into its holder's own benefit unit at face value, count the 3,000 of + # savings at face value, and value the second home at 100,000 less 10% + # (10,000) less the 60,000 mortgage: 3,000 + 4,000 + 30,000 = 37,000. + cash_isa: 4_000 + cash_isa_countable_capital: 4_000 + uc_assessable_capital: 37_000 + housing_benefit_assessable_capital: 37_000 + income_support_assessable_capital: 37_000 + jsa_income_assessable_capital: 37_000 + esa_income_assessable_capital: 37_000 + pension_credit_assessable_capital: 37_000 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_deemed_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_deemed_income.yaml index cf16632edc..5e4f8db1ae 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_deemed_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_deemed_income.yaml @@ -78,9 +78,11 @@ households: household: members: person - owned_land: 10_001 + # 90% of 11,120 is 10,008 after the 10% deduction for the expenses + # of sale (SPC Regs 2002 reg. 19(a)): one 500 step above 10,000. + owned_land: 11_120 output: - pension_credit_assessable_capital: 10_001 + pension_credit_assessable_capital: 10_008 pension_credit_deemed_income: 52 - name: Pension Credit does not dilute pensioner capital across unrelated adult benunits. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml index 6b32d58624..d0b49c7cf7 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml @@ -65,10 +65,11 @@ - name: Case 4, rent from a let property is not Pension Credit income; the property counts through deemed income. # SPC Regs 2002 Sch IV para 18 disregards any actual income from capital, so - # the £10,476 of rent does not count. Deemed income (reg 15(6)): capital - # 55,825 - 10,000 = 45,825, ceil(45,825 / 500) = 92, so £92 a week, 4,784 a - # year. Guarantee credit = (238 - 92) x 52 = 7,592. Savings credit is nil, as - # qualifying income (£92 a week) is below the savings credit threshold. + # the £10,476 of rent does not count. Capital: 455 + 90% of 55,370 (reg. + # 19(a)) = 50,288. Deemed income (reg 15(6)): 50,288 - 10,000 = 40,288, + # ceil(40,288 / 500) = 81, so £81 a week, 4,212 a year. Guarantee credit = + # (238 - 81) x 52 = 8,164. Savings credit is nil, as qualifying income (£81 a + # week) falls below the savings credit threshold. period: 2026 absolute_error_margin: 0.01 input: @@ -85,12 +86,12 @@ other_residential_property_value: 55_370 savings: 455 output: - pension_credit_assessable_capital: 55_825 - pension_credit_deemed_income: 4_784 - pension_credit_income: 4_784 - guarantee_credit: 7_592 + pension_credit_assessable_capital: 50_288 + pension_credit_deemed_income: 4_212 + pension_credit_income: 4_212 + guarantee_credit: 8_164 savings_credit: 0 - pension_credit: 7_592 + pension_credit: 8_164 - name: Case 5, 2026 Pension Credit uses tariff income with the published guarantee rate. period: 2026 @@ -110,12 +111,14 @@ savings: 87_285 corporate_wealth: 12_650 output: - pension_credit_assessable_capital: 99_935 - pension_credit_deemed_income: 9_360 - pension_credit_income: 9_360 + # 87,285 + 90% of 12,650 (SPC Regs 2002 reg. 19(a)). Deemed income is + # 178 steps of 500 above 10,000 at 1 a week: 178 * 52 = 9,256. + pension_credit_assessable_capital: 98_670 + pension_credit_deemed_income: 9_256 + pension_credit_income: 9_256 standard_minimum_guarantee: 12_376 - guarantee_credit: 3_016 - pension_credit: 3_016 + guarantee_credit: 3_120 + pension_credit: 3_120 - name: Pension Credit capital disregards private pension wealth period: 2026 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 index 2b88881ea0..a282d3c84d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml @@ -8,7 +8,12 @@ # 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. +# a unit on the IS, JSA or ESA claim path. Each programme values the pool's +# share_like_capital, and each stocks and shares ISA balance the formula moves +# out of it, at market value less 10% for the expenses of sale (UC Regs 2013 +# reg 49(1)(a); HB Regs 2006 reg 47; IS Regs 1987 reg 49; JSA Regs 1996 reg +# 111; ESA Regs 2008 reg 113; SPC Regs 2002 reg 19), and counts savings and +# cash ISAs at face value. - name: Case 1, a lodger's cash ISA stays out of the main benefit unit's capital. period: 2025 @@ -70,9 +75,9 @@ # 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] + # the 6,000 ISA less 10%, 5,400. + uc_assessable_capital: [6_000, 8_400] + housing_benefit_assessable_capital: [6_000, 8_400] - name: Case 3, an adult child's ISA inside corporate wealth stays out of the parents' capital. period: 2025 @@ -101,10 +106,11 @@ 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] + # 9,000, values the remaining 3,000 less 10% at 2,700 and allocates it: + # 1,800 to the parents and 900 to the child's unit, which adds the ISA + # less 10%, 5,400. + uc_assessable_capital: [1_800, 6_300] + housing_benefit_assessable_capital: [1_800, 6_300] - name: Case 4, a parent's Income Support claim does not count an adult child's cash ISA. period: 2025 @@ -180,7 +186,9 @@ members: [parent, adult_child] savings: 2_000 output: - esa_income_assessable_capital: [2_000, 5_000] + # The parent's claiming unit takes the 2,000 of savings; the child's unit + # counts the ISA less 10%, 4,500. + esa_income_assessable_capital: [2_000, 4_500] - name: Case 7, a pensioner couple's Pension Credit counts their own ISAs and not a lodger's. period: 2025 @@ -209,8 +217,8 @@ # 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] + # 3,000 cash ISA and the 2,000 stocks and shares ISA less 10%, 1,800. + pension_credit_assessable_capital: [8_800, 0] - name: Case 8, a dependent child's junior ISA counts in no benefit unit. period: 2025 @@ -268,9 +276,10 @@ 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] + # 2,000 to the lodger, and the couple's unit adds their 4,000 cash ISA + # and the 3,000 stocks and shares ISA less 10%, 2,700. + uc_assessable_capital: [10_700, 2_000] + housing_benefit_assessable_capital: [10_700, 2_000] - name: Case 10, a household total above the balances keeps the rest in the pool. period: 2025 @@ -378,13 +387,16 @@ 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] + # The ISA less 10%, 30,600, exceeds reg 18's 16,000 limit. Pooling it + # would give each claimant 15,300, within the limit. The formula + # attributes it to its holder. + uc_assessable_capital: [0, 30_600] # 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. +# stays on its boundary. In Cases 14 and 15 the stocks and shares total, less +# 10% and rounded to the nearest penny (UC Regs 2013 reg 6(1)), comes to the +# boundary: 90% of 17,777.78 is 16,000.002 and 90% of 6,666.67 is 6,000.003. # 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. @@ -398,7 +410,7 @@ cash_isa_balance: 16_790 stocks_holder: age: 30 - stocks_and_shares_isa_balance: 16_790 + stocks_and_shares_isa_balance: 18_567 benunits: cash_unit: members: cash_holder @@ -414,10 +426,10 @@ cash_isa: 16_000 stocks_home: members: stocks_holder - stocks_and_shares_isa: 16_000 + stocks_and_shares_isa: 17_777.78 output: cash_isa_countable_capital: [16_000, 0] - stocks_and_shares_isa_countable_capital: [0, 16_000] + stocks_and_shares_isa_countable_capital: [0, 17_777.78] # 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: @@ -452,10 +464,10 @@ cash_isa: 6_000 stocks_home: members: stocks_holder - stocks_and_shares_isa: 6_000 + stocks_and_shares_isa: 6_666.67 output: cash_isa_countable_capital: [6_000, 0] - stocks_and_shares_isa_countable_capital: [0, 6_000] + stocks_and_shares_isa_countable_capital: [0, 6_666.67] # 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] @@ -566,7 +578,8 @@ share_like_capital: 100 output: stocks_and_shares_isa_countable_capital: 100 - uc_assessable_capital: 100 + # UC counts the 100 less 10%. + uc_assessable_capital: 90 - name: Case 19, a child's ISA counts in no unit when the parent reports the family's capital. period: 2025 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml index 95951f1d60..f522fcb351 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml @@ -121,5 +121,160 @@ output: household_uc_reported_financial_capital: 10_000 household_uc_pool_company_holdings: 0 - uc_assessable_capital: [0, 20_000] + # corporate_wealth is valued at 90% (reg. 49(1)(a)), and the holding + # inside the countable override comes off at that value: the owner keeps + # 10,000 - 9,000 = 1,000, and the pool is 20,000 + 9,000 - 10,000 = + # 19,000 for the other claimant. + uc_assessable_capital: [1_000, 19_000] universal_credit: [5_098.80, 0] + +- name: A recorded financial total consumes its owner's company holding at its counted value + period: 2026 + input: + people: + owner: + age: 30 + is_claimant_or_partner: true + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_property_business: true + owned_company_holding_value: 10_000 + owned_company_capital: 0 + other_claimant: + age: 30 + is_claimant_or_partner: true + benunits: + reporting: + members: [owner] + would_claim_uc: true + benunit_reported_capital: 10_000 + missing: + members: [other_claimant] + would_claim_uc: true + households: + household: + members: [owner, other_claimant] + savings: 16_500 + corporate_wealth: 10_000 + output: + # The pool counts the 10,000 of shares at 90% (reg. 49(1)(a)): 16,500 + + # 9,000 = 25,500. The recorded total holds the holding at full value, so + # its holding part is consumed at the counted 9,000, not 10,000: the + # report consumes 10,000 - 1,000 = 9,000. The owner's own assessment takes + # the full holding off the report (10,000 - 10,000 = 0), and the other + # claimant keeps all 16,500 of savings, over the 16,000 limit. The total + # reconciles: 0 + 16,500 + the 9,000 disregarded holding = 25,500. + household_uc_reported_financial_capital: 9_000 + household_uc_pool_company_holdings: 0 + uc_assessable_capital: [0, 16_500] + universal_credit: [5_098.80, 0] + +- name: A capital of exactly 16,000.00 after a company holding leaves the pool is within the limit + period: 2026 + absolute_error_margin: 0 + input: + people: + owner: + age: 30 + is_claimant_or_partner: true + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_property_business: true + owned_company_holding_value: 10_000.01 + owned_company_capital: 0 + other_claimant: + age: 30 + is_claimant_or_partner: true + benunits: + reporting: + members: [owner] + would_claim_uc: true + uc_reported_capital: 0 + missing: + members: [other_claimant] + would_claim_uc: true + households: + household: + members: [owner, other_claimant] + savings: 7_000.01 + corporate_wealth: 20_000 + output: + # The pool is 7,000.01 + 90% of 20,000 = 25,000.01. The owner's holding + # leaves it at 90% of 10,000.01 = 9,000.009, which is 9,000.01 to the + # nearest penny (UC Regs 2013 reg. 6(1)), so the other claimant has + # 16,000.00, which does not exceed the 16,000 limit. Unrounded, the + # 16,000.001 would fail it. + household_uc_reported_financial_capital: 9_000.01 + uc_assessable_capital: [0, 16_000] + is_uc_eligible: [true, true] + +- name: A capital pool shared three ways is rounded to the nearest penny at the last stage + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 30 + is_claimant_or_partner: true + second: + age: 30 + is_claimant_or_partner: true + third: + age: 30 + is_claimant_or_partner: true + benunits: + first_unit: + members: [first] + would_claim_uc: true + second_unit: + members: [second] + would_claim_uc: true + third_unit: + members: [third] + would_claim_uc: true + households: + household: + members: [first, second, third] + savings: 48_000.01 + output: + # A third of 48,000.01 is 16,000.0033, which is 16,000.00 to the nearest + # penny (reg. 6(1)), within the limit for each unit. + uc_assessable_capital: [16_000, 16_000, 16_000] + is_uc_eligible: [true, true, true] + +- name: A recorded financial total below its owner's company holding still consumes the holding + period: 2026 + input: + people: + owner: + age: 30 + is_claimant_or_partner: true + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_property_business: true + owned_company_holding_value: 10_000 + owned_company_capital: 0 + other_claimant: + age: 30 + is_claimant_or_partner: true + benunits: + reporting: + members: [owner] + would_claim_uc: true + benunit_reported_capital: 4_000 + missing: + members: [other_claimant] + would_claim_uc: true + households: + household: + members: [owner, other_claimant] + savings: 10_000 + corporate_wealth: 10_000 + output: + # All 10,000 of shares are the owner's holding, which the pool counts at + # 9,000 (reg. 49(1)(a)). The recorded 4,000 is below the holding, so it + # has no part beyond it and consumes the holding at its counted 9,000, + # as an override would. The other claimant keeps only the 10,000 of + # savings: none of the holding is left for them to count. The owner's + # own assessment takes the full holding off the report: max(0, 4,000 - + # 10,000) = 0. + household_uc_reported_financial_capital: 9_000 + household_uc_pool_company_holdings: 0 + uc_assessable_capital: [0, 10_000] diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_capital_valuation.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_capital_valuation.yaml new file mode 100644 index 0000000000..bdfb816174 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_capital_valuation.yaml @@ -0,0 +1,29 @@ +# Outside the Pension Credit, Universal Credit and passported routes, each +# scheme calculates capital at its current market or surrender value less, +# where there would be expenses attributable to the sale, 10 per cent, and +# less the amount of any encumbrance secured on it (SI 2012/2885 Sch 1 para +# 32; SI 2012/2886 Sch para 65; WSI 2013/3029 Sch 1 para 26 and Sch 6 para +# 28; SSI 2012/319 reg 42; SSI 2012/303 reg 46; SSI 2021/249 reg 70(1)). +# Expected values are worked by hand from those provisions. + +- name: The household route values mortgaged land less sale expenses and the mortgage + period: 2026 + absolute_error_margin: 0 + input: + people: + applicant: + age: 40 + benunits: + benunit: + members: applicant + would_claim_uc: false + households: + household: + members: applicant + country: ENGLAND + local_authority: MAIDSTONE + # 50,000 less 10% (5,000) less the 20,000 secured on it = 25,000. + owned_land: 50_000 + owned_land_secured_debt: 20_000 + output: + council_tax_reduction_assessable_capital: 25_000 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml index 033ddb88c5..9d38dad6dc 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml @@ -472,12 +472,16 @@ savings: 10_000 other_residential_property_value: 7_000 output: - savings_credit: 267.42 + # Capital 10,000 + 90% of 7,000 = 16,300 (SPC Regs 2002 reg. 19(a)), so + # deemed income is 13 steps x 52 = 676 and Pension Credit income is + # 13,314 + 676 = 13,990. Savings credit: 60% of (12,376 - 208.07 x 52) + # = 933.82, less 40% of (13,990 - 12,376) = 645.60, leaves 288.22. + savings_credit: 288.22 in_receipt_of_savings_credit_only: true # Pension Credit counts the second property as capital; household # savings alone (10,000) are under the limit. Before: CTR 1,999.60. - pension_credit_assessable_capital: 17_000 - council_tax_reduction_assessable_capital: 17_000 + pension_credit_assessable_capital: 16_300 + council_tax_reduction_assessable_capital: 16_300 council_tax_reduction: 0 - name: The same pensioner not claiming Pension Credit is tested on savings and the second property @@ -671,20 +675,21 @@ savings: 0 corporate_wealth: 20_000 output: - # (12,000 + 1,500 - 12,570) x 0.2 = 186. (20,000 - 10,000) / 500 = 20 - # steps of £1 a week; 20 x 52 = 1,040 of deemed income. - pension_credit_assessable_capital: 20_000 - pension_credit_deemed_income: 1_040 - # 13,500 - 186 + 1,040 = 14,354, above the minimum guarantee of 12,376. - pension_credit_income: 14_354 + # (12,000 + 1,500 - 12,570) x 0.2 = 186. Shares are valued at 90% for the + # expenses of sale (SPC Regs 2002 reg. 19(a)): 18,000. (18,000 - 10,000) / + # 500 = 16 steps of £1 a week; 16 x 52 = 832 of deemed income. + pension_credit_assessable_capital: 18_000 + pension_credit_deemed_income: 832 + # 13,500 - 186 + 832 = 14,146, above the minimum guarantee of 12,376. + pension_credit_income: 14_146 guarantee_credit: 0 - # 933.816 - 0.4 x (14,354 - 12,376) = 933.816 - 791.20 = 142.616. - savings_credit: 142.62 + # 933.816 - 0.4 x (14,146 - 12,376) = 933.816 - 708 = 225.816. + savings_credit: 225.82 in_receipt_of_savings_credit_only: true - # The Pension Credit capital is 20,000, over 16,000, with nil savings in + # The Pension Credit capital is 18,000, over 16,000, with nil savings in # the household. The same pensioner with 20,000 of private_pension_wealth # keeps CTR in the next case. - council_tax_reduction_assessable_capital: 20_000 + council_tax_reduction_assessable_capital: 18_000 council_tax_reduction: 0 - name: Savings credit only, private_pension_wealth is not Pension Credit capital, so it does not take the pensioner over the CTR capital limit diff --git a/policyengine_uk/tests/test_capital_share_components.py b/policyengine_uk/tests/test_capital_share_components.py index 608aa1e94d..04407d88e2 100644 --- a/policyengine_uk/tests/test_capital_share_components.py +++ b/policyengine_uk/tests/test_capital_share_components.py @@ -58,12 +58,15 @@ def _parameter(path): return _node(path)(f"{YEAR}-01-01") +SALE_LISTS = [ + path.rsplit(".", 1)[0] + ".sale_expenses.sources" for path in SOURCE_LISTS.values() +] ORIGINAL_LISTS = { path: [ "corporate_wealth" if source == "share_like_capital" else source for source in _parameter(path) ] - for path in SOURCE_LISTS.values() + for path in [*SOURCE_LISTS.values(), *SALE_LISTS] } ORIGINAL_REFORM = { path: {f"{YEAR}-01-01.{YEAR + 4}-12-31": sources} @@ -114,13 +117,14 @@ def test_lists_name_share_like_capital_only(variable): assert "share_like_capital" in sources assert "corporate_wealth" not in sources assert not set(COMPONENTS) & set(sources) - # Where a programme lists the sources whose sale incurs expenses, it names - # the same share-like variable, or shares would silently lose the - # deduction. + # Every programme values shares less 10% for the expenses of sale (UC + # Regs 2013 reg. 49(1)(a) and the legacy equivalents), so its list of + # those sources names the same share-like variable, or shares would + # silently lose the deduction. node = _node(SOURCE_LISTS[variable].rsplit(".", 1)[0]) - if "sale_expenses" in node.children: - sale = list(node.sale_expenses.sources(f"{YEAR}-01-01")) - assert not {"corporate_wealth", *COMPONENTS} & set(sale) + sale = list(node.sale_expenses.sources(f"{YEAR}-01-01")) + assert "share_like_capital" in sale + assert not {"corporate_wealth", *COMPONENTS} & set(sale) @SETTINGS @@ -244,7 +248,9 @@ def test_components_alone_are_counted(): ] got = _results(households) assert got["share_like_capital"][0] == 12_000 - assert got["uc_assessable_capital"][0] == 13_000 + # Savings in full and shares less 10% for the expenses of sale (UC Regs + # 2013 reg. 49(1)(a)): 1,000 + 90% of 12,000. + assert got["uc_assessable_capital"][0] == 11_800 def test_a_disregarded_company_holding_leaves_the_capital_that_counts_it(): @@ -281,5 +287,7 @@ def test_a_disregarded_company_holding_leaves_the_capital_that_counts_it(): } sim = Simulation(situation=situation) assert sim.calculate("household_uc_company_holding_pool_scale", YEAR)[0] == 1 - assert sim.calculate("household_uc_pool_company_holdings", YEAR)[0] == 30_000 + # The pool counts the shares at 90% (reg. 49(1)(a)), so the holding + # leaves it at that value. + assert sim.calculate("household_uc_pool_company_holdings", YEAR)[0] == 27_000 assert list(sim.calculate("uc_assessable_capital", YEAR)) == [5_000, 5_000] diff --git a/policyengine_uk/tests/test_capital_valuation_properties.py b/policyengine_uk/tests/test_capital_valuation_properties.py new file mode 100644 index 0000000000..ed2dfec1c7 --- /dev/null +++ b/policyengine_uk/tests/test_capital_valuation_properties.py @@ -0,0 +1,632 @@ +"""Property-based tests for how the means tests value capital. + +Universal Credit and each legacy means test calculate capital in the United +Kingdom at its current market or surrender value less 10% where there would +be expenses attributable to sale, and less any encumbrance secured on it (UC +Regs 2013 reg. 49(1); HB Regs 2006 reg. 47; HB (SPC) Regs 2006 reg. 45; IS +Regs 1987 reg. 49; JSA Regs 1996 reg. 111; ESA Regs 2008 reg. 113; SPC Regs +2002 reg. 19). A bank or building society account is valued at its balance +(ADM H1675); land and property always have costs of sale (H1606) and shares +are valued less 10% (H1665). + +Invariants, for any generated population of single-adult households: + +1. Non-increasing: every programme's assessable capital is at most the same + household's capital with the deduction switched off (rate 0, no debt). +2. Cash only: a household holding only savings and a cash ISA has the same + capital with the deduction on or off, equal to the two balances. +3. Differential: capital equals a direct implementation of the regulation, + savings + sum over sources of max(0, 90% of value - secured debt), where + the test itself fixes which assets take the 10% (land, property and + shares, not cash), so a wrong parameter list fails it. +4. Monotone: a higher sale-expense rate, or more secured debt, never raises + capital. +5. Isolation: debt secured on one source never takes capital below the value + of the household's unencumbered sources, and capital is never negative. + (Isolation between two encumbered sources is pinned by the differential and + by a YAML case.) +6. Pennies: 90% of any whole-penny value stored as a float32 input is the + exact figure rounded down (the legacy benefits) or to the nearest penny + (Universal Credit and Housing Benefit), checked in integer pence up to + 131,071.99, the largest value float32 holds to the penny. +7. Never rounds the wrong way: for any stored value, whole penny or not, the + result is within a penny below 90% of the exact stored value when rounding + down, and within half a penny of it when rounding to the nearest penny. + The same holds for a float32 value widened to float64, as a benefit unit's + maximum over its household's values is. +8. Whole pennies through the model: every programme counts whole-penny + savings, shares and Lifetime ISA balances to the penny: savings in full, + shares at 90% and a Lifetime ISA at 75% below its charge-free age, each + rounded by the programme's rule, and a Lifetime ISA in full from that age. +""" + +from fractions import Fraction +from functools import reduce + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st +from policyengine_core.reforms import Reform + +from policyengine_uk import Simulation +from policyengine_uk.model_api import Person, Variable +from policyengine_uk.model_api import YEAR as YEAR_PERIOD +from policyengine_uk.utils.capital_valuation import exact_amount, less_sale_expenses + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2026 +PROPERTY_SETTINGS = settings( + max_examples=5, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# Assessable capital variable -> parameter node holding its sources and +# sale-expense rule. Pension Credit counts only pension-age adults; Housing +# Benefit is checked at both ages, disregarding all capital for pension-age +# Guarantee Credit recipients (HB (SPC) Regs 2006 reg. 26); the others are +# checked on working-age adults. +PROGRAMMES = { + "uc_assessable_capital": "gov.dwp.universal_credit.means_test.capital", + "housing_benefit_assessable_capital": "gov.dwp.housing_benefit.means_test.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", + "pension_credit_assessable_capital": "gov.dwp.pension_credit.income.capital", +} +PENSION_AGE_ONLY = {"pension_credit_assessable_capital"} +# The assets whose sale incurs expenses, fixed here from the law rather than +# read from the parameters: real property always (ADM H1606), shares (H1665); +# never a bank or building society balance (H1675). +SALE_EXPENSE_ASSETS = { + "owned_land", + "other_residential_property_value", + "non_residential_property_value", + "share_like_capital", +} +LEGAL_RATE = 0.1 +ASSETS = [ + "savings", + "cash_isa", + "owned_land", + "corporate_wealth", + "other_residential_property_value", + "non_residential_property_value", +] +# Ordinary savings and a cash ISA are both deposit balances, counted in full. +CASH_ASSETS = ("savings", "cash_isa") +# The capital sources count shares as share_like_capital, which is the +# corporate_wealth these households are given. +INPUT_OF = {"share_like_capital": "corporate_wealth"} + + +def asset(unit, source): + return unit["assets"][INPUT_OF.get(source, source)] + + +DEBTS = { + "owned_land": "owned_land_secured_debt", + "other_residential_property_value": "other_residential_property_secured_debt", + "non_residential_property_value": "non_residential_property_secured_debt", +} +amount = st.one_of(st.just(0.0), st.floats(0, 400_000, allow_nan=False)) + + +@st.composite +def households(draw, cash_only=False): + assets = {asset: draw(amount) for asset in ASSETS} + debts = {debt: draw(amount) for debt in DEBTS.values()} + if cash_only: + assets = {asset: 0.0 for asset in ASSETS} | { + cash: assets[cash] for cash in CASH_ASSETS + } + debts = {debt: 0.0 for debt in debts} + # About half the units do not claim Universal Credit, so the Housing + # Benefit passport cannot remove every working-age unit from the + # differential below. + return dict( + age=draw(st.sampled_from([30, 70])), + claims_uc=draw(st.booleans()), + assets=assets, + debts=debts, + ) + + +def situation(units, debt_scale=1.0): + people, benunits, homes = {}, {}, {} + for i, unit in enumerate(units): + people[f"p{i}"] = {"age": {YEAR: unit["age"]}} + benunits[f"b{i}"] = { + "members": [f"p{i}"], + "would_claim_uc": {YEAR: unit["claims_uc"]}, + } + homes[f"h{i}"] = { + "members": [f"p{i}"], + **{k: {YEAR: v} for k, v in unit["assets"].items()}, + **{k: {YEAR: v * debt_scale} for k, v in unit["debts"].items()}, + } + return {"people": people, "benunits": benunits, "households": homes} + + +def rate_reform(rate): + return { + f"{node}.sale_expenses.rate": {str(YEAR): rate} for node in PROGRAMMES.values() + } + + +def calculate(units, rate=None, debt_scale=1.0): + sim = Simulation( + situation=situation(units, debt_scale), + reform=None if rate is None else rate_reform(rate), + ) + values = {v: np.asarray(sim.calculate(v, YEAR), dtype=float) for v in PROGRAMMES} + values["guarantee_credit"] = np.asarray(sim.calculate("guarantee_credit", YEAR)) + values["hb_passported"] = np.asarray( + sim.calculate("housing_benefit_on_passporting_benefit", YEAR) + ) + return sim, values + + +def parameter_node(sim, path): + return reduce(getattr, path.split("."), sim.tax_benefit_system.parameters(YEAR)) + + +def applies(variable, unit, guarantee_credit=0.0, hb_passported=False): + if variable == "housing_benefit_assessable_capital": + # A family on a passporting benefit has nil HB capital + # (housing_benefit_on_passporting_benefit, on main since #1927). + if hb_passported: + return False + return unit["age"] < 67 or guarantee_credit <= 0 + return (unit["age"] >= 67) == (variable in PENSION_AGE_ONLY) + + +def expected_capital(unit, node, variable=None): + """Reg. 49(1) applied source by source, independently of the model.""" + total = 0.0 + if variable == "uc_assessable_capital": + # Universal Credit values countable property on its own path + # (property_sources), outside the financial sources. + for source in [ + "other_residential_property_value", + "non_residential_property_value", + ]: + total += max( + 0.0, + asset(unit, source) * (1 - LEGAL_RATE) - unit["debts"][DEBTS[source]], + ) + for source in node.sources: + value = asset(unit, source) + if source in SALE_EXPENSE_ASSETS: + value *= 1 - LEGAL_RATE + if source in DEBTS: + value = max(0.0, value - unit["debts"][DEBTS[source]]) + total += value + return total + + +def close(a, b): + return abs(a - b) <= 1 + 1e-6 * abs(b) + + +def check_valuation(units): + """Assert the valuation invariants; count which Housing Benefit branches ran.""" + sim, valued = calculate(units) + _, full = calculate(units, rate=0.0, debt_scale=0.0) + hb_checked = {"passported_nil": 0, "working_age_differential": 0} + for variable, path in PROGRAMMES.items(): + node = parameter_node(sim, path) + assert node.sale_expenses.rate == LEGAL_RATE + assert "savings" in node.sources + counted = set(node.sources) + assert set(node.sale_expenses.sources) & counted == ( + SALE_EXPENSE_ASSETS & counted + ), variable + hb = variable == "housing_benefit_assessable_capital" + for i, unit in enumerate(units): + gc = valued["guarantee_credit"][i] + assert valued[variable][i] >= 0, (variable, unit) + assert valued[variable][i] <= full[variable][i] + 0.01, (variable, unit) + if hb and valued["hb_passported"][i]: + assert valued[variable][i] == 0, unit + hb_checked["passported_nil"] += 1 + if hb and unit["age"] >= 67 and gc > 0: + assert valued[variable][i] == 0, unit + if applies(variable, unit, gc, valued["hb_passported"][i]): + expected = expected_capital(unit, node, variable) + assert close(valued[variable][i], expected), (variable, unit) + if hb and unit["age"] < 67 and expected > 0: + hb_checked["working_age_differential"] += 1 + # Debt secured on property never reaches the other assets. + floor = sum( + asset(unit, s) * (1 - LEGAL_RATE * (s in SALE_EXPENSE_ASSETS)) + for s in node.sources + if s not in DEBTS + ) + assert valued[variable][i] >= floor - 1, (variable, unit) + return hb_checked + + +@PROPERTY_SETTINGS +@given(st.lists(households(), min_size=20, max_size=200)) +def test_valuation_matches_the_regulation_and_never_adds_capital(units): + check_valuation(units) + + +def test_housing_benefit_branches_are_both_exercised(): + """Fixed units that a passport cannot hide: one claims UC with £5,000 of + savings (UC passports it to nil HB capital), one declines UC and holds a + mortgaged second home (its HB capital is checked against reg 47).""" + no_debt = {debt: 0.0 for debt in DEBTS.values()} + units = [ + dict( + age=30, + claims_uc=True, + assets={asset: 0.0 for asset in ASSETS} | {"savings": 5_000.0}, + debts=no_debt, + ), + dict( + age=30, + claims_uc=False, + assets={asset: 0.0 for asset in ASSETS} + | {"savings": 2_000.0, "other_residential_property_value": 100_000.0}, + debts=no_debt | {"other_residential_property_secured_debt": 60_000.0}, + ), + ] + hb_checked = check_valuation(units) + assert hb_checked["passported_nil"] >= 1 + assert hb_checked["working_age_differential"] >= 1 + + +@PROPERTY_SETTINGS +@given(st.lists(households(cash_only=True), min_size=20, max_size=200)) +def test_cash_only_households_have_no_deduction(units): + _, valued = calculate(units) + _, full = calculate(units, rate=0.0) + for variable in PROGRAMMES: + assert np.allclose(valued[variable], full[variable]), variable + for i, unit in enumerate(units): + if applies( + variable, + unit, + valued["guarantee_credit"][i], + valued["hb_passported"][i], + ): + cash = sum(unit["assets"][asset] for asset in CASH_ASSETS) + assert close(valued[variable][i], cash), variable + + +@PROPERTY_SETTINGS +@given( + st.lists(households(), min_size=20, max_size=200), + st.floats(0, 0.5), + st.floats(0, 0.5), + st.floats(0, 2), +) +def test_capital_is_non_increasing_in_rate_and_secured_debt(units, r1, r2, scale): + low, high = sorted([r1, r2]) + _, at_low = calculate(units, rate=low) + _, at_high = calculate(units, rate=high) + _, more_debt = calculate(units, rate=low, debt_scale=1 + scale) + for variable in PROGRAMMES: + assert np.all(at_high[variable] <= at_low[variable] + 0.01), variable + assert np.all(more_debt[variable] <= at_low[variable] + 0.01), variable + + +@settings(max_examples=200, deadline=None, derandomize=True) +@given(st.lists(st.integers(0, 13_107_199), min_size=1, max_size=1_000)) +# 16,001 is 14,400.8994 after a float32 multiplication; 1,000.10 is stored as +# 1,000.0999756; 90% of 2.30 is 206.99999999999997 pence in float64; 90% of +# 16,666.65 is 14,999.985, a half penny. +@example([1_600_100, 100_010, 230, 1_666_665]) +def test_sale_expenses_round_to_the_penny(pence): + stored = np.asarray(pence, dtype=np.int64).astype(np.float32) / np.float32(100) + # A benefit unit's maximum over its household's float32 values is float64. + for value in (stored, stored.astype(np.float64)): + assert np.round(exact_amount(value) * 100).astype(np.int64).tolist() == pence + down = less_sale_expenses(value, LEGAL_RATE, True) + nearest = less_sale_expenses(value, LEGAL_RATE, False) + assert down.dtype == nearest.dtype == np.float64 + assert np.round(down * 100).astype(np.int64).tolist() == [ + 9 * amount // 10 for amount in pence + ] + assert np.round(nearest * 100).astype(np.int64).tolist() == [ + (9 * amount + 5) // 10 for amount in pence + ] + + +def test_share_valuation_matches_the_adm_worked_example(): + # ADM H1665 and DMG 84763: 250 shares at 4.1375 are 1,034.375, and less + # 10% for the expenses of sale that is 930.9375: 930.93 rounded down, as + # the legacy benefits do, or 930.94 to the nearest penny, as Universal + # Credit does (UC Regs 2013 reg. 6(1)). + for stored in (1034.375, np.float32(1034.375)): + assert less_sale_expenses(stored, LEGAL_RATE, True) == 930.93 + assert less_sale_expenses(stored, LEGAL_RATE, False) == 930.94 + + +@settings(max_examples=200, deadline=None, derandomize=True) +@given( + st.lists( + st.floats(0, 1_000_000, allow_nan=False, width=32), min_size=1, max_size=500 + ) +) +@example([1034.375, 10352.055, 0.005, 999_999.875, 140_000.015625]) +def test_sale_expenses_never_round_the_wrong_way(values): + stored = np.asarray(values, dtype=np.float32) + exact = exact_amount(stored) + assert np.array_equal(exact, exact_amount(stored.astype(np.float64))) + # From 131,072 float32 cannot hold every penny, so no penny is identified + # and the value is kept as stored. The result is then within a penny of + # the stored value's figure, not of every penny it could have been stored + # from: 140,000.01 and 140,000.02 are both stored as 140,000.015625, and + # 90% of that rounds down to 126,000.01, a penny above 140,000.01's. + large = stored >= 131_072 + assert np.array_equal(exact[large], stored[large].astype(np.float64)) + # Storage error is removed only within half a unit of the stored value's + # own precision, so a value is never moved to a penny it was not stored + # from. + assert np.all( + np.abs(exact - stored.astype(np.float64)) + <= np.spacing(np.abs(stored)).astype(np.float64) / 2 + ) + down = less_sale_expenses(stored, LEGAL_RATE, True) + nearest = less_sale_expenses(stored, LEGAL_RATE, False) + for value, low, near in zip(exact.tolist(), down.tolist(), nearest.tolist()): + # Exact rational arithmetic on the value the helper works from, with + # the helper's millionth-of-a-penny allowance for binary noise. + target = Fraction(value) * Fraction(9, 10) + noise = Fraction(1, 100_000_000) + assert target - Fraction(1, 100) - noise < Fraction(low) <= target + noise + assert abs(Fraction(near) - target) <= Fraction(1, 200) + noise + + +ROUNDS_DOWN = { + "uc_assessable_capital": False, + "housing_benefit_assessable_capital": False, + "income_support_assessable_capital": True, + "jsa_income_assessable_capital": True, + "esa_income_assessable_capital": True, + "pension_credit_assessable_capital": True, +} + + +def whole_penny_situation(rows): + people, benunits, homes = {}, {}, {} + for i, (savings, shares, lisa, age) in enumerate(rows): + people[f"p{i}"] = { + "age": {YEAR: age}, + "lifetime_isa_balance": {YEAR: lisa / 100}, + } + benunits[f"b{i}"] = {"members": [f"p{i}"]} + homes[f"h{i}"] = { + "members": [f"p{i}"], + "savings": {YEAR: savings / 100}, + "corporate_wealth": {YEAR: shares / 100}, + } + return {"people": people, "benunits": benunits, "households": homes} + + +@PROPERTY_SETTINGS +@given( + st.lists( + st.tuples( + st.integers(0, 6_000_000), + st.integers(0, 6_000_000), + st.integers(0, 1_500_000), + st.sampled_from([30, 70]), + ), + min_size=20, + max_size=100, + ) +) +# 16,000.01 of savings is over every 16,000 limit; 1,000.10 and 12,345.67 +# lose a penny if float32 storage error reaches a floor; a Lifetime ISA of +# 0.24 or 1,000.04 counts 0.18 or 750.03 under 60. 75% of a 0.26 balance is +# 0.195 and of 1,000.30 is 750.225, half pennies, which float32 stores just +# below: with 15,999.81 or 15,249.77 of savings the capital is 16,000.01 or +# 16,000.00 to the nearest penny, and 16,000.00 or 15,999.99 rounded down. +@example( + [ + (1_600_001, 0, 0, 30), + (100_010, 0, 0, 30), + (1_234_567, 0, 0, 70), + (159_992, 1_600_010, 0, 30), + (0, 0, 24, 30), + (0, 0, 100_004, 30), + (1_599_981, 0, 26, 30), + (1_524_977, 0, 100_030, 30), + ] + * 3 +) +def test_whole_pennies_are_counted_to_the_penny(rows): + sim = Simulation(situation=whole_penny_situation(rows)) + values = {v: np.asarray(sim.calculate(v, YEAR), dtype=float) for v in PROGRAMMES} + gc = np.asarray(sim.calculate("guarantee_credit", YEAR)) + passported = np.asarray( + sim.calculate("housing_benefit_on_passporting_benefit", YEAR) + ) + savings_credit_only = np.asarray( + sim.calculate("in_receipt_of_savings_credit_only", YEAR) + ) + for variable, round_down in ROUNDS_DOWN.items(): + for i, (savings, shares, lisa, age) in enumerate(rows): + if not applies(variable, {"age": age}, gc[i], passported[i]): + continue + # Housing Benefit takes Pension Credit's capital for a savings + # credit only award (HB (SPC) Regs 2006 reg. 27(6)). + down = round_down or ( + variable == "housing_benefit_assessable_capital" + and savings_credit_only[i] + ) + share_pence = (9 * shares + (0 if down else 5)) // 10 + lisa_pence = lisa if age >= 60 else (3 * lisa + (0 if down else 2)) // 4 + expected = (savings + share_pence + lisa_pence) / 100 + # The model stores float32, which holds every penny below 131,072. + assert abs(values[variable][i] - expected) < 0.004, ( + variable, + rows[i], + values[variable][i], + expected, + ) + + +def test_lifetime_isa_reformed_value_is_kept_as_stored(): + # This reform's value and the baseline's 0.195 are the same float32, + # but the reform deliberately values the ISA below the half penny. + class lifetime_isa_countable_capital(Variable): + value_type = float + entity = Person + definition_period = YEAR_PERIOD + + def formula(person, period, parameters): + return person.filled_array(0.19499999) + + class ReplaceLifetimeISA(Reform): + def apply(self): + self.update_variable(lifetime_isa_countable_capital) + + sim = Simulation( + situation=whole_penny_situation([(0, 0, 26, 30)]), + reform=ReplaceLifetimeISA, + ) + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - 0.19) < 0.004 + + +def test_lifetime_isa_baseline_formula_uses_reformed_parameters(): + sim = Simulation( + situation=whole_penny_situation([(0, 0, 26, 30)]), + reform={"gov.hmrc.isa.lifetime.withdrawal_charge": {str(YEAR): 0.5}}, + ) + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - 0.13) < 0.004 + + +@pytest.mark.parametrize("via_holder", [False, True]) +def test_lifetime_isa_input_set_after_construction_is_preserved(via_holder): + sim = Simulation(situation=whole_penny_situation([(0, 0, 26, 30)])) + # Cache the formula first: a later equal-valued input still has input + # provenance, including when the caller uses Holder.set_input directly. + sim.calculate("lifetime_isa_countable_capital", YEAR) + if via_holder: + from policyengine_core import periods + + sim.get_holder("lifetime_isa_countable_capital").set_input( + periods.period(YEAR), np.array([0.195]) + ) + else: + sim.set_input("lifetime_isa_countable_capital", YEAR, np.array([0.195])) + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - 0.19) < 0.004 + + +def test_lifetime_isa_input_for_another_period_does_not_replace_the_formula(): + situation = whole_penny_situation([(0, 0, 26, 30)]) + situation["people"]["p0"]["lifetime_isa_countable_capital"] = {YEAR - 1: 0.195} + sim = Simulation(situation=situation) + # The previous year's supplied value cannot suppress this year's exact + # baseline valuation of 75% of 0.26, rounded nearest to 0.20. + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - 0.20) < 0.004 + + +@pytest.mark.parametrize("inherited", [False, True]) +def test_lifetime_isa_input_is_preserved_on_its_visible_branch(inherited): + situation = whole_penny_situation([(0, 0, 26, 30)]) + if inherited: + situation["people"]["p0"]["lifetime_isa_countable_capital"] = {YEAR: 0.195} + sim = Simulation(situation=situation) + branch = sim.get_branch("entered_lisa") + if not inherited: + branch.set_input("lifetime_isa_countable_capital", YEAR, np.array([0.195])) + assert abs(branch.calculate("uc_assessable_capital", YEAR)[0] - 0.19) < 0.004 + expected_main = 0.19 if inherited else 0.20 + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - expected_main) < 0.004 + + +def lisa_mask_reform(): + """A reform that keeps the baseline LISA formula and only adds a + ``defined_for`` mask, so the formula identity check still passes.""" + + class lisa_is_counted(Variable): + value_type = bool + entity = Person + definition_period = YEAR_PERIOD + default_value = False + label = "Lifetime ISA counted as capital" + + class lifetime_isa_countable_capital(Variable): + defined_for = "lisa_is_counted" + + class MaskLifetimeISA(Reform): + def apply(self): + self.add_variable(lisa_is_counted) + self.update_variable(lifetime_isa_countable_capital) + + return MaskLifetimeISA + + +def test_lifetime_isa_exact_valuation_respects_a_defined_for_mask(): + # Two units hold 0.26 each. Only p1's ISA is counted: Core stores 0 for + # p0, so p0 must have no capital, and p1 must be valued as without the + # mask (0.195, to the penny under each programme's rule). + situation = whole_penny_situation([(0, 0, 26, 30), (0, 0, 26, 30)]) + unmasked = Simulation(situation=situation) + masked = Simulation(situation=situation, reform=lisa_mask_reform()) + # The reform adds the mask, so the shared situation can't supply it. + masked.set_input("lisa_is_counted", YEAR, np.array([False, True])) + assert list(masked.calculate("lifetime_isa_countable_capital", YEAR)) == [ + 0, + pytest.approx(0.195), + ] + assert abs(unmasked.calculate("uc_assessable_capital", YEAR)[0] - 0.20) < 0.004 + for variable in PROGRAMMES: + before = np.asarray(unmasked.calculate(variable, YEAR), dtype=float) + after = np.asarray(masked.calculate(variable, YEAR), dtype=float) + assert abs(after[0]) < 0.004, (variable, after) + assert abs(after[1] - before[1]) < 0.004, (variable, before, after) + + +def test_a_lifetime_isa_masked_for_everyone_runs_no_formula(): + # Core runs no formula when the mask applies to nobody, so a dependency + # only applicable people need is never calculated. A balance formula + # that raises shows whether the exact valuation calculates it anyway. + class lifetime_isa_balance(Variable): + def formula(person, period, parameters): + raise AssertionError("A masked Lifetime ISA balance was calculated") + + mask = lisa_mask_reform() + + class MaskWithUnavailableBalance(Reform): + def apply(self): + mask.apply(self) + self.update_variable(lifetime_isa_balance) + + without_lisa = Simulation(situation=whole_penny_situation([(1_599_981, 0, 0, 30)])) + situation = whole_penny_situation([(1_599_981, 0, 0, 30)]) + del situation["people"]["p0"]["lifetime_isa_balance"] + sim = Simulation(situation=situation, reform=MaskWithUnavailableBalance) + assert list(sim.calculate("lifetime_isa_countable_capital", YEAR)) == [0] + for variable in PROGRAMMES: + before = without_lisa.calculate(variable, YEAR)[0] + after = sim.calculate(variable, YEAR)[0] + assert abs(after - before) < 0.004, (variable, before, after) + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - 15_999.81) < 0.004 + + +def test_a_masked_lifetime_isa_leaves_universal_credit_eligibility(): + # 15,999.81 of savings is within the 16,000 limit. A counted 0.26 ISA + # (0.195, rounded to 0.20) would take it to 16,000.01; masked, it adds + # nothing. + sim = Simulation( + situation=whole_penny_situation([(1_599_981, 0, 26, 30)]), + reform=lisa_mask_reform(), + ) + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - 15_999.81) < 0.004 + assert sim.calculate("is_uc_eligible", YEAR)[0] + sim = Simulation( + situation=whole_penny_situation([(1_599_981, 0, 26, 30)]), + reform=lisa_mask_reform(), + ) + sim.set_input("lisa_is_counted", YEAR, np.array([True])) + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - 16_000.01) < 0.004 + assert not sim.calculate("is_uc_eligible", YEAR)[0] diff --git a/policyengine_uk/tests/test_isa_balances.py b/policyengine_uk/tests/test_isa_balances.py index a38b6cc2f4..e5a28362aa 100644 --- a/policyengine_uk/tests/test_isa_balances.py +++ b/policyengine_uk/tests/test_isa_balances.py @@ -59,10 +59,16 @@ def test_stored_household_totals_without_person_balances_project_with_savings(): shares = simulation.calculate("stocks_and_shares_isa", 2027).values[0] assert cash == pytest.approx(3_000.0 * growth) assert shares == pytest.approx(2_000.0 * growth) - # Savings, the cash ISA and the stocks-and-shares ISA (through - # share_like_capital) all reach the capital pool. + # Savings and the cash ISA reach the capital pool at their value. The + # stocks-and-shares ISA reaches it through share_like_capital, which + # Universal Credit values less its sale-expense rate until the data + # separate ISAs from shares. + rate = simulation.tax_benefit_system.parameters( + "2027-04-06" + ).gov.dwp.universal_credit.means_test.capital.sale_expenses.rate capital = simulation.calculate("uc_assessable_capital", 2027).values[0] - assert capital == pytest.approx(6_000.0 * growth, rel=1e-5) + expected = (1_000.0 + 3_000.0 + 2_000.0 * (1 - rate)) * growth + assert capital == pytest.approx(expected, rel=1e-5) def test_household_totals_sum_the_person_balances_in_every_year(): diff --git a/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py b/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py index 61c0fe3d0a..346cf198e9 100644 --- a/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py +++ b/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py @@ -11,9 +11,11 @@ 1. Override identity: a pension-age unit with reported capital R >= 0 has assessable capital R, whatever the household holds. 2. Default differential: with nothing reported, assessable capital equals the - household proxy recomputed here from the inputs (household sources times - the unit's share of the household's pension-age adults, plus person-level - sources of the claimant or partner), the formula before this change. + household proxy recomputed here from the inputs (household sources, valued + under SPC Regs 2002 reg. 19 with savings at face value and the others at + 90%, times the unit's share of the household's pension-age adults, plus + person-level sources of the claimant or partner), the formula before this + change. 3. Locality: reporting capital for one unit never changes another unit's assessable capital. The strategy for this property always gives the other unit someone over State Pension age, nothing recorded and household capital, @@ -116,7 +118,11 @@ def proxy(people, units, household, index): "non_residential_property_value", "corporate_wealth", ] - household_capital = sum(household.get(s, 0) for s in sources) + # SPC Regs 2002 reg. 19(a): sources whose sale would incur expenses (land, + # property, shares) are valued at 90%; savings at face value. + household_capital = sum( + household.get(s, 0) * (1 if s == "savings" else 0.9) for s in sources + ) sp_age = {name: p["age"] >= 66 for name, p in people.items()} unit = units[index]["members"] unit_sp = sum(sp_age[n] for n in unit) diff --git a/policyengine_uk/tests/test_person_isa_capital.py b/policyengine_uk/tests/test_person_isa_capital.py index 9b105fd5b4..5ab33ba7e3 100644 --- a/policyengine_uk/tests/test_person_isa_capital.py +++ b/policyengine_uk/tests/test_person_isa_capital.py @@ -17,7 +17,9 @@ 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 + shares and each programme's penny rounding. Each programme values + share_like_capital, and each stocks and shares ISA it moves out of it, at + its sale-expense share (90%), and savings and cash ISAs at face value. 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 @@ -393,10 +395,11 @@ def test_household_only_data_keep_todays_capital(): 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. + # corporate_wealth 5,000 (which holds the 2,000 ISA) less 10%, 4,500, + # shared by three claimants, two of them the couple. UC rounds each to + # the nearest penny. uc = simulation.calculate("uc_assessable_capital", YEAR) - assert list(uc[:2]) == [8_000, 4_000] + assert list(uc[:2]) == pytest.approx([7_666.67, 3_833.33], abs=0.001) def test_both_forms_in_one_dataset_add_up_to_the_stored_total(): @@ -430,12 +433,15 @@ def test_both_forms_in_one_dataset_add_up_to_the_stored_total(): uc = simulation.calculate("uc_assessable_capital", year) hb = simulation.calculate("housing_benefit_assessable_capital", year) for capital in (uc, hb): + # UC and Housing Benefit round each unit's capital to the nearest + # penny, so the household's two units can come to a penny over: + # savings of 1,268.69 in 2032 give each 634.345, which rounds up. by_household = np.bincount(benunit_household, weights=capital) - assert by_household == pytest.approx(holdings, rel=1e-6), year + assert by_household == pytest.approx(holdings, rel=1e-6, abs=0.011), 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 + assert uc[1] == pytest.approx(pool / 3 + counted[2], rel=1e-6, abs=0.006), year + assert uc[0] == pytest.approx(2 * pool / 3, rel=1e-6, abs=0.006), 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. @@ -486,10 +492,11 @@ def test_both_forms_in_one_dataset_add_up_to_the_stored_total(): ] -def _household_capital(simulation, year): - return sum( - simulation.calculate(name, year) - for name in ("savings", "cash_isa", "share_like_capital") +def _household_capital(simulation, year, share=1): + return ( + simulation.calculate("savings", year) + + simulation.calculate("cash_isa", year) + + share * simulation.calculate("share_like_capital", year) ) @@ -515,28 +522,33 @@ def test_person_balances_without_household_totals_count_with_their_holders(): simulation.calculate("share_like_capital", year), simulation.calculate("corporate_wealth", year), ) - holdings = _household_capital(simulation, year) + holdings = _household_capital( + simulation, year, _share_counted("uc_assessable_capital") + ) 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 + # Savings 7,000 and 2,000 of non-ISA shares less 10%, 1,800, make the + # first pool, 2,933.33 for each claimant. The couple adds a 3,000 cash ISA + # and a 2,000 stocks and shares ISA less 10% (1,800), and the other unit a + # 5,000 cash ISA and a 1,000 one less 10% (900). The second pool, savings + # 12,000 and 3,000 of trusts less 10% (2,700), gives the parents 9,800 + # and their son 4,900 in UC, to which they add 4,000 and 5,400 of ISAs; + # the pension-age parents count all 14,700 and their 4,000 in Pension + # Credit. UC rounds to the nearest penny, and the legacy programmes + # round down. + expected_uc = [10_666.67, 8_833.33, 13_800, 10_300] + assert list(simulation.calculate("uc_assessable_capital", YEAR)) == pytest.approx( + expected_uc, abs=0.001 + ) 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, - ] + assert list(capital[:2]) == pytest.approx(expected_uc[:2], abs=0.011), name + assert list( + simulation.calculate("pension_credit_assessable_capital", YEAR) + ) == pytest.approx([0, 0, 18_700, 0], abs=0.001) # One benefit unit each, with a child: the adults' cash and stocks and shares @@ -714,10 +726,34 @@ def _check_counted_isas(simulation): return counted -def _holdings(simulation): - return sum( - simulation.calculate(name, YEAR) - for name in ("savings", "cash_isa", "share_like_capital") +def _share_counted(variable): + """The share of share_like_capital's market value ``variable`` counts. + + The programme counts share_like_capital, and each stocks and shares ISA + balance it moves out of it, less its sale-expense rate where its + ``sale_expenses.sources`` lists share_like_capital. + """ + capital = _node(CAPITAL[variable]) + date = f"{YEAR}-01-01" + if "share_like_capital" in capital.sale_expenses.sources(date): + return 1 - capital.sale_expenses.rate(date) + return 1 + + +# The share of each ISA person source's value a programme counts, given the +# share it counts of share_like_capital. +def _isa_shares(share): + return { + "cash_isa_countable_capital": 1, + "stocks_and_shares_isa_countable_capital": share, + } + + +def _holdings(simulation, share=1): + return ( + simulation.calculate("savings", YEAR) + + simulation.calculate("cash_isa", YEAR) + + share * simulation.calculate("share_like_capital", YEAR) ) @@ -732,7 +768,6 @@ def test_a_pound_counts_once_in_working_age_means_tests(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", @@ -740,15 +775,24 @@ def test_a_pound_counts_once_in_working_age_means_tests(units, holdings): "jsa_income_assessable_capital", "esa_income_assessable_capital", ): + share = _share_counted(name) + weights = _isa_shares(share) + uncounted = sum( + (weights[source] * amount * dependants).sum() + for source, amount in counted.items() + ) capital = simulation.calculate(name, YEAR) - # Float32 shares of the remainder round to within a few pence. + # Float32 shares of the remainder, and each valuation's penny + # rounding, come to within a few pence. assert capital.sum() + uncounted == pytest.approx( - _holdings(simulation)[0], rel=1e-5, abs=0.05 + _holdings(simulation, share)[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() + simulation.map_result( + weights[source] * amount * ~dependants, "person", "benunit", how="sum" + ) + for source, amount in counted.items() ) assert (capital >= own * (1 - 1e-6) - 0.01).all(), name @@ -857,22 +901,26 @@ def test_a_pound_counts_once_in_pension_credit(households): > 0 ) counts = simulation.calculate("is_claimant_or_partner", YEAR) & in_pension_unit + share = _share_counted("pension_credit_assessable_capital") + weights = _isa_shares(share) uncounted = sum( - _by_household(simulation, "person", amount * ~counts) - for amount in counted.values() + _by_household(simulation, "person", weights[source] * amount * ~counts) + for source, amount in counted.items() ) capital = simulation.calculate("pension_credit_assessable_capital", YEAR) - # Float32 shares of the remainder round to within a few pence. + # Float32 shares of the remainder, and each valuation's penny rounding, + # come to within a few pence. assert _by_household(simulation, "benunit", capital) + uncounted == pytest.approx( - _holdings(simulation), rel=1e-5, abs=0.05 + _holdings(simulation, share), 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()) + # beyond its 2,000 of ISAs, which Pension Credit values less 10% at + # 4,050: 2,525 for each pension-age adult. + own = sum(weights[source] * amount for source, amount in counted.items()) assert list(capital[-3:]) == pytest.approx( - [2_750 + own[-3], 2_750 + own[-2], 0], rel=1e-6 + [2_525 + own[-3], 2_525 + own[-2], 0], rel=1e-6, abs=0.01 ) @@ -1027,26 +1075,28 @@ def test_reforms_to_either_list_keep_each_isa_with_its_household_source(): 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] + # The lodger's unit counts the 10,000 cash ISA and the 4,000 stocks and + # shares ISA less 10% (3,600), and half the 2,000 of savings. + assert capital("uc_assessable_capital") == [1_000, 14_600] + assert capital("housing_benefit_assessable_capital") == [1_000, 14_600] 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 + # lodger keeps their stocks and shares ISA less 10%. Dropping # share_like_capital: the 10,000 cash ISA stays theirs. for dropped, expected in ( - ("cash_isa", [1_000, 5_000]), + ("cash_isa", [1_000, 4_600]), ("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. + # 2,000, the cash ISA and the stocks and shares ISA less 10%, 15,600 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] + assert capital("uc_assessable_capital") == [7_800, 7_800] + assert capital("housing_benefit_assessable_capital") == [1_000, 14_600] + assert capital("uc_assessable_capital") == [1_000, 14_600] @pytest.mark.parametrize("variable", CAPITAL) diff --git a/policyengine_uk/tests/test_savings_credit_only_receipt_properties.py b/policyengine_uk/tests/test_savings_credit_only_receipt_properties.py index 7197148782..f075c56cc0 100644 --- a/policyengine_uk/tests/test_savings_credit_only_receipt_properties.py +++ b/policyengine_uk/tests/test_savings_credit_only_receipt_properties.py @@ -185,7 +185,13 @@ def family(ages, **overrides): # recipient; and a working-age family. KNOWN_FAMILIES = [ family([90], state_pension=12_480.0), - family([90], state_pension=12_480.0, savings=12_000.0, other_property=4_400.0), + family( + [90], + state_pension=12_480.0, + savings=12_000.0, + other_property=4_400.0, + shares=500.0, + ), family([90], state_pension=12_480.0, savings=12_000.0, rent=6_000.0), family([90], state_pension=12_480.0, would_claim_pc=False), family([70], state_pension=9_000.0), @@ -234,6 +240,7 @@ def situation(units, would_claim_pc=None): "council_tax": {YEAR: unit["council_tax"]}, "savings": {YEAR: unit["savings"]}, "other_residential_property_value": {YEAR: unit["other_property"]}, + "corporate_wealth": {YEAR: unit.get("shares", 0.0)}, } return {"people": people, "benunits": benunits, "households": households} @@ -394,11 +401,13 @@ def test_known_families_detect_main(): ) assert not np.any(fix[FLAG]) # Main keeps the Pension Credit assessment. The owner with capital is - # tested on 16,400 (savings 12,000 and property 4,400) against the - # 16,000 limit and gets no council tax reduction, where the scheme's - # own rules count the property less 10% for the expenses of sale, - # 12,000 + 3,960 = 15,960, and give 2,000. The tenant has no Housing - # Benefit tariff income, where + # tested on 16,410 (savings 12,000, property 4,400 less 10% for the + # expenses of sale, 3,960, and shares 500 less 10%, 450; SPC Regs 2002 + # reg. 19(a)) against the 16,000 limit and gets no council tax + # reduction, where the scheme's own household route counts the + # savings and the property less 10%, 12,000 + 3,960 = 15,960, leaves + # out shares (council tax reduction capital sources), and gives 2,000. + # The tenant has no Housing Benefit tariff income, where # Housing Benefit's own rules count 4 a week on savings of 12,000 # (208). Its Housing Benefit income is the same on both routes: with # no savings credit paid, the Pension Credit assessment is the State @@ -411,7 +420,7 @@ def test_known_families_detect_main(): "housing_benefit_tariff_income", ] capital = "council_tax_reduction_assessable_capital" - assert main[capital][OWNER_WITH_CAPITAL] == 16_400 + assert main[capital][OWNER_WITH_CAPITAL] == 16_410 assert fix[capital][OWNER_WITH_CAPITAL] == 15_960 reduction = "simulated_council_tax_reduction_benunit" assert main[reduction][OWNER_WITH_CAPITAL] == 0 @@ -421,7 +430,7 @@ def test_known_families_detect_main(): assert assessed_differently(fix, not_claiming, known) == [] -def single_pensioner(savings=0.0, other_property=0.0): +def single_pensioner(savings=0.0, other_property=0.0, shares=0.0): # Single, aged 90, State Pension 12,480 (240 a week), owning in Maidstone # with council tax of 2,000. return { @@ -441,6 +450,7 @@ def single_pensioner(savings=0.0, other_property=0.0): "council_tax": {YEAR: 2_000}, "savings": {YEAR: savings}, "other_residential_property_value": {YEAR: other_property}, + "corporate_wealth": {YEAR: shares}, } }, } @@ -501,24 +511,28 @@ def read(simulation, variable): def test_stopping_pension_credit_payments_ends_the_pension_credit_capital_test(): - # Savings of 12,000 and other property worth 4,400. The Pension Credit - # assessment of capital counts both (16,400), above the 16,000 limit, so - # the savings-credit-only award leaves no council tax reduction. Deemed - # income is 1 a week for each 500, or part of 500, over 10,000: 6,400 / - # 500 = 12.8, so 13 a week (SPC Regs 2002 reg 15(6)). Income is 240.00 + + # Savings of 12,000, other property worth 4,400 and shares worth 500. The + # Pension Credit assessment of capital counts all three, the property and + # the shares less 10% for the expenses of sale (SPC Regs 2002 reg. 19(a)): + # 12,000 + 3,960 + 450 = 16,410, above the 16,000 limit, so the + # savings-credit-only award leaves no council tax reduction. Deemed + # income is 1 a week for each 500, or part of 500, over 10,000: 6,410 / + # 500 = 12.82, so 13 a week (SPC Regs 2002 reg 15(6)). Income is 240.00 + # 13.00 = 253.00 a week, so the savings credit is 0.6 x (238.00 - 208.07) # - 0.4 x (253.00 - 238.00) = 17.958 - 6.00 = 11.958 a week (621.82). - data = single_pensioner(savings=12_000.0, other_property=4_400.0) + data = single_pensioner(savings=12_000.0, other_property=4_400.0, shares=500.0) unreformed = Simulation(situation=data) assert abs(unreformed.calculate("savings_credit", YEAR)[0] - 621.82) < 0.01 assert unreformed.calculate(FLAG, YEAR).tolist() == [True] capital = "council_tax_reduction_assessable_capital" - assert unreformed.calculate(capital, YEAR)[0] == 16_400 + assert unreformed.calculate(capital, YEAR)[0] == 16_410 assert unreformed.calculate("council_tax_reduction", YEAR)[0] == 0 # With Pension Credit not paid the scheme's own capital rule applies: the # savings and the property less 10% for the expenses of sale (SI # 2012/2885 Sch 1 para 32(a)), 12,000 + 3,960 = 15,960, within the limit. + # The household route leaves the shares out (council tax reduction + # capital sources). # Tariff income is 1 a week for each 500, or part of 500, over 10,000 # (Sch 1 para 37): 5,960 / 500 = 11.92, so 12 a week, 624 a year. Income # 12,480 + 624 = 13,104 is below the applicable amount of 256.00 a week diff --git a/policyengine_uk/tests/test_stock_capital_periods.py b/policyengine_uk/tests/test_stock_capital_periods.py index 3ad2e0226f..eead7689d9 100644 --- a/policyengine_uk/tests/test_stock_capital_periods.py +++ b/policyengine_uk/tests/test_stock_capital_periods.py @@ -15,6 +15,7 @@ def test_uc_capital_stocks_are_not_prorated_in_monthly_calculations(): "members": ["person"], "savings": {"2026": 12_000}, "other_residential_property_value": {"2026": 12_000}, + "other_residential_property_secured_debt": {"2026": 5_000}, "corporate_wealth": {"2026": 12_000}, } }, @@ -23,7 +24,14 @@ def test_uc_capital_stocks_are_not_prorated_in_monthly_calculations(): assert simulation.calculate("savings", "2026-01")[0] == 12_000 assert simulation.calculate("corporate_wealth", "2026-01")[0] == 12_000 - assert simulation.calculate("uc_assessable_capital", "2026-01")[0] == 34_800 + assert ( + simulation.calculate("other_residential_property_secured_debt", "2026-01")[0] + == 5_000 + ) + # Savings at face value; property and shares less 10% for the expenses + # of sale, and the property less its mortgage (UC Regs 2013 reg. 49(1)): + # 12,000 + (10,800 - 5,000) + 10,800. + assert simulation.calculate("uc_assessable_capital", "2026-01")[0] == 28_600 assert not simulation.calculate("is_uc_eligible", "2026-01")[0] @@ -43,7 +51,9 @@ def test_pension_credit_capital_stocks_are_not_prorated_monthly(): simulation = Simulation(situation=situation) assert simulation.calculate("savings", "2026-01")[0] == 12_000 + # Land and shares less 10% for the expenses of sale (SPC Regs 2002 + # reg. 19(a)): 12,000 + 2 * 10,800. assert ( simulation.calculate("pension_credit_assessable_capital", "2026-01")[0] - == 36_000 + == 33_600 ) diff --git a/policyengine_uk/tests/test_stored_household_totals.py b/policyengine_uk/tests/test_stored_household_totals.py index 1088176bda..da9a7e7ab3 100644 --- a/policyengine_uk/tests/test_stored_household_totals.py +++ b/policyengine_uk/tests/test_stored_household_totals.py @@ -216,10 +216,26 @@ def test_stored_household_totals_grow_past_the_projection_horizon(): assert totals[year][0] == pytest.approx( totals[PROJECTION_END_YEAR][0] * growth, rel=1e-6 ) - # Savings, the cash ISA and the stocks-and-shares ISA (through - # share_like_capital) make up the capital, each grown alike. + # Savings, the cash ISA and the stocks-and-shares ISA make up the capital. + # Savings and the cash ISA count at their value. The stocks-and-shares ISA + # reaches the capital through share_like_capital, which Universal Credit + # values less its sale-expense rate (Universal Credit Regulations 2013 + # reg. 49(1)(a)). The parts grow alike, so capital keeps the same ratio to + # savings in every year: 5.8 for this household. + cash_isa = _values(simulation, "cash_isa") + shares_isa = _values(simulation, "stocks_and_shares_isa") + ratio = capital[DATA_YEAR][0] / savings[DATA_YEAR][0] for year in YEARS: - assert capital[year][0] == pytest.approx(6 * savings[year][0], rel=1e-5) + rate = simulation.tax_benefit_system.parameters( + period(year).start + ).gov.dwp.universal_credit.means_test.capital.sale_expenses.rate + expected = ( + savings[year][0] + cash_isa[year][0] + shares_isa[year][0] * (1 - rate) + ) + assert capital[year][0] == pytest.approx(expected, rel=1e-5), year + face_value = savings[year][0] + cash_isa[year][0] + shares_isa[year][0] + assert capital[year][0] < face_value - 0.01, year + assert capital[year][0] == pytest.approx(ratio * savings[year][0], rel=1e-5) @pytest.mark.parametrize( diff --git a/policyengine_uk/tests/test_uc_company_override_properties.py b/policyengine_uk/tests/test_uc_company_override_properties.py index 735847e6b8..1fac837bf1 100644 --- a/policyengine_uk/tests/test_uc_company_override_properties.py +++ b/policyengine_uk/tests/test_uc_company_override_properties.py @@ -20,6 +20,9 @@ pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") YEAR = 2026 +# corporate_wealth is valued at 90% for the expenses of sale (UC Regs 2013 +# reg. 49(1)(a)); disregarded holdings inside it leave the pool at that value. +SHARE_VALUE = 0.9 LAYOUTS = { "one-owner": ((10_000,), (), ()), "two-owners-one-unit": ((4_000, 6_000), (), ()), @@ -53,7 +56,7 @@ def _company_person(holding=0, dependant=False): def _grid(layout): - """Combine 960 independent three-unit households in one simulation.""" + """Combine 1,280 independent three-unit households in one simulation.""" people, units, households, cases = {}, {}, {}, [] first_holdings, dependant_holdings, second_holdings = LAYOUTS[layout] claimant_holdings = (sum(first_holdings), sum(second_holdings), 0) @@ -67,7 +70,7 @@ def _grid(layout): ) in enumerate( product( (0, 4_000, 10_000, 20_000), - (None, -1, 10_000), + (None, -1, 4_000, 10_000), (0, 4_000, 10_000, 20_000), (6_000, 20_000), OTHER_REPORTS, @@ -132,14 +135,15 @@ def test_company_overrides_conserve_capital_and_keep_holdings_out_of_other_units for row, (specs, adults, holdings, dependants, corporate, savings) in zip( assessed, cases ): - pool = corporate + savings + # The pool counts corporate_wealth at 90% (reg. 49(1)(a)), and holdings + # inside it leave the pool at that value. + pool = SHARE_VALUE * corporate + savings missing = [ "override" not in spec and spec.get("financial", -1) < 0 for spec in specs ] explicit = [ spec.get("override", max(0, spec.get("financial", -1))) for spec in specs ] - explicit_consumption = sum(explicit) # Invalid holdings are bounded per person, before grouping into units. # Allocate one budget by those ownership shares; never recap a route. bounded_holdings = [ @@ -150,8 +154,22 @@ def test_company_overrides_conserve_capital_and_keep_holdings_out_of_other_units total_holdings = sum(bounded_holdings) + bounded_dependants budget = min(corporate, total_holdings) scale = budget / total_holdings if total_holdings else 0 - effective_holdings = [holding * scale for holding in bounded_holdings] - extra_holding_draw = bounded_dependants * scale + sum( + market_holdings = [holding * scale for holding in bounded_holdings] + effective_holdings = [SHARE_VALUE * holding for holding in market_holdings] + # A recorded financial total holds its owners' holdings at full value; + # the pool counts them at 90% (reg. 49(1)(a)), so the report consumes + # its part beyond the holdings in full and the holdings at 90%. Like an + # override, a report below the holdings still consumes them: a report + # of 4,000 against a 10,000 holding consumes 9,000. + explicit_consumption = sum( + spec["override"] + if "override" in spec + else max(0, spec["financial"] - market) + SHARE_VALUE * market + if spec.get("financial", -1) >= 0 + else 0 + for spec, market in zip(specs, market_holdings) + ) + extra_holding_draw = SHARE_VALUE * bounded_dependants * scale + sum( holding if absent else max(0, holding - spec["override"]) @@ -181,9 +199,20 @@ def test_company_overrides_conserve_capital_and_keep_holdings_out_of_other_units # Account for what regulation 77 actually removes from reporting # owners, without scaling that substitution. This identity also # covers inconsistent observations and exhausted residual pools. + # A complete override is a countable figure, so the holding comes off + # it at 90%. A recorded financial total is used unvalued, so the owner + # loses the holding in full while the pool gives up only its 90% + # value: what the report removes from the pool beyond the owner's own + # remaining capital is its consumption less that capital. owner_substitution = sum( - min(holding, reported) - for holding, reported, absent in zip(holdings, explicit, missing) + min(SHARE_VALUE * holding, reported) + if "override" in spec + else max(0, reported - market) + + SHARE_VALUE * market + - max(0, reported - holding) + for holding, market, reported, absent, spec in zip( + holdings, market_holdings, explicit, missing, specs + ) if not absent ) assert row.sum() + owner_substitution + extra_holding_draw == pytest.approx( @@ -303,7 +332,8 @@ def test_regrouping_holdings_between_holders_and_units_preserves_residual_pool() savings, ) ) - expected.append(savings + max(0, corporate - total)) + # Shares left after the holdings count at 90% (reg. 49(1)(a)). + expected.append(savings + SHARE_VALUE * max(0, corporate - total)) situation, residual_rows = _pool_property_situation(cases) capital = np.asarray( Simulation(situation=situation).calculate("uc_assessable_capital", YEAR) diff --git a/policyengine_uk/tests/test_uc_company_owner_properties.py b/policyengine_uk/tests/test_uc_company_owner_properties.py index beafcec75b..85eb2e3818 100644 --- a/policyengine_uk/tests/test_uc_company_owner_properties.py +++ b/policyengine_uk/tests/test_uc_company_owner_properties.py @@ -27,8 +27,9 @@ not report their capital, the household's capital less every owner's holding (up to its corporate wealth) is shared between the units in proportion to their claimants, and each unit adds its own claimants' - company capital; so the units' capital sums to that pool plus the owners' - company capital. + company capital. Each valuation, owner's company capital and unit's final + capital is rounded to the nearest penny, and the household total sums the + rounded units (reg. 6(1)). 4. Earnings identity: earned income before the floor is pay less the person's own income tax and NI on the employment, plus the trading total floored at zero, less their relievable pension contributions, floored at @@ -66,13 +67,22 @@ holding, whatever the generated examples reach. """ +from decimal import Decimal, ROUND_HALF_UP + import numpy as np +import pytest from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation +from policyengine_uk.utils.capital_valuation import exact_amount + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") YEAR = 2026 +# corporate_wealth (shares) is valued at 90% for the expenses of sale (UC Regs +# 2013 reg. 49(1)(a)), so a disregarded holding inside it comes off at 90%. +SHARE_VALUE = 0.9 PROPERTY_SETTINGS = settings( max_examples=10, deadline=None, @@ -121,6 +131,20 @@ def close(a, b): return abs(a - b) <= 1e-2 + 1e-6 * abs(b) +def stored_amount(value): + """Use the same float32 input precision as the simulation.""" + return float(exact_amount(np.float32(value))) + + +def penny(value): + # UC Regs 2013 reg. 6(1): half a penny is rounded up, for every amount + # calculated (https://www.legislation.gov.uk/uksi/2013/376/regulation/6). + # Remove binary arithmetic noise below a millionth of a penny before + # applying the legal rule, independently of the model rounding helper. + amount = Decimal(str(value)).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) + return float(amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) + + @st.composite def companies(draw): company_capital = draw(st.one_of(st.just(0.0), st.floats(0, 12_000))) @@ -265,17 +289,19 @@ def applies(company): def company_capital_of(company): - """Capital treated as possessed under reg. 77(2), net of 77(3)(a).""" + """Reg. 77(2) capital, net of 77(3)(a), rounded per person (reg. 6(1)).""" engaged = ( company["is_engaged_in_owned_company_trade"] or company["owned_company_is_main_employment"] ) disregarded = ( - company["owned_company_trade_assets"] + stored_amount(company["owned_company_trade_assets"]) if company["owned_company_carries_on_trade"] and engaged else 0.0 ) - return max(0.0, company["owned_company_capital"] - disregarded) + return penny( + max(0.0, stored_amount(company["owned_company_capital"]) - disregarded) + ) def pinned_company(**facts): @@ -488,7 +514,7 @@ def test_capital_and_earnings_identities(unit_list): assert close(earned, max(before, floor)) else: assert close(earned, before) - disregarded = min(holdings, unit["corporate_wealth"]) + disregarded = SHARE_VALUE * min(holdings, unit["corporate_wealth"]) expected = base["uc_assessable_capital"][i] - disregarded + company_capital assert close(got["uc_assessable_capital"][i], expected) @@ -612,6 +638,34 @@ def test_property_company_gives_no_earnings(unit_list, share): ) ] ) +@example( + [ + ( + pinned_unit( + pinned_adult(), + pinned_adult(), + corporate_wealth=6392.862111833667, + ), + pinned_unit( + pinned_adult( + pinned_company(owned_company_holding_value=6336.697665836761) + ), + pinned_adult(), + ), + ) + ] +) +@example( + [ + ( + pinned_unit( + pinned_adult(pinned_company(owned_company_holding_value=0.40)), + corporate_wealth=0.65, + ), + pinned_unit(pinned_adult(pinned_company(owned_company_holding_value=0.40))), + ) + ] +) def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs): # Each household holds two benefit units: the first unit's adults and the # second's, with the first unit's savings and corporate wealth. No unit @@ -621,7 +675,7 @@ def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs people, benunits, households = {}, {}, {} expected_units, expected_households = [], [] for h, (first, second) in enumerate(pairs): - names, holdings, unit_company_capital = [], 0.0, [] + names, holdings, unit_company_capital = [], [], [] for b, unit in enumerate((first, second)): members, company_capital = [], 0.0 for j, adult in enumerate(unit["adults"]): @@ -634,7 +688,7 @@ def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs **{k: {YEAR: v} for k, v in c.items()}, } if applies(c): - holdings += c["owned_company_holding_value"] + holdings.append(c["owned_company_holding_value"]) company_capital += company_capital_of(c) members.append(name) benunits[f"h{h}_b{b}"] = {"members": members} @@ -645,12 +699,38 @@ def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs "savings": {YEAR: first["savings"]}, "corporate_wealth": {YEAR: first["corporate_wealth"]}, } - disregarded = min(holdings, first["corporate_wealth"]) - pool = first["savings"] + first["corporate_wealth"] - disregarded + shares = stored_amount(first["corporate_wealth"]) + # The holdings budget uses raw stored inputs and stores its scale as + # float32. Apply that scale to the recovered monetary amounts before + # valuation; an ideal min(total holdings, shares) loses this rounding + # boundary when holdings exceed the budget. + raw_shares = np.float32(first["corporate_wealth"]) + raw_holdings = sum( + float(np.clip(np.float32(holding), np.float32(0), raw_shares)) + for holding in holdings + ) + scale = float( + np.float32(min(1.0, float(raw_shares) / raw_holdings)) + if raw_holdings > 0 + else np.float32(1.0) + ) + disregarded = sum( + min(stored_amount(holding), shares) * scale for holding in holdings + ) + # Reg. 49(1)(a) values the shares and the removed holdings separately; + # reg. 6(1) rounds each valuation, then each unit's final capital. + pool = max( + 0.0, + stored_amount(first["savings"]) + + penny(SHARE_VALUE * shares) + - penny(SHARE_VALUE * disregarded), + ) claimants = [len(first["adults"]), len(second["adults"])] for n, company_capital in zip(claimants, unit_company_capital): - expected_units.append(pool * n / sum(claimants) + company_capital) - expected_households.append(pool + sum(unit_company_capital)) + expected_units.append(penny(pool * n / sum(claimants) + company_capital)) + # Allocation rounding belongs to each benefit unit. Summing the + # unrounded household pool would lose up to half a penny per unit. + expected_households.append(sum(expected_units[-2:])) sim = Simulation( situation={"people": people, "benunits": benunits, "households": households} ) diff --git a/policyengine_uk/tests/test_uc_property_capital.py b/policyengine_uk/tests/test_uc_property_capital.py index 02435651c5..bf697457b3 100644 --- a/policyengine_uk/tests/test_uc_property_capital.py +++ b/policyengine_uk/tests/test_uc_property_capital.py @@ -285,7 +285,10 @@ def test_missing_financial_and_spi_style_inputs_retain_the_financial_proxy(finan }, ) situation["people"]["p0_0"]["lifetime_isa_balance"] = {YEAR: 4_000} - assert _values(Simulation(situation=situation))[0] == 18_000 + # 4,000 of savings + 90% of 2,000 of shares and of 10,000 of property + # (reg. 49(1)(a)) + 75% of the 4,000 Lifetime ISA: 4,000 + 1,800 + 9,000 + # + 3,000. + assert _values(Simulation(situation=situation))[0] == 17_800 @pytest.mark.parametrize("financial", [-1, 0, 6_000]) @@ -559,16 +562,21 @@ def test_complete_override_company_holding_cannot_leak_into_missing_unit_pool( # owner's holding. It consumes at least that holding from the pool once, # including when zero or smaller than the holding, and supersedes the # financial observation. + # The holding sits in corporate_wealth, which counts at 90% (reg. + # 49(1)(a)), so it is 9,000 of countable capital and the pool is 6,000 + + # 9,000 = 15,000. + holding = 0.9 * 10_000 assert _values(simulation, "household_uc_pool_company_holdings")[0] == 0 - assert _values(simulation, "household_uc_reported_financial_capital")[0] == max( - complete_override, 10_000 - ) + assert _values(simulation, "household_uc_reported_financial_capital")[ + 0 + ] == pytest.approx(max(complete_override, holding)) np.testing.assert_allclose( _values(simulation), [ - max(0, complete_override - 10_000), - max(0, 16_000 - max(complete_override, 10_000)), + max(0, complete_override - holding), + max(0, 6_000 + holding - max(complete_override, holding)), ], + atol=0.01, ) @@ -595,5 +603,8 @@ def test_owner_override_consumes_company_holding_once_in_twenty_thousand_savings # £30k household pool less the owner's £10k override leaves £20k. The # override includes the holding; removing it again would leave £10k and # incorrectly award the other unit £4,263.60 Universal Credit in 2026. - np.testing.assert_allclose(_values(simulation, year=year), [0, 20_000]) + # With corporate_wealth counted at 90% (reg. 49(1)(a)) the pool is 20,000 + + # 9,000 = 29,000; the override consumes 10,000, leaving 19,000, and the + # owner keeps 10,000 less the 9,000 holding. + np.testing.assert_allclose(_values(simulation, year=year), [1_000, 19_000]) assert _values(simulation, "universal_credit", year=year)[1] == 0 diff --git a/policyengine_uk/tests/test_uc_property_capital_mock.py b/policyengine_uk/tests/test_uc_property_capital_mock.py index 6f32099a88..a84f8278ee 100644 --- a/policyengine_uk/tests/test_uc_property_capital_mock.py +++ b/policyengine_uk/tests/test_uc_property_capital_mock.py @@ -192,10 +192,13 @@ def test_mock_owner_override_consumes_its_company_holding_once(tmp_path): ), ) - # The owner's override already contains the holding. The other unit keeps - # the £20k residual and is ineligible for UC after the H5 input round trip. + # The owner's override already contains the holding, counted at the 90% + # at which corporate_wealth is valued (reg. 49(1)(a)): the pool is £20k + + # £9k, the override consumes £10k, and the other unit keeps the £19k + # residual and is ineligible for UC after the H5 input round trip; the + # owner keeps £10k less the £9k holding. np.testing.assert_allclose( - simulation.calculate("uc_assessable_capital", year), [0, 20_000] + simulation.calculate("uc_assessable_capital", year), [1_000, 19_000] ) assert simulation.calculate("universal_credit", year)[1] == 0 @@ -363,7 +366,9 @@ def test_mock_property_valuation_uses_the_assessment_year(tmp_path): assert simulation.calculate("uc_assessable_capital", 2026)[0] == 24_000 -@pytest.mark.parametrize("reported, expected", [(6_000, 6_000), (0, 0), (-1, 56_000)]) +# With nothing reported, Pension Credit values the property at 90% (SPC Regs +# 2002 reg. 19(a)): 6,000 + 45,000. +@pytest.mark.parametrize("reported, expected", [(6_000, 6_000), (0, 0), (-1, 51_000)]) def test_mock_pension_credit_keeps_its_existing_reported_capital_contract( tmp_path, reported, expected ): diff --git a/policyengine_uk/utils/capital_valuation.py b/policyengine_uk/utils/capital_valuation.py new file mode 100644 index 0000000000..a10bd0e687 --- /dev/null +++ b/policyengine_uk/utils/capital_valuation.py @@ -0,0 +1,212 @@ +"""Value means-tested capital as the capital-valuation regulations require. + +Universal Credit calculates capital "at its current market value or surrender +value less— (a) where there would be expenses attributable to sale, 10%; and +(b) the amount of any encumbrances secured on it" (UC Regs 2013 reg. 49(1)). +The legacy means tests have equivalent rules (HB Regs 2006 reg. 47, IS Regs +1987 reg. 49, JSA Regs 1996 reg. 111, ESA Regs 2008 reg. 113, SPC Regs 2002 +reg. 19). Each programme sets its own rate and the capital sources whose sale +would incur expenses in a ``capital.sale_expenses`` parameter node, and how it +rounds a fraction of a penny in ``capital.round_down``: to the nearest penny +for Universal Credit and Housing Benefit, down for the others. +""" + +import numpy as np +from policyengine_core.model_api import max_ + +from policyengine_uk.utils.supplied_inputs import supplied_input +from policyengine_uk.variables.gov.dwp._person_capital_pool import ( + HOUSEHOLD_SOURCE_OF, + counted_person_sources, +) + +# Household inputs holding the debt secured on each capital source, such as a +# mortgage on a second property. The debt is deducted from the source it is +# secured on and that source is floored at nil, so negative equity in one +# source never reduces another (ADM H1615-H1616). Each source is a category +# summed over the household's assets, so within a category the debts on one +# asset still offset equity in another. +SECURED_DEBT = { + "other_residential_property_value": "other_residential_property_secured_debt", + "non_residential_property_value": "non_residential_property_secured_debt", + "owned_land": "owned_land_secured_debt", +} + + +def valued_capital(value_of, capital): + """Sum ``capital.sources`` at market value less sale expenses and secured debt. + + ``value_of(variable)`` returns that household variable on the entity the + caller is working with, and ``capital`` is the programme's capital + parameter node. The sale-expense deduction is taken from the gross value + before the encumbrance (ADM H1608), and each valuation is rounded to the + penny under the programme's rule. A source carrying secured debt is never + valued below nil. The total is in float64, unrounded: the caller rounds + the capital it assesses at the last stage (``round_to_penny``). + """ + total = 0 + for source in capital.sources: + value = exact_amount(value_of(source)) + if source in capital.sale_expenses.sources: + value = less_sale_expenses( + value, capital.sale_expenses.rate, capital.round_down + ) + if source in SECURED_DEBT: + value = max_(0, value - exact_amount(value_of(SECURED_DEBT[source]))) + total = total + value + return total + + +def valued_person_capital(benunit, period, parameters, capital, counted): + """Sum ``capital.person_sources`` over the benefit unit's members for whom + ``counted`` is true (its claimant and partner), in float64. + + A source whose formula can give a fraction of a penny + (``CALCULATED_PERSON_SOURCES``) is valued to the penny under the + programme's rule, like any other valuation. The baseline formula is + worked again in float64, so float32 cannot move the rounding, and only + where the variable's ``defined_for`` mask applies it, as Core does. Like + Core, it runs no formula when the mask applies to nobody. + Explicit inputs for this period and branch, and values from a reformed + formula, are kept as stored: matching a baseline value in float32 does + not prove where a value came from. Any other source is a stored + amount. + + The function counts only the sources ``counted_person_sources`` keeps. A + person source that a household source includes (``HOUSEHOLD_SOURCE_OF``) + holds pounds the formulas moved out of that household source, so the + function values it as ``valued_capital`` values the household source: + less the sale-expense rate where ``capital.sale_expenses.sources`` lists + the household source.""" + person = benunit.members + total = 0 + for source in counted_person_sources(capital): + stored = person(source, period) + value = exact_amount(stored) + if HOUSEHOLD_SOURCE_OF.get(source) in capital.sale_expenses.sources: + value = less_sale_expenses( + value, capital.sale_expenses.rate, capital.round_down + ) + if source in CALCULATED_PERSON_SOURCES: + calculate = CALCULATED_PERSON_SOURCES[source] + # A holder can predate a reform; the system owns the active formula. + variable = person.simulation.tax_benefit_system.get_variable(source) + if ( + not variable.is_neutralized + and variable.get_formula(period) is calculate + and supplied_input(person, source, period) is None + ): + applicable = None + if variable.defined_for is not None: + # Core keeps the formula's result only where the + # defined_for variable is above zero and stores the + # default elsewhere, and runs no formula when it is + # above zero for nobody; do the same. + applicable = ( + person.simulation.calculate( + variable.defined_for, period, map_to=variable.entity.key + ) + > 0 + ) + if applicable is None or applicable.any(): + exact = calculate(person, period, parameters) + if applicable is not None: + exact = np.where(applicable, exact, value) + value = exact + value = round_to_penny(value, capital.round_down) + total = total + benunit.sum(value * counted) + return total + + +def lifetime_isa_surrender_value(person, period, parameters): + """Surrender value of each person's Lifetime ISAs, in float64: the balance + less the withdrawal charge below the charge-free age, and the whole balance + from that age. + + 75% of a whole-penny balance can end in a quarter or half penny (75% of + £0.26 is £0.195), which float32 would store as 0.19499999 and so move a + nearest-penny rounding; 75% of £1,000.04 is £750.03.""" + p = parameters(period).gov.hmrc.isa.lifetime + balance = exact_amount(person("lifetime_isa_balance", period)) + charge_free = person("age", period) >= p.charge_free_age + return np.where(charge_free, balance, balance * (1 - p.withdrawal_charge)) + + +# Person-level capital sources the means tests value from their inputs, each +# mapped to the float64 calculation of its value. +CALCULATED_PERSON_SOURCES = { + "lifetime_isa_countable_capital": lifetime_isa_surrender_value, +} + + +def counted_share(sale_expenses, source): + """Share of a source's market value counted as capital: 90% where its + sale would incur expenses (reg. 49(1)(a)), otherwise all of it.""" + return 1 - sale_expenses.rate if source in sale_expenses.sources else 1 + + +def less_sale_expenses(value, rate, round_down): + """Market value less the sale-expense ``rate`` (reg. 49(1)(a)), rounded to + the penny under the programme's rule. + + The deduction is worked on the exact stored value: 250 shares at £4.1375 + are £1,034.375, and less 10% that is £930.9375, so £930.93 rounded down + (ADM H1665) or £930.94 to the nearest penny (UC Regs 2013 reg. 6(1)).""" + return round_to_penny( + exact_amount(value) * (1 - np.asarray(rate, dtype=np.float64)), round_down + ) + + +def exact_amount(amount): + """``amount`` in float64, with float32 storage error removed. + + Inputs are stored in float32, which holds £1,000.10 as 1,000.0999756 and + 90% of £16,001 as 14,400.8994. A float32 value, or one widened from + float32 to float64, is taken to be a whole penny when that penny is within + half its float32 spacing: it is then the penny the value was stored from. + Below £131,072 float32 holds every penny, so at most one penny is that + close. From £131,072 its spacing is wider than a penny, the penny cannot + be identified, and the value is kept as stored. A capital figure built + from it can then be a penny either way: £140,000.01 and £140,000.02 are + both stored as 140,000.015625, and 90% of that rounds down to + £126,000.01, a penny above the £126,000.00 that £140,000.01 gives. The + same holds for any stored value from £262,144, where the spacing exceeds + two pennies. A value in whole pounds below £16,777,216 is stored exactly + and is unaffected. Any other value (a share price to four decimal places, + or a float64 that is not a float32) is kept exactly.""" + wide = np.asarray(amount).astype(np.float64) + pennies = np.round(wide, 2) + # Zero and subnormal values underflow in np.spacing and in the cast to + # float32, and an infinite one overflows; neither is a penny that needs + # recovering. + with np.errstate(over="ignore", under="ignore", invalid="ignore"): + narrow = wide.astype(np.float32) + precision = np.where( + narrow == wide, + np.spacing(np.abs(narrow)).astype(np.float64), + np.spacing(np.abs(wide)), + ) + stored_from_penny = (2 * np.abs(pennies - wide) <= precision) & ( + precision < 0.01 + ) + return np.where(stored_from_penny, pennies, wide) + + +def round_to_penny(amount, round_down): + """Round ``amount`` to the penny: down where ``round_down``, otherwise to + the nearest penny with half a penny rounded up. + + Universal Credit treats a fraction of half a penny or more as a penny and + disregards a smaller one (UC Regs 2013 reg. 6(1)), and Housing Benefit may + do the same (HB Regs 2006 reg. 80). The legacy benefits treat a fraction + as a penny only where that is to the claimant's advantage (IS Regs 1987 + reg. 27, JSA Regs 1996 reg. 92, ESA Regs 2008 reg. 3(a), SPC Regs 2002 + reg. 24A), which for capital means rounding down. + + Computed in float64, and rounded to a millionth of a penny first, so binary + noise next to a whole or half penny (206.99999999999997 pence for 90% of + £2.30) does not move it. That tolerance is intended: an amount within a + millionth of a penny below a whole penny, which only a value that is not + a whole penny can produce, rounds to that penny.""" + pence = np.round(np.asarray(amount, dtype=np.float64) * 100, 6) + return np.where(round_down, np.floor(pence), np.floor(pence + 0.5)) / 100 diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index d8a01bb8c8..f1246bf4c1 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -78,6 +78,7 @@ "pip_m_points", "local_bus_trips", "local_bus_uncapped_single_fare", + "lifetime_isa_countable_capital", "miscellaneous_income_trading_allowance_deduction", "monthly_domestic_energy_consumption", "ni_class_4_losses_brought_forward", 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 c4d0991e28..ac12bf0341 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py @@ -1,6 +1,10 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import ( + round_to_penny, + valued_capital, + valued_person_capital, +) from policyengine_uk.variables.gov.dwp._person_capital_pool import ( - counted_person_sources, pooled_person_capital, ) @@ -11,7 +15,12 @@ class esa_income_assessable_capital(Variable): label = "Assessable capital for income-related ESA" documentation = ( "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 " + "capital test. " + "The formula values each household source, less the ISA balances it " + "counts in their holders' own benunits, at market value less 10% where a sale would incur " + "expenses and less any debt secured on it (reg. 113), and values each " + "such ISA balance as it values that household source. " + "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 " @@ -32,10 +41,12 @@ def formula(benunit, period, parameters): person = benunit.members claiming_esa_income = add(benunit, period, ["esa_income_reported"]) > 0 - household_capital = sum( - benunit.max(person.household(source, period)) - - pooled_person_capital(benunit, source, period, ESA.capital) - for source in ESA.capital.sources + household_capital = valued_capital( + lambda source: ( + benunit.max(person.household(source, period)) + - pooled_person_capital(benunit, source, period, ESA.capital) + ), + ESA.capital, ) # Regulation 83(2) excludes children's and young persons' capital. # The claimant/partner weights approximate otherwise unobserved ownership. @@ -54,11 +65,11 @@ def formula(benunit, period, parameters): household_capital * benunit_claimants_and_partners / fallback_divisor ) claimant_or_partner = person("is_claimant_or_partner", period) - person_capital = sum( - benunit.sum(person(source, period) * claimant_or_partner) - for source in counted_person_sources(ESA.capital) + person_capital = valued_person_capital( + benunit, period, parameters, ESA.capital, claimant_or_partner ) household_share = where( household_reporting_claimants > 0, claiming_proxy, fallback_proxy ) - return household_share + person_capital + # A fraction of a penny is disregarded at the last stage (reg. 3(a)). + return round_to_penny(household_share + person_capital, ESA.capital.round_down) 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 b8e4fdfd59..f3a84f8c8f 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,6 +1,11 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import ( + exact_amount, + round_to_penny, + valued_capital, + valued_person_capital, +) from policyengine_uk.variables.gov.dwp._person_capital_pool import ( - counted_person_sources, pooled_person_capital, ) @@ -10,7 +15,13 @@ class housing_benefit_assessable_capital(Variable): entity = BenUnit label = "Housing Benefit assessable capital" documentation = ( - "Housing Benefit capital counted from the configured capital sources. The " + "Housing Benefit capital counted from the configured capital sources. " + "The formula values each household source, less the ISA balances it " + "counts in their holders' own benunits, at market value less 10% where a sale would incur " + "expenses and less any debt secured on it (reg. 47 of SI 2006/213, " + "reg. 45 of SI 2006/214), and values each " + "such ISA balance as it values that household source. " + "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' " @@ -45,15 +56,16 @@ 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) - - pooled_person_capital(benunit, source, period, p) - for source in p.sources + household_capital = valued_capital( + lambda source: ( + household(source, period) + - pooled_person_capital(benunit, source, period, p) + ), + p, ) claimant_or_partner = person("is_claimant_or_partner", period) - person_capital = sum( - benunit.sum(person(source, period) * claimant_or_partner) - for source in counted_person_sources(p) + person_capital = valued_person_capital( + benunit, period, parameters, p, claimant_or_partner ) benunit_claimants_and_partners = add( benunit, period, ["is_claimant_or_partner"] @@ -81,7 +93,7 @@ def formula(benunit, period, parameters): ) capital = where( savings_credit_only, - benunit("pension_credit_assessable_capital", period), + exact_amount(benunit("pension_credit_assessable_capital", period)), household_capital_proxy + person_capital, ) # Guarantee Credit passport: SI 2006/214 reg 26 (NI: SR 2006/406 @@ -103,8 +115,9 @@ def formula(benunit, period, parameters): on_passporting_benefit = benunit( "housing_benefit_on_passporting_benefit", period ) - return where( - passported | on_passporting_benefit, - 0, - max_(0, capital), + # Rounded to the nearest penny at the last stage (reg. 80; HB (SPC) + # Regs 2006 reg. 61(6)). + return round_to_penny( + where(passported | on_passporting_benefit, 0, max_(0, capital)), + p.round_down, ) 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 82fd5a0cfe..b36f36b1b3 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py @@ -1,6 +1,10 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import ( + round_to_penny, + valued_capital, + valued_person_capital, +) from policyengine_uk.variables.gov.dwp._person_capital_pool import ( - counted_person_sources, pooled_person_capital, ) @@ -11,7 +15,12 @@ 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. For capital whose owner the data do not identify, the model " + "capital test. " + "The formula values each household source, less the ISA balances it " + "counts in their holders' own benunits, at market value less 10% where a sale would incur " + "expenses and less any debt secured on it (reg. 49), and values each " + "such ISA balance as it values that household source. " + "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 " @@ -28,7 +37,6 @@ class income_support_assessable_capital(Variable): def formula(benunit, period, parameters): IS = parameters(period).gov.dwp.income_support - capital = IS.means_test.capital person = benunit.members would_claim_is = benunit("would_claim_IS", period) @@ -38,10 +46,12 @@ def formula(benunit, period, parameters): # If nobody in the household is on that path, use claimant/partner # 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)) - - pooled_person_capital(benunit, source, period, capital) - for source in capital.sources + household_capital = valued_capital( + lambda source: ( + benunit.max(person.household(source, period)) + - pooled_person_capital(benunit, source, period, IS.means_test.capital) + ), + IS.means_test.capital, ) benunit_claimants_and_partners = add( benunit, period, ["is_claimant_or_partner"] @@ -61,11 +71,13 @@ def formula(benunit, period, parameters): household_capital * benunit_claimants_and_partners / fallback_divisor ) claimant_or_partner = person("is_claimant_or_partner", period) - person_capital = sum( - benunit.sum(person(source, period) * claimant_or_partner) - for source in counted_person_sources(capital) + person_capital = valued_person_capital( + benunit, period, parameters, IS.means_test.capital, claimant_or_partner ) household_share = where( household_claiming_members > 0, claiming_proxy, fallback_proxy ) - return household_share + person_capital + # A fraction of a penny is disregarded at the last stage (reg. 27). + return round_to_penny( + household_share + person_capital, IS.means_test.capital.round_down + ) 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 2cf466c410..4ed0c7ea10 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py @@ -1,6 +1,10 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import ( + round_to_penny, + valued_capital, + valued_person_capital, +) from policyengine_uk.variables.gov.dwp._person_capital_pool import ( - counted_person_sources, pooled_person_capital, ) @@ -11,7 +15,12 @@ class jsa_income_assessable_capital(Variable): label = "Assessable capital for income-based JSA" documentation = ( "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 " + "capital test. " + "The formula values each household source, less the ISA balances it " + "counts in their holders' own benunits, at market value less 10% where a sale would incur " + "expenses and less any debt secured on it (reg. 111), and values each " + "such ISA balance as it values that household source. " + "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 " @@ -32,10 +41,12 @@ def formula(benunit, period, parameters): person = benunit.members claiming_jsa_income = add(benunit, period, ["jsa_income_reported"]) > 0 - household_capital = sum( - benunit.max(person.household(source, period)) - - pooled_person_capital(benunit, source, period, JSA.capital) - for source in JSA.capital.sources + household_capital = valued_capital( + lambda source: ( + benunit.max(person.household(source, period)) + - pooled_person_capital(benunit, source, period, JSA.capital) + ), + JSA.capital, ) # Regulation 88(2) excludes children's and young persons' capital. # The claimant/partner weights approximate otherwise unobserved ownership. @@ -54,11 +65,11 @@ def formula(benunit, period, parameters): household_capital * benunit_claimants_and_partners / fallback_divisor ) claimant_or_partner = person("is_claimant_or_partner", period) - person_capital = sum( - benunit.sum(person(source, period) * claimant_or_partner) - for source in counted_person_sources(JSA.capital) + person_capital = valued_person_capital( + benunit, period, parameters, JSA.capital, claimant_or_partner ) household_share = where( household_reporting_claimants > 0, claiming_proxy, fallback_proxy ) - return household_share + person_capital + # A fraction of a penny is disregarded at the last stage (reg. 92). + return round_to_penny(household_share + person_capital, JSA.capital.round_down) diff --git a/policyengine_uk/variables/gov/dwp/lifetime_isa_countable_capital.py b/policyengine_uk/variables/gov/dwp/lifetime_isa_countable_capital.py index 6daf2817c3..83ce5d8dea 100644 --- a/policyengine_uk/variables/gov/dwp/lifetime_isa_countable_capital.py +++ b/policyengine_uk/variables/gov/dwp/lifetime_isa_countable_capital.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import lifetime_isa_surrender_value class lifetime_isa_countable_capital(Variable): @@ -23,8 +24,8 @@ class lifetime_isa_countable_capital(Variable): "https://www.legislation.gov.uk/uksi/1998/1870/schedule", ] - def formula(person, period, parameters): - p = parameters(period).gov.hmrc.isa.lifetime - balance = person("lifetime_isa_balance", period) - charge_free = person("age", period) >= p.charge_free_age - return where(charge_free, balance, balance * (1 - p.withdrawal_charge)) + # The shared function's identity lets the means tests distinguish this + # baseline formula from a reform. They work it again in float64 before + # rounding, while preserving explicit inputs and reformed values + # (capital_valuation.valued_person_capital). + formula = lifetime_isa_surrender_value 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 1e009aa3a3..d62e1a4f0d 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,6 +1,11 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import ( + exact_amount, + round_to_penny, + valued_capital, + valued_person_capital, +) from policyengine_uk.variables.gov.dwp._person_capital_pool import ( - counted_person_sources, pooled_person_capital, ) @@ -10,7 +15,12 @@ class pension_credit_assessable_capital(Variable): entity = BenUnit label = "Pension Credit assessable capital" documentation = ( - "Pension Credit capital counted from the configured capital sources. The " + "Pension Credit capital counted from the configured capital sources. " + "The formula values each household source, less the ISA balances it " + "counts in their holders' own benunits, at market value less 10% where a sale would incur " + "expenses and less any debt secured on it (reg. 19), and values each " + "such ISA balance as it values that household source. " + "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 " @@ -31,15 +41,16 @@ 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) - - pooled_person_capital(benunit, source, period, p) - for source in p.sources + household_capital = valued_capital( + lambda source: ( + household(source, period) + - pooled_person_capital(benunit, source, period, p) + ), + p, ) claimant_or_partner = person("is_claimant_or_partner", period) - person_capital = sum( - benunit.sum(person(source, period) * claimant_or_partner) - for source in counted_person_sources(p) + person_capital = valued_person_capital( + benunit, period, parameters, p, claimant_or_partner ) any_pension_age = benunit.any( person("has_attained_state_pension_credit_qualifying_age", period) @@ -58,10 +69,15 @@ def formula(benunit, period, parameters): household_capital_proxy = ( household_capital * benunit_pension_age_adults / adult_divisor ) - reported_capital = benunit("pension_credit_reported_capital", period) + reported_capital = exact_amount( + benunit("pension_credit_reported_capital", period) + ) assessed_capital = where( reported_capital >= 0, reported_capital, household_capital_proxy + person_capital, ) - return where(any_pension_age, max_(0, assessed_capital), 0) + # A fraction of a penny is disregarded at the last stage (reg. 24A). + return round_to_penny( + where(any_pension_age, max_(0, assessed_capital), 0), p.round_down + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_capital.py index 541e637805..006a5f46b7 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_capital.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import exact_amount, round_to_penny class uc_company_capital(Variable): @@ -47,5 +48,13 @@ def formula(person, period, parameters): person("owned_company_trade_assets", period), 0, ) - capital = max_(0, person("owned_company_capital", period) - disregarded) - return where(applies, capital, 0) + # Worked in float64 from the stored amounts and rounded to the nearest + # penny (UC Regs 2013 reg. 6(1)): a float32 difference of two large + # amounts can be most of a penny out. + capital = max_( + 0, + exact_amount(person("owned_company_capital", period)) + - exact_amount(disregarded), + ) + p = parameters(period).gov.dwp.universal_credit.means_test.capital + return where(applies, round_to_penny(capital, p.round_down), 0) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py index fe96f39a8f..24f7db19eb 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py @@ -1,4 +1,9 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import ( + counted_share, + exact_amount, + round_to_penny, +) from policyengine_uk.variables.gov.dwp.universal_credit.household_uc_company_holding_pool_scale import ( HOLDINGS_SOURCE, ) @@ -26,8 +31,10 @@ class household_uc_pool_company_holdings(Variable): "All other holdings leave the pool here, including a dependant's " "holding even when their benunit supplies a report or override. " "When financial reports and holdings are compatible, no benunit counts " - "any part of a disregarded holding. An inconsistent financial report " - "that omits a known holding can leave it in the residual pool. A holding " + "any part of a disregarded holding. A financial report below its " + "holder's known holding still consumes the holding at its counted " + "value (household_uc_reported_financial_capital), so no report leaves " + "a holding in the residual pool. A holding " "is shares, held within the household's share_like_capital. Each " "person's holding is first clipped to [0, max(0, share_like_capital)], " "with positive infinity mapped to the upper bound and negative infinity " @@ -39,7 +46,9 @@ class household_uc_pool_company_holdings(Variable): "rule for inconsistent inputs, not a statutory determination. The " "shared budget prevents holdings from removing ordinary savings or " "property. It does not change regulation 77 substitution in the " - "owner's own assessment." + "owner's own assessment. The holdings leave the pool at the value at " + "which the pool counts share_like_capital: 90% where regulation 49(1)(a) " + "deducts 10% for the expenses of selling shares." ) definition_period = YEAR unit = GBP @@ -54,8 +63,8 @@ def formula(household, period, parameters): off_reported_capital = ( has_financial_report | has_complete_override ) & claimant_or_partner - holding = person("uc_company_holding_disregard", period) - upper_bound = max_(0, person.household(HOLDINGS_SOURCE, period)) + holding = exact_amount(person("uc_company_holding_disregard", period)) + upper_bound = max_(0, exact_amount(person.household(HOLDINGS_SOURCE, period))) finite_holding = np.where( np.isfinite(holding), holding, np.where(holding > 0, upper_bound, 0) ) @@ -64,4 +73,10 @@ def formula(household, period, parameters): * person.household("household_uc_company_holding_pool_scale", period) * ~off_reported_capital ) - return holdings + # The holdings sit in share_like_capital, which the pool counts at the + # reg. 49(1) value, so they leave it at that value. + p = parameters(period).gov.dwp.universal_credit.means_test.capital + return round_to_penny( + holdings * counted_share(p.sale_expenses, HOLDINGS_SOURCE), + p.round_down, + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py index eaaae275f0..f8fea0f06c 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py @@ -1,4 +1,9 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import ( + counted_share, + exact_amount, + round_to_penny, +) from policyengine_uk.variables.gov.dwp.universal_credit.household_uc_company_holding_pool_scale import ( HOLDINGS_SOURCE, ) @@ -16,7 +21,9 @@ class household_uc_reported_financial_capital(Variable): "replaces ordinary sources before regulation 77 company substitution, " "so it includes its claimant's and partner's disregarded holdings. " "Consume the larger of the override and those holdings after applying " - "household_uc_company_holding_pool_scale. Each person's holding is " + "household_uc_company_holding_pool_scale and valuing them as " + "share_like_capital is valued (90% under regulation 49(1)). Each " + "person's holding is " "first clipped to [0, max(0, share_like_capital)], with positive infinity " "mapped to the upper bound and negative infinity to zero before any " "multiplication. All household holdings share " @@ -30,7 +37,12 @@ class household_uc_reported_financial_capital(Variable): "holding leaves the pool separately in household_uc_pool_company_holdings. " "Subtracting complete overrides retains the inherited residual-pool " "convention. The scale changes only deductions from the household " - "pool, not regulation 77 substitution in the owner's own assessment." + "pool, not regulation 77 substitution in the owner's own assessment. " + "A recorded financial total is unvalued, so it consumes its part " + "beyond its claimants' holdings in full and the holdings at their " + "counted value. Like an override, a total below the holdings still " + "consumes them, so no part of an owner's holding is left in the pool " + "for another unit to count." ) definition_period = YEAR unit = GBP @@ -38,24 +50,41 @@ class household_uc_reported_financial_capital(Variable): def formula(household, period, parameters): person = household.members - financial_capital = person.benunit("benunit_reported_capital", period) - complete_override = person.benunit("uc_reported_capital", period) - holding = person("uc_company_holding_disregard", period) - upper_bound = max_(0, person.household(HOLDINGS_SOURCE, period)) + financial_capital = exact_amount( + person.benunit("benunit_reported_capital", period) + ) + complete_override = exact_amount(person.benunit("uc_reported_capital", period)) + holding = exact_amount(person("uc_company_holding_disregard", period)) + upper_bound = max_(0, exact_amount(person.household(HOLDINGS_SOURCE, period))) finite_holding = np.where( np.isfinite(holding), holding, np.where(holding > 0, upper_bound, 0) ) bounded_holdings = np.clip(finite_holding, 0, upper_bound) - # Match the claimant holding variable's storage precision before scaling. own_holdings = person.benunit.sum( bounded_holdings * person("is_uc_claimant", period) - ).astype(holding.dtype) * person.household( - "household_uc_company_holding_pool_scale", period + ) * person.household("household_uc_company_holding_pool_scale", period) + # The pool counts share_like_capital, and the holdings in it, at the reg. + # 49(1) value, rounded to the nearest penny (reg. 6(1)). A complete + # override is a countable figure, so the holdings it must at least + # cover are counted at that value. A recorded financial total is used + # unvalued and holds the holdings at full value, so it consumes its + # part beyond the holdings in full and the holdings at their counted + # value; a total below the holdings still consumes them, so no part of + # an owner's holding is left for another unit. The owner's own + # assessment still takes the full holding off the report. + p = parameters(period).gov.dwp.universal_credit.means_test.capital + valued_holdings = round_to_penny( + own_holdings * counted_share(p.sale_expenses, HOLDINGS_SOURCE), + p.round_down, ) consumed_capital = where( complete_override >= 0, - max_(complete_override, own_holdings), - financial_capital, + max_(complete_override, valued_holdings), + where( + financial_capital >= 0, + max_(0, financial_capital - own_holdings) + valued_holdings, + financial_capital, + ), ) return household.sum( max_(0, consumed_capital) * person("is_benunit_head", period) 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 11ca5eac23..9428fd87bc 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,6 +1,15 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import ( + counted_share, + exact_amount, + round_to_penny, + valued_capital, + valued_person_capital, +) +from policyengine_uk.variables.gov.dwp.universal_credit.household_uc_company_holding_pool_scale import ( + HOLDINGS_SOURCE, +) from policyengine_uk.variables.gov.dwp._person_capital_pool import ( - counted_person_sources, pooled_person_capital, ) @@ -30,7 +39,17 @@ class uc_assessable_capital(Variable): "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." + "the claimant's or partner's share of company capital. Regulation 49(1) " + "values the financial pool's sources whose sale would incur expenses " + "(sale_expenses.sources, shares in share_like_capital) at 90%, and the " + "formula values a stocks and shares ISA balance it moves out of " + "share_like_capital at the same 90%. A disregarded holding comes off at " + "the value at which the formula counted it: 90% in the pool and in a " + "complete override, which is a countable figure, and in full from a " + "recorded financial total, which the formula uses without valuation. " + "The formula rounds every amount it calculates to the nearest penny " + "(regulation 6(1)), including this capital, which it tests against the " + "limit." ) definition_period = YEAR unit = GBP @@ -54,26 +73,30 @@ def formula(benunit, period, parameters): (person.benunit("benunit_reported_capital", period) >= 0) | (person.benunit("uc_reported_capital", period) >= 0) ) - household_financial_capital = sum( - household(source, period) - - pooled_person_capital(benunit, source, period, p, ~covered) - for source in p.sources + household_financial_capital = valued_capital( + lambda source: ( + household(source, period) + - pooled_person_capital(benunit, source, period, p, ~covered) + ), + p, ) + # A reg. 77 holding sits in share_like_capital, so it is counted at the + # same 90% as the shares around it wherever they are valued. + holding_value = counted_share(p.sale_expenses, HOLDINGS_SOURCE) benunit_claimants = benunit.sum(claimant_or_partner) - person_capital = sum( - benunit.sum(person(source, period) * claimant_or_partner) - for source in counted_person_sources(p) + person_capital = valued_person_capital( + benunit, period, parameters, p, claimant_or_partner ) - financial_report = benunit("benunit_reported_capital", period) - complete_override = benunit("uc_reported_capital", period) + financial_report = exact_amount(benunit("benunit_reported_capital", period)) + complete_override = exact_amount(benunit("uc_reported_capital", period)) unreported_claimants = household("household_uc_unreported_claimants", period) # Each unit consumes its complete override, otherwise its financial # report, from the financial pool under the inherited convention. residual_financial_capital = max_( 0, household_financial_capital - - household("household_uc_reported_financial_capital", period) - - household("household_uc_pool_company_holdings", period), + - exact_amount(household("household_uc_reported_financial_capital", period)) + - exact_amount(household("household_uc_pool_company_holdings", period)), ) financial_proxy = where( unreported_claimants > 0, @@ -83,7 +106,7 @@ def formula(benunit, period, parameters): 0, ) own_holdings = benunit.sum( - benunit.members("uc_company_holding_disregard", period) + exact_amount(benunit.members("uc_company_holding_disregard", period)) * claimant_or_partner ) financial_capital = where( @@ -93,10 +116,17 @@ def formula(benunit, period, parameters): ) assessed_capital = where( complete_override >= 0, - max_(0, complete_override - own_holdings), - financial_capital + benunit("uc_property_capital", period), + max_( + 0, + complete_override + - round_to_penny(own_holdings * holding_value, p.round_down), + ), + financial_capital + exact_amount(benunit("uc_property_capital", period)), ) company_capital = benunit.sum( - benunit.members("uc_company_capital", period) * claimant_or_partner + exact_amount(benunit.members("uc_company_capital", period)) + * claimant_or_partner ) - return max_(0, assessed_capital) + company_capital + # Every amount is rounded to the nearest penny (reg. 6(1)), the + # capital tested against the limit included. + return round_to_penny(max_(0, assessed_capital) + company_capital, p.round_down) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_non_residential_property_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_non_residential_property_capital.py index d59947a042..4574f8ad39 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_non_residential_property_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_non_residential_property_capital.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import exact_amount, less_sale_expenses class uc_non_residential_property_capital(Variable): @@ -21,4 +22,8 @@ def formula(household, period, parameters): p = parameters(period).gov.dwp.universal_credit.means_test.capital market_value = max_(0, household("non_residential_property_value", period)) debt = max_(0, household("non_residential_property_secured_debt", period)) - return max_(0, market_value * (1 - p.property_sale_cost_rate) - debt) + return max_( + 0, + less_sale_expenses(market_value, p.sale_expenses.rate, p.round_down) + - exact_amount(debt), + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_other_residential_property_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_other_residential_property_capital.py index eaeebb1435..fec4d08457 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_other_residential_property_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_other_residential_property_capital.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import exact_amount, less_sale_expenses class uc_other_residential_property_capital(Variable): @@ -21,4 +22,8 @@ def formula(household, period, parameters): p = parameters(period).gov.dwp.universal_credit.means_test.capital market_value = max_(0, household("other_residential_property_value", period)) debt = max_(0, household("other_residential_property_secured_debt", period)) - return max_(0, market_value * (1 - p.property_sale_cost_rate) - debt) + return max_( + 0, + less_sale_expenses(market_value, p.sale_expenses.rate, p.round_down) + - exact_amount(debt), + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_property_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_property_capital.py index 7ae4bbb10e..2a673af312 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_property_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_property_capital.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import exact_amount, round_to_penny class uc_property_capital(Variable): @@ -28,11 +29,15 @@ class uc_property_capital(Variable): def formula(benunit, period, parameters): p = parameters(period).gov.dwp.universal_credit.means_test.capital household_property = sum( - benunit.household(source, period) for source in p.property_sources + exact_amount(benunit.household(source, period)) + for source in p.property_sources ) person = benunit.members household_claimants = benunit.max( person.household.sum(person.household.members("is_uc_claimant", period)) ) benunit_claimants = benunit.sum(person("is_uc_claimant", period)) - return household_property * benunit_claimants / max_(1, household_claimants) + return round_to_penny( + household_property * benunit_claimants / max_(1, household_claimants), + p.round_down, + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_assessable_capital.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_assessable_capital.py index 05115192ad..bc21a1e729 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_assessable_capital.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_assessable_capital.py @@ -7,10 +7,11 @@ # The debt input secured on each capital source. The schemes deduct "the # amount of any encumbrance secured on it" from that asset after the sale # expenses, so a source is never valued below nil and negative equity in one -# source does not reduce another. Land has no secured-debt input. +# source does not reduce another. SECURED_DEBT = { "other_residential_property_value": "other_residential_property_secured_debt", "non_residential_property_value": "non_residential_property_secured_debt", + "owned_land": "owned_land_secured_debt", } @@ -39,8 +40,8 @@ class council_tax_reduction_assessable_capital(Variable): "the applicant's: savings, land and property other than the home " "(gov.local_authorities.council_tax_reduction.capital.sources), with " "land and property valued at market value less 10% for the expenses " - "of sale, then less any debt secured on that property (which defaults " - "to nil because the survey does not record it). The schemes also " + "of sale, then less any debt secured on that land or property (which " + "defaults to nil because the survey does not record it). The schemes also " "count shares and other investments, which in the data are mixed " "with pension wealth the schemes disregard, so they are left out for " "now. The recalculation when capital rises above the limit during an " diff --git a/policyengine_uk/variables/input/non_residential_property_secured_debt.py b/policyengine_uk/variables/input/non_residential_property_secured_debt.py index ec04a1c86d..d64fe5a50b 100644 --- a/policyengine_uk/variables/input/non_residential_property_secured_debt.py +++ b/policyengine_uk/variables/input/non_residential_property_secured_debt.py @@ -7,10 +7,14 @@ class non_residential_property_secured_debt(Variable): label = "Debt secured on non-residential property" documentation = ( "Outstanding encumbrances secured specifically on the household's " - "non-residential property. Excludes main-home mortgage debt and unsecured " - "debt. Deducted after sale expenses in UC regulation 49 valuation. " - "The survey lacks reliable property-specific debt flags, so the " - "default zero is a modelling approximation unless supplied explicitly." + "non-residential property. Excludes main-home mortgage debt and unsecured debt. " + "Deducted after the 10% for sale expenses in the capital valuation of " + "UC regulation 49(1) and its legacy equivalents (HB, IS, JSA, ESA and " + "Pension Credit), flooring the asset class at nil. The value and the " + "debt are household totals across the class, so debt on one asset " + "offsets equity in another. The survey lacks reliable property-specific " + "debt flags, so the default zero is a modelling approximation unless " + "supplied explicitly." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/input/other_residential_property_secured_debt.py b/policyengine_uk/variables/input/other_residential_property_secured_debt.py index ff3488f386..adfa435fa8 100644 --- a/policyengine_uk/variables/input/other_residential_property_secured_debt.py +++ b/policyengine_uk/variables/input/other_residential_property_secured_debt.py @@ -7,10 +7,14 @@ class other_residential_property_secured_debt(Variable): label = "Debt secured on other residential property" documentation = ( "Outstanding encumbrances secured specifically on the household's " - "other residential property. Excludes main-home mortgage debt and unsecured " - "debt. Deducted after sale expenses in UC regulation 49 valuation. " - "The survey lacks reliable property-specific debt flags, so the " - "default zero is a modelling approximation unless supplied explicitly." + "other residential property. Excludes main-home mortgage debt and unsecured debt. " + "Deducted after the 10% for sale expenses in the capital valuation of " + "UC regulation 49(1) and its legacy equivalents (HB, IS, JSA, ESA and " + "Pension Credit), flooring the asset class at nil. The value and the " + "debt are household totals across the class, so debt on one asset " + "offsets equity in another. The survey lacks reliable property-specific " + "debt flags, so the default zero is a modelling approximation unless " + "supplied explicitly." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/input/owned_land_secured_debt.py b/policyengine_uk/variables/input/owned_land_secured_debt.py new file mode 100644 index 0000000000..18d93d7c0b --- /dev/null +++ b/policyengine_uk/variables/input/owned_land_secured_debt.py @@ -0,0 +1,25 @@ +from policyengine_uk.model_api import * + + +class owned_land_secured_debt(Variable): + value_type = float + entity = Household + label = "Debt secured on owned land" + documentation = ( + "Outstanding encumbrances secured specifically on the household's " + "land-only plots. Excludes main-home mortgage debt and unsecured debt. " + "Deducted after the 10% for sale expenses in the capital valuation of " + "the legacy means tests (HB, IS, JSA, ESA and Pension Credit), " + "flooring the asset class at nil. UC counts no land, so this input " + "leaves UC capital unchanged. The value and the debt are household " + "totals across the class, so debt on one asset offsets equity in " + "another. The survey lacks reliable property-specific " + "debt flags, so the default zero is a modelling approximation unless " + "supplied explicitly." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + default_value = 0 + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" + reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/49/1"