diff --git a/changelog.d/corporate-sdlt-once.fixed.md b/changelog.d/corporate-sdlt-once.fixed.md new file mode 100644 index 0000000000..6a70074688 --- /dev/null +++ b/changelog.d/corporate-sdlt-once.fixed.md @@ -0,0 +1 @@ +- Count corporate SDLT once in `household_tax`, `gov_tax` and `pre_budget_change_household_tax`. `expected_sdlt` no longer adds `corporate_sdlt`, which those totals already list. `gov.hmrc.stamp_duty.abolish` now zeroes `corporate_sdlt` and the `stamp_duty_land_tax` liability (no household is `sdlt_liable` once the tax is abolished) as well as `expected_sdlt`, so abolishing SDLT removes all of it. `expected_sdlt` also no longer adds `property_sale_rate` (a 0.045 share) to every household as £0.045 of SDLT. diff --git a/changelog.d/fiscal-partition.changed.md b/changelog.d/fiscal-partition.changed.md new file mode 100644 index 0000000000..0d5a3a9250 --- /dev/null +++ b/changelog.d/fiscal-partition.changed.md @@ -0,0 +1 @@ +- Give corporate SDLT its own head in `policyengine_uk/data/fiscal_partition.yaml` now that the totals count it once, and empty `model_double_counts`. Each tax head now names its row of the OBR current receipts table (EFO November 2025, Table A.5) and, where the March 2026 EFO's Table 3.1 footnotes place it, that table's line. The file also lists the receipt rows the model does not cover, more exclusions (tax assessments, wage-change measures, income tax reductions) and more overlap guards. New tests check on simulated households that each tax total is the sum of its heads and counts each head once. diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index 169920271d..3ae9f9fef0 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -1,27 +1,24 @@ # Fiscal partition: how the model's tax and spending totals divide into heads. # -# Each head is one receipt (an OBR current-receipts line, split by who pays -# where the model splits it) and names the one variable that measures the -# household's share of it. Heads are mutually exclusive: no head's value -# contains another head's value, and no variable is summed twice in a total. +# Each tax head is one receipt (a row of the OBR's current receipts table, +# split by who pays or where it is charged when the model splits it) and +# names the one variable that measures the household's share of it. Heads +# are mutually exclusive: no head's value contains another head's value, and +# no variable is summed twice in a total. # # The heads were specified from the OBR receipts classification and from each # variable's formula, not copied from the model's own lists # (GOV_TAX_VARIABLES, HOUSEHOLD_TAX_VARIABLES, -# PRE_BUDGET_CHANGE_HOUSEHOLD_TAX_VARIABLES). +# PRE_BUDGET_CHANGE_HOUSEHOLD_TAX_VARIABLES). policyengine_uk/tests/ +# test_fiscal_partition.py checks the two against each other and checks the +# accounting properties below on simulated households. # policyengine_uk/tests/aggregate_impact/test_aggregate_impact_partition.py -# checks the two against each other without data, and the aggregate-impact -# report checks the accounting properties on the certified dataset. The #2159 -# fix adds tests of them on simulated households. +# checks closure for every total without data, and the aggregate-impact +# report checks the accounting properties on the certified dataset. # # Spending heads divide gov_spending and household_benefits the same way: # one benefit or service per head, one variable each. # -# This version describes main before the #2159 fix: expected_sdlt still adds -# corporate_sdlt, so the household SDLT head carries corporate SDLT too and -# corporate_sdlt is declared under model_double_counts. The fix gives -# corporate_sdlt its own head (sdlt_corporate) and empties that section. -# # Consumers (tests, the per-PR aggregate impact report) read this file # directly. Bump schema_version on any change to field meanings. # @@ -31,8 +28,12 @@ # label sentence-case name # variable the one model variable that measures the head # entity household or person -# obr_line OBR current-receipts line the head belongs to, or -# not_an_obr_current_receipt +# obr_line the row of OBR EFO November 2025 Table A.5 ("Current +# receipts") the head belongs to, or +# not_an_obr_current_receipt (see sources) +# obr_table_3_1 the line of EFO Table 3.1 ("Public sector receipts +# as a share of GDP") that holds it, where the table's +# footnotes say so; null where they don't (see sources) # report_group grouping for reports # payer who bears it in the model: household (the household # or its members pay it directly), employer, consumer @@ -49,17 +50,57 @@ # kind: component (summed or read inside `head`), measure_within (an # analytical decomposition already inside `head` through its inputs), # composite (spans several heads), duplicate (same value as `head`), -# baseline_measure. +# baseline_measure, assessment (a tax amount worked out for a means test, +# a ceiling or a record, not a payment), change_measure (a wage change +# that already sits inside the incomes a head taxes). # model_double_counts[]: known violations still in the model: a variable a # total lists although the head variable `contained_in` already adds it. -# spending_heads[]: as tax_heads, without obr_line and payer, plus -# caseload (default true): whether reports count recipients. +# None since the #2159 fix gave corporate_sdlt its own head. +# uncovered_receipts[]: rows of Table A.5 the model has no household +# measure for. With the heads' obr_line values they account for every +# tax row of the table. # overlap_guards[]: the places double counting could occur, and the -# property that should hold for each. The aggregate-impact report checks -# the geographic_exclusivity guards on the certified dataset; the #2159 -# fix's tests check the others. +# property the tests check for each. The aggregate-impact report also +# checks the geographic_exclusivity guards on the certified dataset. +# spending_heads[]: as tax_heads, without obr_line, obr_table_3_1 and +# payer, plus caseload (default true): whether reports count recipients. schema_version: 1 +sources: + obr_table_3_1: >- + OBR, Economic and fiscal outlook, March 2026, Table 3.1. Its footnotes put + capital gains tax, inheritance tax, property transaction taxes and stamp + taxes on shares in "Capital taxes", and council tax in "PSNB-neutral + receipts". They do not place vehicle excise duty, the licence fee or + Northern Ireland domestic rates. Read 2026-10-11 from + https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ + obr_line: >- + OBR, Economic and fiscal outlook, November 2025, Table A.5 ("Current + receipts"): its tax rows, from "Income tax" to "Other taxes". Footnote 3 + puts the high-value council tax surcharge in "Council tax"; footnote 4 + puts stamp duty land tax, the devolved property transaction taxes and + the annual tax on enveloped dwellings in "Property transaction taxes". + Read 2026-10-11 from + https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/ + council_tax: >- + OBR, "Council tax" (tax by tax, spend by spend): council tax receipts add + a forecast of Northern Irish domestic rates to Great Britain's, and are + net of discounts and reduction schemes. Read 2026-10-11 from + https://obr.uk/forecasts-in-depth/tax-by-tax-spend-by-spend/council-tax/ + student_loans: >- + Student loan repayments are not a row of Table A.5. In the March 2026 + EFO only the interest accrued on the loans enters receipts, in "Interest + and dividend receipts". + independent_specification: >- + A second specification was written from Table A.5 and the variables' + formulas by a reviewer who was not shown the model's lists or this file + (2026-10-11). It chose the same variable for every head here except + where noted on the head, and supplied the uncovered rows and several of + the exclusions and guards. It listed the change measures (vat_change, + the employer NI response incidence and the two unallocated tax changes) + as exclusions from a total of levels; here they are heads of the totals + that use them, zero at current law. + totals: gov_tax: label: Government tax revenue in respect of the household @@ -115,6 +156,7 @@ tax_heads: variable: income_tax entity: person obr_line: Income tax + obr_table_3_1: Income tax report_group: income_tax payer: household totals: [gov_tax, household_tax, pre_budget_change_household_tax] @@ -124,6 +166,7 @@ tax_heads: variable: national_insurance entity: person obr_line: National insurance contributions + obr_table_3_1: NICs report_group: national_insurance payer: household totals: [gov_tax, household_tax, pre_budget_change_household_tax] @@ -133,6 +176,7 @@ tax_heads: variable: ni_employer entity: person obr_line: National insurance contributions + obr_table_3_1: NICs report_group: national_insurance payer: employer totals: [gov_tax] @@ -142,6 +186,7 @@ tax_heads: variable: employer_ni_response_capital_incidence entity: person obr_line: National insurance contributions + obr_table_3_1: NICs report_group: national_insurance payer: corporate incidence totals: [household_tax] @@ -152,6 +197,7 @@ tax_heads: variable: employer_ni_response_consumer_incidence entity: person obr_line: National insurance contributions + obr_table_3_1: NICs report_group: national_insurance payer: consumer incidence totals: [household_tax] @@ -162,6 +208,7 @@ tax_heads: variable: capital_gains_tax entity: person obr_line: Capital gains tax + obr_table_3_1: Capital taxes report_group: capital_taxes payer: household totals: [gov_tax, household_tax, pre_budget_change_household_tax] @@ -171,10 +218,12 @@ tax_heads: variable: student_loan_repayments entity: person obr_line: not_an_obr_current_receipt + obr_table_3_1: null obr_line_note: >- A financial transaction: repayments reduce the student loan book, not borrowing, so they are outside current receipts. The model counts them - in gov_tax and household_tax; reports should show them separately. + in gov_tax and household_tax; reports should show them separately. The + independent specification left them out of both tax totals. report_group: financial_transactions payer: household totals: [gov_tax, household_tax] @@ -186,15 +235,24 @@ tax_heads: variable: vat entity: household obr_line: Value added tax + obr_table_3_1: Value added tax report_group: vat payer: consumer incidence totals: [gov_tax] sign: nonnegative + data_note: >- + Negative for 76 households on policyengine-uk-data 1.58.0 + (enhanced_frs_2024_25) in every year from 2025 to 2030, -£72m weighted + in 2026: full_rate_vat_consumption is negative there because the + dataset's consumption (44 households) or full_rate_vat_expenditure_rate + (32 households) is negative. policyengine-uk-data issue 565. Measured + 2026-10-11. - id: vat_change label: Change in VAT from its baseline variable: vat_change entity: household obr_line: Value added tax + obr_table_3_1: Value added tax report_group: vat payer: consumer incidence totals: [household_tax, pre_budget_change_household_tax] @@ -209,6 +267,7 @@ tax_heads: variable: private_school_vat entity: household obr_line: Value added tax + obr_table_3_1: Value added tax report_group: vat payer: household totals: [gov_tax, household_tax] @@ -232,6 +291,7 @@ tax_heads: variable: fuel_duty entity: household obr_line: Fuel duties + obr_table_3_1: Fuel duties report_group: excise_duties payer: household totals: [gov_tax, household_tax, pre_budget_change_household_tax] @@ -241,6 +301,7 @@ tax_heads: variable: alcohol_duty entity: household obr_line: Alcohol duties + obr_table_3_1: Alcohol and tobacco duties report_group: excise_duties payer: household totals: [gov_tax, household_tax] @@ -248,12 +309,13 @@ tax_heads: data_note: >- Zero for every household on policyengine-uk-data 1.58.0 (enhanced_frs_2024_25) in every year from 2025 to 2030, measured - 2026-10-11. + 2026-10-11. Issue 1872. - id: tobacco_duty label: Tobacco duties variable: tobacco_duty entity: household obr_line: Tobacco duties + obr_table_3_1: Alcohol and tobacco duties report_group: excise_duties payer: household totals: [gov_tax, household_tax] @@ -261,12 +323,13 @@ tax_heads: data_note: >- Zero for every household on policyengine-uk-data 1.58.0 (enhanced_frs_2024_25) in every year from 2025 to 2030, measured - 2026-10-11. + 2026-10-11. Issue 1873. - id: vehicle_excise_duty label: Vehicle excise duties variable: vehicle_excise_duty entity: household obr_line: Vehicle excise duties + obr_table_3_1: null report_group: excise_duties payer: household totals: [gov_tax, household_tax] @@ -274,12 +337,13 @@ tax_heads: data_note: >- Zero for every household on policyengine-uk-data 1.58.0 (enhanced_frs_2024_25) in every year from 2025 to 2030, measured - 2026-10-11. + 2026-10-11. Issue 1874. - id: tv_licence label: TV licence fee variable: tv_licence entity: household obr_line: Licence fee receipts + obr_table_3_1: null report_group: other_receipts payer: household totals: [gov_tax, household_tax, pre_budget_change_household_tax] @@ -298,6 +362,13 @@ tax_heads: variable: council_tax entity: household obr_line: Council tax + obr_line_note: >- + Gross of Council Tax Reduction. The OBR's receipts are net of discounts + and reduction schemes; the model counts the reduction as spending + (council_tax_benefit) instead. Compare with the OBR row net of it. The + independent specification chose the net variable, + council_tax_less_benefit. + obr_table_3_1: PSNB-neutral receipts report_group: local_taxes payer: household totals: [gov_tax, household_tax, pre_budget_change_household_tax] @@ -307,6 +378,7 @@ tax_heads: variable: high_value_council_tax_surcharge entity: household obr_line: Council tax + obr_table_3_1: PSNB-neutral receipts report_group: local_taxes payer: household totals: [gov_tax, household_tax] @@ -316,10 +388,10 @@ tax_heads: variable: domestic_rates entity: household obr_line: Council tax + obr_table_3_1: null obr_line_note: >- - Northern Ireland's domestic rates are its counterpart to council tax; - check the line against the OBR table in use before publishing a - line-by-line comparison. + The OBR adds Northern Irish domestic rates to its council tax receipts + (see sources). report_group: local_taxes payer: household totals: [gov_tax, household_tax, pre_budget_change_household_tax] @@ -329,46 +401,59 @@ tax_heads: variable: business_rates entity: household obr_line: Business rates + obr_table_3_1: Business rates report_group: business_rates payer: corporate incidence totals: [gov_tax, household_tax, pre_budget_change_household_tax] sign: nonnegative - id: sdlt_household - label: Stamp duty land tax (own purchases, and corporate until the #2159 fix) + label: Stamp duty land tax on the household's own purchases variable: expected_sdlt entity: household - obr_line: Stamp duty land tax + obr_line: Property transaction taxes + obr_table_3_1: Capital taxes report_group: property_transaction_taxes - payer: mixed + payer: household + totals: [gov_tax, household_tax, pre_budget_change_household_tax] + sign: nonnegative + - id: sdlt_corporate + label: Stamp duty land tax paid by companies + variable: corporate_sdlt + entity: household + obr_line: Property transaction taxes + obr_table_3_1: Capital taxes + report_group: property_transaction_taxes + payer: corporate incidence totals: [gov_tax, household_tax, pre_budget_change_household_tax] sign: nonnegative - data_note: >- - Before the #2159 fix expected_sdlt adds corporate_sdlt and a stray 0.045 - per household, so this head is both parts of the receipt. - id: land_transaction_tax label: Land transaction tax (Wales) variable: expected_ltt entity: household - obr_line: Land transaction tax + obr_line: Property transaction taxes + obr_table_3_1: Capital taxes report_group: property_transaction_taxes payer: household totals: [gov_tax, household_tax, pre_budget_change_household_tax] sign: nonnegative data_note: >- expected_ltt multiplies an already purchase-drawn liability by - property_sale_rate, discounting it twice (issue 2246). + property_sale_rate, discounting it twice (issue 2246). The independent + specification chose land_transaction_tax for that reason. - id: land_and_buildings_transaction_tax label: Land and buildings transaction tax (Scotland) variable: expected_lbtt entity: household - obr_line: Land and buildings transaction tax + obr_line: Property transaction taxes + obr_table_3_1: Capital taxes report_group: property_transaction_taxes payer: household totals: [gov_tax, household_tax, pre_budget_change_household_tax] sign: nonnegative data_note: >- expected_lbtt multiplies an already purchase-drawn liability by - property_sale_rate, discounting it twice (issue 2246). + property_sale_rate, discounting it twice (issue 2246). The independent + specification chose land_and_buildings_transaction_tax for that reason. # Reform-only heads - id: corporate_incident_tax_change @@ -376,6 +461,7 @@ tax_heads: variable: corporate_incident_tax_revenue_change entity: household obr_line: not_an_obr_current_receipt + obr_table_3_1: null obr_line_note: A lump-sum revenue change allocated by shareholding. report_group: incidence_changes payer: corporate incidence @@ -387,6 +473,7 @@ tax_heads: variable: consumer_incident_tax_revenue_change entity: household obr_line: not_an_obr_current_receipt + obr_table_3_1: null obr_line_note: A lump-sum revenue change allocated by consumption. report_group: incidence_changes payer: consumer incidence @@ -398,6 +485,7 @@ tax_heads: variable: wealth_tax entity: household obr_line: not_an_obr_current_receipt + obr_table_3_1: null obr_line_note: A hypothetical tax. report_group: hypothetical_taxes payer: household @@ -409,6 +497,7 @@ tax_heads: variable: non_primary_residence_wealth_tax entity: household obr_line: not_an_obr_current_receipt + obr_table_3_1: null obr_line_note: A hypothetical tax. report_group: hypothetical_taxes payer: household @@ -420,6 +509,7 @@ tax_heads: variable: LVT entity: household obr_line: not_an_obr_current_receipt + obr_table_3_1: null obr_line_note: A hypothetical tax. report_group: hypothetical_taxes payer: mixed @@ -431,6 +521,7 @@ tax_heads: variable: carbon_tax entity: household obr_line: not_an_obr_current_receipt + obr_table_3_1: null obr_line_note: A hypothetical tax. report_group: hypothetical_taxes payer: mixed @@ -440,7 +531,7 @@ tax_heads: exclusions: # Property transaction taxes - - {variable: stamp_duty_land_tax, kind: component, head: sdlt_household, note: "Summed inside expected_sdlt, which until the #2159 fix also adds corporate_sdlt and a stray 0.045; not zeroed by gov.hmrc.stamp_duty.abolish. Report the head, not this."} + - {variable: stamp_duty_land_tax, kind: component, head: sdlt_household, note: "The liability expected_sdlt reads; zero under gov.hmrc.stamp_duty.abolish because sdlt_liable is then false."} - {variable: sdlt_on_transactions, kind: component, head: sdlt_household} - {variable: sdlt_on_rent, kind: component, head: sdlt_household} - {variable: sdlt_on_residential_property_transactions, kind: component, head: sdlt_household} @@ -461,8 +552,8 @@ exclusions: - {variable: lbtt_on_non_residential_property_transactions, kind: component, head: land_and_buildings_transaction_tax} - {variable: lbtt_on_residential_property_rent, kind: component, head: land_and_buildings_transaction_tax} - {variable: lbtt_on_non_residential_property_rent, kind: component, head: land_and_buildings_transaction_tax} - - {variable: corporate_tax_incidence, kind: composite, spans: [sdlt_household, business_rates]} - - {variable: hbai_excluded_income, kind: composite, spans: [sdlt_household, business_rates], note: "Minus corporate_tax_incidence."} + - {variable: corporate_tax_incidence, kind: composite, spans: [sdlt_corporate, business_rates]} + - {variable: hbai_excluded_income, kind: composite, spans: [sdlt_corporate, business_rates], note: "Minus corporate_tax_incidence."} - {variable: baseline_business_rates, kind: duplicate, head: business_rates, note: "Same formula and current-law parameters as business_rates."} # Income tax and National Insurance - {variable: income_tax_pre_charges, kind: component, head: income_tax} @@ -472,6 +563,18 @@ exclusions: - {variable: property_income_tax, kind: component, head: income_tax} - {variable: CB_HITC, kind: component, head: income_tax} - {variable: personal_pension_contributions_tax, kind: component, head: income_tax} + - {variable: capped_mcad, kind: component, head: income_tax, note: "A tax reduction, subtracted inside income_tax."} + - {variable: married_couples_allowance_deduction, kind: component, head: income_tax, note: "Inside capped_mcad."} + - {variable: other_tax_credits, kind: component, head: income_tax, note: "A tax reduction, subtracted inside income_tax."} + - {variable: property_finance_cost_relief, kind: component, head: income_tax, note: "A tax reduction, subtracted inside income_tax."} + - {variable: basic_rate_earned_income_tax, kind: assessment, head: income_tax, note: "Earned income tax by band; earned_income_tax does not read it."} + - {variable: higher_rate_earned_income_tax, kind: assessment, head: income_tax, note: "As above."} + - {variable: add_rate_earned_income_tax, kind: assessment, head: income_tax, note: "As above."} + - {variable: tax_reported, kind: assessment, head: income_tax, note: "Tax as the survey reports it, an alternative to the modelled liability."} + - {variable: tax_modelling, kind: assessment, head: income_tax, note: "Modelled minus reported tax."} + - {variable: uc_income_tax_on_earnings, kind: assessment, head: income_tax, note: "Income tax on earnings as Universal Credit deducts it; no extra payment."} + - {variable: uc_minimum_income_floor_income_tax, kind: assessment, head: income_tax, note: "Tax on the notional minimum income floor earnings."} + - {variable: child_benefit_less_tax_charge, kind: composite, spans: [income_tax], note: "Child Benefit net of the High Income Child Benefit Charge, which income_tax already includes; never alongside gross Child Benefit and income_tax."} - {variable: ni_employee, kind: measure_within, head: nics_individual, note: "Class 1 employee; national_insurance adds the classes directly."} - {variable: ni_self_employed, kind: measure_within, head: nics_individual, note: "Classes 2 and 4; national_insurance adds the classes directly."} - {variable: ni_class_1_employee, kind: component, head: nics_individual} @@ -480,6 +583,12 @@ exclusions: - {variable: ni_class_2, kind: component, head: nics_individual} - {variable: ni_class_3, kind: component, head: nics_individual} - {variable: ni_class_4, kind: component, head: nics_individual} + - {variable: ni_class_4_main, kind: assessment, head: nics_individual, note: "Class 4 before the annual maximum."} + - {variable: ni_class_4_maximum, kind: assessment, head: nics_individual, note: "The annual ceiling on Class 4, not a payment."} + - {variable: uc_national_insurance_on_earnings, kind: assessment, head: nics_individual, note: "National Insurance on earnings as Universal Credit deducts it; no extra payment."} + - {variable: uc_minimum_income_floor_national_insurance, kind: assessment, head: nics_individual, note: "National Insurance on the notional minimum income floor earnings."} + - {variable: employer_ni_fixed_employer_cost_change, kind: change_measure, head: nics_employer, note: "The wage change from an employer NI reform; employment_income already adds it."} + - {variable: salary_sacrifice_broad_base_haircut, kind: change_measure, head: nics_employer, note: "A wage change employment_income already adds."} - {variable: ni_class_1_employer, kind: component, head: nics_employer} - {variable: salary_sacrifice_pension_ni_employee, kind: measure_within, head: nics_individual, note: "The excess sacrifice is added back to ni_class_1_income, so Class 1 already charges it."} - {variable: salary_sacrifice_pension_ni_employer, kind: measure_within, head: nics_employer, note: "As above, for employer Class 1."} @@ -497,30 +606,23 @@ exclusions: - {variable: other_fermented_duty, kind: component, head: alcohol_duty} - {variable: car_vehicle_excise_duty, kind: component, head: vehicle_excise_duty} # Council tax net of a benefit - - {variable: council_tax_less_benefit, kind: composite, spans: [council_tax], note: "Council tax minus Council Tax Reduction, a benefit; not a receipt."} + - {variable: council_tax_less_benefit, kind: composite, spans: [council_tax], note: "Council tax net of Council Tax Reduction (the OBR's basis). The totals count gross council tax and credit the reduction as spending, so adding this would count council tax twice."} -model_double_counts: - - variable: corporate_sdlt - contained_in: expected_sdlt - totals: [gov_tax, household_tax, pre_budget_change_household_tax] - issue: https://github.com/PolicyEngine/policyengine-uk/issues/2159 +model_double_counts: [] overlap_guards: - id: sdlt_payer_split kind: payer_split - heads: [sdlt_household] + heads: [sdlt_household, sdlt_corporate] property: >- - Until the #2159 fix, expected_sdlt adds corporate_sdlt, which the totals - also list (model_double_counts). The fix splits the head in two, neither - reading the other, both zeroed by gov.hmrc.stamp_duty.abolish. + Neither head reads the other. gov.hmrc.stamp_duty.abolish zeroes both, + so abolition lowers each total by exactly their sum. With a dataset, + weighted sdlt_corporate equals the corporate SDLT revenue statistics. + (Issue 2159: expected_sdlt used to add corporate_sdlt.) - id: property_transaction_tax_geography kind: geographic_exclusivity heads: [sdlt_household, land_transaction_tax, land_and_buildings_transaction_tax] - property: >- - At most one is non-zero for any household (England and Northern Ireland, - Wales, Scotland). Until the #2159 fix this does not hold: expected_sdlt - carries a stray 0.045, and corporate SDLT for shareholders, in every - nation. + property: At most one is non-zero for any household (England and Northern Ireland, Wales, Scotland). - id: local_tax_geography kind: geographic_exclusivity heads: [council_tax, domestic_rates] @@ -537,7 +639,7 @@ overlap_guards: property: The level is only in gov_tax; the response incidence is only in household_tax. - id: corporate_incidence_allocations kind: shared_allocation_key - heads: [business_rates, sdlt_household, corporate_incident_tax_change, carbon_tax, land_value_tax] + heads: [business_rates, sdlt_corporate, corporate_incident_tax_change, carbon_tax, land_value_tax] property: >- Each allocates a different receipt by shareholding; none reads another. corporate_tax_incidence sums two of them and is excluded. @@ -549,6 +651,28 @@ overlap_guards: fees, turning on private_school_vat would tax them twice. The head is zero at baseline, so baseline totals are unaffected; check the consumption imputation before costing a reform that sets it. + - id: council_tax_gross_and_reduction + kind: gross_and_net + heads: [council_tax] + property: >- + The tax totals hold gross council tax, and the spending totals hold + Council Tax Reduction (council_tax_benefit). Never add + council_tax_less_benefit, and compare with the OBR row net of the + reduction. gov.contrib.abolish_council_tax drops council tax from the + tax totals and council_tax_benefit from the spending totals together. + - id: child_benefit_charge + kind: gross_and_net + heads: [income_tax] + property: >- + income_tax includes the High Income Child Benefit Charge, so benefits + must be counted gross of it; child_benefit_less_tax_charge is excluded. + - id: unallocated_budget_changes + kind: level_and_change + heads: [corporate_incident_tax_change, consumer_incident_tax_change] + property: >- + These allocate a lump-sum revenue change. A change in a tax that has + its own head must be made through that head's parameters, not entered + here as well. - id: eligibility_reads kind: read_not_inclusion heads: [tv_licence, private_school_vat] @@ -557,6 +681,27 @@ overlap_guards: eligibility or imputation (see each head's reads). Raising the other head by a small amount leaves them unchanged on the test population. +uncovered_receipts: + # Rows of Table A.5 with no household measure in the model. + - {obr_line: Corporation tax} + - {obr_line: Petroleum revenue tax} + - {obr_line: VAT refunds, note: "A National Accounts adjustment, not a tax on households."} + - {obr_line: Inheritance tax} + - {obr_line: Stamp taxes on shares} + - {obr_line: Air passenger duty} + - {obr_line: Insurance premium tax} + - {obr_line: Climate change levy} + - {obr_line: Bank levy} + - {obr_line: Bank surcharge} + - {obr_line: Apprenticeship levy} + - {obr_line: Digital services tax} + - {obr_line: Other HMRC taxes} + - {obr_line: Environmental levies} + - {obr_line: Emissions Trading Scheme} + - {obr_line: Energy profits levy} + - {obr_line: Electricity generator levy} + - {obr_line: Other taxes} + spending_heads: # Pensioners - id: state_pension diff --git a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_certified.py b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_certified.py index 0831852567..06f9baedcd 100644 --- a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_certified.py +++ b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_certified.py @@ -205,9 +205,9 @@ def numeric_lists(node): def test_geographic_guards_hold_once_declared_double_counts_are_netted(measured): - """Council tax and domestic rates never meet; nor do SDLT, LTT and LBTT - once corporate SDLT, which expected_sdlt carries while #2159 is open, is - netted out of it as a declared double count.""" + """Council tax and domestic rates never meet; nor do SDLT, LTT and LBTT. + Since #2159 expected_sdlt carries no corporate SDLT, so nothing needs + netting here; a declared double count would be netted out of its head.""" result, _, _, _ = measured status = {c["id"]: c["status"] for c in result["checks"]} assert status["overlap_guard:local_tax_geography"] == "pass" diff --git a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py index f4ba983978..20636f0dad 100644 --- a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py +++ b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py @@ -546,7 +546,7 @@ def calculate(self, variable, period, map_to=None, unweighted=True): return np.asarray(self.values.get(variable, np.zeros(self.n)), dtype=float) -def _measured_year(values): +def _measured_year(values, partition=None): from policyengine_uk.utils.aggregate_impact.measure import measure_year from policyengine_uk.utils.aggregate_impact.partition import ( default_partition_path, @@ -557,7 +557,7 @@ def _measured_year(values): rec.scope = "2026" measure_year( StubSim(values), - load_partition(default_partition_path()), + partition or load_partition(default_partition_path()), load_spec(), 2026, rec, @@ -566,6 +566,45 @@ def _measured_year(values): return rec, households +def _corporate_sdlt_double_counted(): + """The shipped partition in its shape before the #2159 fix: expected_sdlt + carries corporate SDLT, declared as a double count rather than a head. + The fix left the model with no double counts, so the tests of how a + declared one is measured use this.""" + import yaml + + from policyengine_uk.utils.aggregate_impact.partition import ( + default_partition_path, + parse_partition, + ) + + with open(default_partition_path()) as f: + raw = yaml.safe_load(f) + totals = next(h for h in raw["tax_heads"] if h["id"] == "sdlt_corporate")["totals"] + raw["tax_heads"] = [h for h in raw["tax_heads"] if h["id"] != "sdlt_corporate"] + + def repoint(ids): + return list( + dict.fromkeys("sdlt_household" if i == "sdlt_corporate" else i for i in ids) + ) + + for exclusion in raw["exclusions"]: + if exclusion.get("spans"): + exclusion["spans"] = repoint(exclusion["spans"]) + if exclusion.get("head") == "sdlt_corporate": + exclusion["head"] = "sdlt_household" + for guard in raw["overlap_guards"]: + guard["heads"] = repoint(guard.get("heads") or []) + raw["model_double_counts"] = [ + { + "variable": "corporate_sdlt", + "contained_in": "expected_sdlt", + "totals": totals, + } + ] + return parse_partition(raw) + + def _first(n, value): out = np.zeros(N) out[:n] = value @@ -589,7 +628,8 @@ def test_a_double_count_from_few_records_is_suppressed_and_left_out(): "household_tax": model_tax, "pre_budget_change_household_tax": model_tax, "gov_balance": model_tax, - } + }, + _corporate_sdlt_double_counted(), ) published = _published(rec) assert published["double_counts.corporate_sdlt"] is None diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/council_tax/high_value_council_tax_surcharge.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/council_tax/high_value_council_tax_surcharge.yaml index 6a8640b25c..638725ae6f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/council_tax/high_value_council_tax_surcharge.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/council_tax/high_value_council_tax_surcharge.yaml @@ -7,7 +7,6 @@ main_residential_property_purchased: 0 council_tax: 0 household_owns_tv: false - gov.hmrc.stamp_duty.property_sale_rate: 0 output: high_value_council_tax_surcharge: 0 - name: A 2028 value just above the first band on the annual 2026 HPI basis is charged @@ -30,7 +29,6 @@ main_residential_property_purchased: 0 council_tax: 0 household_owns_tv: false - gov.hmrc.stamp_duty.property_sale_rate: 0 output: high_value_council_tax_surcharge: 2_500 household_tax: 2_500 @@ -45,7 +43,6 @@ main_residential_property_purchased: 0 council_tax: 0 household_owns_tv: false - gov.hmrc.stamp_duty.property_sale_rate: 0 output: high_value_council_tax_surcharge: 2_550 household_tax: 2_550 @@ -59,7 +56,6 @@ main_residential_property_purchased: 0 council_tax: 0 household_owns_tv: false - gov.hmrc.stamp_duty.property_sale_rate: 0 output: high_value_council_tax_surcharge: 0 household_tax: 0 @@ -74,7 +70,6 @@ main_residential_property_purchased: 0 council_tax: 1_500 household_owns_tv: false - gov.hmrc.stamp_duty.property_sale_rate: 0 output: high_value_council_tax_surcharge: 2_500 household_tax: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/corporate_sdlt.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/corporate_sdlt.yaml new file mode 100644 index 0000000000..5d09e809a0 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/corporate_sdlt.yaml @@ -0,0 +1,168 @@ +# Corporate SDLT is shareholding x (residential + non-residential corporate +# SDLT revenue) = shareholding x (£705m + £2,425m) = shareholding x £3,130m +# (gov.hmrc.stamp_duty.statistics, 2020 values held flat). A shareholding of +# 1e-6 gives £3,130 and 3e-6 gives £9,390. business_rates is set to 0 so the +# totals isolate SDLT. A £500,000 purchase that is not a first home pays +# £15,000 of SDLT (see expected_sdlt.yaml). +- name: Corporate SDLT enters household and government taxes once + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + shareholding: 0.000001 + business_rates: 0 + council_tax: 0 + household_owns_tv: false + output: + corporate_sdlt: 3_130 + expected_sdlt: 0 + corporate_tax_incidence: 3_130 + household_tax: 3_130 + gov_tax: 3_130 + pre_budget_change_household_tax: 3_130 +- name: Household and corporate SDLT each enter the totals once + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + main_residential_property_purchased: 500_000 + main_residential_property_purchased_is_first_home: false + shareholding: 0.000001 + business_rates: 0 + council_tax: 0 + household_owns_tv: false + output: + expected_sdlt: 15_000 + corporate_sdlt: 3_130 + household_tax: 18_130 + gov_tax: 18_130 + pre_budget_change_household_tax: 18_130 +- name: Abolishing SDLT removes household and corporate SDLT from the totals + period: 2026 + absolute_error_margin: 0.01 + input: + gov.hmrc.stamp_duty.abolish: true + region: LONDON + main_residential_property_purchased: 500_000 + main_residential_property_purchased_is_first_home: false + shareholding: 0.000001 + business_rates: 0 + council_tax: 0 + household_owns_tv: false + output: + stamp_duty_land_tax: 0 + expected_sdlt: 0 + corporate_sdlt: 0 + corporate_tax_incidence: 0 + household_tax: 0 + gov_tax: 0 + pre_budget_change_household_tax: 0 +- name: Two households, baseline - each household's SDLT counted once + period: 2026 + absolute_error_margin: 0.01 + input: + people: + buyer: + age: 40 + shareholder: + age: 60 + benunits: + benunit_1: + members: [buyer] + benunit_2: + members: [shareholder] + households: + household_1: + members: [buyer] + region: LONDON + main_residential_property_purchased: 500_000 + main_residential_property_purchased_is_first_home: false + shareholding: 0.000001 + business_rates: 0 + council_tax: 0 + household_owns_tv: false + household_2: + members: [shareholder] + region: SCOTLAND + shareholding: 0.000003 + business_rates: 0 + council_tax: 0 + household_owns_tv: false + output: + expected_sdlt: [15_000, 0] + corporate_sdlt: [3_130, 9_390] + household_tax: [18_130, 9_390] + gov_tax: [18_130, 9_390] +- name: Two households, SDLT abolished - no SDLT left in either household's totals + period: 2026 + absolute_error_margin: 0.01 + input: + gov.hmrc.stamp_duty.abolish: true + people: + buyer: + age: 40 + shareholder: + age: 60 + benunits: + benunit_1: + members: [buyer] + benunit_2: + members: [shareholder] + households: + household_1: + members: [buyer] + region: LONDON + main_residential_property_purchased: 500_000 + main_residential_property_purchased_is_first_home: false + shareholding: 0.000001 + business_rates: 0 + council_tax: 0 + household_owns_tv: false + household_2: + members: [shareholder] + region: SCOTLAND + shareholding: 0.000003 + business_rates: 0 + council_tax: 0 + household_owns_tv: false + output: + expected_sdlt: [0, 0] + corporate_sdlt: [0, 0] + household_tax: [0, 0] + gov_tax: [0, 0] +# Abolition removes SDLT and nothing else. Corporate SDLT reaches shareholders +# wherever they live; business rates, the other corporate tax allocated by +# shareholding, stays. +- name: A Welsh shareholder bears corporate SDLT and business rates, each once + period: 2026 + absolute_error_margin: 0.01 + input: + region: WALES + shareholding: 0.000001 + business_rates: 500 + council_tax: 0 + household_owns_tv: false + output: + expected_sdlt: 0 + corporate_sdlt: 3_130 + corporate_tax_incidence: 3_630 + household_tax: 3_630 + gov_tax: 3_630 + pre_budget_change_household_tax: 3_630 +- name: Abolishing SDLT removes the Welsh shareholder's corporate SDLT and keeps business rates + period: 2026 + absolute_error_margin: 0.01 + input: + gov.hmrc.stamp_duty.abolish: true + region: WALES + shareholding: 0.000001 + business_rates: 500 + council_tax: 0 + household_owns_tv: false + output: + expected_sdlt: 0 + corporate_sdlt: 0 + corporate_tax_incidence: 500 + household_tax: 500 + gov_tax: 500 + pre_budget_change_household_tax: 500 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/expected_sdlt.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/expected_sdlt.yaml new file mode 100644 index 0000000000..97acbc0cdf --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/expected_sdlt.yaml @@ -0,0 +1,82 @@ +# expected_sdlt is the household's own SDLT, with nothing added to it. +# Residential rates from 1 April 2025 (gov.uk/stamp-duty-land-tax/residential-property-rates): +# 0% to £125,000; 2% to £250,000; 5% to £925,000. A £500,000 purchase that is +# not a first home pays 2% x £125,000 + 5% x £250,000 = £15,000. +- name: No purchase and no corporate wealth means no SDLT in the tax totals (England) + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + council_tax: 0 + household_owns_tv: false + output: + stamp_duty_land_tax: 0 + expected_sdlt: 0 + corporate_sdlt: 0 + household_tax: 0 + gov_tax: 0 +- name: No SDLT in the tax totals of a household outside England and Northern Ireland (Scotland) + period: 2026 + absolute_error_margin: 0 + input: + region: SCOTLAND + council_tax: 0 + household_owns_tv: false + output: + expected_sdlt: 0 + household_tax: 0 + gov_tax: 0 +- name: No SDLT in the tax totals of a household outside England and Northern Ireland (Wales) + period: 2026 + absolute_error_margin: 0 + input: + region: WALES + council_tax: 0 + household_owns_tv: false + output: + expected_sdlt: 0 + household_tax: 0 + gov_tax: 0 +- name: Expected SDLT on a purchase equals the SDLT liability + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + main_residential_property_purchased: 500_000 + main_residential_property_purchased_is_first_home: false + council_tax: 0 + household_owns_tv: false + output: + stamp_duty_land_tax: 15_000 + expected_sdlt: 15_000 + household_tax: 15_000 + gov_tax: 15_000 +- name: Abolishing SDLT zeroes the liability and expected SDLT + period: 2026 + absolute_error_margin: 0 + input: + gov.hmrc.stamp_duty.abolish: true + region: LONDON + main_residential_property_purchased: 500_000 + main_residential_property_purchased_is_first_home: false + council_tax: 0 + household_owns_tv: false + output: + sdlt_liable: false + stamp_duty_land_tax: 0 + expected_sdlt: 0 + household_tax: 0 + gov_tax: 0 +- name: Abolishing SDLT zeroes expected SDLT even when the liability is entered directly + period: 2026 + absolute_error_margin: 0 + input: + gov.hmrc.stamp_duty.abolish: true + region: LONDON + stamp_duty_land_tax: 15_000 + council_tax: 0 + household_owns_tv: false + output: + expected_sdlt: 0 + household_tax: 0 + gov_tax: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/sdlt_liable.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/sdlt_liable.yaml index ce809502ff..7ae38e1781 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/sdlt_liable.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/sdlt_liable.yaml @@ -4,3 +4,10 @@ country: [ENGLAND, WALES, SCOTLAND, NORTHERN_IRELAND] output: sdlt_liable: [true, false, false, true] +- name: No household is liable once Stamp Duty is abolished + period: 2026 + input: + gov.hmrc.stamp_duty.abolish: true + country: [ENGLAND, WALES, SCOTLAND, NORTHERN_IRELAND] + output: + sdlt_liable: [false, false, false, false] diff --git a/policyengine_uk/tests/test_fiscal_partition.py b/policyengine_uk/tests/test_fiscal_partition.py new file mode 100644 index 0000000000..95c0ff53ca --- /dev/null +++ b/policyengine_uk/tests/test_fiscal_partition.py @@ -0,0 +1,588 @@ +"""The tax totals against an independently specified tax-head partition. + +``policyengine_uk/data/fiscal_partition.yaml`` divides UK tax receipts into +heads, one OBR current-receipts line (split by payer where the model splits +it) per head, and names the one variable that measures each. It was written +from the OBR classification and the variables' formulas, not from the +model's lists, so these tests compare two specifications: + +1. Schema: ids and variables are unique, every reference resolves, and no + variable is both a head and an exclusion. Every tax row of the OBR's + current receipts table (EFO November 2025, Table A.5) is either a head's + ``obr_line`` or listed under ``uncovered_receipts``, never both. +2. Differential: for each total, the heads the partition assigns to it are + exactly the variables the model sums (``GOV_TAX_VARIABLES``, + ``HOUSEHOLD_TAX_VARIABLES``, ``PRE_BUDGET_CHANGE_HOUSEHOLD_TAX_VARIABLES``), + each listed once. +3. Overlap guard (traced): within a total, no head's formula reaches another + head of that total unless the partition declares the read (an + eligibility or imputation route) under the head's ``reads``; every + declared read occurs. Component and baseline-measure exclusions are reached + from their head; composite, duplicate and measure-within exclusions are + reached from no head. (Assessment and change-measure exclusions may be + read by means tests and incomes, so only the differential test binds them.) +4. Coefficient one (perturbation): raising one head by delta, with every + other input unchanged, raises each total that includes it by exactly + delta per member and leaves every other head of those totals unchanged. + A head counted twice, as corporate SDLT was through ``expected_sdlt`` + (#2159), moves its totals by 2 x delta. +5. Identity (property-based): on random households, years 2025-2030, and + under SDLT abolition and council tax abolition, each total equals the sum + of its heads, less the heads ``dropped_when`` names. Heads marked + ``sign: nonnegative`` are non-negative and heads marked + ``zero_at_baseline: expected`` are zero at current law; at most one of + SDLT, LTT and LBTT is non-zero per household. + +"Equal" allows float32 rounding: 64 float32 ulps of the largest magnitude +compared, per household. +""" + +from collections import defaultdict +from functools import lru_cache +from pathlib import Path + +import numpy as np +import pytest +import yaml +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +import policyengine_uk +from policyengine_uk import Simulation +from policyengine_uk.system import system +from policyengine_uk.variables.contrib.policyengine.pre_budget_change_household_tax import ( + PRE_BUDGET_CHANGE_HOUSEHOLD_TAX_VARIABLES, +) +from policyengine_uk.variables.gov.gov_tax import GOV_TAX_VARIABLES +from policyengine_uk.variables.gov.hmrc.household_tax import HOUSEHOLD_TAX_VARIABLES + +PARTITION_PATH = ( + Path(policyengine_uk.__file__).parent / "data" / "fiscal_partition.yaml" +) +MODEL_LISTS = { + "gov_tax": GOV_TAX_VARIABLES, + "household_tax": HOUSEHOLD_TAX_VARIABLES, + "pre_budget_change_household_tax": PRE_BUDGET_CHANGE_HOUSEHOLD_TAX_VARIABLES, +} +EXCLUSION_KINDS = { + "component", + "measure_within", + "composite", + "duplicate", + "baseline_measure", + "assessment", + "change_measure", +} +# The tax rows of OBR EFO November 2025 Table A.5, "Current receipts". +OBR_TABLE_A5_TAX_ROWS = { + "Income tax", + "National insurance contributions", + "Value added tax", + "Corporation tax", + "Petroleum revenue tax", + "Fuel duties", + "Business rates", + "Council tax", + "VAT refunds", + "Capital gains tax", + "Inheritance tax", + "Property transaction taxes", + "Stamp taxes on shares", + "Tobacco duties", + "Alcohol duties", + "Air passenger duty", + "Insurance premium tax", + "Climate change levy", + "Bank levy", + "Bank surcharge", + "Apprenticeship levy", + "Digital services tax", + "Other HMRC taxes", + "Vehicle excise duties", + "Licence fee receipts", + "Environmental levies", + "Emissions Trading Scheme", + "Energy profits levy", + "Electricity generator levy", + "Other taxes", +} +OBR_TABLE_3_1_LINES = { + "Income tax", + "NICs", + "Value added tax", + "Onshore corporation tax", + "Capital taxes", + "Business rates", + "Fuel duties", + "Alcohol and tobacco duties", + "PSNB-neutral receipts", + "Other taxes", + "Interest and dividend receipts", + "Other receipts", +} +ULPS = 64 +ALWAYS = "2000-01-01.2100-12-31" + + +@lru_cache(maxsize=None) +def partition() -> dict: + return yaml.safe_load(PARTITION_PATH.read_text()) + + +def heads() -> list: + return partition()["tax_heads"] + + +def head_by_id() -> dict: + return {head["id"]: head for head in heads()} + + +def heads_of(total: str) -> list: + return [head for head in heads() if total in head["totals"]] + + +def tolerance(*arrays) -> np.ndarray: + magnitude = np.max(np.abs(np.stack(arrays)), axis=0) + return ULPS * np.spacing(magnitude.astype(np.float32)).astype(np.float64) + + +# 1. Schema + + +def test_partition_schema(): + p = partition() + assert p["schema_version"] == 1 + # The tax totals are this file's subject; the spending totals and + # spending_heads (the aggregate impact report's) share the file and must + # not borrow tax heads or variables. + assert set(MODEL_LISTS) <= set(p["totals"]) + spending_heads = p.get("spending_heads", []) + spending_totals = {t for head in spending_heads for t in head["totals"]} + assert not spending_totals & set(MODEL_LISTS) + ids = [head["id"] for head in heads()] + variables = [head["variable"] for head in heads()] + assert len(ids) == len(set(ids)), "duplicate head id" + assert len(variables) == len(set(variables)), "a variable is in two heads" + assert not set(variables) & {h["variable"] for h in spending_heads} + assert not set(ids) & {h["id"] for h in spending_heads} + for head in heads(): + variable = system.variables[head["variable"]] + assert head["entity"] == variable.entity.key, head["id"] + assert set(head["totals"]) <= set(MODEL_LISTS), head["id"] + assert head["sign"] in {"nonnegative", "any"}, head["id"] + assert head.get("zero_at_baseline", "expected") == "expected", head["id"] + table_line = head["obr_table_3_1"] + assert table_line is None or table_line in OBR_TABLE_3_1_LINES, head["id"] + if head["obr_line"] == "not_an_obr_current_receipt": + assert table_line is None and head["obr_line_note"], head["id"] + else: + assert head["obr_line"] in OBR_TABLE_A5_TAX_ROWS, head["id"] + assert head["report_group"], head["id"] + for read in head.get("reads", []): + assert read["head"] in head_by_id(), (head["id"], read) + excluded = [x["variable"] for x in p["exclusions"]] + assert len(excluded) == len(set(excluded)), "duplicate exclusion" + assert not set(excluded) & set(variables), "a head variable is also excluded" + for x in p["exclusions"]: + assert x["variable"] in system.variables, x + assert x["kind"] in EXCLUSION_KINDS, x + if x["kind"] == "composite": + assert set(x["spans"]) <= set(ids), x + else: + assert x["head"] in ids, x + for total in MODEL_LISTS: + for switch, dropped in p["totals"][total].get("dropped_when", {}).items(): + system.parameters.get_child(switch) + assert set(dropped) <= {h["variable"] for h in heads_of(total)} + for guard in p["overlap_guards"]: + assert set(guard["heads"]) <= set(ids), guard["id"] + assert p["model_double_counts"] == [] + # Every tax row of Table A.5 is either measured by a head or declared + # uncovered, never both. + covered = {head["obr_line"] for head in heads()} - {"not_an_obr_current_receipt"} + uncovered = [row["obr_line"] for row in p["uncovered_receipts"]] + assert len(uncovered) == len(set(uncovered)) + assert not covered & set(uncovered) + assert covered | set(uncovered) == OBR_TABLE_A5_TAX_ROWS + + +# 2. Differential against the model's lists + + +@pytest.mark.parametrize("total", sorted(MODEL_LISTS)) +def test_partition_heads_are_the_variables_each_total_sums(total): + listed = MODEL_LISTS[total] + assert len(listed) == len(set(listed)), f"{total} lists a variable twice" + specified = {head["variable"] for head in heads_of(total)} + assert set(listed) == specified, ( + f"{total}: listed but not a head: {sorted(set(listed) - specified)}; " + f"a head of it but not listed: {sorted(specified - set(listed))}" + ) + excluded = {x["variable"] for x in partition()["exclusions"]} + assert not set(listed) & excluded + + +# Population for the traced and perturbation tests: every nation, buyers of +# property in each, shareholders, a self-employed person with gains and a +# student loan, a two-adult household, an owner liable for the high value +# council tax surcharge, and an over-75 pensioner (the TV licence route). +# shareholding is set directly: derived from corporate wealth, it would split +# all UK corporate taxes across these few households, and values in the +# billions leave float32 too coarse to see a 1,024 change. + +YEAR = 2028 +PEOPLE = { + "london_buyer": {"age": 45, "employment_income": 90_000}, + "welsh_buyer": {"age": 35, "employment_income": 40_000}, + "scottish_buyer": {"age": 50, "employment_income": 60_000}, + "ni_earner": {"age": 40, "employment_income": 30_000}, + "ni_partner": {"age": 38, "employment_income": 15_000}, + "self_employed": { + "age": 33, + "self_employment_income": 45_000, + "capital_gains": 25_000, + "student_loan_plan": "PLAN_2", + "employment_income": 10_000, + }, + "pensioner": {"age": 80}, +} +HOUSEHOLDS = { + "london": { + "members": ["london_buyer"], + "region": "LONDON", + "main_residential_property_purchased": 600_000, + "additional_residential_property_purchased": 300_000, + "non_residential_property_purchased": 200_000, + "shareholding": 1e-6, + "council_tax": 2_000, + "consumption": 40_000, + "petrol_litres": 900, + "tenure_type": "OWNED_WITH_MORTGAGE", + "main_residence_value": 3_000_000, + }, + "wales": { + "members": ["welsh_buyer"], + "region": "WALES", + "main_residential_property_purchased": 400_000, + "council_tax": 1_800, + "consumption": 25_000, + }, + "scotland": { + "members": ["scottish_buyer"], + "region": "SCOTLAND", + "main_residential_property_purchased": 450_000, + "shareholding": 1e-7, + "council_tax": 1_700, + "consumption": 30_000, + }, + "northern_ireland": { + "members": ["ni_earner", "ni_partner"], + "region": "NORTHERN_IRELAND", + "main_residential_property_purchased": 250_000, + "domestic_rates": 1_200, + "consumption": 28_000, + }, + "north_west": { + "members": ["self_employed"], + "region": "NORTH_WEST", + "shareholding": 4e-8, + "council_tax": 1_600, + "consumption": 20_000, + "petrol_litres": 1_200, + }, + "pensioner": { + "members": ["pensioner"], + "region": "NORTH_EAST", + "council_tax": 1_400, + "consumption": 12_000, + }, +} + + +def fixed_situation(year: int = YEAR) -> dict: + people = { + name: {key: {year: value} for key, value in inputs.items()} + for name, inputs in PEOPLE.items() + } + households, benunits = {}, {} + for name, inputs in HOUSEHOLDS.items(): + members = inputs["members"] + households[name] = { + "members": members, + **{key: {year: value} for key, value in inputs.items() if key != "members"}, + } + benunits[f"{name}_benunit"] = {"members": members} + return {"people": people, "benunits": benunits, "households": households} + + +def household_values(sim: Simulation, variable: str, year: int) -> np.ndarray: + """A variable's annual value per household (person values summed).""" + entity = system.variables[variable].entity.key + if entity == "household": + values = sim.calculate(variable, year) + else: + values = sim.map_result( + sim.calculate(variable, year), entity, "household", how="sum" + ) + return np.asarray(values, dtype=np.float64) + + +# 3. Overlap guard on the traced dependency graph + + +@lru_cache(maxsize=None) +def traced_reads() -> dict: + """Variable name -> names of the variables it reads, at current law. + + The union of two traces of the same households: as a situation, and as + if built from data. Some formulas read further only in a data-built + simulation (private school attendance is imputed from net income only + there, since #1905), and the guards cover both. + """ + children = defaultdict(set) + + def walk(node): + for child in node.children: + children[node.name].add(child.name) + walk(child) + + for built_from_dataset in (False, True): + sim = Simulation(situation=fixed_situation()) + # The flag utils.data_source.built_from_data reads. + sim.built_from_dataset = built_from_dataset + sim.trace = True + for head in heads(): + sim.calculate(head["variable"], YEAR) + for total in MODEL_LISTS: + sim.calculate(total, YEAR) + for tree in sim.tracer.trees: + walk(tree) + return dict(children) + + +def reached_from(variable: str) -> set: + children, seen, stack = traced_reads(), set(), [variable] + while stack: + for child in children.get(stack.pop(), ()): + if child not in seen: + seen.add(child) + stack.append(child) + return seen + + +@pytest.mark.parametrize("total", sorted(MODEL_LISTS)) +def test_no_head_reaches_another_head_of_its_total_undeclared(total): + by_variable = {head["variable"]: head for head in heads_of(total)} + for variable, head in by_variable.items(): + reached = reached_from(variable) & (set(by_variable) - {variable}) + declared = { + head_by_id()[read["head"]]["variable"] for read in head.get("reads", []) + } + undeclared = reached - declared + assert not undeclared, ( + f"{total}: {variable} reaches {sorted(undeclared)}, which {total} " + "also sums; that double counts unless it is an eligibility read " + "declared under the head's reads" + ) + + +def test_declared_reads_occur(): + for head in heads(): + for read in head.get("reads", []): + target = head_by_id()[read["head"]]["variable"] + assert target in reached_from(head["variable"]), ( + f"{head['id']} declares a read of {read['head']} that no " + "longer happens; remove it" + ) + + +def test_exclusions_sit_where_the_partition_says(): + head_variables = {head["variable"] for head in heads()} + for x in partition()["exclusions"]: + readers = {v for v in head_variables if x["variable"] in reached_from(v)} + if x["kind"] in ("component", "baseline_measure"): + owner = head_by_id()[x["head"]]["variable"] + assert owner in readers, f"{x['variable']} is not inside {owner}" + elif x["kind"] in ("composite", "duplicate", "measure_within"): + assert not readers, ( + f"{x['variable']} ({x['kind']}) is read by heads {sorted(readers)}" + ) + + +# 4. Coefficient one + + +DELTA = 1_024.0 + + +@lru_cache(maxsize=None) +def baseline_values() -> dict: + sim = Simulation(situation=fixed_situation()) + names = [head["variable"] for head in heads()] + list(MODEL_LISTS) + return {name: household_values(sim, name, YEAR) for name in names} + + +@lru_cache(maxsize=None) +def baseline_entity_values(variable: str) -> dict: + """A head's baseline values at its own entity, by period.""" + sim = Simulation(situation=fixed_situation()) + if system.variables[variable].definition_period == "month": + periods = [f"{YEAR}-{month:02d}" for month in range(1, 13)] + else: + periods = [str(YEAR)] + return { + period: np.asarray(sim.calculate(variable, period), dtype=np.float64) + for period in periods + } + + +def raise_head(sim: Simulation, variable: str, delta: float) -> None: + """Set a head to its baseline value plus delta a year, per member.""" + values = baseline_entity_values(variable) + for period, base in values.items(): + sim.set_input(variable, period, base + delta / len(values)) + + +def members_per_household(sim: Simulation, variable: str) -> np.ndarray: + if system.variables[variable].entity.key == "household": + return np.ones(len(HOUSEHOLDS)) + return np.asarray(sim.populations["household"].nb_persons(), dtype=float) + + +@pytest.mark.parametrize("head_id", [head["id"] for head in heads()]) +def test_each_head_enters_its_totals_once(head_id): + head = head_by_id()[head_id] + variable = head["variable"] + base = baseline_values() + sim = Simulation(situation=fixed_situation()) + raise_head(sim, variable, DELTA) + after = {name: household_values(sim, name, YEAR) for name in base} + step = DELTA * members_per_household(sim, variable) + + own_tolerance = tolerance(after[variable], base[variable]) + np.testing.assert_array_less( + np.abs(after[variable] - base[variable] - step), own_tolerance + 1e-9 + ) + for total in head["totals"]: + moved = after[total] - base[total] + # Keep the population's magnitudes small enough that float32 can + # resolve the step; otherwise this test would pass vacuously. + assert (tolerance(after[total], base[total]) < DELTA / 8).all(), total + np.testing.assert_array_less( + np.abs(moved - step), + tolerance(after[total], base[total]) + 1e-9, + err_msg=f"{total} moved by {moved} when {variable} rose by {step}", + ) + for other in heads_of(total): + if other["id"] == head_id: + continue + name = other["variable"] + np.testing.assert_array_less( + np.abs(after[name] - base[name]), + tolerance(after[name], base[name]) + 1e-9, + err_msg=f"raising {variable} moved {name}, also in {total}", + ) + for total in MODEL_LISTS: + parts = sum(after[h["variable"]] for h in heads_of(total)) + np.testing.assert_array_less( + np.abs(after[total] - parts), + tolerance(after[total], parts) + 1e-9, + ) + + +# 5. Identity, signs and zero-at-baseline on random households + + +SCENARIOS = { + "baseline": None, + "sdlt_abolished": {"gov.hmrc.stamp_duty.abolish": {ALWAYS: True}}, + "council_tax_abolished": {"gov.contrib.abolish_council_tax": {ALWAYS: True}}, +} +REGIONS = ["LONDON", "NORTH_WEST", "WALES", "SCOTLAND", "NORTHERN_IRELAND"] +amount = lambda top: st.one_of(st.just(0.0), st.floats(1_000, top)) # noqa: E731 +random_household = st.fixed_dictionaries( + { + "region": st.sampled_from(REGIONS), + "age": st.integers(18, 90), + "employment_income": amount(250_000), + "self_employment_income": amount(100_000), + "capital_gains": amount(200_000), + "main_residential_property_purchased": amount(3_000_000), + "shareholding": st.one_of(st.just(0.0), st.floats(1e-9, 1e-5)), + "council_tax": amount(5_000), + "consumption": amount(80_000), + "main_residence_value": amount(5_000_000), + "owner": st.booleans(), + } +) +PERSON_INPUTS = {"age", "employment_income", "self_employment_income", "capital_gains"} + + +def random_situation(drawn: list, year: int) -> dict: + people, benunits, households = {}, {}, {} + for i, inputs in enumerate(drawn): + person = f"person_{i}" + people[person] = {key: {year: inputs[key]} for key in PERSON_INPUTS} + benunits[f"benunit_{i}"] = {"members": [person]} + household = { + key: {year: value} + for key, value in inputs.items() + if key not in PERSON_INPUTS and key != "owner" + } + household["tenure_type"] = { + year: "OWNED_OUTRIGHT" if inputs["owner"] else "RENT_PRIVATELY" + } + households[f"household_{i}"] = {"members": [person], **household} + return {"people": people, "benunits": benunits, "households": households} + + +@settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + drawn=st.lists(random_household, min_size=2, max_size=2), + year=st.integers(2025, 2030), +) +def test_each_total_is_the_sum_of_its_heads(drawn, year): + for scenario, reform in SCENARIOS.items(): + sim = Simulation(situation=random_situation(drawn, year), reform=reform) + values = {h["id"]: household_values(sim, h["variable"], year) for h in heads()} + for total in MODEL_LISTS: + spec = partition()["totals"][total] + dropped = set() + for switch, names in spec.get("dropped_when", {}).items(): + if reform and switch in reform: + dropped |= set(names) + parts = [ + values[h["id"]] for h in heads_of(total) if h["variable"] not in dropped + ] + value = household_values(sim, total, year) + parts_sum = np.sum(parts, axis=0) + np.testing.assert_array_less( + np.abs(value - parts_sum), + tolerance(value, np.sum(np.abs(parts), axis=0)) + 1e-9, + err_msg=f"{scenario}: {total} is not the sum of its heads", + ) + property_taxes = np.stack( + [ + values[i] != 0 + for i in ( + "sdlt_household", + "land_transaction_tax", + "land_and_buildings_transaction_tax", + ) + ] + ) + assert (property_taxes.sum(axis=0) <= 1).all(), scenario + if scenario == "baseline": + for head in heads(): + if head["sign"] == "nonnegative": + assert (values[head["id"]] >= 0).all(), head["id"] + if head.get("zero_at_baseline") == "expected": + np.testing.assert_array_equal(values[head["id"]], 0, head["id"]) + if scenario == "sdlt_abolished": + np.testing.assert_array_equal(values["sdlt_household"], 0) + np.testing.assert_array_equal(values["sdlt_corporate"], 0) diff --git a/policyengine_uk/tests/test_sdlt_counted_once_properties.py b/policyengine_uk/tests/test_sdlt_counted_once_properties.py new file mode 100644 index 0000000000..626f885ee2 --- /dev/null +++ b/policyengine_uk/tests/test_sdlt_counted_once_properties.py @@ -0,0 +1,187 @@ +"""Property-based tests: Stamp Duty Land Tax enters the tax totals once. + +SDLT reaches a household in two parts. ``expected_sdlt`` is the SDLT on the +household's own purchases and leases (``stamp_duty_land_tax``), and +``corporate_sdlt`` is its share, through ``shareholding``, of the SDLT that +corporations pay. ``household_tax``, ``gov_tax`` and +``pre_budget_change_household_tax`` each list both parts, so each part must +appear in them exactly once, and ``gov.hmrc.stamp_duty.abolish`` must remove +both. + +Each example builds a two-household population and simulates it three ways: + +- B: the baseline; +- A: with ``gov.hmrc.stamp_duty.abolish`` set; +- Z: the baseline with both corporate SDLT revenue statistics set to zero. + +The population varies the year, each household's region (inside and outside +England and Northern Ireland), purchases of main, additional and +non-residential property (or none), first-home status, earnings, corporate +wealth (zero or positive in either household) and positive weights. "Equal" +below means equal up to the float32 rounding of the totals (32 float32 ulps +of the largest value compared, per household); "exactly" means bit-equal. + +1. Own SDLT: ``expected_sdlt`` in B is exactly ``stamp_duty_land_tax``, so a + household with no purchase, or outside England and Northern Ireland, has + none. +2. Corporate SDLT once: for each total T, T(B) - T(Z) equals + ``corporate_sdlt`` in B. Counting it twice would make the gap twice that. +3. Abolition removes all of it once: T(B) - T(A) equals + ``stamp_duty_land_tax`` + ``corporate_sdlt`` in B, and T(Z) - T(A) equals + ``stamp_duty_land_tax`` in B, so no corporate SDLT is left after abolition. +4. Abolition zeroes both parts: ``stamp_duty_land_tax``, ``expected_sdlt`` + and ``corporate_sdlt`` are exactly 0 in A, ``corporate_tax_incidence`` in + A equals ``business_rates``, and ``corporate_tax_incidence`` falls by + ``corporate_sdlt`` in B. +5. Weighted totals: when some household has corporate-sector wealth, the + weighted sum of ``corporate_sdlt`` in B is the corporate SDLT revenue + statistic (within a relative 1e-6), and weighted ``gov_tax`` falls under + abolition by weighted ``stamp_duty_land_tax`` plus that revenue, once. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.system import system + +ALWAYS = "2000-01-01.2100-12-31" +ABOLISH_SDLT = {"gov.hmrc.stamp_duty.abolish": {ALWAYS: True}} +NO_CORPORATE_SDLT = { + "gov.hmrc.stamp_duty.statistics.residential.corporate.revenue": {ALWAYS: 0}, + "gov.hmrc.stamp_duty.statistics.non_residential.corporate.revenue": {ALWAYS: 0}, +} +TOTALS = ["household_tax", "gov_tax", "pre_budget_change_household_tax"] +VARIABLES = TOTALS + [ + "stamp_duty_land_tax", + "expected_sdlt", + "corporate_sdlt", + "corporate_tax_incidence", + "business_rates", + "corporate_sector_wealth", + "household_weight", +] +REGIONS = ["LONDON", "NORTH_WEST", "NORTHERN_IRELAND", "SCOTLAND", "WALES"] +ULPS = 32 + +purchase = st.one_of(st.just(0.0), st.floats(40_000, 5_000_000)) + +household = st.fixed_dictionaries( + { + "region": st.sampled_from(REGIONS), + "main_residential_property_purchased": purchase, + "main_residential_property_purchased_is_first_home": st.booleans(), + "additional_residential_property_purchased": purchase, + "non_residential_property_purchased": purchase, + "corporate_wealth": st.one_of(st.just(0.0), st.floats(1_000, 10_000_000)), + "household_weight": st.floats(1, 10_000), + "employment_income": st.floats(0, 200_000), + } +) + + +def situation(households: list, year: int) -> dict: + people, benunits, hh = {}, {}, {} + for i, inputs in enumerate(households): + person, benunit = f"person_{i}", f"benunit_{i}" + people[person] = { + "age": {year: 40 + 20 * i}, + "employment_income": {year: inputs["employment_income"]}, + } + benunits[benunit] = {"members": [person]} + hh[f"household_{i}"] = { + "members": [person], + **{ + name: {year: value} + for name, value in inputs.items() + if name != "employment_income" + }, + } + return {"people": people, "benunits": benunits, "households": hh} + + +def simulate(households: list, year: int, reform=None) -> dict: + sim = Simulation(situation=situation(households, year), reform=reform) + return { + name: np.asarray(sim.calculate(name, year), dtype=np.float64) + for name in VARIABLES + } + + +def tolerance(*arrays) -> np.ndarray: + """Per-household rounding allowance for float32 totals.""" + magnitude = np.max(np.abs(np.stack(arrays)), axis=0) + return ULPS * np.spacing(magnitude.astype(np.float32)).astype(np.float64) + + +def corporate_sdlt_revenue(year: int) -> float: + statistics = system.parameters(str(year)).gov.hmrc.stamp_duty.statistics + return float( + statistics.residential.corporate.revenue + + statistics.non_residential.corporate.revenue + ) + + +@settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + households=st.lists(household, min_size=2, max_size=2), + year=st.sampled_from([2024, 2025, 2026, 2027]), +) +def test_sdlt_is_counted_once_and_abolition_removes_it(households, year): + b = simulate(households, year) + a = simulate(households, year, ABOLISH_SDLT) + z = simulate(households, year, NO_CORPORATE_SDLT) + + sdlt = b["stamp_duty_land_tax"] + corporate = b["corporate_sdlt"] + + # 1. Own SDLT, with nothing added. + np.testing.assert_array_equal(b["expected_sdlt"], sdlt) + + for total in TOTALS: + # 2. Corporate SDLT is in the baseline total once. + np.testing.assert_array_less( + np.abs(b[total] - z[total] - corporate), + tolerance(b[total], z[total], corporate) + 1e-9, + err_msg=f"{total}: corporate SDLT not counted exactly once", + ) + # 3. Abolition removes household and corporate SDLT, once. + np.testing.assert_array_less( + np.abs(b[total] - a[total] - (sdlt + corporate)), + tolerance(b[total], a[total], sdlt + corporate) + 1e-9, + err_msg=f"{total}: abolition did not remove all SDLT once", + ) + np.testing.assert_array_less( + np.abs(z[total] - a[total] - sdlt), + tolerance(z[total], a[total], sdlt) + 1e-9, + err_msg=f"{total}: corporate SDLT left after abolition", + ) + + # 4. Abolition zeroes both parts and the corporate incidence they feed. + np.testing.assert_array_equal(a["stamp_duty_land_tax"], 0) + np.testing.assert_array_equal(a["expected_sdlt"], 0) + np.testing.assert_array_equal(a["corporate_sdlt"], 0) + np.testing.assert_array_equal(a["corporate_tax_incidence"], a["business_rates"]) + np.testing.assert_array_less( + np.abs(b["corporate_tax_incidence"] - a["corporate_tax_incidence"] - corporate), + tolerance(b["corporate_tax_incidence"], corporate) + 1e-9, + ) + + # 5. Weighted, the corporate part is the revenue statistic, once. + weight = b["household_weight"] + if (b["corporate_sector_wealth"] * weight).sum() > 0: + revenue = corporate_sdlt_revenue(year) + assert np.isclose((corporate * weight).sum(), revenue, rtol=1e-6, atol=0) + removed = ((b["gov_tax"] - a["gov_tax"]) * weight).sum() + allowance = (tolerance(b["gov_tax"], a["gov_tax"]) * weight).sum() + assert abs(removed - ((sdlt * weight).sum() + revenue)) <= allowance + ( + 1e-6 * revenue + ) + else: + np.testing.assert_array_equal(corporate, 0) diff --git a/policyengine_uk/tests/test_weighted_incidence.py b/policyengine_uk/tests/test_weighted_incidence.py index 6559b9c05c..9f853a2d00 100644 --- a/policyengine_uk/tests/test_weighted_incidence.py +++ b/policyengine_uk/tests/test_weighted_incidence.py @@ -21,8 +21,9 @@ "gov.hmrc.business_rates.statistics.revenue.NORTHERN_IRELAND": {YEAR: 0}, "gov.hmrc.stamp_duty.statistics.residential.corporate.revenue": {YEAR: 20}, "gov.hmrc.stamp_duty.statistics.non_residential.corporate.revenue": {YEAR: 30}, - # Isolate incidence from the separate expected property-purchase charge. - "gov.hmrc.stamp_duty.abolish": {YEAR: True}, + # No household buys property, so expected_sdlt is zero and household_tax + # holds corporate SDLT once, through corporate_sdlt. Abolishing SDLT would + # zero corporate SDLT too (#2159). "gov.contrib.policyengine.budget.consumer_incident_tax_change": {YEAR: 0.000007}, } diff --git a/policyengine_uk/variables/gov/hmrc/corporate_sdlt.py b/policyengine_uk/variables/gov/hmrc/corporate_sdlt.py index 91e2b68485..9e1195d85e 100644 --- a/policyengine_uk/variables/gov/hmrc/corporate_sdlt.py +++ b/policyengine_uk/variables/gov/hmrc/corporate_sdlt.py @@ -3,14 +3,20 @@ class corporate_sdlt(Variable): label = "Stamp Duty (corporations)" - documentation = "Stamp Duty paid by corporations, incident on this household" + documentation = ( + "Stamp Duty paid by corporations, incident on this household. " + "Zero when Stamp Duty Land Tax is abolished." + ) entity = Household definition_period = YEAR value_type = float unit = GBP def formula(household, period, parameters): - sd = parameters(period).gov.hmrc.stamp_duty.statistics + stamp_duty = parameters(period).gov.hmrc.stamp_duty + if stamp_duty.abolish: + return 0 + sd = stamp_duty.statistics return household("shareholding", period) * ( sd.residential.corporate.revenue + sd.non_residential.corporate.revenue ) diff --git a/policyengine_uk/variables/gov/hmrc/expected_sdlt.py b/policyengine_uk/variables/gov/hmrc/expected_sdlt.py index f15450335b..1b294f6645 100644 --- a/policyengine_uk/variables/gov/hmrc/expected_sdlt.py +++ b/policyengine_uk/variables/gov/hmrc/expected_sdlt.py @@ -3,7 +3,14 @@ class expected_sdlt(Variable): label = "Stamp Duty (expected)" - documentation = "Expected value of Stamp Duty Land Tax" + documentation = ( + "Expected annual Stamp Duty Land Tax on the household's own property " + "purchases and leases. The FRS-based datasets impute an annual " + "purchaser indicator (a seeded draw at the property purchase rate), " + "so this is stamp_duty_land_tax with no further scaling. SDLT paid by " + "corporations and incident on the household is corporate_sdlt, which " + "the tax totals count separately." + ) entity = Household definition_period = YEAR value_type = float @@ -12,7 +19,4 @@ class expected_sdlt(Variable): def formula(household, period, parameters): if parameters(period).gov.hmrc.stamp_duty.abolish: return 0 - return ( - household("stamp_duty_land_tax", period) - + parameters(period).gov.hmrc.stamp_duty.property_sale_rate - ) + household("corporate_sdlt", period) + return household("stamp_duty_land_tax", period) diff --git a/policyengine_uk/variables/gov/hmrc/sdlt_liable.py b/policyengine_uk/variables/gov/hmrc/sdlt_liable.py index e609d02667..2e5e7146a4 100644 --- a/policyengine_uk/variables/gov/hmrc/sdlt_liable.py +++ b/policyengine_uk/variables/gov/hmrc/sdlt_liable.py @@ -3,16 +3,21 @@ class sdlt_liable(Variable): label = "Liable for Stamp Duty" - documentation = "Whether the household is liable for Stamp Duty Land Tax" + documentation = ( + "Whether the household is liable for Stamp Duty Land Tax: it lives in " + "England or Northern Ireland and the tax has not been abolished." + ) entity = Household definition_period = YEAR value_type = bool unit = GBP - def formula(household, period): + def formula(household, period, parameters): country = household("country", period) countries = country.possible_values - return np.isin( + in_england_or_northern_ireland = np.isin( country.decode_to_str(), [countries.ENGLAND.name, countries.NORTHERN_IRELAND.name], ) + abolished = parameters(period).gov.hmrc.stamp_duty.abolish + return in_england_or_northern_ireland & (not abolished)