From a14160a2ec408fec74bc1106511ba25e071a4626 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 10:48:54 -0400 Subject: [PATCH 1/2] Base Missouri's capital gains subtraction on the filers' Form 1040 line 7a RSMo 143.121.3(14)(a) subtracts income reported as a capital gain for federal income tax purposes; the Missouri DOR reads that as the positive amount on federal Form 1040 line 7a. The subtraction read the tax-unit net_capital_gains, which sums every member's Schedule D gains and losses, so a dependent's loss cut the parent's subtraction and a dependent's gain took a share of it. It also left out capital gain distributions, which line 7a includes. The tax-unit amount is now max(0, filer_loss_limited_net_capital_gains) times the rate, and the person allocation covers only the head and spouse with a positive gain of their own (distributions included), with the head taking it if no filer has one. Co-Authored-By: Claude Opus 5.5 --- ...l-gains-subtraction-filer-line-7a.fixed.md | 1 + ...mo_capital_gains_subtraction_invariants.py | 273 ++++++++++++++++++ .../mo/tax/income/income_tax/integration.yaml | 28 ++ .../mo_capital_gains_subtraction.yaml | 189 ++++++++++++ .../mo_capital_gains_subtraction.py | 17 +- .../mo_capital_gains_subtraction_person.py | 27 +- 6 files changed, 526 insertions(+), 9 deletions(-) create mode 100644 changelog.d/mo-capital-gains-subtraction-filer-line-7a.fixed.md create mode 100644 policyengine_us/tests/core/test_mo_capital_gains_subtraction_invariants.py diff --git a/changelog.d/mo-capital-gains-subtraction-filer-line-7a.fixed.md b/changelog.d/mo-capital-gains-subtraction-filer-line-7a.fixed.md new file mode 100644 index 00000000000..c74a27df1ff --- /dev/null +++ b/changelog.d/mo-capital-gains-subtraction-filer-line-7a.fixed.md @@ -0,0 +1 @@ +Base Missouri's capital gains subtraction on the filers' federal Form 1040 line 7a: leave a tax unit dependent's gains and losses out, count capital gain distributions, and give dependents no share. diff --git a/policyengine_us/tests/core/test_mo_capital_gains_subtraction_invariants.py b/policyengine_us/tests/core/test_mo_capital_gains_subtraction_invariants.py new file mode 100644 index 00000000000..bd365a58dd2 --- /dev/null +++ b/policyengine_us/tests/core/test_mo_capital_gains_subtraction_invariants.py @@ -0,0 +1,273 @@ +"""Invariants for Missouri's capital gains subtraction, RSMo 143.121.3(14)(a). + +From 2025 Missouri subtracts income reported as a capital gain for federal +income tax purposes. The Department of Revenue reads that as the positive +amount on federal Form 1040 line 7a: the filers' Schedule D net gain with +capital gain distributions, limited under 26 USC 1211(b), or the +distributions alone. It goes in the Form MO-A column of the spouse with the +gain. A tax unit dependent's gains and losses are on the dependent's own +return, so they never reach the filers' line 7a. + +Hypothesis draws batches of tax units (single, head of household with +dependents, joint with and without dependents) whose members have signed +short- and long-term gains and capital gain distributions, and a seeded +population of 200 units adds breadth. Each batch runs as one vectorized +simulation, and again with every dependent's capital inputs set to zero. +For every tax unit: + +1. Dependents' gains, losses and distributions never change + mo_capital_gains_subtraction, the head's and spouse's + mo_capital_gains_subtraction_person, mo_agi_subtractions or + mo_adjusted_gross_income. (mo_income_tax is left out: it deducts federal + income tax, which a dependent's gains still move through the Schedule D + tax worksheet until that is fixed separately.) +2. Differential: mo_capital_gains_subtraction equals an independent numpy + computation, rate x max(0, max(-loss limit, the head's and spouse's + short- and long-term gains + their distributions floored at zero)), with + the rate and loss limit taken from the statute rather than the model. +3. Accounting: the person amounts add up to the tax unit amount; dependents + get zero; each filer gets between zero and their own positive gain, and + a filer without a positive gain of their own gets zero. +4. For tax units without dependents and without distributions, the amount + equals the previous all-member formula, rate x max(0, net_capital_gains). +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 # dollars + +# RSMo 143.121.3(14)(a): 100% from tax year 2025, nothing before. +RATE = {2024: 0.0, 2025: 1.0, 2026: 1.0} +YEARS = sorted(RATE) +# 26 USC 1211(b)(1): $3,000, or $1,500 for a married individual filing +# separately. The draws below never file separately. +LOSS_LIMIT = 3_000.0 + +CAPITAL_INPUTS = [ + "short_term_capital_gains", + "long_term_capital_gains", + "non_sch_d_capital_gains", +] +OUTPUTS = [ + "mo_capital_gains_subtraction", + "mo_capital_gains_subtraction_person", + "mo_agi_subtractions", + "mo_adjusted_gross_income", + "net_capital_gains", + "is_tax_unit_dependent", +] + +signed = st.one_of( + st.just(0.0), + st.integers(-60_000, -1).map(float), + st.integers(1, 60_000).map(float), +) +# Form 1099-DIV box 2a amounts are not negative; the model floors a negative +# input at zero, so a few are drawn to check that. +distributions = st.one_of( + st.just(0.0), + st.integers(1, 20_000).map(float), + st.integers(-5_000, -1).map(float), +) + + +@st.composite +def capital(draw): + return { + "short_term_capital_gains": draw(signed), + "long_term_capital_gains": draw(signed), + "non_sch_d_capital_gains": draw(distributions), + } + + +@st.composite +def tax_units(draw): + kind = draw(st.sampled_from(["single", "hoh", "joint", "joint_dependents"])) + n_dependents = draw(st.integers(1, 2)) if kind in ("hoh", "joint_dependents") else 0 + return { + "head_wages": float(draw(st.integers(0, 120_000))), + "head": draw(capital()), + "spouse": draw(capital()) if kind.startswith("joint") else None, + "dependents": [draw(capital()) for _ in range(n_dependents)], + } + + +def _seeded_units(n=200, seed=20261006): + rng = np.random.default_rng(seed) + + def amounts(): + out = {} + for name in ["short_term_capital_gains", "long_term_capital_gains"]: + out[name] = float( + round( + rng.choice( + [0.0, rng.uniform(-60_000, 0), rng.uniform(0, 60_000)], + p=[0.4, 0.25, 0.35], + ) + ) + ) + out["non_sch_d_capital_gains"] = float( + round(rng.choice([0.0, rng.uniform(0, 20_000)], p=[0.6, 0.4])) + ) + return out + + units = [] + for _ in range(n): + kind = rng.choice(["single", "hoh", "joint", "joint_dependents"]) + n_dependents = ( + int(rng.integers(1, 3)) if kind in ("hoh", "joint_dependents") else 0 + ) + units.append( + { + "head_wages": float(round(rng.uniform(0, 120_000))), + "head": amounts(), + "spouse": amounts() if kind.startswith("joint") else None, + "dependents": [amounts() for _ in range(n_dependents)], + } + ) + return units + + +def _situation(units, year, zero_dependents): + people, tax_units_, households = {}, {}, {} + for i, u in enumerate(units): + head = f"head_{i}" + members = [head] + people[head] = { + "age": {year: 45}, + "is_tax_unit_head": {year: True}, + "employment_income": {year: u["head_wages"]}, + **{name: {year: value} for name, value in u["head"].items()}, + } + if u["spouse"] is not None: + spouse = f"spouse_{i}" + members.append(spouse) + people[spouse] = { + "age": {year: 44}, + "is_tax_unit_spouse": {year: True}, + **{name: {year: value} for name, value in u["spouse"].items()}, + } + factor = 0.0 if zero_dependents else 1.0 + for j, amounts in enumerate(u["dependents"]): + child = f"child_{i}_{j}" + members.append(child) + people[child] = { + "age": {year: 10 + j}, + "is_tax_unit_dependent": {year: True}, + **{name: {year: factor * value} for name, value in amounts.items()}, + } + tax_units_[f"tu_{i}"] = {"members": members} + households[f"hh_{i}"] = {"members": members, "state_code": {year: "MO"}} + return {"people": people, "tax_units": tax_units_, "households": households} + + +def _run(units, year, zero_dependents=False): + sim = Simulation(situation=_situation(units, year, zero_dependents)) + out = {v: np.asarray(sim.calculate(v, year), dtype=float) for v in OUTPUTS} + out["person_tax_unit"] = sim.populations["tax_unit"].members_entity_id + return out + + +def _by_person(units, key): + """Person-level input arrays in simulation member order.""" + rows = [] + for u in units: + rows.append((u["head"], False)) + if u["spouse"] is not None: + rows.append((u["spouse"], False)) + rows += [(amounts, True) for amounts in u["dependents"]] + if key == "is_dependent": + return np.array([dependent for _, dependent in rows]) + return np.array([amounts[key] for amounts, _ in rows]) + + +def _unit_sum(run, person_values): + return np.bincount( + run["person_tax_unit"], + weights=person_values, + minlength=len(run["mo_capital_gains_subtraction"]), + ) + + +def _check(units, year): + base = _run(units, year) + dependent = _by_person(units, "is_dependent") + filer = ~dependent + assert np.array_equal(base["is_tax_unit_dependent"].astype(bool), dependent) + rate = RATE[year] + + # 1. Dependents never move the filers' amounts. + no_dependents = _run(units, year, zero_dependents=True) + for name in ["mo_capital_gains_subtraction"]: + np.testing.assert_allclose( + base[name], no_dependents[name], atol=TOLERANCE, err_msg=name + ) + for name in [ + "mo_capital_gains_subtraction_person", + "mo_agi_subtractions", + "mo_adjusted_gross_income", + ]: + np.testing.assert_allclose( + base[name][filer], + no_dependents[name][filer], + atol=TOLERANCE, + err_msg=name, + ) + + # 2. Differential against numpy. + gains = _by_person(units, "short_term_capital_gains") + _by_person( + units, "long_term_capital_gains" + ) + own = gains + np.maximum(0, _by_person(units, "non_sch_d_capital_gains")) + line_7a = np.maximum(-LOSS_LIMIT, _unit_sum(base, filer * own)) + expected = rate * np.maximum(0, line_7a) + subtraction = base["mo_capital_gains_subtraction"] + np.testing.assert_allclose(subtraction, expected, atol=TOLERANCE) + + # 3. Accounting. + person = base["mo_capital_gains_subtraction_person"] + np.testing.assert_allclose(_unit_sum(base, person), subtraction, atol=TOLERANCE) + assert np.all(person[dependent] == 0) + assert np.all(person >= -TOLERANCE) + assert np.all(person <= rate * np.maximum(0, own) + TOLERANCE) + assert np.all(person[filer & (own <= 0)] == 0) + + # 4. Unchanged for units without dependents and distributions. + unit_has_dependent = _unit_sum(base, dependent.astype(float)) > 0 + unit_has_distributions = ( + _unit_sum(base, np.abs(_by_person(units, "non_sch_d_capital_gains"))) > 0 + ) + unchanged = ~unit_has_dependent & ~unit_has_distributions + old = rate * np.maximum(0, base["net_capital_gains"]) + np.testing.assert_allclose(subtraction[unchanged], old[unchanged], atol=TOLERANCE) + return base, unit_has_dependent + + +# A batch's cost is mostly per-variable overhead, so each example is a large +# batch and there are few examples. +SETTINGS = dict( + max_examples=6, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given(st.sampled_from(YEARS), st.lists(tax_units(), min_size=5, max_size=30)) +def test_mo_capital_gains_subtraction_invariants(year, units): + _check(units, year) + + +@pytest.mark.parametrize("year", YEARS) +def test_seeded_population(year): + base, unit_has_dependent = _check(_seeded_units(), year) + # The draw exercises the cases the fix is about. + assert unit_has_dependent.sum() > 50 + if RATE[year] > 0: + assert (base["mo_capital_gains_subtraction"] > 0).sum() > 50 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/income_tax/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/income_tax/integration.yaml index 67046abc88a..8c1e56ae904 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/income_tax/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/income_tax/integration.yaml @@ -205,3 +205,31 @@ mo_wftc_liability_cap: 0 mo_wftc: 0 mo_income_tax: -573.84 + +- name: MO head's AGI keeps the full capital gains subtraction when a dependent has a capital loss + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 40_000 + long_term_capital_gains: 10_000 + child: + age: 10 + is_tax_unit_dependent: true + short_term_capital_gains: -4_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: MO + output: + # Federal AGI = 40,000 wages + 10,000 line 7a; the child's loss is on the + # child's own return. MO AGI = 50,000 - 10,000 subtraction = 40,000. + # Pooling the child's loss gave a 6,000 subtraction and MO AGI 44,000. + adjusted_gross_income: 50_000 + mo_capital_gains_subtraction: 10_000 + mo_adjusted_gross_income: [40_000, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction.yaml b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction.yaml index d9bb8fca256..58ff296191f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction.yaml @@ -109,3 +109,192 @@ output: mo_capital_gains_subtraction: 8_000 mo_capital_gains_subtraction_person: [8_000, 0] + +- name: A dependent's capital loss does not reduce the head's subtraction + period: 2025 + absolute_error_margin: 0 + input: + people: + head: + age: 40 + long_term_capital_gains: 10_000 + child: + age: 10 + is_tax_unit_dependent: true + short_term_capital_gains: -4_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: MO + output: + # The head's Form 1040 line 7a is 10,000; the child's loss is on the + # child's own return. Pooled, the unit nets to 6,000. + mo_capital_gains_subtraction: 10_000 + mo_capital_gains_subtraction_person: [10_000, 0] + +- name: A dependent's capital gain gets no share of the subtraction + period: 2025 + absolute_error_margin: 0 + input: + people: + head: + age: 40 + long_term_capital_gains: 10_000 + child: + age: 10 + is_tax_unit_dependent: true + long_term_capital_gains: 5_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: MO + output: + # Line 7a = 10,000, all the head's. Pooled, the unit total was 15,000, + # with 5,000 allocated to the child. + mo_capital_gains_subtraction: 10_000 + mo_capital_gains_subtraction_person: [10_000, 0] + +- name: Joint filers net their gains, and a dependent's gain stays out + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + long_term_capital_gains: 10_000 + spouse: + age: 40 + short_term_capital_gains: -2_000 + child: + age: 10 + is_tax_unit_dependent: true + long_term_capital_gains: 5_000 + tax_units: + tax_unit: + members: [head, spouse, child] + households: + household: + members: [head, spouse, child] + state_code: MO + output: + # Line 7a = 10,000 - 2,000 = 8,000, entered for the head, the spouse + # with the gain. Pooled: 13,000, with 10/15 x 13,000 = 8,666.67 to the + # head and 4,333.33 to the child. + mo_capital_gains_subtraction: 8_000 + mo_capital_gains_subtraction_person: [8_000, 0, 0] + +- name: Capital gain distributions are reported as capital gain + period: 2025 + absolute_error_margin: 0 + input: + people: + person1: + age: 40 + non_sch_d_capital_gains: 3_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MO + output: + # Form 1040 line 7a holds capital gain distributions reported without + # Schedule D, so line 7a = 3,000. + mo_capital_gains_subtraction: 3_000 + mo_capital_gains_subtraction_person: [3_000] + +- name: Capital gain distributions net with a capital loss on Schedule D + period: 2025 + absolute_error_margin: 0 + input: + people: + person1: + age: 40 + long_term_capital_gains: -1_000 + non_sch_d_capital_gains: 4_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MO + output: + # Schedule D line 13 takes the 4,000 of distributions, so line 16 and + # Form 1040 line 7a = -1,000 + 4,000 = 3,000. + mo_capital_gains_subtraction: 3_000 + mo_capital_gains_subtraction_person: [3_000] + +- name: A net capital loss gives no subtraction + period: 2025 + absolute_error_margin: 0 + input: + people: + person1: + age: 40 + short_term_capital_gains: -10_000 + non_sch_d_capital_gains: 2_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MO + output: + # Line 7a = max(-3,000, -10,000 + 2,000) = -3,000, a loss. + mo_capital_gains_subtraction: 0 + mo_capital_gains_subtraction_person: [0] + +- name: A dependent's capital gain distributions stay off the filer's subtraction + period: 2025 + absolute_error_margin: 0 + input: + people: + head: + age: 40 + long_term_capital_gains: 2_000 + child: + age: 10 + is_tax_unit_dependent: true + non_sch_d_capital_gains: 5_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: MO + output: + mo_capital_gains_subtraction: 2_000 + mo_capital_gains_subtraction_person: [2_000, 0] + +- name: No subtraction before 2025, with or without a dependent's loss + period: 2024 + absolute_error_margin: 0 + input: + people: + head: + age: 40 + long_term_capital_gains: 10_000 + child: + age: 10 + is_tax_unit_dependent: true + short_term_capital_gains: -4_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: MO + output: + mo_capital_gains_subtraction: 0 + mo_capital_gains_subtraction_person: [0, 0] diff --git a/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction.py b/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction.py index 908a2edb367..8caa2ebe9a1 100644 --- a/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction.py +++ b/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction.py @@ -7,15 +7,28 @@ class mo_capital_gains_subtraction(Variable): label = "Missouri capital gains subtraction" unit = USD definition_period = YEAR + documentation = ( + "Income reported as a capital gain for federal income tax purposes: " + "the positive amount on the filers' federal Form 1040 line 7a, which " + "is their Schedule D net capital gain with capital gain distributions " + "(or the distributions alone). A tax unit dependent's gains and " + "losses are on the dependent's own return, so they are left out, as " + "they are left out of federal adjusted gross income." + ) reference = ( "https://www.revisor.mo.gov/main/OneSection.aspx?section=143.121&bid=57543", - "https://dor.mo.gov/faq/taxation/individual/capital-gains-subtraction.html", # MO form MO-A + "https://dor.mo.gov/faq/taxation/individual/capital-gains-subtraction.html", + "https://dor.mo.gov/forms/MO-1040%20Instructions_2025.pdf#page=16", ) defined_for = StateCode.MO def formula(tax_unit, period, parameters): + # RSMo 143.121.3(14)(a) subtracts income reported as a capital gain + # for federal income tax purposes, to the extent included in federal + # AGI. The Department of Revenue reads that as Form 1040 line 7a, and + # says capital losses do not qualify. federally_reported_capital_gains = max_( - 0, tax_unit("net_capital_gains", period) + 0, tax_unit("filer_loss_limited_net_capital_gains", period) ) p = parameters(period).gov.states.mo.tax.income.subtractions.net_capital_gain return federally_reported_capital_gains * p.rate diff --git a/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction_person.py b/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction_person.py index 31b4b5435e9..7bcafc6c66b 100644 --- a/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction_person.py +++ b/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction_person.py @@ -17,12 +17,25 @@ def formula(person, period, parameters): tax_unit = person.tax_unit # Get the tax unit level capital gains subtraction tax_unit_subtraction = tax_unit("mo_capital_gains_subtraction", period) - # Allocate only across people with positive capital gains so - # spouse-level capital losses do not over-allocate the unit total. - person_positive_cg = max_(0, person("capital_gains", period)) + # The Department of Revenue enters the Form 1040 line 7a amount in the + # Form MO-A column of the spouse with the gain. Allocate only across + # the head and spouse with a positive gain of their own, capital gain + # distributions included, so a spouse's capital loss does not + # over-allocate the unit total. A tax unit dependent's gains are on + # the dependent's own return and get no share. + not_dependent = ~person("is_tax_unit_dependent", period) + own_gains = person("capital_gains", period) + max_( + 0, person("non_sch_d_capital_gains", period) + ) + person_positive_cg = not_dependent * max_(0, own_gains) tax_unit_positive_cg = tax_unit.sum(person_positive_cg) - # Use mask to avoid divide-by-zero, default to zero allocation - person_share = np.zeros_like(tax_unit_positive_cg) - mask = tax_unit_positive_cg > 0 - person_share[mask] = person_positive_cg[mask] / tax_unit_positive_cg[mask] + # If no head or spouse has a positive gain of their own (only possible + # when the tax unit amount is supplied as an input), the head takes it. + default_share = where(person("is_tax_unit_head", period), 1.0, 0.0) + person_share = np.divide( + person_positive_cg, + tax_unit_positive_cg, + out=default_share, + where=tax_unit_positive_cg > 0, + ) return person_share * tax_unit_subtraction From f0092a2886206b0189bf38a4fbc1f9d7c37b4518 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 11:07:31 -0400 Subject: [PATCH 2/2] Use federally included filer gains for Missouri subtraction --- ...l-gains-subtraction-filer-line-7a.fixed.md | 2 +- ...mo_capital_gains_subtraction_invariants.py | 108 ++++++++++++---- .../mo_capital_gains_subtraction.yaml | 121 ++++++++++++++++++ .../mo_capital_gains_subtraction.py | 13 +- .../mo_capital_gains_subtraction_person.py | 7 +- 5 files changed, 217 insertions(+), 34 deletions(-) diff --git a/changelog.d/mo-capital-gains-subtraction-filer-line-7a.fixed.md b/changelog.d/mo-capital-gains-subtraction-filer-line-7a.fixed.md index c74a27df1ff..51ffe0dc4f0 100644 --- a/changelog.d/mo-capital-gains-subtraction-filer-line-7a.fixed.md +++ b/changelog.d/mo-capital-gains-subtraction-filer-line-7a.fixed.md @@ -1 +1 @@ -Base Missouri's capital gains subtraction on the filers' federal Form 1040 line 7a: leave a tax unit dependent's gains and losses out, count capital gain distributions, and give dependents no share. +Base Missouri's capital gains subtraction on the filers' gains included in federal AGI: exclude dependents and supplied tax-unit aggregates, include capital gain distributions and directly supplied person gains, and allocate only to filers with positive gains. diff --git a/policyengine_us/tests/core/test_mo_capital_gains_subtraction_invariants.py b/policyengine_us/tests/core/test_mo_capital_gains_subtraction_invariants.py index bd365a58dd2..41ffd3b1cb3 100644 --- a/policyengine_us/tests/core/test_mo_capital_gains_subtraction_invariants.py +++ b/policyengine_us/tests/core/test_mo_capital_gains_subtraction_invariants.py @@ -8,27 +8,30 @@ gain. A tax unit dependent's gains and losses are on the dependent's own return, so they never reach the filers' line 7a. -Hypothesis draws batches of tax units (single, head of household with -dependents, joint with and without dependents) whose members have signed -short- and long-term gains and capital gain distributions, and a seeded -population of 200 units adds breadth. Each batch runs as one vectorized -simulation, and again with every dependent's capital inputs set to zero. +Hypothesis draws batches of tax units (single, married filing separately, +head of household with dependents, joint with and without dependents) whose +members have signed short- and long-term gains and capital gain +distributions. A seeded population of 200 units adds breadth. Each batch +runs as one vectorized simulation, and again with every dependent's capital +inputs set to zero. For every tax unit: 1. Dependents' gains, losses and distributions never change mo_capital_gains_subtraction, the head's and spouse's mo_capital_gains_subtraction_person, mo_agi_subtractions or - mo_adjusted_gross_income. (mo_income_tax is left out: it deducts federal - income tax, which a dependent's gains still move through the Schedule D - tax worksheet until that is fixed separately.) + mo_adjusted_gross_income. 2. Differential: mo_capital_gains_subtraction equals an independent numpy - computation, rate x max(0, max(-loss limit, the head's and spouse's - short- and long-term gains + their distributions floored at zero)), with - the rate and loss limit taken from the statute rather than the model. -3. Accounting: the person amounts add up to the tax unit amount; dependents + computation, rate x max(0, the head's and spouse's short- and long-term + gains + their distributions floored at zero), with the rate taken from + the statute rather than the model. +3. Federal AGI equals wages plus the filers' net capital amount, with a net + loss limited to $3,000 ($1,500 when filing separately). This checks the + loss limit before Missouri's zero floor, where it has an effect, and + confirms the subtraction is capital gain included in federal AGI. +4. Accounting: the person amounts add up to the tax unit amount; dependents get zero; each filer gets between zero and their own positive gain, and a filer without a positive gain of their own gets zero. -4. For tax units without dependents and without distributions, the amount +5. For tax units without dependents and without distributions, the amount equals the previous all-member formula, rate x max(0, net_capital_gains). """ @@ -39,14 +42,22 @@ from policyengine_us import Simulation -TOLERANCE = 0.01 # dollars +# The largest generated federal AGI is $400,000, where float32 spacing is +# $0.03125; allow one rounding step plus person-allocation rounding. +TOLERANCE = 0.05 # dollars # RSMo 143.121.3(14)(a): 100% from tax year 2025, nothing before. RATE = {2024: 0.0, 2025: 1.0, 2026: 1.0} YEARS = sorted(RATE) -# 26 USC 1211(b)(1): $3,000, or $1,500 for a married individual filing -# separately. The draws below never file separately. -LOSS_LIMIT = 3_000.0 +# 26 USC 1211(b)(1): $3,000, or $1,500 when married filing separately. +LOSS_LIMIT = {"SEPARATE": 1_500.0, "OTHER": 3_000.0} +FILING_STATUS = { + "single": "SINGLE", + "separate": "SEPARATE", + "hoh": "HEAD_OF_HOUSEHOLD", + "joint": "JOINT", + "joint_dependents": "JOINT", +} CAPITAL_INPUTS = [ "short_term_capital_gains", @@ -58,6 +69,7 @@ "mo_capital_gains_subtraction_person", "mo_agi_subtractions", "mo_adjusted_gross_income", + "adjusted_gross_income", "net_capital_gains", "is_tax_unit_dependent", ] @@ -87,9 +99,10 @@ def capital(draw): @st.composite def tax_units(draw): - kind = draw(st.sampled_from(["single", "hoh", "joint", "joint_dependents"])) + kind = draw(st.sampled_from(list(FILING_STATUS))) n_dependents = draw(st.integers(1, 2)) if kind in ("hoh", "joint_dependents") else 0 return { + "filing_status": FILING_STATUS[kind], "head_wages": float(draw(st.integers(0, 120_000))), "head": draw(capital()), "spouse": draw(capital()) if kind.startswith("joint") else None, @@ -118,18 +131,35 @@ def amounts(): units = [] for _ in range(n): - kind = rng.choice(["single", "hoh", "joint", "joint_dependents"]) + kind = rng.choice(list(FILING_STATUS)) n_dependents = ( int(rng.integers(1, 3)) if kind in ("hoh", "joint_dependents") else 0 ) units.append( { + "filing_status": FILING_STATUS[kind], "head_wages": float(round(rng.uniform(0, 120_000))), "head": amounts(), "spouse": amounts() if kind.startswith("joint") else None, "dependents": [amounts() for _ in range(n_dependents)], } ) + # Exercise both federal loss limits in the existing batch, independently + # of which signed amounts the random population happens to draw. + for unit, filing_status in zip(units, ["SINGLE", "SEPARATE"]): + unit.update( + { + "filing_status": filing_status, + "head_wages": 50_000.0, + "head": { + "short_term_capital_gains": -6_000.0, + "long_term_capital_gains": 0.0, + "non_sch_d_capital_gains": 0.0, + }, + "spouse": None, + "dependents": [], + } + ) return units @@ -161,7 +191,10 @@ def _situation(units, year, zero_dependents): "is_tax_unit_dependent": {year: True}, **{name: {year: factor * value} for name, value in amounts.items()}, } - tax_units_[f"tu_{i}"] = {"members": members} + tax_units_[f"tu_{i}"] = { + "members": members, + "filing_status": {year: u["filing_status"]}, + } households[f"hh_{i}"] = {"members": members, "state_code": {year: "MO"}} return {"people": people, "tax_units": tax_units_, "households": households} @@ -205,7 +238,7 @@ def _check(units, year): no_dependents = _run(units, year, zero_dependents=True) for name in ["mo_capital_gains_subtraction"]: np.testing.assert_allclose( - base[name], no_dependents[name], atol=TOLERANCE, err_msg=name + base[name], no_dependents[name], rtol=0, atol=TOLERANCE, err_msg=name ) for name in [ "mo_capital_gains_subtraction_person", @@ -215,6 +248,7 @@ def _check(units, year): np.testing.assert_allclose( base[name][filer], no_dependents[name][filer], + rtol=0, atol=TOLERANCE, err_msg=name, ) @@ -224,27 +258,45 @@ def _check(units, year): units, "long_term_capital_gains" ) own = gains + np.maximum(0, _by_person(units, "non_sch_d_capital_gains")) - line_7a = np.maximum(-LOSS_LIMIT, _unit_sum(base, filer * own)) - expected = rate * np.maximum(0, line_7a) + filer_net_gains = _unit_sum(base, filer * own) + expected = rate * np.maximum(0, filer_net_gains) subtraction = base["mo_capital_gains_subtraction"] - np.testing.assert_allclose(subtraction, expected, atol=TOLERANCE) + np.testing.assert_allclose(subtraction, expected, rtol=0, atol=TOLERANCE) - # 3. Accounting. + # 3. The federal loss limit matters for negative net gains before the + # Missouri zero floor. With wages and capital amounts as the only inputs, + # federal AGI independently establishes the gain included in AGI. + loss_limits = np.array( + [LOSS_LIMIT.get(u["filing_status"], LOSS_LIMIT["OTHER"]) for u in units] + ) + line_7a = np.maximum(-loss_limits, filer_net_gains) + wages = np.array([u["head_wages"] for u in units]) + federal_agi = base["adjusted_gross_income"] + np.testing.assert_allclose(federal_agi, wages + line_7a, rtol=0, atol=TOLERANCE) + np.testing.assert_allclose( + subtraction, rate * np.maximum(0, federal_agi - wages), rtol=0, atol=TOLERANCE + ) + + # 4. Accounting. person = base["mo_capital_gains_subtraction_person"] - np.testing.assert_allclose(_unit_sum(base, person), subtraction, atol=TOLERANCE) + np.testing.assert_allclose( + _unit_sum(base, person), subtraction, rtol=0, atol=TOLERANCE + ) assert np.all(person[dependent] == 0) assert np.all(person >= -TOLERANCE) assert np.all(person <= rate * np.maximum(0, own) + TOLERANCE) assert np.all(person[filer & (own <= 0)] == 0) - # 4. Unchanged for units without dependents and distributions. + # 5. Unchanged for units without dependents and distributions. unit_has_dependent = _unit_sum(base, dependent.astype(float)) > 0 unit_has_distributions = ( _unit_sum(base, np.abs(_by_person(units, "non_sch_d_capital_gains"))) > 0 ) unchanged = ~unit_has_dependent & ~unit_has_distributions old = rate * np.maximum(0, base["net_capital_gains"]) - np.testing.assert_allclose(subtraction[unchanged], old[unchanged], atol=TOLERANCE) + np.testing.assert_allclose( + subtraction[unchanged], old[unchanged], rtol=0, atol=TOLERANCE + ) return base, unit_has_dependent diff --git a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction.yaml b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction.yaml index 58ff296191f..ec08d6706ac 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction.yaml @@ -298,3 +298,124 @@ output: mo_capital_gains_subtraction: 0 mo_capital_gains_subtraction_person: [0, 0] + +- name: A supplied aggregate gain outside federal AGI gives no subtraction + period: 2025 + absolute_error_margin: 0 + input: + people: + head: + age: 40 + employment_income: 50_000 + tax_units: + tax_unit: + members: [head] + net_capital_gains: 20_000 + households: + household: + members: [head] + state_code: MO + output: + # A tax-unit aggregate input does not enter federal AGI. RSMo 143.121.3 + # allows subtraction only to the extent included in federal AGI. + adjusted_gross_income: 50_000 + mo_capital_gains_subtraction: 0 + mo_capital_gains_subtraction_person: [0] + mo_adjusted_gross_income: [50_000] + +- name: A supplied aggregate does not replace the filer's federally included gain + period: 2025 + absolute_error_margin: 0 + input: + people: + head: + age: 40 + employment_income: 50_000 + long_term_capital_gains: 1_000 + tax_units: + tax_unit: + members: [head] + net_capital_gains: 20_000 + households: + household: + members: [head] + state_code: MO + output: + # Only the person's $1,000 gain reaches federal AGI, not the $20,000 + # supplied aggregate. MO subtracts the federally included $1,000. + adjusted_gross_income: 51_000 + mo_capital_gains_subtraction: 1_000 + mo_capital_gains_subtraction_person: [1_000] + mo_adjusted_gross_income: [50_000] + +- name: A directly supplied person gain included in federal AGI is subtracted + period: 2025 + absolute_error_margin: 0 + input: + people: + head: + age: 40 + employment_income: 50_000 + capital_gains: 2_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: MO + output: + # Federal gross income reads person-level capital_gains, including an + # explicit input. Missouri uses the same source and subtracts $2,000. + adjusted_gross_income: 52_000 + mo_capital_gains_subtraction: 2_000 + mo_capital_gains_subtraction_person: [2_000] + mo_adjusted_gross_income: [50_000] + +- name: A large dependent gain does not round away the filer's small gain + period: 2025 + absolute_error_margin: 0 + input: + people: + head: + age: 40 + short_term_capital_gains: 1 + child: + age: 10 + is_tax_unit_dependent: true + long_term_capital_gains: 67_108_864 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: MO + output: + # At 2**26 float32 spacing is $8. Pooling then removing the dependent + # can lose the filer's $1; summing filers directly preserves it. + mo_capital_gains_subtraction: 1 + mo_capital_gains_subtraction_person: [1, 0] + +- name: A large dependent gain does not create a subtraction for a filer with a loss + period: 2025 + absolute_error_margin: 0 + input: + people: + head: + age: 40 + short_term_capital_gains: -1 + child: + age: 10 + is_tax_unit_dependent: true + long_term_capital_gains: 67_108_864 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: MO + output: + mo_capital_gains_subtraction: 0 + mo_capital_gains_subtraction_person: [0, 0] diff --git a/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction.py b/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction.py index 8caa2ebe9a1..5119e3afb66 100644 --- a/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction.py +++ b/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction.py @@ -27,8 +27,19 @@ def formula(tax_unit, period, parameters): # for federal income tax purposes, to the extent included in federal # AGI. The Department of Revenue reads that as Form 1040 line 7a, and # says capital losses do not qualify. + # Sum filers directly: a supplied tax-unit net_capital_gains does + # not enter federal AGI, and removing dependents from a rounded + # aggregate can lose a filer's smaller gain. The person-level + # source also respects capital_gains supplied directly to AGI. + person = tax_unit.members + not_dependent = ~person("is_tax_unit_dependent", period) + own_gains = person("capital_gains", period) + max_( + 0, person("non_sch_d_capital_gains", period) + ) + # A federal net capital loss stays negative after the loss limit, + # so that limit cannot affect Missouri's positive-gain subtraction. federally_reported_capital_gains = max_( - 0, tax_unit("filer_loss_limited_net_capital_gains", period) + 0, tax_unit.sum(not_dependent * own_gains) ) p = parameters(period).gov.states.mo.tax.income.subtractions.net_capital_gain return federally_reported_capital_gains * p.rate diff --git a/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction_person.py b/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction_person.py index 7bcafc6c66b..56d90545913 100644 --- a/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction_person.py +++ b/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_capital_gains_subtraction_person.py @@ -29,13 +29,12 @@ def formula(person, period, parameters): ) person_positive_cg = not_dependent * max_(0, own_gains) tax_unit_positive_cg = tax_unit.sum(person_positive_cg) - # If no head or spouse has a positive gain of their own (only possible - # when the tax unit amount is supplied as an input), the head takes it. - default_share = where(person("is_tax_unit_head", period), 1.0, 0.0) + # Without a filer's positive gain included in federal AGI, there is + # no amount to allocate, even if a tax-unit subtraction is supplied. person_share = np.divide( person_positive_cg, tax_unit_positive_cg, - out=default_share, + out=np.zeros_like(tax_unit_positive_cg), where=tax_unit_positive_cg > 0, ) return person_share * tax_unit_subtraction