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..0a6ffc7c9a --- /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. 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. A Lifetime ISA's surrender value is worked out from its balance and rounded under the same rule. Explicit Lifetime ISA capital inputs and reformed valuation formulas are preserved. diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index ee591c95de..ee3fb72ff3 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -122,13 +122,14 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - miscellaneous_income - mortgage_capital_repayment - net_financial_wealth -- non_residential_property_value - non_residential_property_secured_debt +- non_residential_property_value - other_investment_income - other_residential_property_secured_debt # 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/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..503df382a8 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml @@ -0,0 +1,608 @@ +# 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 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 d98e564feb..b0c74c42cc 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 @@ -80,10 +80,16 @@ other_residential_property_value: 55_370 savings: 455 output: - pension_credit_assessable_capital: 55_825 - pension_credit_deemed_income: 4_784 - pension_credit_income: 15_260 - pension_credit: 0 + # 455 + 90% of 55,370 (SPC Regs 2002 reg. 19(a)). Deemed income is + # 81 steps of 500 above 10,000 at 1 a week: 81 * 52 = 4,212. + pension_credit_assessable_capital: 50_288 + pension_credit_deemed_income: 4_212 + pension_credit_income: 14_688 + # Income exceeds the 12,376 guarantee, so no guarantee credit. Savings + # credit: 60% of (12,376 - 208.07 * 52) = 933.82, less 40% of + # (14,688 - 12,376) = 924.80, leaves 9.02. + guarantee_credit: 0 + pension_credit: 9.016 - name: Case 5, 2026 Pension Credit uses tariff income with the published guarantee rate. period: 2026 @@ -103,12 +109,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/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_pension_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml index 88c83a064e..93b3c63aff 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 household savings only @@ -663,20 +667,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_savings_credit_only_receipt_properties.py b/policyengine_uk/tests/test_savings_credit_only_receipt_properties.py index 98d8f646c7..e9f987a25f 100644 --- a/policyengine_uk/tests/test_savings_credit_only_receipt_properties.py +++ b/policyengine_uk/tests/test_savings_credit_only_receipt_properties.py @@ -394,7 +394,8 @@ 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 17,000 against the 16,000 limit and gets no council tax + # tested on 16,500 (12,000 of savings and 90% of the 5,000 property, + # SPC Regs 2002 reg. 19(a)) against the 16,000 limit and gets no council tax # reduction, where the scheme's own rules give 2,000 on savings of # 12,000. The tenant has no Housing Benefit tariff income, where # Housing Benefit's own rules count 4 a week on savings of 12,000 @@ -497,16 +498,18 @@ def read(simulation, variable): def test_stopping_pension_credit_payments_ends_the_pension_credit_capital_test(): # Savings of 12,000 and other property worth 5,000. The Pension Credit - # assessment of capital counts both (17,000), 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 over 10,000: 14 a week, so the savings - # credit is 0.6 x 29.93 - 0.4 x (254.00 - 238.00) = 11.558 a week (601.02). + # assessment of capital counts both, the property less 10% for the + # expenses of sale (SPC Regs 2002 reg. 19(a)): 12,000 + 4,500 = 16,500, + # 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 + # over 10,000: 13 a week, so the savings credit is 0.6 x 29.93 - 0.4 x + # (253.00 - 238.00) = 11.958 a week (621.82). data = single_pensioner(savings=12_000.0, other_property=5_000.0) unreformed = Simulation(situation=data) - assert abs(unreformed.calculate("savings_credit", YEAR)[0] - 601.02) < 0.01 + 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] == 17_000 + assert unreformed.calculate(capital, YEAR)[0] == 16_500 assert unreformed.calculate("council_tax_reduction", YEAR)[0] == 0 # With Pension Credit not paid the scheme's own capital rule applies, for 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_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..23c3d326c7 --- /dev/null +++ b/policyengine_uk/utils/capital_valuation.py @@ -0,0 +1,197 @@ +"""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 + +# 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.""" + person = benunit.members + total = 0 + for source in capital.person_sources: + stored = person(source, period) + value = exact_amount(stored) + 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 70e7b0edf3..19e9a3a36b 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -64,6 +64,7 @@ "pip_m_points", "local_bus_trips", "local_bus_uncapped_single_fare", + "lifetime_isa_countable_capital", "monthly_domestic_energy_consumption", "ni_class_4_losses_brought_forward", "ni_class_4_trading_loss", diff --git a/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py b/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py index 5487fc42a9..77624ac1fb 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py @@ -1,4 +1,9 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import ( + round_to_penny, + valued_capital, + valued_person_capital, +) class esa_income_assessable_capital(Variable): @@ -7,7 +12,8 @@ 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. Because the dataset only stores these stocks at " + "ESA capital test, valued at market value less 10% where a sale would " + "incur expenses and less any debt secured on it (reg. 113). Because the dataset only stores these stocks at " "household level, the model allocates full household capital to any " "benunit with a reported income-related ESA award and only falls back to " "a claimant-and-partner share when nobody in the household is on that " @@ -25,12 +31,12 @@ class esa_income_assessable_capital(Variable): def formula(benunit, period, parameters): ESA = parameters(period).gov.dwp.ESA.income - sources = ESA.capital.sources person = benunit.members claiming_esa_income = add(benunit, period, ["esa_income_reported"]) > 0 - household_capital = sum( - benunit.max(person.household(source, period)) for source in sources + household_capital = valued_capital( + lambda variable: benunit.max(person.household(variable, period)), + ESA.capital, ) # Regulation 83(2) excludes children's and young persons' capital. # The claimant/partner weights approximate otherwise unobserved ownership. @@ -49,11 +55,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 ESA.capital.person_sources + 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 2af26611c4..3dc5d6b60d 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py @@ -1,4 +1,10 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import ( + exact_amount, + round_to_penny, + valued_capital, + valued_person_capital, +) class housing_benefit_assessable_capital(Variable): @@ -6,7 +12,10 @@ class housing_benefit_assessable_capital(Variable): entity = BenUnit label = "Housing Benefit assessable capital" documentation = ( - "Housing Benefit capital counted from the configured capital sources. " + "Housing Benefit capital counted from the configured capital sources, " + "each valued 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). " "Household sources are allocated across benunits in proportion to their " "claimants and partners, a PolicyEngine convention because household " "capital data cannot identify ownership; children and young persons add " @@ -42,11 +51,12 @@ def formula(benunit, period, parameters): "housing_benefit_pension_age_regulations_apply", period ) p = parameters(period).gov.dwp.housing_benefit.means_test.capital - household_capital = sum(household(source, period) for source in p.sources) + household_capital = valued_capital( + lambda variable: household(variable, period), p + ) claimant_or_partner = person("is_claimant_or_partner", period) - person_capital = sum( - benunit.sum(person(source, period) * claimant_or_partner) - for source in p.person_sources + person_capital = valued_person_capital( + benunit, period, parameters, p, claimant_or_partner ) benunit_claimants_and_partners = add( benunit, period, ["is_claimant_or_partner"] @@ -74,7 +84,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 @@ -96,8 +106,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 55c6f2aad1..aa38b2279b 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py @@ -1,4 +1,9 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import ( + round_to_penny, + valued_capital, + valued_person_capital, +) class income_support_assessable_capital(Variable): @@ -7,7 +12,8 @@ class income_support_assessable_capital(Variable): label = "Assessable capital for Income Support" documentation = ( "Household capital apportioned to the benefit unit for the Income Support " - "capital test. Because the dataset only stores these stocks at household " + "capital test, valued at market value less 10% where a sale would incur " + "expenses and less any debt secured on it (reg. 49). Because the dataset only stores these stocks at household " "level, the model allocates full household capital to any benunit on the " "IS claim path and only falls back to a claimant-and-partner share when " "nobody in the household is on that path. This allocation is a " @@ -24,7 +30,6 @@ class income_support_assessable_capital(Variable): def formula(benunit, period, parameters): IS = parameters(period).gov.dwp.income_support - sources = IS.means_test.capital.sources person = benunit.members would_claim_is = benunit("would_claim_IS", period) @@ -34,8 +39,9 @@ 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)) for source in sources + household_capital = valued_capital( + lambda variable: benunit.max(person.household(variable, period)), + IS.means_test.capital, ) benunit_claimants_and_partners = add( benunit, period, ["is_claimant_or_partner"] @@ -55,11 +61,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 IS.means_test.capital.person_sources + 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 ec3a6631ee..3ca5804c58 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py @@ -1,4 +1,9 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import ( + round_to_penny, + valued_capital, + valued_person_capital, +) class jsa_income_assessable_capital(Variable): @@ -7,7 +12,8 @@ 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. Because the dataset only stores these stocks at " + "JSA capital test, valued at market value less 10% where a sale would " + "incur expenses and less any debt secured on it (reg. 111). Because the dataset only stores these stocks at " "household level, the model allocates full household capital to any " "benunit with a reported income-based JSA award and only falls back to " "a claimant-and-partner share when nobody in the household is on that " @@ -25,12 +31,12 @@ class jsa_income_assessable_capital(Variable): def formula(benunit, period, parameters): JSA = parameters(period).gov.dwp.JSA.income - sources = JSA.capital.sources person = benunit.members claiming_jsa_income = add(benunit, period, ["jsa_income_reported"]) > 0 - household_capital = sum( - benunit.max(person.household(source, period)) for source in sources + household_capital = valued_capital( + lambda variable: benunit.max(person.household(variable, period)), + JSA.capital, ) # Regulation 88(2) excludes children's and young persons' capital. # The claimant/partner weights approximate otherwise unobserved ownership. @@ -49,11 +55,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 JSA.capital.person_sources + 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 5803c56af2..adf8ac35de 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py @@ -1,4 +1,10 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import ( + exact_amount, + round_to_penny, + valued_capital, + valued_person_capital, +) class pension_credit_assessable_capital(Variable): @@ -7,7 +13,8 @@ class pension_credit_assessable_capital(Variable): label = "Pension Credit assessable capital" documentation = ( "Pension Credit capital counted from the configured capital sources, " - "split only across pension-age adults in the household so pensioner " + "each valued at market value less 10% where a sale would incur " + "expenses and less any debt secured on it (reg. 19), split only across pension-age adults in the household so pensioner " "couples pool capital together without dilution by unrelated working-" "age adults. Person-level sources, such as a Lifetime ISA, count " "only for the holder's own benunit, and only when the holder is its " @@ -28,11 +35,12 @@ def formula(benunit, period, parameters): household = benunit.household person = benunit.members p = parameters(period).gov.dwp.pension_credit.income.capital - household_capital = sum(household(source, period) for source in p.sources) + household_capital = valued_capital( + lambda variable: household(variable, period), p + ) claimant_or_partner = person("is_claimant_or_partner", period) - person_capital = sum( - benunit.sum(person(source, period) * claimant_or_partner) - for source in p.person_sources + 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) @@ -51,10 +59,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 564f64588d..e7ece46cdd 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py @@ -1,4 +1,14 @@ 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, +) class uc_assessable_capital(Variable): @@ -26,7 +36,15 @@ class uc_assessable_capital(Variable): "allocations are assumptions about unobserved ownership, not law. " "Person-level proxies count only claimants/partners, not dependants. " "Under regulation 77, disregarded company holdings are replaced by " - "the claimant/partner's share of company capital." + "the claimant/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 a " + "disregarded holding comes off at the value at which it was counted: " + "90% in the pool and in a complete override, which is a countable " + "figure, and in full from a recorded financial total, which is used " + "without valuation. Every amount calculated is rounded to the nearest " + "penny (regulation 6(1)), including this capital, which is tested " + "against the limit." ) definition_period = YEAR unit = GBP @@ -41,25 +59,27 @@ class uc_assessable_capital(Variable): def formula(benunit, period, parameters): household = benunit.household p = parameters(period).gov.dwp.universal_credit.means_test.capital - household_financial_capital = sum( - household(source, period) for source in p.sources + household_financial_capital = valued_capital( + lambda variable: household(variable, period), 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) claimant_or_partner = benunit.members("is_uc_claimant", period) benunit_claimants = benunit.sum(claimant_or_partner) - person_capital = sum( - benunit.sum(benunit.members(source, period) * claimant_or_partner) - for source in p.person_sources + 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, @@ -69,7 +89,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( @@ -79,10 +99,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/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..e3a890ef82 --- /dev/null +++ b/policyengine_uk/variables/input/owned_land_secured_debt.py @@ -0,0 +1,24 @@ +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 " + "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 + 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"