From 3afd97d8dfd943c01d3e6275110ae28c16d52e1c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:24:41 -0400 Subject: [PATCH 1/2] Stop discounting LTT and LBTT twice (#2246) expected_ltt and expected_lbtt multiplied land_transaction_tax and land_and_buildings_transaction_tax by property_sale_rate (0.045). Those liabilities already charge only the year's purchases (on FRS-based data, the seeded annual purchaser draw) and rent increments, so the sale rate discounted them a second time. Follow #2235's SDLT convention: the purchase draw is the expectation, and each expected value equals its liability. property_sale_rate now has no reader, so remove it and the leftover test overrides of it. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2246.fixed.md | 1 + changelog.d/2246.removed.md | 1 + policyengine_uk/data/fiscal_partition.yaml | 14 ++++++++------ .../gov/hmrc/stamp_duty/property_sale_rate.yaml | 7 ------- .../high_value_council_tax_surcharge.yaml | 7 ------- .../gov/revenue_scotland/expected_lbtt.py | 17 +++++++++++------ .../variables/gov/wra/expected_ltt.py | 16 ++++++++++------ 7 files changed, 31 insertions(+), 32 deletions(-) create mode 100644 changelog.d/2246.fixed.md create mode 100644 changelog.d/2246.removed.md delete mode 100644 policyengine_uk/parameters/gov/hmrc/stamp_duty/property_sale_rate.yaml diff --git a/changelog.d/2246.fixed.md b/changelog.d/2246.fixed.md new file mode 100644 index 0000000000..59540a0fed --- /dev/null +++ b/changelog.d/2246.fixed.md @@ -0,0 +1 @@ +- Stop discounting Land Transaction Tax and Land and Buildings Transaction Tax twice. `expected_ltt` and `expected_lbtt` multiplied `land_transaction_tax` and `land_and_buildings_transaction_tax` by `gov.hmrc.stamp_duty.property_sale_rate` (0.045), but those liabilities are already charged only on the year's purchases and rent increments. Each expected value now equals its liability, the convention `expected_sdlt` follows, so `gov_tax`, `household_tax` and `pre_budget_change_household_tax` count LTT and LBTT at their full value and LTT or LBTT reforms are scored at full value rather than 4.5% of it. diff --git a/changelog.d/2246.removed.md b/changelog.d/2246.removed.md new file mode 100644 index 0000000000..712566d9d6 --- /dev/null +++ b/changelog.d/2246.removed.md @@ -0,0 +1 @@ +- Remove `gov.hmrc.stamp_duty.property_sale_rate`, a 2021 placeholder with no remaining reader once `expected_sdlt`, `expected_ltt` and `expected_lbtt` stopped scaling by it. A reform that sets it now raises an unknown-parameter error instead of silently doing nothing. diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index 84da2e60fd..e5b2577997 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -368,8 +368,9 @@ tax_heads: 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). + expected_ltt is land_transaction_tax, which charges the year's purchases + and rent increments, with no further scaling (issue 2246 removed a + second discount by the property sale rate). - id: land_and_buildings_transaction_tax label: Land and buildings transaction tax (Scotland) variable: expected_lbtt @@ -381,8 +382,9 @@ tax_heads: 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). + expected_lbtt is land_and_buildings_transaction_tax, which charges the year's purchases + and rent increments, with no further scaling (issue 2246 removed a + second discount by the property sale rate). # Reform-only heads - id: corporate_incident_tax_change @@ -467,14 +469,14 @@ exclusions: - {variable: sdlt_on_non_residential_property_transactions, kind: component, head: sdlt_household} - {variable: sdlt_on_residential_property_rent, kind: component, head: sdlt_household} - {variable: sdlt_on_non_residential_property_rent, kind: component, head: sdlt_household} - - {variable: land_transaction_tax, kind: component, head: land_transaction_tax, note: "The liability before the property_sale_rate scaling (issue 2246)."} + - {variable: land_transaction_tax, kind: component, head: land_transaction_tax, note: "Equal to expected_ltt (issue 2246); report the head, not this."} - {variable: ltt_on_transactions, kind: component, head: land_transaction_tax} - {variable: ltt_on_rent, kind: component, head: land_transaction_tax} - {variable: ltt_on_residential_property_transactions, kind: component, head: land_transaction_tax} - {variable: ltt_on_non_residential_property_transactions, kind: component, head: land_transaction_tax} - {variable: ltt_on_residential_property_rent, kind: component, head: land_transaction_tax} - {variable: ltt_on_non_residential_property_rent, kind: component, head: land_transaction_tax} - - {variable: land_and_buildings_transaction_tax, kind: component, head: land_and_buildings_transaction_tax, note: "The liability before the property_sale_rate scaling (issue 2246)."} + - {variable: land_and_buildings_transaction_tax, kind: component, head: land_and_buildings_transaction_tax, note: "Equal to expected_lbtt (issue 2246); report the head, not this."} - {variable: lbtt_on_transactions, kind: component, head: land_and_buildings_transaction_tax} - {variable: lbtt_on_rent, kind: component, head: land_and_buildings_transaction_tax} - {variable: lbtt_on_residential_property_transactions, kind: component, head: land_and_buildings_transaction_tax} diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/property_sale_rate.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/property_sale_rate.yaml deleted file mode 100644 index 9e7c12e606..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/property_sale_rate.yaml +++ /dev/null @@ -1,7 +0,0 @@ -description: This percentage of properties are sold every year. -values: - 2000-01-01: 0.045 -metadata: - unit: /1 - # 4.5% was the value in 2021 and probably is still accurate: TODO add proper calculations for this but seems OK for now. - label: Percentage of properties sold 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 72c6e11539..1b11cb8150 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 @@ -72,7 +72,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 @@ -85,7 +84,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 @@ -99,7 +97,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: Housing association tenants owe nothing @@ -111,7 +108,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 household with no tenure supplied takes the RENT_PRIVATELY default and owes nothing @@ -122,7 +118,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: Ownership condition holds when a reform lowers the first threshold to £1.5m (owner) @@ -135,7 +130,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 - name: Ownership condition holds when a reform lowers the first threshold to £1.5m (private renter) @@ -148,6 +142,5 @@ 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 diff --git a/policyengine_uk/variables/gov/revenue_scotland/expected_lbtt.py b/policyengine_uk/variables/gov/revenue_scotland/expected_lbtt.py index 755b72c1d3..ff60a891c6 100644 --- a/policyengine_uk/variables/gov/revenue_scotland/expected_lbtt.py +++ b/policyengine_uk/variables/gov/revenue_scotland/expected_lbtt.py @@ -3,13 +3,18 @@ class expected_lbtt(Variable): label = "Land and Buildings Transaction Tax (expected)" - documentation = "Expected value of LBTT" + documentation = ( + "Expected annual Land and Buildings Transaction Tax on the " + "household's own property purchases and leases in Scotland. " + "land_and_buildings_transaction_tax is already an annual amount: it " + "charges the prices of properties bought in the year (on the " + "FRS-based datasets, only households in the seeded annual purchaser " + "draw have a main-residence purchase) and the tax on each year's " + "addition to cumulative rent. So this is " + "land_and_buildings_transaction_tax with no further scaling." + ) entity = Household definition_period = YEAR value_type = float unit = GBP - - def formula(household, period, parameters): - property_sale_rate = parameters(period).gov.hmrc.stamp_duty.property_sale_rate - lbtt = household("land_and_buildings_transaction_tax", period) - return property_sale_rate * lbtt + adds = ["land_and_buildings_transaction_tax"] diff --git a/policyengine_uk/variables/gov/wra/expected_ltt.py b/policyengine_uk/variables/gov/wra/expected_ltt.py index 7714d493d1..f730f16849 100644 --- a/policyengine_uk/variables/gov/wra/expected_ltt.py +++ b/policyengine_uk/variables/gov/wra/expected_ltt.py @@ -3,13 +3,17 @@ class expected_ltt(Variable): label = "Land Transaction Tax (expected)" - documentation = "Expected value of Land Transaction Tax" + documentation = ( + "Expected annual Land Transaction Tax on the household's own property " + "purchases and leases in Wales. land_transaction_tax is already an " + "annual amount: it charges the prices of properties bought in the " + "year (on the FRS-based datasets, only households in the seeded " + "annual purchaser draw have a main-residence purchase) and the tax " + "on each year's addition to cumulative rent. So this is " + "land_transaction_tax with no further scaling." + ) entity = Household definition_period = YEAR value_type = float unit = GBP - - def formula(household, period, parameters): - property_sale_rate = parameters(period).gov.hmrc.stamp_duty.property_sale_rate - land_transaction_tax = household("land_transaction_tax", period) - return property_sale_rate * land_transaction_tax + adds = ["land_transaction_tax"] From 7e045639c34ea545a99d5c86dcc5cb9f18fc4975 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:53:46 -0400 Subject: [PATCH 2/2] Test that LTT and LBTT enter the totals once, at full value YAML cases hand-compute LBTT (SSI 2015/126 Tables A and B, first-time buyer relief) and LTT (WSI 2018/128 Tables 1 to 3) on purchases in Scotland and Wales and check expected = liability = the household's tax totals. The Hypothesis test checks expected equals realised and, independently, that zeroing every LTT and LBTT rate lowers each total by exactly the liabilities. A differential test compares the model with the statutory tables; the schedules it has stale (issues 1795, 1796) are strict expected failures. On the #2235 head (41e970b), 9 of the 17 YAML cases and the property test fail; here all pass. The partition's data notes now give the measured 1.58.0 figures. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/data/fiscal_partition.yaml | 18 +- .../gov/revenue_scotland/expected_lbtt.yaml | 147 +++++++ .../land_transaction_tax/expected_ltt.yaml | 137 +++++++ .../test_ltt_lbtt_expected_properties.py | 382 ++++++++++++++++++ 4 files changed, 678 insertions(+), 6 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/gov/revenue_scotland/expected_lbtt.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/wra/land_transaction_tax/expected_ltt.yaml create mode 100644 policyengine_uk/tests/test_ltt_lbtt_expected_properties.py diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index e5b2577997..cf40181b1c 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -368,9 +368,12 @@ tax_heads: totals: [gov_tax, household_tax, pre_budget_change_household_tax] sign: nonnegative data_note: >- - expected_ltt is land_transaction_tax, which charges the year's purchases - and rent increments, with no further scaling (issue 2246 removed a - second discount by the property sale rate). + Equal to land_transaction_tax, with no property sale rate scaling + (issue 2246). On policyengine-uk-data 1.58.0 (enhanced_frs_2024_25), + £0.070bn in 2026, all on main-residence purchases: no Welsh record has + an additional-dwelling or non-residential purchase or a rent charge. + Measured 2026-10-11. The main residential schedule predates the 10 + October 2022 bands (issue 1796). - id: land_and_buildings_transaction_tax label: Land and buildings transaction tax (Scotland) variable: expected_lbtt @@ -382,9 +385,12 @@ tax_heads: totals: [gov_tax, household_tax, pre_budget_change_household_tax] sign: nonnegative data_note: >- - expected_lbtt is land_and_buildings_transaction_tax, which charges the year's purchases - and rent increments, with no further scaling (issue 2246 removed a - second discount by the property sale rate). + Equal to land_and_buildings_transaction_tax, with no property sale rate + scaling (issue 2246). On policyengine-uk-data 1.58.0 + (enhanced_frs_2024_25) in 2026, £0.385bn on main-residence purchases. + Before PR 2241 (issue 2238) it is £2.310bn: the main-residence purchase + flag also charges £15.2bn of other residential property as + additional-dwelling purchases. Measured 2026-10-11. # Reform-only heads - id: corporate_incident_tax_change diff --git a/policyengine_uk/tests/policy/baseline/gov/revenue_scotland/expected_lbtt.yaml b/policyengine_uk/tests/policy/baseline/gov/revenue_scotland/expected_lbtt.yaml new file mode 100644 index 0000000000..3de7a40dd9 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/revenue_scotland/expected_lbtt.yaml @@ -0,0 +1,147 @@ +# expected_lbtt is the household's own LBTT, with no further scaling (issue 2246). +# +# Residential rates: The Land and Buildings Transaction Tax (Tax Rates and Tax +# Bands) (Scotland) Order 2015 (SSI 2015/126) schedule, Table A, in force again +# from 1 April 2021 (the temporary nil band in SSI 2020/215 art. 2 ran to the end +# of 31 March 2021): 0% to £145,000; 2% to £250,000; 5% to £325,000; 10% to +# £750,000; 12% above. +# https://www.legislation.gov.uk/ssi/2015/126/schedule/made +# First-time buyer relief: tax is not chargeable on the first £175,000 +# (Land and Buildings Transaction Tax (Scotland) Act 2013 schedule 4A para 3). +# https://www.legislation.gov.uk/asp/2013/11/schedule/4A +# Non-residential rates: SSI 2015/126 schedule, Table B as substituted by SSI +# 2018/372 from 25 January 2019: 0% to £150,000; 1% to £250,000; 5% above. +# https://www.legislation.gov.uk/ssi/2015/126/schedule +- name: No purchase means no LBTT in the tax totals (Scotland) + period: 2026 + absolute_error_margin: 0 + input: + region: SCOTLAND + council_tax: 0 + household_owns_tv: false + output: + land_and_buildings_transaction_tax: 0 + expected_lbtt: 0 + household_tax: 0 + gov_tax: 0 +- name: Expected LBTT on a main-residence purchase equals the LBTT liability + # £400,000, not a first home: + # 2% x (250,000 - 145,000) = 2,100 + # 5% x (325,000 - 250,000) = 3,750 + # 10% x (400,000 - 325,000) = 7,500 + # Total 13,350. + period: 2026 + absolute_error_margin: 0 + input: + region: SCOTLAND + main_residential_property_purchased: 400_000 + main_residential_property_purchased_is_first_home: false + council_tax: 0 + household_owns_tv: false + output: + lbtt_on_residential_property_transactions: 13_350 + land_and_buildings_transaction_tax: 13_350 + expected_lbtt: 13_350 + household_tax: 13_350 + gov_tax: 13_350 + pre_budget_change_household_tax: 13_350 +- name: Expected LBTT on a purchase above £750,000 includes the 12% band + # £1,000,000, not a first home: + # 2,100 + 3,750 + 10% x (750,000 - 325,000) = 42,500 + # + 12% x (1,000,000 - 750,000) = 30,000 + # Total 78,350. + period: 2026 + absolute_error_margin: 0 + input: + region: SCOTLAND + main_residential_property_purchased: 1_000_000 + main_residential_property_purchased_is_first_home: false + council_tax: 0 + household_owns_tv: false + output: + land_and_buildings_transaction_tax: 78_350 + expected_lbtt: 78_350 + gov_tax: 78_350 +- name: A purchase at the top of the nil band pays no LBTT + period: 2026 + absolute_error_margin: 0 + input: + region: SCOTLAND + main_residential_property_purchased: 145_000 + main_residential_property_purchased_is_first_home: false + council_tax: 0 + household_owns_tv: false + output: + land_and_buildings_transaction_tax: 0 + expected_lbtt: 0 + gov_tax: 0 +- name: A purchase at the top of the 2% band pays 2% of the band + # 2% x (250,000 - 145,000) = 2,100. + period: 2026 + absolute_error_margin: 0 + input: + region: SCOTLAND + main_residential_property_purchased: 250_000 + main_residential_property_purchased_is_first_home: false + council_tax: 0 + household_owns_tv: false + output: + land_and_buildings_transaction_tax: 2_100 + expected_lbtt: 2_100 + gov_tax: 2_100 +- name: First-time buyer relief takes the first £175,000 out of charge + # £200,000 first home: 2% x (200,000 - 175,000) = 500. + period: 2026 + absolute_error_margin: 0 + input: + region: SCOTLAND + main_residential_property_purchased: 200_000 + main_residential_property_purchased_is_first_home: true + council_tax: 0 + household_owns_tv: false + output: + land_and_buildings_transaction_tax: 500 + expected_lbtt: 500 + household_tax: 500 + gov_tax: 500 +- name: Expected LBTT on a non-residential purchase equals the LBTT liability + # £300,000: 1% x (250,000 - 150,000) = 1,000 + # 5% x (300,000 - 250,000) = 2,500 + # Total 3,500. + period: 2026 + absolute_error_margin: 0 + input: + region: SCOTLAND + non_residential_property_purchased: 300_000 + council_tax: 0 + household_owns_tv: false + output: + lbtt_on_non_residential_property_transactions: 3_500 + land_and_buildings_transaction_tax: 3_500 + expected_lbtt: 3_500 + household_tax: 3_500 + gov_tax: 3_500 +- name: No LBTT outside Scotland, even on a purchase (Wales) + period: 2026 + absolute_error_margin: 0 + input: + region: WALES + main_residential_property_purchased: 400_000 + main_residential_property_purchased_is_first_home: false + council_tax: 0 + household_owns_tv: false + output: + land_and_buildings_transaction_tax: 0 + expected_lbtt: 0 +- name: No LBTT outside Scotland, even on a purchase (England) + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + main_residential_property_purchased: 400_000 + main_residential_property_purchased_is_first_home: false + council_tax: 0 + household_owns_tv: false + output: + land_and_buildings_transaction_tax: 0 + expected_lbtt: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/wra/land_transaction_tax/expected_ltt.yaml b/policyengine_uk/tests/policy/baseline/gov/wra/land_transaction_tax/expected_ltt.yaml new file mode 100644 index 0000000000..f8ebae7b0e --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/wra/land_transaction_tax/expected_ltt.yaml @@ -0,0 +1,137 @@ +# expected_ltt is the household's own LTT, with no further scaling (issue 2246). +# +# Rates: The Land Transaction Tax (Tax Bands and Tax Rates) (Wales) Regulations +# 2018 (WSI 2018/128) schedule. +# - Table 1, main residential rates, as at 1 January 2022 (the model samples +# 2022 at 30 April 2022, before the 10 October 2022 substitution by WSI +# 2022/1027): 0% to £180,000; 3.5% to £250,000; 5% to £400,000; 7.5% to +# £750,000; 10% to £1,500,000; 12% above. +# https://www.legislation.gov.uk/wsi/2018/128/schedule/2022-01-01 +# The model's main residential schedule still has these bands after 10 +# October 2022 (issue 1796), so the residential cases are in 2022. +# - Table 2, higher rates, as substituted by WSI 2020/1618 and in force on 30 +# April 2022: 4% to £180,000; 7.5% to £250,000; 9% to £400,000; 11.5% to +# £750,000; 14% to £1,500,000; 16% above. +# - Table 3, non-residential rates, as substituted by WSI 2020/1618 and still +# in force: 0% to £225,000; 1% to £250,000; 5% to £1,000,000; 6% above. +# https://www.legislation.gov.uk/wsi/2018/128/schedule +- name: No purchase means no LTT in the tax totals (Wales) + period: 2026 + absolute_error_margin: 0 + input: + region: WALES + council_tax: 0 + household_owns_tv: false + output: + land_transaction_tax: 0 + expected_ltt: 0 + household_tax: 0 + gov_tax: 0 +- name: Expected LTT on a main-residence purchase equals the LTT liability + # £300,000 in 2022: + # 3.5% x (250,000 - 180,000) = 2,450 + # 5% x (300,000 - 250,000) = 2,500 + # Total 4,950. + period: 2022 + absolute_error_margin: 0 + input: + region: WALES + main_residential_property_purchased: 300_000 + council_tax: 0 + household_owns_tv: false + output: + ltt_on_residential_property_transactions: 4_950 + land_transaction_tax: 4_950 + expected_ltt: 4_950 + household_tax: 4_950 + gov_tax: 4_950 + pre_budget_change_household_tax: 4_950 +- name: Expected LTT on a main-residence purchase above £1.5m includes the 12% band + # £2,000,000 in 2022: + # 2,450 + 5% x (400,000 - 250,000) = 7,500 + # + 7.5% x (750,000 - 400,000) = 26,250 + # + 10% x (1,500,000 - 750,000) = 75,000 + # + 12% x (2,000,000 - 1,500,000) = 60,000 + # Total 171,200. + period: 2022 + absolute_error_margin: 0 + input: + region: WALES + main_residential_property_purchased: 2_000_000 + council_tax: 0 + household_owns_tv: false + output: + land_transaction_tax: 171_200 + expected_ltt: 171_200 + gov_tax: 171_200 +- name: A purchase at the top of the zero rate band pays no LTT + period: 2022 + absolute_error_margin: 0 + input: + region: WALES + main_residential_property_purchased: 180_000 + council_tax: 0 + household_owns_tv: false + output: + land_transaction_tax: 0 + expected_ltt: 0 + gov_tax: 0 +- name: Expected LTT on an additional-dwelling purchase equals the higher-rates liability + # £200,000 in 2022 at the higher rates: + # 4% x 180,000 = 7,200 + # 7.5% x (200,000 - 180,000) = 1,500 + # Total 8,700. + period: 2022 + absolute_error_margin: 0 + input: + region: WALES + additional_residential_property_purchased: 200_000 + council_tax: 0 + household_owns_tv: false + output: + ltt_on_residential_property_transactions: 8_700 + land_transaction_tax: 8_700 + expected_ltt: 8_700 + household_tax: 8_700 + gov_tax: 8_700 +- name: Expected LTT on a non-residential purchase equals the LTT liability + # £1,200,000 in 2026: + # 1% x (250,000 - 225,000) = 250 + # 5% x (1,000,000 - 250,000) = 37,500 + # 6% x (1,200,000 - 1,000,000) = 12,000 + # Total 49,750. + period: 2026 + absolute_error_margin: 0 + input: + region: WALES + non_residential_property_purchased: 1_200_000 + council_tax: 0 + household_owns_tv: false + output: + ltt_on_non_residential_property_transactions: 49_750 + land_transaction_tax: 49_750 + expected_ltt: 49_750 + household_tax: 49_750 + gov_tax: 49_750 +- name: No LTT outside Wales, even on a purchase (Scotland) + period: 2026 + absolute_error_margin: 0 + input: + region: SCOTLAND + non_residential_property_purchased: 1_200_000 + council_tax: 0 + household_owns_tv: false + output: + land_transaction_tax: 0 + expected_ltt: 0 +- name: No LTT outside Wales, even on a purchase (England) + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + non_residential_property_purchased: 1_200_000 + council_tax: 0 + household_owns_tv: false + output: + land_transaction_tax: 0 + expected_ltt: 0 diff --git a/policyengine_uk/tests/test_ltt_lbtt_expected_properties.py b/policyengine_uk/tests/test_ltt_lbtt_expected_properties.py new file mode 100644 index 0000000000..966cf3e64e --- /dev/null +++ b/policyengine_uk/tests/test_ltt_lbtt_expected_properties.py @@ -0,0 +1,382 @@ +"""Property-based tests: LTT and LBTT enter the tax totals once, at full value. + +``land_transaction_tax`` (Wales) and ``land_and_buildings_transaction_tax`` +(Scotland) are already annual: they charge the prices of properties bought +in the year and the tax on each year's addition to cumulative rent. Their +expected values, ``expected_ltt`` and ``expected_lbtt``, are what +``household_tax``, ``gov_tax`` and ``pre_budget_change_household_tax`` sum, +so the expected value must be the liability itself. Issue 2246: both used to +be multiplied by a 4.5% property sale rate, discounting the purchase draw a +second time. + +Each example builds a population of three to five households and simulates +it twice: + +- B: the baseline; +- Z: every LTT and LBTT rate (residential, first-time buyer, additional + dwelling, non-residential and rent schedules) set to zero. + +The population varies the year (2022 to 2030), each household's region +(all four nations), purchases of main, additional and non-residential +property (or none), first-home status, residential and non-residential +rent with cumulative rent, earnings and positive weights. "Equal" 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. Expected is realised: ``expected_ltt`` is exactly ``land_transaction_tax`` + and ``expected_lbtt`` is exactly ``land_and_buildings_transaction_tax``, + for every household, and so also in weighted total. +2. Geography: LTT is zero outside Wales and LBTT is zero outside Scotland, + and both are non-negative. +3. Once, at full value: for each total T, T(B) - T(Z) equals the + liabilities ``land_transaction_tax + land_and_buildings_transaction_tax`` + in B, independently of 1. The old 4.5% scaling made the gap 0.045 times + that; counting either twice would double it. +4. Zeroing the rates removes them: ``expected_ltt`` and ``expected_lbtt`` + are exactly 0 in Z, and SDLT is unchanged, so with 3 nothing else in the + totals moves. + +``test_lbtt_and_ltt_match_the_rate_tables`` is a differential test: it +compares the model's expected LBTT and LTT with an independent marginal-band +calculation from the statutory tables, for the schedules the model has up to +date. ``test_stale_schedules_still_differ_from_the_rate_tables`` records, as +strict expected failures, the schedules it does not (issues 1795 and 1796). +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, event, given, settings +from hypothesis import strategies as st +from policyengine_core.parameters import Parameter + +from policyengine_uk import Simulation +from policyengine_uk.system import system + +ALWAYS = "2000-01-01.2100-12-31" +RATE_ROOTS = ("gov.revenue_scotland.lbtt", "gov.wra.land_transaction_tax") + + +def zero_rate_reform() -> dict: + """Every LTT and LBTT rate, and the additional dwelling supplement, at 0.""" + reform = {} + for root in RATE_ROOTS: + for parameter in system.parameters.get_child(root).get_descendants(): + if not isinstance(parameter, Parameter): + continue + name = parameter.name + if name.endswith(".rate") or name.endswith( + "additional_residence_surcharge" + ): + reform[name] = {ALWAYS: 0} + return reform + + +NO_LTT_OR_LBTT = zero_rate_reform() +TOTALS = ["household_tax", "gov_tax", "pre_budget_change_household_tax"] +VARIABLES = TOTALS + [ + "land_transaction_tax", + "expected_ltt", + "land_and_buildings_transaction_tax", + "expected_lbtt", + "expected_sdlt", + "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)) +rent = st.one_of(st.just(0.0), st.floats(1_000, 400_000)) +cumulative_rent = st.one_of(st.just(0.0), st.floats(1_000, 3_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, + "rent": rent, + "cumulative_residential_rent": cumulative_rent, + "non_residential_rent": rent, + "cumulative_non_residential_rent": cumulative_rent, + "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: 30 + 10 * 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 test_zero_rate_reform_covers_every_schedule(): + # A schedule left out of the reform would leave tax in Z and weaken 3. + names = set(NO_LTT_OR_LBTT) + for schedule in ( + "gov.revenue_scotland.lbtt.residential.rate", + "gov.revenue_scotland.lbtt.residential.first_time_buyer_rate", + "gov.revenue_scotland.lbtt.non_residential", + "gov.revenue_scotland.lbtt.rent", + "gov.wra.land_transaction_tax.residential.primary", + "gov.wra.land_transaction_tax.residential.higher_rate", + "gov.wra.land_transaction_tax.non_residential", + "gov.wra.land_transaction_tax.rent", + ): + brackets = system.parameters.get_child(schedule).brackets + assert brackets, schedule + for i in range(len(brackets)): + assert f"{schedule}[{i}].rate" in names + assert "gov.revenue_scotland.lbtt.residential.additional_residence_surcharge" in ( + names + ) + + +@settings( + max_examples=12, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + households=st.lists(household, min_size=3, max_size=5), + year=st.integers(2022, 2030), +) +def test_ltt_and_lbtt_enter_the_totals_once_at_full_value(households, year): + b = simulate(households, year) + z = simulate(households, year, NO_LTT_OR_LBTT) + regions = np.array([h["region"] for h in households]) + + ltt, lbtt = b["land_transaction_tax"], b["land_and_buildings_transaction_tax"] + + event(f"LTT charged: {bool((ltt > 0).any())}") + event(f"LBTT charged: {bool((lbtt > 0).any())}") + + # 1. Expected is realised, per household and in weighted total. + np.testing.assert_array_equal(b["expected_ltt"], ltt) + np.testing.assert_array_equal(b["expected_lbtt"], lbtt) + weight = b["household_weight"] + assert (b["expected_ltt"] * weight).sum() == (ltt * weight).sum() + assert (b["expected_lbtt"] * weight).sum() == (lbtt * weight).sum() + + # 2. Geography and sign. + np.testing.assert_array_equal(ltt[regions != "WALES"], 0) + np.testing.assert_array_equal(lbtt[regions != "SCOTLAND"], 0) + assert (ltt >= 0).all() and (lbtt >= 0).all() + + # 3. Once, at full value, in every total. + transaction_taxes = ltt + lbtt + for total in TOTALS: + np.testing.assert_array_less( + np.abs(b[total] - z[total] - transaction_taxes), + tolerance(b[total], z[total], transaction_taxes) + 1e-9, + err_msg=f"{total}: LTT and LBTT not counted once at full value", + ) + + # 4. Zeroing the rates removes them and nothing else. + np.testing.assert_array_equal(z["expected_ltt"], 0) + np.testing.assert_array_equal(z["expected_lbtt"], 0) + np.testing.assert_array_equal(z["expected_sdlt"], b["expected_sdlt"]) + + +# Differential: an independent calculation from the statutory rate tables. +# +# LBTT: SSI 2015/126 schedule, Table A (residential, in force from 1 April +# 2021 after SSI 2020/215's temporary nil band ended) and Table B +# (non-residential, as substituted by SSI 2018/372); first-time buyer relief +# takes the first £175,000 out of charge (LBTT(S)A 2013 schedule 4A para 3); +# the additional dwelling supplement is 8% of the relevant consideration from +# 5 December 2024 (LBTT(S)A 2013 schedule 2A para 4(2), as amended by SSI +# 2024/367). +# LTT: WSI 2018/128 schedule. Table 1 (main rates) as at 2022, before WSI +# 2022/1027 substituted it from 10 October 2022, and as substituted; Table 2 +# (higher rates) as substituted by WSI 2020/1618, and by WSI 2024/1311 from +# 11 December 2024; Table 3 (non-residential) as substituted by WSI +# 2020/1618, still in force. +LBTT_RESIDENTIAL = [(0, 0), (145_000, 0.02), (250_000, 0.05), (325_000, 0.10)] +LBTT_RESIDENTIAL += [(750_000, 0.12)] +LBTT_FIRST_TIME_BUYER = [(0, 0), (175_000, 0.02), (250_000, 0.05)] +LBTT_FIRST_TIME_BUYER += [(325_000, 0.10), (750_000, 0.12)] +LBTT_NON_RESIDENTIAL = [(0, 0), (150_000, 0.01), (250_000, 0.05)] +LBTT_SUPPLEMENT_FROM_2024_12_05 = 0.08 +LTT_MAIN_2022 = [(0, 0), (180_000, 0.035), (250_000, 0.05), (400_000, 0.075)] +LTT_MAIN_2022 += [(750_000, 0.10), (1_500_000, 0.12)] +LTT_MAIN_FROM_2022_10_10 = [(0, 0), (225_000, 0.06), (400_000, 0.075)] +LTT_MAIN_FROM_2022_10_10 += [(750_000, 0.10), (1_500_000, 0.12)] +LTT_HIGHER_2022 = [(0, 0.04), (180_000, 0.075), (250_000, 0.09)] +LTT_HIGHER_2022 += [(400_000, 0.115), (750_000, 0.14), (1_500_000, 0.16)] +LTT_HIGHER_FROM_2024_12_11 = [(0, 0.05), (180_000, 0.085), (250_000, 0.10)] +LTT_HIGHER_FROM_2024_12_11 += [(400_000, 0.125), (750_000, 0.15), (1_500_000, 0.17)] +LTT_NON_RESIDENTIAL = [(0, 0), (225_000, 0.01), (250_000, 0.05)] +LTT_NON_RESIDENTIAL += [(1_000_000, 0.06)] + +PRICES = [0, 40_000, 145_000, 150_000, 175_000, 180_000, 225_000, 250_000] +PRICES += [251_000, 325_000, 400_000, 612_345, 750_000, 1_000_000, 1_500_000] +PRICES += [2_750_000] +# The higher rates and the supplement are compared only at £145,000 and over: +# the rate tables say nothing about the minimum consideration below which a +# transaction is not a higher-rates or supplement transaction. +HIGHER_RATE_PRICES = [price for price in PRICES if price >= 145_000] + + +def marginal_tax(bands: list, price: float) -> float: + tax = 0.0 + for i, (threshold, rate) in enumerate(bands): + top = bands[i + 1][0] if i + 1 < len(bands) else np.inf + tax += rate * max(0.0, min(price, top) - threshold) + return tax + + +def lbtt_with_supplement(price: float) -> float: + return marginal_tax(LBTT_RESIDENTIAL, price) + ( + LBTT_SUPPLEMENT_FROM_2024_12_05 * price + ) + + +def modelled_expected_tax(region, year, price_input, first_home, prices): + n = len(prices) + sim = Simulation( + situation={ + "people": {f"person_{i}": {"age": {year: 40}} for i in range(n)}, + "benunits": { + f"benunit_{i}": {"members": [f"person_{i}"]} for i in range(n) + }, + "households": { + f"household_{i}": { + "members": [f"person_{i}"], + "region": {year: region}, + price_input: {year: price}, + "main_residential_property_purchased_is_first_home": { + year: first_home + }, + } + for i, price in enumerate(prices) + }, + } + ) + expected = "expected_lbtt" if region == "SCOTLAND" else "expected_ltt" + return np.asarray(sim.calculate(expected, year), dtype=np.float64) + + +MAIN = "main_residential_property_purchased" +ADDITIONAL = "additional_residential_property_purchased" +NON_RESIDENTIAL = "non_residential_property_purchased" + + +def bands(table): + return lambda price: marginal_tax(table, price) + + +# (region, year, input, first home, prices, statutory tax) +DIFFERENTIAL_CASES = { + "lbtt_main_2026": ("SCOTLAND", 2026, MAIN, False, PRICES, bands(LBTT_RESIDENTIAL)), + "lbtt_first_home_2026": ( + "SCOTLAND", + 2026, + MAIN, + True, + PRICES, + bands(LBTT_FIRST_TIME_BUYER), + ), + "lbtt_non_residential_2026": ( + "SCOTLAND", + 2026, + NON_RESIDENTIAL, + False, + PRICES, + bands(LBTT_NON_RESIDENTIAL), + ), + "ltt_main_2022": ("WALES", 2022, MAIN, False, PRICES, bands(LTT_MAIN_2022)), + "ltt_higher_2022": ( + "WALES", + 2022, + ADDITIONAL, + False, + HIGHER_RATE_PRICES, + bands(LTT_HIGHER_2022), + ), + "ltt_non_residential_2026": ( + "WALES", + 2026, + NON_RESIDENTIAL, + False, + PRICES, + bands(LTT_NON_RESIDENTIAL), + ), +} +# Schedules the model has not caught up with. Strict, so fixing the issue +# turns these into failures that prompt moving them to DIFFERENTIAL_CASES. +STALE_CASES = { + "ltt_main_2026 (issue 1796)": ( + "WALES", + 2026, + MAIN, + False, + PRICES, + bands(LTT_MAIN_FROM_2022_10_10), + ), + "ltt_higher_2026 (issue 1796)": ( + "WALES", + 2026, + ADDITIONAL, + False, + HIGHER_RATE_PRICES, + bands(LTT_HIGHER_FROM_2024_12_11), + ), + "lbtt_supplement_2026 (issue 1795)": ( + "SCOTLAND", + 2026, + ADDITIONAL, + False, + HIGHER_RATE_PRICES, + lbtt_with_supplement, + ), +} + + +def assert_matches_statute(region, year, price_input, first_home, prices, statute): + modelled = modelled_expected_tax(region, year, price_input, first_home, prices) + reference = np.array([statute(price) for price in prices]) + np.testing.assert_allclose(modelled, reference, rtol=0, atol=0.01) + + +@pytest.mark.parametrize("case", sorted(DIFFERENTIAL_CASES)) +def test_lbtt_and_ltt_match_the_rate_tables(case): + assert_matches_statute(*DIFFERENTIAL_CASES[case]) + + +@pytest.mark.xfail( + strict=True, raises=AssertionError, reason="stale schedule; see the case's issue" +) +@pytest.mark.parametrize("case", sorted(STALE_CASES)) +def test_stale_schedules_still_differ_from_the_rate_tables(case): + assert_matches_statute(*STALE_CASES[case])