diff --git a/changelog.d/2577.fixed.md b/changelog.d/2577.fixed.md new file mode 100644 index 0000000000..728a3642b5 --- /dev/null +++ b/changelog.d/2577.fixed.md @@ -0,0 +1 @@ +The model now counts overseas and other real estate in property wealth and in every means-tested capital assessment that includes household property. The Pension Credit formula now adds owned land, non-residential property and residential property other than the main residence to a financial-only capital report instead of letting the report replace them. The household land formula now applies regional land intensity to residential, non-residential and other real estate instead of property_wealth, and adds direct UK land once at full value. On the certified file, whose producer imputed the saved property_wealth total and the components separately, household land at data-year values rises from about £5,113bn to £5,521bn ([measurement](https://github.com/PolicyEngine/policyengine-uk/pull/2688#issuecomment-6449462413)). 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/docs/engineering/property-wealth-components.md b/docs/engineering/property-wealth-components.md new file mode 100644 index 0000000000..d0ed2cdfae --- /dev/null +++ b/docs/engineering/property-wealth-components.md @@ -0,0 +1,242 @@ +# Property wealth components and UK land + +The model derives worldwide gross property wealth from six household stock +inputs. The companion [policyengine-uk-data change #543](https://github.com/PolicyEngine/policyengine-uk-data/pull/543) +preserves the seven Wealth and Assets Survey (WAS) round-8 gross-property +terms in those six inputs. The earlier [data change #501](https://github.com/PolicyEngine/policyengine-uk-data/pull/501) +combines second homes and buy-to-let into one other-residential input and +separates property wealth from financial and private-pension wealth. + +## Source and input contract + +| WAS round-8 source | Saved household input | Coverage | +| --- | --- | --- | +| `DVhValueR8` | `main_residence_value` | Main residence | +| `DVHseValR8_sum` | `other_residential_property_value` | Second UK homes, including holiday homes and time-shares | +| `DVBltValR8_sum` | `other_residential_property_value` | UK residential property let for profit, added to second homes | +| `DVBlDValR8_sum` | `non_residential_property_value` | Other UK buildings | +| `DVLUKValR8_sum` | `owned_land` | Direct UK land-only holdings | +| `DVLOSValR8_sum` | `overseas_property_value` | Overseas land and property, including time-shares | +| `DVOprValR8_sum` | `other_property_value` | Other real estate with unspecified geography and property type | + +The data producer combines second homes and buy-to-let before exporting +`other_residential_property_value`. There is no additional buy-to-let model +input to add to an aggregate, benefit-capital list or purchase calculation. +The associated `other_residential_property_secured_debt` likewise combines +their identified secured debt. + +The [WAS round-8 derived-variable specification](https://doc.ukdataservice.ac.uk/doc/7215/mrdoc/excel/7215_property_dv_spec_r8.xlsx), +worksheet `DV_Spec_Property_R8`, defines `DVPropertyR8` as the sum of all +seven source terms. This is gross property value. The separate `HpropWR8` +measure deducts mortgage liabilities. The model's gross wealth aggregates +retain this distinction from net benefit-capital valuations. + +The data producer must reconcile the seven source terms before imputation, +with documented missing, not-applicable and refusal values handled +consistently and reported separately for complete-case and operational +zero-filled reconciliation. It must also remove a saved `property_wealth` +column, including one inherited from an earlier dataset. A conditioning-total +draw and independently imputed component draws do not have an accounting +identity: the model derives the released total from the saved components. + +## Aggregates and land + +The formulas under `policyengine_uk/variables/household/wealth/` implement: + +```text +residential_property_value = main_residence_value + + other_residential_property_value + +property_wealth = residential_property_value + + non_residential_property_value + + owned_land + + overseas_property_value + + other_property_value + +total_wealth = property_wealth + corporate_wealth + + private_pension_wealth + savings + +household_land_value = (residential_property_value + + non_residential_property_value + + other_property_value) * regional_intensity + + owned_land +``` + +Residential property covers the identified domestic residential categories. +Overseas and other-real-estate aggregates do not identify residential +subcomponents. Direct UK land enters worldwide property wealth once and UK +household land once at its full value. Identified overseas land and buildings +contribute to worldwide wealth and contribute zero to UK household land. +`total_wealth` receives all property categories through `property_wealth`; +adding land, overseas or other property separately would duplicate them. + +`household_land_value.py` retains the existing regional intensities and the +dwelling-weighted fallback for an unknown region. Summing the domestic base +directly avoids subtracting components from an already rounded aggregate, +so float32 cancellation cannot reduce land below directly owned UK land. +Unclassified other real +estate uses that regional approximation. Its unknown geography means this +can attribute overseas holdings to UK land. Improved source classification, +or excluding unclassified holdings from the domestic land base, requires a +separate modelling decision. + +## Uprating and periods + +`overseas_property_value` and `other_property_value` are annual household +stocks, defaulting to zero, with GDP-per-capita uprating. Their variable +definitions use `gov.economic_assumptions.indices.obr.per_capita.gdp` and +their dataset registrations use +`gov.economic_assumptions.yoy_growth.obr.per_capita.gdp` in +`policyengine_uk/data/uprating_indices.yaml`. Both registrations are required: +Core carries situation inputs forward using the variable attribute, while +`policyengine_uk/data/economic_assumptions.py` projects stored dataset inputs +using the YAML map. Stored annual inputs take precedence over formula and +fallback calculations. + +Current `main_residence_value` and `other_residential_property_value` use the +model's annual OBR house-price index in both routes. Non-residential property +and direct UK land retain GDP-per-capita uprating. The property-component +change preserves these existing choices; the historical specification's +blanket GDP treatment for residential inputs is obsolete. Each projected +year must retain the aggregate identity using the individually uprated +components, rather than grow a saved property total with a single index. +Monthly requests for these stocks retain the full stock amount rather than +one twelfth of the annual amount. + +The upstream regional house-price adjustment to main-residence imputation is +separate from the model's projection indexes. Source reconciliation must +precede that adjustment; the other mapped source categories do not acquire +the adjustment through the aggregate. + +## Benefit-capital routing + +Countable capital reads configured sources, rather than `property_wealth`. +The new overseas and other-property inputs enter each affected programme's +capital calculation once and receive the existing real-property +sale-expense treatment. The shared `policyengine_uk/utils/capital_valuation.py` helper +deducts source-specific sale expenses before mapped secured debt, with the +existing zero floor for secured-property categories. A source appearing only +in a sale-expense list does not become countable capital. + +Capital parameters are under these paths relative to +`policyengine_uk/parameters/`: + +| Programme | Capital directory | +| --- | --- | +| Universal Credit | `gov/dwp/universal_credit/means_test/capital/` | +| Housing Benefit | `gov/dwp/housing_benefit/means_test/capital/` | +| Income Support | `gov/dwp/income_support/means_test/capital/` | +| Income-based JSA | `gov/dwp/JSA/income/capital/` | +| Income-related ESA | `gov/dwp/ESA/income/capital/` | +| Pension Credit | `gov/dwp/pension_credit/income/capital/` | + +Universal Credit keeps financial `sources.yaml` separate from +`property_sources.yaml` and `uc_property_capital`. Its existing domestic +property valuation variables remain in that property pool, independent of +financial `benunit_reported_capital` observations. The new overseas and +unclassified other-property categories follow the same route through +`uc_overseas_property_capital` and `uc_other_property_capital`, which deduct +sale expenses once before `uc_property_capital` allocates their net values. +They are absent from the financial and financial sale-expense source lists. +A financial-only report leaves all separately valued property counted. +A complete `uc_reported_capital` override replaces the ordinary assessment +before the existing company-capital substitution. All countable property +shares remain allocated across all claimant/partner adults, with +complete-override units consuming their shares rather than redistributing +them. Direct `owned_land` remains outside UC countable sources; adding it +requires a separate model decision. + +Pension Credit retains its source lists and pension-age-adult allocation. +Its assessment combines a financial-only benefit-unit report with separately +valued land and property before final rounding. Its independent complete +override, `pension_credit_reported_capital`, defaults to -1; an explicitly +supplied nonnegative override still replaces the whole total. Combining the +financial report and property directly in the assessment avoids an intermediate +rounded aggregate that could reduce counted property for large values. +Housing Benefit's savings-credit-only route inherits that corrected total. +If a dataset stores a financial-only survey total in +`pension_credit_reported_capital` rather than in `benunit_reported_capital`, +the formula treats that total as a complete override and counts none of the +household's land or property for that unit. The data producer must move such a +total to `benunit_reported_capital` before the formula can count the property. +Ordinary Housing Benefit, Income Support, income-based JSA and income-related +ESA do not replace their source pools with a financial-only report, so their +existing routing is retained. `share_like_capital` retains the component-aware financial fallback, +so the new property sources do not restore `corporate_wealth` lists or add +the underlying share components twice. Main residence and the separately +identified private-pension input remain excluded from countable sources. + +These broad asset categories do not identify every statutory disregard or +individual's beneficial ownership. Their allocation is a model assumption, +not an observation of legal ownership or a determination that every holding +is countable. No new secured-debt inputs are exported for overseas or other +real estate, so the mapping cannot identify or deduct debt on those holdings. +Legacy financial aggregates may also contain pension wealth that cannot be +separately identified. Gross worldwide wealth remains distinct from these +limited net capital proxies. + +UC unearned income continues to use its claimant unearned-income source +list. Actual interest, dividends and rent are excluded at every capital +level; countable capital affects tariff income. There is no historical +property-income gate to extend. + +## Purchases and downstream consumers + +Current main uses explicit transaction inputs for +`additional_residential_property_purchased` and +`non_residential_property_purchased`. Holding other-residential or +non-residential stock does not automatically create a purchase when +`property_purchased` is true. This supersedes the historical stock-based +purchase proxy described in the original companion specification. A domestic +additional-dwelling transaction is supplied once, including a buy-to-let +purchase; overseas and unclassified other-property stocks create no UK SDLT +transaction. `main_residential_property_purchased` retains its existing +main-residence-value calculation. + +The current consumer paths below replace historical wheel file-and-line +citations. Paths are relative to `policyengine_uk/variables/`. + +| Consumer | Current path and dependency | +| --- | --- | +| Residential and worldwide property | `household/wealth/residential_property_value.py` and `property_wealth.py` use the component identities above | +| Total wealth and wealth deciles | `household/wealth/total_wealth.py` includes property once; `household_wealth_decile.py` reads total wealth with person-weighted household weights | +| Wealth taxes | `contrib/ubi_center/wealth_tax.py` reads `net_wealth`, which deducts mortgage, consumer and student-loan debt from total wealth; `contrib/cec/non_primary_residence_wealth_tax.py` subtracts main residence from gross total wealth | +| UK land and LVT | `household/wealth/household_land_value.py` estimates domestic household land; `land_value.py` adds corporate land; `contrib/ubi_center/land_value_tax.py` applies the configured combined, household and corporate rates | +| Domestic property purchases | `household/consumption/main_residential_property_purchased.py`, `additional_residential_property_purchased.py` and `non_residential_property_purchased.py` retain their separate transaction routing | +| SDLT | `gov/hmrc/sdlt_on_residential_property_transactions.py` and `sdlt_on_non_residential_property_transactions.py` read transactions; `stamp_duty_land_tax.py` aggregates liabilities and `expected_sdlt.py` carries the household liability | +| High Value Council Tax Surcharge | `gov/hmrc/high_value_council_tax_surcharge.py` reads main residence and ownership tenure directly | +| Benefit capital | `gov/dwp/universal_credit/uc_assessable_capital.py` and `uc_property_capital.py`; the assessable-capital variables for Housing Benefit, Pension Credit, Income Support, JSA and ESA use the configured programme sources | +| UC unearned income | `gov/dwp/universal_credit/income/uc_unearned_income.py` reads configured claimant income sources, without a property-income gate | + +Changing the aggregate alone does not directly change benefit capital, +purchase transactions or the high-value surcharge at fixed inputs and +weights. Completing the missing components and their routing can change +capital; subsequent recalibration can also change weighted outputs. + +## Release boundary and validation + +The model and the data release carrying the component contract must land in +lockstep, after the data #501 prerequisite. That data release must depend on +a model release with the new inputs and capital-valuation routing. Its +compatibility guard must reject a model unable to carry the new components. + +Existing datasets are not migrated. A stored `property_wealth` input remains +authoritative and overrides the formula, even after the model changes. The +new release must stop storing it and remove inherited copies; updating only +the installed model cannot repair old saved totals. Household land reads the +domestic component inputs directly, including when a saved worldwide total +is present, so that total does not determine the domestic land base. + +Synthetic tests cover isolated and mixed components, aggregate identities, +land accounting, household LVT, monthly stocks, situation and dataset +uprating, benefit valuation and transaction routing without licensed data. +They do not establish reconciliation of the licensed WAS household source. +The upstream seven-term reconciliation must run on that licensed source +before release, with its missing-value report and the second-home/buy-to-let +combination checked before imputation. + +Population and LVT effects with the compatible data release stay unmeasured +until that release exists and its rebuild and recalibration finish. Existing +land target definitions and target weights stay the same, but calculated land, +losses and fitted geographic weights can change. Production rebuilding, recalibration +and impact measurement are separate release work. diff --git a/policyengine_uk/data/economic_assumptions.py b/policyengine_uk/data/economic_assumptions.py index fd1f7c16fa..40fc2b7750 100644 --- a/policyengine_uk/data/economic_assumptions.py +++ b/policyengine_uk/data/economic_assumptions.py @@ -162,8 +162,8 @@ def growth_rate( # # Past this year policyengine-core serves each stored input from the last # projected year. An input whose variable has a formula is calculated by it -# instead (pension_credit_reported_capital reads benunit_reported_capital, -# which the Enhanced FRS does not store, so it falls to -1); one whose +# instead (the complete pension_credit_reported_capital override defaults +# to -1, while financial observations are assessed separately); one whose # variable has an `uprating` attribute is scaled by that index; any other, # rent and council tax among them, is held at its last projected value. # Simulation grows the household totals in STORED_HOUSEHOLD_TOTALS itself. diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index c8d64451d8..e3159c6890 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -133,12 +133,17 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - net_financial_wealth - non_residential_property_value - non_residential_property_secured_debt +- other_property_value - other_residential_property_secured_debt +- overseas_property_value # Non-residential purchase prices and land retain GDP-per-capita growth. - non_residential_property_purchased - owned_land -# A formula of benunit_reported_capital; listed so a dataset that stores it -# directly is uprated like the input. +- owned_land_secured_debt +# The formula returns -1, meaning no complete override; +# pension_credit_assessable_capital assesses a financial-only report through +# benunit_reported_capital instead. This index uprates programme observations +# that a dataset stores directly in the column. - pension_credit_reported_capital - pension_income - private_pension_wealth 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..f0ec363c41 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml @@ -0,0 +1,50 @@ +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. + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. Both use the real-property sale-expense treatment + when counted. These broad household sources cannot identify individual + beneficial ownership or every statutory disregard; no separate secured + debt is available for either category. +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 2008 reg. 114 + href: https://www.legislation.gov.uk/uksi/2008/794/regulation/114 + - 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 + - overseas_property_value + - other_property_value diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml index 5892c52c74..c66a7a43e4 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml @@ -25,6 +25,11 @@ description: > only falls back to an adult-share proxy when nobody in the household is on that reported claim path. That allocation is a data approximation, not a legislated ownership rule. + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. These broad household categories cannot identify + individual beneficial ownership or additional statutory disregards. + No separate secured-debt input is available for either category. metadata: label: Income-related ESA countable capital sources period: year @@ -44,3 +49,5 @@ values: - share_like_capital - other_residential_property_value - non_residential_property_value + - overseas_property_value + - other_property_value 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..3a6b46c6ab --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml @@ -0,0 +1,48 @@ +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. + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. Both use the real-property sale-expense treatment + when counted. These broad household sources cannot identify individual + beneficial ownership or every statutory disregard; no separate secured + debt is available for either category. +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 1996 reg. 112 + href: https://www.legislation.gov.uk/uksi/1996/207/regulation/112 + - 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 + - overseas_property_value + - other_property_value diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml index 163e3aa1a9..14007f775e 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml @@ -25,6 +25,11 @@ description: > with a reported income-based JSA award and only falls back to an adult-share proxy when nobody in the household is on that reported claim path. That allocation is a data approximation, not a legislated ownership rule. + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. These broad household categories cannot identify + individual beneficial ownership or additional statutory disregards. + No separate secured-debt input is available for either category. metadata: label: Income-based JSA countable capital sources period: year @@ -44,3 +49,5 @@ values: - share_like_capital - other_residential_property_value - non_residential_property_value + - overseas_property_value + - other_property_value 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..4dae861f35 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml @@ -0,0 +1,65 @@ +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. + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. Both use the real-property sale-expense treatment + when counted. These broad household sources cannot identify individual + beneficial ownership or every statutory disregard; no separate secured + debt is available for either category. +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 Regulations 2006 reg. 48 + href: https://www.legislation.gov.uk/uksi/2006/213/regulation/48 + - 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 (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg. 46 + href: https://www.legislation.gov.uk/uksi/2006/214/regulation/46 + - 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 + - overseas_property_value + - other_property_value diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml index eddb604c4b..45520f4310 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml @@ -25,6 +25,11 @@ description: > classes are not yet split out separately. Because the dataset only provides household-level capital stocks, the model allocates them across benunits using an adult-share proxy. + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. These broad household categories cannot identify + individual beneficial ownership or additional statutory disregards. + No separate secured-debt input is available for either category. metadata: label: Housing Benefit countable capital sources period: year @@ -52,3 +57,5 @@ values: - share_like_capital - other_residential_property_value - non_residential_property_value + - overseas_property_value + - other_property_value 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..1e5b7bfecc --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml @@ -0,0 +1,48 @@ +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. + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. Both use the real-property sale-expense treatment + when counted. These broad household sources cannot identify individual + beneficial ownership or every statutory disregard; no separate secured + debt is available for either category. +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 1987 reg. 50 + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/50 + - 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 + - overseas_property_value + - other_property_value diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml index 4db2982412..1a47b7ef36 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml @@ -10,6 +10,11 @@ description: > `stocks_and_shares_isa`) where they exceed it beyond float32 rounding (see `share_like_capital`), so a household given only the components has them counted and no holding counts twice. + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. These broad household categories cannot identify + individual beneficial ownership or additional statutory disregards. + No separate secured-debt input is available for either category. metadata: label: Income Support capital sources period: year @@ -33,3 +38,5 @@ values: - share_like_capital - other_residential_property_value - non_residential_property_value + - overseas_property_value + - other_property_value diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/property_sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/property_sources.yaml new file mode 100644 index 0000000000..9baa6e68fb --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/property_sources.yaml @@ -0,0 +1,19 @@ +description: > + Household real-property capital retained alongside the benefit unit's + reported financial capital. The financial-only observation excludes land + and property, so it replaces savings and investments while these sources + retain their regulation 19 valuation and pension-age-adult allocation. + Only sources also present in capital.sources are counted. +metadata: + label: Pension Credit property capital sources + unit: list + reference: + - title: State Pension Credit Regulations 2002 reg. 19 + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/19 +values: + 2003-10-06: + - owned_land + - other_residential_property_value + - non_residential_property_value + - overseas_property_value + - other_property_value 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..e963fbee05 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml @@ -0,0 +1,50 @@ +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. + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. Both use the real-property sale-expense treatment + when counted. These broad household sources cannot identify individual + beneficial ownership or every statutory disregard; no separate secured + debt is available for either category. +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 2002 reg. 20 + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/20 + - 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 + - overseas_property_value + - other_property_value diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml index 60c43a3138..5f1f9bca75 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml @@ -12,6 +12,11 @@ description: > `unit_and_investment_trusts`, `stocks_and_shares_isa`) where they exceed it beyond float32 rounding (see `share_like_capital`), so a household given only the components has them counted and no holding counts twice. + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. These broad household categories cannot identify + individual beneficial ownership or additional statutory disregards. + No separate secured-debt input is available for either category. metadata: label: Pension Credit capital sources unit: list @@ -33,3 +38,5 @@ values: - other_residential_property_value - non_residential_property_value - share_like_capital + - overseas_property_value + - other_property_value 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/property_sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/property_sources.yaml index 9e6786ee38..97e1b4fd45 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/property_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/property_sources.yaml @@ -10,6 +10,9 @@ description: > to their number in each benefit unit, including units with financial reports, complete overrides and no UC receipt. A complete override consumes its share. This is an assumption about unobserved ownership, not a statutory rule. + Overseas land and property and unclassified other real estate follow this + same route, so a financial-only report cannot replace their counted values. + Neither category has an identified secured-debt input. metadata: label: Universal Credit countable property sources period: year @@ -23,3 +26,5 @@ values: 2013-04-29: - uc_other_residential_property_capital - uc_non_residential_property_capital + - uc_overseas_property_capital + - uc_other_property_capital 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..8ea5180e07 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml @@ -0,0 +1,22 @@ +description: > + Share of the current market or surrender value that the Universal Credit + formulas deduct from capital where there would be expenses attributable to + sale. The formulas deduct a flat 10% whatever the actual costs, from the + gross value and before any encumbrance secured on the asset. They apply it + to the countable property in property_sources (other residential, + non-residential, overseas and unclassified other property, which they treat + as having costs of sale) and to the financial sources 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..b096c77937 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml @@ -0,0 +1,42 @@ +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). + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. Both use the real-property sale-expense treatment + through their separate property valuations. These broad household sources + cannot identify individual beneficial ownership or every statutory + disregard; no separate secured debt is available for either category. +metadata: + label: Universal Credit capital sources with sale expenses + period: year + unit: list + reference: + - title: The Universal Credit Regulations 2013 reg. 49(1)(a) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/49 + - title: The Universal Credit Regulations (Northern Ireland) 2016 reg. 49(1)(a) + href: https://www.legislation.gov.uk/nisr/2016/216/regulation/49 + - title: Advice for Decision Making chapter H1, paras. H1604-H1607 + href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=44 + - title: Advice for Decision Making chapter H1, para. H1656 + href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=55 + - title: Advice for Decision Making chapter H1, para. H1665 + href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=57 + - title: Advice for Decision Making chapter H1, paras. H1673-H1675 + href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=59 +values: + 2013-04-29: + - share_like_capital diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml index 9e3e5dbcbc..921a3d9ef4 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml @@ -33,6 +33,12 @@ description: > pension wealth that is not separately identified and removed. Asset-specific Schedule 10 disregards within these broad financial classes are not separately observed. + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. These broad household categories cannot identify + individual beneficial ownership or additional statutory disregards. + No separate secured-debt input is available for either category. They use + the separate property_sources route, independently of financial reports. metadata: label: Universal Credit financial capital proxy sources period: year 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..0ebaf07026 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,144 @@ 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 + +- name: Income-related ESA values overseas land and property less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + overseas_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + esa_income_assessable_capital: 9_000 + +- name: Income-related ESA preserves household allocation for overseas land and property + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + single: + members: first + couple: + members: [second, partner] + households: + household: + members: [first, second, partner] + overseas_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + esa_income_assessable_capital: [5_400, 10_800] + +- name: Income-related ESA reported claim path receives full overseas land and property capital + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + esa_income_reported: 1_000 + unrelated: + age: 40 + benunits: + claiming: + members: claimant + other: + members: unrelated + households: + household: + members: [claimant, unrelated] + overseas_property_value: 18_000 + output: + # Existing claim-path allocation gives the claimant all net household capital. + esa_income_assessable_capital: [16_200, 0] + +- name: Income-related ESA values unclassified other real estate less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + other_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + esa_income_assessable_capital: 9_000 + +- name: Income-related ESA preserves household allocation for unclassified other real estate + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + single: + members: first + couple: + members: [second, partner] + households: + household: + members: [first, second, partner] + other_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + esa_income_assessable_capital: [5_400, 10_800] + +- name: Income-related ESA reported claim path receives full unclassified other real estate capital + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + esa_income_reported: 1_000 + unrelated: + age: 40 + benunits: + claiming: + members: claimant + other: + members: unrelated + households: + household: + members: [claimant, unrelated] + other_property_value: 18_000 + output: + # Existing claim-path allocation gives the claimant all net household capital. + esa_income_assessable_capital: [16_200, 0] + +- name: Income-related ESA deducts sale expenses before combined secured debt for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 80_000 + output: + # max(0, 90% of 120,000 - 80,000); no separate buy-to-let input. + esa_income_assessable_capital: 28_000 + +- name: Income-related ESA floors combined property equity at zero for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 120_000 + output: + # max(0, 90% of 120,000 - 120,000); no separate buy-to-let input. + esa_income_assessable_capital: 0 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..372f0a50e8 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 @@ -277,3 +304,123 @@ output: housing_benefit_assessable_capital: 5_000 housing_benefit_eligible: true + +- name: Housing Benefit values overseas land and property less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + housing_benefit_on_passporting_benefit: false + overseas_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + housing_benefit_assessable_capital: 9_000 + +- name: Housing Benefit preserves household allocation for overseas land and property + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + single: + members: first + housing_benefit_on_passporting_benefit: false + couple: + members: [second, partner] + housing_benefit_on_passporting_benefit: false + households: + household: + members: [first, second, partner] + overseas_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + housing_benefit_assessable_capital: [5_400, 10_800] + +- name: Housing Benefit passport disregards overseas land and property + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + housing_benefit_on_passporting_benefit: true + overseas_property_value: 18_000 + output: + housing_benefit_assessable_capital: 0 + +- name: Housing Benefit values unclassified other real estate less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + housing_benefit_on_passporting_benefit: false + other_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + housing_benefit_assessable_capital: 9_000 + +- name: Housing Benefit preserves household allocation for unclassified other real estate + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + single: + members: first + housing_benefit_on_passporting_benefit: false + couple: + members: [second, partner] + housing_benefit_on_passporting_benefit: false + households: + household: + members: [first, second, partner] + other_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + housing_benefit_assessable_capital: [5_400, 10_800] + +- name: Housing Benefit passport disregards unclassified other real estate + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + housing_benefit_on_passporting_benefit: true + other_property_value: 18_000 + output: + housing_benefit_assessable_capital: 0 + +- name: Housing Benefit deducts sale expenses before combined secured debt for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + housing_benefit_on_passporting_benefit: false + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 80_000 + output: + # max(0, 90% of 120,000 - 80,000); no separate buy-to-let input. + housing_benefit_assessable_capital: 28_000 + +- name: Housing Benefit floors combined property equity at zero for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + housing_benefit_on_passporting_benefit: false + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 120_000 + output: + # max(0, 90% of 120,000 - 120,000); no separate buy-to-let input. + housing_benefit_assessable_capital: 0 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..b22b47cd1b 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,150 @@ 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 + +- name: Income Support values overseas land and property less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + overseas_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + income_support_assessable_capital: 9_000 + +- name: Income Support preserves household allocation for overseas land and property + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + single: + members: first + would_claim_IS: false + couple: + members: [second, partner] + would_claim_IS: false + households: + household: + members: [first, second, partner] + overseas_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + income_support_assessable_capital: [5_400, 10_800] + +- name: Income Support reported claim path receives full overseas land and property capital + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + unrelated: + age: 40 + benunits: + claiming: + members: claimant + would_claim_IS: true + other: + members: unrelated + would_claim_IS: false + households: + household: + members: [claimant, unrelated] + overseas_property_value: 18_000 + output: + # Existing claim-path allocation gives the claimant all net household capital. + income_support_assessable_capital: [16_200, 0] + +- name: Income Support values unclassified other real estate less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + other_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + income_support_assessable_capital: 9_000 + +- name: Income Support preserves household allocation for unclassified other real estate + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + single: + members: first + would_claim_IS: false + couple: + members: [second, partner] + would_claim_IS: false + households: + household: + members: [first, second, partner] + other_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + income_support_assessable_capital: [5_400, 10_800] + +- name: Income Support reported claim path receives full unclassified other real estate capital + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + unrelated: + age: 40 + benunits: + claiming: + members: claimant + would_claim_IS: true + other: + members: unrelated + would_claim_IS: false + households: + household: + members: [claimant, unrelated] + other_property_value: 18_000 + output: + # Existing claim-path allocation gives the claimant all net household capital. + income_support_assessable_capital: [16_200, 0] + +- name: Income Support deducts sale expenses before combined secured debt for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 80_000 + output: + # max(0, 90% of 120,000 - 80,000); no separate buy-to-let input. + income_support_assessable_capital: 28_000 + +- name: Income Support floors combined property equity at zero for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 120_000 + output: + # max(0, 90% of 120,000 - 120,000); no separate buy-to-let input. + income_support_assessable_capital: 0 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..748b2ef9ef 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,144 @@ 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 + +- name: Income-based JSA values overseas land and property less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + overseas_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + jsa_income_assessable_capital: 9_000 + +- name: Income-based JSA preserves household allocation for overseas land and property + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + single: + members: first + couple: + members: [second, partner] + households: + household: + members: [first, second, partner] + overseas_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + jsa_income_assessable_capital: [5_400, 10_800] + +- name: Income-based JSA reported claim path receives full overseas land and property capital + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + jsa_income_reported: 1_000 + unrelated: + age: 40 + benunits: + claiming: + members: claimant + other: + members: unrelated + households: + household: + members: [claimant, unrelated] + overseas_property_value: 18_000 + output: + # Existing claim-path allocation gives the claimant all net household capital. + jsa_income_assessable_capital: [16_200, 0] + +- name: Income-based JSA values unclassified other real estate less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + other_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + jsa_income_assessable_capital: 9_000 + +- name: Income-based JSA preserves household allocation for unclassified other real estate + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + single: + members: first + couple: + members: [second, partner] + households: + household: + members: [first, second, partner] + other_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + jsa_income_assessable_capital: [5_400, 10_800] + +- name: Income-based JSA reported claim path receives full unclassified other real estate capital + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + jsa_income_reported: 1_000 + unrelated: + age: 40 + benunits: + claiming: + members: claimant + other: + members: unrelated + households: + household: + members: [claimant, unrelated] + other_property_value: 18_000 + output: + # Existing claim-path allocation gives the claimant all net household capital. + jsa_income_assessable_capital: [16_200, 0] + +- name: Income-based JSA deducts sale expenses before combined secured debt for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 80_000 + output: + # max(0, 90% of 120,000 - 80,000); no separate buy-to-let input. + jsa_income_assessable_capital: 28_000 + +- name: Income-based JSA floors combined property equity at zero for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 120_000 + output: + # max(0, 90% of 120,000 - 120,000); no separate buy-to-let input. + jsa_income_assessable_capital: 0 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/finance/benefit/family/universal_credit/uc_assessable_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_assessable_capital.yaml index 618ee78696..e2919f21fd 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_assessable_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_assessable_capital.yaml @@ -2,6 +2,9 @@ # UC Regulations 2013 reg.18(2) includes a single claimant's partner's capital. # https://www.legislation.gov.uk/ukpga/2012/5/section/5 # https://www.legislation.gov.uk/uksi/2013/376/regulation/18 +# Property expectations deduct 10% sale expenses, then secured debt, under +# https://www.legislation.gov.uk/uksi/2013/376/regulation/49 +# Capital above the limit fails https://www.legislation.gov.uk/uksi/2013/376/regulation/18 # Allocating unreported household capital by claimant/partner count is a # PolicyEngine convention, not a statutory allocation rule. @@ -163,3 +166,263 @@ household_uc_unreported_claimants: 0 # No claimant weights means no allocation: guarded zero instead of 0/0. uc_assessable_capital: 0 + +- name: Universal Credit values overseas land and property less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + overseas_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + uc_assessable_capital: 9_000 + +- name: Universal Credit preserves household allocation for overseas land and property + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + single: + members: first + couple: + members: [second, partner] + households: + household: + members: [first, second, partner] + overseas_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + uc_assessable_capital: [5_400, 10_800] + +- name: Universal Credit reported capital overrides overseas land and property + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + reporting: + members: first + uc_reported_capital: 4_000 + unreported: + members: [second, partner] + households: + household: + members: [first, second, partner] + overseas_property_value: 18_000 + output: + # The complete override consumes its property share; it is not redistributed. + uc_assessable_capital: [4_000, 10_800] + +- name: Universal Credit values unclassified other real estate less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + other_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + uc_assessable_capital: 9_000 + +- name: Universal Credit preserves household allocation for unclassified other real estate + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + single: + members: first + couple: + members: [second, partner] + households: + household: + members: [first, second, partner] + other_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + uc_assessable_capital: [5_400, 10_800] + +- name: Universal Credit reported capital overrides unclassified other real estate + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + reporting: + members: first + uc_reported_capital: 4_000 + unreported: + members: [second, partner] + households: + household: + members: [first, second, partner] + other_property_value: 18_000 + output: + # The complete override consumes its property share; it is not redistributed. + uc_assessable_capital: [4_000, 10_800] + +- name: Universal Credit deducts sale expenses before combined secured debt for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 80_000 + output: + # max(0, 90% of 120,000 - 80,000); no separate buy-to-let input. + uc_assessable_capital: 28_000 + +- name: Universal Credit floors combined property equity at zero for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 120_000 + output: + # max(0, 90% of 120,000 - 120,000); no separate buy-to-let input. + uc_assessable_capital: 0 + +- name: A financial report retains overseas and domestic property capital + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + benunit_reported_capital: 0 + overseas_property_value: 18_000 + other_residential_property_value: 10_000 + output: + # Regulation 49: 90% of both property stocks, independent of financial reports. + uc_property_capital: 25_200 + uc_assessable_capital: 25_200 + +- name: A financial report retains unclassified other and domestic property capital + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + benunit_reported_capital: 0 + other_property_value: 18_000 + non_residential_property_value: 10_000 + output: + # Regulation 49: 90% of both property stocks, independent of financial reports. + uc_property_capital: 25_200 + uc_assessable_capital: 25_200 + +- name: Overseas property exceeds the UC capital limit without a financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + overseas_property_value: 20_000 + output: + # UC regulation 49(1)-(2): market value less 10% sale expenses, no debt. + uc_property_capital: 18_000 + uc_assessable_capital: 18_000 + is_uc_eligible: false + +- name: A zero financial report preserves overseas property above the UC capital limit + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + overseas_property_value: 20_000 + benunit_reported_capital: 0 + output: + uc_property_capital: 18_000 + uc_assessable_capital: 18_000 + is_uc_eligible: false + +- name: A complete UC override replaces overseas property and the financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + overseas_property_value: 20_000 + benunit_reported_capital: 0 + uc_reported_capital: 4_000 + output: + uc_property_capital: 18_000 + uc_assessable_capital: 4_000 + is_uc_eligible: true + +- name: Unclassified other property exceeds the UC capital limit without a financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + other_property_value: 20_000 + output: + uc_property_capital: 18_000 + uc_assessable_capital: 18_000 + is_uc_eligible: false + +- name: A zero financial report preserves unclassified other property above the UC capital limit + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + other_property_value: 20_000 + benunit_reported_capital: 0 + output: + uc_property_capital: 18_000 + uc_assessable_capital: 18_000 + is_uc_eligible: false + +- name: A complete UC override replaces unclassified other property and the financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + other_property_value: 20_000 + benunit_reported_capital: 0 + uc_reported_capital: 4_000 + output: + uc_property_capital: 18_000 + uc_assessable_capital: 4_000 + is_uc_eligible: true + +- name: A financial report adds to all property with sale expenses and secured debt applied once + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + savings: 50_000 + benunit_reported_capital: 2_000 + overseas_property_value: 20_000 + other_property_value: 10_000 + other_residential_property_value: 20_000 + other_residential_property_secured_debt: 8_000 + non_residential_property_value: 30_000 + non_residential_property_secured_debt: 30_000 + output: + # 18,000 + 9,000 + (18,000 - 8,000) + max(0, 27,000 - 30,000). + uc_property_capital: 37_000 + uc_assessable_capital: 39_000 + is_uc_eligible: false 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/pension_credit/pension_credit_reported_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_reported_capital.yaml index 1ed36adf50..a7929272dd 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_reported_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_reported_capital.yaml @@ -1,6 +1,8 @@ # Expected values are hand-computed from SPC Regs 2002 reg 15(6): deemed income # is 1 pound a week for every 500 pounds, or part of 500 pounds, of capital # above 10,000 pounds, times 52 weeks a year. +# Property expectations deduct 10% sale expenses followed by secured debt: +# https://www.legislation.gov.uk/uksi/2002/1792/regulation/19 - name: With no reported capital (the default, -1), household capital is the proxy. period: 2025 @@ -174,3 +176,330 @@ pension_credit_income: 9_000 # minimum guarantee 218.15 a week (2024-25, SI 2024/242) x 52 - 9,000 guarantee_credit: 218.15 * 52 - 9_000 + +- name: Pension Credit values overseas land and property less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + overseas_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + pension_credit_assessable_capital: 9_000 + +- name: Pension Credit preserves household allocation for overseas land and property + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 75 + second: + age: 75 + partner: + age: 75 + benunits: + single: + members: first + couple: + members: [second, partner] + households: + household: + members: [first, second, partner] + overseas_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + pension_credit_assessable_capital: [5_400, 10_800] + +- name: Pension Credit reported capital overrides overseas land and property + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 75 + second: + age: 75 + partner: + age: 75 + benunits: + reporting: + members: first + pension_credit_reported_capital: 4_000 + unreported: + members: [second, partner] + households: + household: + members: [first, second, partner] + overseas_property_value: 18_000 + output: + # The override replaces its own proxy; the other unit keeps its two-thirds share. + pension_credit_assessable_capital: [4_000, 10_800] + +- name: Pension Credit values unclassified other real estate less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + other_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + pension_credit_assessable_capital: 9_000 + +- name: Pension Credit preserves household allocation for unclassified other real estate + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 75 + second: + age: 75 + partner: + age: 75 + benunits: + single: + members: first + couple: + members: [second, partner] + households: + household: + members: [first, second, partner] + other_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + pension_credit_assessable_capital: [5_400, 10_800] + +- name: Pension Credit reported capital overrides unclassified other real estate + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 75 + second: + age: 75 + partner: + age: 75 + benunits: + reporting: + members: first + pension_credit_reported_capital: 4_000 + unreported: + members: [second, partner] + households: + household: + members: [first, second, partner] + other_property_value: 18_000 + output: + # The override replaces its own proxy; the other unit keeps its two-thirds share. + pension_credit_assessable_capital: [4_000, 10_800] + +- name: Pension Credit deducts sale expenses before combined secured debt for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 80_000 + output: + # max(0, 90% of 120,000 - 80,000); no separate buy-to-let input. + pension_credit_assessable_capital: 28_000 + +- name: Pension Credit floors combined property equity at zero for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 120_000 + output: + # max(0, 90% of 120,000 - 120,000); no separate buy-to-let input. + pension_credit_assessable_capital: 0 + +- name: Pension Credit counts overseas property with no financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + overseas_property_value: 20_000 + output: + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 18_000 + +- name: Pension Credit retains overseas property with a zero financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + overseas_property_value: 20_000 + benunit_reported_capital: 0 + output: + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 18_000 + +- name: Pension Credit counts other property with no financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + other_property_value: 20_000 + output: + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 18_000 + +- name: Pension Credit retains other property with a zero financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + other_property_value: 20_000 + benunit_reported_capital: 0 + output: + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 18_000 + +- name: Pension Credit adds property to the financial report while replacing financial proxies + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + savings: 50_000 + corporate_wealth: 30_000 + lifetime_isa_countable_capital: 5_000 + overseas_property_value: 20_000 + other_property_value: 10_000 + benunit_reported_capital: 6_000 + output: + # Financial proxies are replaced; each property category is valued once. + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 33_000 + +- name: A complete Pension Credit override still replaces new property and financial reports + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + overseas_property_value: 20_000 + other_property_value: 20_000 + benunit_reported_capital: 6_000 + pension_credit_reported_capital: 0 + output: + pension_credit_assessable_capital: 0 + +- name: Pension Credit financial reports retain domestic property with expenses and debt deducted once + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + benunit_reported_capital: 2_000 + owned_land: 10_000 + owned_land_secured_debt: 3_000 + other_residential_property_value: 20_000 + other_residential_property_secured_debt: 8_000 + non_residential_property_value: 30_000 + non_residential_property_secured_debt: 7_000 + output: + # 2,000 + (9,000 - 3,000) + (18,000 - 8,000) + (27,000 - 7,000). + pension_credit_assessable_capital: 38_000 + +- name: Pension Credit financial reports preserve the pension-age property allocation + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 75 + second: + age: 75 + partner: + age: 75 + benunits: + reporting: + members: first + benunit_reported_capital: 0 + unreported: + members: [second, partner] + households: + household: + members: [first, second, partner] + overseas_property_value: 18_000 + output: + pension_credit_reported_capital: [-1, -1] + pension_credit_assessable_capital: [5_400, 10_800] + +- name: Savings-credit-only Housing Benefit inherits retained overseas property + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + benunit_reported_capital: 0 + overseas_property_value: 20_000 + housing_benefit_pension_age_regulations_apply: true + in_receipt_of_savings_credit_only: true + in_receipt_of_guarantee_credit: false + housing_benefit_on_passporting_benefit: false + output: + pension_credit_assessable_capital: 18_000 + housing_benefit_assessable_capital: 18_000 + +- name: Savings-credit-only Housing Benefit inherits retained other property + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + benunit_reported_capital: 0 + other_property_value: 20_000 + housing_benefit_pension_age_regulations_apply: true + in_receipt_of_savings_credit_only: true + in_receipt_of_guarantee_credit: false + housing_benefit_on_passporting_benefit: false + output: + pension_credit_assessable_capital: 18_000 + housing_benefit_assessable_capital: 18_000 + +- name: Pension Credit values large overseas property without an intermediate reported total + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + overseas_property_value: 145635.68 + output: + # Stored gross 145635.6875 less 10%, rounded down, is 131072.11. + # Final float32 storage gives 131072.109375. + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 131072.109375 + +- name: A zero financial report cannot reduce large overseas property through extra float32 rounding + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + overseas_property_value: 145635.68 + benunit_reported_capital: 0 + output: + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 131072.109375 + +- name: Pension Credit values large other property without an intermediate reported total + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + other_property_value: 145635.68 + output: + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 131072.109375 + +- name: A zero financial report cannot reduce large other property through extra float32 rounding + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + other_property_value: 145635.68 + benunit_reported_capital: 0 + output: + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 131072.109375 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/policy/baseline/household/wealth/benunit_reported_capital.yaml b/policyengine_uk/tests/policy/baseline/household/wealth/benunit_reported_capital.yaml index 819ebc010a..b6a709ce7a 100644 --- a/policyengine_uk/tests/policy/baseline/household/wealth/benunit_reported_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/household/wealth/benunit_reported_capital.yaml @@ -3,8 +3,8 @@ # 2013 reg.18) and Pension Credit (SPCA 2002 s.5) assess the claimant's and # partner's capital together. UC replaces financial proxies with this # observation and counts property separately; uc_reported_capital remains a -# separate complete override. Pension Credit continues to use the observation -# in place of its whole capital proxy unless its own variable is set. +# separate complete override. Pension Credit also retains separately valued +# property unless its own complete override is set directly. # Tariff income: UC Regulations 2013 reg.72, 4.35 pounds a month for each 250 # pounds or part above 6,000. @@ -24,7 +24,7 @@ savings: 50_000 output: uc_reported_capital: -1 - pension_credit_reported_capital: 2_000 + pension_credit_reported_capital: -1 uc_assessable_capital: 2_000 is_uc_eligible: true @@ -65,7 +65,7 @@ savings: 50_000 output: uc_assessable_capital: 3_000 - pension_credit_reported_capital: 20_000 + pension_credit_reported_capital: -1 - name: Recorded capital sets UC tariff income. period: 2025 diff --git a/policyengine_uk/tests/policy/baseline/household/wealth/household_land_value.yaml b/policyengine_uk/tests/policy/baseline/household/wealth/household_land_value.yaml index 2747220122..55c27a49aa 100644 --- a/policyengine_uk/tests/policy/baseline/household/wealth/household_land_value.yaml +++ b/policyengine_uk/tests/policy/baseline/household/wealth/household_land_value.yaml @@ -48,7 +48,7 @@ output: household_land_value: 104000 -- name: Zero property wealth still counts owned land +- name: Zero domestic building value still counts owned land period: 2023 absolute_error_margin: 0 input: @@ -87,3 +87,80 @@ owned_land: 0 output: household_land_value: 88000 + +- name: UK land-only holdings enter property wealth and household land once + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + owned_land: 25000 + output: + property_wealth: 25000 + household_land_value: 25000 + total_wealth: 25000 + +- name: Overseas-only holdings have worldwide wealth but no UK land or household LVT + period: 2026 + absolute_error_margin: 0 + input: + gov.contrib.ubi_center.land_value_tax.rate: 0 + gov.contrib.ubi_center.land_value_tax.household_rate: 0.02 + gov.contrib.ubi_center.land_value_tax.corporate_rate: 0 + region: LONDON + overseas_property_value: 30000 + output: + property_wealth: 30000 + total_wealth: 30000 + household_land_value: 0 + LVT: 0 + +- name: Mixed London holdings exclude overseas property and count direct UK land once + period: 2026 + absolute_error_margin: 0.01 + input: + gov.contrib.ubi_center.land_value_tax.rate: 0 + gov.contrib.ubi_center.land_value_tax.household_rate: 0.02 + gov.contrib.ubi_center.land_value_tax.corporate_rate: 0 + region: LONDON + main_residence_value: 100000 + owned_land: 20000 + overseas_property_value: 30000 + output: + property_wealth: 150000 + total_wealth: 150000 + household_land_value: 105000 + LVT: 2100 + +- name: Unclassified other real estate uses the regional land approximation + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + other_property_value: 20000 + output: + property_wealth: 20000 + total_wealth: 20000 + household_land_value: 17000 + +- name: Large overseas holdings cannot reduce directly owned UK land through float32 cancellation + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + owned_land: 20000.50 + overseas_property_value: 8388608 + output: + household_land_value: 20000.50 + +- name: A saved worldwide aggregate does not replace the domestic land components + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + property_wealth: 900000 + main_residence_value: 100000 + owned_land: 20000 + overseas_property_value: 30000 + output: + property_wealth: 900000 + household_land_value: 105000 diff --git a/policyengine_uk/tests/policy/baseline/household/wealth/property_wealth_components.yaml b/policyengine_uk/tests/policy/baseline/household/wealth/property_wealth_components.yaml new file mode 100644 index 0000000000..cec2f04739 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/household/wealth/property_wealth_components.yaml @@ -0,0 +1,165 @@ +# Second homes and buy-to-let are separate WAS source categories but share +# the single saved other_residential_property_value input after data #501. +- name: Main residence contributes once to residential property and total wealth + period: 2026 + absolute_error_margin: 0 + input: + main_residence_value: 100000 + output: + residential_property_value: 100000 + property_wealth: 100000 + total_wealth: 100000 + +- name: Second-home-only source is carried by the other-residential component + period: 2026 + absolute_error_margin: 0 + input: + other_residential_property_value: 40000 + output: + residential_property_value: 40000 + property_wealth: 40000 + total_wealth: 40000 + +- name: Buy-to-let-only source is carried once by the other-residential component + period: 2026 + absolute_error_margin: 0 + input: + other_residential_property_value: 60000 + output: + residential_property_value: 60000 + property_wealth: 60000 + total_wealth: 60000 + +- name: Second homes and buy-to-let use one combined other-residential component + period: 2026 + absolute_error_margin: 0 + input: + other_residential_property_value: 100000 + output: + residential_property_value: 100000 + property_wealth: 100000 + total_wealth: 100000 + +- name: UK non-residential buildings contribute once to worldwide property wealth + period: 2026 + absolute_error_margin: 0 + input: + non_residential_property_value: 70000 + output: + residential_property_value: 0 + property_wealth: 70000 + total_wealth: 70000 + +- name: Direct UK land contributes once to worldwide property wealth + period: 2026 + absolute_error_margin: 0 + input: + owned_land: 20000 + output: + residential_property_value: 0 + property_wealth: 20000 + total_wealth: 20000 + +- name: Overseas land and buildings contribute once to worldwide property wealth + period: 2026 + absolute_error_margin: 0 + input: + overseas_property_value: 30000 + output: + residential_property_value: 0 + property_wealth: 30000 + total_wealth: 30000 + +- name: Other real estate contributes once without residential classification + period: 2026 + absolute_error_margin: 0 + input: + other_property_value: 50000 + output: + residential_property_value: 0 + property_wealth: 50000 + total_wealth: 50000 + +- name: All six saved property components and other wealth categories count once + period: 2026 + absolute_error_margin: 0 + input: + main_residence_value: 100000 + other_residential_property_value: 100000 + non_residential_property_value: 70000 + owned_land: 20000 + overseas_property_value: 30000 + other_property_value: 50000 + corporate_wealth: 11000 + private_pension_wealth: 13000 + savings: 17000 + output: + residential_property_value: 200000 + property_wealth: 370000 + total_wealth: 411000 + +- name: Overseas property never creates a UK dwelling or non-residential purchase + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + property_purchased: true + overseas_property_value: 500000 + output: + main_residential_property_purchased: 0 + additional_residential_property_purchased: 0 + non_residential_property_purchased: 0 + stamp_duty_land_tax: 0 + expected_sdlt: 0 + +- name: Unclassified other property never creates a UK property purchase + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + property_purchased: true + other_property_value: 500000 + output: + main_residential_property_purchased: 0 + additional_residential_property_purchased: 0 + non_residential_property_purchased: 0 + stamp_duty_land_tax: 0 + expected_sdlt: 0 + +- name: Second-home and buy-to-let stocks do not imply additional purchases + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + property_purchased: true + other_residential_property_value: 400000 + non_residential_property_value: 500000 + output: + residential_property_value: 400000 + additional_residential_property_purchased: 0 + non_residential_property_purchased: 0 + stamp_duty_land_tax: 0 + expected_sdlt: 0 + +# From 1 April 2025 a £300,000 additional-dwelling transaction pays +# 5% x £125,000 + 7% x £125,000 + 10% x £50,000 = £20,000. +# Finance Act 2025 sections 50-51 and Finance Act 2003 Schedule 4ZA. +- name: An explicit additional-dwelling transaction enters SDLT once with overseas and other stocks + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + property_purchased: true + other_residential_property_value: 400000 + overseas_property_value: 500000 + other_property_value: 500000 + additional_residential_property_purchased: 300000 + output: + residential_property_value: 400000 + property_wealth: 1400000 + main_residential_property_purchased: 0 + additional_residential_property_purchased: 300000 + non_residential_property_purchased: 0 + sdlt_on_residential_property_transactions: 20000 + stamp_duty_land_tax: 20000 + expected_sdlt: 20000 diff --git a/policyengine_uk/tests/test_benunit_reported_capital_properties.py b/policyengine_uk/tests/test_benunit_reported_capital_properties.py index b1884bf6eb..724dcde71f 100644 --- a/policyengine_uk/tests/test_benunit_reported_capital_properties.py +++ b/policyengine_uk/tests/test_benunit_reported_capital_properties.py @@ -2,14 +2,14 @@ A dataset records financial capital (the benefit unit's savings and investments, -1 when nothing is recorded). Universal Credit replaces only -financial proxies with that observation; `uc_reported_capital` is a separate -complete override and defaults to -1. Pension Credit continues to derive -`pension_credit_reported_capital` from the observation. +financial proxies with that observation. `uc_reported_capital` and +`pension_credit_reported_capital` are separate complete overrides and both +default to -1; Pension Credit also assesses the financial input separately. Invariants, over generated households of one or two benefit units: -1. Derivation: Pension Credit's reported capital equals the financial input, - -1 included. UC's complete override remains -1 without an explicit value. +1. Separation: both programmes' complete overrides remain -1 without an + explicit value, including when the financial input is supplied. 2. Differential: without property or person-level capital, a financial observation gives the same assessment as explicitly setting that unit's complete override too. Each unit consumes the same amount from the financial @@ -27,12 +27,13 @@ 7. Periods: the input set for one year is uprated into later years. A programme variable set directly applies only to the years it is set, as for any formula variable. Later years use the financial input, when present, - while the complete UC override returns to -1. + while both complete programme overrides return to -1. """ from pathlib import Path import numpy as np +import pytest import yaml from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st @@ -42,6 +43,8 @@ from policyengine_uk import Microsimulation, Simulation from policyengine_uk.data import UKSingleYearDataset +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + YEAR = 2025 SETTINGS = settings( max_examples=15, @@ -157,11 +160,11 @@ def test_financial_capital_matches_complete_override_without_nonfinancial_assets a, b = outputs(via_input), outputs(via_programmes) for i, unit in enumerate(units): assert a["uc_reported_capital"][i] == -1 - assert a["pension_credit_reported_capital"][i] == unit["recorded"] + assert a["pension_credit_reported_capital"][i] == -1 if unit["recorded"] >= 0: assert a["uc_assessable_capital"][i] == unit["recorded"] for v in OUTPUTS: - if v == "uc_reported_capital": + if v in PROGRAMME_VARIABLES: continue np.testing.assert_allclose(a[v], b[v], rtol=0, atol=1e-6, err_msg=v) for v in ["uc_assessable_capital", "pension_credit_assessable_capital"]: @@ -178,7 +181,7 @@ def test_programme_variable_set_directly_wins_for_that_programme(case, base, uc) inputs[0]["uc_reported_capital"] = uc sim = simulate(units, household, inputs) assert sim.calculate("uc_reported_capital", YEAR)[0] == uc - assert sim.calculate("pension_credit_reported_capital", YEAR)[0] == base + assert sim.calculate("pension_credit_reported_capital", YEAR)[0] == -1 assert sim.calculate("uc_assessable_capital", YEAR)[0] == uc @@ -274,13 +277,13 @@ def test_stored_financial_observations_and_explicit_overrides_project_like_savin / sim.calculate("savings", 2025).values ) assert np.all(growth > 1) - projected_names = ["pension_credit_reported_capital"] - if "uc_reported_capital" in columns: - projected_names.append("uc_reported_capital") - else: + projected_names = list(columns) + for name in PROGRAMME_VARIABLES: + if name in columns: + continue for year in [2025, 2027]: np.testing.assert_array_equal( - sim.calculate("uc_reported_capital", year).values, + sim.calculate(name, year).values, [-1, -1, -1], ) for name in projected_names: @@ -303,10 +306,10 @@ def test_stored_financial_observations_and_explicit_overrides_project_like_savin ) -def _single_adult(benunit_inputs): +def _single_adult(benunit_inputs, age=30): return Simulation( situation={ - "people": {"p": {"age": {"2025": 30}}}, + "people": {"p": {"age": {"2025": age}}}, "benunits": {"b": {"members": ["p"], **benunit_inputs}}, "households": {"h": {"members": ["p"], "savings": {"2025": 50_000}}}, } @@ -314,14 +317,17 @@ def _single_adult(benunit_inputs): def test_input_set_once_is_uprated_into_later_years(): - sim = _single_adult({"benunit_reported_capital": {"2025": 10_000}}) + sim = _single_adult({"benunit_reported_capital": {"2025": 10_000}}, age=75) index = sim.tax_benefit_system.parameters.get_child( "gov.economic_assumptions.indices.obr.per_capita.gdp" ) expected = 10_000 * index("2026-01-01") / index("2025-01-01") assert sim.calculate("uc_reported_capital", 2026)[0] == -1 + assert sim.calculate("pension_credit_reported_capital", 2026)[0] == -1 np.testing.assert_allclose( - sim.calculate("pension_credit_reported_capital", 2026)[0], expected, rtol=1e-6 + sim.calculate("pension_credit_assessable_capital", 2026)[0], + expected, + rtol=1e-6, ) np.testing.assert_allclose( sim.calculate("uc_assessable_capital", 2026)[0], expected, rtol=1e-6 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..50c4554ef1 --- /dev/null +++ b/policyengine_uk/tests/test_capital_valuation_properties.py @@ -0,0 +1,639 @@ +"""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", + "overseas_property_value", + "other_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", + "overseas_property_value", + "other_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", + "overseas_property_value", + "other_property_value", + ]: + total += max( + 0.0, + asset(unit, source) * (1 - LEGAL_RATE) + - unit["debts"].get(DEBTS.get(source), 0.0), + ) + 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..2468bef265 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 + # uc_assessable_capital values less the sale-expense rate whether + # share_like_capital holds shares or, as here, only the ISA. + 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_projection_horizon.py b/policyengine_uk/tests/test_projection_horizon.py index 6b2ff922c0..6c937c5666 100644 --- a/policyengine_uk/tests/test_projection_horizon.py +++ b/policyengine_uk/tests/test_projection_horizon.py @@ -37,6 +37,8 @@ ) from policyengine_uk.system import system +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + BASE_YEAR = 2024 GROWTH = parameters.gov.economic_assumptions.yoy_growth # The fixture declares the OBR savings income projection, under which the @@ -114,9 +116,9 @@ def _dataset(amount=1_000.0, base_year=BASE_YEAR): input set to ``amount`` (or one amount per household), plus rent and council tax. - Recorded Pension Credit capital is twice the benefit unit's recorded - capital, which its formula reads, so the two tell apart a stored value - from a calculated one.""" + Recorded complete Pension Credit capital is twice the benefit unit's + financial report, distinguishing the stored complete override from both + the financial observation and the default no-override sentinel.""" n = len(HOUSEHOLDS) ids = np.arange(1, n + 1) amounts = np.broadcast_to(np.asarray(amount, dtype=float), (n,)).copy() @@ -390,11 +392,11 @@ def test_a_year_past_the_horizon_is_served_from_the_last_projected_year(sim): one with an uprating attribute is scaled by that index from the last projected year, and any other is held at its last projected value.""" last, after = PROJECTION_END_YEAR, PROJECTION_END_YEAR + 1 - # The formula reads benunit_reported_capital, uprated from the last - # projected year, in place of the stored value, which was twice it. + # The complete Pension Credit override expires after the stored horizon. + # Its default is -1; the financial observation remains separately indexed. assert system.variables["pension_credit_reported_capital"].formulas assert sim.calculate("pension_credit_reported_capital", after).values == ( - pytest.approx(sim.calculate("benunit_reported_capital", after).values) + pytest.approx(np.full(len(HOUSEHOLDS), -1)) ) assert sim.calculate("pension_credit_reported_capital", last).values == ( pytest.approx(2 * sim.calculate("benunit_reported_capital", last).values) @@ -420,7 +422,7 @@ def test_legacy_reported_capital_expires_only_after_the_horizon(projected): """Legacy survey files store programme capital without the shared input. Projection preserves that observation through the supported horizon. - Afterward the programme formula uses the absent shared input's sentinel, + Afterward the complete-override formula returns its default sentinel, matching direct-situation semantics rather than carrying the observation. """ dataset = _dataset() diff --git a/policyengine_uk/tests/test_property_wealth_components.py b/policyengine_uk/tests/test_property_wealth_components.py new file mode 100644 index 0000000000..1e5039f7f8 --- /dev/null +++ b/policyengine_uk/tests/test_property_wealth_components.py @@ -0,0 +1,637 @@ +"""Synthetic accounting and projection contracts for the WAS property components. + +WAS has seven gross terms, but second homes and buy-to-let are saved together +by policyengine-uk-data. These tests exercise that six-input model contract; +licensed-source reconciliation and release persistence belong to the data repo. +The formula properties use fresh Core simulation holders with one shared +model; uprating, datasets, monthly stocks, LVT and purchases exercise the UK +simulation wrapper separately. +""" + +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +import yaml +from hypothesis import example, given, settings +from hypothesis import strategies as st +from policyengine_core.simulations import Simulation as CoreSimulation + +from policyengine_uk import Simulation +from policyengine_uk.data import UKSingleYearDataset + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +BASE_YEAR = 2024 +GDP_INDEX = "gov.economic_assumptions.indices.obr.per_capita.gdp" +GDP_GROWTH = "gov.economic_assumptions.yoy_growth.obr.per_capita.gdp" +PROPERTY_INPUTS = ( + "main_residence_value", + "other_residential_property_value", + "non_residential_property_value", + "owned_land", + "overseas_property_value", + "other_property_value", +) +OTHER_WEALTH_INPUTS = ("corporate_wealth", "private_pension_wealth", "savings") +CAPITAL_PATHS = ( + "gov.dwp.universal_credit.means_test.capital", + "gov.dwp.housing_benefit.means_test.capital", + "gov.dwp.income_support.means_test.capital", + "gov.dwp.JSA.income.capital", + "gov.dwp.ESA.income.capital", + "gov.dwp.pension_credit.income.capital", +) +SOURCE_INPUTS = ( + ("main_residence", "main_residence_value"), + ("second_homes", "other_residential_property_value"), + ("buy_to_let", "other_residential_property_value"), + ("other_uk_buildings", "non_residential_property_value"), + ("uk_land", "owned_land"), + ("overseas_land_and_property", "overseas_property_value"), + ("other_real_estate", "other_property_value"), +) +REGIONS = ( + "LONDON", + "SOUTH_EAST", + "EAST_OF_ENGLAND", + "SOUTH_WEST", + "WEST_MIDLANDS", + "EAST_MIDLANDS", + "NORTH_WEST", + "YORKSHIRE", + "SCOTLAND", + "WALES", + "NORTH_EAST", + "NORTHERN_IRELAND", + "UNKNOWN", +) +PROPERTY_SETTINGS = settings(max_examples=15, deadline=None, derandomize=True) +# Finite non-negative stocks at float32 precision, as held by the model. +MONEY = st.floats( + min_value=0, + max_value=10_000_000, + allow_nan=False, + allow_infinity=False, + width=32, +) +COMPONENT_VALUES = dict( + zip(PROPERTY_INPUTS, (60_000, 25_000, 15_000, 20_000, 30_000, 0)) +) + + +@pytest.fixture(scope="module") +def _property_metadata_system(cloned_uk_tax_benefit_system): + # The dependency installs the clone factory before this read-only fixture. + import policyengine_uk.simulation as simulation_module + + return simulation_module.CountryTaxBenefitSystem() + + +def _saved_components(source_values): + """The upstream release contract combines second homes and buy-to-let.""" + components = dict.fromkeys(PROPERTY_INPUTS, 0.0) + for source, variable in SOURCE_INPUTS: + components[variable] += source_values.get(source, 0.0) + return components + + +def _situation(assets, region="LONDON"): + return { + "people": {"person": {"age": {BASE_YEAR: 40}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": { + "household": { + "members": ["person"], + "region": {BASE_YEAR: region}, + **{variable: {BASE_YEAR: value} for variable, value in assets.items()}, + } + }, + } + + +def _simulation(assets, *, region="LONDON", reform=None): + return Simulation(situation=_situation(assets, region), reform=reform) + + +@pytest.fixture(scope="module") +def _formula_simulation(_property_metadata_system): + """Fresh holders per example; unchanged production formula definitions.""" + + def build(assets, *, region="LONDON"): + return CoreSimulation( + tax_benefit_system=_property_metadata_system, + situation=_situation(assets, region), + ) + + return build + + +def _value(simulation, variable, period=BASE_YEAR): + return float(simulation.calculate(variable, period)[0]) + + +def _property_sum(simulation, period=BASE_YEAR): + return sum(_value(simulation, variable, period) for variable in PROPERTY_INPUTS) + + +def _component_dataset(components, *, stored_total=None): + """Use the actual single-year loader and its YAML uprating registrations.""" + household = { + "household_id": [1], + "region": ["LONDON"], + "tenure_type": ["OWNED_OUTRIGHT"], + "council_tax": [0.0], + "rent": [0.0], + **{variable: [float(value)] for variable, value in components.items()}, + } + if stored_total is not None: + household["property_wealth"] = [stored_total] + return UKSingleYearDataset( + person=pd.DataFrame( + { + "person_id": [1], + "person_benunit_id": [1], + "person_household_id": [1], + "age": [40], + } + ), + benunit=pd.DataFrame({"benunit_id": [1]}), + household=pd.DataFrame(household), + fiscal_year=BASE_YEAR, + ) + + +@pytest.mark.parametrize( + "variable", ["overseas_property_value", "other_property_value"] +) +def test_new_property_inputs_register_gdp_uprating_for_situations_and_datasets( + variable, _property_metadata_system +): + model_variable = _property_metadata_system.variables[variable] + assert model_variable.uprating == GDP_INDEX + assert model_variable.entity.key == "household" + assert model_variable.default_value == 0 + registrations_path = Path(__file__).parents[1] / "data" / "uprating_indices.yaml" + registrations = yaml.safe_load(registrations_path.read_text()) + assert registrations[GDP_GROWTH].count(variable) == 1 + assert sum(names.count(variable) for names in registrations.values()) == 1 + + +@pytest.mark.parametrize("capital_path", CAPITAL_PATHS) +@pytest.mark.parametrize("year", [2024, 2026]) +def test_property_capital_lists_preserve_each_source_once( + capital_path, year, _property_metadata_system +): + parameters = _property_metadata_system.parameters + sources = list(parameters.get_child(f"{capital_path}.sources")(year)) + sale_sources = list( + parameters.get_child(f"{capital_path}.sale_expenses.sources")(year) + ) + for names in (sources, sale_sources): + for variable in ("overseas_property_value", "other_property_value"): + assert names.count(variable) == ( + 0 if "universal_credit" in capital_path else 1 + ) + assert "main_residence_value" not in names + assert "private_pension_wealth" not in names + assert names.count("share_like_capital") == 1 + assert "corporate_wealth" not in names + assert "buy_to_let_value" not in names + # Preserve UC's existing omission; the aggregate change does not decide it. + if "universal_credit" in capital_path: + assert "owned_land" not in sources + property_sources = list( + parameters.get_child(f"{capital_path}.property_sources")(year) + ) + for variable in ("uc_overseas_property_capital", "uc_other_property_capital"): + assert property_sources.count(variable) == 1 + else: + assert sources.count("owned_land") == 1 + + +PARAMETERS_DIR = Path(__file__).parents[1] / "parameters" +PROPERTY_SOURCE_FILES = sorted(PARAMETERS_DIR.rglob("property_sources.yaml")) +# Universal Credit's uc_property_capital formula reads each listed variable +# directly. Every other list goes to valued_capital(..., sources=...), which +# skips a name missing from the programme's capital.sources. +DIRECTLY_READ_PROPERTY_SOURCES = {"gov.dwp.universal_credit.means_test.capital"} + + +def _dated_lists(path): + values = yaml.safe_load(path.read_text())["values"] + return {str(start): names for start, names in values.items()} + + +@pytest.mark.parametrize( + "path", + PROPERTY_SOURCE_FILES, + ids=lambda path: ".".join(path.relative_to(PARAMETERS_DIR).parent.parts), +) +def test_property_source_lists_name_configured_sources(path, _property_metadata_system): + node = ".".join(path.relative_to(PARAMETERS_DIR).parent.parts) + property_lists = _dated_lists(path) + assert property_lists + if node in DIRECTLY_READ_PROPERTY_SOURCES: + variables = _property_metadata_system.variables + for names in property_lists.values(): + assert len(names) == len(set(names)) + unknown = [name for name in names if name not in variables] + assert not unknown, f"{path}: {unknown} are not variables" + return + parameters = _property_metadata_system.parameters + first = min(property_lists) + dates = set(property_lists) | set(_dated_lists(path.with_name("sources.yaml"))) + for date in sorted(d for d in dates if d >= first): + names = list(parameters.get_child(f"{node}.property_sources")(date)) + configured = list(parameters.get_child(f"{node}.sources")(date)) + assert len(names) == len(set(names)) + missing = [name for name in names if name not in configured] + assert not missing, f"{path} at {date}: {missing} missing from sources" + + +@pytest.mark.parametrize( + "variable", ["overseas_property_value", "other_property_value"] +) +@PROPERTY_SETTINGS +@given( + property_pence=st.integers(min_value=0, max_value=2_000_000), + report_pence=st.integers(min_value=0, max_value=2_000_000), + holding_pence=st.integers(min_value=0, max_value=2_000_000), +) +@example(property_pence=2_000_000, report_pence=0, holding_pence=0) +def test_financial_reports_preserve_separately_valued_property( + _property_metadata_system, variable, property_pence, report_pence, holding_pence +): + # The only financial proxy is an entirely disregarded company holding. + # A financial-only report adds to the same independently valued property, + # net of that holding, even when the report is zero. + holding = holding_pence / 100 + situation = _situation( + {variable: property_pence / 100, "corporate_wealth": holding} + ) + situation["people"]["person"]["uc_company_holding_disregard"] = {BASE_YEAR: holding} + unreported = CoreSimulation( + tax_benefit_system=_property_metadata_system, situation=situation + ) + baseline = _value(unreported, "uc_assessable_capital") + property_capital = _value(unreported, "uc_property_capital") + report = report_pence / 100 + situation["benunits"]["benunit"]["benunit_reported_capital"] = {BASE_YEAR: report} + reported = CoreSimulation( + tax_benefit_system=_property_metadata_system, situation=situation + ) + counted = _value(reported, "uc_assessable_capital") + + assert counted >= baseline + assert baseline == property_capital + assert _value(reported, "uc_property_capital") == property_capital + # Recover the penny from float32 storage before adding the observed report; + # compare the exact value after the final float32 holder conversion. + net_report = max(0, report - holding) + assert counted == float( + np.float32(round(net_report + round(property_capital, 2), 2)) + ) + + +@pytest.mark.parametrize("source,variable", SOURCE_INPUTS) +def test_each_was_source_enters_gross_and_total_property_once(source, variable): + components = _saved_components({source: 100_000}) + sim = _simulation(components) + + assert _value(sim, variable) == 100_000 + assert _value(sim, "property_wealth") == 100_000 + assert _value(sim, "total_wealth") == 100_000 + residential = 100_000 if variable in PROPERTY_INPUTS[:2] else 0 + assert _value(sim, "residential_property_value") == residential + + +def test_seven_source_terms_reconcile_to_six_saved_components(): + # This is a synthetic mapping contract, not reconciliation of licensed WAS. + source_values = dict(zip((source for source, _ in SOURCE_INPUTS), range(1, 8))) + components = _saved_components(source_values) + sim = _simulation(components) + + assert len(components) == 6 + assert components["other_residential_property_value"] == 2 + 3 + assert _value(sim, "residential_property_value") == 1 + 2 + 3 + assert _value(sim, "property_wealth") == sum(source_values.values()) + assert _value(sim, "total_wealth") == sum(source_values.values()) + + +def test_london_mixed_property_has_150000_gross_and_105000_uk_land(): + sim = _simulation(COMPONENT_VALUES) + + assert _value(sim, "property_wealth") == 150_000 + assert _value(sim, "total_wealth") == 150_000 + assert _value(sim, "household_land_value") == 105_000 + + +@PROPERTY_SETTINGS +@given(st.tuples(*(MONEY for _ in PROPERTY_INPUTS))) +@example((60_000, 25_000, 15_000, 20_000, 30_000, 40_000)) +def test_property_wealth_equals_sum_of_saved_inputs(_formula_simulation, values): + sim = _formula_simulation(dict(zip(PROPERTY_INPUTS, values))) + + assert _value(sim, "property_wealth") == pytest.approx( + _property_sum(sim), rel=2e-6, abs=0.01 + ) + + +@PROPERTY_SETTINGS +@given(st.tuples(*(MONEY for _ in PROPERTY_INPUTS)), st.sampled_from(REGIONS)) +@example((0, 0, 0, 20_000, 0, 0), "LONDON") +@example((0, 0, 0, 20_000.50, 8_388_608, 0), "LONDON") +def test_household_land_value_is_at_least_direct_uk_land( + _formula_simulation, values, region +): + sim = _formula_simulation(dict(zip(PROPERTY_INPUTS, values)), region=region) + direct_land = _value(sim, "owned_land") + land = _value(sim, "household_land_value") + # A nonnegative domestic base cannot reduce the directly owned land. + assert land >= direct_land + + +@PROPERTY_SETTINGS +@given(MONEY, st.sampled_from(REGIONS)) +@example(30_000, "LONDON") +def test_overseas_only_households_have_zero_uk_land(_formula_simulation, value, region): + sim = _formula_simulation({"overseas_property_value": value}, region=region) + + assert _value(sim, "property_wealth") == _value(sim, "overseas_property_value") + assert _value(sim, "household_land_value") == 0 + + +@PROPERTY_SETTINGS +@given(MONEY, st.sampled_from(REGIONS)) +@example(20_000, "LONDON") +def test_land_only_households_count_direct_uk_land_once( + _formula_simulation, value, region +): + sim = _formula_simulation({"owned_land": value}, region=region) + direct_land = _value(sim, "owned_land") + + assert _value(sim, "property_wealth") == direct_land + assert _value(sim, "household_land_value") == direct_land + assert _value(sim, "total_wealth") == direct_land + + +@PROPERTY_SETTINGS +@given(st.tuples(*(MONEY for _ in (*PROPERTY_INPUTS, *OTHER_WEALTH_INPUTS)))) +@example((60_000, 25_000, 15_000, 20_000, 30_000, 40_000, 10_000, 5_000, 1_000)) +def test_total_wealth_includes_every_asset_category_once(_formula_simulation, values): + sim = _formula_simulation( + dict(zip((*PROPERTY_INPUTS, *OTHER_WEALTH_INPUTS), values)) + ) + expected = sum( + _value(sim, variable) for variable in (*PROPERTY_INPUTS, *OTHER_WEALTH_INPUTS) + ) + + assert _value(sim, "total_wealth") == pytest.approx(expected, rel=2e-6, abs=0.01) + + +def test_overseas_only_household_has_no_household_lvt(): + reform = { + "gov.contrib.ubi_center.land_value_tax.household_rate": { + "2024-01-01.2026-12-31": 0.01 + } + } + overseas = _simulation({"overseas_property_value": 100_000}, reform=reform) + domestic = _simulation(COMPONENT_VALUES, reform=reform) + + assert _value(overseas, "LVT") == 0 + assert _value(domestic, "LVT") == pytest.approx(1_050) + + +@pytest.mark.parametrize( + "second_homes,buy_to_let", [(100_000, 0), (0, 200_000), (100_000, 200_000)] +) +def test_second_homes_and_buy_to_let_use_one_explicit_domestic_purchase( + second_homes, buy_to_let +): + components = _saved_components( + {"second_homes": second_homes, "buy_to_let": buy_to_let} + ) + stocks_only = _simulation({**components, "property_purchased": True}) + assert _value(stocks_only, "additional_residential_property_purchased") == 0 + assert _value(stocks_only, "sdlt_on_transactions") == 0 + combined = second_homes + buy_to_let + sim = _simulation( + {**components, "additional_residential_property_purchased": combined} + ) + + assert _value(sim, "residential_property_value") == combined + assert _value(sim, "property_wealth") == combined + assert _value(sim, "total_wealth") == combined + assert _value(sim, "additional_residential_property_purchased") == combined + stamp_duty = sim.tax_benefit_system.parameters(BASE_YEAR).gov.hmrc.stamp_duty + assert _value(sim, "sdlt_on_transactions") == pytest.approx( + stamp_duty.residential.purchase.additional.rate.calc(np.array([combined]))[0] + ) + + +@pytest.mark.parametrize( + "variable", ["overseas_property_value", "other_property_value"] +) +def test_overseas_and_unclassified_property_do_not_enter_uk_purchases_or_sdlt(variable): + sim = _simulation({variable: 500_000, "property_purchased": True}) + + for purchase in ( + "main_residential_property_purchased", + "additional_residential_property_purchased", + "non_residential_property_purchased", + ): + assert _value(sim, purchase) == 0 + assert _value(sim, "sdlt_on_transactions") == 0 + assert _value(sim, "stamp_duty_land_tax") == 0 + + +def _assert_projected_component_accounting(sim, assets, year): + parameters = sim.tax_benefit_system.parameters + projected = {} + for variable, amount in assets.items(): + index_path = sim.tax_benefit_system.variables[variable].uprating + index = parameters.get_child(index_path) + expected = amount * index(year) / index(BASE_YEAR) + projected[variable] = _value(sim, variable, year) + # Dataset GDP projections compound the growth rates; cumulative indices + # round their levels. Residential inputs retain current main's HPI route. + assert projected[variable] == pytest.approx(expected, rel=1e-4, abs=0.02) + assert _value(sim, variable, f"{year}-01") == projected[variable] + total = sum(projected.values()) + assert _value(sim, "property_wealth", year) == pytest.approx(total, rel=2e-6) + assert _value(sim, "total_wealth", year) == pytest.approx(total, rel=2e-6) + assert _value(sim, "property_wealth", f"{year}-01") == _value( + sim, "property_wealth", year + ) + land = ( + total - projected["owned_land"] - projected["overseas_property_value"] + ) * 0.85 + projected["owned_land"] + assert _value(sim, "household_land_value", year) == pytest.approx(land, rel=2e-6) + assert _value(sim, "household_land_value", f"{year}-01") == _value( + sim, "household_land_value", year + ) + + +@pytest.mark.parametrize("year", [2024, 2026]) +def test_situation_uprating_preserves_accounting_and_monthly_stocks(year): + assets = {**COMPONENT_VALUES, "other_property_value": 40_000} + sim = _simulation(assets) + + _assert_projected_component_accounting(sim, assets, year) + + +@pytest.mark.parametrize("year", [2024, 2026]) +def test_component_only_dataset_derives_uprated_property_wealth(year): + assets = {**COMPONENT_VALUES, "other_property_value": 40_000} + dataset = _component_dataset(assets) + assert "property_wealth" not in dataset.household.columns + sim = Simulation(dataset=dataset) + + _assert_projected_component_accounting(sim, assets, year) + + +def test_legacy_stored_total_overrides_formula_until_removed_by_data_release(): + # Existing releases are deliberately not migrated by the model. uk-data#543 + # must stop persisting this column, including inherited copies of it. + legacy = _component_dataset(COMPONENT_VALUES, stored_total=1) + legacy_sim = Simulation(dataset=legacy) + assert _value(legacy_sim, "property_wealth") == 1 + assert _value(legacy_sim, "property_wealth", 2026) == 1 + + components_only = legacy.copy() + components_only.household = components_only.household.drop( + columns="property_wealth" + ) + sim = Simulation(dataset=components_only) + assert _value(sim, "property_wealth") == 150_000 + _assert_projected_component_accounting(sim, COMPONENT_VALUES, 2026) + + +# These outputs come from a run of these situations on main at 2da212ab, which +# has neither overseas_property_value nor other_property_value. Row 8 pins +# wealth, land and Universal Credit capital; row 9 pins wealth and land only. +# Unlike main at 2da212ab, the Housing Benefit, JSA, ESA and Income Support +# formulas here take sale expenses off other residential and non-residential +# property and off share_like_capital, which holds row 9's corporate_wealth, +# and the Universal Credit formula takes them off share_like_capital. These +# formulas therefore change the Housing Benefit, JSA, ESA and Income Support +# capital of both rows, and row 9's Universal Credit capital, independently +# of the new inputs. Rows 8 and 9 stop before Pension Credit capital, which +# the formula sets to nil while no member has reached the qualifying age. +WEALTH_AND_LAND = ("property_wealth", "total_wealth", "household_land_value") +CAPITAL = ( + "uc_assessable_capital", + "housing_benefit_assessable_capital", + "pension_credit_assessable_capital", + "jsa_income_assessable_capital", + "esa_income_assessable_capital", + "income_support_assessable_capital", +) +ZERO_INPUT_GRID = ( + ( + (40,), + "LONDON", + {"savings": 5_000, "main_residence_value": 200_000}, + None, + (200_000, 205_000, 170_000, 5_000, 0, 0, 5_000, 5_000, 5_000), + ), + ( + (40,), + "NORTH_EAST", + {"savings": 17_000}, + None, + (0, 17_000, 0, 17_000, 17_000, 0, 17_000, 17_000, 17_000), + ), + ( + (30, 32), + "WALES", + {"savings": 9_000, "main_residence_value": 150_000}, + None, + (150_000, 159_000, 70_500, 9_000, 0, 0, 9_000, 9_000, 9_000), + ), + ( + (70,), + "SCOTLAND", + {"savings": 12_000, "main_residence_value": 300_000}, + None, + (300_000, 312_000, 132_000, 12_000, 0, 12_000, 12_000, 12_000, 12_000), + ), + ( + (72, 69), + "SOUTH_WEST", + {"savings": 30_000}, + None, + (0, 30_000, 0, 30_000, 0, 30_000, 30_000, 30_000, 30_000), + ), + ( + (40,), + "LONDON", + {"savings": 5_000}, + 2_000, + (0, 5_000, 0, 2_000, 0, 0, 5_000, 5_000, 5_000), + ), + ( + (70,), + "YORKSHIRE", + {"savings": 8_000}, + 4_000, + (0, 8_000, 0, 4_000, 0, 4_000, 8_000, 8_000, 8_000), + ), + ( + (45,), + "LONDON", + { + "main_residence_value": 400_000, + "other_residential_property_value": 150_000, + "non_residential_property_value": 80_000, + }, + None, + (630_000, 630_000, 535_500, 207_000), + ), + ( + (45,), + "EAST_MIDLANDS", + { + "main_residence_value": 220_000, + "non_residential_property_value": 35_000, + "corporate_wealth": 10_000, + "private_pension_wealth": 90_000, + }, + None, + (255_000, 355_000, 122_400), + ), +) + + +def _grid_situation(ages, region, assets, reported, year=2026): + people = {f"person_{i}": {"age": {year: age}} for i, age in enumerate(ages)} + benunit = {"members": list(people)} + if reported is not None: + benunit["benunit_reported_capital"] = {year: reported} + return { + "people": people, + "benunits": {"benunit": benunit}, + "households": { + "household": { + "members": list(people), + "region": {year: region}, + **{variable: {year: value} for variable, value in assets.items()}, + } + }, + } + + +@pytest.mark.parametrize("explicit_zero", [False, True]) +@pytest.mark.parametrize("ages,region,assets,reported,expected", ZERO_INPUT_GRID) +def test_new_inputs_at_zero_reproduce_main( + ages, region, assets, reported, expected, explicit_zero +): + if explicit_zero: + assets = {**assets, "overseas_property_value": 0, "other_property_value": 0} + sim = Simulation(situation=_grid_situation(ages, region, assets, reported)) + outputs = (*WEALTH_AND_LAND, *CAPITAL)[: len(expected)] + + actual = tuple(_value(sim, variable, 2026) for variable in outputs) + assert actual == pytest.approx(expected, abs=0.01) 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_stored_household_totals.py b/policyengine_uk/tests/test_stored_household_totals.py index 468ff7c971..4172704c5a 100644 --- a/policyengine_uk/tests/test_stored_household_totals.py +++ b/policyengine_uk/tests/test_stored_household_totals.py @@ -216,10 +216,26 @@ def test_stored_household_totals_grow_past_the_projection_horizon(): assert totals[year][0] == pytest.approx( totals[PROJECTION_END_YEAR][0] * growth, rel=1e-6 ) - # Savings, the cash ISA and the stocks-and-shares ISA (through - # share_like_capital) make up the capital, each grown alike. + # Savings, the cash ISA and the stocks-and-shares ISA make up the capital. + # Savings and the cash ISA count at their value. The stocks-and-shares ISA + # reaches the capital through share_like_capital, which uc_assessable_capital + # values less the sale-expense rate (Universal Credit Regulations 2013 + # reg. 49(1)(a)). The parts grow alike, so capital keeps the same ratio to + # savings in every year: 5.8 for this household. + cash_isa = _values(simulation, "cash_isa") + shares_isa = _values(simulation, "stocks_and_shares_isa") + ratio = capital[DATA_YEAR][0] / savings[DATA_YEAR][0] for year in YEARS: - assert capital[year][0] == pytest.approx(6 * savings[year][0], rel=1e-5) + rate = simulation.tax_benefit_system.parameters( + period(year).start + ).gov.dwp.universal_credit.means_test.capital.sale_expenses.rate + expected = ( + savings[year][0] + cash_isa[year][0] + shares_isa[year][0] * (1 - rate) + ) + assert capital[year][0] == pytest.approx(expected, rel=1e-5), year + face_value = savings[year][0] + cash_isa[year][0] + shares_isa[year][0] + assert capital[year][0] < face_value - 0.01, year + assert capital[year][0] == pytest.approx(ratio * savings[year][0], rel=1e-5) @pytest.mark.parametrize( diff --git a/policyengine_uk/tests/test_uc_company_override_properties.py b/policyengine_uk/tests/test_uc_company_override_properties.py index 735847e6b8..1fac837bf1 100644 --- a/policyengine_uk/tests/test_uc_company_override_properties.py +++ b/policyengine_uk/tests/test_uc_company_override_properties.py @@ -20,6 +20,9 @@ pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") YEAR = 2026 +# corporate_wealth is valued at 90% for the expenses of sale (UC Regs 2013 +# reg. 49(1)(a)); disregarded holdings inside it leave the pool at that value. +SHARE_VALUE = 0.9 LAYOUTS = { "one-owner": ((10_000,), (), ()), "two-owners-one-unit": ((4_000, 6_000), (), ()), @@ -53,7 +56,7 @@ def _company_person(holding=0, dependant=False): def _grid(layout): - """Combine 960 independent three-unit households in one simulation.""" + """Combine 1,280 independent three-unit households in one simulation.""" people, units, households, cases = {}, {}, {}, [] first_holdings, dependant_holdings, second_holdings = LAYOUTS[layout] claimant_holdings = (sum(first_holdings), sum(second_holdings), 0) @@ -67,7 +70,7 @@ def _grid(layout): ) in enumerate( product( (0, 4_000, 10_000, 20_000), - (None, -1, 10_000), + (None, -1, 4_000, 10_000), (0, 4_000, 10_000, 20_000), (6_000, 20_000), OTHER_REPORTS, @@ -132,14 +135,15 @@ def test_company_overrides_conserve_capital_and_keep_holdings_out_of_other_units for row, (specs, adults, holdings, dependants, corporate, savings) in zip( assessed, cases ): - pool = corporate + savings + # The pool counts corporate_wealth at 90% (reg. 49(1)(a)), and holdings + # inside it leave the pool at that value. + pool = SHARE_VALUE * corporate + savings missing = [ "override" not in spec and spec.get("financial", -1) < 0 for spec in specs ] explicit = [ spec.get("override", max(0, spec.get("financial", -1))) for spec in specs ] - explicit_consumption = sum(explicit) # Invalid holdings are bounded per person, before grouping into units. # Allocate one budget by those ownership shares; never recap a route. bounded_holdings = [ @@ -150,8 +154,22 @@ def test_company_overrides_conserve_capital_and_keep_holdings_out_of_other_units total_holdings = sum(bounded_holdings) + bounded_dependants budget = min(corporate, total_holdings) scale = budget / total_holdings if total_holdings else 0 - effective_holdings = [holding * scale for holding in bounded_holdings] - extra_holding_draw = bounded_dependants * scale + sum( + market_holdings = [holding * scale for holding in bounded_holdings] + effective_holdings = [SHARE_VALUE * holding for holding in market_holdings] + # A recorded financial total holds its owners' holdings at full value; + # the pool counts them at 90% (reg. 49(1)(a)), so the report consumes + # its part beyond the holdings in full and the holdings at 90%. Like an + # override, a report below the holdings still consumes them: a report + # of 4,000 against a 10,000 holding consumes 9,000. + explicit_consumption = sum( + spec["override"] + if "override" in spec + else max(0, spec["financial"] - market) + SHARE_VALUE * market + if spec.get("financial", -1) >= 0 + else 0 + for spec, market in zip(specs, market_holdings) + ) + extra_holding_draw = SHARE_VALUE * bounded_dependants * scale + sum( holding if absent else max(0, holding - spec["override"]) @@ -181,9 +199,20 @@ def test_company_overrides_conserve_capital_and_keep_holdings_out_of_other_units # Account for what regulation 77 actually removes from reporting # owners, without scaling that substitution. This identity also # covers inconsistent observations and exhausted residual pools. + # A complete override is a countable figure, so the holding comes off + # it at 90%. A recorded financial total is used unvalued, so the owner + # loses the holding in full while the pool gives up only its 90% + # value: what the report removes from the pool beyond the owner's own + # remaining capital is its consumption less that capital. owner_substitution = sum( - min(holding, reported) - for holding, reported, absent in zip(holdings, explicit, missing) + min(SHARE_VALUE * holding, reported) + if "override" in spec + else max(0, reported - market) + + SHARE_VALUE * market + - max(0, reported - holding) + for holding, market, reported, absent, spec in zip( + holdings, market_holdings, explicit, missing, specs + ) if not absent ) assert row.sum() + owner_substitution + extra_holding_draw == pytest.approx( @@ -303,7 +332,8 @@ def test_regrouping_holdings_between_holders_and_units_preserves_residual_pool() savings, ) ) - expected.append(savings + max(0, corporate - total)) + # Shares left after the holdings count at 90% (reg. 49(1)(a)). + expected.append(savings + SHARE_VALUE * max(0, corporate - total)) situation, residual_rows = _pool_property_situation(cases) capital = np.asarray( Simulation(situation=situation).calculate("uc_assessable_capital", YEAR) diff --git a/policyengine_uk/tests/test_uc_company_owner_properties.py b/policyengine_uk/tests/test_uc_company_owner_properties.py index beafcec75b..8f383dd15d 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) @@ -598,9 +624,7 @@ def test_property_company_gives_no_earnings(unit_list, share): np.testing.assert_allclose(a, b, rtol=0, atol=1e-6, err_msg=v) -@PROPERTY_SETTINGS -@given(st.lists(st.tuples(units(), units()), min_size=1, max_size=3)) -@example( +PINNED_ALLOCATIONS = [ [ ( pinned_unit( @@ -610,9 +634,36 @@ def test_property_company_gives_no_earnings(unit_list, share): ), pinned_unit(pinned_adult()), ) - ] -) -def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs): + ], + [ + ( + pinned_unit( + pinned_adult(), + pinned_adult(), + corporate_wealth=6392.862111833667, + ), + pinned_unit( + pinned_adult( + pinned_company(owned_company_holding_value=6336.697665836761) + ), + pinned_adult(), + ), + ) + ], + [ + ( + 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 _allocation_outputs(pairs): + """Return UC capital and the oracle's unit and household totals.""" # 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 # reports its own capital, so every owner's holding leaves the household @@ -621,7 +672,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 +685,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,17 +696,61 @@ 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} ) capital = np.asarray(sim.calculate("uc_assessable_capital", YEAR)) + return capital, expected_units, expected_households + + +@PROPERTY_SETTINGS +@given(st.lists(st.tuples(units(), units()), min_size=1, max_size=3)) +@example(PINNED_ALLOCATIONS[0]) +@example(PINNED_ALLOCATIONS[1]) +@example(PINNED_ALLOCATIONS[2]) +def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs): + capital, expected_units, expected_households = _allocation_outputs(pairs) assert (capital >= 0).all() + if pairs in PINNED_ALLOCATIONS: + # Each pinned unit's capital equals the penny-rounded oracle exactly + # once the test converts the oracle to float32, so a missing final + # rounding or a lost penny fails. The household totals are sums of + # these exact unit values, so the test checks no separate total here. + assert capital.dtype == np.float32 + assert capital.tolist() == np.float32(expected_units).tolist() + return for got, want in zip(capital, expected_units): assert close(got, want) per_household = capital.reshape(-1, 2).sum(axis=1) 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..a712f62d80 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,11 +366,17 @@ 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)]) -def test_mock_pension_credit_keeps_its_existing_reported_capital_contract( - tmp_path, reported, expected +# Pension Credit retains property at 90% alongside financial-only reports +# (SPC Regs 2002 reg. 19(a)): the report plus 45,000, or 6,000 + 45,000 +# when no financial capital is reported. +@pytest.mark.parametrize( + "reported, expected_report, expected", + [(6_000, -1, 51_000), (0, -1, 45_000), (-1, -1, 51_000)], +) +def test_mock_pension_credit_financial_reports_preserve_property( + tmp_path, reported, expected_report, expected ): - """Changing UC's financial boundary does not change Pension Credit.""" + """A financial observation excludes the separately imputed property.""" simulation = _load_mock( tmp_path, _mock_year( @@ -382,7 +391,10 @@ def test_mock_pension_credit_keeps_its_existing_reported_capital_contract( ) assert simulation.calculate("uc_assessable_capital", YEAR)[0] == 0 - assert simulation.calculate("pension_credit_reported_capital", YEAR)[0] == reported + assert ( + simulation.calculate("pension_credit_reported_capital", YEAR)[0] + == expected_report + ) assert ( simulation.calculate("pension_credit_assessable_capital", YEAR)[0] == expected ) diff --git a/policyengine_uk/utils/capital_valuation.py b/policyengine_uk/utils/capital_valuation.py new file mode 100644 index 0000000000..51ba30df37 --- /dev/null +++ b/policyengine_uk/utils/capital_valuation.py @@ -0,0 +1,203 @@ +"""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, *, sources=None): + """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``). + + ``sources`` can restrict the valuation to a subset of the configured + sources, for example property retained alongside a financial-only report. + """ + total = 0 + selected_sources = capital.sources if sources is None else sources + for source in selected_sources: + if source not in capital.sources: + continue + 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 c04b516bcb..06d0e517a5 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -77,6 +77,7 @@ "pip_m_points", "local_bus_trips", "local_bus_uncapped_single_fare", + "lifetime_isa_countable_capital", "miscellaneous_income_trading_allowance_deduction", "monthly_domestic_energy_consumption", "ni_class_4_losses_brought_forward", diff --git a/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py b/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py index 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..e25321f353 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,15 +13,18 @@ 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 " "claimant or partner (is_claimant_or_partner): a dependant's capital " - "is not the claimant's. Where `pension_credit_reported_capital` records " - "the benefit unit's own capital (0 or more), it replaces the " - "household proxy and the person-level sources. Unlike " - "`uc_assessable_capital`, another benefit unit's recorded capital is " + "is not the claimant's. An explicit nonnegative " + "pension_credit_reported_capital replaces the whole assessment. " + "Otherwise a nonnegative benunit_reported_capital replaces financial " + "household and person-level proxies, retaining separately valued " + "land and property with the same pension-age-adult allocation. " + "Unlike `uc_assessable_capital`, another benefit unit's recorded capital is " "not subtracted from the household capital that an unrecorded unit's " "proxy shares out: the recorded figure and the household sources come " "from different measures, so the proxy is left as it was." @@ -28,11 +37,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 +61,28 @@ def formula(benunit, period, parameters): household_capital_proxy = ( household_capital * benunit_pension_age_adults / adult_divisor ) - reported_capital = benunit("pension_credit_reported_capital", period) + household_property = valued_capital( + lambda variable: household(variable, period), + p, + sources=p.property_sources, + ) + property_share = household_property * benunit_pension_age_adults / adult_divisor + # Combine in float64 before final rounding. An intermediate reported + # total stored in float32 can reduce large property values by one ULP. + financial_report = exact_amount(benunit("benunit_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, + where( + financial_report >= 0, + financial_report + property_share, + household_capital_proxy + person_capital, + ), + ) + # 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 ) - return where(any_pension_age, max_(0, assessed_capital), 0) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py index 58fcbcf789..adb0e7b784 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py @@ -6,26 +6,33 @@ class pension_credit_reported_capital(Variable): entity = BenUnit label = "reported Pension Credit capital" documentation = ( - "Countable capital of the claimant and partner as recorded for this " - "benefit unit. Pension Credit counts the claimant's capital, and the " - "State Pension Credit Act 2002 s. 5 treats the partner's as the " - "claimant's, which a benefit-unit capital record covers, so this is " - "the benefit unit's recorded capital (benunit_reported_capital) unless " - "set directly. When this is 0 or more it replaces every capital source " - "in Pension Credit assessable capital: the household proxy (savings, " - "land, property and share_like_capital) and the person-level sources such " - "as a Lifetime ISA. The model applies neither the Schedule V disregards " - "nor the reg. 19 valuation to it, so it must already be the countable " - "figure, and reforms to the capital source parameters do not reach a " - "benefit unit that records it. Any negative value, including -1 when " - "nothing is recorded, means the household proxy applies. 0 records no " - "capital." + "Explicit complete countable-capital override for the claimant and " + "partner together. A nonnegative value, including zero, replaces " + "household and person-level capital sources. It must already reflect " + "the regulation 19 valuation and Schedule V disregards. The default " + "-1 means no complete override. In that case a nonnegative " + "benunit_reported_capital replaces only financial proxies, while " + "separately valued land and property remain counted; with neither " + "report the household proxy and person-level sources apply. This " + "variable does not derive from the financial-only observation. A " + "value that a user sets in a situation applies only in the years the " + "user sets it for. The model uprates a value that a dataset stores in " + "this column into each later year it projects the dataset to, and the " + "formula returns -1 after the last of those years. " + "If a dataset stores a financial-only survey total, such as the " + "Family Resources Survey's TOTCAPB4, in this column rather than in " + "benunit_reported_capital, the pension_credit_assessable_capital " + "formula treats that total as a complete override and counts none of " + "the household's land or property." ) definition_period = YEAR unit = GBP quantity_type = STOCK default_value = -1 - reference = "https://www.legislation.gov.uk/ukpga/2002/16/section/5" + reference = ( + "https://www.legislation.gov.uk/ukpga/2002/16/section/5", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/19", + ) def formula(benunit, period, parameters): - return benunit("benunit_reported_capital", period) + return -1 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..4b3e7816ca 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): @@ -10,7 +20,8 @@ class uc_assessable_capital(Variable): "uc_reported_capital, including zero, replaces ordinary capital sources " "before the existing regulation 77 company substitution. Otherwise " "a nonnegative benunit_reported_capital replaces financial household " - "and person-level proxies only; it does not replace countable property. " + "and person-level proxies; it does not replace separately valued " + "domestic, overseas or unclassified other property. " "Complete overrides, otherwise observed financial totals, reduce the " "household financial pool before " "its residual is allocated by claimant/partner counts to units with " @@ -26,7 +37,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 +60,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 +90,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 +100,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_property_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_other_property_capital.py new file mode 100644 index 0000000000..5ab875fcf4 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_other_property_capital.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import less_sale_expenses + + +class uc_other_property_capital(Variable): + value_type = float + entity = Household + label = "Universal Credit net unclassified other property capital" + documentation = ( + "Assessment-year value of unclassified other real estate less regulation " + "49 sale expenses. Assumes no Schedule 10 disregard; the broad input " + "does not identify asset-level disregards or secured debt. Valued " + "separately from financial reports and allocated through uc_property_capital." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/49" + + def formula(household, period, parameters): + p = parameters(period).gov.dwp.universal_credit.means_test.capital + return less_sale_expenses( + max_(0, household("other_property_value", period)), + p.sale_expenses.rate, + p.round_down, + ) 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_overseas_property_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_overseas_property_capital.py new file mode 100644 index 0000000000..bf696151d7 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_overseas_property_capital.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import less_sale_expenses + + +class uc_overseas_property_capital(Variable): + value_type = float + entity = Household + label = "Universal Credit net overseas property capital" + documentation = ( + "Assessment-year value of overseas land and property less regulation " + "49 sale expenses. Assumes no Schedule 10 disregard; the broad input " + "does not identify asset-level disregards or secured debt. Valued " + "separately from financial reports and allocated through uc_property_capital." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/49" + + def formula(household, period, parameters): + p = parameters(period).gov.dwp.universal_credit.means_test.capital + return less_sale_expenses( + max_(0, household("overseas_property_value", period)), + p.sale_expenses.rate, + p.round_down, + ) 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/household/wealth/benunit_reported_capital.py b/policyengine_uk/variables/household/wealth/benunit_reported_capital.py index f99b7c3d78..65401cb11a 100644 --- a/policyengine_uk/variables/household/wealth/benunit_reported_capital.py +++ b/policyengine_uk/variables/household/wealth/benunit_reported_capital.py @@ -14,7 +14,9 @@ class benunit_reported_capital(Variable): "This financial observation excludes property. Universal Credit uses " "it instead of financial savings, investments and covered Lifetime " "ISA proxies, while retaining countable property separately. Pension " - "Credit retains its existing treatment via pension_credit_reported_capital. " + "Credit also counts separately valued property alongside this report; " + "setting pension_credit_reported_capital directly " + "supplies a complete override. " "Survey financial totals approximate countable financial capital: " "asset-specific disregards and valuations are not observed. Supply " "uc_reported_capital directly for a complete UC countable-capital " diff --git a/policyengine_uk/variables/household/wealth/household_land_value.py b/policyengine_uk/variables/household/wealth/household_land_value.py index 7557b2a842..e93ffd6597 100644 --- a/policyengine_uk/variables/household/wealth/household_land_value.py +++ b/policyengine_uk/variables/household/wealth/household_land_value.py @@ -5,7 +5,12 @@ class household_land_value(Variable): entity = Household label = "Land value" - documentation = "Estimated total land value directly owned by the household" + documentation = ( + "Estimated UK land value: regional land intensity applied to property " + "excluding direct UK land and identified overseas holdings, plus " + "direct UK land at full value. Unclassified other real estate retains " + "the regional approximation because its geography is not identified." + ) unit = GBP definition_period = YEAR value_type = float @@ -14,7 +19,15 @@ class household_land_value(Variable): def formula(household, period, parameters): region = household("region", period) Region = region.possible_values - property_wealth = household("property_wealth", period) + domestic_property = add( + household, + period, + [ + "residential_property_value", + "non_residential_property_value", + "other_property_value", + ], + ) owned_land = household("owned_land", period) regional = parameters( @@ -53,4 +66,4 @@ def formula(household, period, parameters): default=regional.UNKNOWN, ) - return property_wealth * property_wealth_intensity + owned_land + return domestic_property * property_wealth_intensity + owned_land diff --git a/policyengine_uk/variables/household/wealth/property_wealth.py b/policyengine_uk/variables/household/wealth/property_wealth.py index 2ea53aa28c..3cf876685d 100644 --- a/policyengine_uk/variables/household/wealth/property_wealth.py +++ b/policyengine_uk/variables/household/wealth/property_wealth.py @@ -3,10 +3,21 @@ class property_wealth(Variable): label = "Property wealth" - documentation = "Total property wealth across all owned properties" + documentation = ( + "Worldwide gross value of all owned property, including direct UK " + "land, overseas land and property, and unclassified other real estate. " + "Sale expenses and secured debt are considered separately when " + "valuing countable benefit capital." + ) entity = Household definition_period = YEAR value_type = float unit = GBP quantity_type = STOCK - adds = ["residential_property_value", "non_residential_property_value"] + adds = [ + "residential_property_value", + "non_residential_property_value", + "owned_land", + "overseas_property_value", + "other_property_value", + ] diff --git a/policyengine_uk/variables/household/wealth/residential_property_value.py b/policyengine_uk/variables/household/wealth/residential_property_value.py index b97fa30ac1..82ac87dede 100644 --- a/policyengine_uk/variables/household/wealth/residential_property_value.py +++ b/policyengine_uk/variables/household/wealth/residential_property_value.py @@ -3,7 +3,12 @@ class residential_property_value(Variable): label = "Residential property value" - documentation = "Total value of all owned residential property" + documentation = ( + "Gross value of domestic residential property: the main residence " + "plus other residential property, which combines second homes and " + "buy-to-let holdings. Overseas and unclassified other real estate " + "cannot be classified as residential from their aggregate values." + ) entity = Household definition_period = YEAR value_type = float 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_property_value.py b/policyengine_uk/variables/input/other_property_value.py new file mode 100644 index 0000000000..eced2715ec --- /dev/null +++ b/policyengine_uk/variables/input/other_property_value.py @@ -0,0 +1,17 @@ +from policyengine_uk.model_api import * + + +class other_property_value(Variable): + label = "Other property value" + documentation = ( + "Gross value of other real estate with unspecified geography and " + "property type. Included in worldwide property wealth and the regional " + "property-to-land approximation, but not UK property purchase values." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = STOCK + default_value = 0 + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" 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/overseas_property_value.py b/policyengine_uk/variables/input/overseas_property_value.py new file mode 100644 index 0000000000..f7ff16653e --- /dev/null +++ b/policyengine_uk/variables/input/overseas_property_value.py @@ -0,0 +1,17 @@ +from policyengine_uk.model_api import * + + +class overseas_property_value(Variable): + label = "Overseas property value" + documentation = ( + "Gross value of overseas land and property, including time-shares. " + "Included in worldwide property wealth and excluded from UK land " + "estimates and UK property purchase values." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = STOCK + default_value = 0 + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" diff --git a/policyengine_uk/variables/input/owned_land_secured_debt.py b/policyengine_uk/variables/input/owned_land_secured_debt.py new file mode 100644 index 0000000000..18d93d7c0b --- /dev/null +++ b/policyengine_uk/variables/input/owned_land_secured_debt.py @@ -0,0 +1,25 @@ +from policyengine_uk.model_api import * + + +class owned_land_secured_debt(Variable): + value_type = float + entity = Household + label = "Debt secured on owned land" + documentation = ( + "Outstanding encumbrances secured specifically on the household's " + "land-only plots. Excludes main-home mortgage debt and unsecured debt. " + "Deducted after the 10% for sale expenses in the capital valuation of " + "the legacy means tests (HB, IS, JSA, ESA and Pension Credit), " + "flooring the asset class at nil. UC counts no land, so this input " + "leaves UC capital unchanged. The value and the debt are household " + "totals across the class, so debt on one asset offsets equity in " + "another. The survey lacks reliable property-specific " + "debt flags, so the default zero is a modelling approximation unless " + "supplied explicitly." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + default_value = 0 + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" + reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/49/1"