From 1b3588c23861d2641626e7d74406855ca38d7068 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 20:09:32 -0400 Subject: [PATCH 01/17] Project the gambling duty bases with OBR Table 4.10 Each of the fifteen gambling inputs declares the uprating index of its duty, and dataset projection grows it by the same series. The series come from RECEIPTS_SERIES_SPECS: the OBR's October 2026 betting and gaming forecast breakdown (receipts Table 4.10), after HMRC's 2024-25 and 2025-26 receipts, less Autumn Budget 2026 measure 56 for Machine Games Duty, divided by the rates the model charges. The model then applies the April 2026 Remote Gaming Duty and April 2027 remote betting rate rises once, not twice. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/data/uprating_indices.yaml | 25 + .../gov/economic_assumptions/yoy_growth.yaml | 211 ++++++++ .../tests/fixtures/obr_receipts/README.md | 9 +- .../obr_efo_march_2026_receipts.xlsx | Bin 35740 -> 37689 bytes .../obr_efo_october_2026_receipts.xlsx | Bin 44075 -> 46977 bytes .../tests/test_import_obr_forecasts.py | 379 ++++++++++++- policyengine_uk/utils/import_obr_forecasts.py | 501 ++++++++++++++++-- .../gambling/betting_exchange_commission.py | 3 + .../gambling/bingo_net_receipts.py | 1 + .../financial_spread_betting_net_losses.py | 3 + .../horse_and_dog_pool_betting_net_losses.py | 3 + .../gambling/land_based_casino_net_losses.py | 1 + ...and_based_fixed_odds_betting_net_losses.py | 3 + .../machine_games_higher_rate_net_takings.py | 1 + .../machine_games_lower_rate_net_takings.py | 1 + ...machine_games_standard_rate_net_takings.py | 1 + .../national_lottery_ticket_spending.py | 1 + .../other_spread_betting_net_losses.py | 3 + .../gambling/pool_betting_net_receipts.py | 1 + .../gambling/remote_gaming_net_losses.py | 1 + .../remote_other_betting_net_losses.py | 3 + ...emote_uk_horseracing_betting_net_losses.py | 3 + 22 files changed, 1091 insertions(+), 63 deletions(-) diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index 2f92d6e6fc..d1ecb9ffc6 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -170,6 +170,31 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.mixed_income: - self_employment_gross_receipts - trading_loss - owned_company_income_share +# Gambling duty bases grow with the OBR's receipts forecast for their duty, +# divided by the rates the model charges, so the model applies its own rate +# changes to them once (#2738). +gov.economic_assumptions.yoy_growth.obr.gambling_duty_bases.general_betting: +- betting_exchange_commission +- financial_spread_betting_net_losses +- horse_and_dog_pool_betting_net_losses +- land_based_fixed_odds_betting_net_losses +- other_spread_betting_net_losses +- remote_other_betting_net_losses +- remote_uk_horseracing_betting_net_losses +gov.economic_assumptions.yoy_growth.obr.gambling_duty_bases.pool_betting: +- pool_betting_net_receipts +gov.economic_assumptions.yoy_growth.obr.gambling_duty_bases.remote_gaming: +- remote_gaming_net_losses +gov.economic_assumptions.yoy_growth.obr.gambling_duty_bases.machine_games: +- machine_games_higher_rate_net_takings +- machine_games_lower_rate_net_takings +- machine_games_standard_rate_net_takings +gov.economic_assumptions.yoy_growth.obr.gambling_duty_bases.gaming: +- land_based_casino_net_losses +gov.economic_assumptions.yoy_growth.obr.gambling_duty_bases.bingo: +- bingo_net_receipts +gov.economic_assumptions.yoy_growth.obr.gambling_duty_bases.lottery: +- national_lottery_ticket_spending gov.economic_assumptions.yoy_growth.obr.private_pension_index: - private_pension_income gov.economic_assumptions.yoy_growth.obr.lagged_average_earnings: diff --git a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml index bd5cb74d6c..6f86f87e40 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml +++ b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml @@ -905,6 +905,217 @@ obr: - title: OBR EFO March 2026 (detailed forecast tables, receipts, Table 3.2; 2024-25 outturn) href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ + # Gambling duty bases (#2738). Each series grows a duty's base, not its + # receipts, so the model applies its own rate changes once; see + # docs/engineering/gambling-duties.md. The importer writes every year + # after the 2024 base year. + gambling_duty_bases: + remote_gaming: + description: Growth in the Remote Gaming Duty base, measured as the OBR's forecast of the duty's receipts divided by the rate the model charges. + values: + # Base year (2024-25): the gambling duty inputs start in model year 2024 + 2024-01-01: 0.0000 + # Outturn (2025-26 over 2024-25) from HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges + 2025-01-01: 0.136277 + # Forecast (2026-27 over 2025-26) from OBR EFO October 2026, receipts Table 4.10 "Remote gaming duty", over HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges + 2026-01-01: 0.093757 + # Forecast (2027-2031) from OBR EFO October 2026, receipts Table 4.10; base growth: receipts over the rate the model charges + 2027-01-01: 0.010627 + 2028-01-01: 0.021721 + 2029-01-01: 0.022411 + 2030-01-01: 0.01353 + 2031-01-01: 0.00629 + # Beyond the OBR forecast: the average_earnings long-run path above + 2032-01-01: 0.0285 + 2033-01-01: 0.0309 + 2034-01-01: 0.0334 + 2035-01-01: 0.0358 + 2036-01-01: 0.0383 + metadata: + unit: /1 + label: Remote Gaming Duty base growth + reference: + - title: OBR EFO October 2026 (detailed forecast tables, receipts, Table 4.10) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + - title: HMRC UK Betting and Gaming Statistics, July 2026 tables (Table_1_BG_receipts, 2024-25 and 2025-26 receipts) + href: https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods + lottery: + description: Growth in the Lottery Duty base, measured as the OBR's forecast of the duty's receipts divided by the rate the model charges. + values: + # Base year (2024-25): the gambling duty inputs start in model year 2024 + 2024-01-01: 0.0000 + # Outturn (2025-26 over 2024-25) from HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges + 2025-01-01: 0.051771 + # Forecast (2026-27 over 2025-26) from OBR EFO October 2026, receipts Table 4.10 "Lottery duty", over HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges + 2026-01-01: -0.152644 + # Forecast (2027-2031) from OBR EFO October 2026, receipts Table 4.10; base growth: receipts over the rate the model charges + 2027-01-01: 0.02304 + 2028-01-01: 0.030706 + 2029-01-01: 0.031702 + 2030-01-01: 0.028862 + 2031-01-01: 0.02933 + # Beyond the OBR forecast: the average_earnings long-run path above + 2032-01-01: 0.0285 + 2033-01-01: 0.0309 + 2034-01-01: 0.0334 + 2035-01-01: 0.0358 + 2036-01-01: 0.0383 + metadata: + unit: /1 + label: Lottery Duty base growth + reference: + - title: OBR EFO October 2026 (detailed forecast tables, receipts, Table 4.10) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + - title: HMRC UK Betting and Gaming Statistics, July 2026 tables (Table_1_BG_receipts, 2024-25 and 2025-26 receipts) + href: https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods + general_betting: + description: Growth in the General Betting Duty base, measured as the OBR's forecast of the duty's receipts divided by the rates the model charges, each weighted by its part's 2024-25 share of the base. + values: + # Base year (2024-25): the gambling duty inputs start in model year 2024 + 2024-01-01: 0.0000 + # Outturn (2025-26 over 2024-25) from HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges + 2025-01-01: -0.041123 + # Forecast (2026-27 over 2025-26) from OBR EFO October 2026, receipts Table 4.10 "General betting duty", over HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges + 2026-01-01: -0.042216 + # Forecast (2027-2031) from OBR EFO October 2026, receipts Table 4.10; base growth: receipts over the rate the model charges + 2027-01-01: 0.027038 + 2028-01-01: 0.045019 + 2029-01-01: 0.034553 + 2030-01-01: 0.029345 + 2031-01-01: 0.028374 + # Beyond the OBR forecast: the average_earnings long-run path above + 2032-01-01: 0.0285 + 2033-01-01: 0.0309 + 2034-01-01: 0.0334 + 2035-01-01: 0.0358 + 2036-01-01: 0.0383 + metadata: + unit: /1 + label: General Betting Duty base growth + reference: + - title: OBR EFO October 2026 (detailed forecast tables, receipts, Table 4.10) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + - title: HMRC UK Betting and Gaming Statistics, July 2026 tables (Table_1_BG_receipts, 2024-25 and 2025-26 receipts) + href: https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods + - title: Microcosm, FY2024-25 General Betting Duty bases split by the Gambling Commission's gross gambling yield (docs/evidence/uk-excise-quantities-1359/outputs/national_reconciliation.json) + href: https://github.com/PolicyEngine/microcosm/blob/main/docs/evidence/uk-excise-quantities-1359/outputs/national_reconciliation.json + - title: Gambling Commission, industry statistics, April 2024 to March 2025 + href: https://www.gamblingcommission.gov.uk/statistics-and-research/publication/industry-statistics-annual-report-financial-year-april-2024-to-march-2025 + machine_games: + description: Growth in the Machine Games Duty base, measured as the OBR's forecast of the duty's receipts, less the Budget measures it includes, divided by the rates the model charges, each weighted by its band's 2024-25 share of the base. + values: + # Base year (2024-25): the gambling duty inputs start in model year 2024 + 2024-01-01: 0.0000 + # Outturn (2025-26 over 2024-25) from HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts less Autumn Budget 2026 measure 56 (Machine Games Duty on higher-stake machines and supplier liability), over the rate the model charges + 2025-01-01: 0.052138 + # Forecast (2026-27 over 2025-26) from OBR EFO October 2026, receipts Table 4.10 "Machine games duty", over HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts less Autumn Budget 2026 measure 56 (Machine Games Duty on higher-stake machines and supplier liability), over the rate the model charges + 2026-01-01: 0.056256 + # Forecast (2027-2031) from OBR EFO October 2026, receipts Table 4.10; base growth: receipts less Autumn Budget 2026 measure 56 (Machine Games Duty on higher-stake machines and supplier liability), over the rate the model charges + 2027-01-01: 0.008426 + 2028-01-01: 0.017391 + 2029-01-01: 0.018744 + 2030-01-01: 0.016861 + 2031-01-01: 0.028213 + # Beyond the OBR forecast: the average_earnings long-run path above + 2032-01-01: 0.0285 + 2033-01-01: 0.0309 + 2034-01-01: 0.0334 + 2035-01-01: 0.0358 + 2036-01-01: 0.0383 + metadata: + unit: /1 + label: Machine Games Duty base growth + reference: + - title: OBR EFO October 2026 (detailed forecast tables, receipts, Table 4.10) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + - title: HMRC UK Betting and Gaming Statistics, July 2026 tables (Table_1_BG_receipts, 2024-25 and 2025-26 receipts) + href: https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods + - title: OBR EFO October 2026 (detailed forecast tables, policy, Table 1.1, measure 56, Betting) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + gaming: + description: Growth in the Gaming Duty base, measured as the OBR's forecast of the duty's receipts divided by the casino effective rate the model charges. + values: + # Base year (2024-25): the gambling duty inputs start in model year 2024 + 2024-01-01: 0.0000 + # Outturn (2025-26 over 2024-25) from HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges + 2025-01-01: 0.051422 + # Forecast (2026-27 over 2025-26) from OBR EFO October 2026, receipts Table 4.10 "Gaming duty", over HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges + 2026-01-01: 0.001885 + # Forecast (2027-2031) from OBR EFO October 2026, receipts Table 4.10; base growth: receipts over the rate the model charges + 2027-01-01: 0.06113 + 2028-01-01: 0.001913 + 2029-01-01: 0.00133 + 2030-01-01: 0.005548 + 2031-01-01: -0.004442 + # Beyond the OBR forecast: the average_earnings long-run path above + 2032-01-01: 0.0285 + 2033-01-01: 0.0309 + 2034-01-01: 0.0334 + 2035-01-01: 0.0358 + 2036-01-01: 0.0383 + metadata: + unit: /1 + label: Gaming Duty base growth + reference: + - title: OBR EFO October 2026 (detailed forecast tables, receipts, Table 4.10) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + - title: HMRC UK Betting and Gaming Statistics, July 2026 tables (Table_1_BG_receipts, 2024-25 and 2025-26 receipts) + href: https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods + bingo: + description: Growth in the Bingo Duty base, measured as the OBR's forecast of the duty's receipts divided by the rate the model charges, held flat once the rate is zero. + values: + # Base year (2024-25): the gambling duty inputs start in model year 2024 + 2024-01-01: 0.0000 + # Outturn (2025-26 over 2024-25) from HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges + 2025-01-01: 0.03329 + # Forecast (2026-27 over 2025-26) from OBR EFO October 2026, receipts Table 4.10 "Bingo duty", over HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges + 2026-01-01: 0.0000 + # Forecast (2027-2031) from OBR EFO October 2026, receipts Table 4.10; base growth: receipts over the rate the model charges + 2027-01-01: 0.0000 + 2028-01-01: 0.0000 + 2029-01-01: 0.0000 + 2030-01-01: 0.0000 + 2031-01-01: 0.0000 + # Beyond the OBR forecast: held flat, as the rate is zero + 2032-01-01: 0.0000 + metadata: + unit: /1 + label: Bingo Duty base growth + reference: + - title: OBR EFO October 2026 (detailed forecast tables, receipts, Table 4.10) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + - title: HMRC UK Betting and Gaming Statistics, July 2026 tables (Table_1_BG_receipts, 2024-25 and 2025-26 receipts) + href: https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods + pool_betting: + description: Growth in the Pool Betting Duty base, measured as the OBR's forecast of the duty's receipts divided by the rate the model charges. + values: + # Base year (2024-25): the gambling duty inputs start in model year 2024 + 2024-01-01: 0.0000 + # Outturn (2025-26 over 2024-25) from HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges + 2025-01-01: -0.511385 + # Forecast (2026-27 over 2025-26) from OBR EFO October 2026, receipts Table 4.10 "Pool betting duty", over HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges + 2026-01-01: -0.287435 + # Forecast (2027-2031) from OBR EFO October 2026, receipts Table 4.10; base growth: receipts over the rate the model charges + 2027-01-01: -0.052527 + 2028-01-01: 0.066462 + 2029-01-01: -0.007445 + 2030-01-01: 0.055959 + 2031-01-01: 0.043367 + # Beyond the OBR forecast: the average_earnings long-run path above + 2032-01-01: 0.0285 + 2033-01-01: 0.0309 + 2034-01-01: 0.0334 + 2035-01-01: 0.0358 + 2036-01-01: 0.0383 + metadata: + unit: /1 + label: Pool Betting Duty base growth + reference: + - title: OBR EFO October 2026 (detailed forecast tables, receipts, Table 4.10) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + - title: HMRC UK Betting and Gaming Statistics, July 2026 tables (Table_1_BG_receipts, 2024-25 and 2025-26 receipts) + href: https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods + per_capita: gdp: description: Per capita nominal GDP year-on-year growth. diff --git a/policyengine_uk/tests/fixtures/obr_receipts/README.md b/policyengine_uk/tests/fixtures/obr_receipts/README.md index 825e379a3d..cf59073008 100644 --- a/policyengine_uk/tests/fixtures/obr_receipts/README.md +++ b/policyengine_uk/tests/fixtures/obr_receipts/README.md @@ -2,10 +2,11 @@ These parser fixtures retain the original worksheet XML for Contents, Other taxes (3.2, or 4.2 in October 2026), Property transactions taxes: receipts by -sector (3.7, or 4.7), and Current receipts (on a cash basis: 3.9 in March -2025, 3.8 in March 2026, 4.8 in October 2026), plus the original shared -strings. Only the workbook's sheet list and relationships were reduced to those -entries. The published unit cells, fiscal-year headers, markers, row labels and +sector (3.7, or 4.7), Current receipts (on a cash basis: 3.9 in March 2025, +3.8 in March 2026, 4.8 in October 2026) and, from March 2026, Betting and +gaming duties forecast breakdown (3.10, or 4.10), plus the original shared +strings. The March 2025 tables have no betting and gaming breakdown. Only +the workbook's sheet list and relationships were reduced to those entries. The published unit cells, fiscal-year headers, markers, row labels and numeric cells are unchanged. These are minimal archives for the importer's XLSX reader, rather than complete Excel documents with styles and charts. The October 2026 workbook titles sheet 4.8 "43.8 Current receipts (on a cash diff --git a/policyengine_uk/tests/fixtures/obr_receipts/obr_efo_march_2026_receipts.xlsx b/policyengine_uk/tests/fixtures/obr_receipts/obr_efo_march_2026_receipts.xlsx index 6b8b16f9bfaf8b26f4b0812bca59ef8a9083955f..4ccc9c019be754d953bcf0f51eb0676857b8dcdc 100644 GIT binary patch delta 11985 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 WD1e%&0N%~!DWhk z8))TFO)y*Y7$Z*g+HBJ)APKD(sDs2qLKMnrZ$cEm;(XgKZgW38S{5@oYOT~=SqAZH z{c68VNt+?pGzOo$YK$r-CL$k$aa8CNIT~tee)IZfX^@16=z87?02$?AX9(8irsXwQ z36wR`^k^%;@0d^B-<4O+O`_yxm-^5~)NCI|X94i=R`1#`o5NbvSuunHo13k2M@M4q zXec=|UpcppL7Oz0rRhz*G56G_b-vzWP+LS6&cSD{%AguZvwa95i7`fH>o~qmxYRu6 ztOd&k)?pD}HeYE&6eiW+X-#Q}jCLy*&uZd`;c1jH;b}Ov?)=)`?(8x++3e1eis&TDs3TA&gOn>%4W*Imok80%s6oi6 zBXG!m=L)|pa#A6lgoakoC^L{M*G!v~ol3+g-F?*_f55U#P9Zmel$@V3a%sWKY%!6T z3_(KA8STPghaO@rhen%3!sH}D62C0Ru^SPk5Kl-$!>{J&HzWq!f%5M%^_M|j->jUf zP9NT;OP(d7GMV)`wHZVpS#l&L=%V;L*CC8qTW3h&Q1S{bIO`~BC_eer zJC%!ySPbXGB~^sOB@t5xN*IM)T&}Lu_5U%_woJb`^pk-R|3C>N2rVj+3k!u)azQ2* z+XkR+v#Vwk4pg&YoFO7pM<?ThpH3is*a{JRPHvVy8`MgVZ^=- zV*61h7rE{dEhDA3SM5h>h3!pgrFtnMOHV6mls^583ES~f=i5F|Cw6n?|NOioBFjU` zSxM{)_|ejAWe5{#D0K;lQonQC$Vg=Z31UB|$K2UVm-;9hVtXqas-9*F;KTN=-FN~C zwso4F6?LF56?H3h)lNfx&UNzfgYuIw065PHgh-Tj%--VD@IMeWaD3(a-BoX)H+ffn z=by#n9&&U$@4ja5QQ}IAot3a$%(khZ4cJi7*6xWl88nq^U^kK5Z)q+y*a30~WF-&} zW9X;&{{!@07AmqA7jZfd?$uBG{*n*=O`r}5aGf6=a#O?4Cz)y=V}0U5D^x}As#SO` zv1n>KV&W3SHUs=u5PdY{s(>}^LpW9UO&fL7pDk4(iNutFul43NJKxtb@~j;>W;uIL zF&mp+w!|iO)|GOa=&ak`3WB)z~bv@o zuT2<5YHyf`=#^ir8b}XVTMtJKu>1dqPa1U&J#92796`{{f_bQB|S=B(r{on@(gMM@ih0U|R0R9=hjTv$Vcr{)w%pOL@# znY|V*OsT0aPN_MXp8)18OaPb7$S)Dk$h)r@Favg+k&+TJKOWgK9@)3z^DC`ox=T^< zAE&#ZnD=XH)Es<-JphH&^0lNDszGyUKyOu|_&05Rv2TMm)^f1}_cSndrHE#JU#x}Y!Hx`Mxq0D{B+cvU|=t2)>H|@bnm2uePV4oU;}pAP+<1K zj!`At;aqp8Ay(gSUG^?aMhnK+SR>~TIAH$=y%X$NQZ16xiABp1ENcpx{(q1&g~o_0 zS#VGjUjMY$VoZ!tZMU`p5_+mQbI=gxfvv5yce1l5M>y;;e5GKDeyMWhaw|>PgN^dX zr_n@>)Tcwc$c7ISA%v|TgC`DgEYq2lg(*E8ca(!h=*u#oBSr{Q3I=cv+J6Ac{#EKP zzOw(}%M$hvUqSqT_?C0(R`&r1`h}cSm5(WUfd|Et}{faqRu#Garh_6DzkY9Yj zPe@skKOuIQZwB)1e7_ush#Or&L9X%_gy$Y{)SZxMJC=S7pS;n;R?SN4%uLS|{ zs#!qi5?O^o%E+gtC*&v24ML@OJ1Jkxg@F;cCqyYk0h6eLlyA$@4B}Pgu30cS;uu84 zQ{%a2kn&gjd(h(Eix?frr!YuaK|SuH-+A6g!;kG+qwJ(6$?ZvjZgDry>W)VkkyG?%vN8D4qVA2n3%w#=8xZrym9h!}Z+y9&>6 zP6Il-GApmo@`utikV$}*E$R#q#*vr_sTjBh7HQaFk*4^+NJF$u zI9Lk7 zHS>qm2D; z6OK@PuURor3L}sZg#{8Q+{0IoJR>um|JhF57!e>M`!ID5nM{!(Q zc_34QT0xZ*`7FvT4v`*8fCdt8P}fQKPP=Br+y~}vBx`#V3qn)sm0k_Zv|-<)iEDd% zMif*Bi4DvwNzmBZ)$-xC62D7zh`*ppkdcuxJf$PAjV~WJU`D#9>j-~ew-6fmxIy0{ zz~3|>SGoAZN319#BUXsKRtGkwfK2JUj2>o<iPvC_thfZSNl0oM85Zh5w|hcAN7kHWLhUGry|-=40VcCxZsUmH=;!;+Bno(VA| zM17hrz9t9JwMAwJ{nRF>3o|D`zako`_4|0d$`K^0FR~$mb2w2xo$hD8&o2*2D>=7L zS4q{vA4eKlpD25R7OTzjAJvcaMU9FcJhh#ch0ok5fc3%pP9DJJg>0pp)N4w>%ljoq zi5He{ecQPC@J=VsGi=k$N{p$MG7Hh=)u}=V4l_#Rp#nPjvJ*d)auNiXB35dhB(GOZ zt(b7Ut<5-amb+1hN*dw~sE($h+e>8HYq6D5ewp`bZDG!VYRpPRW>++U?kNxIE91_;L`S%j6<2yQW2Fq{)8?!soxmn)`)k@>q&Pp zJ&A=NnzGT-+ZOI-2yCJfzo^SOHspEH1>%~}nGD&zJ=D9Xcs25oRwjMOiOY%Tt{^E1 zWMjXTd1%8ZOCeF^;{Y}B5m}4Jk3h92!0!M|T5uBfe!MM2l+tlcV-;wet#fe`4+5V` zb`;f_FLy3`QqR3D528BuV7L?OWa8aJ%oA(x;Wh6q?#=A6v}L?o)X7VJY9;^+0ZT_{ z4&s*cI?B*YXuswbIBgPE>J6#fc4QGXM(3P(%<7xq3$A6&7qLw?6E{nq85VI`p*$Ef zepH^S2r-66Fj%HZsj6|nJH+c?l(*0Y<<`8XSomuRNhnG6c-w4y*5wOuG^WGyo8hnc z__UPZaDQ2q>142GMF`1rphE$`N9!^jYWDxc0|EKXR?UCIic+a5O}{hIC#1X^sEF5m zgw!Qrw^YlxAdEf>{G#nI8(DXGno!*8Wug}-Le%#khd1`%y&o=CEH6fv!jx9%&`TbK zd((x}JEi{l;MU906#3a&NILue^;Ebz@bntG66$@5f@6{~1|)yzCtOuSHC{?!S)b?n zGgN?6pS#=q?P7)qdD1tTH6`bxMAMCTnISLH0x7j(x-dP4*$)vmLqLwwTz&J9bHVoE za(gezY)CaVNUz^f*sGSXLr|QDj-0H=FUO8eoqdEzX``jvrQh*SlG>b&Hxkv{Qa}%A zZLU4IY%9!m69_4qK80ZK3QiU$2Jyy+4by!9=@G4l^QuAC77?(SaEKD45oiNb*C)6l zHX>8@R0?u-<@6pI`HG*K;d$#jzFHp{HViF2bPBijebC?J*|GEvVVU2iDpqO}5f|!k zQLep7_H>C!N8Q}ExVar>^LzL#-H*NT`RUo^b7U$FK2VuO+KFVdWzi)sfn~t?5I$2S zc7>~B?+6qS@#{iKpxQ7s#0M=7ipKd3`l-w{p?SnshN#~60l=P1NS*?208ZzIc*|x^Zk0J{CXb!dbl*RIUDnZ z8}r{Z<|{dVsBsNB$jFIVI)m=mtDM(&>yQPwMDk)$%eUc!-dhN);w-cCW`P`uQ-pLH zPZR+%eDv=-ISC5Hdl``n^con6x0W*)y`1@IfY_s6!U!tVHJ7+xK*n?L>vH5YONmm# zS7toe)Z_H&APN@P$>)eEFVTJ_JE7r0zz*=eb`W11Z~?y}a0CF#OM`=R0c~dF7i@p*YZa2O`>(xN+)j{3{ zx@iUT^lO`0h5oQ4NwMhgzAv8@(gb~=jo^Chy}kFJ zzyTP3Eny8F1S_~cbj`@Bq zd{A~ExQhyGp@T9=2+C{-q0A=X#kPr(MU00j77+Op+@&$T4KQH}y88AXc;`!?H#DN| z(`Q6KYsqF5_j9t zYTW6+YZP#jL{)zKJw(s0g1B=6l%zqD@FW62S+uIw|!s zD8iK~u5!I}4?O&_GQVp)!3<*ea{`W%ioEIpt6n1nn%e7yG-7&d-gj!8r`KXmqq^Ia zXbojVZWo%4%W%Yx5R7I#cT8wpGircrvF<5fX(og0K}8PL?I?5-|;XO zec6e*U$Khhj`1)vGiK5Nu^1u^Xc!Wp0SLKaAth=)yYk+g3rkog(9&M)uaT1_u$};Q zs^Qv70_4Ehy4+ps$pn>@5h=O|eX}{MK|UvNmQfkm$lvVRZTh(Z`x^mPpH?=}O@L_I z0$F(ZsH2E|8(*w3fZNq}0f@%{qOol5NQCu!t`Xu*$gm)Q9o!(es&>M^`kp94E$4MQoC{c;eFfeAAKK?3yHed`Yt1;dT#wk*QG!9 zQK9*P2f{-}+4+&25zL6*vz!3~d0YCAoxVQhRaQIzs;rX=dZxJElA96TEn3&&vA#Lh z-Y8fzBYJ*Z%v;a<-X%Jcr-e*F7uBt6wrqSO+#|^g9CYj{=&KYQ{!$rY>N_9}ozecf zUEoy^D(*`Sn!PW}Cy`4OZR-h5TddT6;lHoZ=;6vIxxRlypQ_?--FjNeheQ9GDCrfZ z5?x6x|M~i^=}(YKvtK66N0!%&2ZIwz?UI(yw<+A!rpfO^)9{IaMM|YU^-|QNf>Lq_ z>~@jBU$=1n+1{gxES2IxEb_ZsMR9ZJ18)vaXuq{q0YQY~?jWG!HlI6Vk11J`dil!Op5R(;D3%(A+|KU7La5BP3c-F%=;_(KcUc0pHPUoS``y`BjQxF)`P ze~*psC@StlkdCBoHoewOcn9UArQqIjY{TmWCNki4Jiy#USF#h{(iG)u^V5#V7I~jQ zmu4FUz1bXv6$y756CgfI-l<zsw1hp)x3flWs2}=5 zrV5cV<^AB#`%7k07#ER;L>WWk(loEtU!6too+-!RN7{QU7bS#^pr&6=A{v}cW?v^a zfVZnhgX0gL;Ay*k^doSCaBfjnuLWrTW`c6cF^4C4yec3c20!ohpR4|Jkfx$^th*vV zGP^XrEr&34DEsG7$f8x{B7)jPl;prG33^(LSv2VTKsG5x2thN45MEy8ifF*o8?8_{ zJBch@(BZfIZeE$ETQ}GB`se5Go+W5N?os8D2ZXjn$KlyPc%h@u$Ln_E9t46f@5z95 zj$+Yk2U5p#8BlpDe`UIFeNNo=$CE1MGkni+pR#*R$c)-{F#%3KF#iDRV=)&y&s&JY zk#RWGMTNyI0`!WKPl`;~Jr{IZpx87;@_?U~wOHsWXqx3xSZ^thhG6-zqdu?>+uJ%~ zUE_^B8+Ha8Y+Bk{3Ml>VcycjpWEtkAqzRM&t=-}a$#{Jazx7@maDpu#es5GRy<47W zV6y!E>Oqa42f167t3+}2VuXl|teQyieZl7U9}{W}lg20@E2`KCi+Bdh@YFpCScV8a zo~X+#&#(y*S~|y50z4&B1058j-{iB}+j#w=Zll#6FXNp|;nIOWoseM=60ZF@fP0(p zZ7fYp`nAX|%^$39+Pt#;qRW_?7p6CqL`&{RtCj)AZ|=NfD#bm^@BoBTiZUt8XwD;# zEWDjE$QOg|PZJn2$<)P4_5mR*^d~sjjvvdsD@Gcp)D*<>5|iMQc7d5>_QPMmQ?-+L&upt`LA_51I;E$zN2&oO-l7Az z1@gKkgnpr+44utXtpiGll-SZtQ+2D5QcPR5wHg)G%ez@^PuVTR`h%rFXb@e+e_NqYH~I-Dcu$&Ij0 zYgin=^i#Pm9s1OneMW5jUCBthiEJl=c@Zz(nmf6Q#T3}S9sumJ5upqyG9!mS0yHL2 ztwg;>j=Cx7?UxQZBu)*#()by`f?GX*We45=R`iADQRh5^+>fPo?}5=yr09?OC9&}u zsSEBk)5t2SKf&OMB+cgQmNV+Fl*Vy$(qb0P%g@!JEjp!qI9WhI}7PXDW*lr=o z?yzF?j#zPKDgfw}&%4Ol_AA=2W!&hxcqMMFr&Y@6?(5V`@2Xqgh6l2g1TMk~F9@Pq zJ~1?b)2+6xhErZOi2U~Z9N%S#*ixJ%9S%0$@b|exjXG>%j%E?BAxMJ=QzUID>rD=N zVBuC0MElR_M(+>9=cyLph6}xyGcujufe$i5uJ@1MQUXA2#Ng`T1&o8rBYx_>^J2B=I2h-Wt?^nvjuNkWb3FWB}M%F zCRBc;<}fE*p})87oDZw|@Lg@|eiLQm{#xhfc3(25_&GvO)}F{m8s5pETPm|krwRfy zlH*~odkR9}hsxL{@@u__9-Z*5r2HafH_VKp5*uTc#>zuoiGUR_frVfuGrVw;YzAoR zX)G}?CX48C?a?K$%4WCHHsaOC*V9|#W_Kc4$NGMfPg_X`G+Xwmd8p6flr2@j4PH#{ zPqgQ+u1!V{hjDn|5K%P$B=%BYx>TLtIgx)e`qP^PT!MZIy;f1RICyrr*HhW|s3JqA z@pskK7`%}TWc2Muqq;EiWp&?m0+hUh)IriPSH4z@exwwS=i+uhNq3wSxjZP^ws*rg z-xk^?;_y2m4vc!fQf^8dh*S2{MDK2k$XdB@lD)g?k2CiAxkvhu+U6kZ;!zD$xI3Rk zZp2jwJZ~IX3S{?JQew&^^m*lfQt`w7Fb*lGgaLz>@K;4FSsl~i1jW-s9;QTdEqYgt z3_i)$#a{~_F=d@@ur>HGoKn&c75@M77tC4riFY+fztf)XAB}E9j1^O5l7Ouf(dKLQ!0$N z*n*hfdF}9U|I9O*k4=+3%D9f)k$&)q4Z%y&#*n?MLzW(zHXxB*jUTp*%c%M_)q*3{ zx(^^yDLV2&GDE#{ul0VA7yTH=jS!l5B8=wvsVRI69fG*maH>`xZ1F}68gzKszF%VV z5eTRqdG23@4~|FshWMP1){gs~eC;H!?+clGJU9ECNe!jyHe0Jpu4LKE?B1s>iHCFf zF5}?elZ}z)7Jfq6g6^CCf5arF4otfsC_YaL?ki%0a$1(Rrxqu&7cIx}%xY)H6x1DM z$uL`B1F%mBE>f^PEE5x`ql2!K%34D!G+5GL&4)#wG(djvH1iXtF^bjN0dO>EwS?0C!0Eev+MwK6uQ&*=Y z>H9!?ET(XF^6g&AME24>NZC8y7xs&yB*@A7adweQ~ATVv2- zx~NQQ02h0gTi552p3PMI9zPvvoxi|3UV~iepnxw74)(dKY}g5Ku!2ODQ42HzXKbSw zZ4liD#=QRE0Jg$^2QVc|5n77;j}g#Wg!R#Qru4`ON*k9Qp-MHYXqG>7lHhqIdHFTU z{H$g>Yr@sJt+#Vg26EI95><|@6XP?0IwPXk+5;yu3p=dxzfT*RbC&8YnPfS%?lU`z91%G1~j)riXvku&c3RrYxN; z%>=7kkpmADjt13%IpnyDB4EBdAw=t%>& zpdrba3gXD7hUFrAq1WSIPV#yfq1#KT^PXX-zEFco#PgKagG2?mxn43s0b3~LUYUi2 znt8Q?GMW#<7qIq+_k9dhNL7d;F@#M zXq_SlUqkOg!;lBjlwgKfV%gtzpdzel;=@m66^m#Dw^^i&6~rrza9>3l$6tlx0IMM@ zu52>o8V8Fj4OLG?ni_|{U)BwYt+4frzf-o?lT{k_%xD2JUWiY{ok-e(z#8H3T2bq9 zh({Wp&5bw?m-B_4gFmj6FMKd;JK@}7pU}+JEm%?}OUWF6-(gJRIMCkJVzJX=kPy?% z#}u736RvMJ*{PpG63Q?;uAk@84eB&@e_J_#_Xw2$)7!8zSC(tY8qSR-*xm!|gC7UD znS?bhkb!AoHCb7luoL!{yvE>feOxqkSKt8U;}A&=xV~SESUy-`yXZd%mqOXz7%)C| z`OylTIB7ifpV4Q1Z|-p7#(h7Y{8ZZ)sh=8|e>S`CtVsUH>})=Pa-otU4r2ng2vhcRN?RCXX!ovP{Isj2#wUugSgCu?8#z3U+}KS&hy z0+z`^n-=E1_k;2Jjd#D17HZe;KO6q3ux%5w{PYymFn>n%3`bdrDV1y-RO(CkQ8Q)6 zI^mmd>P(r-v}oRpn}qJ!iIm#R)kNxfvv&NlGjrb;g6B$}N9?wC@l?id!EX2BlPTxL zluy$KXg^;%|A-nW5B^eD^Eq{qe|?NU=L=ko1!u1i*rhE@-Q7|qpR)?~hJV^Mp212r z%lXAEZuRq1mCCsv0{!(5)rRcZgXSW5v6Qm!6RUUm$K1 zIaj}ds`l>(G>Z~%oD6R#Qhx;eCm58LibfDBGZJd>c?bAT1_e+gj+^(u63v2?Hk%JpYoZfogKxu~0ZOxa; zCi^W;E~A&=v?^9=;B$SY{Y}=f^2(WfXV*9iius6rmb!N{g1Lly;ejm z4p)TmTc{9n?t4!!5K)WO9*l94U`A_-&mVUUd)#qsC+)$rSOh5O22)eVD{7R`_KpM34cvqVAvp(-x^=iEJmv&KoYTpwFKJ)$l zJZ|si!*kZ}7u5gv5*Hg>oN*8AN|$n)5IX}fZ5AV(4=c)C?hl+iYjkYD`tb25QZ|Zu zu#3a%Pd!f)IA?Fp_#`495zM6!#+i}Va1>lWGAsSc?Y#0iNz*S46KYS}PK=i8NEEoEIR+TCEH`oMDgc{YP59gg6LWk%VGs(`r-k{t356>GY)V2t5v{|634I5-LzEdTQgfyZZ0d&N$b7M8++fWncsV?>-bm_nSE8Hk7Vf5!|8 zreUX%rRk;Oq^+h&Yx97P#OC`>RgFgUOZ7aiS(yuA({8Za>bIvjRLpjf!lZDjO9 zvYuA*GLJ37ygB5w`FmlP8L<94ZzPJIJeAa0Z$`-!kE;a9$Bn-2_{VT&r(sn99nRgZ z6K;-ay<0p=YX1`i7sJ?)C;Mc9Pz?LAq*DxL1(gPW=jo`be3g??@_c!^%3@OaR~445 zqT5l`6M2c4__CW4iQxEUgex{dJTsrC;FhbzF-QUMt3-Gxd0-mJnao8(tZ;*k_PU11 zK-bL35WX4QN{ujnYBUxe1ZOz~bzy~D*`pdHX`r9f6D{0vUZ02QpgpAEV)yIPmccL`8=&guhEfp`j5m z0Y#x)ObnSdR@+D~{iQbNH8-6#H!(DA8&&|g4?R?KP;Fkh&SA=e7FPS&BC-zdK;rL7 zl^C*O=sd_WXLS>_M|BgYhB2nnn+C%pLNTUe8wOm>>9MA2N}z_~O#kClT!!M$R$E-# zEQZvY8)JFtFl)ts&vOuuV*=~rmz0BS3dy>|qB$_o>+8rxwn1Xo>L$vMj9rukQqa?5 zXQiGsgz_J8b+I0Bbycrec<_j25IcuYOgt*T&w52wM|eh6ua%yj4T#!Q%FFhM(+PkR zRGcfbV@vHnOW+aLJ+WWfsIyEzF&N0tzf^wfbjIQSeqrtx#29yM1bmqW3)eB2?q$yg zFVD{cFF)NHt=_vdQRlccL0r{f$QjDAK_jpoDRiXL9uQB43NCbwR2Cjq7COT&3W~uZ zxv`G+ zA+VVDa;V%hELt@j1GoX+yaIoVs=WtQXH(Z4CfBJw@bw70uFR8nNOUuXg~M(b4x~o; z5CaAoLB;*OZQ3sj?XO4vcJ*fU8wn=na!7EIx=dzhgDf1Q2Q56oZ}sK_SULBNWQH?TI&Gj4)vpMHDp*agQX z1r+970@K_#3J!~2TC&ioLmDKV4>P29hc`@H1t+w_3+)^vtd+T1jXBZ? z4&u`nkhDk*yOK{nnC&SOe$S#y{oao#buBz1p!r=8(41*kU$SgFxz;!a>Vt3%({f`6r*4*9VIKzM?r7n$ zN2`HcP16D(oCy^L;mj9u5YB+dPaqljO+9O{&l>Ujl3r{%_JkE|5}@E3HhO=8e~ECh z6QREes}*S!7v|E+KS8aEABoQ9o?FA8eCB4qxWdBx-4jFlPv7=EmeA)4t3$%OX2%DF zK}_gk$oL@qX$1ihGEp7Q`ANJ-FAyP@k{Hp=k0ovZgg!(D0E)l)WFamw{wW@q8Wl?& z?PXfFwoKfc_kIKOui{JI8(j5U)_e<$CdLf)7NFj!;J>YFwO}p#*rLfbf(ojixIQ*(kEgq$n`bBY0P6ZN~+ z$|4DfM=f&cky}EWC-*s5#~Gu z2M-!M%m{zvfEcj>8db^wsmC(|w*lLdw{WQjP&g_8mpm!}8zM@05Z%xLz2FyisgfA# z!$^U2cqHHV!U#&3-tn4zwK{pOAeIL}J_X|~SS0s@1|Yz?d&h5*ajH5He!QCdAkkY) z)jShE0?<~G*g66!8iBw9M~r1|rR2zn3e(1s)T#)p2X}NnQWZ83%yX>_CK}@yj_8^T zbabcW$q4sX!u2S`EmfLCCuSP#p*A5K{2pyn5fKlKCQgP(17mX#J>0rZD{NHFPl?8| zjhzwqt0E*a3@%Nr<+C4EKqkOnDQprGlH(J)!eEk7 zuK47-0iA!dCXh-fjx`GQCuC5#PJ2CPt@H(Z-irzj}+|21`eG)v}Pri5~WI4Z&IlhPiYJ-91^AWUj&&o2BZ=Y(N*5-T!EQB zn0OMyF~PA6U1IPQK!bx9W5kFlFIF5%?&uW1OML|4D>*+YI66wYgF%R6WpMA+Yl42i z`AeOqwJsqUhi7xf$HchSv|txkU2U?AOAKd3B+`aJp3F{fr)QUqn5{cDj)p~&j297A zBlw2f!|9zkl37|3;Rg*chXY{aWlZGL+H?`K_-=0XtR4U9ZoDzw{rb={SM%DSC5}!8 z3R^O}M^_*Mxrj5EYb|;C=Zh@d^9|H-_?JTbmCzV_OL58Qqr<;Nq=Dh&5WdXpql=}qkBg;An-rWuy##CL6^K9!anc_l#gHIbf(g_jo$<1G>ClovdA}RcLtG?Xm`fwK^zly%islsi z$rD4oEX&vz<%;ysw~D(d6~uN{sfJL&9u9(eOu-`GKXrW`L|CK zbAPKBgVzN7hDVQ7n|wU2Ab7%}Vza);14!DN50ZZxU<_A(KAfp94A-kq{0uV4yufgx zq5}rgektesrk^!6LNKuNw{^`zcYlkyiS(#`*yrXZ;MX|uqwgRzOR5hvzgTkS-&S`Y z#;|YaSCw!4bzu-!{y!oDu`8VfWzpP>JJggvHhgai%I}#I@?ZTYNoX?(S*?-P?z!@`&9)o zJ&Wa*9^`^|A@+)TR2igzE40J=rse#R-@I9ALkKMK2&%npN-n8=dq{w7LC&&fGI0Q^ z8gT;p%xkmYn@cs38Y?s>;<+w)Rgdnr!!9wQ$6KOHlz%aK7@VDX6{yjg>bvABE}KvX z)3ykg;g{0~R7Yc}olSU^n7JT%fJbdoJ4wqo*zr`&WVA{_kM^EWtn>M}N`K3vUQ=7J zm68AAVPu%mg68nO|HS3gZIk`?!}?cX;|}O3pN8lqZD_){5pgA;?hgc&!5B_4~7x)^J&Nh&q zKdOjedxikb$<9-}$-#QXsYVTem&%h(p~%(Uj&4Xj*2`7wu?nMhpj1Xs;kVnr4`68( z)W#23E=yDiSU$!m_(c494fLxy5(tz?d+)Mf59$nLy3XHq2^>NEcn(=W6uA37=#cBg zttfOt6W)(Cv^%!%AeLXJf~><1%a{X+laQTIuDNq)YM1XK*(!)Tw@>GdAfiw+5|qaq zp3)Zw(1A%+%BXK)Unn0b%5yViK!WcLR>Z5O23!WkxB#>UW7q-eK%AJFx?P7Ek~sqO z@2859-Ks=Z_xhzGS`K(2kiUN!11(R?kU@u@WwbC)Pl^^vcZ%#sNO__xUb&pLsbrW6 z+$@HDVYDd0X-u<)$d5uKTgh;(PNXfS`@ZzP-JGuBd2CoiFACywM@yc+#8?vr-1o$0 zeQ2K)wjeL+t*uXe-geMbLe zS6apWl<3W5VKoH!2wR&|)Rni3EH4Qvp*=w_F?c@-kq-Fk7M3pQD4kGgIip&}B)4B| zcLbbMg$WRRp@X5}du|5zpa|LDp(vhl$u;0y!NmEPX*M($|c7d5H0S2hkZG z3bdj2C;jp^bm{u2bt-=u^@u~x2HL`5CIcKI0w&+~!KQCzd@MaO^)C@W!#|MpR}RiC z!L)Et&x9)=jC3u+XcvOTOF#qu@6?EkOm;tPAgw$$81~$*hHt z%Z5P7$+3?)#dh-45d4%Tc8VYe!BA{t|P=A{7NjNU?~Fp=3y}!@#FR^w`ImVtc(9ft40#BN?R)Q&AKpat*z|wFC9H zQ+(;(5r&!_>-uN68;!bam>Uh4&_cd)7#UiKe?bdTI~)A1D29F}{9sXrIO=d1y${DH z*J0&Kn9<7=)P}Gr;4$I5$f>pbTtX}i__@b2T#D-|+G0ZaSeq$wWf;GsV()>zpTy*$ zwd02#F3tTI&7FTc<%1}Xoo?>yk_GH{J3#LJ8pv#fh3|Y>gM`G}Vj+U~hhQxZytRPGMx@?7Pcd)>@GGfO6I(oW|IX zv><4rY1o}fQMT6tQrxz{UTaN%8ob4RN84GzdFJ^e z*6}A9E@l+BneX<4ccnqpaPN;7)jzi#_su%=*s%rzRpDoy6O(9h4wD-Kh|XmAc9`38 ziHO(wMq`KjdxLet)%b6rL!`dIwW3|z#kTTjWj50Ah}a%^#++;d5f`!G=K3zx}zH=vuOzC<|ClOOx$_U(+#`)S;D zJ9-aZv8NiR`!A26s!Hvq9$_brTE)B1f;BL|y1R(a)Y_geQ%)OY7198>lgd*msn|YR zO;CvGacU)BT*VC5EuO!{p5^s}J9_}!-yM;H^vSmu9g5@b^*kfvUU8~NPA~YpwQk;U zekCpBd=}A6$xBk95Z589;uPL@KxsCy4}8Rj-@k2AzS9gZ1&L|uzrY{r@1N&7Dlb%A ztr{_0ho)aYcGB$IfdTV@v~{#!%Lek}3Nfvn(N``DjP>rqwZdb6|GiqrsECyyk1_NNLRA|nf4lhx3-LE&s^cz_5Ev(wbYWH?9aC4cdBkWaT-XPezHTr0TeWkKpZMl_`i+=l_Q0f|_EJtHHnP#*qH$Ok zDu*xr9aN;-KwLSCt8uxcOM#D!nBq$Yz{SC6P+oLL5i>>qc0o;mxyK)H_%inUupkxu z({PhHkY`B_0TL(0(TFTc6$2KFn^-ACX+_h9n89oRi|pf;v8xmm z&M5}3#f2n2y`J4g9WB1fh!7>{;Fl{}TS|rr2EdlQ@<9upybtS&Se95Bg3^OW%1-d+$KV5%&fJegFgAZkqqn*Bh82iph0H7L7?&hM3fft&g)o5a$(lt zdr_Was1829du0(B*@wyW%#g(~n}uLW0xwC*7tK=ewWMHC3tajBKz+DOzW5S{I~Sv` z9Ul0;#d;9|B;~1jnDUD?ii-U#&IRX?5XX10J=3`;eq0^lI1;#Xa6+_w6G8@Tms~zu zSojt~Kum@l9igx$26kw z(IQs_8#sM0@nHE0*4L+KTuM=^Owuw;T*bTh<`XwRXXy}nIppPkl)h%LW35-uCh)sO zNc@;sPJn63VonM`H^g*GFzQ)-e6lcr(+kZ8USFX&tj3K*hh&6+>iIQz>Fq)xTTs4p z1duZPED-u~8CpJ8-zR@+eP5Bm*7}Y*E{q80tSK3EAY*YhU%4o#i%5mqldTL;J}JQ= ztxYp+%xICDKk=g?mBaS-MX}VN2`#gz3lWm@`725^p53RoOpX!4lM31rA`N!5O`>u@ zrp&(R(~FdO%a)ysEe}+`(47r47(I_;wI5g+*#R|2Ri5jO1g;G-3Nc_0c_k9fXCi?C zRWCCu2JHg(6r*aMrEZ-Gp$ir;7!HplvV0`IZI69`j)YY}0Ca zxP@`g(2|oAo<`?qPk0Q1=-E!qDSV|5Hn%+~I7pu4RT~T^usm9q6BLZSF0Zc3FKkD)~~<~sICp- zft}>{_4ew$$&ZbC?`1o3fXyIjaPI{K6uWg9iT^<{_8S(CGhODRuTAX&ea4b`qY4Fm zYtbfWPpm)v1m;~M;%wzZv$YvL3-I{$u~V`ioL8acZ`mnHD=t;o}PNF$+x!}(?fS9 zX>o|EA(NWTtmnbR=i?(vUg71LAG9$^dKJHev)RfiRx(|sgeNr%)KpAI*uyrt6+P6* ze#vfA+P7A2t5lWl#0&?+9!Xpw1YZVQ6m~x!2U9T-V-*`-pNR22T>S=Ol?}d3^V(=x zXl4!c;u-Xjj&5IuQgq$DUHs(P_a5JljVIU;a7eE}wBhHQCW#s4ze>vAVVmYauxkBD zx7|Z9B@DB5YZt6{i>_n=??89G7jS2LuCPUkY&;B?mZ4<3MfGtT%QciS_saFaRfNdy zW(mHXd-W+@nRT7`t)mhMt(VrXkaBG;`oy<;$njDbpq_chBHz}klxyyB@9Cc+Gv1ERrraS=)LrW{H^nAKs) z?|Jyl^XIj@tY)#At9{gi-kpg8hUQ)fF)ku4&%~7cP@rFF0{SNqiv<8v&ZXbD*5cL) z-ZjkZ_$L~Ac+^R5$8<(#-W`v9`9K$D@Yipd6>1E3Jl^F-U5H5z%T#7(xmEQEzOZgh zrnH9wiLRs!wx^n{RXhQ5g-4BemZu`td8||EyqP{B)lj<|Y(e z-oWiZyS|ySaIbO$eh_Oz1h}utpf1=Pg|Hy@vK=;`MQ7Qa2(Vsz-XAa=$M3)Rl#B@) zWlFXIYmfj%SfU0AiaizBJ-n z1Y8Ty2@tO~s(3-jqk=Kdh&7xntkqV^9hEuXBlK{fqI5VqcRP}pcbHI8P97UgtB{f+ zkqux-<=X-FM}*H}t(2;UF7K&rKC2V;h2goF#uk&u-X1V2SrxF2ln>cf7*A{@- zD>yFI^B%4CwF9T_B!#+?7Jmolzi+BxzVAsD#q|O*iqQl3Pbkqsh;KnLCYDqs;$lG& zBL!rt{}BBvok$>d8tcM}bW)ySq%0nO%RiMN3bc^&IceNE|1;)7I}3f&Qi23p5iY*U zBr+m&qEbWua1q;aPRCNuRm{*r$)XikkCeu|zovq=AkIukQrJts#X`vqq9~!2J}7Ae zB25%?Q-}>)(dd?R)IyS=?z_RIDnwFWO9&`O1R(WkHwS}Y(=U<{zcv5$p^P;wIZszO zjg=iCo?1i%HI21Vh8iv8G(i-%T$;{MGIT0=UfpA@mCfn3lBxa4{DUHY&O8~DaA-T} zfzSP1Y$w&k)eI#bXHa&RxMs>`4=EG4_2$S{`p1RG4C83n?&*BsvLDy?S>xFA7Bwmh zW8Pozxt29pAxN$Gx@~5x5rX4_hv=A-xR<2)kJ^^57yHXLs5z4DLP&)`7g1%+8&yB}d@HoRE* z;o9P4b6;iyFFjZE@vY1c<`f|tQbK}$=^N#2J#_K#LJ-SM;t^pBc<~>t00hX7|Hzwx z&D7y?)qmYtUKrkW_~mm*Vk;zBlqa&FY*~tuA|>KetAf845C{Cv;EU@%MC9ptmirF; za%Q5!>h*RZ2y;!oq+MUEy8qAAV_*Tdm>bLMtLMFBL0o51o>G_&z81WOjEL~Y`=0@6 z`^NS=bF^^*u{3ig+wckaQVU5HO0HcMk@DM)zb^9J%W>s=OmcE?gi7dHX}N2(si8a_ z7rxGLQ+OTImU4o_=ybG z$@WG18AX<0KZ^S>cy1baP2$fUc`sLxI)N0w{iv>TG)9brdAfay^0E~ekzBq12`NRx zN>I1>-zN4H&+6qshlO*HjHVTHq2s&?(8Qo@T570CBf|5uInlh>f4+1;^3Uk;^p~w{ z-auxsZIl<&nteo2hE+_%n3@OH3CBNg#Pkc@Z8~Ha8IA4 zC^hcfc9@a*2f>V}p6=`BB(r6W+-nvRzh!I*KTVZ_rE^i^dqMMM{v|N=Z}AD&r1vXx z-zTZut2^~u>})jn063VtW~B zsrM}f=WIRc*@r1RIBGeDGXusDa25Ue>-qe){(3r5ze4Q?s@d5#&S^{41+b^& zI6;t_{v+Qx(PK7lIxA~CHep)VXVN`m`if6|>ws)@>Mfa4a4#3PVqF>tbf$cllY1es zuy5%U_4S**ja4Jx{$ze>XF{9%tbJMbROo+Duy?ztG~*e^n?YHLJkeN6z)76ydjjW) zBPnI>`s~{GW&|YYW!%eg9X-1Z>s*8oqS|@d>3z7ShFIV zysO_vin|U^*!K7_3*((yf4FtJ2Qxy9|2o;XSA!xmw;APm9gE*9hk|R;Hc*$TEjz;Ft_9_TS99V)~W=+v&S9iS*Ob z_g57R(%c(0;wSFhTa<9&bWktvMb4gPy)DG-AU7{mK9y$+qA1G!C2H8#WW6p)xxxBF z-phj)Ny7fe5IbSPtV0xCMe%*iInxAEZ%Yq93Q*$uvHcAbnp`==OT3KLWpPj%Z*JF< z9iMM*JPdqIar~chjrKp~nrw*%bNt40CZr|?C(BYxO_@73_?s81Ssemmf=)FJ$q)Oj z(xeE}ty;oq;CRH)2Q zYiH9FM{^?}I|3BGrjLHZ;CJ7=Mvu>bolFT&yt&pyyQU%QZCVECZk z!-h%U$<)S~$=yD&q8tU-IWLDCz>EsLQxQk(M-ya0x-*8P{Q!XkNCLB+FH^F&--iHx zVFXl2=_hh+n>mT`t?!cRlwgfcDLN3tilt2>e$8UT15-tp5X$2$eX}pPpw0xYVMmpE z&5{pf*7OMct(E&&BT91Zwb7JdK}5z0?53ZQn^$dJTyzvY6gb!OPi*Fw5;HrV^d^KBFX49uGi3=A*PmKF!d%IINhQ45)rrE_0DN2^N;`S2C_r(O5pU+o0(~)bBt6psS z41x<{U&@xv#{ZlecD>!^3E($R{p@UBl}|xQ9JKoOs`$yVzVPYuFb&lC(5UJL+GU$H z*dmeBbCH}XtY3@|K0lkiuzP3Q;8&!sqF3`(FHI*hbm#)VZFf|fCx&Lt-|gvq4d(}D zPozHh1Vuh*8&wjM9M7*#TD@F&Hg8-8R+>kSV&87_P3m)oT1?Nk1bJsycKdpN%c#!Q zCmlF`HtKqBLe#3^S77>hs@JIkQsv;xT;>QX6gTBqKhf>x@NAe3k7i{b&p%i-cx`Z! zI>ocu`f{TjCtBi#H-E|*28AF$2~5LY<4S)_Y7|FN#`Z331I)$^Jl(69x+X~k7Cz~8 z=Nd2fC)4Z@e2Ouwrv&t5T(M=*3tgs0%+5EnxKujEC2B*ZVK@Wk~y0VVy!{6^6T0WE8Z{W`q!xpOROY#)& zt`~-Xxu2U?gke6@1Z{Ez7DPl^4LaE#`kP)CV{JG;ZyST*DG0uzaO*N~wtrer-~H6{ z&-^OrE(km?fSYxg!>5x23UvD(q6bFK+h<&ywzbAnLtKcAkO$F=NN&!EhjU3Nia`No z$vq3Kxex4|7%3Vd^{97|xwamk zrf9FEits&m-ka*Z&qxEF@6R5r9${D~_I)wM=q>QBk*=@Q$8@rR!0AMqD7#1TSGU6* z1;8cqiNVZZZm1ARpaSlOF>{YWST9_lBXp+7aSA{E_I$smduNe%&7*$+XYysTz`*-D zpT;WdBte97gcfEK%^31X@`>6W+TadyATo?Dzzg@DNQRQVqId{_PqRM&{sO zt^qw*Yd|F)cwsUHK7%1mU1&9|5=~8q^PF*BORbM2rYyWIdX>c#G^a`nC@L}k(BNVs5#pH9{S{6d z{EIJgUK@k?0ix?sN4=GVvpRaYQg|Kr%2t7xZX$%=HMIH-V5p^MqQMsnlDMG^1Dn-E zR3TU->yZkO*R=5hq=GQ?=={Z`hzuajh?4Yn(bJ%mf#l;|tK-T&da{Tm&PbtglA&h1 zwuU6535M=~Gv>ws`C3v%-(nL)S_ER!JtirAQx!vZo1o${%Hjb9N5#nkto9>J8-gq86;+h5>dttVJU4 z>$fd%`G&ckBg_Qp!a@AxC~=y0V~tQl-H->|B_>VPf`nOWQ^*h`JWit(ZnHY^?MIKd zMschPrAUtQ{2MGJ6m!f{ZK)fHV)G}$ARY9|(Zp2k5~daf{i!iE%8f(qJX{7Px8Yjk ze&BDw8B3*peR&FI0p%DBfPmFzpw@B1h@tZeoWFwd?>?KOhZc#C?^h!1r7b{ATId~9 zCi_khf(Xuc^|3|MXnN(8I3-7ZGn)dIjIiP7%I(6W}A^(wEiKM$fDOJ1oq=%A<0M;y(leP3_H_IpqiFo5dW;)E=y2NZ|8e_HC{cC`6BW!bed2{+- zQ8ev_|H)G@|G7f1DsaGri#hk7avXE4P8GSSh1wV2*iBht_| z#FQnnJKfc*3HSqkveiIo=s#8_O35`aXQ80$#w|C44H;i>&& z>?Yn_-alRipTSF*kTKc7A>3{@={E^>hc{dm!A^!^~(q|3C)+ z6rFyfZ+2DpM0&%Z(T|MkE{2w8^>|vBPo(GKt`WEQ*tXvzn8T({@mG0ibmTe*$KYOv zS^*?lZja9>3=qjm!$WP zAB8ysjCf$;zD%Z^0(a(06MKLY?I0ssE|z|9l0${7g%SHS$= zlc3%Y+>kcWjuspG-}pK4cR4MDAqR+opHM*yktzDmRATox=tKz+8R?$rTpZEcLKP!ll;XognP(gr!i8lQs_5T3b`;?#n delta 10054 zcmY*<1ymeOur3nZ-Q8V+B)BdX2p-(s-C@whEjR>s5AJTko!}B&1A$;c-X{OO@4UBr zdfR4ZtGcS|tE%aspD>lnFa*j9uy1gopr8<;D&6$-r<6D3IhmlK6zT|gV1Xg`IR1uZ zOW0}4=b{61pq^4N8l&c3ZVvqtT+(psdb30Ey4ZXMOstSBF}J#KQLu+!Y17*hJUG3N z;c%KSu=^r2FGRd2>l#OUC|liuR_0!t&^Ew)sN)M*?sZ+MMs?$d;dD>K2vL|;X>)CH}QCkCPK}8j9)>E&8Lch zy)k48Etb}cwRp&@kuiC@u0T0vWCB@@(S zGue?#kgrXmK(0nI2O@m=1?9P5GB;*{?OTGlX>10y&s40wysLM_vFGbsh~$NZ;5L zoHNFUlborOobi*KZ#G#&7QJN%B8l|h7Gbj`lOM&^NbX2Yf;;|PLlu~18!>;Lan{m_ zH`nBX6bx5ZwG((j5V{Mi+5vn=-J&0BLzE9|gHO6?hAO`fwcOoQ*hFlO>|C>}inMU5 z_W#aTTxpd639*f-)Mhkj57Ks383_roctnqJ=9;sR!b?$@^s6p;#xF)`Yy=s*Yvib+ z3{rgJXGbr!bdH2U3j zp;BkdjwtV8M`+i~CCka<;%-GlK(FS$Zh#ydw<*0@vh!1HA2 zsBmV`{Wfg#z;j_1y=)~(A1k;*%~wIoNL#I&j4hCgtkc3s=T@~n(9a{CBESZr8UK?zcR^t2KPl#}yd9fPh6g(Tw{F;-Jc2)9U zfNk`zLrMwZ%h(JN=Fv^kb|)Lud|WLNij!+x^d}xM<<5d22pKB84jG6mTE(S~LsweiN((K0*gOD>L<1Z5 z^RPKP6$#5W54&sRvK4fxbB4koC z%B=N<+~Po@4_eMkfHyk6^n<1k9Q0*Mh2orfQt z)371p)J!z*lifF`GIp!A8zrOEuz)Q5@=RAfoo65(OXjC$*7X#&Q!y4@RZ*+<-5NXX zaIF;pTd+_4jjiyvxejtO;~E6*% zvj`!diJ?%sK#b5~5WZMZKmvi-UclD^Bv0tUxmGA@GgCBUUiQFZEu_%=PLp2KJ ze^f(Z)y+bvhKcw`wVbXyAX$Oge6jzg4YsI)$}6WTw z(uBb(ibg5RzH8os#C6rqHivJmY~Dh5w^94saD^jryvl#7(e7BNcn7)2pNL!~e4{p6 zY=j0Lan&TuKW_-Rjbe}dR4f_~VmzbqTtu~ILp!vLZS|@Eh?L;J5fiXT*)=wUc1^HVRBfv15jbFV_@97>^KB12pt{{xY$q9Q`Yy|(gjDmd{m5tXpga1sbk z9AkwYR)c{vP1lcQLE;vgV^ji<%xT5QM88KOXZbbgWoPeX6bt%3^PN=g(n=Na%FCE~ zxG*ddGrk%P`NL240pSDofoJ1Qd0O`nL7k-bKC;Tub;|gKB8M3mr0Z&_yKrxo#D&;Y z*|}#;>I@R$#G>=Q8Q+B#>opTZZt}br6;aDpyQr}p760T`1ZzJ$ z`WJE#R%4PL`yA}t9fvn|9fmh-1IAO5<24$1diLeVMwpel9NXGu{Kpr&He2-m87Keo z6jTV%=xn*T$3LW2`o@wxN$@S@$?n4UM8x8I6Z79o+o{WamGuC+%KdMdAUH$&7iX{_ zID-*k=70(7HZ@WZiGO+pYke073nD$=^y)<{-1vh*s8ZxgViGA6N>;#&-9C#g9c~pX z<#02YxU^(7HIi=))aK2=dA#{se}nVcJMyjF7GCv*`3#aj>vgr|DoranZ)sx9_+Ffi`E8zkYMK zJMp){ZoUTB%hl5 zt~7dI=3@5HdPTim2v$gqXU9MWs{f%?B}SczbnwWW1Vrq(5V0%&6?=jau^}pZ3C-A- zyXWt9f1F%ypoJp0Ei7#;2%H)|(=d`-DZwSUqJE=;3H^TZgc%dcSPbh>A7>;Y&9kb{ zQ0|bfA@u_WA)QC`@v~s*p)sBcjIg9iBuAJRbGHyu1)Tt`hzlk#&hSm{LtxtkL0`Pc zxL+6uY9*HW7gO3ND|cmY8s&;;u~GFHKkJm%Ug#hE8rnYO_#id9yxy#edI>cR_S7+@ z_oY0*F%tFd;#6rp*s6tLgY-1-|*hH*&N`S~C>a#XIhtZ2cs4~uMREzh`19qpqS>tMu-|r2+dOj}9Cb1pA zSxuIDvnriLI@84{Rw5QmZnMV!I;h%Ngb{#Fnd_V>5NynJW0ez;6r+&xj#<-Ew(Xk} zu4}#%Gq*5qWKuM$3>U$rWTwN5oDw{A?sls(Y~gnl`lP&{!=3Vdu_;xZGsoZy8j=t; z610)ldK;xKrgkxM9kVqjWan!b1oYgi^auaY50T=?E82m*%rPTGHrs zm9f!0$?4HRwY_K6g{a#2R@74u$!@^QMr}_@z((#O_h&@s%%#I8#bJEH&a36nAjP%g z40h=b9Jr45qdGp+m?$SC&=8`r^@jeuGwo)t9h zcu$YlQKm^aIhv${&qax{*ETP*b5+IfY0PQIl-)lwNj6zmqaqo**?>kif5-7+hl9EP zL`kyM>X8zU*+-T-o0lb}GD-S3N)c8luo%lC&_r!;=Aie{-pea0@M@#8+E! zKCXr{BW~t*M(S*GzuZJuy9b-~&f~e(u%|#Iwaesy=FP5MZi1jTz@$#3vOoH8;s)BJ zgl`#H1f3G!fy?2z(u6Of;|T`!Zd>nI*dyA`SdN$~_8LdJPF=+(i~-C2pOMnIUKn3g zaqa3`Mw!ehb^B6W^3>m~tf}K&Cv>G~u;IvSB7RIp=CbjIny$g7*%=~5By3_0Wblbp zDH&B8muTKsACr&mFJMC{q3-wUlM}l1K z+aSLfwEDKt)zFFORg9e8hT#+a^Rcl`e>rh8DjD(p_Xu`$exn>P(sT%Dm->kG{kCuo zs9vjzm*Y<#JWt|ArZfnJhG;@lYO969_G{CGTHTNIe5pc!O6&$>Op%cD4_~DxZSwRd zP2xJ8x5~M5UqNoAj9T6HLMi=zeXzQQ-$|NOnf?^OhPBg6GO4%W$uBCKW4YN^ebupC z667tk?L{#<10|}tWV+8=hiwijGRvZa7*-CBE7iu3q-UE89CTfX`*2X!I%5N2i9w}m zEvF^3)mwZ(J{)8#tMT6y>`if(?e@n1a9WDi-inlLxl0KPe#=G?P~ORs{T$p22Age< z7(;a{+Z*Heofj+{oAMb4oY9KHz&eO7?xf(~v1M4~@SA4p&u6r5g$jCKj>ERh;MuT;mS6e)~ zg1#~`TQcqc?Xw%G$0txw$uaS}V$Yx(bC4RtlCFC$e4$#xRRpVA>3(ATnm@~RUX$qE zQYT?7D~oJiPYZqi=d1Vf>Qz^R$yHg~W@Y!%X z1VHfPy(1DDJG+xEq&g$>3nD-(C4mH@)BHsSml+7mVAzy_AY_S;a-=n!=%2{LS+HY^ zUA7C}J3WxLCfp>ePr`6;xZ)H`_CNO}kQ=V35&HN@)1z+4HR3|ni@P;uHMy%eUsIYL zt%=3fS{9K#bj(TG*oncd$aIF;qsX`wXm<*YLkkaiN!Uz)QVrL6syYB|#QriMpI`|W{q;sTr}-V?Jk6C+yuy6lU6~R2Q2rgKg5lnCJK5y5@*mAhppE@9 zjQw()o$mbs04;pb6Z=}Bt#qGO_9P@z(-OqN>l|&RXZ`_zrD@-!^c1sm55&S+BiloY z{AA{?RMh-%sdgiZ?QDhEqDV^IW)}sqbgGv?NPyHNsuq=vtp~Fdni7a=&a?)B_Z_Bl zS@jA%I`7-89|^e6rFDhBzO2jvQ$&G|mf9K3QE4l>mVKU*_CtNS>%QV@Z!Iom7E-|{ zE-t$2!gN9!;%V>TPG$Jjv3cqw7o?)pKAS0q)X4i=6Z5;jeNC$2#(bJJpNiv40KXupe0gL;rj22G>18HUK3hiJSATo$;JP^60-hh6Z*-48aW=%F2I6q%uL_PZc zxP0Hp0-M77FS&%DNh77@p^Bo1T-9tu7aC5~0jG+3qHzHaBUvX|zy9zqWhJ4=F4$dr zc*{E0vNTyZ?o0Xz3KqScEG`MsH&x-dDarVBOxC15o#hj=i_({8G{|-Rr)J9(&Qu6J zXRcO0?Xb9I-=xm#&9$|n-cNIfxWG``gIn!%lv$_3dxuQDz*lTFb_xQHI6RldZBZWh zE1=TMea#d9i<)@|rP+J&(YrR<_UfhK0hcNDq6B#Z*+aNI#0H19t8cf%wS#3nueSJg zoR79+qHx`UFD%4uRHv`z_sX=OlfohXPIY zG(KL^#6{)cqKjReB2CzPobZl+wexZw{tx`Wew>aMo(<`p@nP!^tQ`6RGwrtq;C&%i zkJR9jqr(rQE!N6P>h+)?yUzT$An&)(1Z?GK`3pTdAvZnr3yYI2#`x@xuM#qRMSJ(wgYWHj=kiKtV8gE~xERds^sm6G~&{mQmgl&8aXYx(?#xZ&%#3PW~i0M+m%nM+iqQNc0TuYwXO+(_7 zguOZCFdYR+i6<RH_u@ZAFNoy@HBkPB2OJL{qaqnEO--1j`#?OUy}f1oV);i4 zd<+C0$kLmQoVeqv>cE>_TsV?sY67DL7pSdI2i3IatUo!>Vg>i+W?D_z(2%Yee8GGm z!Cdh_?^D(vzLJhgpv{!9(n<_C5tBbjZ0|)1g=LlEyxkd^zkK^!757QG3$@s(A|Nc7 zI4*<4oC5P*t|(LhygyXuj$-G#{sa)BDwYL0OqO%c@pa9N7z$8e^{|I5QDvXcWW`)s zj@Tg{vbsrVUu@0V@)oP68OLOMz71kg5RK)T4;U?A3@{x?eYUo{F_+@sV5aDWe2Q<4 zG?@rc(eY{HLMs(DjXR7sP(iG5cFC03*eWVt_J~bAt5XxoH0e&|DLW6%$4!O=ph>u} zQ{5vnu#%SoH$W%=1dfBH9C+4wAo+Y?a%LTk`-c89X_HXvBogc1W_9624c6vh9;V-* zeg-C33tvYpeO!W?)=)w>*P{C6Sm#|3)NQg!f`n)nRL~@A;YO!@CI~?Y+PZ%E+E;c! z{{08(XkJvClLm$pty^4hJAMY|LK`bYB{JRB%IRzXpB-EWb;p)3N*!>Hcf%o2LT?WF z-d^?#wq#Z;-{AW3I!>;YY?Ni``?$O*!w?p(*QX%HwOb4yhF|-g&mh-4CfRhFB2)x= zn;zKlYV_mba=eScVmoER0uy!FOaMJ6iQjF1t8rsWE#1^{xBNqc;pO&H-F2@Ja5O

SV-i$lNZ=1mvdrkB?ZnXEZpcqW$^9-<7J;1IL1n8GnN9GSLyNW4sL zXxbC6woF23?zFmZS}G0aqN=U=28=zudp2gs(l@+H}Wrygf|a=@q#4nQ?CE=m8npw3no`xu*< zVxo$pP@J5p-(cHV)tp%@Xr6Bpt0kbRz~|*Nrnpd_!4Q8!v}aMW7<%Zk7u$8b00*qW z&3`x-nY_xR!TL#>;TbvZ%R4YSJ%0VUJUMN|hc8ETGE}dO+it2p#hBy9$Pg4ar=Y%n z^U1mBMrV=j=8Fnl-$9iEw#^iAQvtJNiTK%YP=5n*pV=e3_kj6xiH$Uzrx3dkr>M^- zMmG*An&(MT4k7QWGETG*8IB+2z;~cpmTd{xG@h3jKcPjRg2I89d+`vZy~;jxJe()m z(tQbtS>L`5n%C1eI^FSpar$uKU5X2r>s_OwJg~3(0Ke&k&wZKd$#U~KEkZ&eP&bHx z;ESUv@QdA<5c1++J;vr*?!YI($A|?K)HwafBj@~9-2F`V-B!gj*$k` zhZ@rKA0lzo&NAS2{(yKOV?(hp!UKiVD2;kL-IK&>scm}ctO7(l+ilA+*WdqZB--J5t?B;)@^bi{xs<`T16rJt9boOAIJVd48TNCiu~nmCsrd=rwF|8cU~2jt{{i41etzmO1X!Mt2MYVN;=R@Sn!F;8_1k;ln?o3! zX1~6$+H-a|yffqKya4L+`_TgIE8#qGuYSnpL=v`ky+g!1votlXtMJ5SDG}JIu;?xI zvgdfL@DS7!On-QmlJe~DiNeYmT#;6{3Tna8(AoYZ)%u)d8qp{ZwmF>1&rJ6ye^-M$ zHzqw~y!Qnjy@H`Miuq$|s!R?lTbhstp0}O4cYUMKFEgvQJ!pVJ^FvHcFQ{6e6TjtT zs^c*C&-g#N zou3FgNj0YAUwyA0ew+Z~ir2TUg{n^trUq$i8^5qHDTHNyuPS)_c1008hNJTO#C?Ks zlwS++G7-9I`)2@GCKKZ$agIpNn}jrok11=SUq;U6PDRv;%44x&NJQNDEjfQ!vj7!M z7m^ujm4;-7=ps4QQ#AV%(DlNp+$H}oOKv2n6SeOc7o#309EqW!d?2;aZH|ONNxplvz~3RY#&O`ZMGM0qxNHpcoph7_E>VhN`w>5y8GvcvlG`W{a1!WuNkmXL@JB6IbTYu4%p&)UK0*xA&I1`d0lcFBn;t5?b zB(~8qo14J|v7@0G|7(<>Ht|4wj(k8H5=Fbk+TeA{rVvuu38QdbRv*5Hw@GF_d;f{| zQqgK%cL3XfGOSN!%)eiyPL=r{Y|fItAxQs(e^T>@jZ4i3Py0vxCQr&b`-l0vIq{8O zv@CRKZ8!{&RO}LI1EW&6L|2C;ng+=2bOXEyEr=fMyp7Q}<)gnRa2#OX~u zw1ZFBe{O2-7FVz?t!$=_zFy9ra*bGAV!i+9$qgGAH`JQQ3*A#9f*w9_#`0=~BEgJC zR*)($>HqZu@kvPW+zfsw@c%kr?fLwq{nPQo85mCC76uw?X{GclT?+V_sF=2!q0(AFc*65Sb?`9KglAaKsps!h9BQj{%%LW3=B%g;g-OD<>^3koY zM#+4iZ}!u+cX8L<;6-r%Aa&>VDw@R0a4lRML#?3`Q4o6&)r(y6-B6o zk2o9wbhB_m-milaXpU3`|PL{=`;G|MqOO z5PR45^v8O@!CeYS*Hcq2Gka-YVozr0;CO=%qL%1Ocmd%GUT+1`|2kRy=>V4VwO{>d zcO>U4bS`J@FQ3Iyd`{8iPT>C9(a0~|W4*Z+j`f0Iz1kzW1H^bd`(oypP0_ec0@u6a z4cTnBDzU4wyqS9Gy$|osrXS%1_-6U(pX*{1|(5y0o@RCVu)}v6^*yG_LR< zntC}tjZ9dkrG)`GPdZJR!JN9U-O4@o_OGxa!f8+9##aQ{S%cAFzl*b{fMmam6Wi`J zzktKGU-TGq9tX9o4_F98S{s^=Ko-lfqs`owm~Y29-7nafRG<<2;U#=_%Re&VC48}G z%9pqX@M*BMCyw0O-s6&YLriX%IZY@0l! z4Qak>?)oGZ4I!zS2NLr`>=cbQNS)TKMOFOS8VcKrri)`f^IA>yQ|LN2nJ9_`YrYQ$cqLl+-2EZ@Ou+1^t`+`!@oDR^`_0JZNVZe!%F8(9!P!2ZwEi>j67n ztAqm??fa3014~?Le|pqS>sQ?A&-OSG?87Tmn8KqS`jkF-p5bCQ4yosl%5ZBn7>l*v z9}`IL_)B&OW5K3++PuY{W`MB+VmI7{_q*Vpt^HDf5i^l{;jMg1ekHr+SOWTV;p}#z z0SI*ltdk1Keh)ba%em|NB<-)a%7*;`UMP&%6O( z!ROe{e{4u@spjAL?d7pjJ^3c9l=A5j^NpeClj#3{>Z?bRkfIB6223$2fQjP`*ov|@ zsg4OBu-K$^>{fg$Ewr&isaJ|OI;HGHjv!q=?Mk2hmI(^--d;SPuk5LL)&qAcz8Q|D z!k`h6W$L>Tg|Aj(mj{A*_FLU0EEIHtke-{%||YL z(r;QAko{Ruwxc6B6n`|7cp0k-vB=}&(=`+<;veMFe(pEv@@!UP+tB`aB^BI~tELCt zV9a|BApPHiYDxM{JS6{@0az#~VhAJu`w4{BPpWESOQLKhhEZis!f3%uQfcOZiGBaC znbyn+<7fA;xd|D)14Eip>;frJP+oQ%u2#k_=4NVcF4hi~uIyg+cFGEH@PD6+0Qqi1 LLqYLY{O$c8V%Yt* diff --git a/policyengine_uk/tests/test_import_obr_forecasts.py b/policyengine_uk/tests/test_import_obr_forecasts.py index 5e9d9ecb18..6053935e28 100644 --- a/policyengine_uk/tests/test_import_obr_forecasts.py +++ b/policyengine_uk/tests/test_import_obr_forecasts.py @@ -18,6 +18,10 @@ STATUTORY_GAP_SPECS, _sheet_paths, ForecastGap, + RateComponent, + ReceiptsMeasure, + ReceiptsSeriesSpec, + measure_receipts, average_earnings_path, compute_receipts_growth, find_receipts_line, @@ -472,6 +476,28 @@ def receipts_table( "great_britain": (29.0, 30.1, 31.0), "northern_ireland": (0.39, 0.395, 0.4), } +# Synthetic betting and gaming duty receipts, £bn, from the year after each +# EFO's first (outturn) year, as the OBR's forecast breakdown starts. Remote +# Gaming Duty doubles in 2026-27 (2.8 = 1.4 x 40% / 21% x 1.05), when its +# rate rises from 21% to 40%, so its base grows 5%. +GAMBLING_LEVELS = { + "remote_gaming": (1.4, 2.8, 2.94), + "lottery": (1.0, 1.05, 1.1), + "general_betting": (0.7, 0.72, 0.9), + "machine_games": (0.65, 0.66, 0.8), + "gaming": (0.17, 0.18, 0.19), + "bingo": (0.03, 0.0001, 0.0), + "pool_betting": (0.004, 0.0042, 0.0044), +} +GAMBLING_LABELS = { + "remote_gaming": "Remote gaming duty", + "lottery": "Lottery duty", + "general_betting": "General betting duty", + "machine_games": "Machine games duty", + "gaming": "Gaming duty", + "bingo": "Bingo duty", + "pool_betting": "Pool betting duty", +} def make_receipts_xlsx( @@ -483,10 +509,12 @@ def make_receipts_xlsx( outturn_years: int = 1, sdlt_rows: list[tuple[str, tuple]] | None = None, chapter: int = 3, + gambling_levels: dict[str, tuple] | None = None, ) -> bytes: """Receipts tables numbered in ``chapter``: 3 to March 2026, 4 from October 2026.""" levels = levels or RECEIPTS_LEVELS + gambling_levels = gambling_levels or GAMBLING_LEVELS sheet_318 = make_sheet({2: [make_inline_cell("B2", "Other table")]}) sheet_319 = make_sheet( { @@ -571,6 +599,24 @@ def make_receipts_xlsx( first_column=4, outturn_years=outturn_years, ) + # The betting and gaming breakdown publishes forecasts only, from the + # year after the outturn year, under a total and an "of which" row. + sheets[f"{chapter}.10"] = receipts_table( + f"{chapter}.10 Betting and gaming duties forecast breakdown", + first_year + 1, + [ + ( + "Betting and gaming duties", + tuple( + sum(float(level) for level in year) + for year in zip(*gambling_levels.values()) + ), + ), + ("of which:", ()), + *((GAMBLING_LABELS[key], gambling_levels[key]) for key in GAMBLING_LABELS), + ], + outturn_years=0, + ) sheets[f"{chapter}.18"] = sheet_318 sheets[f"{chapter}.19"] = sheet_319 return make_workbook(sheets) @@ -751,6 +797,19 @@ def test_small_gaps_are_written_in_fixed_point(value, text): + " business_rates:\n" + RECEIPTS_SERIES_FIXTURE.format(key="great_britain") + RECEIPTS_SERIES_FIXTURE.format(key="northern_ireland") + + " gambling_duty_bases:\n" + + "".join( + RECEIPTS_SERIES_FIXTURE.format(key=key) + for key in ( + "remote_gaming", + "lottery", + "general_betting", + "machine_games", + "gaming", + "bingo", + "pool_betting", + ) + ) + "\n" ) YOY_GROWTH_FIXTURE = ( @@ -951,9 +1010,18 @@ def failing_replace(source, target): assert list(tmp_path.rglob("*.staged")) == [] -# Receipts growth series: stamp_duty_land_tax and business_rates under obr. +# Receipts growth series: stamp_duty_land_tax and business_rates under obr, +# which link their first year to the previous EFO's outturn, and the +# gambling_duty_bases, which grow a duty's base from HMRC's outturn. -RECEIPTS_PATHS = {spec.path[-1]: spec.path for spec in RECEIPTS_SERIES_SPECS} +RECEIPTS_SPECS = tuple(spec for spec in RECEIPTS_SERIES_SPECS if spec.outturn is None) +RECEIPTS_PATHS = {spec.path[-1]: spec.path for spec in RECEIPTS_SPECS} +GAMBLING_PATHS = { + spec.path[-1]: spec.path + for spec in RECEIPTS_SERIES_SPECS + if spec.outturn is not None +} +ALL_RECEIPTS_PATHS = {**RECEIPTS_PATHS, **GAMBLING_PATHS} def _series(tree, path): @@ -994,7 +1062,7 @@ def test_receipts_lines_are_found_by_title_and_label_not_table_number(): find_receipts_line(current, spec), find_receipts_line(previous, spec), ) - for spec in RECEIPTS_SERIES_SPECS + for spec in RECEIPTS_SPECS } great_britain, great_britain_before = tables[RECEIPTS_PATHS["great_britain"]] @@ -1019,7 +1087,11 @@ def test_receipts_growth_is_the_ratio_of_published_levels(): ) # The first year is this EFO's outturn over the previous EFO's outturn, # e.g. residential 10.0 / 8.0; later years are this EFO's own ratios. - assert {path[-1]: series.values for path, series in growth.items()} == { + assert { + path[-1]: series.values + for path, series in growth.items() + if path in RECEIPTS_PATHS.values() + } == { "residential": {2024: 0.25, 2025: 0.1, 2026: 0.1}, "non_residential": {2024: 0.25, 2025: 0.25, 2026: -0.1}, "great_britain": {2024: 0.034483, 2025: 0.05, 2026: 0.05}, @@ -1232,7 +1304,7 @@ def test_render_changes_only_the_receipts_values_and_references(): rendered = render() before, after = yaml.safe_load(YOY_GROWTH_FIXTURE), yaml.safe_load(rendered) for tree in (before, after): - for path in RECEIPTS_PATHS.values(): + for path in ALL_RECEIPTS_PATHS.values(): del _series(tree, path)["values"] del _series(tree, path)["metadata"]["reference"] assert after == before @@ -1371,7 +1443,7 @@ def test_read_calendar_values_includes_nested_series(tmp_path): 2031: 0.02, } assert set(values) == set(SERIES_KEYS) | { - ".".join(path) for path in RECEIPTS_PATHS.values() + ".".join(path) for path in ALL_RECEIPTS_PATHS.values() } @@ -1422,6 +1494,17 @@ def test_rendered_growth_compounds_the_published_levels(years, data): "great_britain": ("33.929814327646056", "35.43608032863263", "37.21735218338801", "38.66510919685915", "41.462982206056644", "42.42035811098974", "43.447399285510656"), "northern_ireland": ("0.3955394050104385", "0.40595301023058955", "0.414556055466312", "0.4226801104356366", "0.43095512116333085", "0.4394491849889923", "0.44783993918412923"), } # fmt: skip +# The October 2026 EFO's betting and gaming duties (£bn, Table 4.10) from +# 2026-27, which publishes forecasts only. +OCTOBER_2026_GAMBLING = { + "remote_gaming": ("2.7520296250837784", "2.7812750734699425", "2.8416867878041874", "2.9053716896455919", "2.9446821891345443", "2.963204761653297"), + "lottery": ("0.83052038459597621", "0.84965566898040046", "0.87574553838772884", "0.90350845044750805", "0.92958587301870155", "0.95685079671453654"), + "general_betting": ("0.65560522982822456", "0.87744453968275893", "0.91694664204685206", "0.94862995017184315", "0.97646741817020943", "1.0041740187990271"), + "machine_games": ("0.67707068705786499", "0.82692873727074745", "0.85695449075649544", "0.86524127517297356", "0.87550370868921701", "0.89631020781238802"), + "gaming": ("0.17422987982556123", "0.18488058456996015", "0.18523427331264838", "0.18548069649974164", "0.18650974968911124", "0.18568125218312026"), + "bingo": ("1.8867652000000002E-4", "0", "0", "0", "0", "0"), + "pool_betting": ("2.8288821260900035E-3", "2.6802893699237468E-3", "2.85842704577818E-3", "2.8371447589557743E-3", "2.9959073747097458E-3", "3.1258296515431082E-3"), +} # fmt: skip MARCH_2026_RECEIPTS = { "residential": ("10.379999999999999", "11.731471524432562", "13.428906667032589", "14.984943814530874", "16.673948133476184", "17.964513468126285", "19.057301113969185"), "non_residential": ("3.5049999999999999", "3.6750451743530199", "4.72127759316189", "5.3288288104799904", "5.9009139798817092", "6.5330695355310402", "7.1046581772762902"), @@ -1432,9 +1515,9 @@ def test_rendered_growth_compounds_the_published_levels(years, data): def test_october_2026_tables_reproduce_the_committed_series(): """yoy_growth.yaml holds exactly what the importer writes from the - October 2026 receipts tables (Tables 4.7, 4.8 and 4.2) and the March 2026 - ones (3.7, 3.8 and 3.2): rerunning it on the committed file changes - nothing.""" + October 2026 receipts tables (Tables 4.7, 4.8, 4.2 and 4.10) and the + March 2026 ones (3.7, 3.8 and 3.2): rerunning it on the committed file + changes nothing.""" committed = get_yoy_growth_path().read_text() rendered = render( committed, @@ -1444,6 +1527,7 @@ def test_october_2026_tables_reproduce_the_committed_series(): levels=OCTOBER_2026_RECEIPTS, cash_table="4.8", chapter=4, + gambling_levels=OCTOBER_2026_GAMBLING, ), previous=make_previous_receipts_xlsx( release="March 2026", @@ -1709,7 +1793,7 @@ def test_main_requires_the_previous_receipts_tables(tmp_path): ) content = yoy.read_text() assert "2026-01-01: 0.0248" in content - for path in RECEIPTS_PATHS.values(): + for path in ALL_RECEIPTS_PATHS.values(): assert _values(content, path) == _values(YOY_GROWTH_FIXTURE, path) @@ -1927,3 +2011,278 @@ def test_main_writes_receipts_growth_to_the_output_path(tmp_path): ) assert yoy.read_text() == YOY_GROWTH_FIXTURE assert _values(output.read_text(), RECEIPTS_PATHS["residential"])[2024] == 0.25 + + +# Gambling duty bases (#2738): each line's receipts, less the measures they +# include, over the rate the model charges, after HMRC's outturn. + +GAMBLING_SPECS = tuple(spec for spec in RECEIPTS_SERIES_SPECS if spec.outturn) +# HMRC's 2024-25 and 2025-26 receipts, £bn (July 2026 Betting and Gaming +# tables, Table_1_BG_receipts rows 45 and 46). +HMRC_RECEIPTS = { + "remote_gaming": (1.162539, 1.320966), + "lottery": (0.931887, 0.980132), + "general_betting": (0.713858, 0.684502), + "machine_games": (0.609245, 0.641010), + "gaming": (0.165397, 0.173902), + "bingo": (0.025233, 0.026073), + "pool_betting": (0.008125, 0.003970), +} +# The remote part of the General Betting Duty base, which pays 25% from +# 1 April 2027; the rest pays 15% throughout. +REMOTE_BETTING_SHARE = 2_163_978_657 / (2_471_124_767 + 2_163_978_657 + 123_949_909) +# Autumn Budget 2026 measure 56's betting receipts, £m (OBR October 2026 +# policy Table 1.1). +MEASURE_56 = {2026: 0.0, 2027: 144.15331361978284, 2028: 162.30500845324954} + + +def gambling_growth(**overrides): + growth = compute_receipts_growth( + make_receipts_xlsx(**overrides), b"not read", specs=GAMBLING_SPECS + ) + return {path[-1]: series.values for path, series in growth.items()} + + +def ratio(new, old): + return round(new / old - 1, 6) + + +def test_gambling_bases_take_out_the_rates_the_model_charges(): + """Synthetic March 2026 tables: growth from HMRC's 2024-25 receipts into + the table's first year (2025-26), then the table's own years. Remote + Gaming Duty's rise from 21% to 40% (1 April 2026) and the remote betting + rise from 15% to 25% (1 April 2027) come out of the base; Bingo Duty's + abolition (1 April 2026) holds its base flat.""" + growth = gambling_growth() + hmrc = {key: values[0] for key, values in HMRC_RECEIPTS.items()} + remote = 0.15 * (1 - REMOTE_BETTING_SHARE) + 0.25 * REMOTE_BETTING_SHARE + expected = { + "remote_gaming": { + 2025: ratio(1.4, hmrc["remote_gaming"]), + # Receipts double (1.4 to 2.8), the base grows 5%. + 2026: ratio(2.8 / 0.40, 1.4 / 0.21), + 2027: ratio(2.94, 2.8), + }, + "general_betting": { + 2025: ratio(0.7, hmrc["general_betting"]), + 2026: ratio(0.72, 0.7), + 2027: ratio(0.9 / remote, 0.72 / 0.15), + }, + "bingo": {2025: ratio(0.03, hmrc["bingo"]), 2026: 0.0, 2027: 0.0}, + # A March 2026 EFO includes no Autumn Budget 2026 measure. + "machine_games": { + 2025: ratio(0.65, hmrc["machine_games"]), + 2026: ratio(0.66, 0.65), + 2027: ratio(0.8, 0.66), + }, + } + for key in ("lottery", "gaming", "pool_betting"): + levels = (hmrc[key], *GAMBLING_LEVELS[key]) + expected[key] = {2025 + i: ratio(levels[i + 1], levels[i]) for i in range(3)} + assert growth.keys() == expected.keys() + for key, values in expected.items(): + assert growth[key] == pytest.approx(values, abs=1.5e-6), key + # Without the rate taken out, Remote Gaming Duty's 2026 growth would be + # the receipts' 100%. + assert growth["remote_gaming"][2026] == pytest.approx(0.05, abs=1e-6) + + +def test_efos_from_october_2026_take_off_the_machine_games_measure(): + growth = gambling_growth(release="October 2026")["machine_games"] + assert growth == pytest.approx( + { + 2025: ratio(0.65, HMRC_RECEIPTS["machine_games"][0]), + 2026: ratio(0.66 - MEASURE_56[2026] / 1000, 0.65), + 2027: ratio(0.8 - MEASURE_56[2027] / 1000, 0.66), + }, + abs=1.5e-6, + ) + + +def test_real_october_2026_gambling_series(): + """The committed series, from Table 4.10 of the real October 2026 tables + and HMRC's outturn, by hand.""" + growth = compute_receipts_growth( + REAL_OCTOBER_2026_RECEIPTS, b"not read", specs=GAMBLING_SPECS + ) + values = {path[-1]: series.values for path, series in growth.items()} + # Remote Gaming Duty: 2026-27 at 40% over HMRC's 2025-26 at 21%. + assert values["remote_gaming"][2026] == round( + (2.7520296250837784 / 0.40) / (1.320966 / 0.21) - 1, 6 + ) + assert values["remote_gaming"][2026] == 0.093757 + # General Betting Duty: 2027-28 with its remote part at 25%. + remote = 0.15 + 0.10 * REMOTE_BETTING_SHARE + assert values["general_betting"][2027] == round( + (0.87744453968275893 / remote) / (0.65560522982822456 / 0.15) - 1, 6 + ) + assert values["general_betting"][2027] == 0.027038 + # Machine Games Duty: 2027-28 less measure 56's GBP 144.2m. + assert values["machine_games"][2027] == round( + (0.82692873727074745 - MEASURE_56[2027] / 1000) / 0.67707068705786499 - 1, 6 + ) + assert values["machine_games"][2027] == 0.008426 + assert values["bingo"] == dict.fromkeys(range(2026, 2032), 0.0) | {2025: 0.03329} + for series in values.values(): + assert sorted(series) == list(range(2025, 2032)) + + +def test_gambling_lines_read_no_previous_efo(): + # March 2025's tables have no betting and gaming breakdown; the gambling + # lines take 2024-25 and 2025-26 from HMRC instead. + real = compute_receipts_growth(REAL_RECEIPTS[2026], REAL_RECEIPTS[2025]) + alone = compute_receipts_growth(REAL_RECEIPTS[2026], b"", specs=GAMBLING_SPECS) + assert {path: real[path].values for path in alone} == { + path: series.values for path, series in alone.items() + } + assert all(series.previous_line is None for series in alone.values()) + + +def test_gambling_outturn_must_reach_the_year_before_the_table(): + # A table from 2027-28 needs 2026-27 receipts, which HMRC has not + # published in the July 2026 tables. + with pytest.raises(ValueError, match="have no 2026-27, the year before Table"): + compute_receipts_growth( + make_receipts_xlsx(first_year=2026), b"", specs=GAMBLING_SPECS + ) + + +def test_a_measure_covers_every_year_from_its_first(): + spec = ReceiptsSeriesSpec( + path=("x",), + title="t", + row="r", + rates=(RateComponent("gov.hmrc.gambling_duty.lottery.rate"),), + measures=( + ReceiptsMeasure("m", ("October", 2026), ((2027, 100.0), (2028, 50.0))), + ), + ) + # No receipts before its first year; none in an EFO before its own. + assert measure_receipts(spec, [2026, 2027, 2028], None) == { + 2026: 0.0, + 2027: 0.1, + 2028: 0.05, + } + assert measure_receipts(spec, [2027], ("March", 2026)) == {2027: 0.0} + with pytest.raises(ValueError, match="m has no receipts for 2029-30"): + measure_receipts(spec, [2028, 2029], ("March", 2027)) + + +def test_bingo_holds_its_base_flat_after_the_forecast(): + rendered = render() + start = rendered.index(" bingo:") + block = rendered[start : rendered.index(" pool_betting:")] + assert "# Beyond the OBR forecast: held flat, as the rate is zero" in block + assert _values(rendered, GAMBLING_PATHS["bingo"])[2028] == 0.0 + assert max(_values(rendered, GAMBLING_PATHS["bingo"])) == 2028 + # Remote Gaming Duty follows average earnings, as the receipts lines do. + remote_gaming = _values(rendered, GAMBLING_PATHS["remote_gaming"]) + assert {year: remote_gaming[year] for year in (2028, 2031)} == { + 2028: 0.0, + 2031: 0.02, + } + + +def test_gambling_comments_and_references_name_hmrc_and_the_rates(): + rendered = render() + block = rendered[ + rendered.index(" remote_gaming:") : rendered.index(" lottery:") + ] + # March 2026's table starts in 2025-26, so no year comes from HMRC alone. + assert "# Outturn (2025" not in block + assert ( + "# Forecast (2025-26 over 2024-25) from OBR EFO March 2026, receipts " + 'Table 3.10 "Remote gaming duty", over HMRC\'s receipts (July 2026 ' + "Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts " + "over the rate the model charges" + ) in block + references = _series(yaml.safe_load(rendered), GAMBLING_PATHS["remote_gaming"])[ + "metadata" + ]["reference"] + assert [reference["title"] for reference in references] == [ + "OBR EFO March 2026 (detailed forecast tables, receipts, Table 3.10)", + "HMRC UK Betting and Gaming Statistics, July 2026 tables " + "(Table_1_BG_receipts, 2024-25 and 2025-26 receipts)", + ] + + +GAMBLING_RATE_PARAMETERS = sorted( + {component.parameter for spec in GAMBLING_SPECS for component in spec.rates} +) +RATE = st.floats(min_value=0.01, max_value=0.6, allow_nan=False) + + +@st.composite +def rate_schedules(draw): + """Dated rates for every gambling rate parameter: a first value, then + perhaps a change on any day from 2024 to 2028, to zero or not.""" + schedules = {} + for parameter in GAMBLING_RATE_PARAMETERS: + entries = [(date(2015, 1, 1), draw(RATE))] + if draw(st.booleans()): + day = draw(st.dates(date(2024, 1, 1), date(2028, 12, 31))) + entries.append((day, draw(st.one_of(st.just(0.0), RATE)))) + schedules[parameter] = tuple(entries) + return schedules + + +def oracle_rate(spec, schedules, year): + """The weighted mean of each part's daily rate over 1 April to 31 March.""" + days = [ + date.fromordinal(day) + for day in range(date(year, 4, 1).toordinal(), date(year + 1, 4, 1).toordinal()) + ] + total = sum(component.weight for component in spec.rates) + mean = 0.0 + for component in spec.rates: + entries = schedules[component.parameter] + daily = [ + max((e for e in entries if e[0] <= day), default=entries[0])[1] + for day in days + ] + mean += component.weight * sum(daily) / len(daily) + return mean / total + + +@settings(max_examples=40, deadline=None, derandomize=True) +@given( + levels=st.fixed_dictionaries( + {key: st.tuples(LEVELS, LEVELS, LEVELS) for key in GAMBLING_LABELS} + ), + schedules=rate_schedules(), + factor=st.floats(min_value=0.1, max_value=10, allow_nan=False), +) +def test_base_growth_is_receipts_growth_over_rate_growth(levels, schedules, factor): + """For any positive receipts and dated rates: one plus each year's growth + times the rate's growth is the receipts' growth (to the six decimals + written); a year whose rate, or the year before's, is zero holds the base + flat; and one factor on every rate, a rate reform the model applies + itself, changes no growth.""" + module = import_obr_forecasts + workbook = make_receipts_xlsx(gambling_levels=levels) + with pytest.MonkeyPatch.context() as patch: + patch.setattr(module, "rate_values", lambda parameter: schedules[parameter]) + growth = compute_receipts_growth(workbook, b"", specs=GAMBLING_SPECS) + scaled = { + parameter: tuple((day, value * factor) for day, value in entries) + for parameter, entries in schedules.items() + } + patch.setattr(module, "rate_values", lambda parameter: scaled[parameter]) + growth_scaled = compute_receipts_growth(workbook, b"", specs=GAMBLING_SPECS) + for spec in GAMBLING_SPECS: + key = spec.path[-1] + values = growth[spec.path].values + receipts = {2024: HMRC_RECEIPTS[key][0], 2025: levels[key][0]} + receipts |= {2026: levels[key][1], 2027: levels[key][2]} + rates = {year: oracle_rate(spec, schedules, year) for year in receipts} + assert sorted(values) == [2025, 2026, 2027] + for year, value in values.items(): + if rates[year] < 1e-12 or rates[year - 1] < 1e-12: + assert value == 0.0 + else: + exact = (receipts[year] / rates[year]) / ( + receipts[year - 1] / rates[year - 1] + ) + assert value == pytest.approx(exact - 1, abs=1e-6, rel=1e-9) + assert 1 + value > 0 + assert growth_scaled[spec.path].values == pytest.approx(values, abs=1.5e-6) diff --git a/policyengine_uk/utils/import_obr_forecasts.py b/policyengine_uk/utils/import_obr_forecasts.py index fe271e864f..6343519cee 100644 --- a/policyengine_uk/utils/import_obr_forecasts.py +++ b/policyengine_uk/utils/import_obr_forecasts.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from datetime import date from decimal import Decimal +from functools import lru_cache from io import BytesIO from pathlib import Path from urllib.parse import unquote @@ -591,6 +592,44 @@ def update_forecast_gap_yaml( VALUE_LINE_RE = re.compile(r"^\s*(\d{4})-\d{2}-\d{2}:") +@dataclass(frozen=True) +class RateComponent: + """A part of a duty's base and the rate parameter the model charges on it. + + ``parameter`` is the rate's path in the model's parameter tree, such as + ``gov.hmrc.gambling_duty.remote_gaming.rate``. ``weight`` is the part's + size, in any unit: divided by the total of a line's weights, it is the + part's share of the base, held at that share in every year. + """ + + parameter: str + weight: float = 1.0 + + +@dataclass(frozen=True) +class ReceiptsMeasure: + """Receipts a policy measure adds to a line, in £ million (as the OBR's + policy tables publish them) by the calendar year in which each fiscal + year starts. EFOs from ``release`` on include them, and so does outturn + once the measure starts: it has no receipts before its first year.""" + + description: str + release: tuple[str, int] + receipts_millions: tuple[tuple[int, float], ...] + + +@dataclass(frozen=True) +class ReceiptsOutturn: + """A line's outturn receipts from outside the EFOs, in £ thousand (as + published) by the calendar year in which each fiscal year starts. + ``source`` names them in comments and ``title`` in references.""" + + source: str + title: str + href: str + receipts_thousands: tuple[tuple[int, float], ...] + + @dataclass(frozen=True) class ReceiptsSeriesSpec: """A line of the OBR receipts tables, grown by a yoy_growth.yaml series. @@ -600,11 +639,56 @@ class ReceiptsSeriesSpec: receipts on a cash basis are Table 3.8 in March 2026 and 3.9 in March 2025. Titles and row labels are compared lowercased, without footnote markers. + + A line with ``rates`` grows a duty's base rather than its receipts, so a + rate change the model applies itself moves receipts only once. Each + year's base is its receipts, less the ``measures`` they include, over + the rate the model charges in that fiscal year (see ``line_rate``). A + year whose rate is zero has no base, so the series holds the base flat + into and out of it. A line with ``outturn`` takes the years before the + EFO's first from it, not from the previous EFO, which it does not read. + ``references`` are cited after the sources of the levels. """ path: tuple[str, ...] title: str row: str + rates: tuple[RateComponent, ...] = () + measures: tuple[ReceiptsMeasure, ...] = () + outturn: ReceiptsOutturn | None = None + references: tuple[tuple[str, str], ...] = () + + +GAMBLING_RATES = "gov.hmrc.gambling_duty" + + +def gambling_spec( + key: str, + row: str, + rates: tuple[RateComponent, ...], + receipts_thousands: tuple[tuple[int, float], ...], + **extra, +) -> ReceiptsSeriesSpec: + """A betting and gaming duty's base (#2738): its line in the receipts + tables' forecast breakdown (Table 4.10 in October 2026, 3.10 in March + 2026), which publishes forecasts only, after HMRC's outturn. The model's + gambling inputs start in 2024-25, the first year here.""" + return ReceiptsSeriesSpec( + path=("gambling_duty_bases", key), + title="betting and gaming duties forecast breakdown", + row=row, + rates=rates, + outturn=ReceiptsOutturn( + source="HMRC's receipts (July 2026 Betting and Gaming tables, " + "Table_1_BG_receipts)", + title="HMRC UK Betting and Gaming Statistics, July 2026 tables " + "(Table_1_BG_receipts, 2024-25 and 2025-26 receipts)", + href="https://assets.publishing.service.gov.uk/media/" + "6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods", + receipts_thousands=receipts_thousands, + ), + **extra, + ) RECEIPTS_SERIES_SPECS = ( @@ -628,9 +712,189 @@ class ReceiptsSeriesSpec: title="other taxes", row="northern ireland business rates", ), + # The seven betting and gaming duties' bases. HMRC's receipts are row 45 + # (2024-25) and row 46 (2025-26) of Table_1_BG_receipts, £ thousand. + gambling_spec( + "remote_gaming", + "remote gaming duty", + (RateComponent(f"{GAMBLING_RATES}.remote_gaming.rate"),), + ((2024, 1_162_539), (2025, 1_320_966)), + ), + gambling_spec( + "lottery", + "lottery duty", + (RateComponent(f"{GAMBLING_RATES}.lottery.rate"),), + ((2024, 931_887), (2025, 980_132)), + ), + # Each part's 2024-25 base (£): HMRC's General Betting Duty receipts over + # 15%, split by the Gambling Commission's 2024-25 gross gambling yield as + # Microcosm splits the model's inputs. Fixed odds take remote UK horse + # racing, land-based fixed odds and racing pools. Spread betting, which + # the Commission does not regulate, has no published base. + gambling_spec( + "general_betting", + "general betting duty", + ( + RateComponent( + f"{GAMBLING_RATES}.general_betting.rates.fixed_odds", 2_471_124_767 + ), + RateComponent( + f"{GAMBLING_RATES}.general_betting.rates.remote", 2_163_978_657 + ), + RateComponent( + f"{GAMBLING_RATES}.general_betting.rates.exchange_commission", + 123_949_909, + ), + ), + ((2024, 713_858), (2025, 684_502)), + references=( + ( + "Microcosm, FY2024-25 General Betting Duty bases split by the " + "Gambling Commission's gross gambling yield (docs/evidence/" + "uk-excise-quantities-1359/outputs/national_reconciliation.json)", + "https://github.com/PolicyEngine/microcosm/blob/main/docs/evidence/" + "uk-excise-quantities-1359/outputs/national_reconciliation.json", + ), + ( + "Gambling Commission, industry statistics, April 2024 to March 2025", + "https://www.gamblingcommission.gov.uk/statistics-and-research/" + "publication/industry-statistics-annual-report-financial-year-april-" + "2024-to-march-2025", + ), + ), + ), + # Each band's 2024-25 base (£): HMRC's Machine Games Duty liabilities + # (Table_2_MGD row 20) over its rate. The October 2026 line includes + # Autumn Budget 2026's Machine Games Duty measures (paragraph 4.45), + # which come off before the rates. + gambling_spec( + "machine_games", + "machine games duty", + ( + RateComponent(f"{GAMBLING_RATES}.machine_games.rates.lower", 138_420_000), + RateComponent( + f"{GAMBLING_RATES}.machine_games.rates.standard", 3_023_740_000 + ), + RateComponent(f"{GAMBLING_RATES}.machine_games.rates.higher", 2_384_000), + ), + ((2024, 609_245), (2025, 641_010)), + measures=( + ReceiptsMeasure( + "Autumn Budget 2026 measure 56 (Machine Games Duty on higher-stake " + "machines and supplier liability)", + ("October", 2026), + ( + (2026, 0.0), + (2027, 144.15331361978284), + (2028, 162.30500845324954), + (2029, 157.57136998103317), + (2030, 155.90187446314559), + (2031, 156.40657502685724), + ), + ), + ), + references=( + ( + "OBR EFO October 2026 (detailed forecast tables, policy, Table 1.1, " + "measure 56, Betting)", + "https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/", + ), + ), + ), + # The household Gaming Duty formula charges one effective rate, not the + # statutory bands. + gambling_spec( + "gaming", + "gaming duty", + (RateComponent("household.consumption.gambling.casino_effective_rate"),), + ((2024, 165_397), (2025, 173_902)), + ), + gambling_spec( + "bingo", + "bingo duty", + (RateComponent(f"{GAMBLING_RATES}.bingo.rate"),), + ((2024, 25_233), (2025, 26_073)), + ), + gambling_spec( + "pool_betting", + "pool betting duty", + (RateComponent(f"{GAMBLING_RATES}.pool_betting.rate"),), + ((2024, 8_125), (2025, 3_970)), + ), ) +@lru_cache(maxsize=None) +def rate_values(parameter: str) -> tuple[tuple[date, float], ...]: + """A rate parameter's dated values, earliest first, from its file in the + model's parameter tree.""" + path = get_repo_root() / "policyengine_uk/parameters" / Path(*parameter.split(".")) + values = yaml.safe_load(path.with_suffix(".yaml").read_text())["values"] + entries = [] + for instant, value in values.items(): + if isinstance(value, dict): + value = value["value"] + entries.append((date.fromisoformat(str(instant)), float(value))) + if not entries: + raise ValueError(f"{parameter} has no values") + return tuple(sorted(entries)) + + +def fiscal_year_rate(parameter: str, year: int) -> float: + """A rate's mean over the OBR's fiscal year ``year``, 1 April to 31 March, + weighted by days. Before the rate's first value it takes that value, as + the model does: it backdates each parameter's first value.""" + start, end = date(year, 4, 1), date(year + 1, 4, 1) + entries = rate_values(parameter) + segments = [] + for index, (instant, value) in enumerate(entries): + lower = start if index == 0 else max(instant, start) + upper = min(entries[index + 1][0], end) if index + 1 < len(entries) else end + if upper > lower: + segments.append((value, (upper - lower).days)) + if len({value for value, _ in segments}) == 1: + return segments[0][0] + return sum(value * days for value, days in segments) / (end - start).days + + +def line_rate(spec: ReceiptsSeriesSpec, year: int) -> float: + """The rate the model charges on a line's base in fiscal year ``year``: + its parts' fiscal-year rates, averaged by their weights.""" + total = sum(component.weight for component in spec.rates) + return ( + sum( + component.weight * fiscal_year_rate(component.parameter, year) + for component in spec.rates + ) + / total + ) + + +def measure_receipts( + spec: ReceiptsSeriesSpec, years: list[int], release: tuple[str, int] | None +) -> dict[int, float]: + """The receipts (£bn) of ``spec``'s measures in each of ``years``, as an + EFO of ``release`` includes them, or outturn if ``release`` is None. + A measure has none before its first year, and must cover every later + year it is included in.""" + total = dict.fromkeys(years, 0.0) + for measure in spec.measures: + if release is not None and release_order(release) < release_order( + measure.release + ): + continue + receipts = dict(measure.receipts_millions) + missing = [year for year in years if year > max(receipts)] + if missing: + raise ValueError( + f"{'.'.join(spec.path)}: {measure.description} has no receipts " + "for " + ", ".join(fiscal_year_label(year) for year in missing) + ) + for year in years: + total[year] += receipts.get(year, 0.0) / 1000 + return total + + @dataclass(frozen=True) class ReceiptsLine: """A receipts line as one EFO publishes it: £bn by the calendar year in @@ -648,12 +912,15 @@ class ReceiptsGrowth: The first year is outturn growth: this EFO's first (outturn) year over the year before in the previous EFO, where it is outturn. Each later - year is the ratio of this EFO's consecutive levels. + year is the ratio of this EFO's consecutive levels. A line with its own + outturn starts at that outturn's second year and has no previous line; + one with rates grows its base instead (see ``ReceiptsSeriesSpec``). """ values: dict[int, float] line: ReceiptsLine - previous_line: ReceiptsLine + previous_line: ReceiptsLine | None + spec: ReceiptsSeriesSpec | None = None def fiscal_year_label(year: int) -> str: @@ -860,48 +1127,93 @@ def find_receipts_line( def compute_receipts_growth( - xlsx_bytes: bytes, previous_xlsx_bytes: bytes + xlsx_bytes: bytes, + previous_xlsx_bytes: bytes, + release: tuple[str, int] | None = None, + specs: tuple[ReceiptsSeriesSpec, ...] = RECEIPTS_SERIES_SPECS, ) -> dict[tuple[str, ...], ReceiptsGrowth]: """Growth in each receipts line, from an EFO's receipts tables and an earlier EFO's, whose outturn is the base for the first year's growth. The first year must be outturn in this EFO, and the year before it - outturn in the earlier one (March 2025 for March 2026). + outturn in the earlier one (March 2025 for March 2026). A line with its + own outturn needs that outturn to reach the year before this EFO's + first instead. ``release``, which says which measures this EFO + includes, defaults to the one its contents sheet names. """ sheets = read_workbook_sheets(xlsx_bytes) - previous_sheets = read_workbook_sheets(previous_xlsx_bytes) + previous_sheets = None result = {} - for spec in RECEIPTS_SERIES_SPECS: + for spec in specs: name = ".".join(spec.path) line = find_receipts_line(sheets, spec) - previous = find_receipts_line(previous_sheets, spec) first_year = min(line.levels) base_year = first_year - 1 - if first_year not in line.outturn_years: - raise ValueError( - f"{name}: {fiscal_year_label(first_year)}, the first year in " - f"Table {line.table}, is not outturn" - ) - if base_year not in previous.outturn_years: - raise ValueError( - f"{name}: the previous receipts tables have no " - f"{fiscal_year_label(base_year)} outturn; pass the EFO whose " - f"outturn year is {fiscal_year_label(base_year)}" - ) - levels = {base_year: previous.levels[base_year], **line.levels} + if spec.outturn is not None: + previous = None + outturn = { + year: value / 1e6 + for year, value in spec.outturn.receipts_thousands + if year < first_year + } + if base_year not in outturn: + raise ValueError( + f"{name}: {spec.outturn.source} have no " + f"{fiscal_year_label(base_year)}, the year before Table " + f"{line.table}'s first; add them" + ) + levels = {**outturn, **line.levels} + if sorted(levels) != list(range(min(levels), max(levels) + 1)): + raise ValueError(f"{name}: the outturn skips a year: {sorted(levels)}") + else: + if previous_sheets is None: + previous_sheets = read_workbook_sheets(previous_xlsx_bytes) + previous = find_receipts_line(previous_sheets, spec) + if first_year not in line.outturn_years: + raise ValueError( + f"{name}: {fiscal_year_label(first_year)}, the first year in " + f"Table {line.table}, is not outturn" + ) + if base_year not in previous.outturn_years: + raise ValueError( + f"{name}: the previous receipts tables have no " + f"{fiscal_year_label(base_year)} outturn; pass the EFO whose " + f"outturn year is {fiscal_year_label(base_year)}" + ) + levels = {base_year: previous.levels[base_year], **line.levels} + if spec.measures: + efo = release or infer_workbook_release(xlsx_bytes) + if efo is None: + raise ValueError( + f"{name}: could not tell which EFO the receipts tables are " + "from, so which measures they include" + ) + outturn_years = sorted(year for year in levels if year < first_year) + for years, source in ((outturn_years, None), (sorted(line.levels), efo)): + for year, receipts in measure_receipts(spec, years, source).items(): + levels[year] -= receipts + rates = {year: line_rate(spec, year) for year in levels} if spec.rates else {} # Growth to or from a level at or below zero means nothing, and a - # zero last level would zero the series' index from then on. + # zero last level would zero the series' index from then on. A year + # whose rate is zero has no base to grow, whatever its receipts. for year, level in sorted(levels.items()): - if level <= 0: + if level <= 0 and rates.get(year) != 0: raise ValueError( f"{name}: {fiscal_year_label(year)} receipts of {level} are " "not positive, so there is no growth to or from them" ) - values = { - year: round(levels[year] / levels[year - 1] - 1, 6) + 0.0 - for year in sorted(line.levels) - } - result[spec.path] = ReceiptsGrowth(values, line, previous) + values = {} + for year in sorted(levels)[1:]: + if not spec.rates: + values[year] = round(levels[year] / levels[year - 1] - 1, 6) + 0.0 + elif rates[year] == 0 or rates[year - 1] == 0: + values[year] = 0.0 + else: + base_growth = (levels[year] / rates[year]) / ( + levels[year - 1] / rates[year - 1] + ) + values[year] = round(base_growth - 1, 6) + 0.0 + result[spec.path] = ReceiptsGrowth(values, line, previous, spec) return result @@ -974,6 +1286,7 @@ def render_receipts_values( tail: dict[int, float], release: tuple[str, int], previous_release: tuple[str, int], + tail_comment: str = "the average_earnings long-run path above", ) -> list[str]: """A series' values entries: earlier years as they were, then this EFO's growth, then the average_earnings path.""" @@ -994,17 +1307,44 @@ def render_receipts_values( month, year = release previous_month, previous_year = previous_release published, previous = growth.line, growth.previous_line - table = f"Table {published.table}" - if previous.table != published.table: - table += f" ({previous_month} {previous_year}: Table {previous.table})" + efo_first = min(published.levels) pad = " " * indent - lines = kept + [ - f"{pad}# Outturn ({fiscal_year_label(first_year)} over " - f"{fiscal_year_label(first_year - 1)}) from OBR EFO {month} {year} over " - f'{previous_month} {previous_year}, receipts {table} "{published.label}"', - f"{pad}{first_year}-01-01: {format_growth(growth.values[first_year])}", - ] - later = sorted(y for y in growth.values if y > first_year) + first_kind = "Outturn" if efo_first in published.outturn_years else "Forecast" + basis = receipts_basis(growth.spec) + lines = list(kept) + if previous is None: + # The years before the EFO's first, and the base of its first, are + # the line's own outturn. + source = growth.spec.outturn.source + years = sorted(y for y in growth.values if y < efo_first) + if years: + span = ( + f"{fiscal_year_label(years[0])} over {fiscal_year_label(years[0] - 1)}" + if len(years) == 1 + else f"{years[0]}-{years[-1]}" + ) + lines.append(f"{pad}# Outturn ({span}) from {source}{basis}") + lines += [ + f"{pad}{y}-01-01: {format_growth(growth.values[y])}" for y in years + ] + lines.append( + f"{pad}# {first_kind} ({fiscal_year_label(efo_first)} over " + f"{fiscal_year_label(efo_first - 1)}) from OBR EFO {month} {year}, " + f'receipts Table {published.table} "{published.label}", over ' + f"{source}{basis}" + ) + else: + table = f"Table {published.table}" + if previous.table != published.table: + table += f" ({previous_month} {previous_year}: Table {previous.table})" + lines.append( + f"{pad}# {first_kind} ({fiscal_year_label(efo_first)} over " + f"{fiscal_year_label(efo_first - 1)}) from OBR EFO {month} {year} over " + f'{previous_month} {previous_year}, receipts {table} "{published.label}"' + f"{basis}" + ) + lines.append(f"{pad}{efo_first}-01-01: {format_growth(growth.values[efo_first])}") + later = sorted(y for y in growth.values if y > efo_first) for kind in ("Outturn", "Forecast"): years = [ y for y in later if (y in published.outturn_years) == (kind == "Outturn") @@ -1014,16 +1354,59 @@ def render_receipts_values( span = f"{years[0]}" if len(years) == 1 else f"{years[0]}-{years[-1]}" lines.append( f"{pad}# {kind} ({span}) from OBR EFO {month} {year}, receipts " - f"Table {published.table}" + f"Table {published.table}{basis}" ) lines += [f"{pad}{y}-01-01: {format_growth(growth.values[y])}" for y in years] - lines.append( - f"{pad}# Beyond the OBR forecast: the average_earnings long-run path above" - ) + lines.append(f"{pad}# Beyond the OBR forecast: {tail_comment}") lines += [f"{pad}{y}-01-01: {format_growth(v)}" for y, v in sorted(tail.items())] return lines +def receipts_basis(spec: ReceiptsSeriesSpec | None) -> str: + """How a line's growth is measured, for its comments: nothing for + receipts, the measures and rates taken out for a base.""" + if spec is None or not spec.rates: + return "" + measures = "".join(f" less {measure.description}," for measure in spec.measures) + return f"; base growth: receipts{measures} over the rate the model charges" + + +def receipts_tail(content: str, growth: ReceiptsGrowth) -> tuple[dict[int, float], str]: + """A series' growth after the OBR's forecast, and its comment: the + average_earnings path, held at zero in years a base's rate is zero in or + comes from.""" + last = max(growth.values) + path = average_earnings_path(content, last) + spec = growth.spec + if spec is None or not spec.rates: + return path, "the average_earnings long-run path above" + # Rates hold their last value after their last change, as the path does. + changes = [ + instant.year + for component in spec.rates + for instant, _ in rate_values(component.parameter) + ] + end = max(max(path), max(changes) + 1, last + 1) + yearly = {} + for year in range(last + 1, end + 1): + if line_rate(spec, year) == 0 or line_rate(spec, year - 1) == 0: + yearly[year] = 0.0 + else: + yearly[year] = path[max(y for y in path if y <= year)] + tail = { + year: value + for year, value in yearly.items() + if year == last + 1 or value != yearly[year - 1] + } + if all(value == 0 for value in yearly.values()): + return tail, "held flat, as the rate is zero" + if any(value == 0 for value in yearly.values()): + return tail, ( + "the average_earnings long-run path above, held flat where the rate is zero" + ) + return tail, "the average_earnings long-run path above" + + def receipts_references( growth: ReceiptsGrowth, release: tuple[str, int], @@ -1031,19 +1414,29 @@ def receipts_references( ) -> list[dict[str, str]]: month, year = release previous_month, previous_year = previous_release + current = { + "title": f"OBR EFO {month} {year} (detailed forecast tables, receipts, " + f"Table {growth.line.table})", + "href": build_efo_href(month, year), + } + spec = growth.spec + extra = [ + {"title": title, "href": href} + for title, href in (spec.references if spec is not None else ()) + ] + if growth.previous_line is None: + outturn = {"title": spec.outturn.title, "href": spec.outturn.href} + return [current, outturn, *extra] base_year = min(growth.values) - 1 return [ - { - "title": f"OBR EFO {month} {year} (detailed forecast tables, receipts, " - f"Table {growth.line.table})", - "href": build_efo_href(month, year), - }, + current, { "title": f"OBR EFO {previous_month} {previous_year} (detailed forecast " f"tables, receipts, Table {growth.previous_line.table}; " f"{fiscal_year_label(base_year)} outturn)", "href": build_efo_href(previous_month, previous_year), }, + *extra, ] @@ -1065,7 +1458,7 @@ def render_receipts_growth_yaml( expected = copy.deepcopy(before) lines = content.split("\n") for path, series in growth.items(): - tail = average_earnings_path(content, max(series.values)) + tail, tail_comment = receipts_tail(content, series) references = receipts_references(series, release, previous_release) start, end = _series_block(lines, ("obr", *path)) @@ -1077,7 +1470,13 @@ def render_receipts_growth_yaml( indent + 2, ) new_values = render_receipts_values( - old_values, entry_indent, series, tail, release, previous_release + old_values, + entry_indent, + series, + tail, + release, + previous_release, + tail_comment, ) lines = [*lines[: values_start + 1], *new_values, *lines[values_end:]] @@ -1392,7 +1791,9 @@ def build_arg_parser() -> argparse.ArgumentParser: action="store_true", help=( "Do not update the receipts growth series (stamp_duty_land_tax, " - "business_rates) that project corporate SDLT and business rates" + "business_rates) that project corporate SDLT and business rates, " + "or the gambling_duty_bases series that project the gambling " + "duty inputs" ), ) parser.add_argument( @@ -1580,7 +1981,9 @@ def main(argv: list[str] | None = None) -> int: f"({previous_release[0]} {previous_release[1]}) must come from " f"an EFO before {month} {year}" ) - receipts_growth = compute_receipts_growth(receipts_bytes, previous_bytes) + receipts_growth = compute_receipts_growth( + receipts_bytes, previous_bytes, (month, year) + ) print_receipts_summary(receipts_growth) calendar_values = ( read_calendar_values(source_yaml_path) if args.gaps_only else series_values diff --git a/policyengine_uk/variables/input/consumption/gambling/betting_exchange_commission.py b/policyengine_uk/variables/input/consumption/gambling/betting_exchange_commission.py index 453f5c85b9..0aefbecf32 100644 --- a/policyengine_uk/variables/input/consumption/gambling/betting_exchange_commission.py +++ b/policyengine_uk/variables/input/consumption/gambling/betting_exchange_commission.py @@ -7,5 +7,8 @@ class betting_exchange_commission(Variable): definition_period = YEAR default_value = 0 unit = GBP + uprating = ( + "gov.economic_assumptions.indices.obr.gambling_duty_bases.general_betting" + ) label = "Chargeable betting exchange commission attributed to the household" reference = "https://www.legislation.gov.uk/ukpga/2014/26/section/141" diff --git a/policyengine_uk/variables/input/consumption/gambling/bingo_net_receipts.py b/policyengine_uk/variables/input/consumption/gambling/bingo_net_receipts.py index ad67462e6d..26ed12b9c1 100644 --- a/policyengine_uk/variables/input/consumption/gambling/bingo_net_receipts.py +++ b/policyengine_uk/variables/input/consumption/gambling/bingo_net_receipts.py @@ -7,5 +7,6 @@ class bingo_net_receipts(Variable): definition_period = YEAR default_value = 0 unit = GBP + uprating = "gov.economic_assumptions.indices.obr.gambling_duty_bases.bingo" label = "Attributed dutiable bingo promotion profits" reference = "https://www.legislation.gov.uk/ukpga/1981/63/section/17/2024-04-06" diff --git a/policyengine_uk/variables/input/consumption/gambling/financial_spread_betting_net_losses.py b/policyengine_uk/variables/input/consumption/gambling/financial_spread_betting_net_losses.py index 8aefb4c920..3501feade7 100644 --- a/policyengine_uk/variables/input/consumption/gambling/financial_spread_betting_net_losses.py +++ b/policyengine_uk/variables/input/consumption/gambling/financial_spread_betting_net_losses.py @@ -7,5 +7,8 @@ class financial_spread_betting_net_losses(Variable): definition_period = YEAR default_value = 0 unit = GBP + uprating = ( + "gov.economic_assumptions.indices.obr.gambling_duty_bases.general_betting" + ) label = "Attributed dutiable financial spread betting losses" reference = "https://www.legislation.gov.uk/ukpga/2014/26/section/128" diff --git a/policyengine_uk/variables/input/consumption/gambling/horse_and_dog_pool_betting_net_losses.py b/policyengine_uk/variables/input/consumption/gambling/horse_and_dog_pool_betting_net_losses.py index f628b80280..c57172ce1d 100644 --- a/policyengine_uk/variables/input/consumption/gambling/horse_and_dog_pool_betting_net_losses.py +++ b/policyengine_uk/variables/input/consumption/gambling/horse_and_dog_pool_betting_net_losses.py @@ -7,5 +7,8 @@ class horse_and_dog_pool_betting_net_losses(Variable): definition_period = YEAR default_value = 0 unit = GBP + uprating = ( + "gov.economic_assumptions.indices.obr.gambling_duty_bases.general_betting" + ) label = "Attributed dutiable horse and dog racing pool losses" reference = "https://www.legislation.gov.uk/ukpga/2014/26/section/134" diff --git a/policyengine_uk/variables/input/consumption/gambling/land_based_casino_net_losses.py b/policyengine_uk/variables/input/consumption/gambling/land_based_casino_net_losses.py index bb19e35e74..2c75dbef9b 100644 --- a/policyengine_uk/variables/input/consumption/gambling/land_based_casino_net_losses.py +++ b/policyengine_uk/variables/input/consumption/gambling/land_based_casino_net_losses.py @@ -7,5 +7,6 @@ class land_based_casino_net_losses(Variable): definition_period = YEAR default_value = 0 unit = GBP + uprating = "gov.economic_assumptions.indices.obr.gambling_duty_bases.gaming" label = "Attributed dutiable land-based casino gaming yield" reference = "https://www.legislation.gov.uk/ukpga/1997/16/section/11" diff --git a/policyengine_uk/variables/input/consumption/gambling/land_based_fixed_odds_betting_net_losses.py b/policyengine_uk/variables/input/consumption/gambling/land_based_fixed_odds_betting_net_losses.py index 0156162579..f816f4f23f 100644 --- a/policyengine_uk/variables/input/consumption/gambling/land_based_fixed_odds_betting_net_losses.py +++ b/policyengine_uk/variables/input/consumption/gambling/land_based_fixed_odds_betting_net_losses.py @@ -7,5 +7,8 @@ class land_based_fixed_odds_betting_net_losses(Variable): definition_period = YEAR default_value = 0 unit = GBP + uprating = ( + "gov.economic_assumptions.indices.obr.gambling_duty_bases.general_betting" + ) label = "Attributed dutiable land-based fixed-odds losses" reference = "https://www.legislation.gov.uk/ukpga/2014/26/section/126" diff --git a/policyengine_uk/variables/input/consumption/gambling/machine_games_higher_rate_net_takings.py b/policyengine_uk/variables/input/consumption/gambling/machine_games_higher_rate_net_takings.py index 5a80acaa9c..6ffad81df7 100644 --- a/policyengine_uk/variables/input/consumption/gambling/machine_games_higher_rate_net_takings.py +++ b/policyengine_uk/variables/input/consumption/gambling/machine_games_higher_rate_net_takings.py @@ -7,5 +7,6 @@ class machine_games_higher_rate_net_takings(Variable): definition_period = YEAR default_value = 0 unit = GBP + uprating = "gov.economic_assumptions.indices.obr.gambling_duty_bases.machine_games" label = "Attributed dutiable higher-rate machine games net takings" reference = "https://www.gov.uk/guidance/machine-games-duty-excise-notice-452" diff --git a/policyengine_uk/variables/input/consumption/gambling/machine_games_lower_rate_net_takings.py b/policyengine_uk/variables/input/consumption/gambling/machine_games_lower_rate_net_takings.py index f746d877d6..021b5a7b53 100644 --- a/policyengine_uk/variables/input/consumption/gambling/machine_games_lower_rate_net_takings.py +++ b/policyengine_uk/variables/input/consumption/gambling/machine_games_lower_rate_net_takings.py @@ -7,5 +7,6 @@ class machine_games_lower_rate_net_takings(Variable): definition_period = YEAR default_value = 0 unit = GBP + uprating = "gov.economic_assumptions.indices.obr.gambling_duty_bases.machine_games" label = "Attributed dutiable lower-rate machine games net takings" reference = "https://www.gov.uk/guidance/machine-games-duty-excise-notice-452" diff --git a/policyengine_uk/variables/input/consumption/gambling/machine_games_standard_rate_net_takings.py b/policyengine_uk/variables/input/consumption/gambling/machine_games_standard_rate_net_takings.py index c4acb3d525..404a600f48 100644 --- a/policyengine_uk/variables/input/consumption/gambling/machine_games_standard_rate_net_takings.py +++ b/policyengine_uk/variables/input/consumption/gambling/machine_games_standard_rate_net_takings.py @@ -7,5 +7,6 @@ class machine_games_standard_rate_net_takings(Variable): definition_period = YEAR default_value = 0 unit = GBP + uprating = "gov.economic_assumptions.indices.obr.gambling_duty_bases.machine_games" label = "Attributed dutiable standard-rate machine games net takings" reference = "https://www.gov.uk/guidance/machine-games-duty-excise-notice-452" diff --git a/policyengine_uk/variables/input/consumption/gambling/national_lottery_ticket_spending.py b/policyengine_uk/variables/input/consumption/gambling/national_lottery_ticket_spending.py index e493e2bbbf..f6d50bd56a 100644 --- a/policyengine_uk/variables/input/consumption/gambling/national_lottery_ticket_spending.py +++ b/policyengine_uk/variables/input/consumption/gambling/national_lottery_ticket_spending.py @@ -7,5 +7,6 @@ class national_lottery_ticket_spending(Variable): definition_period = YEAR default_value = 0 unit = GBP + uprating = "gov.economic_assumptions.indices.obr.gambling_duty_bases.lottery" label = "Chargeable National Lottery ticket consideration" reference = "https://www.legislation.gov.uk/ukpga/1993/34/section/25" diff --git a/policyengine_uk/variables/input/consumption/gambling/other_spread_betting_net_losses.py b/policyengine_uk/variables/input/consumption/gambling/other_spread_betting_net_losses.py index 075809c3b2..7a1ff059e7 100644 --- a/policyengine_uk/variables/input/consumption/gambling/other_spread_betting_net_losses.py +++ b/policyengine_uk/variables/input/consumption/gambling/other_spread_betting_net_losses.py @@ -7,5 +7,8 @@ class other_spread_betting_net_losses(Variable): definition_period = YEAR default_value = 0 unit = GBP + uprating = ( + "gov.economic_assumptions.indices.obr.gambling_duty_bases.general_betting" + ) label = "Attributed dutiable other spread betting losses" reference = "https://www.legislation.gov.uk/ukpga/2014/26/section/130" diff --git a/policyengine_uk/variables/input/consumption/gambling/pool_betting_net_receipts.py b/policyengine_uk/variables/input/consumption/gambling/pool_betting_net_receipts.py index d3f88029c1..12dadc7824 100644 --- a/policyengine_uk/variables/input/consumption/gambling/pool_betting_net_receipts.py +++ b/policyengine_uk/variables/input/consumption/gambling/pool_betting_net_receipts.py @@ -7,5 +7,6 @@ class pool_betting_net_receipts(Variable): definition_period = YEAR default_value = 0 unit = GBP + uprating = "gov.economic_assumptions.indices.obr.gambling_duty_bases.pool_betting" label = "Attributed dutiable non-racing pool betting net receipts" reference = "https://www.legislation.gov.uk/ukpga/2014/26/section/143" diff --git a/policyengine_uk/variables/input/consumption/gambling/remote_gaming_net_losses.py b/policyengine_uk/variables/input/consumption/gambling/remote_gaming_net_losses.py index ecaae21921..e92645b39a 100644 --- a/policyengine_uk/variables/input/consumption/gambling/remote_gaming_net_losses.py +++ b/policyengine_uk/variables/input/consumption/gambling/remote_gaming_net_losses.py @@ -7,5 +7,6 @@ class remote_gaming_net_losses(Variable): definition_period = YEAR default_value = 0 unit = GBP + uprating = "gov.economic_assumptions.indices.obr.gambling_duty_bases.remote_gaming" label = "Attributed chargeable non-exempt remote gaming losses" reference = "https://www.legislation.gov.uk/ukpga/2014/26/section/161" diff --git a/policyengine_uk/variables/input/consumption/gambling/remote_other_betting_net_losses.py b/policyengine_uk/variables/input/consumption/gambling/remote_other_betting_net_losses.py index c2ee45223c..40ca355a0d 100644 --- a/policyengine_uk/variables/input/consumption/gambling/remote_other_betting_net_losses.py +++ b/policyengine_uk/variables/input/consumption/gambling/remote_other_betting_net_losses.py @@ -7,5 +7,8 @@ class remote_other_betting_net_losses(Variable): definition_period = YEAR default_value = 0 unit = GBP + uprating = ( + "gov.economic_assumptions.indices.obr.gambling_duty_bases.general_betting" + ) label = "Attributed dutiable other remote fixed-odds losses" reference = "https://www.legislation.gov.uk/ukpga/2026/11/section/87" diff --git a/policyengine_uk/variables/input/consumption/gambling/remote_uk_horseracing_betting_net_losses.py b/policyengine_uk/variables/input/consumption/gambling/remote_uk_horseracing_betting_net_losses.py index 367b3e80e3..0db5f7657f 100644 --- a/policyengine_uk/variables/input/consumption/gambling/remote_uk_horseracing_betting_net_losses.py +++ b/policyengine_uk/variables/input/consumption/gambling/remote_uk_horseracing_betting_net_losses.py @@ -7,5 +7,8 @@ class remote_uk_horseracing_betting_net_losses(Variable): definition_period = YEAR default_value = 0 unit = GBP + uprating = ( + "gov.economic_assumptions.indices.obr.gambling_duty_bases.general_betting" + ) label = "Attributed dutiable remote UK horseracing fixed-odds losses" reference = "https://www.legislation.gov.uk/ukpga/2014/26/section/127" From a5bcc7871bcc496f79710b1f3ef3498225384e1b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 21:51:21 -0400 Subject: [PATCH 02/17] Test and document the gambling duty base projection The new tests pin the Remote Gaming, General Betting and Machine Games Duty indices to Table 4.10 and the rates by hand, check that dataset projection and Core's variable uprating agree, and check that bases giving HMRC's 2024-25 receipts give HMRC's 2025-26 receipts and then Table 4.10, less measure 56, in every year to 2031-32. A property test checks that a rate reform leaves the bases alone and moves receipts by the rate. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2738.fixed.md | 1 + docs/engineering/gambling-duties.md | 95 +++++ .../gov/economic_assumptions/README.md | 25 ++ .../test_gambling_duty_base_projection.py | 394 ++++++++++++++++++ 4 files changed, 515 insertions(+) create mode 100644 changelog.d/2738.fixed.md create mode 100644 policyengine_uk/tests/test_gambling_duty_base_projection.py diff --git a/changelog.d/2738.fixed.md b/changelog.d/2738.fixed.md new file mode 100644 index 0000000000..5e11f22c2e --- /dev/null +++ b/changelog.d/2738.fixed.md @@ -0,0 +1 @@ +Projected the fifteen gambling duty inputs from HMRC's 2025-26 receipts and the OBR's October 2026 betting and gaming forecast (receipts Table 4.10), divided by the rates the model charges, so they no longer stay at their 2024-25 cash level and the 2026 and 2027 remote gambling rate rises apply once. diff --git a/docs/engineering/gambling-duties.md b/docs/engineering/gambling-duties.md index ba66d26751..298301000b 100644 --- a/docs/engineering/gambling-duties.md +++ b/docs/engineering/gambling-duties.md @@ -215,6 +215,101 @@ Sources: [Finance Act 1997, sections 10 and 11](https://www.legislation.gov.uk/u [HMRC July 2026 tables](https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods) and [Gambling Commission 2024/25 industry statistics](https://www.gamblingcommission.gov.uk/statistics-and-research/publication/industry-statistics-annual-report-financial-year-april-2024-to-march-2025). +## Projection + +Each input declares its duty's index, +`gov.economic_assumptions.indices.obr.gambling_duty_bases.`, and +dataset projection grows it by the same duty's series in `yoy_growth.yaml` +(`policyengine_uk/data/uprating_indices.yaml`). A series grows the duty's +base, not its receipts: each year's receipts, less the Budget measures they +include, divided by the rate the model charges that year. The model then +applies its own rate changes once. Grown with receipts instead, the base +would carry Remote Gaming Duty's rise to 40% and the formula would charge +40% on it again. + +| Fiscal year | Receipts | Source | +| --- | --- | --- | +| 2024-25 | Base year (index 1) | HMRC July 2026 Betting and Gaming tables, `Table_1_BG_receipts` row 45 | +| 2025-26 | Outturn | The same table, row 46 | +| 2026-27 to 2031-32 | Forecast | OBR October 2026 Economic and Fiscal Outlook, receipts Table 4.10, "Betting and gaming duties forecast breakdown" | +| 2032-33 on | None | The `average_earnings` long-run path, as for the other receipts series | + +`policyengine_uk/utils/import_obr_forecasts.py` writes the series from +`RECEIPTS_SERIES_SPECS`. It reads each rate from the model's parameter file +and averages it over the OBR's April-to-March year by days. Each duty's rate +comes out as follows: + +- **Remote Gaming Duty**: 21% to 31 March 2026, 40% from 1 April 2026. + Receipts grow 108.3% into 2026-27 (£2,752.0 million against HMRC's + £1,321.0 million); the base grows (2,752.0 / 40%) / (1,321.0 / 21%) − 1 = + 9.38%. +- **General Betting Duty**: the remote part, 45.5% of the 2024-25 base, goes + from 15% to 25% on 1 April 2027, so the rate on the whole base goes from + 15% to 15% + 10% × 45.5% = 19.55%. Receipts grow 33.8% into 2027-28 + (£655.6 million to £877.4 million); the base grows (877.4 / 19.55%) / + (655.6 / 15%) − 1 = 2.70%. The shares come from Microcosm's split of + HMRC's 2024-25 receipts by the Gambling Commission's 2024-25 gross + gambling yield, held fixed in every year. That split counts all remote + horse racing as UK horse racing, which keeps 15%, so the part at 25% may + be larger, and the 2.70% then overstates the base's growth. Spread betting + has no published base and no weight. +- **Machine Games Duty**: the model's three rates do not change. Table + 4.10's line includes Autumn Budget 2026's increase to machine gaming + rates, which "increases receipts by a further estimated £0.1 billion a year + from 2027-28" (EFO paragraph 4.45). The forecast incorporates every measure + since March (paragraph 3.2), and the OBR's policy Table 1.1 gives this one, + with the move of liability to machine suppliers from April 2028, as + measure 56: £144.2 million of betting receipts in 2027-28, then £162.3, + £157.6, £155.9 and £156.4 million. The series takes those receipts off. + The line grows 22.1% into 2027-28; the base grows (826.9 − 144.2) / 677.1 + − 1 = 0.84%. Those receipts also hold the supplier measure's yield, which + the base therefore excludes, and the Remote Gaming Duty on the machine + stakes the costing moves online, which Table 4.10 counts in the remote + gaming line: taking it off this line understates the base by that amount. + The result is the base before the measure, without the costing's + behavioural response, on which a static higher-stake rate (#2731) charges + 25%. +- **Gaming Duty**: the household formula charges one effective rate, which + does not change, so the base grows with receipts. The statutory bands do + not enter: any change in the duty's effective rate that the OBR's + forecast includes stays in the base. +- **Bingo Duty**: 10% until its abolition on 1 April 2026. With a zero rate + there is no base, so the index holds its 2025-26 level from 2026-27. + Current-law receipts are zero whatever the base; a reform that charges + Bingo Duty again charges that 2025-26 base in cash terms. +- **Lottery Duty** (12%) and **Pool Betting Duty** (15%): one rate each, so + the base grows with receipts. + +The series rest on these approximations: + +- HMRC's receipts are cash. Table 4.10 is on an accrued basis: its totals + equal the "Betting and gaming taxes" row of the October 2026 Table 4.1, + whose note says all its taxes are accrued. Growth into 2026-27 compares + accrued 2026-27 receipts with cash 2025-26 receipts. +- Model years run from 6 April and the OBR's from 1 April. A model year + that takes in five days of a 1 April rate change charges them at the new + rate, so FY2025 Remote Gaming Duty and FY2026 General Betting Duty sit + slightly above the published receipts. +- Two lines fall where no rate changes. Table 4.10's Lottery Duty for + 2026-27, £830.5 million, is 15.3% below HMRC's 2025-26 outturn of £980.1 + million; the EFO gives no reason in paragraph 4.45. Pool Betting Duty + receipts fall from £8.1 million in 2024-25 to £4.0 million in 2025-26 and + a forecast £2.8 million in 2026-27, so its index falls by 65%. + +If #2731 lands, its input `machine_games_standard_rate_higher_stake_net_takings` +needs the Machine Games Duty index too: +`policyengine_uk/tests/test_gambling_duty_base_projection.py` fails until +every input in the gambling folder declares one. + +Sources: [OBR October 2026 Economic and Fiscal Outlook](https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/) +(receipts Table 4.10 and policy Table 1.1, in the +[detailed forecast tables zip file](https://obr.uk/download/october-2026-economic-and-fiscal-outlook-detailed-forecast-tables-zip-file/); +receipts workbook SHA-256 +`d115705716d77b11c190385d41cb1924f30080c9331a8a386c04d4e65a368e95`), +[HMRC July 2026 Betting and Gaming tables](https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods), +[Autumn Budget 2026 policy costings, pages 67 and 68](https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=67) +and [Microcosm's 2024-25 gambling bases](https://github.com/PolicyEngine/microcosm/blob/main/docs/evidence/uk-excise-quantities-1359/outputs/national_reconciliation.json). + ## Tax totals and fiscal classification The seven component duties enter `gambling_duty`, which enters household and diff --git a/policyengine_uk/parameters/gov/economic_assumptions/README.md b/policyengine_uk/parameters/gov/economic_assumptions/README.md index b4e58b8cfc..a719889fd3 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/README.md +++ b/policyengine_uk/parameters/gov/economic_assumptions/README.md @@ -34,6 +34,7 @@ GDP and population assumptions remain supported. | `stamp_duty_land_tax.non_residential` | SDLT receipts from non-residential property | EFO receipts, property transactions taxes by sector: Table 4.7 in October 2026 | | `business_rates.great_britain` | Business rates receipts, England, Scotland and Wales (cash) | EFO receipts, current receipts (cash basis): Table 4.8 in October 2026 | | `business_rates.northern_ireland` | Northern Ireland business rates receipts | EFO receipts, other taxes: Table 4.2 in October 2026 | +| `gambling_duty_bases.` | The base of each of the seven betting and gaming duties (receipts over the model's rate) | HMRC Betting and Gaming tables (2024-25, 2025-26), then EFO receipts, betting and gaming duties forecast breakdown: Table 4.10 in October 2026 | The four receipts series project the corporate SDLT and business rates statistics (`gov.hmrc.stamp_duty.statistics`, @@ -44,6 +45,20 @@ in an earlier EFO), the EFO's forecast years, and after them the `average_earnings` path. Years before the outturn year are kept, so each index keeps its base year. +The seven `gambling_duty_bases` series (`general_betting`, `pool_betting`, +`remote_gaming`, `machine_games`, `gaming`, `bingo`, `lottery`) grow the +gambling duty inputs (#2738). They grow a duty's base, not its receipts: +each year's receipts, less the Budget measures they include, divided by the +rate the model charges that fiscal year. The model then applies its own rate +changes, such as Remote Gaming Duty's rise from 21% to 40% on 1 April 2026, +once. The table publishes forecasts only, so each series starts from HMRC's +2024-25 receipts, the model's base year, and its 2025-26 outturn, which the +importer holds in `RECEIPTS_SERIES_SPECS`. A year whose rate is zero, Bingo +Duty's from 2026-27, holds the base flat. See +[gambling duties](../../../../docs/engineering/gambling-duties.md#projection) +for each duty's rates, the measure the Machine Games Duty line includes and +the approximations. + ## Time horizons Three horizons are stitched together for each OBR and ONS series (not @@ -347,6 +362,16 @@ threshold by the same rises, up to the last year of `policyengine_uk/tests/test_corporate_receipts_statistics.py` (`OBR_RECEIPTS_BN`), which pin the series to the OBR's tables. Pass `--skip-receipts-growth` only to leave them on the previous EFO. + The same run refreshes the `gambling_duty_bases` series from the new + EFO's betting and gaming duties forecast breakdown. They read no previous + EFO: the year before the table's first comes from HMRC's receipts in each + spec. When the table starts a year later (2027-28 in October 2027), add + HMRC's outturn for the year before from its latest Betting and Gaming + tables; the importer stops and names the year until you do. Check the + EFO's policy tables for any new measure in the betting and gaming lines, + and extend the Machine Games Duty measure's receipts when the forecast + runs past 2031-32. Then update the published levels in + `policyengine_uk/tests/test_gambling_duty_base_projection.py`. 7. If the new EFO runs past `PROJECTION_END_YEAR`, move it on (see [Dataset projection horizon](#dataset-projection-horizon)). diff --git a/policyengine_uk/tests/test_gambling_duty_base_projection.py b/policyengine_uk/tests/test_gambling_duty_base_projection.py new file mode 100644 index 0000000000..f7fc50ad46 --- /dev/null +++ b/policyengine_uk/tests/test_gambling_duty_base_projection.py @@ -0,0 +1,394 @@ +"""The gambling duty inputs grow with their duty's base, not its receipts (#2738). + +Each input declares its duty's index under +``gov.economic_assumptions.indices.obr.gambling_duty_bases``, and dataset +projection grows it by the same ``yoy_growth`` series. A series is HMRC's +2024-25 and 2025-26 receipts, then the OBR's October 2026 forecast (receipts +Table 4.10), less Autumn Budget 2026 measure 56 for Machine Games Duty, each +year over the rate the model charges. Bases that give HMRC's 2024-25 receipts +therefore give the OBR's receipts in later years, and a rate reform moves +receipts through the rate alone. +""" + +from datetime import date +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +import yaml +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine_uk import CountryTaxBenefitSystem, Simulation +from policyengine_uk.data import UKSingleYearDataset +from policyengine_uk.data.economic_assumptions import extend_single_year_dataset +from policyengine_uk.model_api import Scenario + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +GROWTH = "gov.economic_assumptions.yoy_growth.obr.gambling_duty_bases" +INDEX = "gov.economic_assumptions.indices.obr.gambling_duty_bases" +INPUTS = { + "general_betting": ( + "betting_exchange_commission", + "financial_spread_betting_net_losses", + "horse_and_dog_pool_betting_net_losses", + "land_based_fixed_odds_betting_net_losses", + "other_spread_betting_net_losses", + "remote_other_betting_net_losses", + "remote_uk_horseracing_betting_net_losses", + ), + "pool_betting": ("pool_betting_net_receipts",), + "remote_gaming": ("remote_gaming_net_losses",), + "machine_games": ( + "machine_games_higher_rate_net_takings", + "machine_games_lower_rate_net_takings", + "machine_games_standard_rate_net_takings", + ), + "gaming": ("land_based_casino_net_losses",), + "bingo": ("bingo_net_receipts",), + "lottery": ("national_lottery_ticket_spending",), +} +DUTIES = { + "general_betting": "general_betting_duty", + "pool_betting": "pool_betting_duty", + "remote_gaming": "remote_gaming_duty", + "machine_games": "machine_games_duty", + "gaming": "gaming_duty", + "bingo": "bingo_duty", + "lottery": "lottery_duty", +} +# OBR, Economic and fiscal outlook, October 2026, detailed forecast tables: +# receipts, Table 4.10 (£ billion), 2026-27 to 2031-32, as published. +TABLE_4_10 = { + "remote_gaming": (2.7520296250837784, 2.7812750734699425, 2.8416867878041874, 2.9053716896455919, 2.9446821891345443, 2.963204761653297), + "lottery": (0.83052038459597621, 0.84965566898040046, 0.87574553838772884, 0.90350845044750805, 0.92958587301870155, 0.95685079671453654), + "general_betting": (0.65560522982822456, 0.87744453968275893, 0.91694664204685206, 0.94862995017184315, 0.97646741817020943, 1.0041740187990271), + "machine_games": (0.67707068705786499, 0.82692873727074745, 0.85695449075649544, 0.86524127517297356, 0.87550370868921701, 0.89631020781238802), + "gaming": (0.17422987982556123, 0.18488058456996015, 0.18523427331264838, 0.18548069649974164, 0.18650974968911124, 0.18568125218312026), + "bingo": (1.8867652000000002e-4, 0.0, 0.0, 0.0, 0.0, 0.0), + "pool_betting": (2.8288821260900035e-3, 2.6802893699237468e-3, 2.85842704577818e-3, 2.8371447589557743e-3, 2.9959073747097458e-3, 3.1258296515431082e-3), +} # fmt: skip +# HMRC, UK Betting and Gaming Statistics, July 2026 tables, +# Table_1_BG_receipts rows 45 (2024-25) and 46 (2025-26), £ thousand. +HMRC = { + "general_betting": (713_858, 684_502), + "pool_betting": (8_125, 3_970), + "remote_gaming": (1_162_539, 1_320_966), + "machine_games": (609_245, 641_010), + "gaming": (165_397, 173_902), + "bingo": (25_233, 26_073), + "lottery": (931_887, 980_132), +} +# OBR, October 2026, detailed forecast tables: policy, Table 1.1, measure 56 +# (Machine Games Duty), Betting receipts, £ million, 2027-28 to 2031-32. +MEASURE_56 = ( + 144.15331361978284, + 162.30500845324954, + 157.57136998103317, + 155.90187446314559, + 156.40657502685724, +) +# The 2024-25 bases of the duties with more than one rate, in £ (Microcosm's +# national levels): General Betting Duty's split by Gambling Commission +# yield, and Machine Games Duty's bands from HMRC's liabilities over their +# rates. The remote part pays 25% from 1 April 2027; the rest 15%. +GENERAL_BETTING_PARTS = { + "remote_uk_horseracing_betting_net_losses": 888_079_509.7245454, + "land_based_fixed_odds_betting_net_losses": 1_527_826_124.9133615, + "horse_and_dog_pool_betting_net_losses": 55_219_132.22492455, + "remote_other_betting_net_losses": 2_163_978_657.2782593, + "betting_exchange_commission": 123_949_909.1922428, +} +MACHINE_GAMES_BANDS = { + "machine_games_lower_rate_net_takings": (138_420_000, 0.05), + "machine_games_standard_rate_net_takings": (3_023_740_000, 0.20), + "machine_games_higher_rate_net_takings": (2_384_000, 0.25), +} +SINGLE_RATES = { + "remote_gaming_net_losses": 0.21, + "national_lottery_ticket_spending": 0.12, + "pool_betting_net_receipts": 0.15, + "land_based_casino_net_losses": 0.235487, + "bingo_net_receipts": 0.10, +} +SINGLE_RATE_DUTY = { + "remote_gaming_net_losses": "remote_gaming", + "national_lottery_ticket_spending": "lottery", + "pool_betting_net_receipts": "pool_betting", + "land_based_casino_net_losses": "gaming", + "bingo_net_receipts": "bingo", +} +REMOTE_SHARE = GENERAL_BETTING_PARTS["remote_other_betting_net_losses"] / sum( + GENERAL_BETTING_PARTS.values() +) +YEARS = range(2024, 2033) + + +@pytest.fixture(scope="module") +def parameters(): + return CountryTaxBenefitSystem().parameters + + +def index(parameters, duty, year): + return parameters.get_child(f"{INDEX}.{duty}")(f"{year}-01-01") + + +def test_every_gambling_input_declares_its_duty_index(): + """Every input in the gambling folder declares its duty's index, and + dataset projection grows it by the same duty's series.""" + from policyengine_uk.system import system + + folder = Path(__file__).parents[1] / "variables/input/consumption/gambling" + inputs = {path.stem for path in folder.glob("*.py")} + declared = {name: duty for duty, names in INPUTS.items() for name in names} + assert set(declared) == inputs + for name, duty in declared.items(): + assert system.variables[name].uprating == f"{INDEX}.{duty}", name + mapping = yaml.safe_load( + (Path(__file__).parents[1] / "data/uprating_indices.yaml").read_text() + ) + gambling = {key: value for key, value in mapping.items() if "gambling" in key} + assert gambling == { + f"{GROWTH}.{duty}": list(names) for duty, names in INPUTS.items() + } + + +def test_indices_are_positive_from_the_2024_base_year(parameters): + for duty in INPUTS: + assert index(parameters, duty, 2024) == 1 + for year in range(2024, 2040): + assert index(parameters, duty, year) > 0, (duty, year) + + +def test_remote_gaming_index_takes_out_the_april_2026_rate_rise(parameters): + """Receipts more than double into 2026-27 as the rate goes from 21% to + 40%; the base grows 9.4%.""" + receipts_growth = TABLE_4_10["remote_gaming"][0] / (HMRC["remote_gaming"][1] / 1e6) + assert receipts_growth - 1 == pytest.approx(1.0834, abs=1e-4) + base_growth = receipts_growth * 0.21 / 0.40 + growth = parameters.get_child(f"{GROWTH}.remote_gaming") + assert growth("2026-01-01") == round(base_growth - 1, 6) == 0.093757 + assert index(parameters, "remote_gaming", 2026) / index( + parameters, "remote_gaming", 2025 + ) == pytest.approx(base_growth, rel=2e-5) + + +def test_general_betting_index_takes_out_the_april_2027_remote_rise(parameters): + """The remote part (45.5% of the 2024-25 base) goes from 15% to 25%, so + the duty's rate on the whole base goes from 15% to 19.5%.""" + rate_2027 = 0.15 + 0.10 * REMOTE_SHARE + assert rate_2027 == pytest.approx(0.19547, abs=1e-5) + receipts = TABLE_4_10["general_betting"] + base_growth = (receipts[1] / rate_2027) / (receipts[0] / 0.15) + growth = parameters.get_child(f"{GROWTH}.general_betting") + assert growth("2027-01-01") == round(base_growth - 1, 6) == 0.027038 + # Receipts grow 33.8%. + assert receipts[1] / receipts[0] - 1 == pytest.approx(0.3384, abs=1e-4) + + +def test_machine_games_index_takes_out_autumn_budget_2026_measure_56(parameters): + """Table 4.10's Machine Games Duty line includes the Budget's higher-stake + rate and supplier liability (OBR paragraph 4.45): +22.1% into 2027-28, + 0.8% without them.""" + receipts = TABLE_4_10["machine_games"] + assert receipts[1] / receipts[0] - 1 == pytest.approx(0.2213, abs=1e-4) + base_growth = (receipts[1] - MEASURE_56[0] / 1000) / receipts[0] + growth = parameters.get_child(f"{GROWTH}.machine_games") + assert growth("2027-01-01") == round(base_growth - 1, 6) == 0.008426 + + +def test_bingo_base_holds_flat_once_its_rate_is_zero(parameters): + """Bingo Duty ends on 1 April 2026; its base stays at its 2025-26 level.""" + at_2025 = index(parameters, "bingo", 2025) + assert at_2025 == pytest.approx(HMRC["bingo"][1] / HMRC["bingo"][0], rel=1e-5) + for year in range(2026, 2040): + assert index(parameters, "bingo", year) == at_2025 + + +def household_frame(year, inputs): + """One household of one adult, with the given household inputs.""" + return UKSingleYearDataset( + person=pd.DataFrame( + { + "person_id": [1], + "person_benunit_id": [1], + "person_household_id": [1], + "age": [40], + } + ), + benunit=pd.DataFrame({"benunit_id": [1]}), + household=pd.DataFrame( + { + "household_id": [1], + "region": ["LONDON"], + "tenure_type": ["OWNED_OUTRIGHT"], + "council_tax": [0.0], + "rent": [0.0], + "household_weight": [1.0], + **{name: [value] for name, value in inputs.items()}, + } + ), + fiscal_year=year, + ) + + +def situation(inputs, year=2024): + return { + "people": {"person": {"age": {year: 40}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": { + "household": { + "members": ["person"], + **{name: {year: value} for name, value in inputs.items()}, + } + }, + } + + +ALL_INPUTS = { + name: 1_000.0 * (position + 1) + for position, name in enumerate(name for names in INPUTS.values() for name in names) +} + + +def test_dataset_projection_matches_core_uprating(parameters): + """Two routes grow a 2024 input: dataset projection compounds the + yoy_growth series a year at a time; Core scales a situation input by the + ratio of the index (rounded to five decimals). They agree.""" + projected = extend_single_year_dataset( + household_frame(2024, ALL_INPUTS), parameters, end_year=2032 + ) + simulation = Simulation(situation=situation(ALL_INPUTS)) + for year in YEARS: + for name, value in ALL_INPUTS.items(): + stored = projected[year].household[name].iloc[0] + uprated = simulation.calculate(name, year)[0] + assert stored == pytest.approx(uprated, rel=1e-4), (name, year) + # Remote gaming grows by the base series, not the receipts'. + stored = projected[2026].household["remote_gaming_net_losses"].iloc[0] + assert stored == pytest.approx( + ALL_INPUTS["remote_gaming_net_losses"] * 1.136277 * 1.093757, rel=1e-6 + ) + + +def national_bases(): + """2024-25 bases that give HMRC's 2024-25 receipts at the model's rates.""" + bases = { + name: HMRC[SINGLE_RATE_DUTY[name]][0] * 1e3 / rate + for name, rate in SINGLE_RATES.items() + } + general_betting = HMRC["general_betting"][0] * 1e3 / 0.15 + total = sum(GENERAL_BETTING_PARTS.values()) + bases |= { + name: general_betting * part / total + for name, part in GENERAL_BETTING_PARTS.items() + } + machine_games = ( + HMRC["machine_games"][0] + * 1e3 + / sum(base * rate for base, rate in MACHINE_GAMES_BANDS.values()) + ) + bases |= { + name: base * machine_games for name, (base, _) in MACHINE_GAMES_BANDS.items() + } + return bases + + +def model_year_share(change: date, year: int) -> float: + """The share of model year ``year`` (6 April to 5 April) from ``change``.""" + start, end = date(year, 4, 6), date(year + 1, 4, 6) + after = max(start, min(change, end)) + return (end - after).days / (end - start).days + + +def expected_receipts(duty, year): + """The duty in model year ``year`` on bases that give HMRC's 2024-25 + receipts: HMRC's receipts to 2025-26, then Table 4.10 less measure 56. + Model years run 6 April to 5 April, so those that take in five days of + a 1 April rate change charge them at the new rate. Bingo Duty is zero + from 2026-27, whatever late receipts Table 4.10 records.""" + if duty == "bingo" and year >= 2026: + return 0.0 + if year < 2026: + receipts = HMRC[duty][year - 2024] * 1e3 + else: + receipts = TABLE_4_10[duty][min(year, 2031) - 2026] * 1e9 + if duty == "machine_games" and year >= 2027: + receipts -= MEASURE_56[min(year, 2031) - 2027] * 1e6 + if duty == "remote_gaming" and year == 2025: + share = model_year_share(date(2026, 4, 1), 2025) + receipts *= (0.21 * (1 - share) + 0.40 * share) / 0.21 + if duty == "bingo" and year == 2025: + receipts *= 1 - model_year_share(date(2026, 4, 1), 2025) + if duty == "general_betting" and year == 2026: + share = model_year_share(date(2027, 4, 1), 2026) + receipts *= (0.15 + 0.10 * REMOTE_SHARE * share) / 0.15 + return receipts + + +@pytest.fixture(scope="module") +def national_simulation(): + return Simulation(dataset=household_frame(2024, national_bases())) + + +@pytest.mark.parametrize("duty", sorted(DUTIES)) +def test_bases_that_give_hmrc_receipts_give_the_obr_forecast(national_simulation, duty): + """Receipts are each base times its rate, and the bases grow by the + OBR's receipts over those rates: so bases that give HMRC's 2024-25 + receipts give HMRC's 2025-26 receipts and then Table 4.10, less measure + 56, in every year to 2031-32.""" + for year in range(2024, 2032): + model = national_simulation.calculate(DUTIES[duty], year)[0] + assert model == pytest.approx(expected_receipts(duty, year), rel=2e-4, abs=1), ( + duty, + year, + ) + + +# Each example builds a reform, which reprocesses the parameter tree. +@settings(max_examples=4, deadline=None, derandomize=True) +@given( + bases=st.lists( + st.floats(min_value=0, max_value=1e7, allow_nan=False), + min_size=2, + max_size=2, + ), + factor=st.floats(min_value=0.1, max_value=3, allow_nan=False), + year=st.integers(min_value=2027, max_value=2031), +) +def test_a_rate_reform_moves_receipts_through_the_rate_alone(bases, factor, year): + """Scaling the Remote Gaming Duty rate and the remote betting rate for a + year leaves every projected base as it was and scales those duties' + rate parts by the same factor.""" + inputs = { + "remote_gaming_net_losses": bases[0], + "remote_other_betting_net_losses": bases[1], + } + baseline = Simulation(situation=situation(inputs)) + reform = Simulation( + situation=situation(inputs), + scenario=Scenario( + parameter_changes={ + "gov.hmrc.gambling_duty.remote_gaming.rate": {str(year): 0.40 * factor}, + "gov.hmrc.gambling_duty.general_betting.rates.remote": { + str(year): 0.25 * factor + }, + } + ), + ) + for name in inputs: + np.testing.assert_array_equal( + reform.calculate(name, year), baseline.calculate(name, year) + ) + for name, duty in ( + ("remote_gaming_net_losses", "remote_gaming_duty"), + ("remote_other_betting_net_losses", "general_betting_duty"), + ): + base = baseline.calculate(name, year)[0] + before = baseline.calculate(duty, year)[0] + after = reform.calculate(duty, year)[0] + assert after == pytest.approx(before * factor, rel=1e-5, abs=0.05) + rate = 0.40 if name == "remote_gaming_net_losses" else 0.25 + assert before == pytest.approx(base * rate, rel=1e-5, abs=0.05) From 7717413b550c4d1589866b0e1ef5ab39417524d7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 00:03:28 -0400 Subject: [PATCH 03/17] State only what the sources show about the gambling base adjustments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Machine Games Duty paragraph now says that the OBR's tables do not show which line holds the Remote Gaming Duty that measure 56's costing moves online, so taking the whole measure off may understate the base. The approximations give the size of each five-day rate overlap and note that March 2026's Table 3.10 forecast £1,008.6 million of Lottery Duty for 2026-27. Spread betting's zero weight now rests on Microcosm's split alone. Co-Authored-By: Claude Opus 5.5 --- docs/engineering/gambling-duties.md | 22 ++++++++++++------- policyengine_uk/utils/import_obr_forecasts.py | 4 ++-- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/engineering/gambling-duties.md b/docs/engineering/gambling-duties.md index 298301000b..d917e7e52b 100644 --- a/docs/engineering/gambling-duties.md +++ b/docs/engineering/gambling-duties.md @@ -251,8 +251,8 @@ comes out as follows: HMRC's 2024-25 receipts by the Gambling Commission's 2024-25 gross gambling yield, held fixed in every year. That split counts all remote horse racing as UK horse racing, which keeps 15%, so the part at 25% may - be larger, and the 2.70% then overstates the base's growth. Spread betting - has no published base and no weight. + be larger, and the 2.70% then overstates the base's growth. Microcosm's + split gives spread betting no part, so it has no weight. - **Machine Games Duty**: the model's three rates do not change. Table 4.10's line includes Autumn Budget 2026's increase to machine gaming rates, which "increases receipts by a further estimated £0.1 billion a year @@ -263,9 +263,12 @@ comes out as follows: £157.6, £155.9 and £156.4 million. The series takes those receipts off. The line grows 22.1% into 2027-28; the base grows (826.9 − 144.2) / 677.1 − 1 = 0.84%. Those receipts also hold the supplier measure's yield, which - the base therefore excludes, and the Remote Gaming Duty on the machine - stakes the costing moves online, which Table 4.10 counts in the remote - gaming line: taking it off this line understates the base by that amount. + the base therefore excludes. The rate costing also moves a quarter of the + stakes lost from machines to remote gaming. If measure 56's betting + receipts count that Remote Gaming Duty and Table 4.10 puts it in the + remote gaming line, taking the whole measure off this line understates + the base by that amount. Table 1.1 gives only the measure's head, + Betting, and Table 4.10 carries no note on it. The result is the base before the measure, without the costing's behavioural response, on which a static higher-stake rate (#2731) charges 25%. @@ -288,11 +291,14 @@ The series rest on these approximations: accrued 2026-27 receipts with cash 2025-26 receipts. - Model years run from 6 April and the OBR's from 1 April. A model year that takes in five days of a 1 April rate change charges them at the new - rate, so FY2025 Remote Gaming Duty and FY2026 General Betting Duty sit - slightly above the published receipts. + rate, so FY2025 Remote Gaming Duty (1.2%) and FY2026 General Betting Duty + (0.4%) sit slightly above the published receipts, and FY2025 Bingo Duty + (1.4%) slightly below. - Two lines fall where no rate changes. Table 4.10's Lottery Duty for 2026-27, £830.5 million, is 15.3% below HMRC's 2025-26 outturn of £980.1 - million; the EFO gives no reason in paragraph 4.45. Pool Betting Duty + million. The March 2026 Table 3.10 forecast £1,008.6 million for the same + year; the October policy table holds no Lottery Duty measure, and the EFO + gives no reason in paragraph 4.45. Pool Betting Duty receipts fall from £8.1 million in 2024-25 to £4.0 million in 2025-26 and a forecast £2.8 million in 2026-27, so its index falls by 65%. diff --git a/policyengine_uk/utils/import_obr_forecasts.py b/policyengine_uk/utils/import_obr_forecasts.py index 6343519cee..837537f3eb 100644 --- a/policyengine_uk/utils/import_obr_forecasts.py +++ b/policyengine_uk/utils/import_obr_forecasts.py @@ -729,8 +729,8 @@ def gambling_spec( # Each part's 2024-25 base (£): HMRC's General Betting Duty receipts over # 15%, split by the Gambling Commission's 2024-25 gross gambling yield as # Microcosm splits the model's inputs. Fixed odds take remote UK horse - # racing, land-based fixed odds and racing pools. Spread betting, which - # the Commission does not regulate, has no published base. + # racing, land-based fixed odds and racing pools. Microcosm's split gives + # spread betting no part, so it has no weight. gambling_spec( "general_betting", "general betting duty", From 777af84108b99028fac00d585c26bfe3b7d8f007 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 00:07:43 -0400 Subject: [PATCH 04/17] Grow the higher-stake machine games input by the Machine Games Duty index #2731 added machine_games_standard_rate_higher_stake_net_takings, a part of the standard-rate base that pays the higher rate from 1 April 2027. It now declares the Machine Games Duty index, as the standard-rate input it belongs to does, and dataset projection grows it by the same series. The series takes measure 56 off Table 4.10's line, so the model adds the higher-stake rise once; a new test checks the duty from 2027-28 to 2031-32. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2738.fixed.md | 2 +- docs/engineering/gambling-duties.md | 23 +++++++++++-------- policyengine_uk/data/uprating_indices.yaml | 1 + .../test_gambling_duty_base_projection.py | 23 +++++++++++++++++++ ..._standard_rate_higher_stake_net_takings.py | 1 + 5 files changed, 39 insertions(+), 11 deletions(-) diff --git a/changelog.d/2738.fixed.md b/changelog.d/2738.fixed.md index 5e11f22c2e..f59c9ae5e6 100644 --- a/changelog.d/2738.fixed.md +++ b/changelog.d/2738.fixed.md @@ -1 +1 @@ -Projected the fifteen gambling duty inputs from HMRC's 2025-26 receipts and the OBR's October 2026 betting and gaming forecast (receipts Table 4.10), divided by the rates the model charges, so they no longer stay at their 2024-25 cash level and the 2026 and 2027 remote gambling rate rises apply once. +Projected the sixteen gambling duty inputs from HMRC's 2025-26 receipts and the OBR's October 2026 betting and gaming forecast (receipts Table 4.10), divided by the rates the model charges, so they no longer stay at their 2024-25 cash level and the 2026 and 2027 remote gambling rate rises apply once. diff --git a/docs/engineering/gambling-duties.md b/docs/engineering/gambling-duties.md index 8ce8966959..e0072c8102 100644 --- a/docs/engineering/gambling-duties.md +++ b/docs/engineering/gambling-duties.md @@ -317,10 +317,12 @@ comes out as follows: horse racing as UK horse racing, which keeps 15%, so the part at 25% may be larger, and the 2.70% then overstates the base's growth. Microcosm's split gives spread betting no part, so it has no weight. -- **Machine Games Duty**: the model's three rates do not change. Table - 4.10's line includes Autumn Budget 2026's increase to machine gaming - rates, which "increases receipts by a further estimated £0.1 billion a year - from 2027-28" (EFO paragraph 4.45). The forecast incorporates every measure +- **Machine Games Duty**: the three rates do not change, but from 1 April + 2027 the model charges the higher-stake part of the standard-rate base at + the higher rate (see [higher-stake machines](#higher-stake-machines-from-1-april-2027)). + Table 4.10's line includes that increase to machine gaming rates, which + "increases receipts by a further estimated £0.1 billion a year from + 2027-28" (EFO paragraph 4.45). The forecast incorporates every measure since March (paragraph 3.2), and the OBR's policy Table 1.1 gives this one, with the move of liability to machine suppliers from April 2028, as measure 56: £144.2 million of betting receipts in 2027-28, then £162.3, @@ -334,8 +336,9 @@ comes out as follows: the base by that amount. Table 1.1 gives only the measure's head, Betting, and Table 4.10 carries no note on it. The result is the base before the measure, without the costing's - behavioural response, on which a static higher-stake rate (#2731) charges - 25%. + behavioural response. The model charges its higher-stake part 25% once, + and the higher-stake input takes the same index as the standard-rate + input it belongs to. - **Gaming Duty**: the household formula charges one effective rate, which does not change, so the base grows with receipts. The statutory bands do not enter: any change in the duty's effective rate that the OBR's @@ -366,10 +369,10 @@ The series rest on these approximations: receipts fall from £8.1 million in 2024-25 to £4.0 million in 2025-26 and a forecast £2.8 million in 2026-27, so its index falls by 65%. -If #2731 lands, its input `machine_games_standard_rate_higher_stake_net_takings` -needs the Machine Games Duty index too: -`policyengine_uk/tests/test_gambling_duty_base_projection.py` fails until -every input in the gambling folder declares one. +Every input in the gambling folder declares an index, including +`machine_games_standard_rate_higher_stake_net_takings`; +`policyengine_uk/tests/test_gambling_duty_base_projection.py` fails for a new +input that declares none. Sources: [OBR October 2026 Economic and Fiscal Outlook](https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/) (receipts Table 4.10 and policy Table 1.1, in the diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index d1ecb9ffc6..0660a1b72e 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -188,6 +188,7 @@ gov.economic_assumptions.yoy_growth.obr.gambling_duty_bases.remote_gaming: gov.economic_assumptions.yoy_growth.obr.gambling_duty_bases.machine_games: - machine_games_higher_rate_net_takings - machine_games_lower_rate_net_takings +- machine_games_standard_rate_higher_stake_net_takings - machine_games_standard_rate_net_takings gov.economic_assumptions.yoy_growth.obr.gambling_duty_bases.gaming: - land_based_casino_net_losses diff --git a/policyengine_uk/tests/test_gambling_duty_base_projection.py b/policyengine_uk/tests/test_gambling_duty_base_projection.py index f7fc50ad46..ff9d26b14a 100644 --- a/policyengine_uk/tests/test_gambling_duty_base_projection.py +++ b/policyengine_uk/tests/test_gambling_duty_base_projection.py @@ -44,6 +44,7 @@ "machine_games": ( "machine_games_higher_rate_net_takings", "machine_games_lower_rate_net_takings", + "machine_games_standard_rate_higher_stake_net_takings", "machine_games_standard_rate_net_takings", ), "gaming": ("land_based_casino_net_losses",), @@ -273,6 +274,28 @@ def test_dataset_projection_matches_core_uprating(parameters): ) +def test_higher_stake_machines_pay_the_april_2027_rise_once(): + """The Machine Games Duty series takes measure 56 off Table 4.10's line, + so the projected base holds no higher-stake rise and the model adds it + once (#2731): from model year 2027, 25% on the higher-stake part of the + standard-rate base and 20% on the rest, both grown by the one index.""" + standard = "machine_games_standard_rate_net_takings" + higher_stake = "machine_games_standard_rate_higher_stake_net_takings" + simulation = Simulation( + situation=situation({standard: 1_000.0, higher_stake: 400.0}) + ) + without = Simulation(situation=situation({standard: 1_000.0})) + for year in range(2027, 2032): + base = simulation.calculate(standard, year)[0] + part = simulation.calculate(higher_stake, year)[0] + assert base > 1_000 + assert part == pytest.approx(0.4 * base, rel=1e-9) + duty = simulation.calculate("machine_games_duty", year)[0] + assert duty == pytest.approx(0.20 * (base - part) + 0.25 * part, rel=1e-9) + rise = duty - without.calculate("machine_games_duty", year)[0] + assert rise == pytest.approx(0.05 * part, rel=1e-6) + + def national_bases(): """2024-25 bases that give HMRC's 2024-25 receipts at the model's rates.""" bases = { diff --git a/policyengine_uk/variables/input/consumption/gambling/machine_games_standard_rate_higher_stake_net_takings.py b/policyengine_uk/variables/input/consumption/gambling/machine_games_standard_rate_higher_stake_net_takings.py index c4121a45b6..cc032b34e5 100644 --- a/policyengine_uk/variables/input/consumption/gambling/machine_games_standard_rate_higher_stake_net_takings.py +++ b/policyengine_uk/variables/input/consumption/gambling/machine_games_standard_rate_higher_stake_net_takings.py @@ -7,6 +7,7 @@ class machine_games_standard_rate_higher_stake_net_takings(Variable): definition_period = YEAR default_value = 0 unit = GBP + uprating = "gov.economic_assumptions.indices.obr.gambling_duty_bases.machine_games" label = "Attributed higher-stake part of standard-rate machine games net takings" documentation = ( "The part of machine_games_standard_rate_net_takings that comes from " From 148051f510e8de38082b5bf7172e898c9c7c7afb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 00:15:57 -0400 Subject: [PATCH 05/17] Compare the higher-stake duty test at float32 precision The model stores inputs and duties as float32, so the test compares them to one part in a million, and the rise, a difference of two duties, to one part in 100,000. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_gambling_duty_base_projection.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/test_gambling_duty_base_projection.py b/policyengine_uk/tests/test_gambling_duty_base_projection.py index ff9d26b14a..3dc9eba525 100644 --- a/policyengine_uk/tests/test_gambling_duty_base_projection.py +++ b/policyengine_uk/tests/test_gambling_duty_base_projection.py @@ -289,11 +289,12 @@ def test_higher_stake_machines_pay_the_april_2027_rise_once(): base = simulation.calculate(standard, year)[0] part = simulation.calculate(higher_stake, year)[0] assert base > 1_000 - assert part == pytest.approx(0.4 * base, rel=1e-9) + # Inputs and duties are float32, and the rise is a difference of two. + assert part == pytest.approx(0.4 * base, rel=1e-6) duty = simulation.calculate("machine_games_duty", year)[0] - assert duty == pytest.approx(0.20 * (base - part) + 0.25 * part, rel=1e-9) + assert duty == pytest.approx(0.20 * (base - part) + 0.25 * part, rel=1e-6) rise = duty - without.calculate("machine_games_duty", year)[0] - assert rise == pytest.approx(0.05 * part, rel=1e-6) + assert rise == pytest.approx(0.05 * part, rel=1e-5) def national_bases(): From 6f4f8a0bd4fc087453e4201e7af717330ebd89a1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 02:06:47 -0400 Subject: [PATCH 06/17] Divide Machine Games Duty receipts by the rate with the higher-stake rise in it Table 4.10's Machine Games Duty line no longer loses Autumn Budget 2026 measure 56. The series divides it by an effective rate that charges the Gambling Commission's Category B share of the standard-rate base (90.78%, 2024-25 machine yield in Categories B and C) at the higher rate on the days the model's higher-stake switch is on, from 1 April 2027. The behavioural response, the supplier-liability yield and any Remote Gaming Duty substitution stay in the base, as for every other duty. ReceiptsMeasure goes with the subtraction. A rate part can now follow a dated switch, read with the rates from the parameter tree. The importer refuses growth that would round the model's five-decimal index to zero. Tests add the reconciliation invariant at any share, the zero-share rule, index positivity, the 100-at-20% / 80-at-25% example, the shortfall when a dataset lacks the higher-stake input, rate reforms that leave the base alone, and a model-to-importer differential of every rate path's wiring. Co-Authored-By: Claude Opus 5.5 --- .../gov/economic_assumptions/yoy_growth.yaml | 22 +- .../test_gambling_duty_base_projection.py | 266 ++++++++++--- .../tests/test_import_obr_forecasts.py | 352 ++++++++++++++---- policyengine_uk/utils/import_obr_forecasts.py | 253 +++++++------ 4 files changed, 657 insertions(+), 236 deletions(-) diff --git a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml index 73940c1347..acef315895 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml +++ b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml @@ -1085,20 +1085,20 @@ obr: - title: Gambling Commission, industry statistics, April 2024 to March 2025 href: https://www.gamblingcommission.gov.uk/statistics-and-research/publication/industry-statistics-annual-report-financial-year-april-2024-to-march-2025 machine_games: - description: Growth in the Machine Games Duty base, measured as the OBR's forecast of the duty's receipts, less the Budget measures it includes, divided by the rates the model charges, each weighted by its band's 2024-25 share of the base. + description: Growth in the Machine Games Duty base, measured as the OBR's forecast of the duty's receipts divided by the rates the model charges, each weighted by its band's 2024-25 share of the base, with the higher-stake share of the standard-rate band at the higher rate from 1 April 2027. values: # Base year (2024-25): the gambling duty inputs start in model year 2024 2024-01-01: 0.0000 - # Outturn (2025-26 over 2024-25) from HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts less Autumn Budget 2026 measure 56 (Machine Games Duty on higher-stake machines and supplier liability), over the rate the model charges + # Outturn (2025-26 over 2024-25) from HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges 2025-01-01: 0.052138 - # Forecast (2026-27 over 2025-26) from OBR EFO October 2026, receipts Table 4.10 "Machine games duty", over HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts less Autumn Budget 2026 measure 56 (Machine Games Duty on higher-stake machines and supplier liability), over the rate the model charges + # Forecast (2026-27 over 2025-26) from OBR EFO October 2026, receipts Table 4.10 "Machine games duty", over HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges 2026-01-01: 0.056256 - # Forecast (2027-2031) from OBR EFO October 2026, receipts Table 4.10; base growth: receipts less Autumn Budget 2026 measure 56 (Machine Games Duty on higher-stake machines and supplier liability), over the rate the model charges - 2027-01-01: 0.008426 - 2028-01-01: 0.017391 - 2029-01-01: 0.018744 - 2030-01-01: 0.016861 - 2031-01-01: 0.028213 + # Forecast (2027-2031) from OBR EFO October 2026, receipts Table 4.10; base growth: receipts over the rate the model charges + 2027-01-01: -0.002319 + 2028-01-01: 0.03631 + 2029-01-01: 0.00967 + 2030-01-01: 0.011861 + 2031-01-01: 0.023765 # Beyond the OBR forecast: the average_earnings long-run path above 2032-01-01: 0.0285 2033-01-01: 0.0309 @@ -1113,8 +1113,8 @@ obr: href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ - title: HMRC UK Betting and Gaming Statistics, July 2026 tables (Table_1_BG_receipts, 2024-25 and 2025-26 receipts) href: https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods - - title: OBR EFO October 2026 (detailed forecast tables, policy, Table 1.1, measure 56, Betting) - href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + - title: Gambling Commission, Industry Statistics, April 2025 to March 2026 (sheet 4, 2024-25 gaming machine yield by category) + href: https://www.gamblingcommission.gov.uk/statistics-and-research/publication/industry-statistics-annual-report-financial-year-april-2025-to-march-2026-official-statistics gaming: description: Growth in the Gaming Duty base, measured as the OBR's forecast of the duty's receipts divided by the casino effective rate the model charges. values: diff --git a/policyengine_uk/tests/test_gambling_duty_base_projection.py b/policyengine_uk/tests/test_gambling_duty_base_projection.py index 3dc9eba525..a5c8ac87c4 100644 --- a/policyengine_uk/tests/test_gambling_duty_base_projection.py +++ b/policyengine_uk/tests/test_gambling_duty_base_projection.py @@ -4,10 +4,11 @@ ``gov.economic_assumptions.indices.obr.gambling_duty_bases``, and dataset projection grows it by the same ``yoy_growth`` series. A series is HMRC's 2024-25 and 2025-26 receipts, then the OBR's October 2026 forecast (receipts -Table 4.10), less Autumn Budget 2026 measure 56 for Machine Games Duty, each -year over the rate the model charges. Bases that give HMRC's 2024-25 receipts -therefore give the OBR's receipts in later years, and a rate reform moves -receipts through the rate alone. +Table 4.10), each year over the rate the model charges. For Machine Games +Duty that rate charges the higher-stake share of the standard-rate base at +the higher rate from 1 April 2027. Bases that give HMRC's 2024-25 receipts, +with the higher-stake input at that share, therefore give the OBR's receipts +in later years, and a rate reform moves receipts through the rate alone. """ from datetime import date @@ -24,6 +25,12 @@ from policyengine_uk.data import UKSingleYearDataset from policyengine_uk.data.economic_assumptions import extend_single_year_dataset from policyengine_uk.model_api import Scenario +from policyengine_uk.utils import import_obr_forecasts +from policyengine_uk.utils.import_obr_forecasts import ( + MACHINE_GAMES_HIGHER_STAKE_SHARE, + RECEIPTS_SERIES_SPECS, + line_rate, +) pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") @@ -82,19 +89,11 @@ "bingo": (25_233, 26_073), "lottery": (931_887, 980_132), } -# OBR, October 2026, detailed forecast tables: policy, Table 1.1, measure 56 -# (Machine Games Duty), Betting receipts, £ million, 2027-28 to 2031-32. -MEASURE_56 = ( - 144.15331361978284, - 162.30500845324954, - 157.57136998103317, - 155.90187446314559, - 156.40657502685724, -) # The 2024-25 bases of the duties with more than one rate, in £ (Microcosm's # national levels): General Betting Duty's split by Gambling Commission # yield, and Machine Games Duty's bands from HMRC's liabilities over their -# rates. The remote part pays 25% from 1 April 2027; the rest 15%. +# rates. The remote part pays 25% from 1 April 2027; the rest 15%. From the +# same date the higher-stake part of the standard-rate band pays 25%. GENERAL_BETTING_PARTS = { "remote_uk_horseracing_betting_net_losses": 888_079_509.7245454, "land_based_fixed_odds_betting_net_losses": 1_527_826_124.9133615, @@ -124,6 +123,25 @@ REMOTE_SHARE = GENERAL_BETTING_PARTS["remote_other_betting_net_losses"] / sum( GENERAL_BETTING_PARTS.values() ) +STANDARD = "machine_games_standard_rate_net_takings" +HIGHER_STAKE = "machine_games_standard_rate_higher_stake_net_takings" +# The Gambling Commission's 2024-25 gaming machine yield, £ million, in +# Category B and in Category C (Industry Statistics, April 2025 to March 2026 +# release, sheet 4, K34 and L34). +HIGHER_STAKE_SHARE = 2_289.84969448829 / (2_289.84969448829 + 232.47489298159996) + + +def machine_games_rate(higher_stake_rate, share=HIGHER_STAKE_SHARE): + """Machine Games Duty's rate on its 2024-25 bands, with ``share`` of the + standard-rate band at ``higher_stake_rate``.""" + total = sum(base for base, _ in MACHINE_GAMES_BANDS.values()) + charged = sum(base * rate for base, rate in MACHINE_GAMES_BANDS.values()) + standard = MACHINE_GAMES_BANDS[STANDARD][0] + return (charged + standard * share * (higher_stake_rate - 0.20)) / total + + +MACHINE_GAMES_BEFORE = machine_games_rate(0.20) +MACHINE_GAMES_AFTER = machine_games_rate(0.25) YEARS = range(2024, 2033) @@ -189,15 +207,25 @@ def test_general_betting_index_takes_out_the_april_2027_remote_rise(parameters): assert receipts[1] / receipts[0] - 1 == pytest.approx(0.3384, abs=1e-4) -def test_machine_games_index_takes_out_autumn_budget_2026_measure_56(parameters): - """Table 4.10's Machine Games Duty line includes the Budget's higher-stake - rate and supplier liability (OBR paragraph 4.45): +22.1% into 2027-28, - 0.8% without them.""" +def test_the_higher_stake_share_is_category_b_over_b_and_c(): + assert MACHINE_GAMES_HIGHER_STAKE_SHARE == HIGHER_STAKE_SHARE + assert HIGHER_STAKE_SHARE == pytest.approx(0.90783, abs=1e-5) + + +def test_machine_games_index_takes_out_the_april_2027_higher_stake_rise(parameters): + """Table 4.10's Machine Games Duty line includes the Budget's higher rate + on higher-stake machines (OBR paragraph 4.45): receipts grow 22.1% into + 2027-28. With 90.8% of the standard-rate base at 25%, the rate on the + whole base goes from 19.35% to 23.68%, so the base falls 0.23%.""" receipts = TABLE_4_10["machine_games"] assert receipts[1] / receipts[0] - 1 == pytest.approx(0.2213, abs=1e-4) - base_growth = (receipts[1] - MEASURE_56[0] / 1000) / receipts[0] + assert MACHINE_GAMES_BEFORE == pytest.approx(0.193477, abs=1e-6) + assert MACHINE_GAMES_AFTER == pytest.approx(0.236849, abs=1e-6) + base_growth = (receipts[1] / MACHINE_GAMES_AFTER) / ( + receipts[0] / MACHINE_GAMES_BEFORE + ) growth = parameters.get_child(f"{GROWTH}.machine_games") - assert growth("2027-01-01") == round(base_growth - 1, 6) == 0.008426 + assert growth("2027-01-01") == round(base_growth - 1, 6) == -0.002319 def test_bingo_base_holds_flat_once_its_rate_is_zero(parameters): @@ -275,12 +303,12 @@ def test_dataset_projection_matches_core_uprating(parameters): def test_higher_stake_machines_pay_the_april_2027_rise_once(): - """The Machine Games Duty series takes measure 56 off Table 4.10's line, - so the projected base holds no higher-stake rise and the model adds it - once (#2731): from model year 2027, 25% on the higher-stake part of the - standard-rate base and 20% on the rest, both grown by the one index.""" - standard = "machine_games_standard_rate_net_takings" - higher_stake = "machine_games_standard_rate_higher_stake_net_takings" + """The Machine Games Duty series divides Table 4.10's line by the rate + with the higher-stake rise in it, so the projected base holds no rise and + the model adds it once (#2731): from model year 2027, 25% on the + higher-stake part of the standard-rate base and 20% on the rest, both + grown by the one index.""" + standard, higher_stake = STANDARD, HIGHER_STAKE simulation = Simulation( situation=situation({standard: 1_000.0, higher_stake: 400.0}) ) @@ -297,8 +325,36 @@ def test_higher_stake_machines_pay_the_april_2027_rise_once(): assert rise == pytest.approx(0.05 * part, rel=1e-5) -def national_bases(): - """2024-25 bases that give HMRC's 2024-25 receipts at the model's rates.""" +def test_a_rise_that_behaviour_offsets_leaves_the_duty_where_it_was(): + """£100 of higher-stake takings pay £20 at 20%. If the rise to 25% + cuts them to £80, they pay the same £20, receipts hold flat, and the + base index is 0.8: the model's 25% on the projected £80 gives the £20.""" + spec = import_obr_forecasts.machine_games_spec(1.0) + rates = tuple(part for part in spec.rates if part.parameter.endswith("standard")) + standard_only = import_obr_forecasts.ReceiptsSeriesSpec( + path=spec.path, title=spec.title, row=spec.row, rates=rates + ) + before, after = line_rate(standard_only, 2026), line_rate(standard_only, 2027) + assert (before, after) == (0.20, 0.25) + index = (20 / after) / (20 / before) + assert index == pytest.approx(0.8, abs=1e-12) + takings = 100 * index + simulation = Simulation( + situation=situation({STANDARD: takings, HIGHER_STAKE: takings}, year=2027) + ) + assert simulation.calculate("machine_games_duty", 2027)[0] == pytest.approx( + 20, rel=1e-6 + ) + earlier = Simulation(situation=situation({STANDARD: 100, HIGHER_STAKE: 100})) + assert earlier.calculate("machine_games_duty", 2024)[0] == pytest.approx( + 20, rel=1e-6 + ) + + +def national_bases(higher_stake_share=HIGHER_STAKE_SHARE): + """2024-25 bases that give HMRC's 2024-25 receipts at the model's rates, + with the higher-stake input at ``higher_stake_share`` of the + standard-rate input (none if None).""" bases = { name: HMRC[SINGLE_RATE_DUTY[name]][0] * 1e3 / rate for name, rate in SINGLE_RATES.items() @@ -317,6 +373,8 @@ def national_bases(): bases |= { name: base * machine_games for name, (base, _) in MACHINE_GAMES_BANDS.items() } + if higher_stake_share is not None: + bases[HIGHER_STAKE] = bases[STANDARD] * higher_stake_share return bases @@ -329,18 +387,21 @@ def model_year_share(change: date, year: int) -> float: def expected_receipts(duty, year): """The duty in model year ``year`` on bases that give HMRC's 2024-25 - receipts: HMRC's receipts to 2025-26, then Table 4.10 less measure 56. - Model years run 6 April to 5 April, so those that take in five days of - a 1 April rate change charge them at the new rate. Bingo Duty is zero - from 2026-27, whatever late receipts Table 4.10 records.""" + receipts: HMRC's receipts to 2025-26, then Table 4.10. Model years run + 6 April to 5 April, so those that take in five days of a 1 April rate + change charge them at the new rate. Bingo Duty is zero from 2026-27, + whatever late receipts Table 4.10 records.""" if duty == "bingo" and year >= 2026: return 0.0 if year < 2026: receipts = HMRC[duty][year - 2024] * 1e3 else: receipts = TABLE_4_10[duty][min(year, 2031) - 2026] * 1e9 - if duty == "machine_games" and year >= 2027: - receipts -= MEASURE_56[min(year, 2031) - 2027] * 1e6 + if duty == "machine_games" and year == 2026: + share = model_year_share(date(2027, 4, 1), 2026) + receipts *= ( + MACHINE_GAMES_BEFORE * (1 - share) + MACHINE_GAMES_AFTER * share + ) / MACHINE_GAMES_BEFORE if duty == "remote_gaming" and year == 2025: share = model_year_share(date(2026, 4, 1), 2025) receipts *= (0.21 * (1 - share) + 0.40 * share) / 0.21 @@ -357,12 +418,17 @@ def national_simulation(): return Simulation(dataset=household_frame(2024, national_bases())) +@pytest.fixture(scope="module") +def national_simulation_without_higher_stake(): + return Simulation(dataset=household_frame(2024, national_bases(None))) + + @pytest.mark.parametrize("duty", sorted(DUTIES)) def test_bases_that_give_hmrc_receipts_give_the_obr_forecast(national_simulation, duty): """Receipts are each base times its rate, and the bases grow by the OBR's receipts over those rates: so bases that give HMRC's 2024-25 - receipts give HMRC's 2025-26 receipts and then Table 4.10, less measure - 56, in every year to 2031-32.""" + receipts give HMRC's 2025-26 receipts and then Table 4.10 in every year + to 2031-32, with the higher-stake input at its share.""" for year in range(2024, 2032): model = national_simulation.calculate(DUTIES[duty], year)[0] assert model == pytest.approx(expected_receipts(duty, year), rel=2e-4, abs=1), ( @@ -371,25 +437,53 @@ def test_bases_that_give_hmrc_receipts_give_the_obr_forecast(national_simulation ) +def test_without_the_higher_stake_input_the_duty_falls_short_of_table_4_10( + national_simulation_without_higher_stake, +): + """A dataset without the higher-stake input charges the standard rate on + all of the standard-rate base, the rates before Budget 2026, while the + base grows by receipts over the rates after it. From 2027-28 the duty is + therefore Table 4.10's line times 19.35% / 23.68%, 18.3% short.""" + simulation = national_simulation_without_higher_stake + assert 1 - MACHINE_GAMES_BEFORE / MACHINE_GAMES_AFTER == pytest.approx( + 0.1831, abs=1e-4 + ) + for year in range(2024, 2032): + model = simulation.calculate("machine_games_duty", year)[0] + expected = expected_receipts("machine_games", year) + if year == 2026: + expected = TABLE_4_10["machine_games"][0] * 1e9 + if year >= 2027: + expected *= MACHINE_GAMES_BEFORE / MACHINE_GAMES_AFTER + assert model == pytest.approx(expected, rel=2e-4, abs=1), year + + # Each example builds a reform, which reprocesses the parameter tree. @settings(max_examples=4, deadline=None, derandomize=True) @given( bases=st.lists( st.floats(min_value=0, max_value=1e7, allow_nan=False), - min_size=2, - max_size=2, + min_size=4, + max_size=4, ), factor=st.floats(min_value=0.1, max_value=3, allow_nan=False), + switch=st.booleans(), year=st.integers(min_value=2027, max_value=2031), ) -def test_a_rate_reform_moves_receipts_through_the_rate_alone(bases, factor, year): - """Scaling the Remote Gaming Duty rate and the remote betting rate for a - year leaves every projected base as it was and scales those duties' - rate parts by the same factor.""" +def test_a_rate_reform_moves_receipts_through_the_rate_alone( + bases, factor, switch, year +): + """Scaling the Remote Gaming Duty rate, the remote betting rate and the + Machine Games Duty standard and higher rates for a year, and turning the + higher-stake switch on or off, leaves every projected base as it was and + moves each duty through its rates alone.""" inputs = { "remote_gaming_net_losses": bases[0], "remote_other_betting_net_losses": bases[1], + STANDARD: bases[2], + HIGHER_STAKE: min(bases[3], bases[2]), } + machine_games = "gov.hmrc.gambling_duty.machine_games" baseline = Simulation(situation=situation(inputs)) reform = Simulation( situation=situation(inputs), @@ -399,6 +493,11 @@ def test_a_rate_reform_moves_receipts_through_the_rate_alone(bases, factor, year "gov.hmrc.gambling_duty.general_betting.rates.remote": { str(year): 0.25 * factor }, + f"{machine_games}.rates.standard": {str(year): 0.20 * factor}, + f"{machine_games}.rates.higher": {str(year): 0.25 * factor}, + f"{machine_games}.higher_stake_machines_at_higher_rate": { + str(year): switch + }, } ), ) @@ -416,3 +515,86 @@ def test_a_rate_reform_moves_receipts_through_the_rate_alone(bases, factor, year assert after == pytest.approx(before * factor, rel=1e-5, abs=0.05) rate = 0.40 if name == "remote_gaming_net_losses" else 0.25 assert before == pytest.approx(base * rate, rel=1e-5, abs=0.05) + standard = baseline.calculate(STANDARD, year)[0] + part = baseline.calculate(HIGHER_STAKE, year)[0] + before = baseline.calculate("machine_games_duty", year)[0] + after = reform.calculate("machine_games_duty", year)[0] + assert before == pytest.approx( + 0.20 * (standard - part) + 0.25 * part, rel=1e-5, abs=0.05 + ) + part_rate = 0.25 if switch else 0.20 + assert after == pytest.approx( + factor * (0.20 * (standard - part) + part_rate * part), rel=1e-5, abs=0.05 + ) + + +# A distinct rate for every rate parameter the duty formulas read, so a part +# of an importer series wired to the wrong parameter takes the wrong rate. +WIRING_RATES = { + "gov.hmrc.gambling_duty.remote_gaming.rate": 0.31, + "gov.hmrc.gambling_duty.lottery.rate": 0.17, + "gov.hmrc.gambling_duty.general_betting.rates.fixed_odds": 0.11, + "gov.hmrc.gambling_duty.general_betting.rates.remote": 0.29, + "gov.hmrc.gambling_duty.general_betting.rates.exchange_commission": 0.43, + "gov.hmrc.gambling_duty.general_betting.rates.financial_spread": 0.07, + "gov.hmrc.gambling_duty.general_betting.rates.other_spread": 0.13, + "gov.hmrc.gambling_duty.machine_games.rates.lower": 0.03, + "gov.hmrc.gambling_duty.machine_games.rates.standard": 0.19, + "gov.hmrc.gambling_duty.machine_games.rates.higher": 0.37, + "gov.hmrc.gambling_duty.bingo.rate": 0.23, + "gov.hmrc.gambling_duty.pool_betting.rate": 0.41, + "household.consumption.gambling.casino_effective_rate": 0.47, +} +SWITCH = "gov.hmrc.gambling_duty.machine_games.higher_stake_machines_at_higher_rate" + + +def test_the_wiring_rates_cover_every_gambling_rate_parameter(parameters): + """Every rate parameter under the gambling duties has a wiring rate, + except Gaming Duty's statutory bands, an operator catalogue the household + formula does not read.""" + leaves = { + parameter.name + for parameter in parameters.gov.hmrc.gambling_duty.get_descendants() + if not hasattr(parameter, "children") and not hasattr(parameter, "brackets") + } + leaves = {name for name in leaves if ".gaming.normal_six_month_bands" not in name} + assert leaves == set(WIRING_RATES) - { + "household.consumption.gambling.casino_effective_rate" + } | {SWITCH} + + +@pytest.mark.parametrize("switch", [True, False]) +def test_each_series_charges_the_rates_the_model_charges(switch): + """Differential: with every rate parameter set to its own value in + 2029-30, a household holding the national bases pays, over each duty's + base, the rate the importer divides that duty's receipts by. The model's + formulas decide which rate each input pays, so a part of a series wired + to another parameter, or weighted as another part, fails.""" + year = 2029 + changes = {path: {str(year): rate} for path, rate in WIRING_RATES.items()} + changes[SWITCH] = {str(year): switch} + simulation = Simulation( + dataset=household_frame(2024, national_bases()), + scenario=Scenario(parameter_changes=changes), + ) + values = {**WIRING_RATES, SWITCH: float(switch)} + with pytest.MonkeyPatch.context() as patch: + patch.setattr( + import_obr_forecasts, + "parameter_values", + lambda path: ((date(2000, 1, 1), values[path]),), + ) + rates = { + spec.path[-1]: line_rate(spec, year) + for spec in RECEIPTS_SERIES_SPECS + if spec.path[0] == "gambling_duty_bases" + } + assert rates.keys() == DUTIES.keys() + for duty, names in INPUTS.items(): + base = sum( + simulation.calculate(name, year)[0] + for name in names + if name != HIGHER_STAKE + ) + charged = simulation.calculate(DUTIES[duty], year)[0] + assert charged / base == pytest.approx(rates[duty], rel=1e-5), duty diff --git a/policyengine_uk/tests/test_import_obr_forecasts.py b/policyengine_uk/tests/test_import_obr_forecasts.py index 6053935e28..e778774825 100644 --- a/policyengine_uk/tests/test_import_obr_forecasts.py +++ b/policyengine_uk/tests/test_import_obr_forecasts.py @@ -1,4 +1,5 @@ import os +from dataclasses import replace from datetime import date from pathlib import Path from io import BytesIO @@ -7,7 +8,7 @@ import pytest import yaml -from hypothesis import given, settings +from hypothesis import event, given, settings from hypothesis import strategies as st from policyengine_uk.utils import import_obr_forecasts @@ -18,10 +19,9 @@ STATUTORY_GAP_SPECS, _sheet_paths, ForecastGap, - RateComponent, - ReceiptsMeasure, + MACHINE_GAMES_HIGHER_STAKE_SHARE, ReceiptsSeriesSpec, - measure_receipts, + machine_games_spec, average_earnings_path, compute_receipts_growth, find_receipts_line, @@ -2013,8 +2013,8 @@ def test_main_writes_receipts_growth_to_the_output_path(tmp_path): assert _values(output.read_text(), RECEIPTS_PATHS["residential"])[2024] == 0.25 -# Gambling duty bases (#2738): each line's receipts, less the measures they -# include, over the rate the model charges, after HMRC's outturn. +# Gambling duty bases (#2738): each line's receipts over the rate the model +# charges, after HMRC's outturn. GAMBLING_SPECS = tuple(spec for spec in RECEIPTS_SERIES_SPECS if spec.outturn) # HMRC's 2024-25 and 2025-26 receipts, £bn (July 2026 Betting and Gaming @@ -2031,9 +2031,28 @@ def test_main_writes_receipts_growth_to_the_output_path(tmp_path): # The remote part of the General Betting Duty base, which pays 25% from # 1 April 2027; the rest pays 15% throughout. REMOTE_BETTING_SHARE = 2_163_978_657 / (2_471_124_767 + 2_163_978_657 + 123_949_909) -# Autumn Budget 2026 measure 56's betting receipts, £m (OBR October 2026 -# policy Table 1.1). -MEASURE_56 = {2026: 0.0, 2027: 144.15331361978284, 2028: 162.30500845324954} +# Machine Games Duty's 2024-25 bands (£): HMRC's liabilities (Table_2_MGD +# row 20, £ thousand) over their rates, 5%, 20% and 25%. +MACHINE_GAMES_BANDS = { + "lower": (6_921e3 / 0.05, 0.05), + "standard": (604_748e3 / 0.20, 0.20), + "higher": (596e3 / 0.25, 0.25), +} +# The Gambling Commission's 2024-25 gaming machine yield, £ million, in +# Category B and in Category C (Industry Statistics, April 2025 to March 2026 +# release, sheet 4, K34 and L34). +HIGHER_STAKE_SHARE = 2_289.84969448829 / (2_289.84969448829 + 232.47489298159996) + + +def machine_games_rate(year, share=HIGHER_STAKE_SHARE): + """Machine Games Duty's rate on its 2024-25 bands in the OBR's fiscal + year ``year``: Budget 2026 moves ``share`` of the standard-rate band from + 20% to 25% on 1 April 2027, the first day of 2027-28.""" + total = sum(base for base, _ in MACHINE_GAMES_BANDS.values()) + charged = sum(base * rate for base, rate in MACHINE_GAMES_BANDS.values()) + if year >= 2027: + charged += MACHINE_GAMES_BANDS["standard"][0] * share * 0.05 + return charged / total def gambling_growth(**overrides): @@ -2051,7 +2070,8 @@ def test_gambling_bases_take_out_the_rates_the_model_charges(): """Synthetic March 2026 tables: growth from HMRC's 2024-25 receipts into the table's first year (2025-26), then the table's own years. Remote Gaming Duty's rise from 21% to 40% (1 April 2026) and the remote betting - rise from 15% to 25% (1 April 2027) come out of the base; Bingo Duty's + rise from 15% to 25% (1 April 2027) come out of the base, and so does the + higher-stake machines' rise from 20% to 25% (1 April 2027); Bingo Duty's abolition (1 April 2026) holds its base flat.""" growth = gambling_growth() hmrc = {key: values[0] for key, values in HMRC_RECEIPTS.items()} @@ -2069,11 +2089,13 @@ def test_gambling_bases_take_out_the_rates_the_model_charges(): 2027: ratio(0.9 / remote, 0.72 / 0.15), }, "bingo": {2025: ratio(0.03, hmrc["bingo"]), 2026: 0.0, 2027: 0.0}, - # A March 2026 EFO includes no Autumn Budget 2026 measure. + # Higher-stake machines pay 25% from 2027-28. "machine_games": { 2025: ratio(0.65, hmrc["machine_games"]), 2026: ratio(0.66, 0.65), - 2027: ratio(0.8, 0.66), + 2027: ratio( + 0.8 / machine_games_rate(2027), 0.66 / machine_games_rate(2026) + ), }, } for key in ("lottery", "gaming", "pool_betting"): @@ -2087,18 +2109,6 @@ def test_gambling_bases_take_out_the_rates_the_model_charges(): assert growth["remote_gaming"][2026] == pytest.approx(0.05, abs=1e-6) -def test_efos_from_october_2026_take_off_the_machine_games_measure(): - growth = gambling_growth(release="October 2026")["machine_games"] - assert growth == pytest.approx( - { - 2025: ratio(0.65, HMRC_RECEIPTS["machine_games"][0]), - 2026: ratio(0.66 - MEASURE_56[2026] / 1000, 0.65), - 2027: ratio(0.8 - MEASURE_56[2027] / 1000, 0.66), - }, - abs=1.5e-6, - ) - - def test_real_october_2026_gambling_series(): """The committed series, from Table 4.10 of the real October 2026 tables and HMRC's outturn, by hand.""" @@ -2117,11 +2127,14 @@ def test_real_october_2026_gambling_series(): (0.87744453968275893 / remote) / (0.65560522982822456 / 0.15) - 1, 6 ) assert values["general_betting"][2027] == 0.027038 - # Machine Games Duty: 2027-28 less measure 56's GBP 144.2m. + # Machine Games Duty: 2027-28 with the higher-stake share at 25%. assert values["machine_games"][2027] == round( - (0.82692873727074745 - MEASURE_56[2027] / 1000) / 0.67707068705786499 - 1, 6 + (0.82692873727074745 / machine_games_rate(2027)) + / (0.67707068705786499 / machine_games_rate(2026)) + - 1, + 6, ) - assert values["machine_games"][2027] == 0.008426 + assert values["machine_games"][2027] == -0.002319 assert values["bingo"] == dict.fromkeys(range(2026, 2032), 0.0) | {2025: 0.03329} for series in values.values(): assert sorted(series) == list(range(2025, 2032)) @@ -2147,27 +2160,6 @@ def test_gambling_outturn_must_reach_the_year_before_the_table(): ) -def test_a_measure_covers_every_year_from_its_first(): - spec = ReceiptsSeriesSpec( - path=("x",), - title="t", - row="r", - rates=(RateComponent("gov.hmrc.gambling_duty.lottery.rate"),), - measures=( - ReceiptsMeasure("m", ("October", 2026), ((2027, 100.0), (2028, 50.0))), - ), - ) - # No receipts before its first year; none in an EFO before its own. - assert measure_receipts(spec, [2026, 2027, 2028], None) == { - 2026: 0.0, - 2027: 0.1, - 2028: 0.05, - } - assert measure_receipts(spec, [2027], ("March", 2026)) == {2027: 0.0} - with pytest.raises(ValueError, match="m has no receipts for 2029-30"): - measure_receipts(spec, [2028, 2029], ("March", 2027)) - - def test_bingo_holds_its_base_flat_after_the_forecast(): rendered = render() start = rendered.index(" bingo:") @@ -2206,44 +2198,196 @@ def test_gambling_comments_and_references_name_hmrc_and_the_rates(): ] +def test_the_machine_games_parameters_are_the_oracles(): + """The hand-written Machine Games Duty rates above are the model's: 5%, + 20% and 25%, with higher-stake machines at the higher rate from 1 April + 2027.""" + folder = Path(__file__).parents[1] / "parameters/gov/hmrc/gambling_duty" + + def values(name): + data = yaml.safe_load((folder / f"machine_games/{name}.yaml").read_text()) + return { + str(day): value["value"] if isinstance(value, dict) else value + for day, value in data["values"].items() + } + + assert values("rates/lower") == {"2013-02-01": 0.05} + assert values("rates/standard") == {"2013-02-01": 0.2} + assert values("rates/higher") == {"2015-03-01": 0.25} + assert values("higher_stake_machines_at_higher_rate") == { + "2013-02-01": False, + "2027-04-01": True, + } + + +def test_the_higher_stake_share_is_category_b_over_b_and_c(): + assert MACHINE_GAMES_HIGHER_STAKE_SHARE == HIGHER_STAKE_SHARE + assert HIGHER_STAKE_SHARE == pytest.approx(0.907833, abs=1e-6) + spec = machine_games_spec(HIGHER_STAKE_SHARE) + assert spec in GAMBLING_SPECS + standard = MACHINE_GAMES_BANDS["standard"][0] + assert [part.weight for part in spec.rates] == pytest.approx( + [ + MACHINE_GAMES_BANDS["lower"][0], + standard * (1 - HIGHER_STAKE_SHARE), + standard * HIGHER_STAKE_SHARE, + MACHINE_GAMES_BANDS["higher"][0], + ] + ) + + +def machine_games_levels(levels): + """A synthetic March 2026 workbook whose Machine Games Duty line is + ``levels`` from 2025-26 (the other lines are 1).""" + return make_receipts_xlsx( + gambling_levels={key: (1.0,) * len(levels) for key in GAMBLING_LABELS} + | {"machine_games": tuple(levels)} + ) + + +def machine_games_growth(spec, levels): + growth = compute_receipts_growth(machine_games_levels(levels), b"", specs=(spec,)) + return growth[spec.path].values + + +def test_a_rise_that_behaviour_offsets_gives_a_base_index_of_0_8(): + """£100 of higher-stake takings pay £20 at 20%. If the rise to 25% cuts + them to £80, they pay the same £20 and receipts hold flat. The base index + falls to 0.8, so the model's 25% on the projected £80 gives back £20.""" + spec = machine_games_spec(1.0) + standard_only = replace( + spec, + rates=tuple(part for part in spec.rates if part.parameter.endswith("standard")), + outturn=replace(spec.outturn, receipts_thousands=((2024, 20e3),)), + ) + values = machine_games_growth(standard_only, (0.02, 0.02, 0.02)) + assert values == {2025: 0.0, 2026: 0.0, 2027: -0.2} + index = (1 + values[2025]) * (1 + values[2026]) * (1 + values[2027]) + assert index == pytest.approx(0.8, abs=1e-12) + assert 100 * index * 0.25 == pytest.approx(20, abs=1e-9) + + +SHARES = st.floats(min_value=0, max_value=1, allow_nan=False) + + +@settings(max_examples=60, deadline=None, derandomize=True) +@given(share=SHARES, levels=st.lists(LEVELS, min_size=5, max_size=5)) +def test_machine_games_bases_reconcile_to_receipts_at_any_share(share, levels): + """For any higher-stake share and any receipts path from 2025-26 to + 2029-30: each year's growth is the receipts' growth over the growth in + the rate the model charges with that share at 25% from 2027-28. So a base + that gives 2024-25's receipts gives every later year's at that rate, to + within the rounding of the six-decimal growth.""" + values = machine_games_growth(machine_games_spec(share), levels) + receipts = {2024: HMRC_RECEIPTS["machine_games"][0]} + receipts |= dict(zip(range(2025, 2030), levels)) + rates = {year: machine_games_rate(year, share) for year in receipts} + assert sorted(values) == list(range(2025, 2030)) + base = receipts[2024] / rates[2024] + tolerance = 1.0 + for year in range(2025, 2030): + exact = (receipts[year] / rates[year]) / (receipts[year - 1] / rates[year - 1]) + assert values[year] == pytest.approx(exact - 1, abs=5.1e-7) + base *= 1 + values[year] + tolerance *= 1 + 5.1e-7 / exact + assert base * rates[year] == pytest.approx( + receipts[year], rel=tolerance - 1 + 1e-12 + ) + + +@settings(max_examples=40, deadline=None, derandomize=True) +@given(levels=st.lists(LEVELS, min_size=5, max_size=5)) +def test_a_zero_share_grows_the_machine_games_base_with_receipts(levels): + """With no higher-stake share the rate does not change, so the base grows + with receipts: the plain rule of receipts over the rate, the series of + the three bands alone.""" + zero = machine_games_spec(0.0) + bands = replace(zero, rates=tuple(part for part in zero.rates if not part.switch)) + assert len(bands.rates) == 3 + values = machine_games_growth(zero, levels) + assert values == machine_games_growth(bands, levels) + receipts = {2024: HMRC_RECEIPTS["machine_games"][0]} + receipts |= dict(zip(range(2025, 2030), levels)) + assert values == pytest.approx( + {year: receipts[year] / receipts[year - 1] - 1 for year in range(2025, 2030)}, + abs=5.1e-7, + ) + + GAMBLING_RATE_PARAMETERS = sorted( - {component.parameter for spec in GAMBLING_SPECS for component in spec.rates} + {path for spec in GAMBLING_SPECS for part in spec.rates for path in part.paths} ) +SWITCHES = {part.switch for spec in GAMBLING_SPECS for part in spec.rates} - {None} RATE = st.floats(min_value=0.01, max_value=0.6, allow_nan=False) @st.composite def rate_schedules(draw): - """Dated rates for every gambling rate parameter: a first value, then - perhaps a change on any day from 2024 to 2028, to zero or not.""" + """Dated values for every gambling rate parameter: a first value, then + perhaps a change on any day from 2024 to 2028, to zero or not. A switch + takes true or false, read as 1 or 0.""" schedules = {} for parameter in GAMBLING_RATE_PARAMETERS: - entries = [(date(2015, 1, 1), draw(RATE))] + value = st.booleans().map(float) if parameter in SWITCHES else RATE + changed = st.booleans().map(float) if parameter in SWITCHES else RATE + entries = [(date(2015, 1, 1), draw(value))] if draw(st.booleans()): day = draw(st.dates(date(2024, 1, 1), date(2028, 12, 31))) - entries.append((day, draw(st.one_of(st.just(0.0), RATE)))) + entries.append((day, draw(st.one_of(st.just(0.0), changed)))) schedules[parameter] = tuple(entries) return schedules def oracle_rate(spec, schedules, year): - """The weighted mean of each part's daily rate over 1 April to 31 March.""" + """The weighted mean of each part's daily rate over 1 April to 31 March. + A switched part pays its switched rate on the days its switch is on.""" days = [ date.fromordinal(day) for day in range(date(year, 4, 1).toordinal(), date(year + 1, 4, 1).toordinal()) ] + + def on(parameter, day): + entries = schedules[parameter] + return max((e for e in entries if e[0] <= day), default=entries[0])[1] + total = sum(component.weight for component in spec.rates) mean = 0.0 for component in spec.rates: - entries = schedules[component.parameter] daily = [ - max((e for e in entries if e[0] <= day), default=entries[0])[1] + on(component.switched, day) + if component.switch and on(component.switch, day) + else on(component.parameter, day) for day in days ] mean += component.weight * sum(daily) / len(daily) return mean / total +def oracle_growth(spec, receipts, schedules): + """Each year's base growth to six decimals, and whether the index the + model compounds from it, rounded to five decimals, reaches zero.""" + rates = {year: oracle_rate(spec, schedules, year) for year in receipts} + growth, index, zero = {}, 1.0, False + for year in sorted(receipts)[1:]: + if rates[year] < 1e-12 or rates[year - 1] < 1e-12: + growth[year] = 0.0 + else: + exact = (receipts[year] / rates[year]) / ( + receipts[year - 1] / rates[year - 1] + ) + growth[year] = round(exact - 1, 6) + index = round(index * (1 + growth[year]), 5) + zero = zero or index <= 0 + return growth, rates, zero + + +def gambling_receipts(key, levels): + return { + 2024: HMRC_RECEIPTS[key][0], + **dict(zip(range(2025, 2025 + len(levels)), levels)), + } + + @settings(max_examples=40, deadline=None, derandomize=True) @given( levels=st.fixed_dictionaries( @@ -2257,24 +2401,39 @@ def test_base_growth_is_receipts_growth_over_rate_growth(levels, schedules, fact times the rate's growth is the receipts' growth (to the six decimals written); a year whose rate, or the year before's, is zero holds the base flat; and one factor on every rate, a rate reform the model applies - itself, changes no growth.""" + itself, changes no growth. A series whose index would round to zero is + refused instead.""" module = import_obr_forecasts workbook = make_receipts_xlsx(gambling_levels=levels) + scaled = { + parameter: tuple( + (day, value if parameter in SWITCHES else value * factor) + for day, value in entries + ) + for parameter, entries in schedules.items() + } + oracles = { + spec.path: oracle_growth( + spec, gambling_receipts(spec.path[-1], levels[spec.path[-1]]), schedules + ) + for spec in GAMBLING_SPECS + } with pytest.MonkeyPatch.context() as patch: - patch.setattr(module, "rate_values", lambda parameter: schedules[parameter]) + if any(zero for _, _, zero in oracles.values()): + event("refused") + patch.setattr(module, "parameter_values", lambda p: schedules[p]) + with pytest.raises(ValueError, match="too small to grow from"): + compute_receipts_growth(workbook, b"", specs=GAMBLING_SPECS) + return + event("written") + patch.setattr(module, "parameter_values", lambda p: schedules[p]) growth = compute_receipts_growth(workbook, b"", specs=GAMBLING_SPECS) - scaled = { - parameter: tuple((day, value * factor) for day, value in entries) - for parameter, entries in schedules.items() - } - patch.setattr(module, "rate_values", lambda parameter: scaled[parameter]) + patch.setattr(module, "parameter_values", lambda p: scaled[p]) growth_scaled = compute_receipts_growth(workbook, b"", specs=GAMBLING_SPECS) for spec in GAMBLING_SPECS: - key = spec.path[-1] values = growth[spec.path].values - receipts = {2024: HMRC_RECEIPTS[key][0], 2025: levels[key][0]} - receipts |= {2026: levels[key][1], 2027: levels[key][2]} - rates = {year: oracle_rate(spec, schedules, year) for year in receipts} + receipts = gambling_receipts(spec.path[-1], levels[spec.path[-1]]) + expected, rates, _ = oracles[spec.path] assert sorted(values) == [2025, 2026, 2027] for year, value in values.items(): if rates[year] < 1e-12 or rates[year - 1] < 1e-12: @@ -2286,3 +2445,62 @@ def test_base_growth_is_receipts_growth_over_rate_growth(levels, schedules, fact assert value == pytest.approx(exact - 1, abs=1e-6, rel=1e-9) assert 1 + value > 0 assert growth_scaled[spec.path].values == pytest.approx(values, abs=1.5e-6) + + +def test_a_fall_that_rounds_the_index_to_zero_is_refused(): + """Receipts that fall from £1,000bn to £0.01bn as the rate rises from 1% + to 60% shrink the base by a factor of six million. To six decimals that + growth is -100%, which would hold the index at zero in every later year, + so the importer refuses it.""" + spec = next(spec for spec in GAMBLING_SPECS if spec.path[-1] == "remote_gaming") + workbook = make_receipts_xlsx( + gambling_levels=GAMBLING_LEVELS | {"remote_gaming": (1000.0, 0.01, 0.01)} + ) + schedule = ((date(2015, 1, 1), 0.01), (date(2026, 4, 1), 0.6)) + with pytest.MonkeyPatch.context() as patch: + patch.setattr(import_obr_forecasts, "parameter_values", lambda p: schedule) + with pytest.raises( + ValueError, + match="2026-27 growth of -1.0 takes the index to 0.0 at five decimals", + ): + compute_receipts_growth(workbook, b"", specs=(spec,)) + + +POSITIVE = st.one_of(LEVELS, st.floats(min_value=1e-9, max_value=1e-2, allow_nan=False)) + + +@settings(max_examples=60, deadline=None, derandomize=True) +@given( + levels=st.fixed_dictionaries( + {key: st.tuples(POSITIVE, POSITIVE, POSITIVE) for key in GAMBLING_LABELS} + ), + schedules=rate_schedules(), +) +def test_a_written_series_keeps_every_index_positive(levels, schedules): + """For any positive receipts, however small, and any dated rates: the + importer either refuses, when the written growth would round the model's + index to zero, or writes series whose index the model compounds to five + decimals stays above zero in every year.""" + workbook = make_receipts_xlsx(gambling_levels=levels) + with pytest.MonkeyPatch.context() as patch: + patch.setattr(import_obr_forecasts, "parameter_values", lambda p: schedules[p]) + try: + growth = compute_receipts_growth(workbook, b"", specs=GAMBLING_SPECS) + except ValueError as error: + event("refused") + assert "too small to grow from" in str(error) + assert any( + oracle_growth( + spec, + gambling_receipts(spec.path[-1], levels[spec.path[-1]]), + schedules, + )[2] + for spec in GAMBLING_SPECS + ) + return + event("written") + for series in growth.values(): + index = 1.0 + for year in sorted(series.values): + index = round(index * (1 + series.values[year]), 5) + assert index > 0 diff --git a/policyengine_uk/utils/import_obr_forecasts.py b/policyengine_uk/utils/import_obr_forecasts.py index 837537f3eb..a22a95f0cb 100644 --- a/policyengine_uk/utils/import_obr_forecasts.py +++ b/policyengine_uk/utils/import_obr_forecasts.py @@ -600,22 +600,32 @@ class RateComponent: ``gov.hmrc.gambling_duty.remote_gaming.rate``. ``weight`` is the part's size, in any unit: divided by the total of a line's weights, it is the part's share of the base, held at that share in every year. + + A part with a ``switch``, the path of a dated true-or-false parameter, + pays the rate at ``switched`` on the days the switch is true, as the + model's formula does for a part that moves between rates. """ parameter: str weight: float = 1.0 + switch: str | None = None + switched: str | None = None + def __post_init__(self): + if (self.switch is None) != (self.switched is None): + raise ValueError( + f"{self.parameter}: a switched part needs both a switch and " + "the rate it switches to" + ) -@dataclass(frozen=True) -class ReceiptsMeasure: - """Receipts a policy measure adds to a line, in £ million (as the OBR's - policy tables publish them) by the calendar year in which each fiscal - year starts. EFOs from ``release`` on include them, and so does outturn - once the measure starts: it has no receipts before its first year.""" - - description: str - release: tuple[str, int] - receipts_millions: tuple[tuple[int, float], ...] + @property + def paths(self) -> tuple[str, ...]: + """The parameters the part's rate reads.""" + return tuple( + path + for path in (self.parameter, self.switch, self.switched) + if path is not None + ) @dataclass(frozen=True) @@ -642,24 +652,41 @@ class ReceiptsSeriesSpec: A line with ``rates`` grows a duty's base rather than its receipts, so a rate change the model applies itself moves receipts only once. Each - year's base is its receipts, less the ``measures`` they include, over - the rate the model charges in that fiscal year (see ``line_rate``). A - year whose rate is zero has no base, so the series holds the base flat - into and out of it. A line with ``outturn`` takes the years before the - EFO's first from it, not from the previous EFO, which it does not read. - ``references`` are cited after the sources of the levels. + year's base is its receipts over the rate the model charges in that + fiscal year (see ``line_rate``). A year whose rate is zero has no base, + so the series holds the base flat into and out of it. A line with + ``outturn`` takes the years before the EFO's first from it, not from the + previous EFO, which it does not read. ``references`` are cited after the + sources of the levels. """ path: tuple[str, ...] title: str row: str rates: tuple[RateComponent, ...] = () - measures: tuple[ReceiptsMeasure, ...] = () outturn: ReceiptsOutturn | None = None references: tuple[tuple[str, str], ...] = () GAMBLING_RATES = "gov.hmrc.gambling_duty" +MACHINE_GAMES = f"{GAMBLING_RATES}.machine_games" + +# The higher-stake share of Machine Games Duty's standard-rate base: the part +# from machines with a maximum charge above £1 or a maximum prize above £100, +# which pays the higher rate from 1 April 2027 (#2731). It is the Gambling +# Commission's 2024-25 gaming machine yield in Category B over that in +# Categories B and C, £ million (Industry Statistics, April 2025 to March +# 2026 release, sheet 4, cells K34 and L34, revised). Category B holds the +# machines outside Categories C and D, and Category C's limits are a £1 +# charge and a £100 prize. Category D machines either offer no cash prize, +# so their games are not dutiable, or charge at most 20p for at most £10 in +# cash, the lower-rate class (Finance Act 2012, Schedule 24, paragraphs 2(2) +# and 5(2)). The statistics cover only the premises the Commission licenses +# in Great Britain, not pubs, clubs or Northern Ireland: see +# docs/engineering/gambling-duties.md for how that bears on the share. +MACHINE_GAMES_HIGHER_STAKE_SHARE = 2_289.84969448829 / ( + 2_289.84969448829 + 232.47489298159996 +) def gambling_spec( @@ -691,6 +718,43 @@ def gambling_spec( ) +def machine_games_spec(higher_stake_share: float) -> ReceiptsSeriesSpec: + """Machine Games Duty's base. Each band's weight is its 2024-25 base (£): + HMRC's liabilities (Table_2_MGD row 20) over its rate. Table 4.10's line + includes Autumn Budget 2026's higher rate on higher-stake machines + (paragraph 4.45), so ``higher_stake_share`` of the standard-rate band + pays the higher rate on the days the model's switch is on, as + ``machine_games_duty`` charges a higher-stake input at that share.""" + standard = 3_023_740_000 + return gambling_spec( + "machine_games", + "machine games duty", + ( + RateComponent(f"{MACHINE_GAMES}.rates.lower", 138_420_000), + RateComponent( + f"{MACHINE_GAMES}.rates.standard", standard * (1 - higher_stake_share) + ), + RateComponent( + f"{MACHINE_GAMES}.rates.standard", + standard * higher_stake_share, + switch=f"{MACHINE_GAMES}.higher_stake_machines_at_higher_rate", + switched=f"{MACHINE_GAMES}.rates.higher", + ), + RateComponent(f"{MACHINE_GAMES}.rates.higher", 2_384_000), + ), + ((2024, 609_245), (2025, 641_010)), + references=( + ( + "Gambling Commission, Industry Statistics, April 2025 to March " + "2026 (sheet 4, 2024-25 gaming machine yield by category)", + "https://www.gamblingcommission.gov.uk/statistics-and-research/" + "publication/industry-statistics-annual-report-financial-year-april-" + "2025-to-march-2026-official-statistics", + ), + ), + ) + + RECEIPTS_SERIES_SPECS = ( ReceiptsSeriesSpec( path=("stamp_duty_land_tax", "residential"), @@ -763,44 +827,7 @@ def gambling_spec( ), ), ), - # Each band's 2024-25 base (£): HMRC's Machine Games Duty liabilities - # (Table_2_MGD row 20) over its rate. The October 2026 line includes - # Autumn Budget 2026's Machine Games Duty measures (paragraph 4.45), - # which come off before the rates. - gambling_spec( - "machine_games", - "machine games duty", - ( - RateComponent(f"{GAMBLING_RATES}.machine_games.rates.lower", 138_420_000), - RateComponent( - f"{GAMBLING_RATES}.machine_games.rates.standard", 3_023_740_000 - ), - RateComponent(f"{GAMBLING_RATES}.machine_games.rates.higher", 2_384_000), - ), - ((2024, 609_245), (2025, 641_010)), - measures=( - ReceiptsMeasure( - "Autumn Budget 2026 measure 56 (Machine Games Duty on higher-stake " - "machines and supplier liability)", - ("October", 2026), - ( - (2026, 0.0), - (2027, 144.15331361978284), - (2028, 162.30500845324954), - (2029, 157.57136998103317), - (2030, 155.90187446314559), - (2031, 156.40657502685724), - ), - ), - ), - references=( - ( - "OBR EFO October 2026 (detailed forecast tables, policy, Table 1.1, " - "measure 56, Betting)", - "https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/", - ), - ), - ), + machine_games_spec(MACHINE_GAMES_HIGHER_STAKE_SHARE), # The household Gaming Duty formula charges one effective rate, not the # statutory bands. gambling_spec( @@ -825,9 +852,9 @@ def gambling_spec( @lru_cache(maxsize=None) -def rate_values(parameter: str) -> tuple[tuple[date, float], ...]: - """A rate parameter's dated values, earliest first, from its file in the - model's parameter tree.""" +def parameter_values(parameter: str) -> tuple[tuple[date, float], ...]: + """A parameter's dated values, earliest first, from its file in the + model's parameter tree. True and false read as 1 and 0.""" path = get_repo_root() / "policyengine_uk/parameters" / Path(*parameter.split(".")) values = yaml.safe_load(path.with_suffix(".yaml").read_text())["values"] entries = [] @@ -840,18 +867,39 @@ def rate_values(parameter: str) -> tuple[tuple[date, float], ...]: return tuple(sorted(entries)) -def fiscal_year_rate(parameter: str, year: int) -> float: - """A rate's mean over the OBR's fiscal year ``year``, 1 April to 31 March, - weighted by days. Before the rate's first value it takes that value, as - the model does: it backdates each parameter's first value.""" +def value_on(parameter: str, day: date) -> float: + """A parameter's value on ``day``. Before its first value it takes that + value, as the model does: it backdates each parameter's first value.""" + entries = parameter_values(parameter) + value = entries[0][1] + for instant, entry in entries: + if instant <= day: + value = entry + return value + + +def component_rate(component: RateComponent, day: date) -> float: + """The rate a part of a base pays on ``day``.""" + if component.switch is not None and value_on(component.switch, day): + return value_on(component.switched, day) + return value_on(component.parameter, day) + + +def fiscal_year_rate(component: RateComponent, year: int) -> float: + """A part's rate over the OBR's fiscal year ``year``, 1 April to 31 + March, averaged by days.""" start, end = date(year, 4, 1), date(year + 1, 4, 1) - entries = rate_values(parameter) - segments = [] - for index, (instant, value) in enumerate(entries): - lower = start if index == 0 else max(instant, start) - upper = min(entries[index + 1][0], end) if index + 1 < len(entries) else end - if upper > lower: - segments.append((value, (upper - lower).days)) + changes = { + instant + for path in component.paths + for instant, _ in parameter_values(path) + if start < instant < end + } + bounds = [start, *sorted(changes), end] + segments = [ + (component_rate(component, lower), (upper - lower).days) + for lower, upper in zip(bounds, bounds[1:]) + ] if len({value for value, _ in segments}) == 1: return segments[0][0] return sum(value * days for value, days in segments) / (end - start).days @@ -863,38 +911,13 @@ def line_rate(spec: ReceiptsSeriesSpec, year: int) -> float: total = sum(component.weight for component in spec.rates) return ( sum( - component.weight * fiscal_year_rate(component.parameter, year) + component.weight * fiscal_year_rate(component, year) for component in spec.rates ) / total ) -def measure_receipts( - spec: ReceiptsSeriesSpec, years: list[int], release: tuple[str, int] | None -) -> dict[int, float]: - """The receipts (£bn) of ``spec``'s measures in each of ``years``, as an - EFO of ``release`` includes them, or outturn if ``release`` is None. - A measure has none before its first year, and must cover every later - year it is included in.""" - total = dict.fromkeys(years, 0.0) - for measure in spec.measures: - if release is not None and release_order(release) < release_order( - measure.release - ): - continue - receipts = dict(measure.receipts_millions) - missing = [year for year in years if year > max(receipts)] - if missing: - raise ValueError( - f"{'.'.join(spec.path)}: {measure.description} has no receipts " - "for " + ", ".join(fiscal_year_label(year) for year in missing) - ) - for year in years: - total[year] += receipts.get(year, 0.0) / 1000 - return total - - @dataclass(frozen=True) class ReceiptsLine: """A receipts line as one EFO publishes it: £bn by the calendar year in @@ -1129,7 +1152,6 @@ def find_receipts_line( def compute_receipts_growth( xlsx_bytes: bytes, previous_xlsx_bytes: bytes, - release: tuple[str, int] | None = None, specs: tuple[ReceiptsSeriesSpec, ...] = RECEIPTS_SERIES_SPECS, ) -> dict[tuple[str, ...], ReceiptsGrowth]: """Growth in each receipts line, from an EFO's receipts tables and an @@ -1138,8 +1160,7 @@ def compute_receipts_growth( The first year must be outturn in this EFO, and the year before it outturn in the earlier one (March 2025 for March 2026). A line with its own outturn needs that outturn to reach the year before this EFO's - first instead. ``release``, which says which measures this EFO - includes, defaults to the one its contents sheet names. + first instead. """ sheets = read_workbook_sheets(xlsx_bytes) previous_sheets = None @@ -1181,17 +1202,6 @@ def compute_receipts_growth( f"outturn year is {fiscal_year_label(base_year)}" ) levels = {base_year: previous.levels[base_year], **line.levels} - if spec.measures: - efo = release or infer_workbook_release(xlsx_bytes) - if efo is None: - raise ValueError( - f"{name}: could not tell which EFO the receipts tables are " - "from, so which measures they include" - ) - outturn_years = sorted(year for year in levels if year < first_year) - for years, source in ((outturn_years, None), (sorted(line.levels), efo)): - for year, receipts in measure_receipts(spec, years, source).items(): - levels[year] -= receipts rates = {year: line_rate(spec, year) for year in levels} if spec.rates else {} # Growth to or from a level at or below zero means nothing, and a # zero last level would zero the series' index from then on. A year @@ -1213,6 +1223,19 @@ def compute_receipts_growth( levels[year - 1] / rates[year - 1] ) values[year] = round(base_growth - 1, 6) + 0.0 + # The model compounds the growth into an index rounded to five + # decimals (create_economic_assumption_indices), from 1 in a line's + # first year when the line has its own outturn. A positive base that + # rounds the index to zero would zero it in every later year. + index = 1.0 + for year, value in values.items(): + index = round(index * (1 + value), 5) + if index <= 0: + raise ValueError( + f"{name}: {fiscal_year_label(year)} growth of {value} takes " + f"the index to {index} at five decimals, so the base is " + "too small to grow from" + ) result[spec.path] = ReceiptsGrowth(values, line, previous, spec) return result @@ -1364,11 +1387,10 @@ def render_receipts_values( def receipts_basis(spec: ReceiptsSeriesSpec | None) -> str: """How a line's growth is measured, for its comments: nothing for - receipts, the measures and rates taken out for a base.""" + receipts, the rates taken out for a base.""" if spec is None or not spec.rates: return "" - measures = "".join(f" less {measure.description}," for measure in spec.measures) - return f"; base growth: receipts{measures} over the rate the model charges" + return "; base growth: receipts over the rate the model charges" def receipts_tail(content: str, growth: ReceiptsGrowth) -> tuple[dict[int, float], str]: @@ -1384,7 +1406,8 @@ def receipts_tail(content: str, growth: ReceiptsGrowth) -> tuple[dict[int, float changes = [ instant.year for component in spec.rates - for instant, _ in rate_values(component.parameter) + for parameter in component.paths + for instant, _ in parameter_values(parameter) ] end = max(max(path), max(changes) + 1, last + 1) yearly = {} @@ -1981,9 +2004,7 @@ def main(argv: list[str] | None = None) -> int: f"({previous_release[0]} {previous_release[1]}) must come from " f"an EFO before {month} {year}" ) - receipts_growth = compute_receipts_growth( - receipts_bytes, previous_bytes, (month, year) - ) + receipts_growth = compute_receipts_growth(receipts_bytes, previous_bytes) print_receipts_summary(receipts_growth) calendar_values = ( read_calendar_values(source_yaml_path) if args.gaps_only else series_values From c52ebb93fb5ef060915d58fa216a42b0e8693ffc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 02:28:28 -0400 Subject: [PATCH 07/17] Document the higher-stake share and the shortfall without its input The projection notes now give the Machine Games Duty rate with the higher-stake share at 25% from 2027-28, the share's source and what it leaves out (pubs, clubs and Northern Ireland), and the 18.3% shortfall against Table 4.10 a dataset without the higher-stake input gives. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2738.fixed.md | 2 +- docs/engineering/gambling-duties.md | 135 ++++++++++++++---- .../gov/economic_assumptions/README.md | 18 +-- 3 files changed, 115 insertions(+), 40 deletions(-) diff --git a/changelog.d/2738.fixed.md b/changelog.d/2738.fixed.md index f59c9ae5e6..a8d2a5d06e 100644 --- a/changelog.d/2738.fixed.md +++ b/changelog.d/2738.fixed.md @@ -1 +1 @@ -Projected the sixteen gambling duty inputs from HMRC's 2025-26 receipts and the OBR's October 2026 betting and gaming forecast (receipts Table 4.10), divided by the rates the model charges, so they no longer stay at their 2024-25 cash level and the 2026 and 2027 remote gambling rate rises apply once. +Projected the sixteen gambling duty inputs from HMRC's 2025-26 receipts and the OBR's October 2026 betting and gaming forecast (receipts Table 4.10), divided by the rates the model charges, so they no longer stay at their 2024-25 cash level and the 2026 and 2027 remote gambling rate rises and the 2027 higher-stake machine rate rise apply once. diff --git a/docs/engineering/gambling-duties.md b/docs/engineering/gambling-duties.md index e0072c8102..cb3b710ade 100644 --- a/docs/engineering/gambling-duties.md +++ b/docs/engineering/gambling-duties.md @@ -146,7 +146,8 @@ exactly £1 and £100 stays at 20%, while £1 with a £500 prize, or £2 with a B3A: £2 and £500; B4: £2 and £400](https://www.gamblingcommission.gov.uk/licensees-and-businesses/guide/gaming-machine-categories)) exceed one of the thresholds. Category C (£1 and £100) and Category D stay within them. The tax class turns on the highest charge and prize a machine - actually offers, so a category split approximates it. + actually offers, so a category split approximates it. The projection + assumes 90.78% (see [the higher-stake share](#the-higher-stake-share)). - The duty floors the new input at zero and caps it at the standard-rate input. It never taxes takings that the standard-rate input leaves out. @@ -285,11 +286,10 @@ Each input declares its duty's index, `gov.economic_assumptions.indices.obr.gambling_duty_bases.`, and dataset projection grows it by the same duty's series in `yoy_growth.yaml` (`policyengine_uk/data/uprating_indices.yaml`). A series grows the duty's -base, not its receipts: each year's receipts, less the Budget measures they -include, divided by the rate the model charges that year. The model then -applies its own rate changes once. Grown with receipts instead, the base -would carry Remote Gaming Duty's rise to 40% and the formula would charge -40% on it again. +base, not its receipts: each year's receipts divided by the rate the model +charges that year. The model then applies its own rate changes once. Grown +with receipts instead, the base would carry Remote Gaming Duty's rise to 40% +and the formula would charge 40% on it again. | Fiscal year | Receipts | Source | | --- | --- | --- | @@ -317,28 +317,34 @@ comes out as follows: horse racing as UK horse racing, which keeps 15%, so the part at 25% may be larger, and the 2.70% then overstates the base's growth. Microcosm's split gives spread betting no part, so it has no weight. -- **Machine Games Duty**: the three rates do not change, but from 1 April - 2027 the model charges the higher-stake part of the standard-rate base at - the higher rate (see [higher-stake machines](#higher-stake-machines-from-1-april-2027)). - Table 4.10's line includes that increase to machine gaming rates, which +- **Machine Games Duty**: from 1 April 2027 the model charges the + higher-stake part of the standard-rate base at the higher rate (see + [higher-stake machines](#higher-stake-machines-from-1-april-2027)), and + Table 4.10's line includes that rise: the forecast incorporates the + measures announced since March (EFO paragraph 3.2), and the rise "increases receipts by a further estimated £0.1 billion a year from - 2027-28" (EFO paragraph 4.45). The forecast incorporates every measure - since March (paragraph 3.2), and the OBR's policy Table 1.1 gives this one, - with the move of liability to machine suppliers from April 2028, as - measure 56: £144.2 million of betting receipts in 2027-28, then £162.3, - £157.6, £155.9 and £156.4 million. The series takes those receipts off. - The line grows 22.1% into 2027-28; the base grows (826.9 − 144.2) / 677.1 - − 1 = 0.84%. Those receipts also hold the supplier measure's yield, which - the base therefore excludes. The rate costing also moves a quarter of the - stakes lost from machines to remote gaming. If measure 56's betting - receipts count that Remote Gaming Duty and Table 4.10 puts it in the - remote gaming line, taking the whole measure off this line understates - the base by that amount. Table 1.1 gives only the measure's head, - Betting, and Table 4.10 carries no note on it. - The result is the base before the measure, without the costing's - behavioural response. The model charges its higher-stake part 25% once, - and the higher-stake input takes the same index as the standard-rate - input it belongs to. + 2027-28" (paragraph 4.45). The series divides the line by a rate that + charges the higher-stake share of the standard-rate base, 90.78% (see + [the higher-stake share](#the-higher-stake-share)), at 25% from 2027-28. + Each band's weight is HMRC's 2024-25 liabilities over its rate, so the + rate on the whole base goes from 19.35% to 23.68%. Receipts grow 22.1% + into 2027-28 (£677.1 million to £826.9 million); the base falls by + (826.9 / 23.68%) / (677.1 / 19.35%) − 1 = −0.23%. The importer reads the + date from `gov.hmrc.gambling_duty.machine_games.higher_stake_machines_at_higher_rate` + and the rates from their parameter files, so the series and the formula + read the same dates and rates. + + The OBR's policy Table 1.1 joins two costings as measure 56, under the + head Betting: the 25% rate and the move of liability to machine + suppliers from April 2028. The rate costing nets off a behavioural + response: an elasticity of −0.95, with a quarter of the stakes lost + moving to Remote Gaming Duty, and a general attrition assumption. The + supplier costing comes from reduced non-compliance and earlier payment + ([Budget 2026 policy costings, pages 67 and 68](https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=67)). + Dividing the line by the rate that produced it keeps all of these in the + base, as receipts over the rate does for every other duty. Taking the + measure off the line instead would drop them, and the model's static + rates restore none of them. - **Gaming Duty**: the household formula charges one effective rate, which does not change, so the base grows with receipts. The statutory bands do not enter: any change in the duty's effective rate that the OBR's @@ -360,7 +366,10 @@ The series rest on these approximations: that takes in five days of a 1 April rate change charges them at the new rate, so FY2025 Remote Gaming Duty (1.2%) and FY2026 General Betting Duty (0.4%) sit slightly above the published receipts, and FY2025 Bingo Duty - (1.4%) slightly below. + (1.4%) slightly below. With the higher-stake input at its share, FY2026 + Machine Games Duty sits 0.31% above Table 4.10 for the same reason: the + OBR's 2026-27 charges no higher-stake machine at 25%, while model year + 2026 charges 1 to 5 April 2027 at 25%. - Two lines fall where no rate changes. Table 4.10's Lottery Duty for 2026-27, £830.5 million, is 15.3% below HMRC's 2025-26 outturn of £980.1 million. The March 2026 Table 3.10 forecast £1,008.6 million for the same @@ -369,6 +378,69 @@ The series rest on these approximations: receipts fall from £8.1 million in 2024-25 to £4.0 million in 2025-26 and a forecast £2.8 million in 2026-27, so its index falls by 65%. +### The higher-stake share + +`MACHINE_GAMES_HIGHER_STAKE_SHARE` in +`policyengine_uk/utils/import_obr_forecasts.py` holds the share: 90.78% of +standard-rate takings come from machines with a maximum charge above £1 +or a maximum prize above £100. It is the Gambling Commission's 2024-25 +gaming machine yield in Category B, £2,289.8 million, over that in +Categories B and C, £2,522.3 million (Industry Statistics, April 2025 to +March 2026 release, sheet 4, cells K34 and L34, revised figures). The +2007 regulations define a Category B machine as one outside Categories C +and D, and Category C's limits are a £1 charge and a £100 prize +([Categories of Gaming Machine Regulations 2007, regulation 5](https://www.legislation.gov.uk/uksi/2007/2158/regulation/5); +[Gambling Commission, gaming machine categories](https://www.gamblingcommission.gov.uk/licensees-and-businesses/guide/gaming-machine-categories)). +Category D enters neither side. Its money-prize machines charge at most +20p for at most £10 in cash, which puts them in the lower-rate class +(Finance Act 2012, Schedule 24, paragraph 5(2)). Its other machines pay no +cash prize, so their games are not dutiable (paragraph 2(2)). A machine's +category caps its charge and prize, while the tax class turns on the +highest charge and prize it actually offers, so the category split only +approximates the class. + +The statistics cover the premises the Commission licenses in Great +Britain: arcades, betting shops, bingo halls and casinos. They leave out: + +- pubs and other alcohol-licensed premises, which may hold only Category C + and D machines; +- members' and commercial clubs, which may hold Category B3A or B4 + machines as well as C and D; +- Northern Ireland, outside the statistics' Great Britain coverage. + +HMRC's 2024-25 standard-rate liabilities, £604.7 million at 20%, give a +UK base of £3,023.7 million, £501.4 million (16.6%) more than the +Commission's Category B and C yield. Differences in definition and timing +between gross gambling yield and dutiable net takings may account for +part of it. Pubs' machines never pay the higher rate, so leaving them out +pushes the share up; leaving out clubs' Category B3A and B4 machines +pushes it down. If the whole difference stayed at 20%, +the share would be £2,289.8 million over £3,023.7 million, 75.73%, and +the 2027-28 base index would come out 3.13% higher than at 90.78%. The +Commission's provisional 2025-26 figures give 91.41%. The importer holds +the 2024-25 share fixed in every year, as the band weights are. + +### Datasets without the higher-stake input + +No dataset supplies +`machine_games_standard_rate_higher_stake_net_takings` yet. Until one +does, `machine_games_duty` charges the standard rate on the whole +standard-rate input, the rates before Budget 2026, on a base that grows +by receipts over the rates after it. From 2027-28 the duty therefore +falls 18.3% short of Table 4.10: it is Table 4.10's line times 19.35% / +23.68%. On one constructed household holding the 2024-25 national bases: + +| £ million | 2027-28 | 2028-29 | 2029-30 | 2030-31 | 2031-32 | +| --- | ---: | ---: | ---: | ---: | ---: | +| Table 4.10 | 826.9 | 857.0 | 865.2 | 875.5 | 896.3 | +| Higher-stake input at 90.78% | 826.9 | 857.0 | 865.2 | 875.5 | 896.3 | +| Higher-stake input absent | 675.5 | 700.0 | 706.8 | 715.2 | 732.2 | +| Shortfall without it | −151.4 | −156.9 | −158.4 | −160.3 | −164.1 | + +A dataset that sets the input to the share of its standard-rate input +closes the gap. Before 2026-27 the input changes nothing; in 2026-27 it +adds the five days at 25%. + Every input in the gambling folder declares an index, including `machine_games_standard_rate_higher_stake_net_takings`; `policyengine_uk/tests/test_gambling_duty_base_projection.py` fails for a new @@ -380,8 +452,11 @@ Sources: [OBR October 2026 Economic and Fiscal Outlook](https://obr.uk/efo/econo receipts workbook SHA-256 `d115705716d77b11c190385d41cb1924f30080c9331a8a386c04d4e65a368e95`), [HMRC July 2026 Betting and Gaming tables](https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods), -[Autumn Budget 2026 policy costings, pages 67 and 68](https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=67) -and [Microcosm's 2024-25 gambling bases](https://github.com/PolicyEngine/microcosm/blob/main/docs/evidence/uk-excise-quantities-1359/outputs/national_reconciliation.json). +[Autumn Budget 2026 policy costings, pages 67 and 68](https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=67), +[Microcosm's 2024-25 gambling bases](https://github.com/PolicyEngine/microcosm/blob/main/docs/evidence/uk-excise-quantities-1359/outputs/national_reconciliation.json) +and the [Gambling Commission's Industry Statistics, April 2025 to March 2026](https://www.gamblingcommission.gov.uk/statistics-and-research/publication/industry-statistics-annual-report-financial-year-april-2025-to-march-2026-official-statistics) +(workbook SHA-256 +`a6ea23b5ddff2f3839ebec2a8cc0d9a0817e218f5bf10921e0172ecae768da58`). ## Tax totals and fiscal classification diff --git a/policyengine_uk/parameters/gov/economic_assumptions/README.md b/policyengine_uk/parameters/gov/economic_assumptions/README.md index 0bd4e79a37..ab0173d409 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/README.md +++ b/policyengine_uk/parameters/gov/economic_assumptions/README.md @@ -49,16 +49,16 @@ index keeps its base year. The seven `gambling_duty_bases` series (`general_betting`, `pool_betting`, `remote_gaming`, `machine_games`, `gaming`, `bingo`, `lottery`) grow the gambling duty inputs (#2738). They grow a duty's base, not its receipts: -each year's receipts, less the Budget measures they include, divided by the -rate the model charges that fiscal year. The model then applies its own rate -changes, such as Remote Gaming Duty's rise from 21% to 40% on 1 April 2026, -once. The table publishes forecasts only, so each series starts from HMRC's -2024-25 receipts, the model's base year, and its 2025-26 outturn, which the -importer holds in `RECEIPTS_SERIES_SPECS`. A year whose rate is zero, Bingo -Duty's from 2026-27, holds the base flat. See +each year's receipts divided by the rate the model charges that fiscal +year. The model then applies its own rate changes, such as Remote Gaming +Duty's rise from 21% to 40% on 1 April 2026, once. The table publishes +forecasts only, so each series starts from HMRC's 2024-25 receipts, the +model's base year, and its 2025-26 outturn, which the importer holds in +`RECEIPTS_SERIES_SPECS`. A year whose rate is zero, Bingo Duty's from +2026-27, holds the base flat. See [gambling duties](../../../../docs/engineering/gambling-duties.md#projection) -for each duty's rates, the measure the Machine Games Duty line includes and -the approximations. +for each duty's rates, the higher-stake share Machine Games Duty's rate +assumes, and the approximations. `national_living_wage` projects the minimum wage rates (`gov.hmrc.minimum_wage`) after the last rate set. It is the growth of the From 09c1c1f5f4667ed7b8779ccc1acfa28fbc237a4c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 04:43:57 -0400 Subject: [PATCH 08/17] Write the gambling projection notes in the active voice Co-Authored-By: Claude Opus 5.5 --- docs/engineering/gambling-duties.md | 42 +++++++++++++++-------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/docs/engineering/gambling-duties.md b/docs/engineering/gambling-duties.md index cb3b710ade..89ef226694 100644 --- a/docs/engineering/gambling-duties.md +++ b/docs/engineering/gambling-duties.md @@ -326,8 +326,9 @@ comes out as follows: 2027-28" (paragraph 4.45). The series divides the line by a rate that charges the higher-stake share of the standard-rate base, 90.78% (see [the higher-stake share](#the-higher-stake-share)), at 25% from 2027-28. - Each band's weight is HMRC's 2024-25 liabilities over its rate, so the - rate on the whole base goes from 19.35% to 23.68%. Receipts grow 22.1% + The importer weights each band by HMRC's 2024-25 liabilities over its + rate, so the rate on the whole base rises from 19.35% to 23.68%. Receipts + grow 22.1% into 2027-28 (£677.1 million to £826.9 million); the base falls by (826.9 / 23.68%) / (677.1 / 19.35%) − 1 = −0.23%. The importer reads the date from `gov.hmrc.gambling_duty.machine_games.higher_stake_machines_at_higher_rate` @@ -349,19 +350,19 @@ comes out as follows: does not change, so the base grows with receipts. The statutory bands do not enter: any change in the duty's effective rate that the OBR's forecast includes stays in the base. -- **Bingo Duty**: 10% until its abolition on 1 April 2026. With a zero rate - there is no base, so the index holds its 2025-26 level from 2026-27. - Current-law receipts are zero whatever the base; a reform that charges - Bingo Duty again charges that 2025-26 base in cash terms. +- **Bingo Duty**: 10% until its abolition on 1 April 2026. A zero rate + leaves no base to divide out, so the index holds its 2025-26 level from + 2026-27. Current law charges nothing whatever the base; a reform that + charges Bingo Duty again charges that 2025-26 base in cash terms. - **Lottery Duty** (12%) and **Pool Betting Duty** (15%): one rate each, so the base grows with receipts. The series rest on these approximations: -- HMRC's receipts are cash. Table 4.10 is on an accrued basis: its totals - equal the "Betting and gaming taxes" row of the October 2026 Table 4.1, - whose note says all its taxes are accrued. Growth into 2026-27 compares - accrued 2026-27 receipts with cash 2025-26 receipts. +- HMRC reports cash receipts. Table 4.10 measures accrued receipts: its + totals equal the "Betting and gaming taxes" row of the October 2026 Table + 4.1, whose note reads "All taxes are on an accrued basis." Growth into + 2026-27 compares accrued 2026-27 receipts with cash 2025-26 receipts. - Model years run from 6 April and the OBR's from 1 April. A model year that takes in five days of a 1 April rate change charges them at the new rate, so FY2025 Remote Gaming Duty (1.2%) and FY2026 General Betting Duty @@ -371,8 +372,8 @@ The series rest on these approximations: OBR's 2026-27 charges no higher-stake machine at 25%, while model year 2026 charges 1 to 5 April 2027 at 25%. - Two lines fall where no rate changes. Table 4.10's Lottery Duty for - 2026-27, £830.5 million, is 15.3% below HMRC's 2025-26 outturn of £980.1 - million. The March 2026 Table 3.10 forecast £1,008.6 million for the same + 2026-27, £830.5 million, falls short of HMRC's 2025-26 outturn of £980.1 + million by 15.3%. The March 2026 Table 3.10 forecast £1,008.6 million for the same year; the October policy table holds no Lottery Duty measure, and the EFO gives no reason in paragraph 4.45. Pool Betting Duty receipts fall from £8.1 million in 2024-25 to £4.0 million in 2025-26 and @@ -383,18 +384,19 @@ The series rest on these approximations: `MACHINE_GAMES_HIGHER_STAKE_SHARE` in `policyengine_uk/utils/import_obr_forecasts.py` holds the share: 90.78% of standard-rate takings come from machines with a maximum charge above £1 -or a maximum prize above £100. It is the Gambling Commission's 2024-25 -gaming machine yield in Category B, £2,289.8 million, over that in +or a maximum prize above £100. It divides the Gambling Commission's +2024-25 gaming machine yield in Category B, £2,289.8 million, by that in Categories B and C, £2,522.3 million (Industry Statistics, April 2025 to March 2026 release, sheet 4, cells K34 and L34, revised figures). The 2007 regulations define a Category B machine as one outside Categories C -and D, and Category C's limits are a £1 charge and a £100 prize +and D, and the Commission gives Category C's limits as a £1 charge and a +£100 prize ([Categories of Gaming Machine Regulations 2007, regulation 5](https://www.legislation.gov.uk/uksi/2007/2158/regulation/5); [Gambling Commission, gaming machine categories](https://www.gamblingcommission.gov.uk/licensees-and-businesses/guide/gaming-machine-categories)). Category D enters neither side. Its money-prize machines charge at most 20p for at most £10 in cash, which puts them in the lower-rate class (Finance Act 2012, Schedule 24, paragraph 5(2)). Its other machines pay no -cash prize, so their games are not dutiable (paragraph 2(2)). A machine's +cash prize, so their games fall outside the duty (paragraph 2(2)). A machine's category caps its charge and prize, while the tax class turns on the highest charge and prize it actually offers, so the category split only approximates the class. @@ -416,9 +418,9 @@ part of it. Pubs' machines never pay the higher rate, so leaving them out pushes the share up; leaving out clubs' Category B3A and B4 machines pushes it down. If the whole difference stayed at 20%, the share would be £2,289.8 million over £3,023.7 million, 75.73%, and -the 2027-28 base index would come out 3.13% higher than at 90.78%. The +the 2027-28 base index would rise by 3.13% from its level at 90.78%. The Commission's provisional 2025-26 figures give 91.41%. The importer holds -the 2024-25 share fixed in every year, as the band weights are. +the 2024-25 share in every year, as it does the band weights. ### Datasets without the higher-stake input @@ -427,8 +429,8 @@ No dataset supplies does, `machine_games_duty` charges the standard rate on the whole standard-rate input, the rates before Budget 2026, on a base that grows by receipts over the rates after it. From 2027-28 the duty therefore -falls 18.3% short of Table 4.10: it is Table 4.10's line times 19.35% / -23.68%. On one constructed household holding the 2024-25 national bases: +falls short of Table 4.10 by 18.3%: the duty equals Table 4.10's line +times 19.35% / 23.68%. On one constructed household holding the 2024-25 national bases: | £ million | 2027-28 | 2028-29 | 2029-30 | 2030-31 | 2031-32 | | --- | ---: | ---: | ---: | ---: | ---: | From 1995d66e0fa83a44213c04e190f717658a6379ab Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 05:16:10 -0400 Subject: [PATCH 09/17] Check the finished index before writing a series The positivity check ran on the forecast's growth, before the earnings tail joined it, so a tail could still round the model's index to zero. Compound the finished series as the model does and refuse that case. Correct the machine category reasoning in the docs and drop a README step for a series the importer no longer has. Co-Authored-By: Claude Opus 5.5 --- docs/engineering/gambling-duties.md | 10 +++++---- .../gov/economic_assumptions/README.md | 5 ++--- .../tests/test_import_obr_forecasts.py | 21 +++++++++++++++++++ policyengine_uk/utils/import_obr_forecasts.py | 18 ++++++++++++++++ 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/docs/engineering/gambling-duties.md b/docs/engineering/gambling-duties.md index 89ef226694..028dfff906 100644 --- a/docs/engineering/gambling-duties.md +++ b/docs/engineering/gambling-duties.md @@ -414,10 +414,12 @@ HMRC's 2024-25 standard-rate liabilities, £604.7 million at 20%, give a UK base of £3,023.7 million, £501.4 million (16.6%) more than the Commission's Category B and C yield. Differences in definition and timing between gross gambling yield and dutiable net takings may account for -part of it. Pubs' machines never pay the higher rate, so leaving them out -pushes the share up; leaving out clubs' Category B3A and B4 machines -pushes it down. If the whole difference stayed at 20%, -the share would be £2,289.8 million over £3,023.7 million, 75.73%, and +part of it. Pubs' Category C and D machines never pay the higher rate, so +leaving them out pushes the share up, though skill-with-prize machines there +fall outside the categories. Clubs' taxable Category B4 machines pay it, so +leaving them out pushes the share down. Legally operated Category B3A +lottery machines pay no duty and enter neither side. If the whole +difference stayed at 20%, the share would be £2,289.8 million over £3,023.7 million, 75.73%, and the 2027-28 base index would rise by 3.13% from its level at 90.78%. The Commission's provisional 2025-26 figures give 91.41%. The importer holds the 2024-25 share in every year, as it does the band weights. diff --git a/policyengine_uk/parameters/gov/economic_assumptions/README.md b/policyengine_uk/parameters/gov/economic_assumptions/README.md index ab0173d409..93f9e0dd1f 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/README.md +++ b/policyengine_uk/parameters/gov/economic_assumptions/README.md @@ -479,9 +479,8 @@ the forecast levels, though not their growth. spec. When the table starts a year later (2027-28 in October 2027), add HMRC's outturn for the year before from its latest Betting and Gaming tables; the importer stops and names the year until you do. Check the - EFO's policy tables for any new measure in the betting and gaming lines, - and extend the Machine Games Duty measure's receipts when the forecast - runs past 2031-32. Then update the published levels in + EFO's policy tables for any new measure in the betting and gaming lines. + Then update the published levels in `policyengine_uk/tests/test_gambling_duty_base_projection.py`. 7. If the new EFO runs past `PROJECTION_END_YEAR`, move it on (see [Dataset projection horizon](#dataset-projection-horizon)). diff --git a/policyengine_uk/tests/test_import_obr_forecasts.py b/policyengine_uk/tests/test_import_obr_forecasts.py index e778774825..cc46c2d430 100644 --- a/policyengine_uk/tests/test_import_obr_forecasts.py +++ b/policyengine_uk/tests/test_import_obr_forecasts.py @@ -1173,6 +1173,27 @@ def test_a_last_level_at_or_below_zero_is_refused(last): compute_receipts_growth(levels, make_previous_receipts_xlsx()) +def test_a_tail_that_rounds_the_index_to_zero_is_refused(): + # The forecast keeps the index above zero; the average_earnings tail + # takes it there, after the forecast's own check has run. + levels = make_receipts_xlsx( + sdlt_rows=[ + ("SDLT from residential property", (1.0, 1.0, 0.00005)), + ("SDLT from non-residential property", (4.0, 5.0, 4.5)), + ] + ) + falling = FLAT_SERIES_FIXTURE.format(key="average_earnings").replace( + "0.0200", "-0.5100" + ) + content = YOY_GROWTH_FIXTURE.replace( + FLAT_SERIES_FIXTURE.format(key="average_earnings"), falling + ) + assert content != YOY_GROWTH_FIXTURE + render(current=levels) + with pytest.raises(ValueError, match="stamp_duty_land_tax.residential: the index"): + render(content, current=levels) + + def test_receipts_labels_lose_footnote_markers_only(): line = find_receipts_line( read_workbook_sheets( diff --git a/policyengine_uk/utils/import_obr_forecasts.py b/policyengine_uk/utils/import_obr_forecasts.py index a22a95f0cb..452e28fcf4 100644 --- a/policyengine_uk/utils/import_obr_forecasts.py +++ b/policyengine_uk/utils/import_obr_forecasts.py @@ -590,6 +590,8 @@ def update_forecast_gap_yaml( TABLE_NUMBER_RE = re.compile(r"\d+\.\d+") FOOTNOTE_RE = re.compile(r"(?<=[a-z])\d+$", re.IGNORECASE) VALUE_LINE_RE = re.compile(r"^\s*(\d{4})-\d{2}-\d{2}:") +# The last year create_economic_assumption_indices compounds a series to. +LAST_INDEX_YEAR = 2039 @dataclass(frozen=True) @@ -1463,6 +1465,21 @@ def receipts_references( ] +def check_index_stays_positive(path: tuple[str, ...], values: dict) -> None: + """Compound a rendered series as create_economic_assumption_indices does, + from 1 in its first listed year through 2039, rounding to five decimals. + An index that rounds to zero would zero the base in every later year.""" + growth = {int(str(instant)[:4]): value for instant, value in values.items()} + index = 1.0 + for year in range(min(growth) + 1, LAST_INDEX_YEAR + 1): + index = round(index * (1 + growth[max(y for y in growth if y <= year)]), 5) + if index <= 0: + raise ValueError( + f"{'.'.join(path)}: the index reaches {index} in {year} at five " + "decimals, so the base is too small to grow from" + ) + + def render_receipts_growth_yaml( content: str, growth: dict[tuple[str, ...], ReceiptsGrowth], @@ -1534,6 +1551,7 @@ def render_receipts_growth_yaml( for year, value in {**series.values, **tail}.items() }, } + check_index_stays_positive(path, node["values"]) node["metadata"]["reference"] = references rendered = "\n".join(lines) From 2dc10f5778f249229b3617521207039f48d5be08 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 05:32:55 -0400 Subject: [PATCH 10/17] Read the value in force each January in the index check A value dated mid-year applies from the next January, as the model reads it, so it must not replace that year's January value in the check. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_import_obr_forecasts.py | 14 ++++++++++++++ policyengine_uk/utils/import_obr_forecasts.py | 7 ++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/test_import_obr_forecasts.py b/policyengine_uk/tests/test_import_obr_forecasts.py index cc46c2d430..8a9cb098f8 100644 --- a/policyengine_uk/tests/test_import_obr_forecasts.py +++ b/policyengine_uk/tests/test_import_obr_forecasts.py @@ -1194,6 +1194,20 @@ def test_a_tail_that_rounds_the_index_to_zero_is_refused(): render(content, current=levels) +def test_the_index_check_reads_the_value_in_force_each_january(): + # A value dated mid-year applies from the next January, so it must not + # replace that year's January value. + with pytest.raises(ValueError, match="in 2023"): + import_obr_forecasts.check_index_stays_positive( + ("example",), + { + date(2022, 1, 1): 0.0, + date(2023, 1, 1): -1.0, + date(2023, 7, 1): 0.0, + }, + ) + + def test_receipts_labels_lose_footnote_markers_only(): line = find_receipts_line( read_workbook_sheets( diff --git a/policyengine_uk/utils/import_obr_forecasts.py b/policyengine_uk/utils/import_obr_forecasts.py index 452e28fcf4..6d6d8958b8 100644 --- a/policyengine_uk/utils/import_obr_forecasts.py +++ b/policyengine_uk/utils/import_obr_forecasts.py @@ -1469,10 +1469,11 @@ def check_index_stays_positive(path: tuple[str, ...], values: dict) -> None: """Compound a rendered series as create_economic_assumption_indices does, from 1 in its first listed year through 2039, rounding to five decimals. An index that rounds to zero would zero the base in every later year.""" - growth = {int(str(instant)[:4]): value for instant, value in values.items()} + growth = {str(instant): value for instant, value in values.items()} index = 1.0 - for year in range(min(growth) + 1, LAST_INDEX_YEAR + 1): - index = round(index * (1 + growth[max(y for y in growth if y <= year)]), 5) + for year in range(int(min(growth)[:4]) + 1, LAST_INDEX_YEAR + 1): + in_force = max(instant for instant in growth if instant <= f"{year}-01-01") + index = round(index * (1 + growth[in_force]), 5) if index <= 0: raise ValueError( f"{'.'.join(path)}: the index reaches {index} in {year} at five " From 84b1dbf4938f3e00ce737b1d5fcd079b9b9c32d4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 06:07:27 -0400 Subject: [PATCH 11/17] Check the index to the end of the growth file The model's indices stop at or before that year, so the check holds however long they run. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_import_obr_forecasts.py | 13 +++++++++++++ policyengine_uk/utils/import_obr_forecasts.py | 8 +++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/test_import_obr_forecasts.py b/policyengine_uk/tests/test_import_obr_forecasts.py index 8a9cb098f8..c28893a714 100644 --- a/policyengine_uk/tests/test_import_obr_forecasts.py +++ b/policyengine_uk/tests/test_import_obr_forecasts.py @@ -1194,6 +1194,19 @@ def test_a_tail_that_rounds_the_index_to_zero_is_refused(): render(content, current=levels) +def test_the_index_check_reaches_the_end_of_the_growth_file(): + # A fall from 2035 takes the index to zero at five decimals in 2044. + with pytest.raises(ValueError, match="in 2044"): + import_obr_forecasts.check_index_stays_positive( + ("example",), + { + date(2024, 1, 1): 0.0, + date(2035, 1, 1): -0.75, + date(2074, 1, 1): 0.0, + }, + ) + + def test_the_index_check_reads_the_value_in_force_each_january(): # A value dated mid-year applies from the next January, so it must not # replace that year's January value. diff --git a/policyengine_uk/utils/import_obr_forecasts.py b/policyengine_uk/utils/import_obr_forecasts.py index 6d6d8958b8..c2f31d3bdd 100644 --- a/policyengine_uk/utils/import_obr_forecasts.py +++ b/policyengine_uk/utils/import_obr_forecasts.py @@ -590,8 +590,10 @@ def update_forecast_gap_yaml( TABLE_NUMBER_RE = re.compile(r"\d+\.\d+") FOOTNOTE_RE = re.compile(r"(?<=[a-z])\d+$", re.IGNORECASE) VALUE_LINE_RE = re.compile(r"^\s*(\d{4})-\d{2}-\d{2}:") -# The last year create_economic_assumption_indices compounds a series to. -LAST_INDEX_YEAR = 2039 +# The last year yoy_growth.yaml states. create_economic_assumption_indices +# stops at or before it, so a series that stays positive to here stays +# positive in the model. +LAST_INDEX_YEAR = 2074 @dataclass(frozen=True) @@ -1467,7 +1469,7 @@ def receipts_references( def check_index_stays_positive(path: tuple[str, ...], values: dict) -> None: """Compound a rendered series as create_economic_assumption_indices does, - from 1 in its first listed year through 2039, rounding to five decimals. + from 1 in its first listed year, rounding to five decimals. An index that rounds to zero would zero the base in every later year.""" growth = {str(instant): value for instant, value in values.items()} index = 1.0 From 654b85c6ace76fecdb9c449034f3cc6e4624b932 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 08:58:39 -0400 Subject: [PATCH 12/17] Build the expected duty without writing in place Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_gambling_duty_base_projection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_gambling_duty_base_projection.py b/policyengine_uk/tests/test_gambling_duty_base_projection.py index a5c8ac87c4..29cc040e58 100644 --- a/policyengine_uk/tests/test_gambling_duty_base_projection.py +++ b/policyengine_uk/tests/test_gambling_duty_base_projection.py @@ -454,7 +454,7 @@ def test_without_the_higher_stake_input_the_duty_falls_short_of_table_4_10( if year == 2026: expected = TABLE_4_10["machine_games"][0] * 1e9 if year >= 2027: - expected *= MACHINE_GAMES_BEFORE / MACHINE_GAMES_AFTER + expected = expected * MACHINE_GAMES_BEFORE / MACHINE_GAMES_AFTER assert model == pytest.approx(expected, rel=2e-4, abs=1), year From dbba5aef503b9c290330b89d51d2980d9bfed92a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 12:16:36 -0400 Subject: [PATCH 13/17] Grow the gambling bases per head of population Dataset projection grows household weights by ons.population, so a gambling series that grew each household's input by its duty's whole base growth counted population growth twice: the weighted bases ran 1.54% above Table 4.10 by 2031-32. The seven series move to obr.per_capita.gambling_duty_bases. The importer writes each base's growth over one plus ons.population growth, to six decimals, in the forecast years and in the tail, where the base grows with average_earnings (or holds flat while its rate is zero) in every year to the last that ons.population states. check_index, population_growth, growth_in_effect and per_head_growth carry #2596's names and signatures; ReceiptsSeriesSpec.per_head marks every series under per_capita. test_gambling_duty_base_projection.py now projects two weighted households through extend_single_year_dataset and checks the weighted duty against HMRC and Table 4.10; test_weighted_gambling_bases_follow_ the_aggregate checks every weighted input against its duty's national base to 2039. A Hypothesis property checks that the per-head series, compounded with population growth, gives back the aggregate index. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2738.fixed.md | 2 +- docs/engineering/gambling-duties.md | 37 +- policyengine_uk/data/uprating_indices.yaml | 21 +- .../gov/economic_assumptions/README.md | 36 +- .../gov/economic_assumptions/yoy_growth.yaml | 597 +++++++++++------- .../test_gambling_duty_base_projection.py | 233 +++++-- .../tests/test_import_obr_forecasts.py | 240 ++++++- policyengine_uk/utils/import_obr_forecasts.py | 207 ++++-- .../gambling/betting_exchange_commission.py | 4 +- .../gambling/bingo_net_receipts.py | 4 +- .../financial_spread_betting_net_losses.py | 4 +- .../horse_and_dog_pool_betting_net_losses.py | 4 +- .../gambling/land_based_casino_net_losses.py | 4 +- ...and_based_fixed_odds_betting_net_losses.py | 4 +- .../machine_games_higher_rate_net_takings.py | 2 +- .../machine_games_lower_rate_net_takings.py | 2 +- ..._standard_rate_higher_stake_net_takings.py | 2 +- ...machine_games_standard_rate_net_takings.py | 2 +- .../national_lottery_ticket_spending.py | 4 +- .../other_spread_betting_net_losses.py | 4 +- .../gambling/pool_betting_net_receipts.py | 2 +- .../gambling/remote_gaming_net_losses.py | 2 +- .../remote_other_betting_net_losses.py | 4 +- ...emote_uk_horseracing_betting_net_losses.py | 4 +- 24 files changed, 1048 insertions(+), 377 deletions(-) diff --git a/changelog.d/2738.fixed.md b/changelog.d/2738.fixed.md index a8d2a5d06e..5a1d6d5767 100644 --- a/changelog.d/2738.fixed.md +++ b/changelog.d/2738.fixed.md @@ -1 +1 @@ -Projected the sixteen gambling duty inputs from HMRC's 2025-26 receipts and the OBR's October 2026 betting and gaming forecast (receipts Table 4.10), divided by the rates the model charges, so they no longer stay at their 2024-25 cash level and the 2026 and 2027 remote gambling rate rises and the 2027 higher-stake machine rate rise apply once. +Projected the sixteen gambling duty inputs per person from HMRC's 2025-26 receipts and the OBR's October 2026 betting and gaming forecast (receipts Table 4.10), divided by the rates the model charges and by population growth. The inputs no longer stay at their 2024-25 cash level, their weighted totals follow each duty's base as household weights grow with population, and the 2026 and 2027 remote gambling rate rises and the 2027 higher-stake machine rate rise apply once. diff --git a/docs/engineering/gambling-duties.md b/docs/engineering/gambling-duties.md index 028dfff906..eff05b4cd4 100644 --- a/docs/engineering/gambling-duties.md +++ b/docs/engineering/gambling-duties.md @@ -283,13 +283,24 @@ and [Gambling Commission 2024/25 industry statistics](https://www.gamblingcommis ## Projection Each input declares its duty's index, -`gov.economic_assumptions.indices.obr.gambling_duty_bases.`, and -dataset projection grows it by the same duty's series in `yoy_growth.yaml` -(`policyengine_uk/data/uprating_indices.yaml`). A series grows the duty's -base, not its receipts: each year's receipts divided by the rate the model -charges that year. The model then applies its own rate changes once. Grown -with receipts instead, the base would carry Remote Gaming Duty's rise to 40% -and the formula would charge 40% on it again. +`gov.economic_assumptions.indices.obr.per_capita.gambling_duty_bases.`, +and dataset projection grows it by the same duty's series in +`yoy_growth.yaml` (`policyengine_uk/data/uprating_indices.yaml`). A series +grows the duty's base, not its receipts: each year's receipts divided by the +rate the model charges that year. The model then applies its own rate +changes once. Grown with receipts instead, the base would carry Remote +Gaming Duty's rise to 40% and the formula would charge 40% on it again. + +Each series holds the base's growth per person: one plus the base's growth, +over one plus `ons.population` growth, minus one, to six decimals. Dataset +projection grows every household weight by `ons.population`, so each +weighted input grows with its duty's national base. Past +`PROJECTION_END_YEAR`, Core scales each input by its index and each weight +by the population index, so the weighted input keeps that path. Grown by +the base's own growth, the weighted bases would count population growth +twice and exceed Table 4.10 by 1.54% in 2031-32. `uprate_council_tax` +divides each council tax bill's growth by the growth in household weights +for the same reason. | Fiscal year | Receipts | Source | | --- | --- | --- | @@ -298,6 +309,10 @@ and the formula would charge 40% on it again. | 2026-27 to 2031-32 | Forecast | OBR October 2026 Economic and Fiscal Outlook, receipts Table 4.10, "Betting and gaming duties forecast breakdown" | | 2032-33 on | None | The `average_earnings` long-run path, as for the other receipts series | +After 2031-32 each series divides the base's growth by one plus population +growth in every year to 2073, the last year `ons.population` states, and +the model holds both series' 2073 values after that. + `policyengine_uk/utils/import_obr_forecasts.py` writes the series from `RECEIPTS_SERIES_SPECS`. It reads each rate from the model's parameter file and averages it over the OBR's April-to-March year by days. Each duty's rate @@ -351,9 +366,11 @@ comes out as follows: not enter: any change in the duty's effective rate that the OBR's forecast includes stays in the base. - **Bingo Duty**: 10% until its abolition on 1 April 2026. A zero rate - leaves no base to divide out, so the index holds its 2025-26 level from - 2026-27. Current law charges nothing whatever the base; a reform that - charges Bingo Duty again charges that 2025-26 base in cash terms. + leaves no base to divide out, so the base holds its 2025-26 level from + 2026-27. Per person the series falls as population grows, and the + weighted base holds. Current law charges nothing whatever the base; a + reform that charges Bingo Duty again charges that 2025-26 base in cash + terms. - **Lottery Duty** (12%) and **Pool Betting Duty** (15%): one rate each, so the base grows with receipts. diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index d21df43f29..5fafbc82cb 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -170,10 +170,11 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.mixed_income: - self_employment_gross_receipts - trading_loss - owned_company_income_share -# Gambling duty bases grow with the OBR's receipts forecast for their duty, -# divided by the rates the model charges, so the model applies its own rate -# changes to them once (#2738). -gov.economic_assumptions.yoy_growth.obr.gambling_duty_bases.general_betting: +# Gambling duty bases grow per person with the OBR's receipts forecast for +# their duty, divided by the rates the model charges, so the model applies +# its own rate changes to them once, and household weights grow by +# population (#2738). +gov.economic_assumptions.yoy_growth.obr.per_capita.gambling_duty_bases.general_betting: - betting_exchange_commission - financial_spread_betting_net_losses - horse_and_dog_pool_betting_net_losses @@ -181,20 +182,20 @@ gov.economic_assumptions.yoy_growth.obr.gambling_duty_bases.general_betting: - other_spread_betting_net_losses - remote_other_betting_net_losses - remote_uk_horseracing_betting_net_losses -gov.economic_assumptions.yoy_growth.obr.gambling_duty_bases.pool_betting: +gov.economic_assumptions.yoy_growth.obr.per_capita.gambling_duty_bases.pool_betting: - pool_betting_net_receipts -gov.economic_assumptions.yoy_growth.obr.gambling_duty_bases.remote_gaming: +gov.economic_assumptions.yoy_growth.obr.per_capita.gambling_duty_bases.remote_gaming: - remote_gaming_net_losses -gov.economic_assumptions.yoy_growth.obr.gambling_duty_bases.machine_games: +gov.economic_assumptions.yoy_growth.obr.per_capita.gambling_duty_bases.machine_games: - machine_games_higher_rate_net_takings - machine_games_lower_rate_net_takings - machine_games_standard_rate_higher_stake_net_takings - machine_games_standard_rate_net_takings -gov.economic_assumptions.yoy_growth.obr.gambling_duty_bases.gaming: +gov.economic_assumptions.yoy_growth.obr.per_capita.gambling_duty_bases.gaming: - land_based_casino_net_losses -gov.economic_assumptions.yoy_growth.obr.gambling_duty_bases.bingo: +gov.economic_assumptions.yoy_growth.obr.per_capita.gambling_duty_bases.bingo: - bingo_net_receipts -gov.economic_assumptions.yoy_growth.obr.gambling_duty_bases.lottery: +gov.economic_assumptions.yoy_growth.obr.per_capita.gambling_duty_bases.lottery: - national_lottery_ticket_spending gov.economic_assumptions.yoy_growth.obr.private_pension_index: - private_pension_income diff --git a/policyengine_uk/parameters/gov/economic_assumptions/README.md b/policyengine_uk/parameters/gov/economic_assumptions/README.md index 93f9e0dd1f..1f20556af5 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/README.md +++ b/policyengine_uk/parameters/gov/economic_assumptions/README.md @@ -35,7 +35,7 @@ GDP and population assumptions remain supported. | `stamp_duty_land_tax.non_residential` | SDLT receipts from non-residential property | EFO receipts, property transactions taxes by sector: Table 4.7 in October 2026 | | `business_rates.great_britain` | Business rates receipts, England, Scotland and Wales (cash) | EFO receipts, current receipts (cash basis): Table 4.8 in October 2026 | | `business_rates.northern_ireland` | Northern Ireland business rates receipts | EFO receipts, other taxes: Table 4.2 in October 2026 | -| `gambling_duty_bases.` | The base of each of the seven betting and gaming duties (receipts over the model's rate) | HMRC Betting and Gaming tables (2024-25, 2025-26), then EFO receipts, betting and gaming duties forecast breakdown: Table 4.10 in October 2026 | +| `per_capita.gambling_duty_bases.` | The base of each of the seven betting and gaming duties (receipts over the model's rate), per person | HMRC Betting and Gaming tables (2024-25, 2025-26), then EFO receipts, betting and gaming duties forecast breakdown: Table 4.10 in October 2026, over `ons.population` | The four receipts series project the corporate SDLT and business rates statistics (`gov.hmrc.stamp_duty.statistics`, @@ -46,16 +46,21 @@ in an earlier EFO), the EFO's forecast years, and after them the `average_earnings` path. Years before the outturn year are kept, so each index keeps its base year. -The seven `gambling_duty_bases` series (`general_betting`, `pool_betting`, -`remote_gaming`, `machine_games`, `gaming`, `bingo`, `lottery`) grow the -gambling duty inputs (#2738). They grow a duty's base, not its receipts: -each year's receipts divided by the rate the model charges that fiscal -year. The model then applies its own rate changes, such as Remote Gaming -Duty's rise from 21% to 40% on 1 April 2026, once. The table publishes -forecasts only, so each series starts from HMRC's 2024-25 receipts, the -model's base year, and its 2025-26 outturn, which the importer holds in -`RECEIPTS_SERIES_SPECS`. A year whose rate is zero, Bingo Duty's from -2026-27, holds the base flat. See +The seven `per_capita.gambling_duty_bases` series (`general_betting`, +`pool_betting`, `remote_gaming`, `machine_games`, `gaming`, `bingo`, +`lottery`) grow the gambling duty inputs (#2738). They grow a duty's base, +not its receipts: each year's receipts divided by the rate the model charges +that fiscal year. The model then applies its own rate changes, such as +Remote Gaming Duty's rise from 21% to 40% on 1 April 2026, once. The table +publishes forecasts only, so each series starts from HMRC's 2024-25 +receipts, the model's base year, and its 2025-26 outturn, which the importer +holds in `RECEIPTS_SERIES_SPECS`. A year whose rate is zero, Bingo Duty's +from 2026-27, holds the base flat, and after the forecast the base grows +with `average_earnings`. The importer writes each series per person, (1 + +base growth) / (1 + `ons.population` growth) - 1 to six decimals, in every +year to the last that `ons.population` states. Dataset projection grows +household weights by `ons.population`, so the weighted inputs grow with the +base. See [gambling duties](../../../../docs/engineering/gambling-duties.md#projection) for each duty's rates, the higher-stake share Machine Games Duty's rate assumes, and the approximations. @@ -473,10 +478,11 @@ the forecast levels, though not their growth. `policyengine_uk/tests/test_corporate_receipts_statistics.py` (`OBR_RECEIPTS_BN`), which pin the series to the OBR's tables. Pass `--skip-receipts-growth` only to leave them on the previous EFO. - The same run refreshes the `gambling_duty_bases` series from the new - EFO's betting and gaming duties forecast breakdown. They read no previous - EFO: the year before the table's first comes from HMRC's receipts in each - spec. When the table starts a year later (2027-28 in October 2027), add + The same run refreshes the `per_capita.gambling_duty_bases` series from + the new EFO's betting and gaming duties forecast breakdown and the + `ons.population` growth in `yoy_growth.yaml`, so rerun it after any change + to that population series. They read no previous EFO: the year before the + table's first comes from HMRC's receipts in each spec. When the table starts a year later (2027-28 in October 2027), add HMRC's outturn for the year before from its latest Betting and Gaming tables; the importer stops and names the year until you do. Check the EFO's policy tables for any new measure in the betting and gaming lines. diff --git a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml index acef315895..1c710f658c 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml +++ b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml @@ -988,218 +988,393 @@ obr: - title: OBR EFO March 2026 (detailed forecast tables, receipts, Table 3.2; 2024-25 outturn) href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ - # Gambling duty bases (#2738). Each series grows a duty's base, not its - # receipts, so the model applies its own rate changes once; see - # docs/engineering/gambling-duties.md. The importer writes every year - # after the 2024 base year. - gambling_duty_bases: - remote_gaming: - description: Growth in the Remote Gaming Duty base, measured as the OBR's forecast of the duty's receipts divided by the rate the model charges. - values: - # Base year (2024-25): the gambling duty inputs start in model year 2024 - 2024-01-01: 0.0000 - # Outturn (2025-26 over 2024-25) from HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges - 2025-01-01: 0.136277 - # Forecast (2026-27 over 2025-26) from OBR EFO October 2026, receipts Table 4.10 "Remote gaming duty", over HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges - 2026-01-01: 0.093757 - # Forecast (2027-2031) from OBR EFO October 2026, receipts Table 4.10; base growth: receipts over the rate the model charges - 2027-01-01: 0.010627 - 2028-01-01: 0.021721 - 2029-01-01: 0.022411 - 2030-01-01: 0.01353 - 2031-01-01: 0.00629 - # Beyond the OBR forecast: the average_earnings long-run path above - 2032-01-01: 0.0285 - 2033-01-01: 0.0309 - 2034-01-01: 0.0334 - 2035-01-01: 0.0358 - 2036-01-01: 0.0383 - metadata: - unit: /1 - label: Remote Gaming Duty base growth - reference: - - title: OBR EFO October 2026 (detailed forecast tables, receipts, Table 4.10) - href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ - - title: HMRC UK Betting and Gaming Statistics, July 2026 tables (Table_1_BG_receipts, 2024-25 and 2025-26 receipts) - href: https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods - lottery: - description: Growth in the Lottery Duty base, measured as the OBR's forecast of the duty's receipts divided by the rate the model charges. - values: - # Base year (2024-25): the gambling duty inputs start in model year 2024 - 2024-01-01: 0.0000 - # Outturn (2025-26 over 2024-25) from HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges - 2025-01-01: 0.051771 - # Forecast (2026-27 over 2025-26) from OBR EFO October 2026, receipts Table 4.10 "Lottery duty", over HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges - 2026-01-01: -0.152644 - # Forecast (2027-2031) from OBR EFO October 2026, receipts Table 4.10; base growth: receipts over the rate the model charges - 2027-01-01: 0.02304 - 2028-01-01: 0.030706 - 2029-01-01: 0.031702 - 2030-01-01: 0.028862 - 2031-01-01: 0.02933 - # Beyond the OBR forecast: the average_earnings long-run path above - 2032-01-01: 0.0285 - 2033-01-01: 0.0309 - 2034-01-01: 0.0334 - 2035-01-01: 0.0358 - 2036-01-01: 0.0383 - metadata: - unit: /1 - label: Lottery Duty base growth - reference: - - title: OBR EFO October 2026 (detailed forecast tables, receipts, Table 4.10) - href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ - - title: HMRC UK Betting and Gaming Statistics, July 2026 tables (Table_1_BG_receipts, 2024-25 and 2025-26 receipts) - href: https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods - general_betting: - description: Growth in the General Betting Duty base, measured as the OBR's forecast of the duty's receipts divided by the rates the model charges, each weighted by its part's 2024-25 share of the base. - values: - # Base year (2024-25): the gambling duty inputs start in model year 2024 - 2024-01-01: 0.0000 - # Outturn (2025-26 over 2024-25) from HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges - 2025-01-01: -0.041123 - # Forecast (2026-27 over 2025-26) from OBR EFO October 2026, receipts Table 4.10 "General betting duty", over HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges - 2026-01-01: -0.042216 - # Forecast (2027-2031) from OBR EFO October 2026, receipts Table 4.10; base growth: receipts over the rate the model charges - 2027-01-01: 0.027038 - 2028-01-01: 0.045019 - 2029-01-01: 0.034553 - 2030-01-01: 0.029345 - 2031-01-01: 0.028374 - # Beyond the OBR forecast: the average_earnings long-run path above - 2032-01-01: 0.0285 - 2033-01-01: 0.0309 - 2034-01-01: 0.0334 - 2035-01-01: 0.0358 - 2036-01-01: 0.0383 - metadata: - unit: /1 - label: General Betting Duty base growth - reference: - - title: OBR EFO October 2026 (detailed forecast tables, receipts, Table 4.10) - href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ - - title: HMRC UK Betting and Gaming Statistics, July 2026 tables (Table_1_BG_receipts, 2024-25 and 2025-26 receipts) - href: https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods - - title: Microcosm, FY2024-25 General Betting Duty bases split by the Gambling Commission's gross gambling yield (docs/evidence/uk-excise-quantities-1359/outputs/national_reconciliation.json) - href: https://github.com/PolicyEngine/microcosm/blob/main/docs/evidence/uk-excise-quantities-1359/outputs/national_reconciliation.json - - title: Gambling Commission, industry statistics, April 2024 to March 2025 - href: https://www.gamblingcommission.gov.uk/statistics-and-research/publication/industry-statistics-annual-report-financial-year-april-2024-to-march-2025 - machine_games: - description: Growth in the Machine Games Duty base, measured as the OBR's forecast of the duty's receipts divided by the rates the model charges, each weighted by its band's 2024-25 share of the base, with the higher-stake share of the standard-rate band at the higher rate from 1 April 2027. - values: - # Base year (2024-25): the gambling duty inputs start in model year 2024 - 2024-01-01: 0.0000 - # Outturn (2025-26 over 2024-25) from HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges - 2025-01-01: 0.052138 - # Forecast (2026-27 over 2025-26) from OBR EFO October 2026, receipts Table 4.10 "Machine games duty", over HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges - 2026-01-01: 0.056256 - # Forecast (2027-2031) from OBR EFO October 2026, receipts Table 4.10; base growth: receipts over the rate the model charges - 2027-01-01: -0.002319 - 2028-01-01: 0.03631 - 2029-01-01: 0.00967 - 2030-01-01: 0.011861 - 2031-01-01: 0.023765 - # Beyond the OBR forecast: the average_earnings long-run path above - 2032-01-01: 0.0285 - 2033-01-01: 0.0309 - 2034-01-01: 0.0334 - 2035-01-01: 0.0358 - 2036-01-01: 0.0383 - metadata: - unit: /1 - label: Machine Games Duty base growth - reference: - - title: OBR EFO October 2026 (detailed forecast tables, receipts, Table 4.10) - href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ - - title: HMRC UK Betting and Gaming Statistics, July 2026 tables (Table_1_BG_receipts, 2024-25 and 2025-26 receipts) - href: https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods - - title: Gambling Commission, Industry Statistics, April 2025 to March 2026 (sheet 4, 2024-25 gaming machine yield by category) - href: https://www.gamblingcommission.gov.uk/statistics-and-research/publication/industry-statistics-annual-report-financial-year-april-2025-to-march-2026-official-statistics - gaming: - description: Growth in the Gaming Duty base, measured as the OBR's forecast of the duty's receipts divided by the casino effective rate the model charges. - values: - # Base year (2024-25): the gambling duty inputs start in model year 2024 - 2024-01-01: 0.0000 - # Outturn (2025-26 over 2024-25) from HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges - 2025-01-01: 0.051422 - # Forecast (2026-27 over 2025-26) from OBR EFO October 2026, receipts Table 4.10 "Gaming duty", over HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges - 2026-01-01: 0.001885 - # Forecast (2027-2031) from OBR EFO October 2026, receipts Table 4.10; base growth: receipts over the rate the model charges - 2027-01-01: 0.06113 - 2028-01-01: 0.001913 - 2029-01-01: 0.00133 - 2030-01-01: 0.005548 - 2031-01-01: -0.004442 - # Beyond the OBR forecast: the average_earnings long-run path above - 2032-01-01: 0.0285 - 2033-01-01: 0.0309 - 2034-01-01: 0.0334 - 2035-01-01: 0.0358 - 2036-01-01: 0.0383 - metadata: - unit: /1 - label: Gaming Duty base growth - reference: - - title: OBR EFO October 2026 (detailed forecast tables, receipts, Table 4.10) - href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ - - title: HMRC UK Betting and Gaming Statistics, July 2026 tables (Table_1_BG_receipts, 2024-25 and 2025-26 receipts) - href: https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods - bingo: - description: Growth in the Bingo Duty base, measured as the OBR's forecast of the duty's receipts divided by the rate the model charges, held flat once the rate is zero. - values: - # Base year (2024-25): the gambling duty inputs start in model year 2024 - 2024-01-01: 0.0000 - # Outturn (2025-26 over 2024-25) from HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges - 2025-01-01: 0.03329 - # Forecast (2026-27 over 2025-26) from OBR EFO October 2026, receipts Table 4.10 "Bingo duty", over HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges - 2026-01-01: 0.0000 - # Forecast (2027-2031) from OBR EFO October 2026, receipts Table 4.10; base growth: receipts over the rate the model charges - 2027-01-01: 0.0000 - 2028-01-01: 0.0000 - 2029-01-01: 0.0000 - 2030-01-01: 0.0000 - 2031-01-01: 0.0000 - # Beyond the OBR forecast: held flat, as the rate is zero - 2032-01-01: 0.0000 - metadata: - unit: /1 - label: Bingo Duty base growth - reference: - - title: OBR EFO October 2026 (detailed forecast tables, receipts, Table 4.10) - href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ - - title: HMRC UK Betting and Gaming Statistics, July 2026 tables (Table_1_BG_receipts, 2024-25 and 2025-26 receipts) - href: https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods - pool_betting: - description: Growth in the Pool Betting Duty base, measured as the OBR's forecast of the duty's receipts divided by the rate the model charges. - values: - # Base year (2024-25): the gambling duty inputs start in model year 2024 - 2024-01-01: 0.0000 - # Outturn (2025-26 over 2024-25) from HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges - 2025-01-01: -0.511385 - # Forecast (2026-27 over 2025-26) from OBR EFO October 2026, receipts Table 4.10 "Pool betting duty", over HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts over the rate the model charges - 2026-01-01: -0.287435 - # Forecast (2027-2031) from OBR EFO October 2026, receipts Table 4.10; base growth: receipts over the rate the model charges - 2027-01-01: -0.052527 - 2028-01-01: 0.066462 - 2029-01-01: -0.007445 - 2030-01-01: 0.055959 - 2031-01-01: 0.043367 - # Beyond the OBR forecast: the average_earnings long-run path above - 2032-01-01: 0.0285 - 2033-01-01: 0.0309 - 2034-01-01: 0.0334 - 2035-01-01: 0.0358 - 2036-01-01: 0.0383 - metadata: - unit: /1 - label: Pool Betting Duty base growth - reference: - - title: OBR EFO October 2026 (detailed forecast tables, receipts, Table 4.10) - href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ - - title: HMRC UK Betting and Gaming Statistics, July 2026 tables (Table_1_BG_receipts, 2024-25 and 2025-26 receipts) - href: https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods - per_capita: + # Gambling duty bases per person (#2738), which project the household + # gambling inputs (data/uprating_indices.yaml). Each series grows a + # duty's base, not its receipts: each year's receipts over the rate the + # model charges, so the model applies its own rate changes once (see + # docs/engineering/gambling-duties.md). The importer writes the base's + # growth over one plus ons.population growth, to six decimals: (1 + base + # growth) / (1 + population growth) - 1. Dataset projection grows + # household weights by ons.population, so the weighted base grows with + # the duty's base. After the OBR's forecast the base grows with + # average_earnings, or holds flat while its rate is zero, and the series + # divides that growth the same way in each year to ons.population's last. + # policyengine_uk/utils/import_obr_forecasts.py writes every year after + # the 2024 base year from HMRC's outturn, the EFO's receipts tables and + # the ons.population growth below. + gambling_duty_bases: + remote_gaming: + description: Growth per person in the Remote Gaming Duty base, the duty's receipts over the rate the model charges. + values: + # Base year (2024-25): the gambling duty inputs start in model year 2024 + 2024-01-01: 0.0000 + # Outturn (2025-26 over 2024-25) from HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); per head: base growth (receipts over the rate the model charges) over one plus ons.population growth + 2025-01-01: 0.132539 + # Forecast (2026-27 over 2025-26) from OBR EFO October 2026, receipts Table 4.10 "Remote gaming duty", over HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); per head: base growth (receipts over the rate the model charges) over one plus ons.population growth + 2026-01-01: 0.091574 + # Forecast (2027-2031) from OBR EFO October 2026, receipts Table 4.10; per head: base growth (receipts over the rate the model charges) over one plus ons.population growth + 2027-01-01: 0.009315 + 2028-01-01: 0.019987 + 2029-01-01: 0.020268 + 2030-01-01: 0.011003 + 2031-01-01: 0.003881 + # Beyond the OBR forecast: the average_earnings long-run path above; per head: (1 + that growth) / (1 + ons.population growth) - 1 + 2032-01-01: 0.026242 + 2033-01-01: 0.02874 + 2034-01-01: 0.03144 + 2035-01-01: 0.033939 + 2036-01-01: 0.036538 + 2037-01-01: 0.036641 + 2038-01-01: 0.036745 + 2040-01-01: 0.036848 + 2042-01-01: 0.036952 + 2044-01-01: 0.037056 + 2045-01-01: 0.037159 + 2047-01-01: 0.037263 + 2048-01-01: 0.037366 + 2049-01-01: 0.03747 + 2050-01-01: 0.037677 + 2051-01-01: 0.037781 + 2052-01-01: 0.037989 + 2053-01-01: 0.038092 + 2054-01-01: 0.0383 + 2055-01-01: 0.038404 + 2056-01-01: 0.038612 + 2057-01-01: 0.038715 + 2058-01-01: 0.038819 + 2059-01-01: 0.038923 + 2060-01-01: 0.039027 + 2061-01-01: 0.039131 + 2064-01-01: 0.039235 + 2071-01-01: 0.039339 + metadata: + unit: /1 + label: Remote Gaming Duty base growth per person + reference: + - title: OBR EFO October 2026 (detailed forecast tables, receipts, Table 4.10) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + - title: HMRC UK Betting and Gaming Statistics, July 2026 tables (Table_1_BG_receipts, 2024-25 and 2025-26 receipts) + href: https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods + lottery: + description: Growth per person in the Lottery Duty base, the duty's receipts over the rate the model charges. + values: + # Base year (2024-25): the gambling duty inputs start in model year 2024 + 2024-01-01: 0.0000 + # Outturn (2025-26 over 2024-25) from HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); per head: base growth (receipts over the rate the model charges) over one plus ons.population growth + 2025-01-01: 0.048312 + # Forecast (2026-27 over 2025-26) from OBR EFO October 2026, receipts Table 4.10 "Lottery duty", over HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); per head: base growth (receipts over the rate the model charges) over one plus ons.population growth + 2026-01-01: -0.154336 + # Forecast (2027-2031) from OBR EFO October 2026, receipts Table 4.10; per head: base growth (receipts over the rate the model charges) over one plus ons.population growth + 2027-01-01: 0.021712 + 2028-01-01: 0.028957 + 2029-01-01: 0.02954 + 2030-01-01: 0.026297 + 2031-01-01: 0.026866 + # Beyond the OBR forecast: the average_earnings long-run path above; per head: (1 + that growth) / (1 + ons.population growth) - 1 + 2032-01-01: 0.026242 + 2033-01-01: 0.02874 + 2034-01-01: 0.03144 + 2035-01-01: 0.033939 + 2036-01-01: 0.036538 + 2037-01-01: 0.036641 + 2038-01-01: 0.036745 + 2040-01-01: 0.036848 + 2042-01-01: 0.036952 + 2044-01-01: 0.037056 + 2045-01-01: 0.037159 + 2047-01-01: 0.037263 + 2048-01-01: 0.037366 + 2049-01-01: 0.03747 + 2050-01-01: 0.037677 + 2051-01-01: 0.037781 + 2052-01-01: 0.037989 + 2053-01-01: 0.038092 + 2054-01-01: 0.0383 + 2055-01-01: 0.038404 + 2056-01-01: 0.038612 + 2057-01-01: 0.038715 + 2058-01-01: 0.038819 + 2059-01-01: 0.038923 + 2060-01-01: 0.039027 + 2061-01-01: 0.039131 + 2064-01-01: 0.039235 + 2071-01-01: 0.039339 + metadata: + unit: /1 + label: Lottery Duty base growth per person + reference: + - title: OBR EFO October 2026 (detailed forecast tables, receipts, Table 4.10) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + - title: HMRC UK Betting and Gaming Statistics, July 2026 tables (Table_1_BG_receipts, 2024-25 and 2025-26 receipts) + href: https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods + general_betting: + description: Growth per person in the General Betting Duty base, the duty's receipts over the rates the model charges, each weighted by its part's 2024-25 share of the base. + values: + # Base year (2024-25): the gambling duty inputs start in model year 2024 + 2024-01-01: 0.0000 + # Outturn (2025-26 over 2024-25) from HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); per head: base growth (receipts over the rate the model charges) over one plus ons.population growth + 2025-01-01: -0.044277 + # Forecast (2026-27 over 2025-26) from OBR EFO October 2026, receipts Table 4.10 "General betting duty", over HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); per head: base growth (receipts over the rate the model charges) over one plus ons.population growth + 2026-01-01: -0.044128 + # Forecast (2027-2031) from OBR EFO October 2026, receipts Table 4.10; per head: base growth (receipts over the rate the model charges) over one plus ons.population growth + 2027-01-01: 0.025705 + 2028-01-01: 0.043246 + 2029-01-01: 0.032385 + 2030-01-01: 0.026778 + 2031-01-01: 0.025912 + # Beyond the OBR forecast: the average_earnings long-run path above; per head: (1 + that growth) / (1 + ons.population growth) - 1 + 2032-01-01: 0.026242 + 2033-01-01: 0.02874 + 2034-01-01: 0.03144 + 2035-01-01: 0.033939 + 2036-01-01: 0.036538 + 2037-01-01: 0.036641 + 2038-01-01: 0.036745 + 2040-01-01: 0.036848 + 2042-01-01: 0.036952 + 2044-01-01: 0.037056 + 2045-01-01: 0.037159 + 2047-01-01: 0.037263 + 2048-01-01: 0.037366 + 2049-01-01: 0.03747 + 2050-01-01: 0.037677 + 2051-01-01: 0.037781 + 2052-01-01: 0.037989 + 2053-01-01: 0.038092 + 2054-01-01: 0.0383 + 2055-01-01: 0.038404 + 2056-01-01: 0.038612 + 2057-01-01: 0.038715 + 2058-01-01: 0.038819 + 2059-01-01: 0.038923 + 2060-01-01: 0.039027 + 2061-01-01: 0.039131 + 2064-01-01: 0.039235 + 2071-01-01: 0.039339 + metadata: + unit: /1 + label: General Betting Duty base growth per person + reference: + - title: OBR EFO October 2026 (detailed forecast tables, receipts, Table 4.10) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + - title: HMRC UK Betting and Gaming Statistics, July 2026 tables (Table_1_BG_receipts, 2024-25 and 2025-26 receipts) + href: https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods + - title: Microcosm, FY2024-25 General Betting Duty bases split by the Gambling Commission's gross gambling yield (docs/evidence/uk-excise-quantities-1359/outputs/national_reconciliation.json) + href: https://github.com/PolicyEngine/microcosm/blob/main/docs/evidence/uk-excise-quantities-1359/outputs/national_reconciliation.json + - title: Gambling Commission, industry statistics, April 2024 to March 2025 + href: https://www.gamblingcommission.gov.uk/statistics-and-research/publication/industry-statistics-annual-report-financial-year-april-2024-to-march-2025 + machine_games: + description: Growth per person in the Machine Games Duty base, the duty's receipts over the rates the model charges, each weighted by its band's 2024-25 share of the base, with the higher-stake share of the standard-rate band at the higher rate from 1 April 2027. + values: + # Base year (2024-25): the gambling duty inputs start in model year 2024 + 2024-01-01: 0.0000 + # Outturn (2025-26 over 2024-25) from HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); per head: base growth (receipts over the rate the model charges) over one plus ons.population growth + 2025-01-01: 0.048678 + # Forecast (2026-27 over 2025-26) from OBR EFO October 2026, receipts Table 4.10 "Machine games duty", over HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); per head: base growth (receipts over the rate the model charges) over one plus ons.population growth + 2026-01-01: 0.054148 + # Forecast (2027-2031) from OBR EFO October 2026, receipts Table 4.10; per head: base growth (receipts over the rate the model charges) over one plus ons.population growth + 2027-01-01: -0.003614 + 2028-01-01: 0.034551 + 2029-01-01: 0.007554 + 2030-01-01: 0.009337 + 2031-01-01: 0.021314 + # Beyond the OBR forecast: the average_earnings long-run path above; per head: (1 + that growth) / (1 + ons.population growth) - 1 + 2032-01-01: 0.026242 + 2033-01-01: 0.02874 + 2034-01-01: 0.03144 + 2035-01-01: 0.033939 + 2036-01-01: 0.036538 + 2037-01-01: 0.036641 + 2038-01-01: 0.036745 + 2040-01-01: 0.036848 + 2042-01-01: 0.036952 + 2044-01-01: 0.037056 + 2045-01-01: 0.037159 + 2047-01-01: 0.037263 + 2048-01-01: 0.037366 + 2049-01-01: 0.03747 + 2050-01-01: 0.037677 + 2051-01-01: 0.037781 + 2052-01-01: 0.037989 + 2053-01-01: 0.038092 + 2054-01-01: 0.0383 + 2055-01-01: 0.038404 + 2056-01-01: 0.038612 + 2057-01-01: 0.038715 + 2058-01-01: 0.038819 + 2059-01-01: 0.038923 + 2060-01-01: 0.039027 + 2061-01-01: 0.039131 + 2064-01-01: 0.039235 + 2071-01-01: 0.039339 + metadata: + unit: /1 + label: Machine Games Duty base growth per person + reference: + - title: OBR EFO October 2026 (detailed forecast tables, receipts, Table 4.10) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + - title: HMRC UK Betting and Gaming Statistics, July 2026 tables (Table_1_BG_receipts, 2024-25 and 2025-26 receipts) + href: https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods + - title: Gambling Commission, Industry Statistics, April 2025 to March 2026 (sheet 4, 2024-25 gaming machine yield by category) + href: https://www.gamblingcommission.gov.uk/statistics-and-research/publication/industry-statistics-annual-report-financial-year-april-2025-to-march-2026-official-statistics + gaming: + description: Growth per person in the Gaming Duty base, the duty's receipts over the casino effective rate the model charges. + values: + # Base year (2024-25): the gambling duty inputs start in model year 2024 + 2024-01-01: 0.0000 + # Outturn (2025-26 over 2024-25) from HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); per head: base growth (receipts over the rate the model charges) over one plus ons.population growth + 2025-01-01: 0.047963 + # Forecast (2026-27 over 2025-26) from OBR EFO October 2026, receipts Table 4.10 "Gaming duty", over HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); per head: base growth (receipts over the rate the model charges) over one plus ons.population growth + 2026-01-01: -0.000114 + # Forecast (2027-2031) from OBR EFO October 2026, receipts Table 4.10; per head: base growth (receipts over the rate the model charges) over one plus ons.population growth + 2027-01-01: 0.059753 + 2028-01-01: 0.000213 + 2029-01-01: -0.000768 + 2030-01-01: 0.00304 + 2031-01-01: -0.006826 + # Beyond the OBR forecast: the average_earnings long-run path above; per head: (1 + that growth) / (1 + ons.population growth) - 1 + 2032-01-01: 0.026242 + 2033-01-01: 0.02874 + 2034-01-01: 0.03144 + 2035-01-01: 0.033939 + 2036-01-01: 0.036538 + 2037-01-01: 0.036641 + 2038-01-01: 0.036745 + 2040-01-01: 0.036848 + 2042-01-01: 0.036952 + 2044-01-01: 0.037056 + 2045-01-01: 0.037159 + 2047-01-01: 0.037263 + 2048-01-01: 0.037366 + 2049-01-01: 0.03747 + 2050-01-01: 0.037677 + 2051-01-01: 0.037781 + 2052-01-01: 0.037989 + 2053-01-01: 0.038092 + 2054-01-01: 0.0383 + 2055-01-01: 0.038404 + 2056-01-01: 0.038612 + 2057-01-01: 0.038715 + 2058-01-01: 0.038819 + 2059-01-01: 0.038923 + 2060-01-01: 0.039027 + 2061-01-01: 0.039131 + 2064-01-01: 0.039235 + 2071-01-01: 0.039339 + metadata: + unit: /1 + label: Gaming Duty base growth per person + reference: + - title: OBR EFO October 2026 (detailed forecast tables, receipts, Table 4.10) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + - title: HMRC UK Betting and Gaming Statistics, July 2026 tables (Table_1_BG_receipts, 2024-25 and 2025-26 receipts) + href: https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods + bingo: + description: Growth per person in the Bingo Duty base, the duty's receipts over the rate the model charges; the base holds flat once the rate is zero. + values: + # Base year (2024-25): the gambling duty inputs start in model year 2024 + 2024-01-01: 0.0000 + # Outturn (2025-26 over 2024-25) from HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); per head: base growth (receipts over the rate the model charges) over one plus ons.population growth + 2025-01-01: 0.029891 + # Forecast (2026-27 over 2025-26) from OBR EFO October 2026, receipts Table 4.10 "Bingo duty", over HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); per head: base growth (receipts over the rate the model charges) over one plus ons.population growth + 2026-01-01: -0.001996 + # Forecast (2027-2031) from OBR EFO October 2026, receipts Table 4.10; per head: base growth (receipts over the rate the model charges) over one plus ons.population growth + 2027-01-01: -0.001298 + 2028-01-01: -0.001697 + 2029-01-01: -0.002096 + 2030-01-01: -0.002494 + 2031-01-01: -0.002394 + # Beyond the OBR forecast: held flat, as the rate is zero; per head: (1 + that growth) / (1 + ons.population growth) - 1 + 2032-01-01: -0.002195 + 2033-01-01: -0.002096 + 2034-01-01: -0.001896 + 2035-01-01: -0.001797 + 2036-01-01: -0.001697 + 2037-01-01: -0.001597 + 2038-01-01: -0.001498 + 2040-01-01: -0.001398 + 2042-01-01: -0.001298 + 2044-01-01: -0.001199 + 2045-01-01: -0.001099 + 2047-01-01: -0.000999 + 2048-01-01: -0.000899 + 2049-01-01: -0.000799 + 2050-01-01: -0.0006 + 2051-01-01: -0.0005 + 2052-01-01: -0.0003 + 2053-01-01: -0.0002 + 2054-01-01: 0.0000 + 2055-01-01: 0.0001 + 2056-01-01: 0.0003 + 2057-01-01: 0.0004 + 2058-01-01: 0.0005 + 2059-01-01: 0.0006 + 2060-01-01: 0.0007 + 2061-01-01: 0.000801 + 2064-01-01: 0.000901 + 2071-01-01: 0.001001 + metadata: + unit: /1 + label: Bingo Duty base growth per person + reference: + - title: OBR EFO October 2026 (detailed forecast tables, receipts, Table 4.10) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + - title: HMRC UK Betting and Gaming Statistics, July 2026 tables (Table_1_BG_receipts, 2024-25 and 2025-26 receipts) + href: https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods + pool_betting: + description: Growth per person in the Pool Betting Duty base, the duty's receipts over the rate the model charges. + values: + # Base year (2024-25): the gambling duty inputs start in model year 2024 + 2024-01-01: 0.0000 + # Outturn (2025-26 over 2024-25) from HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); per head: base growth (receipts over the rate the model charges) over one plus ons.population growth + 2025-01-01: -0.512992 + # Forecast (2026-27 over 2025-26) from OBR EFO October 2026, receipts Table 4.10 "Pool betting duty", over HMRC's receipts (July 2026 Betting and Gaming tables, Table_1_BG_receipts); per head: base growth (receipts over the rate the model charges) over one plus ons.population growth + 2026-01-01: -0.288858 + # Forecast (2027-2031) from OBR EFO October 2026, receipts Table 4.10; per head: base growth (receipts over the rate the model charges) over one plus ons.population growth + 2027-01-01: -0.053757 + 2028-01-01: 0.064652 + 2029-01-01: -0.009525 + 2030-01-01: 0.053325 + 2031-01-01: 0.040869 + # Beyond the OBR forecast: the average_earnings long-run path above; per head: (1 + that growth) / (1 + ons.population growth) - 1 + 2032-01-01: 0.026242 + 2033-01-01: 0.02874 + 2034-01-01: 0.03144 + 2035-01-01: 0.033939 + 2036-01-01: 0.036538 + 2037-01-01: 0.036641 + 2038-01-01: 0.036745 + 2040-01-01: 0.036848 + 2042-01-01: 0.036952 + 2044-01-01: 0.037056 + 2045-01-01: 0.037159 + 2047-01-01: 0.037263 + 2048-01-01: 0.037366 + 2049-01-01: 0.03747 + 2050-01-01: 0.037677 + 2051-01-01: 0.037781 + 2052-01-01: 0.037989 + 2053-01-01: 0.038092 + 2054-01-01: 0.0383 + 2055-01-01: 0.038404 + 2056-01-01: 0.038612 + 2057-01-01: 0.038715 + 2058-01-01: 0.038819 + 2059-01-01: 0.038923 + 2060-01-01: 0.039027 + 2061-01-01: 0.039131 + 2064-01-01: 0.039235 + 2071-01-01: 0.039339 + metadata: + unit: /1 + label: Pool Betting Duty base growth per person + reference: + - title: OBR EFO October 2026 (detailed forecast tables, receipts, Table 4.10) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + - title: HMRC UK Betting and Gaming Statistics, July 2026 tables (Table_1_BG_receipts, 2024-25 and 2025-26 receipts) + href: https://assets.publishing.service.gov.uk/media/6ab54fd9fe72ed1e2b02f06b/2026_Jul_Bet_and_Gam_Tab.ods + gdp: description: Per capita nominal GDP year-on-year growth. values: diff --git a/policyengine_uk/tests/test_gambling_duty_base_projection.py b/policyengine_uk/tests/test_gambling_duty_base_projection.py index 29cc040e58..c65d3150b5 100644 --- a/policyengine_uk/tests/test_gambling_duty_base_projection.py +++ b/policyengine_uk/tests/test_gambling_duty_base_projection.py @@ -1,14 +1,17 @@ """The gambling duty inputs grow with their duty's base, not its receipts (#2738). Each input declares its duty's index under -``gov.economic_assumptions.indices.obr.gambling_duty_bases``, and dataset -projection grows it by the same ``yoy_growth`` series. A series is HMRC's -2024-25 and 2025-26 receipts, then the OBR's October 2026 forecast (receipts -Table 4.10), each year over the rate the model charges. For Machine Games -Duty that rate charges the higher-stake share of the standard-rate base at -the higher rate from 1 April 2027. Bases that give HMRC's 2024-25 receipts, -with the higher-stake input at that share, therefore give the OBR's receipts -in later years, and a rate reform moves receipts through the rate alone. +``gov.economic_assumptions.indices.obr.per_capita.gambling_duty_bases``, and +dataset projection grows it by the same ``yoy_growth`` series. A series is the +growth in HMRC's 2024-25 and 2025-26 receipts, then the OBR's October 2026 +forecast (receipts Table 4.10), each year over the rate the model charges, +and then over one plus ``ons.population`` growth. Household weights grow by +``ons.population``, so the weighted inputs grow with the duty's base. For +Machine Games Duty that rate charges the higher-stake share of the +standard-rate base at the higher rate from 1 April 2027. Weighted bases that +give HMRC's 2024-25 receipts, with the higher-stake input at that share, +therefore give the OBR's receipts in later years, and a rate reform moves +receipts through the rate alone. """ from datetime import date @@ -34,8 +37,11 @@ pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") -GROWTH = "gov.economic_assumptions.yoy_growth.obr.gambling_duty_bases" -INDEX = "gov.economic_assumptions.indices.obr.gambling_duty_bases" +GROWTH = "gov.economic_assumptions.yoy_growth.obr.per_capita.gambling_duty_bases" +INDEX = "gov.economic_assumptions.indices.obr.per_capita.gambling_duty_bases" +POPULATION = "gov.economic_assumptions.yoy_growth.ons.population" +POPULATION_INDEX = "gov.economic_assumptions.indices.ons.population" +AVERAGE_EARNINGS = "gov.economic_assumptions.yoy_growth.obr.average_earnings" INPUTS = { "general_betting": ( "betting_exchange_commission", @@ -154,6 +160,61 @@ def index(parameters, duty, year): return parameters.get_child(f"{INDEX}.{duty}")(f"{year}-01-01") +def population(parameters, year): + """One plus ``ons.population`` growth into ``year``.""" + return 1 + parameters.get_child(POPULATION)(f"{year}-01-01") + + +def population_index(parameters, year): + """The ``ons.population`` index in ``year`` over its 2024 level (the + index itself starts at 1 in 2021).""" + index = parameters.get_child(POPULATION_INDEX) + return index(f"{year}-01-01") / index("2024-01-01") + + +def weighted_index(parameters, duty, year): + """The duty's index times the population index: the growth of a + weighted input since 2024.""" + return index(parameters, duty, year) * population_index(parameters, year) + + +def obr_rate(duty, year): + """The rate on a duty's 2024-25 base over the OBR's year from 1 April + ``year``. Every gambling rate change starts on 1 April.""" + if duty == "remote_gaming": + return 0.21 if year < 2026 else 0.40 + if duty == "bingo": + return 0.10 if year < 2026 else 0.0 + if duty == "general_betting": + return 0.15 if year < 2027 else 0.15 + 0.10 * REMOTE_SHARE + if duty == "machine_games": + return MACHINE_GAMES_BEFORE if year < 2027 else MACHINE_GAMES_AFTER + return {"lottery": 0.12, "pool_betting": 0.15, "gaming": 0.235487}[duty] + + +def aggregate_index(parameters, duty, year): + """The duty's national base in ``year`` over its 2024-25 base: HMRC's + receipts to 2025-26 and Table 4.10's to 2031-32, each over the rate of + the OBR's year, then average earnings growth after the forecast. A base + whose rate is zero holds flat.""" + if year == 2024: + return 1.0 + if obr_rate(duty, year) == 0: + return aggregate_index(parameters, duty, year - 1) + if year > 2031: + earnings = parameters.get_child(AVERAGE_EARNINGS)(f"{year}-01-01") + return aggregate_index(parameters, duty, year - 1) * (1 + earnings) + + def base(year): + if year < 2026: + receipts = HMRC[duty][year - 2024] * 1e3 + else: + receipts = TABLE_4_10[duty][year - 2026] * 1e9 + return receipts / obr_rate(duty, year) + + return base(year) / base(2024) + + def test_every_gambling_input_declares_its_duty_index(): """Every input in the gambling folder declares its duty's index, and dataset projection grows it by the same duty's series.""" @@ -183,13 +244,15 @@ def test_indices_are_positive_from_the_2024_base_year(parameters): def test_remote_gaming_index_takes_out_the_april_2026_rate_rise(parameters): """Receipts more than double into 2026-27 as the rate goes from 21% to - 40%; the base grows 9.4%.""" + 40%; the base grows 9.4%, and per head 9.4% over population growth.""" receipts_growth = TABLE_4_10["remote_gaming"][0] / (HMRC["remote_gaming"][1] / 1e6) assert receipts_growth - 1 == pytest.approx(1.0834, abs=1e-4) base_growth = receipts_growth * 0.21 / 0.40 + assert round(base_growth - 1, 6) == 0.093757 growth = parameters.get_child(f"{GROWTH}.remote_gaming") - assert growth("2026-01-01") == round(base_growth - 1, 6) == 0.093757 - assert index(parameters, "remote_gaming", 2026) / index( + people = population(parameters, 2026) + assert growth("2026-01-01") == round(base_growth / people - 1, 6) + assert weighted_index(parameters, "remote_gaming", 2026) / weighted_index( parameters, "remote_gaming", 2025 ) == pytest.approx(base_growth, rel=2e-5) @@ -201,8 +264,10 @@ def test_general_betting_index_takes_out_the_april_2027_remote_rise(parameters): assert rate_2027 == pytest.approx(0.19547, abs=1e-5) receipts = TABLE_4_10["general_betting"] base_growth = (receipts[1] / rate_2027) / (receipts[0] / 0.15) + assert round(base_growth - 1, 6) == 0.027038 growth = parameters.get_child(f"{GROWTH}.general_betting") - assert growth("2027-01-01") == round(base_growth - 1, 6) == 0.027038 + people = population(parameters, 2027) + assert growth("2027-01-01") == round(base_growth / people - 1, 6) # Receipts grow 33.8%. assert receipts[1] / receipts[0] - 1 == pytest.approx(0.3384, abs=1e-4) @@ -224,45 +289,104 @@ def test_machine_games_index_takes_out_the_april_2027_higher_stake_rise(paramete base_growth = (receipts[1] / MACHINE_GAMES_AFTER) / ( receipts[0] / MACHINE_GAMES_BEFORE ) + assert round(base_growth - 1, 6) == -0.002319 growth = parameters.get_child(f"{GROWTH}.machine_games") - assert growth("2027-01-01") == round(base_growth - 1, 6) == -0.002319 + people = population(parameters, 2027) + assert growth("2027-01-01") == round(base_growth / people - 1, 6) def test_bingo_base_holds_flat_once_its_rate_is_zero(parameters): - """Bingo Duty ends on 1 April 2026; its base stays at its 2025-26 level.""" - at_2025 = index(parameters, "bingo", 2025) - assert at_2025 == pytest.approx(HMRC["bingo"][1] / HMRC["bingo"][0], rel=1e-5) + """Bingo Duty ends on 1 April 2026; its base stays at its 2025-26 level. + Per head it falls as population grows: in every year to the end of + ons.population, one plus its growth, times one plus population growth, is + one, so the weighted index holds.""" + at_2025 = weighted_index(parameters, "bingo", 2025) + assert at_2025 == pytest.approx(HMRC["bingo"][1] / HMRC["bingo"][0], rel=2e-5) + growth = parameters.get_child(f"{GROWTH}.bingo") + for year in range(2026, 2075): + people = population(parameters, year) + assert (1 + growth(f"{year}-01-01")) * people == pytest.approx( + 1, abs=5e-7 * people + 1e-12 + ), year for year in range(2026, 2040): - assert index(parameters, "bingo", year) == at_2025 + assert weighted_index(parameters, "bingo", year) == pytest.approx( + at_2025, rel=2e-4 + ), year -def household_frame(year, inputs): - """One household of one adult, with the given household inputs.""" +def households_frame(year, columns, weights): + """Households of one adult each, with the given weights and household + columns.""" + ids = list(range(1, len(weights) + 1)) + count = len(ids) return UKSingleYearDataset( person=pd.DataFrame( { - "person_id": [1], - "person_benunit_id": [1], - "person_household_id": [1], - "age": [40], + "person_id": ids, + "person_benunit_id": ids, + "person_household_id": ids, + "age": [40] * count, } ), - benunit=pd.DataFrame({"benunit_id": [1]}), + benunit=pd.DataFrame({"benunit_id": ids}), household=pd.DataFrame( { - "household_id": [1], - "region": ["LONDON"], - "tenure_type": ["OWNED_OUTRIGHT"], - "council_tax": [0.0], - "rent": [0.0], - "household_weight": [1.0], - **{name: [value] for name, value in inputs.items()}, + "household_id": ids, + "region": ["LONDON"] * count, + "tenure_type": ["OWNED_OUTRIGHT"] * count, + "council_tax": [0.0] * count, + "rent": [0.0] * count, + "household_weight": list(weights), + **columns, } ), fiscal_year=year, ) +def household_frame(year, inputs): + """One household of one adult, weight 1, with the given household + inputs.""" + return households_frame( + year, {name: [value] for name, value in inputs.items()}, (1.0,) + ) + + +# Two households hold each total between them, 30% at weight 700 and 70% at +# weight 1,900, as survey households hold the national bases. +WEIGHTS = (700.0, 1_900.0) +SHARES = (0.3, 0.7) + + +def weighted_frame(year, totals): + """Two households whose weighted inputs sum to ``totals``: each holds + its share of a total over its weight.""" + return households_frame( + year, + { + name: [total * share / weight for share, weight in zip(SHARES, WEIGHTS)] + for name, total in totals.items() + }, + WEIGHTS, + ) + + +def weighted(simulation, variable, year): + """``variable``'s total over the households in ``year``, each weighted by + its household weight.""" + values = np.asarray(simulation.calculate(variable, year), dtype=float) + weights = np.asarray(simulation.calculate("household_weight", year), dtype=float) + return float((values * weights).sum()) + + +def weighted_simulation(parameters, totals): + """A simulation of ``weighted_frame``'s households, projected as a survey + dataset is: by extend_single_year_dataset to its last year, and by Core's + uprating after it.""" + projected = extend_single_year_dataset(weighted_frame(2024, totals), parameters) + return Simulation(dataset=projected) + + def situation(inputs, year=2024): return { "people": {"person": {"age": {year: 40}}}, @@ -295,13 +419,33 @@ def test_dataset_projection_matches_core_uprating(parameters): stored = projected[year].household[name].iloc[0] uprated = simulation.calculate(name, year)[0] assert stored == pytest.approx(uprated, rel=1e-4), (name, year) - # Remote gaming grows by the base series, not the receipts'. + # Remote gaming grows by its base's growth over population growth. stored = projected[2026].household["remote_gaming_net_losses"].iloc[0] + people = population(parameters, 2025) * population(parameters, 2026) + base_growth = aggregate_index(parameters, "remote_gaming", 2026) assert stored == pytest.approx( - ALL_INPUTS["remote_gaming_net_losses"] * 1.136277 * 1.093757, rel=1e-6 + ALL_INPUTS["remote_gaming_net_losses"] * base_growth / people, rel=3e-6 ) +def test_weighted_gambling_bases_follow_the_aggregate(parameters): + """Household weights grow by ons.population, so each household's input + must grow per head for the weighted input to grow as its duty's base + does. Projected by extend_single_year_dataset to its last year and by + Core's uprating after it, each of the sixteen weighted inputs grows in + every year to 2039 as its duty's national base: receipts over the rate + to 2031-32, then average earnings, or flat while the rate is zero.""" + names = [name for names in INPUTS.values() for name in names] + totals = {name: 1e6 * (position + 1) for position, name in enumerate(names)} + simulation = weighted_simulation(parameters, totals) + for duty, duty_inputs in INPUTS.items(): + for year in range(2024, 2040): + expected = aggregate_index(parameters, duty, year) + for name in duty_inputs: + growth = weighted(simulation, name, year) / totals[name] + assert growth == pytest.approx(expected, rel=1e-4), (name, year) + + def test_higher_stake_machines_pay_the_april_2027_rise_once(): """The Machine Games Duty series divides Table 4.10's line by the rate with the higher-stake rise in it, so the projected base holds no rise and @@ -414,23 +558,24 @@ def expected_receipts(duty, year): @pytest.fixture(scope="module") -def national_simulation(): - return Simulation(dataset=household_frame(2024, national_bases())) +def national_simulation(parameters): + return weighted_simulation(parameters, national_bases()) @pytest.fixture(scope="module") -def national_simulation_without_higher_stake(): - return Simulation(dataset=household_frame(2024, national_bases(None))) +def national_simulation_without_higher_stake(parameters): + return weighted_simulation(parameters, national_bases(None)) @pytest.mark.parametrize("duty", sorted(DUTIES)) def test_bases_that_give_hmrc_receipts_give_the_obr_forecast(national_simulation, duty): - """Receipts are each base times its rate, and the bases grow by the - OBR's receipts over those rates: so bases that give HMRC's 2024-25 + """Receipts are each base times its rate. The weighted bases grow by the + OBR's receipts over those rates: household inputs per head, and household + weights by population. So weighted bases that give HMRC's 2024-25 receipts give HMRC's 2025-26 receipts and then Table 4.10 in every year to 2031-32, with the higher-stake input at its share.""" for year in range(2024, 2032): - model = national_simulation.calculate(DUTIES[duty], year)[0] + model = weighted(national_simulation, DUTIES[duty], year) assert model == pytest.approx(expected_receipts(duty, year), rel=2e-4, abs=1), ( duty, year, @@ -449,7 +594,7 @@ def test_without_the_higher_stake_input_the_duty_falls_short_of_table_4_10( 0.1831, abs=1e-4 ) for year in range(2024, 2032): - model = simulation.calculate("machine_games_duty", year)[0] + model = weighted(simulation, "machine_games_duty", year) expected = expected_receipts("machine_games", year) if year == 2026: expected = TABLE_4_10["machine_games"][0] * 1e9 @@ -587,7 +732,7 @@ def test_each_series_charges_the_rates_the_model_charges(switch): rates = { spec.path[-1]: line_rate(spec, year) for spec in RECEIPTS_SERIES_SPECS - if spec.path[0] == "gambling_duty_bases" + if "gambling_duty_bases" in spec.path } assert rates.keys() == DUTIES.keys() for duty, names in INPUTS.items(): diff --git a/policyengine_uk/tests/test_import_obr_forecasts.py b/policyengine_uk/tests/test_import_obr_forecasts.py index c28893a714..a5e2b02fe6 100644 --- a/policyengine_uk/tests/test_import_obr_forecasts.py +++ b/policyengine_uk/tests/test_import_obr_forecasts.py @@ -3,6 +3,7 @@ from datetime import date from pathlib import Path from io import BytesIO +from textwrap import indent from zipfile import ZipFile import xml.etree.ElementTree as ET @@ -41,6 +42,8 @@ extract_september_cpi_from_receipts, infer_forecast_start_year, infer_release, + per_head_growth, + population_growth, update_forecast_gap_yaml, update_yoy_growth_yaml, ) @@ -797,9 +800,15 @@ def test_small_gaps_are_written_in_fixed_point(value, text): + " business_rates:\n" + RECEIPTS_SERIES_FIXTURE.format(key="great_britain") + RECEIPTS_SERIES_FIXTURE.format(key="northern_ireland") - + " gambling_duty_bases:\n" + + "\n" +) +# The gambling duty bases, which the importer writes per head of +# ons.population under per_capita. +PER_CAPITA_FIXTURE = ( + " per_capita:\n" + " gambling_duty_bases:\n" + "".join( - RECEIPTS_SERIES_FIXTURE.format(key=key) + indent(RECEIPTS_SERIES_FIXTURE.format(key=key), " ") for key in ( "remote_gaming", "lottery", @@ -812,16 +821,22 @@ def test_small_gaps_are_written_in_fixed_point(value, text): ) + "\n" ) +# Population growth changes in 2026 and 2030, so each year's per-head growth +# must take that year's population growth out. +POPULATION_FIXTURE = """ons: + population: + values: + 2024-01-01: 0.0100 + 2026-01-01: 0.0200 + 2030-01-01: -0.0050 +""" YOY_GROWTH_FIXTURE = ( "obr:\n" + "".join(FLAT_SERIES_FIXTURE.format(key=key) for key in SERIES_KEYS[:2]) + RECEIPTS_FIXTURE + + PER_CAPITA_FIXTURE + "".join(FLAT_SERIES_FIXTURE.format(key=key) for key in SERIES_KEYS[2:]) - + """ons: - population: - values: - 2024-01-01: 0.0100 -""" + + POPULATION_FIXTURE ) GAP_FIXTURE = """description: Gap. values: @@ -1012,7 +1027,8 @@ def failing_replace(source, target): # Receipts growth series: stamp_duty_land_tax and business_rates under obr, # which link their first year to the previous EFO's outturn, and the -# gambling_duty_bases, which grow a duty's base from HMRC's outturn. +# gambling_duty_bases under per_capita, which grow a duty's base per head +# from HMRC's outturn. RECEIPTS_SPECS = tuple(spec for spec in RECEIPTS_SERIES_SPECS if spec.outturn is None) RECEIPTS_PATHS = {spec.path[-1]: spec.path for spec in RECEIPTS_SPECS} @@ -2188,6 +2204,37 @@ def test_real_october_2026_gambling_series(): assert sorted(series) == list(range(2025, 2032)) +def test_committed_gambling_series_are_the_real_bases_per_head(): + """yoy_growth.yaml holds each real October 2026 base's growth over one + plus the committed ons.population growth: Remote Gaming Duty's 2026-27 + base growth of 9.38% is 9.16% per head as population grows 0.2%. One plus + each value, times one plus population growth, is one plus the base's + growth, to the rounding of the six decimals both carry.""" + committed = get_yoy_growth_path().read_text() + population = population_growth(committed) + assert population[2026] == 0.002 + growth = compute_receipts_growth( + REAL_OCTOBER_2026_RECEIPTS, b"not read", specs=GAMBLING_SPECS + ) + per_head = { + path[-1]: per_head_growth(series, population).values + for path, series in growth.items() + } + base_growth = (2.7520296250837784 / 0.40) / (1.320966 / 0.21) + assert per_head["remote_gaming"][2026] == round( + base_growth / (1 + population[2026]) - 1, 6 + ) + for key, values in per_head.items(): + stored = _values(committed, GAMBLING_PATHS[key]) + assert stored[2024] == 0.0 + assert {year: stored[year] for year in values} == values, key + aggregate = growth[GAMBLING_PATHS[key]].values + for year, value in values.items(): + assert (1 + value) * (1 + population[year]) == pytest.approx( + 1 + aggregate[year], abs=1.1e-6 + ), (key, year) + + def test_gambling_lines_read_no_previous_efo(): # March 2025's tables have no betting and gaming breakdown; the gambling # lines take 2024-25 and 2025-26 from HMRC instead. @@ -2209,32 +2256,46 @@ def test_gambling_outturn_must_reach_the_year_before_the_table(): def test_bingo_holds_its_base_flat_after_the_forecast(): + """Bingo Duty's rate is zero from 1 April 2026, so its base holds flat. + Per head it falls with population in every year to the last that + ons.population states, so the weighted base stays where it was.""" rendered = render() - start = rendered.index(" bingo:") - block = rendered[start : rendered.index(" pool_betting:")] - assert "# Beyond the OBR forecast: held flat, as the rate is zero" in block - assert _values(rendered, GAMBLING_PATHS["bingo"])[2028] == 0.0 - assert max(_values(rendered, GAMBLING_PATHS["bingo"])) == 2028 - # Remote Gaming Duty follows average earnings, as the receipts lines do. + start = rendered.index(" bingo:") + block = rendered[start : rendered.index(" pool_betting:")] + assert ( + "# Beyond the OBR forecast: held flat, as the rate is zero; per head: " + "(1 + that growth) / (1 + ons.population growth) - 1" + ) in block + bingo = _values(rendered, GAMBLING_PATHS["bingo"]) + population = population_growth(rendered) + for year in range(2026, 2040): + flat = round(1 / (1 + _in_effect(population, year)) - 1, 6) + assert _in_effect(bingo, year) == flat, year + # Population last changes in 2030. + assert max(bingo) == 2030 + # Remote Gaming Duty follows average earnings, as the receipts lines do, + # over population growth: 2% to 2029, then -0.5%. remote_gaming = _values(rendered, GAMBLING_PATHS["remote_gaming"]) - assert {year: remote_gaming[year] for year in (2028, 2031)} == { - 2028: 0.0, - 2031: 0.02, + assert {year: remote_gaming[year] for year in (2028, 2030, 2031)} == { + 2028: round(1 / 1.02 - 1, 6), + 2030: round(1 / 0.995 - 1, 6), + 2031: round(1.02 / 0.995 - 1, 6), } def test_gambling_comments_and_references_name_hmrc_and_the_rates(): rendered = render() block = rendered[ - rendered.index(" remote_gaming:") : rendered.index(" lottery:") + rendered.index(" remote_gaming:") : rendered.index(" lottery:") ] # March 2026's table starts in 2025-26, so no year comes from HMRC alone. assert "# Outturn (2025" not in block assert ( "# Forecast (2025-26 over 2024-25) from OBR EFO March 2026, receipts " 'Table 3.10 "Remote gaming duty", over HMRC\'s receipts (July 2026 ' - "Betting and Gaming tables, Table_1_BG_receipts); base growth: receipts " - "over the rate the model charges" + "Betting and Gaming tables, Table_1_BG_receipts); per head: base growth " + "(receipts over the rate the model charges) over one plus ons.population " + "growth" ) in block references = _series(yaml.safe_load(rendered), GAMBLING_PATHS["remote_gaming"])[ "metadata" @@ -2246,6 +2307,54 @@ def test_gambling_comments_and_references_name_hmrc_and_the_rates(): ] +def test_gambling_series_are_written_per_head(): + """The importer writes each gambling base's growth over one plus that + year's ons.population growth. In the synthetic March 2026 tables Remote + Gaming Duty's receipts double into 2026-27 as its rate rises from 21% to + 40%, so its base grows 5%, and per head 5% over population growth of 2%. + Every other year and duty divides the same way.""" + rendered = render() + remote_gaming = _values(rendered, GAMBLING_PATHS["remote_gaming"]) + assert remote_gaming[2026] == round((2.8 / 0.40) / (1.4 / 0.21) / 1.02 - 1, 6) + population = population_growth(YOY_GROWTH_FIXTURE) + for key, aggregate in gambling_growth().items(): + values = _values(rendered, GAMBLING_PATHS[key]) + assert sorted(aggregate) == [2025, 2026, 2027] + for year, growth in aggregate.items(): + per_head = (1 + growth) / (1 + _in_effect(population, year)) - 1 + # Both carry six decimals. + assert values[year] == pytest.approx(per_head, abs=1.1e-6), (key, year) + + +def test_the_index_check_reads_the_per_head_series(): + """The model compounds the per-head series, so the index check reads it. + Bingo Duty's 2025-26 receipts here leave its index at 0.00002, and its + base holds flat from 2026-27, so the aggregate index stays there. If + population triples in 2028 and 2029, the per-head index falls to 0.00001 + and then to zero at five decimals, and the importer refuses it.""" + bingo = HMRC_RECEIPTS["bingo"][0] * 0.00002 + workbook = make_receipts_xlsx( + gambling_levels=GAMBLING_LEVELS | {"bingo": (bingo, 0.0001, 0.0)} + ) + aggregate = compute_receipts_growth(workbook, b"", specs=GAMBLING_SPECS) + assert aggregate[GAMBLING_PATHS["bingo"]].values == { + 2025: -0.99998, + 2026: 0.0, + 2027: 0.0, + } + render(current=workbook) + tripling = YOY_GROWTH_FIXTURE.replace( + " 2030-01-01: -0.0050\n", + " 2028-01-01: 2.0000\n 2030-01-01: -0.0050\n", + ) + assert population_growth(tripling)[2028] == 2.0 + with pytest.raises( + ValueError, + match="per_capita.gambling_duty_bases.bingo: the index reaches 0.0 in 2029", + ): + render(tripling, current=workbook) + + def test_the_machine_games_parameters_are_the_oracles(): """The hand-written Machine Games Duty rates above are the model's: 5%, 20% and 25%, with higher-stake machines at the higher rate from 1 April @@ -2552,3 +2661,94 @@ def test_a_written_series_keeps_every_index_positive(levels, schedules): for year in sorted(series.values): index = round(index * (1 + series.values[year]), 5) assert index > 0 + + +# Per head (#2738): dataset projection grows household weights by +# ons.population, so each gambling series is its base's growth over one plus +# population growth, and compounding it with population growth gives back +# the aggregate base. + +GROWTH_FACTOR = st.floats(min_value=0.5, max_value=2, allow_nan=False) +POPULATION_GROWTH = st.floats(min_value=-0.02, max_value=0.03, allow_nan=False).map( + lambda value: round(value, 4) +) +REAL_RATES = { + parameter: import_obr_forecasts.parameter_values(parameter) + for parameter in GAMBLING_RATE_PARAMETERS +} +# The model's gambling rates last change in 2027-28, so each line's rate holds +# from 2028-29. +assert max(day.year for entries in REAL_RATES.values() for day, _ in entries) < 2028 +LINE_RATES = { + spec.path: {year: oracle_rate(spec, REAL_RATES, year) for year in range(2024, 2029)} + for spec in GAMBLING_SPECS +} + + +@settings(max_examples=40, deadline=None, derandomize=True) +@given(last=st.integers(2025, 2045), data=st.data()) +def test_per_head_series_compound_to_the_aggregate_index(last, data): + """For any gambling receipts and any ons.population series ending in any + year: in every year to 2060, one plus a rendered series' growth, times one + plus population growth, is one plus the base's growth, to the rounding of + the six decimals written. The base grows by receipts over the rate the + model charges to the end of the forecast, then with average_earnings, or + holds flat while its rate is zero. So the per-head index, compounded with + the population index, gives the aggregate index, and a second run changes + nothing.""" + levels = {} + for key in GAMBLING_LABELS: + level = HMRC_RECEIPTS[key][0] + series = [] + for _ in range(3): + level *= data.draw(GROWTH_FACTOR) + series.append(level) + levels[key] = tuple(series) + population = { + year: data.draw(POPULATION_GROWTH, label=f"population {year}") + for year in range(2024, last + 1) + } + content = YOY_GROWTH_FIXTURE.replace( + POPULATION_FIXTURE, + "ons:\n population:\n values:\n" + + "".join( + f" {year}-01-01: {format_growth(value)}\n" + for year, value in population.items() + ), + ) + current = make_receipts_xlsx(gambling_levels=levels) + rendered = render(content, current=current) + earnings = _values(rendered, ("average_earnings",)) + for spec in GAMBLING_SPECS: + values = _values(rendered, spec.path) + # Years before the first growth year keep the fixture's values. + assert (values[2023], values[2024]) == (0.05, 0.01) + receipts = gambling_receipts(spec.path[-1], levels[spec.path[-1]]) + rates = { + year: LINE_RATES[spec.path][min(year, 2028)] for year in range(2024, 2061) + } + per_head_index = population_index = aggregate_index = tolerance = 1.0 + for year in range(2025, 2061): + if rates[year] < 1e-12 or rates[year - 1] < 1e-12: + exact = 1.0 + elif year in receipts: + exact = (receipts[year] / rates[year]) / ( + receipts[year - 1] / rates[year - 1] + ) + else: + exact = 1 + _in_effect(earnings, year) + growth = _in_effect(values, year) + people = 1 + _in_effect(population, year) + assert growth > -1 + # Six-decimal rounding moves the product by at most 5e-7 times + # one plus the population growth. + error = 5e-7 * people + 1e-12 + assert abs((1 + growth) * people - exact) <= error, (spec.path, year) + per_head_index *= 1 + growth + population_index *= people + aggregate_index *= exact + tolerance *= 1 + error / exact + assert per_head_index * population_index == pytest.approx( + aggregate_index, rel=tolerance - 1 + 1e-12 + ), (spec.path, year) + assert render(rendered, current=current) == rendered diff --git a/policyengine_uk/utils/import_obr_forecasts.py b/policyengine_uk/utils/import_obr_forecasts.py index c2f31d3bdd..59331b4a5b 100644 --- a/policyengine_uk/utils/import_obr_forecasts.py +++ b/policyengine_uk/utils/import_obr_forecasts.py @@ -5,7 +5,7 @@ import os import re import shutil -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import date from decimal import Decimal from functools import lru_cache @@ -662,6 +662,8 @@ class ReceiptsSeriesSpec: ``outturn`` takes the years before the EFO's first from it, not from the previous EFO, which it does not read. ``references`` are cited after the sources of the levels. + + A series under ``per_capita`` grows its line per head (``per_head``). """ path: tuple[str, ...] @@ -671,6 +673,15 @@ class ReceiptsSeriesSpec: outturn: ReceiptsOutturn | None = None references: tuple[tuple[str, str], ...] = () + @property + def per_head(self) -> bool: + """Whether the series sits under ``per_capita``. Dataset projection + grows household weights by ``ons.population``, so + ``render_receipts_growth_yaml`` writes such a series' growth over one + plus that growth (``per_head_growth``, ``per_head_tail``), and the + weighted total grows with the line in every year.""" + return self.path[0] == "per_capita" + GAMBLING_RATES = "gov.hmrc.gambling_duty" MACHINE_GAMES = f"{GAMBLING_RATES}.machine_games" @@ -700,12 +711,12 @@ def gambling_spec( receipts_thousands: tuple[tuple[int, float], ...], **extra, ) -> ReceiptsSeriesSpec: - """A betting and gaming duty's base (#2738): its line in the receipts - tables' forecast breakdown (Table 4.10 in October 2026, 3.10 in March - 2026), which publishes forecasts only, after HMRC's outturn. The model's - gambling inputs start in 2024-25, the first year here.""" + """A betting and gaming duty's base per head (#2738): its line in the + receipts tables' forecast breakdown (Table 4.10 in October 2026, 3.10 in + March 2026), which publishes forecasts only, after HMRC's outturn. The + model's gambling inputs start in 2024-25, the first year here.""" return ReceiptsSeriesSpec( - path=("gambling_duty_bases", key), + path=("per_capita", "gambling_duty_bases", key), title="betting and gaming duties forecast breakdown", row=row, rates=rates, @@ -1216,34 +1227,102 @@ def compute_receipts_growth( f"{name}: {fiscal_year_label(year)} receipts of {level} are " "not positive, so there is no growth to or from them" ) - values = {} - for year in sorted(levels)[1:]: - if not spec.rates: - values[year] = round(levels[year] / levels[year - 1] - 1, 6) + 0.0 - elif rates[year] == 0 or rates[year - 1] == 0: - values[year] = 0.0 - else: - base_growth = (levels[year] / rates[year]) / ( - levels[year - 1] / rates[year - 1] - ) - values[year] = round(base_growth - 1, 6) + 0.0 - # The model compounds the growth into an index rounded to five - # decimals (create_economic_assumption_indices), from 1 in a line's - # first year when the line has its own outturn. A positive base that - # rounds the index to zero would zero it in every later year. - index = 1.0 - for year, value in values.items(): - index = round(index * (1 + value), 5) - if index <= 0: - raise ValueError( - f"{name}: {fiscal_year_label(year)} growth of {value} takes " - f"the index to {index} at five decimals, so the base is " - "too small to grow from" - ) + values = { + year: round(growth_ratio(spec, levels, year) - 1, 6) + 0.0 + for year in sorted(levels)[1:] + } + check_index(name, values) result[spec.path] = ReceiptsGrowth(values, line, previous, spec) return result +def growth_ratio( + spec: ReceiptsSeriesSpec, levels: dict[int, float], year: int +) -> float: + """One plus a line's growth into ``year``, before rounding: the ratio of + its levels, or for a line with rates, of its bases, each year's receipts + over the rate the model charges (``line_rate``). A year whose rate, or + the year before's, is zero has no base, so the base holds flat.""" + if not spec.rates: + return levels[year] / levels[year - 1] + rate, previous_rate = line_rate(spec, year), line_rate(spec, year - 1) + if rate == 0 or previous_rate == 0: + return 1.0 + return (levels[year] / rate) / (levels[year - 1] / previous_rate) + + +def check_index(name: str, values: dict[int, float]) -> None: + """Refuse growth that zeroes the index the model builds from it. + + The model compounds the growth into an index rounded to five decimals + (create_economic_assumption_indices), from 1 in a line's first year when + the line has its own outturn. A positive base that rounds the index to + zero would zero it in every later year. + """ + index = 1.0 + for year, value in values.items(): + index = round(index * (1 + value), 5) + if index <= 0: + raise ValueError( + f"{name}: {fiscal_year_label(year)} growth of {value} takes " + f"the index to {index} at five decimals, so the base is " + "too small to grow from" + ) + + +def population_growth(content: str) -> dict[int, float]: + """``ons.population`` growth by year, as a yoy_growth.yaml holds it.""" + values = yaml.safe_load(content)["ons"]["population"]["values"] + return dict( + sorted( + (int(str(instant)[:4]), float(value)) for instant, value in values.items() + ) + ) + + +def growth_in_effect(series: dict[int, float], year: int) -> float: + """A growth series' value in ``year`` as the model reads it: its latest + entry at or before the year, or its first entry before that, since the + model backdates each parameter's first value.""" + earlier = [entry for entry in series if entry <= year] + return series[max(earlier) if earlier else min(series)] + + +def per_head_growth( + growth: ReceiptsGrowth, population: dict[int, float] +) -> ReceiptsGrowth: + """A series' growth per head, to six decimals: each year's growth before + rounding (``growth_ratio``), over one plus that year's ``ons.population`` + growth, minus one. + + Dataset projection grows each household's input by this and its weight + by the population growth, so the weighted input grows by their product, + the line's growth: a duty's base, for a line with rates. + """ + spec = growth.spec + first = min(growth.line.levels) + if growth.previous_line is None: + earlier = { + year: value / 1e6 + for year, value in spec.outturn.receipts_thousands + if year < first + } + else: + earlier = {first - 1: growth.previous_line.levels[first - 1]} + levels = {**earlier, **growth.line.levels} + values = { + year: round( + growth_ratio(spec, levels, year) / (1 + growth_in_effect(population, year)) + - 1, + 6, + ) + + 0.0 + for year in growth.values + } + check_index(".".join(spec.path), values) + return replace(growth, values=values) + + def format_growth(value: float) -> str: """Fixed-point text with at least four decimals that reads back as the same float: 0.038 is written 0.0380, 1e-05 as 0.00001.""" @@ -1391,9 +1470,15 @@ def render_receipts_values( def receipts_basis(spec: ReceiptsSeriesSpec | None) -> str: """How a line's growth is measured, for its comments: nothing for - receipts, the rates taken out for a base.""" + receipts, the rates taken out for a base, and population for a base per + head.""" if spec is None or not spec.rates: return "" + if spec.per_head: + return ( + "; per head: base growth (receipts over the rate the model charges) " + "over one plus ons.population growth" + ) return "; base growth: receipts over the rate the model charges" @@ -1434,6 +1519,35 @@ def receipts_tail(content: str, growth: ReceiptsGrowth) -> tuple[dict[int, float return tail, "the average_earnings long-run path above" +def per_head_tail( + tail: dict[int, float], comment: str, population: dict[int, float] +) -> tuple[dict[int, float], str]: + """A series' growth after the OBR's forecast per head, and its comment. + + In each year from the tail's first to the last that it or + ``ons.population`` states, (1 + the tail's growth) / (1 + population + growth) - 1, to six decimals, written where the value changes. The model + holds each series' last value, so the weighted total grows by the tail in + every later year too. + """ + first = min(tail) + yearly = { + year: round( + (1 + growth_in_effect(tail, year)) + / (1 + growth_in_effect(population, year)) + - 1, + 6, + ) + + 0.0 + for year in range(first, max(max(tail), max(population), first) + 1) + } + return { + year: value + for year, value in yearly.items() + if year == first or value != yearly[year - 1] + }, f"{comment}; per head: (1 + that growth) / (1 + ons.population growth) - 1" + + def receipts_references( growth: ReceiptsGrowth, release: tuple[str, int], @@ -1494,14 +1608,21 @@ def render_receipts_growth_yaml( Years before this EFO's first year are kept, so a series' index keeps its base year. Years after the OBR's receipts forecast follow - average_earnings as ``content`` holds it. The result is checked to - differ from ``content`` only in those values and references. + average_earnings as ``content`` holds it. A series under ``per_capita`` + is written per head of ``ons.population`` as ``content`` holds it, in + every year to the last that population or the tail states. The result is + checked to differ from ``content`` only in those values and references. """ before = yaml.safe_load(content) expected = copy.deepcopy(before) lines = content.split("\n") + population = None for path, series in growth.items(): tail, tail_comment = receipts_tail(content, series) + if series.spec is not None and series.spec.per_head: + population = population or population_growth(content) + series = per_head_growth(series, population) + tail, tail_comment = per_head_tail(tail, tail_comment, population) references = receipts_references(series, release, previous_release) start, end = _series_block(lines, ("obr", *path)) @@ -1763,14 +1884,26 @@ def print_summary( print(f"- {spec.key}: {', '.join(window)}") -def print_receipts_summary(growth: dict[tuple[str, ...], ReceiptsGrowth]) -> None: +def print_receipts_summary( + growth: dict[tuple[str, ...], ReceiptsGrowth], + population: dict[int, float] | None = None, +) -> None: + """Print each series' growth: a series under ``per_capita`` per head when + ``population`` is given, as it is written.""" print(f"Updating {len(growth)} OBR receipts growth series") for path, series in growth.items(): + basis = "" + if series.spec is not None and series.spec.per_head: + if population is None: + basis = ", aggregate" + else: + series = per_head_growth(series, population) + basis = ", per head" window = ", ".join( f"{year}: {format_growth(value)}" for year, value in sorted(series.values.items()) ) - print(f"- {'.'.join(path)} (Table {series.line.table}): {window}") + print(f"- {'.'.join(path)} (Table {series.line.table}{basis}): {window}") def build_arg_parser() -> argparse.ArgumentParser: @@ -2026,7 +2159,9 @@ def main(argv: list[str] | None = None) -> int: f"an EFO before {month} {year}" ) receipts_growth = compute_receipts_growth(receipts_bytes, previous_bytes) - print_receipts_summary(receipts_growth) + print_receipts_summary( + receipts_growth, population_growth(source_yaml_path.read_text()) + ) calendar_values = ( read_calendar_values(source_yaml_path) if args.gaps_only else series_values ) diff --git a/policyengine_uk/variables/input/consumption/gambling/betting_exchange_commission.py b/policyengine_uk/variables/input/consumption/gambling/betting_exchange_commission.py index 0aefbecf32..3935e900d4 100644 --- a/policyengine_uk/variables/input/consumption/gambling/betting_exchange_commission.py +++ b/policyengine_uk/variables/input/consumption/gambling/betting_exchange_commission.py @@ -7,8 +7,6 @@ class betting_exchange_commission(Variable): definition_period = YEAR default_value = 0 unit = GBP - uprating = ( - "gov.economic_assumptions.indices.obr.gambling_duty_bases.general_betting" - ) + uprating = "gov.economic_assumptions.indices.obr.per_capita.gambling_duty_bases.general_betting" label = "Chargeable betting exchange commission attributed to the household" reference = "https://www.legislation.gov.uk/ukpga/2014/26/section/141" diff --git a/policyengine_uk/variables/input/consumption/gambling/bingo_net_receipts.py b/policyengine_uk/variables/input/consumption/gambling/bingo_net_receipts.py index 26ed12b9c1..9bd6ed1f4a 100644 --- a/policyengine_uk/variables/input/consumption/gambling/bingo_net_receipts.py +++ b/policyengine_uk/variables/input/consumption/gambling/bingo_net_receipts.py @@ -7,6 +7,8 @@ class bingo_net_receipts(Variable): definition_period = YEAR default_value = 0 unit = GBP - uprating = "gov.economic_assumptions.indices.obr.gambling_duty_bases.bingo" + uprating = ( + "gov.economic_assumptions.indices.obr.per_capita.gambling_duty_bases.bingo" + ) label = "Attributed dutiable bingo promotion profits" reference = "https://www.legislation.gov.uk/ukpga/1981/63/section/17/2024-04-06" diff --git a/policyengine_uk/variables/input/consumption/gambling/financial_spread_betting_net_losses.py b/policyengine_uk/variables/input/consumption/gambling/financial_spread_betting_net_losses.py index 3501feade7..6ef38f34ab 100644 --- a/policyengine_uk/variables/input/consumption/gambling/financial_spread_betting_net_losses.py +++ b/policyengine_uk/variables/input/consumption/gambling/financial_spread_betting_net_losses.py @@ -7,8 +7,6 @@ class financial_spread_betting_net_losses(Variable): definition_period = YEAR default_value = 0 unit = GBP - uprating = ( - "gov.economic_assumptions.indices.obr.gambling_duty_bases.general_betting" - ) + uprating = "gov.economic_assumptions.indices.obr.per_capita.gambling_duty_bases.general_betting" label = "Attributed dutiable financial spread betting losses" reference = "https://www.legislation.gov.uk/ukpga/2014/26/section/128" diff --git a/policyengine_uk/variables/input/consumption/gambling/horse_and_dog_pool_betting_net_losses.py b/policyengine_uk/variables/input/consumption/gambling/horse_and_dog_pool_betting_net_losses.py index c57172ce1d..ff2b49b094 100644 --- a/policyengine_uk/variables/input/consumption/gambling/horse_and_dog_pool_betting_net_losses.py +++ b/policyengine_uk/variables/input/consumption/gambling/horse_and_dog_pool_betting_net_losses.py @@ -7,8 +7,6 @@ class horse_and_dog_pool_betting_net_losses(Variable): definition_period = YEAR default_value = 0 unit = GBP - uprating = ( - "gov.economic_assumptions.indices.obr.gambling_duty_bases.general_betting" - ) + uprating = "gov.economic_assumptions.indices.obr.per_capita.gambling_duty_bases.general_betting" label = "Attributed dutiable horse and dog racing pool losses" reference = "https://www.legislation.gov.uk/ukpga/2014/26/section/134" diff --git a/policyengine_uk/variables/input/consumption/gambling/land_based_casino_net_losses.py b/policyengine_uk/variables/input/consumption/gambling/land_based_casino_net_losses.py index 2c75dbef9b..5a493d52df 100644 --- a/policyengine_uk/variables/input/consumption/gambling/land_based_casino_net_losses.py +++ b/policyengine_uk/variables/input/consumption/gambling/land_based_casino_net_losses.py @@ -7,6 +7,8 @@ class land_based_casino_net_losses(Variable): definition_period = YEAR default_value = 0 unit = GBP - uprating = "gov.economic_assumptions.indices.obr.gambling_duty_bases.gaming" + uprating = ( + "gov.economic_assumptions.indices.obr.per_capita.gambling_duty_bases.gaming" + ) label = "Attributed dutiable land-based casino gaming yield" reference = "https://www.legislation.gov.uk/ukpga/1997/16/section/11" diff --git a/policyengine_uk/variables/input/consumption/gambling/land_based_fixed_odds_betting_net_losses.py b/policyengine_uk/variables/input/consumption/gambling/land_based_fixed_odds_betting_net_losses.py index f816f4f23f..975db86a41 100644 --- a/policyengine_uk/variables/input/consumption/gambling/land_based_fixed_odds_betting_net_losses.py +++ b/policyengine_uk/variables/input/consumption/gambling/land_based_fixed_odds_betting_net_losses.py @@ -7,8 +7,6 @@ class land_based_fixed_odds_betting_net_losses(Variable): definition_period = YEAR default_value = 0 unit = GBP - uprating = ( - "gov.economic_assumptions.indices.obr.gambling_duty_bases.general_betting" - ) + uprating = "gov.economic_assumptions.indices.obr.per_capita.gambling_duty_bases.general_betting" label = "Attributed dutiable land-based fixed-odds losses" reference = "https://www.legislation.gov.uk/ukpga/2014/26/section/126" diff --git a/policyengine_uk/variables/input/consumption/gambling/machine_games_higher_rate_net_takings.py b/policyengine_uk/variables/input/consumption/gambling/machine_games_higher_rate_net_takings.py index 6ffad81df7..2602ce0e38 100644 --- a/policyengine_uk/variables/input/consumption/gambling/machine_games_higher_rate_net_takings.py +++ b/policyengine_uk/variables/input/consumption/gambling/machine_games_higher_rate_net_takings.py @@ -7,6 +7,6 @@ class machine_games_higher_rate_net_takings(Variable): definition_period = YEAR default_value = 0 unit = GBP - uprating = "gov.economic_assumptions.indices.obr.gambling_duty_bases.machine_games" + uprating = "gov.economic_assumptions.indices.obr.per_capita.gambling_duty_bases.machine_games" label = "Attributed dutiable higher-rate machine games net takings" reference = "https://www.gov.uk/guidance/machine-games-duty-excise-notice-452" diff --git a/policyengine_uk/variables/input/consumption/gambling/machine_games_lower_rate_net_takings.py b/policyengine_uk/variables/input/consumption/gambling/machine_games_lower_rate_net_takings.py index 021b5a7b53..2b6aa332b4 100644 --- a/policyengine_uk/variables/input/consumption/gambling/machine_games_lower_rate_net_takings.py +++ b/policyengine_uk/variables/input/consumption/gambling/machine_games_lower_rate_net_takings.py @@ -7,6 +7,6 @@ class machine_games_lower_rate_net_takings(Variable): definition_period = YEAR default_value = 0 unit = GBP - uprating = "gov.economic_assumptions.indices.obr.gambling_duty_bases.machine_games" + uprating = "gov.economic_assumptions.indices.obr.per_capita.gambling_duty_bases.machine_games" label = "Attributed dutiable lower-rate machine games net takings" reference = "https://www.gov.uk/guidance/machine-games-duty-excise-notice-452" diff --git a/policyengine_uk/variables/input/consumption/gambling/machine_games_standard_rate_higher_stake_net_takings.py b/policyengine_uk/variables/input/consumption/gambling/machine_games_standard_rate_higher_stake_net_takings.py index cc032b34e5..b5676691d6 100644 --- a/policyengine_uk/variables/input/consumption/gambling/machine_games_standard_rate_higher_stake_net_takings.py +++ b/policyengine_uk/variables/input/consumption/gambling/machine_games_standard_rate_higher_stake_net_takings.py @@ -7,7 +7,7 @@ class machine_games_standard_rate_higher_stake_net_takings(Variable): definition_period = YEAR default_value = 0 unit = GBP - uprating = "gov.economic_assumptions.indices.obr.gambling_duty_bases.machine_games" + uprating = "gov.economic_assumptions.indices.obr.per_capita.gambling_duty_bases.machine_games" label = "Attributed higher-stake part of standard-rate machine games net takings" documentation = ( "The part of machine_games_standard_rate_net_takings that comes from " diff --git a/policyengine_uk/variables/input/consumption/gambling/machine_games_standard_rate_net_takings.py b/policyengine_uk/variables/input/consumption/gambling/machine_games_standard_rate_net_takings.py index 404a600f48..21798450d9 100644 --- a/policyengine_uk/variables/input/consumption/gambling/machine_games_standard_rate_net_takings.py +++ b/policyengine_uk/variables/input/consumption/gambling/machine_games_standard_rate_net_takings.py @@ -7,6 +7,6 @@ class machine_games_standard_rate_net_takings(Variable): definition_period = YEAR default_value = 0 unit = GBP - uprating = "gov.economic_assumptions.indices.obr.gambling_duty_bases.machine_games" + uprating = "gov.economic_assumptions.indices.obr.per_capita.gambling_duty_bases.machine_games" label = "Attributed dutiable standard-rate machine games net takings" reference = "https://www.gov.uk/guidance/machine-games-duty-excise-notice-452" diff --git a/policyengine_uk/variables/input/consumption/gambling/national_lottery_ticket_spending.py b/policyengine_uk/variables/input/consumption/gambling/national_lottery_ticket_spending.py index f6d50bd56a..6c8fc049b8 100644 --- a/policyengine_uk/variables/input/consumption/gambling/national_lottery_ticket_spending.py +++ b/policyengine_uk/variables/input/consumption/gambling/national_lottery_ticket_spending.py @@ -7,6 +7,8 @@ class national_lottery_ticket_spending(Variable): definition_period = YEAR default_value = 0 unit = GBP - uprating = "gov.economic_assumptions.indices.obr.gambling_duty_bases.lottery" + uprating = ( + "gov.economic_assumptions.indices.obr.per_capita.gambling_duty_bases.lottery" + ) label = "Chargeable National Lottery ticket consideration" reference = "https://www.legislation.gov.uk/ukpga/1993/34/section/25" diff --git a/policyengine_uk/variables/input/consumption/gambling/other_spread_betting_net_losses.py b/policyengine_uk/variables/input/consumption/gambling/other_spread_betting_net_losses.py index 7a1ff059e7..c23e31b5f1 100644 --- a/policyengine_uk/variables/input/consumption/gambling/other_spread_betting_net_losses.py +++ b/policyengine_uk/variables/input/consumption/gambling/other_spread_betting_net_losses.py @@ -7,8 +7,6 @@ class other_spread_betting_net_losses(Variable): definition_period = YEAR default_value = 0 unit = GBP - uprating = ( - "gov.economic_assumptions.indices.obr.gambling_duty_bases.general_betting" - ) + uprating = "gov.economic_assumptions.indices.obr.per_capita.gambling_duty_bases.general_betting" label = "Attributed dutiable other spread betting losses" reference = "https://www.legislation.gov.uk/ukpga/2014/26/section/130" diff --git a/policyengine_uk/variables/input/consumption/gambling/pool_betting_net_receipts.py b/policyengine_uk/variables/input/consumption/gambling/pool_betting_net_receipts.py index 12dadc7824..cb8293872a 100644 --- a/policyengine_uk/variables/input/consumption/gambling/pool_betting_net_receipts.py +++ b/policyengine_uk/variables/input/consumption/gambling/pool_betting_net_receipts.py @@ -7,6 +7,6 @@ class pool_betting_net_receipts(Variable): definition_period = YEAR default_value = 0 unit = GBP - uprating = "gov.economic_assumptions.indices.obr.gambling_duty_bases.pool_betting" + uprating = "gov.economic_assumptions.indices.obr.per_capita.gambling_duty_bases.pool_betting" label = "Attributed dutiable non-racing pool betting net receipts" reference = "https://www.legislation.gov.uk/ukpga/2014/26/section/143" diff --git a/policyengine_uk/variables/input/consumption/gambling/remote_gaming_net_losses.py b/policyengine_uk/variables/input/consumption/gambling/remote_gaming_net_losses.py index e92645b39a..b170dfd7a7 100644 --- a/policyengine_uk/variables/input/consumption/gambling/remote_gaming_net_losses.py +++ b/policyengine_uk/variables/input/consumption/gambling/remote_gaming_net_losses.py @@ -7,6 +7,6 @@ class remote_gaming_net_losses(Variable): definition_period = YEAR default_value = 0 unit = GBP - uprating = "gov.economic_assumptions.indices.obr.gambling_duty_bases.remote_gaming" + uprating = "gov.economic_assumptions.indices.obr.per_capita.gambling_duty_bases.remote_gaming" label = "Attributed chargeable non-exempt remote gaming losses" reference = "https://www.legislation.gov.uk/ukpga/2014/26/section/161" diff --git a/policyengine_uk/variables/input/consumption/gambling/remote_other_betting_net_losses.py b/policyengine_uk/variables/input/consumption/gambling/remote_other_betting_net_losses.py index 40ca355a0d..251d9c507f 100644 --- a/policyengine_uk/variables/input/consumption/gambling/remote_other_betting_net_losses.py +++ b/policyengine_uk/variables/input/consumption/gambling/remote_other_betting_net_losses.py @@ -7,8 +7,6 @@ class remote_other_betting_net_losses(Variable): definition_period = YEAR default_value = 0 unit = GBP - uprating = ( - "gov.economic_assumptions.indices.obr.gambling_duty_bases.general_betting" - ) + uprating = "gov.economic_assumptions.indices.obr.per_capita.gambling_duty_bases.general_betting" label = "Attributed dutiable other remote fixed-odds losses" reference = "https://www.legislation.gov.uk/ukpga/2026/11/section/87" diff --git a/policyengine_uk/variables/input/consumption/gambling/remote_uk_horseracing_betting_net_losses.py b/policyengine_uk/variables/input/consumption/gambling/remote_uk_horseracing_betting_net_losses.py index 0db5f7657f..d508614d96 100644 --- a/policyengine_uk/variables/input/consumption/gambling/remote_uk_horseracing_betting_net_losses.py +++ b/policyengine_uk/variables/input/consumption/gambling/remote_uk_horseracing_betting_net_losses.py @@ -7,8 +7,6 @@ class remote_uk_horseracing_betting_net_losses(Variable): definition_period = YEAR default_value = 0 unit = GBP - uprating = ( - "gov.economic_assumptions.indices.obr.gambling_duty_bases.general_betting" - ) + uprating = "gov.economic_assumptions.indices.obr.per_capita.gambling_duty_bases.general_betting" label = "Attributed dutiable remote UK horseracing fixed-odds losses" reference = "https://www.legislation.gov.uk/ukpga/2014/26/section/127" From 27f02366c68ef5d5010c3f0197a8907c63589650 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 12:38:49 -0400 Subject: [PATCH 14/17] Describe the weighted constructed households and the importer's division The docs' constructed-household table now comes from two households, weighted 700 and 1,900, that hold the national bases between them; its figures do not change. Comments give the division to the importer. Co-Authored-By: Claude Opus 5.5 --- docs/engineering/gambling-duties.md | 14 ++++++++------ policyengine_uk/data/uprating_indices.yaml | 8 ++++---- .../gov/economic_assumptions/yoy_growth.yaml | 2 +- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/engineering/gambling-duties.md b/docs/engineering/gambling-duties.md index eff05b4cd4..6a5530aa29 100644 --- a/docs/engineering/gambling-duties.md +++ b/docs/engineering/gambling-duties.md @@ -295,8 +295,8 @@ Each series holds the base's growth per person: one plus the base's growth, over one plus `ons.population` growth, minus one, to six decimals. Dataset projection grows every household weight by `ons.population`, so each weighted input grows with its duty's national base. Past -`PROJECTION_END_YEAR`, Core scales each input by its index and each weight -by the population index, so the weighted input keeps that path. Grown by +`PROJECTION_END_YEAR`, policyengine-core scales each input by its index and +each weight by the population index, so the weighted input keeps that path. Grown by the base's own growth, the weighted bases would count population growth twice and exceed Table 4.10 by 1.54% in 2031-32. `uprate_council_tax` divides each council tax bill's growth by the growth in household weights @@ -309,9 +309,9 @@ for the same reason. | 2026-27 to 2031-32 | Forecast | OBR October 2026 Economic and Fiscal Outlook, receipts Table 4.10, "Betting and gaming duties forecast breakdown" | | 2032-33 on | None | The `average_earnings` long-run path, as for the other receipts series | -After 2031-32 each series divides the base's growth by one plus population -growth in every year to 2073, the last year `ons.population` states, and -the model holds both series' 2073 values after that. +After 2031-32 the importer divides the base's growth by one plus +population growth in every year to 2073, the last year `ons.population` +states, and the model holds both series' 2073 values after that. `policyengine_uk/utils/import_obr_forecasts.py` writes the series from `RECEIPTS_SERIES_SPECS`. It reads each rate from the model's parameter file @@ -449,7 +449,9 @@ does, `machine_games_duty` charges the standard rate on the whole standard-rate input, the rates before Budget 2026, on a base that grows by receipts over the rates after it. From 2027-28 the duty therefore falls short of Table 4.10 by 18.3%: the duty equals Table 4.10's line -times 19.35% / 23.68%. On one constructed household holding the 2024-25 national bases: +times 19.35% / 23.68%. Two constructed households, weighted 700 and 1,900, +hold the 2024-25 national bases between them, and their weighted duty +comes to: | £ million | 2027-28 | 2028-29 | 2029-30 | 2030-31 | 2031-32 | | --- | ---: | ---: | ---: | ---: | ---: | diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index 5fafbc82cb..bbb436d27e 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -170,10 +170,10 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.mixed_income: - self_employment_gross_receipts - trading_loss - owned_company_income_share -# Gambling duty bases grow per person with the OBR's receipts forecast for -# their duty, divided by the rates the model charges, so the model applies -# its own rate changes to them once, and household weights grow by -# population (#2738). +# Gambling duty bases grow per person, as household weights grow by +# population, with the OBR's receipts forecast for their duty divided by the +# rates the model charges, so the model applies its own rate changes to them +# once (#2738). gov.economic_assumptions.yoy_growth.obr.per_capita.gambling_duty_bases.general_betting: - betting_exchange_commission - financial_spread_betting_net_losses diff --git a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml index 1c710f658c..651b284593 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml +++ b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml @@ -998,7 +998,7 @@ obr: # growth) / (1 + population growth) - 1. Dataset projection grows # household weights by ons.population, so the weighted base grows with # the duty's base. After the OBR's forecast the base grows with - # average_earnings, or holds flat while its rate is zero, and the series + # average_earnings, or holds flat while its rate is zero, and the importer # divides that growth the same way in each year to ons.population's last. # policyengine_uk/utils/import_obr_forecasts.py writes every year after # the 2024 base year from HMRC's outturn, the EFO's receipts tables and From d08fbb6f1ff30b83099bcc6e9ab4b69f1bf44be4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 12:54:55 -0400 Subject: [PATCH 15/17] Divide a base's tail in receipts_tail, beside #2596's clearance tail render_receipts_growth_yaml now divides a per-head series' forecast years in the same block #2596 uses for its clearances, and receipts_tail divides a base's tail through per_head_tail. Merged with #2596, the two blocks conflict on one condition line instead of combining into a second division of the clearance series. growth_ratio sits beside line_rate, per_head_tail before receipts_tail, and the per-head property test beside the other gambling properties, so the merge conflicts only where the two branches differ. The written series do not change. test_population_vintage.py's docstring names the three per-capita series it checks. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_import_obr_forecasts.py | 118 +++++++++--------- .../tests/test_population_vintage.py | 6 +- policyengine_uk/utils/import_obr_forecasts.py | 108 ++++++++-------- 3 files changed, 119 insertions(+), 113 deletions(-) diff --git a/policyengine_uk/tests/test_import_obr_forecasts.py b/policyengine_uk/tests/test_import_obr_forecasts.py index a5e2b02fe6..3c32efc24f 100644 --- a/policyengine_uk/tests/test_import_obr_forecasts.py +++ b/policyengine_uk/tests/test_import_obr_forecasts.py @@ -2604,65 +2604,6 @@ def test_base_growth_is_receipts_growth_over_rate_growth(levels, schedules, fact assert growth_scaled[spec.path].values == pytest.approx(values, abs=1.5e-6) -def test_a_fall_that_rounds_the_index_to_zero_is_refused(): - """Receipts that fall from £1,000bn to £0.01bn as the rate rises from 1% - to 60% shrink the base by a factor of six million. To six decimals that - growth is -100%, which would hold the index at zero in every later year, - so the importer refuses it.""" - spec = next(spec for spec in GAMBLING_SPECS if spec.path[-1] == "remote_gaming") - workbook = make_receipts_xlsx( - gambling_levels=GAMBLING_LEVELS | {"remote_gaming": (1000.0, 0.01, 0.01)} - ) - schedule = ((date(2015, 1, 1), 0.01), (date(2026, 4, 1), 0.6)) - with pytest.MonkeyPatch.context() as patch: - patch.setattr(import_obr_forecasts, "parameter_values", lambda p: schedule) - with pytest.raises( - ValueError, - match="2026-27 growth of -1.0 takes the index to 0.0 at five decimals", - ): - compute_receipts_growth(workbook, b"", specs=(spec,)) - - -POSITIVE = st.one_of(LEVELS, st.floats(min_value=1e-9, max_value=1e-2, allow_nan=False)) - - -@settings(max_examples=60, deadline=None, derandomize=True) -@given( - levels=st.fixed_dictionaries( - {key: st.tuples(POSITIVE, POSITIVE, POSITIVE) for key in GAMBLING_LABELS} - ), - schedules=rate_schedules(), -) -def test_a_written_series_keeps_every_index_positive(levels, schedules): - """For any positive receipts, however small, and any dated rates: the - importer either refuses, when the written growth would round the model's - index to zero, or writes series whose index the model compounds to five - decimals stays above zero in every year.""" - workbook = make_receipts_xlsx(gambling_levels=levels) - with pytest.MonkeyPatch.context() as patch: - patch.setattr(import_obr_forecasts, "parameter_values", lambda p: schedules[p]) - try: - growth = compute_receipts_growth(workbook, b"", specs=GAMBLING_SPECS) - except ValueError as error: - event("refused") - assert "too small to grow from" in str(error) - assert any( - oracle_growth( - spec, - gambling_receipts(spec.path[-1], levels[spec.path[-1]]), - schedules, - )[2] - for spec in GAMBLING_SPECS - ) - return - event("written") - for series in growth.values(): - index = 1.0 - for year in sorted(series.values): - index = round(index * (1 + series.values[year]), 5) - assert index > 0 - - # Per head (#2738): dataset projection grows household weights by # ons.population, so each gambling series is its base's growth over one plus # population growth, and compounding it with population growth gives back @@ -2752,3 +2693,62 @@ def test_per_head_series_compound_to_the_aggregate_index(last, data): aggregate_index, rel=tolerance - 1 + 1e-12 ), (spec.path, year) assert render(rendered, current=current) == rendered + + +def test_a_fall_that_rounds_the_index_to_zero_is_refused(): + """Receipts that fall from £1,000bn to £0.01bn as the rate rises from 1% + to 60% shrink the base by a factor of six million. To six decimals that + growth is -100%, which would hold the index at zero in every later year, + so the importer refuses it.""" + spec = next(spec for spec in GAMBLING_SPECS if spec.path[-1] == "remote_gaming") + workbook = make_receipts_xlsx( + gambling_levels=GAMBLING_LEVELS | {"remote_gaming": (1000.0, 0.01, 0.01)} + ) + schedule = ((date(2015, 1, 1), 0.01), (date(2026, 4, 1), 0.6)) + with pytest.MonkeyPatch.context() as patch: + patch.setattr(import_obr_forecasts, "parameter_values", lambda p: schedule) + with pytest.raises( + ValueError, + match="2026-27 growth of -1.0 takes the index to 0.0 at five decimals", + ): + compute_receipts_growth(workbook, b"", specs=(spec,)) + + +POSITIVE = st.one_of(LEVELS, st.floats(min_value=1e-9, max_value=1e-2, allow_nan=False)) + + +@settings(max_examples=60, deadline=None, derandomize=True) +@given( + levels=st.fixed_dictionaries( + {key: st.tuples(POSITIVE, POSITIVE, POSITIVE) for key in GAMBLING_LABELS} + ), + schedules=rate_schedules(), +) +def test_a_written_series_keeps_every_index_positive(levels, schedules): + """For any positive receipts, however small, and any dated rates: the + importer either refuses, when the written growth would round the model's + index to zero, or writes series whose index the model compounds to five + decimals stays above zero in every year.""" + workbook = make_receipts_xlsx(gambling_levels=levels) + with pytest.MonkeyPatch.context() as patch: + patch.setattr(import_obr_forecasts, "parameter_values", lambda p: schedules[p]) + try: + growth = compute_receipts_growth(workbook, b"", specs=GAMBLING_SPECS) + except ValueError as error: + event("refused") + assert "too small to grow from" in str(error) + assert any( + oracle_growth( + spec, + gambling_receipts(spec.path[-1], levels[spec.path[-1]]), + schedules, + )[2] + for spec in GAMBLING_SPECS + ) + return + event("written") + for series in growth.values(): + index = 1.0 + for year in sorted(series.values): + index = round(index * (1 + series.values[year]), 5) + assert index > 0 diff --git a/policyengine_uk/tests/test_population_vintage.py b/policyengine_uk/tests/test_population_vintage.py index 62307a4679..13dbbb5ece 100644 --- a/policyengine_uk/tests/test_population_vintage.py +++ b/policyengine_uk/tests/test_population_vintage.py @@ -14,8 +14,10 @@ (28 May 2026), which from 2031-32 is the principal projection of the ONS 2024-based national population projections. -per_capita.*: (1 + aggregate growth) / (1 + ons.population) - 1, rounded to 4 -decimals, with calendar-year aggregates from the October 2026 EFO tables. +per_capita.gdp, mixed_income and non_labour_income: (1 + aggregate growth) / +(1 + ons.population) - 1, rounded to 4 decimals, with calendar-year +aggregates from the October 2026 EFO tables. The importer writes the +per_capita gambling series to six decimals (test_import_obr_forecasts.py). """ import pytest diff --git a/policyengine_uk/utils/import_obr_forecasts.py b/policyengine_uk/utils/import_obr_forecasts.py index 59331b4a5b..6ddd4cc9ee 100644 --- a/policyengine_uk/utils/import_obr_forecasts.py +++ b/policyengine_uk/utils/import_obr_forecasts.py @@ -933,6 +933,21 @@ def line_rate(spec: ReceiptsSeriesSpec, year: int) -> float: ) +def growth_ratio( + spec: ReceiptsSeriesSpec, levels: dict[int, float], year: int +) -> float: + """One plus a line's growth into ``year``, before rounding: the ratio of + its levels, or for a line with rates, of its bases, each year's receipts + over the rate the model charges (``line_rate``). A year whose rate, or + the year before's, is zero has no base, so the base holds flat.""" + if not spec.rates: + return levels[year] / levels[year - 1] + rate, previous_rate = line_rate(spec, year), line_rate(spec, year - 1) + if rate == 0 or previous_rate == 0: + return 1.0 + return (levels[year] / rate) / (levels[year - 1] / previous_rate) + + @dataclass(frozen=True) class ReceiptsLine: """A receipts line as one EFO publishes it: £bn by the calendar year in @@ -1236,21 +1251,6 @@ def compute_receipts_growth( return result -def growth_ratio( - spec: ReceiptsSeriesSpec, levels: dict[int, float], year: int -) -> float: - """One plus a line's growth into ``year``, before rounding: the ratio of - its levels, or for a line with rates, of its bases, each year's receipts - over the rate the model charges (``line_rate``). A year whose rate, or - the year before's, is zero has no base, so the base holds flat.""" - if not spec.rates: - return levels[year] / levels[year - 1] - rate, previous_rate = line_rate(spec, year), line_rate(spec, year - 1) - if rate == 0 or previous_rate == 0: - return 1.0 - return (levels[year] / rate) / (levels[year - 1] / previous_rate) - - def check_index(name: str, values: dict[int, float]) -> None: """Refuse growth that zeroes the index the model builds from it. @@ -1319,7 +1319,7 @@ def per_head_growth( + 0.0 for year in growth.values } - check_index(".".join(spec.path), values) + check_index(".".join(growth.spec.path), values) return replace(growth, values=values) @@ -1482,10 +1482,40 @@ def receipts_basis(spec: ReceiptsSeriesSpec | None) -> str: return "; base growth: receipts over the rate the model charges" +def per_head_tail( + tail: dict[int, float], comment: str, population: dict[int, float] +) -> tuple[dict[int, float], str]: + """A series' growth after the OBR's forecast per head, and its comment. + + In each year from the tail's first to the last that it or + ``ons.population`` states, (1 + the tail's growth) / (1 + population + growth) - 1, to six decimals, written where the value changes. The model + holds each series' last value, so the weighted total grows by the tail in + every later year too. + """ + first = min(tail) + yearly = { + year: round( + (1 + growth_in_effect(tail, year)) + / (1 + growth_in_effect(population, year)) + - 1, + 6, + ) + + 0.0 + for year in range(first, max(max(tail), max(population), first) + 1) + } + return { + year: value + for year, value in yearly.items() + if year == first or value != yearly[year - 1] + }, f"{comment}; per head: (1 + that growth) / (1 + ons.population growth) - 1" + + def receipts_tail(content: str, growth: ReceiptsGrowth) -> tuple[dict[int, float], str]: """A series' growth after the OBR's forecast, and its comment: the average_earnings path, held at zero in years a base's rate is zero in or - comes from.""" + comes from. A base under ``per_capita`` divides that growth by one plus + ``ons.population`` growth (``per_head_tail``).""" last = max(growth.values) path = average_earnings_path(content, last) spec = growth.spec @@ -1511,41 +1541,16 @@ def receipts_tail(content: str, growth: ReceiptsGrowth) -> tuple[dict[int, float if year == last + 1 or value != yearly[year - 1] } if all(value == 0 for value in yearly.values()): - return tail, "held flat, as the rate is zero" - if any(value == 0 for value in yearly.values()): - return tail, ( + comment = "held flat, as the rate is zero" + elif any(value == 0 for value in yearly.values()): + comment = ( "the average_earnings long-run path above, held flat where the rate is zero" ) - return tail, "the average_earnings long-run path above" - - -def per_head_tail( - tail: dict[int, float], comment: str, population: dict[int, float] -) -> tuple[dict[int, float], str]: - """A series' growth after the OBR's forecast per head, and its comment. - - In each year from the tail's first to the last that it or - ``ons.population`` states, (1 + the tail's growth) / (1 + population - growth) - 1, to six decimals, written where the value changes. The model - holds each series' last value, so the weighted total grows by the tail in - every later year too. - """ - first = min(tail) - yearly = { - year: round( - (1 + growth_in_effect(tail, year)) - / (1 + growth_in_effect(population, year)) - - 1, - 6, - ) - + 0.0 - for year in range(first, max(max(tail), max(population), first) + 1) - } - return { - year: value - for year, value in yearly.items() - if year == first or value != yearly[year - 1] - }, f"{comment}; per head: (1 + that growth) / (1 + ons.population growth) - 1" + else: + comment = "the average_earnings long-run path above" + if spec.per_head: + return per_head_tail(tail, comment, population_growth(content)) + return tail, comment def receipts_references( @@ -1618,11 +1623,10 @@ def render_receipts_growth_yaml( lines = content.split("\n") population = None for path, series in growth.items(): - tail, tail_comment = receipts_tail(content, series) if series.spec is not None and series.spec.per_head: population = population or population_growth(content) series = per_head_growth(series, population) - tail, tail_comment = per_head_tail(tail, tail_comment, population) + tail, tail_comment = receipts_tail(content, series) references = receipts_references(series, release, previous_release) start, end = _series_block(lines, ("obr", *path)) From f11dc5a586a73da83bfe4e4bff19159f501d4541 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 17:07:48 -0400 Subject: [PATCH 16/17] Separate the growth and index horizons in the gambling docs Give the importer's comment the file's real last year, say where the model's cumulative indices stop, flag the rounded operands in the equations, and give the OBR, the Treasury and Parliament the actions the text gave their tables. Co-Authored-By: Claude Opus 5.5 --- docs/engineering/gambling-duties.md | 80 ++++++++++--------- .../gov/economic_assumptions/README.md | 13 +-- policyengine_uk/utils/import_obr_forecasts.py | 7 +- 3 files changed, 55 insertions(+), 45 deletions(-) diff --git a/docs/engineering/gambling-duties.md b/docs/engineering/gambling-duties.md index 6a5530aa29..1cb86dc70b 100644 --- a/docs/engineering/gambling-duties.md +++ b/docs/engineering/gambling-duties.md @@ -296,9 +296,11 @@ over one plus `ons.population` growth, minus one, to six decimals. Dataset projection grows every household weight by `ons.population`, so each weighted input grows with its duty's national base. Past `PROJECTION_END_YEAR`, policyengine-core scales each input by its index and -each weight by the population index, so the weighted input keeps that path. Grown by -the base's own growth, the weighted bases would count population growth -twice and exceed Table 4.10 by 1.54% in 2031-32. `uprate_council_tax` +each weight by the population index, so the weighted input keeps that path +until `create_economic_assumption_indices` stops compounding, and both hold +from then on. Grown by the base's own growth, the weighted bases would count +population growth twice and exceed Table 4.10 by 1.54% in 2031-32. +`uprate_council_tax` divides each council tax bill's growth by the growth in household weights for the same reason. @@ -311,12 +313,14 @@ for the same reason. After 2031-32 the importer divides the base's growth by one plus population growth in every year to 2073, the last year `ons.population` -states, and the model holds both series' 2073 values after that. +states. Each series' last value stays in force after that, as population's +does. `policyengine_uk/utils/import_obr_forecasts.py` writes the series from `RECEIPTS_SERIES_SPECS`. It reads each rate from the model's parameter file -and averages it over the OBR's April-to-March year by days. Each duty's rate -comes out as follows: +and averages it over the OBR's April-to-March year by days. The equations +below round receipts and rates for display, and each result comes from the +unrounded figures. Each duty's rate comes out as follows: - **Remote Gaming Duty**: 21% to 31 March 2026, 40% from 1 April 2026. Receipts grow 108.3% into 2026-27 (£2,752.0 million against HMRC's @@ -334,11 +338,11 @@ comes out as follows: split gives spread betting no part, so it has no weight. - **Machine Games Duty**: from 1 April 2027 the model charges the higher-stake part of the standard-rate base at the higher rate (see - [higher-stake machines](#higher-stake-machines-from-1-april-2027)), and - Table 4.10's line includes that rise: the forecast incorporates the - measures announced since March (EFO paragraph 3.2), and the rise - "increases receipts by a further estimated £0.1 billion a year from - 2027-28" (paragraph 4.45). The series divides the line by a rate that + [higher-stake machines](#higher-stake-machines-from-1-april-2027)). The + OBR includes that rise and the measures announced since March in + Table 4.10 (EFO paragraph 3.2), and says the rise "increases receipts by a + further estimated £0.1 billion a year from 2027-28" (paragraph 4.45). The + series divides the line by a rate that charges the higher-stake share of the standard-rate base, 90.78% (see [the higher-stake share](#the-higher-stake-share)), at 25% from 2027-28. The importer weights each band by HMRC's 2024-25 liabilities over its @@ -350,12 +354,13 @@ comes out as follows: and the rates from their parameter files, so the series and the formula read the same dates and rates. - The OBR's policy Table 1.1 joins two costings as measure 56, under the - head Betting: the 25% rate and the move of liability to machine - suppliers from April 2028. The rate costing nets off a behavioural - response: an elasticity of −0.95, with a quarter of the stakes lost - moving to Remote Gaming Duty, and a general attrition assumption. The - supplier costing comes from reduced non-compliance and earlier payment + The OBR joins the Treasury's two costings as measure 56 in policy + Table 1.1, under the head Betting: the 25% rate and the move of liability + to machine suppliers from April 2028. The Treasury nets off a behavioural + response in its rate costing: an elasticity of −0.95, with a quarter of the + stakes lost moving to Remote Gaming Duty, and a general attrition + assumption. It estimates that moving liability to suppliers reduces + non-compliance and brings payments forward ([Budget 2026 policy costings, pages 67 and 68](https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=67)). Dividing the line by the rate that produced it keeps all of these in the base, as receipts over the rate does for every other duty. Taking the @@ -363,11 +368,12 @@ comes out as follows: rates restore none of them. - **Gaming Duty**: the household formula charges one effective rate, which does not change, so the base grows with receipts. The statutory bands do - not enter: any change in the duty's effective rate that the OBR's - forecast includes stays in the base. -- **Bingo Duty**: 10% until its abolition on 1 April 2026. A zero rate - leaves no base to divide out, so the base holds its 2025-26 level from - 2026-27. Per person the series falls as population grows, and the + not enter: any change in the duty's effective rate that the OBR + forecasts stays in the base. +- **Bingo Duty**: 10% until Parliament abolished the duty from 1 April 2026. + A zero rate leaves no base to divide out, so the importer holds the base + at its 2025-26 level while the rate stays at zero. Per person the series + falls as population grows, and the weighted base holds. Current law charges nothing whatever the base; a reform that charges Bingo Duty again charges that 2025-26 base in cash terms. @@ -376,23 +382,25 @@ comes out as follows: The series rest on these approximations: -- HMRC reports cash receipts. Table 4.10 measures accrued receipts: its - totals equal the "Betting and gaming taxes" row of the October 2026 Table - 4.1, whose note reads "All taxes are on an accrued basis." Growth into +- HMRC reports cash receipts, while the OBR forecasts accrued receipts in + Table 4.10: its totals equal the "Betting and gaming taxes" row of the + October 2026 Table 4.1, whose note reads "All taxes are on an accrued + basis." Growth into 2026-27 compares accrued 2026-27 receipts with cash 2025-26 receipts. - Model years run from 6 April and the OBR's from 1 April. A model year that takes in five days of a 1 April rate change charges them at the new - rate, so FY2025 Remote Gaming Duty (1.2%) and FY2026 General Betting Duty - (0.4%) sit slightly above the published receipts, and FY2025 Bingo Duty - (1.4%) slightly below. With the higher-stake input at its share, FY2026 - Machine Games Duty sits 0.31% above Table 4.10 for the same reason: the - OBR's 2026-27 charges no higher-stake machine at 25%, while model year - 2026 charges 1 to 5 April 2027 at 25%. -- Two lines fall where no rate changes. Table 4.10's Lottery Duty for - 2026-27, £830.5 million, falls short of HMRC's 2025-26 outturn of £980.1 - million by 15.3%. The March 2026 Table 3.10 forecast £1,008.6 million for the same - year; the October policy table holds no Lottery Duty measure, and the EFO - gives no reason in paragraph 4.45. Pool Betting Duty + rate, so FY2025 Remote Gaming Duty and FY2026 General Betting Duty exceed + the published receipts by 1.2% and 0.4%, and FY2025 Bingo Duty falls short + of them by 1.4%. With the higher-stake input at its share, FY2026 Machine + Games Duty exceeds Table 4.10 by 0.31% for the same reason: the OBR charges + no higher-stake machine at 25% in 2026-27, while the model charges 1 to + 5 April 2027 at 25% in model year 2026. +- Two lines fall where no rate changes. The OBR forecasts Lottery Duty + receipts of £830.5 million for 2026-27, which fall short of HMRC's 2025-26 + outturn of £980.1 million by 15.3%. It forecast £1,008.6 million for the + same year in March 2026's Table 3.10, lists no Lottery Duty measure in + October's policy table, and gives no reason in EFO paragraph 4.45. Pool + Betting Duty receipts fall from £8.1 million in 2024-25 to £4.0 million in 2025-26 and a forecast £2.8 million in 2026-27, so its index falls by 65%. diff --git a/policyengine_uk/parameters/gov/economic_assumptions/README.md b/policyengine_uk/parameters/gov/economic_assumptions/README.md index 1f20556af5..954aaae5a1 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/README.md +++ b/policyengine_uk/parameters/gov/economic_assumptions/README.md @@ -51,12 +51,13 @@ The seven `per_capita.gambling_duty_bases` series (`general_betting`, `lottery`) grow the gambling duty inputs (#2738). They grow a duty's base, not its receipts: each year's receipts divided by the rate the model charges that fiscal year. The model then applies its own rate changes, such as -Remote Gaming Duty's rise from 21% to 40% on 1 April 2026, once. The table -publishes forecasts only, so each series starts from HMRC's 2024-25 -receipts, the model's base year, and its 2025-26 outturn, which the importer -holds in `RECEIPTS_SERIES_SPECS`. A year whose rate is zero, Bingo Duty's -from 2026-27, holds the base flat, and after the forecast the base grows -with `average_earnings`. The importer writes each series per person, (1 + +Remote Gaming Duty's rise from 21% to 40% on 1 April 2026, once. The OBR +publishes only forecasts in Table 4.10, so each series starts from HMRC's +2024-25 receipts, the model's base year, and its 2025-26 outturn, which the +importer holds in `RECEIPTS_SERIES_SPECS`. The importer holds a base flat in +any year whose rate is zero, as for Bingo Duty from 2026-27 on, and after the +forecast it grows every other base with `average_earnings`. The importer +writes each series per person, (1 + base growth) / (1 + `ons.population` growth) - 1 to six decimals, in every year to the last that `ons.population` states. Dataset projection grows household weights by `ons.population`, so the weighted inputs grow with the diff --git a/policyengine_uk/utils/import_obr_forecasts.py b/policyengine_uk/utils/import_obr_forecasts.py index 6ddd4cc9ee..e9423bdd02 100644 --- a/policyengine_uk/utils/import_obr_forecasts.py +++ b/policyengine_uk/utils/import_obr_forecasts.py @@ -590,9 +590,10 @@ def update_forecast_gap_yaml( TABLE_NUMBER_RE = re.compile(r"\d+\.\d+") FOOTNOTE_RE = re.compile(r"(?<=[a-z])\d+$", re.IGNORECASE) VALUE_LINE_RE = re.compile(r"^\s*(\d{4})-\d{2}-\d{2}:") -# The last year yoy_growth.yaml states. create_economic_assumption_indices -# stops at or before it, so a series that stays positive to here stays -# positive in the model. +# The last year any growth series reaches: yoy_growth.yaml's last entries are +# for 2073, and lag_source_years runs a lagged series a year past its source. +# create_economic_assumption_indices stops at or before 2074, so a series that +# stays positive to here stays positive in the model. LAST_INDEX_YEAR = 2074 From 0037137adc62ebc58e1f974661faae567cd3d31c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 17:27:45 -0400 Subject: [PATCH 17/17] Name the government and the Treasury in the machines costing docs Co-Authored-By: Claude Opus 5.5 --- docs/engineering/gambling-duties.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/engineering/gambling-duties.md b/docs/engineering/gambling-duties.md index 1cb86dc70b..6aacbabec5 100644 --- a/docs/engineering/gambling-duties.md +++ b/docs/engineering/gambling-duties.md @@ -113,10 +113,11 @@ and [HMRC Notice 452](https://www.gov.uk/guidance/machine-games-duty-excise-noti ### Higher-stake machines from 1 April 2027 -Budget 2026 moves machines with a maximum charge to play of more than £1, or -a maximum prize of more than £100, from the standard rate of 20% to the higher -rate of 25% from 1 April 2027. Finance Bill 2026-27 will amend the stake and -prize thresholds that set each machine's type, and with it the rate +In Budget 2026 the government said it would move machines with a maximum +charge to play of more than £1, or a maximum prize of more than £100, from the +standard rate of 20% to the higher rate of 25% from 1 April 2027. In Finance +Bill 2026-27 the government will amend the stake and prize thresholds that set each machine's +type, and with it the rate ([OOTLAR paragraph 1.50](https://www.gov.uk/government/publications/budget-2026-overview-of-tax-legislation-and-rates-ootlar/budget-2026-overview-of-tax-legislation-and-rates-ootlar)). Today, Finance Act 2012 Schedule 24 paragraph 5(3) puts every machine outside the lower-rate class with a highest charge of at most £5 in the standard-rate @@ -166,10 +167,10 @@ change, or move its date to delay it. It shares fiscal-year segments with the rates, so a rate reform applies to the higher-stake part at whichever rate the switch selects. -The policy costing puts the measure at £135 million in 2027-28 and £130 -million a year to 2031-32 +The Treasury puts the measure at £135 million in 2027-28 and £130 million a +year to 2031-32 ([Budget 2026 policy costings, page 67](https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=67)). -That costing grows its base with the OBR's Machine Games Duty forecast and nets +The Treasury grows its base with the OBR's Machine Games Duty forecast and nets off a behavioural response: an elasticity of -0.95, a quarter of lost stakes moving to Remote Gaming Duty, general attrition and lower Corporation Tax. The model applies the rates statically to supplied takings and reproduces none of