From 875eae4d2d0cf91d6f3052d516cbb7f77539d645 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 28 Oct 2026 22:06:18 -0400 Subject: [PATCH 1/3] Count other investment income in market and HBAI income Income tax charges other_investment_income as savings income (taxable_savings_interest_income), but market_income, household_market_income and hbai_household_net_income left it out, so household net income carried the tax without the income. Add it to all three lists, with a YAML test that pins it against the same amount of bank interest. Fixes #2733. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2733.fixed.md | 1 + ...investment_income_in_household_income.yaml | 39 +++++++++++++++++++ .../income/hbai_household_net_income.py | 3 ++ .../income/household_market_income.py | 3 ++ .../household/income/market_income.py | 3 ++ 5 files changed, 49 insertions(+) create mode 100644 changelog.d/2733.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/household/income/other_investment_income_in_household_income.yaml diff --git a/changelog.d/2733.fixed.md b/changelog.d/2733.fixed.md new file mode 100644 index 0000000000..d74116d6d1 --- /dev/null +++ b/changelog.d/2733.fixed.md @@ -0,0 +1 @@ +Count `other_investment_income` in `market_income`, `household_market_income` and `hbai_household_net_income`. Income tax already charged it as savings income, so household net income carried the tax on it without the income. diff --git a/policyengine_uk/tests/policy/baseline/household/income/other_investment_income_in_household_income.yaml b/policyengine_uk/tests/policy/baseline/household/income/other_investment_income_in_household_income.yaml new file mode 100644 index 0000000000..59748095f8 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/household/income/other_investment_income_in_household_income.yaml @@ -0,0 +1,39 @@ +# Other investment income (National Savings, gilt and other securities +# interest) is savings income for income tax (taxable_savings_interest_income), +# so market income and HBAI income count it as they count bank interest. +# 2026-27: personal allowance £12,570, basic rate 20%, personal savings +# allowance £1,000 for a basic rate taxpayer, starting rate for savings +# £5,000 less non-savings income above the personal allowance (here nil), +# employee NI 8% from £12,570 to £50,270. + +- name: Other investment income counts in market and HBAI income + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 40_000 + other_investment_income: 2_000 + output: + # 40,000 + 2,000. + market_income: 42_000 + household_market_income: 42_000 + # (40,000 - 12,570) x 20% = 5,486, plus (2,000 - 1,000 PSA) x 20% = 200. + income_tax: 5_686 + # (40,000 - 12,570) x 8%. + national_insurance: 2_194.40 + # 42,000 - 5,686 - 2,194.40. + hbai_household_net_income: 34_119.60 + +- name: The same amount as bank interest gives the same incomes + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 40_000 + savings_interest_income: 2_000 + output: + market_income: 42_000 + household_market_income: 42_000 + income_tax: 5_686 + national_insurance: 2_194.40 + hbai_household_net_income: 34_119.60 diff --git a/policyengine_uk/variables/household/income/hbai_household_net_income.py b/policyengine_uk/variables/household/income/hbai_household_net_income.py index 604ec70078..7b09beb6f7 100644 --- a/policyengine_uk/variables/household/income/hbai_household_net_income.py +++ b/policyengine_uk/variables/household/income/hbai_household_net_income.py @@ -7,6 +7,9 @@ "employment_income", "self_employment_income", "savings_interest_income", + # National Savings, gilt and other securities interest, which income + # tax charges as savings income (taxable_savings_interest_income). + "other_investment_income", "tax_free_savings_income", "individual_savings_account_dividend_income", "dividend_income", diff --git a/policyengine_uk/variables/household/income/household_market_income.py b/policyengine_uk/variables/household/income/household_market_income.py index 1fd5503c79..766b11e901 100644 --- a/policyengine_uk/variables/household/income/household_market_income.py +++ b/policyengine_uk/variables/household/income/household_market_income.py @@ -7,6 +7,9 @@ "employment_income", "self_employment_income", "savings_interest_income", + # National Savings, gilt and other securities interest, which income + # tax charges as savings income (taxable_savings_interest_income). + "other_investment_income", "tax_free_savings_income", "individual_savings_account_dividend_income", "dividend_income", diff --git a/policyengine_uk/variables/household/income/market_income.py b/policyengine_uk/variables/household/income/market_income.py index f759e64f76..01aa0c71a0 100644 --- a/policyengine_uk/variables/household/income/market_income.py +++ b/policyengine_uk/variables/household/income/market_income.py @@ -16,6 +16,9 @@ def formula(person, period, parameters): "employment_income", "self_employment_income", "savings_interest_income", + # National Savings, gilt and other securities interest, which income + # tax charges as savings income (taxable_savings_interest_income). + "other_investment_income", "tax_free_savings_income", "individual_savings_account_dividend_income", "dividend_income", From 22ae4d34940817d39a718008d8a60ba2304c5cae Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 28 Oct 2026 23:29:50 -0400 Subject: [PATCH 2/3] List other investment income in the HBAI mapping table The HBAI validation page's investment income row now names other_investment_income, which hbai_household_net_income counts since the previous commit (Max's review of #2736, point 4). Co-Authored-By: Claude Opus 5.5 --- docs/book/validation/hbai.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/book/validation/hbai.md b/docs/book/validation/hbai.md index 30839e69d7..d493d606bb 100644 --- a/docs/book/validation/hbai.md +++ b/docs/book/validation/hbai.md @@ -26,7 +26,7 @@ The table below shows how the HBAI income components map to PolicyEngine variabl | Income received from dividends (from FYE 2022) |`dividend_income`| | State support - all benefits and tax credits |`hbai_benefits`| | Income from occupational and private pensions |`private_pension_income` (includes occupational pension income)| -| Investment income |`dividend_income`, `savings_interest_income`, `property_income_after_finance_costs`| +| Investment income |`dividend_income`, `savings_interest_income`, `other_investment_income`, `property_income_after_finance_costs`| | Maintenance payments |`maintenance_income`, `spousal_maintenance_income`| | Income from educational grants and scholarships (including, for students, student loans and parental contributions) |`student_payments`| | The cash value of certain forms of income in kind (free school meals, free school breakfast, free school milk, free school fruit and vegetables, Healthy Start vouchers and free TV licences for those aged 75 and over who receive Pension Credit) |`free_school_meals`, `free_school_fruit_veg`, `free_school_milk`, `healthy_start_vouchers`, `free_tv_licence_value`| From 9ec12db600baf84943bea45acd5a9799bd551cf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:09:55 +0000 Subject: [PATCH 3/3] Count other investment income in capital income, and test it in every income measure capital_income ("Income from savings or dividends") added bank interest, ISA interest and dividends but not other_investment_income, the National Savings, gilt and other securities interest that income tax charges as savings income. It now adds it next to savings_interest_income, as market and HBAI income do. No formula reads capital_income, so tax, benefits and the other income measures are unchanged. The legacy ISA contract case that sets other investment income now expects it in capital income. The YAML cases also check capital, gross and household net income, where the market income gap showed. A property test simulates each case with no interest, with bank interest and with the same amount as other investment income: tax and every household income measure treat the two alike, market and capital income rise by the interest, and net and HBAI income rise by the interest less its tax. Fixes #2745. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2745.fixed.md | 1 + ...investment_income_in_household_income.yaml | 18 ++- .../tests/test_isa_income_contract.py | 5 +- ...ment_income_household_income_properties.py | 137 ++++++++++++++++++ .../household/income/capital_income.py | 1 + 5 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 changelog.d/2745.fixed.md create mode 100644 policyengine_uk/tests/test_other_investment_income_household_income_properties.py diff --git a/changelog.d/2745.fixed.md b/changelog.d/2745.fixed.md new file mode 100644 index 0000000000..a28ad3209e --- /dev/null +++ b/changelog.d/2745.fixed.md @@ -0,0 +1 @@ +Count `other_investment_income` (National Savings, gilt and other securities interest, and trust interest) in `capital_income`, as market and HBAI income now do. Nothing reads `capital_income`, so income tax, benefits and the other income measures are unchanged. diff --git a/policyengine_uk/tests/policy/baseline/household/income/other_investment_income_in_household_income.yaml b/policyengine_uk/tests/policy/baseline/household/income/other_investment_income_in_household_income.yaml index 59748095f8..16ebc9e59f 100644 --- a/policyengine_uk/tests/policy/baseline/household/income/other_investment_income_in_household_income.yaml +++ b/policyengine_uk/tests/policy/baseline/household/income/other_investment_income_in_household_income.yaml @@ -1,12 +1,12 @@ # Other investment income (National Savings, gilt and other securities # interest) is savings income for income tax (taxable_savings_interest_income), -# so market income and HBAI income count it as they count bank interest. -# 2026-27: personal allowance £12,570, basic rate 20%, personal savings -# allowance £1,000 for a basic rate taxpayer, starting rate for savings +# so market, capital, gross, net and HBAI income count it as they count bank +# interest. 2026-27: personal allowance £12,570, basic rate 20%, personal +# savings allowance £1,000 for a basic rate taxpayer, starting rate for savings # £5,000 less non-savings income above the personal allowance (here nil), -# employee NI 8% from £12,570 to £50,270. +# employee NI 8% from £12,570 to £50,270, TV licence £180. -- name: Other investment income counts in market and HBAI income +- name: Other investment income counts in market, capital, net and HBAI income period: 2026 absolute_error_margin: 0.01 input: @@ -17,12 +17,17 @@ # 40,000 + 2,000. market_income: 42_000 household_market_income: 42_000 + household_gross_income: 42_000 + capital_income: 2_000 # (40,000 - 12,570) x 20% = 5,486, plus (2,000 - 1,000 PSA) x 20% = 200. income_tax: 5_686 # (40,000 - 12,570) x 8%. national_insurance: 2_194.40 # 42,000 - 5,686 - 2,194.40. hbai_household_net_income: 34_119.60 + # Less the 180 TV licence. Without the interest it is 32,139.60, so the + # interest raises net income by 2,000 less its 200 of tax. + household_net_income: 33_939.60 - name: The same amount as bank interest gives the same incomes period: 2026 @@ -34,6 +39,9 @@ output: market_income: 42_000 household_market_income: 42_000 + household_gross_income: 42_000 + capital_income: 2_000 income_tax: 5_686 national_insurance: 2_194.40 hbai_household_net_income: 34_119.60 + household_net_income: 33_939.60 diff --git a/policyengine_uk/tests/test_isa_income_contract.py b/policyengine_uk/tests/test_isa_income_contract.py index 81f5b62353..b5d9c7da98 100644 --- a/policyengine_uk/tests/test_isa_income_contract.py +++ b/policyengine_uk/tests/test_isa_income_contract.py @@ -150,7 +150,10 @@ def test_unmarked_legacy_conversion_preserves_cash_and_supplied_exemptions( assert value( simulation, "individual_savings_account_interest_income" ) == income.get("individual_savings_account_interest_income", 0) - assert value(simulation, "capital_income") == income["savings_interest_income"] + # Capital income is the legacy gross interest plus other investment income. + assert value(simulation, "capital_income") == ( + income["savings_interest_income"] + income.get("other_investment_income", 0) + ) assert value(simulation, "taxable_savings_interest_income") == expected_taxable pd.testing.assert_frame_equal(dataset.person, original) diff --git a/policyengine_uk/tests/test_other_investment_income_household_income_properties.py b/policyengine_uk/tests/test_other_investment_income_household_income_properties.py new file mode 100644 index 0000000000..337cd18195 --- /dev/null +++ b/policyengine_uk/tests/test_other_investment_income_household_income_properties.py @@ -0,0 +1,137 @@ +"""Property tests for other investment income in household income. + +Income tax charges other investment income (interest on National Savings and +Investments products, gilts and other securities, and from trusts) as savings +income through taxable_savings_interest_income, exactly as it charges bank +interest. HBAI counts investment income, which the Family Resources Survey +defines as the interest and dividends received on savings and investments, +NS&I products and gilts included. So every household income measure must +count a pound of other investment income as it counts a pound of bank +interest. Household net income used to take off the tax on it without adding +the income, so the interest lowered net income. + +Each generated case is simulated three times: with no interest, with the +amount as bank interest and with it as other investment income. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2027 +EMPLOYMENT_INCOME = [0, 8_000, 13_000, 30_000, 60_000, 110_000, 200_000] +# Every third pay level (£13,000 and £110,000) is in Scotland. +SCOTTISH = [i % 3 == 2 for i in range(len(EMPLOYMENT_INCOME))] +CASES = 21 +# Each case is simulated with no interest, bank interest and other investment +# income, in that order. +PEOPLE = 3 * CASES +VARIABLES = [ + "income_tax", + "market_income", + "capital_income", + "household_market_income", + "household_gross_income", + "household_net_income", + "hbai_household_net_income_before_reset_to_zero", +] + +amount = st.one_of( + st.floats(min_value=0, max_value=2_500), + st.floats(min_value=0, max_value=60_000), + st.sampled_from([0.0, 500.0, 1_000.0, 5_000.0]), +) +amounts = st.lists(amount, min_size=CASES, max_size=CASES) + + +def person_index(i): + return i % CASES % len(EMPLOYMENT_INCOME) + + +@pytest.fixture(scope="module") +def sim(): + people = { + f"person_{i}": { + "age": {YEAR: 40}, + "employment_income": {YEAR: EMPLOYMENT_INCOME[person_index(i)]}, + "savings_interest_income": {YEAR: 0}, + "other_investment_income": {YEAR: 0}, + } + for i in range(PEOPLE) + } + households = { + f"household_{i}": { + "members": [f"person_{i}"], + "region": {YEAR: "SCOTLAND" if SCOTTISH[person_index(i)] else "LONDON"}, + } + for i in range(PEOPLE) + } + return Simulation( + situation={ + "people": people, + "benunits": { + f"benunit_{i}": {"members": [f"person_{i}"]} for i in range(PEOPLE) + }, + "households": households, + } + ) + + +def run(sim, drawn): + interest = np.asarray(drawn, dtype=float) + none = np.zeros(CASES) + sim.set_input( + "savings_interest_income", YEAR, np.concatenate([none, interest, none]) + ) + sim.set_input( + "other_investment_income", YEAR, np.concatenate([none, none, interest]) + ) + sim.reset_calculations() + return interest, { + variable: sim.calculate(variable, YEAR).astype(float).reshape(3, CASES) + for variable in VARIABLES + } + + +def tolerance(*values): + """A penny plus float32 rounding on the amounts involved.""" + return 0.01 + 1e-6 * sum(np.abs(v) for v in values) + + +SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + database=None, + suppress_health_check=[HealthCheck.too_slow], +) + + +@SETTINGS +@given(amounts) +@example([2_000.0] * CASES) +def test_other_investment_income_counts_like_bank_interest(sim, drawn): + interest, r = run(sim, drawn) + # Income tax charges the two alike, and so does every income measure. + for variable, (_, bank, other) in r.items(): + assert (np.abs(other - bank) <= tolerance(bank, other)).all(), variable + # Market and capital income rise by the interest. + for variable in ("market_income", "capital_income", "household_market_income"): + none, _, other = r[variable] + change = other - none + assert (np.abs(change - interest) <= tolerance(other, interest)).all(), variable + # Net income rises by the interest less the tax on it: it never falls. + for variable in ( + "household_net_income", + "hbai_household_net_income_before_reset_to_zero", + ): + none, _, other = r[variable] + change = other - none + margin = tolerance(other, none) + assert (change >= -margin).all(), variable + assert (change <= interest + margin).all(), variable diff --git a/policyengine_uk/variables/household/income/capital_income.py b/policyengine_uk/variables/household/income/capital_income.py index e3c26b32df..f99320f9df 100644 --- a/policyengine_uk/variables/household/income/capital_income.py +++ b/policyengine_uk/variables/household/income/capital_income.py @@ -12,6 +12,7 @@ class capital_income(Variable): adds = [ "savings_interest_income", + "other_investment_income", "tax_free_savings_income", "dividend_income", "individual_savings_account_dividend_income",