From 0bcebf6127bfb3bd1e6f824413b85130cd8c1887 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 15:13:29 -0400 Subject: [PATCH 1/6] Net capital gain distributions inside the capital loss limit A filer with capital losses or other capital gains enters capital gain distributions on Schedule D line 13 (2025 Instructions for Schedule D), so they net against the losses before the 26 U.S.C. 1211(b) limit on line 21. The model added distributions (non_sch_d_capital_gains) to gross income and deducted min(3,000, losses) separately: a 5,000 long-term loss with 3,000 of distributions gave 0 in AGI instead of -2,000. - capital_losses_allowed_against_gains (new): losses allowed up to the gains in gross income, distributions included (1211(b), first part). - limited_capital_loss: the net loss up to the limit (1211(b)(1)-(2), Schedule D line 21). loss_ald deducts both. This also nets spouses' gains and losses on a joint return. - loss_limited_net_capital_gains (+ person version): Form 1040 line 7a, max(-limit, LT + ST + distributions). Net investment income no longer adds the distributions separately (Form 8960 line 5a). - has_qdiv_or_ltcg: qualified dividends, or Schedule D lines 15 and 16 both above zero (Form 1040 instructions, line 16). - dependent_taxable_ss_magi: the same netting on the dependent's return. - dc_self_employment_loss_addition: subtracts both capital parts of loss_ald. Tests: YAML cases with form line comments; grid, Hypothesis property and monotonicity tests. Co-Authored-By: Claude Opus 5.5 --- .../sch-d-cg-distributions-agi.fixed.md | 1 + .../gov/irs/gross_income/sources.yaml | 4 +- .../gov/irs/investment/income/sources.yaml | 4 +- ...schedule_d_capital_gain_distributions.yaml | 335 ++++++++++++++++++ ...t_capital_gain_distributions_loss_limit.py | 311 ++++++++++++++++ .../capital_losses_allowed_against_gains.py | 56 +++ .../limited_capital_loss.py | 25 +- .../above_the_line_deductions/loss_ald.py | 13 +- .../irs_gross_income/has_qdiv_or_ltcg.py | 43 ++- .../loss_limited_net_capital_gains.py | 31 +- .../loss_limited_net_capital_gains_person.py | 14 +- .../dependent_taxable_ss_magi.py | 15 +- .../dc_self_employment_loss_addition.py | 8 +- .../capital_gains/non_sch_d_capital_gains.py | 12 + pyproject.toml | 1 + uv.lock | 90 +++++ 16 files changed, 933 insertions(+), 30 deletions(-) create mode 100644 changelog.d/sch-d-cg-distributions-agi.fixed.md create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml create mode 100644 policyengine_us/tests/test_capital_gain_distributions_loss_limit.py create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/capital_losses_allowed_against_gains.py diff --git a/changelog.d/sch-d-cg-distributions-agi.fixed.md b/changelog.d/sch-d-cg-distributions-agi.fixed.md new file mode 100644 index 00000000000..3d0811e886e --- /dev/null +++ b/changelog.d/sch-d-cg-distributions-agi.fixed.md @@ -0,0 +1 @@ +Net capital gain distributions against Schedule D capital losses before the 26 U.S.C. 1211(b) $3,000 loss limit in adjusted gross income, net investment income, loss-limited capital gains (Michigan, Mississippi, Delaware and Georgia), a dependent's Social Security modified AGI and the capital gain tax worksheet test, and net spouses' gains and losses on a joint return. diff --git a/policyengine_us/parameters/gov/irs/gross_income/sources.yaml b/policyengine_us/parameters/gov/irs/gross_income/sources.yaml index 0d5ec44971a..d5ed2911232 100644 --- a/policyengine_us/parameters/gov/irs/gross_income/sources.yaml +++ b/policyengine_us/parameters/gov/irs/gross_income/sources.yaml @@ -10,7 +10,9 @@ values: - other_net_gain_gross_income - capital_gains - non_sch_d_capital_gains - # Capital gain distributions received without filing Schedule D (Form 1040 line 7). + # Capital gain distributions reported on Form 1040 line 7a. With capital + # losses they go on Schedule D line 13; loss_ald nets the losses against + # them (capital_losses_allowed_against_gains). - taxable_interest_income - rental_income # Royalties included in rental income diff --git a/policyengine_us/parameters/gov/irs/investment/income/sources.yaml b/policyengine_us/parameters/gov/irs/investment/income/sources.yaml index 8bc8ed878e7..75c2d0158a3 100644 --- a/policyengine_us/parameters/gov/irs/investment/income/sources.yaml +++ b/policyengine_us/parameters/gov/irs/investment/income/sources.yaml @@ -14,9 +14,9 @@ values: # code H input says otherwise, and a dependent's amounts stay out of the # filer's net investment income. - estate_net_investment_income + # Form 8960 line 5a: the Form 1040 line 7 capital gain or loss, which + # includes capital gain distributions whether or not Schedule D is filed. - loss_limited_net_capital_gains - - non_sch_d_capital_gains - # Capital gain distributions received without filing Schedule D (Form 1040 line 7). metadata: unit: currency-USD label: net investment income sources diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml new file mode 100644 index 00000000000..6925c15a5e5 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml @@ -0,0 +1,335 @@ +# Capital gain distributions with Schedule D gains and losses (2025). +# +# Instructions for Schedule D, "Capital Gain Distributions": "Enter on +# Schedule D, line 13, the total capital gain distributions paid to you during +# the year". Form 1040 instructions, line 7a, Exception 1: a filer skips +# Schedule D only if "You have no capital losses, and your only capital gains +# are capital gain distributions". 26 U.S.C. 1211(b) allows capital losses +# "only to the extent of the gains from such sales or exchanges, plus (if such +# losses exceed such gains) the lower of" $3,000 ($1,500 married filing +# separately) or the excess. +# +# Schedule D: line 7 is the net short-term gain or loss, line 15 the net +# long-term gain or loss including line 13, line 16 = lines 7 + 15, and line 21 +# is the smaller loss of line 16 or ($3,000). Form 1040 line 7a is line 16, or +# line 21 if line 16 is a loss. + +- name: Distributions net against a long-term loss before the loss limit + period: 2025 + input: + people: + person1: + age: 45 + long_term_capital_gains: -5_000 + non_sch_d_capital_gains: 3_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Line 13 = 3,000; line 15 = -5,000 + 3,000 = -2,000; line 7 = 0; + # line 16 = -2,000; line 21 = -2,000 (smaller loss than -3,000). + loss_limited_net_capital_gains: -2_000 + # 1211(b): 3,000 of the loss offsets the distributions, and the 2,000 + # excess is below the 3,000 limit. + capital_losses_allowed_against_gains: 3_000 + limited_capital_loss: 2_000 + # Gross income 3,000 (the distributions) less 5,000 of allowed losses. + adjusted_gross_income: -2_000 + # Form 8960 line 5a is Form 1040 line 7a. + net_investment_income: -2_000 + # Lines 15 and 16 are losses and there are no qualified dividends. + has_qdiv_or_ltcg: false + +- name: Distributions above the loss leave a net gain + period: 2025 + input: + people: + person1: + age: 45 + long_term_capital_gains: -5_000 + non_sch_d_capital_gains: 6_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Line 15 = -5,000 + 6,000 = 1,000 = line 16, a gain on Form 1040 line 7a. + loss_limited_net_capital_gains: 1_000 + capital_losses_allowed_against_gains: 5_000 + limited_capital_loss: 0 + adjusted_gross_income: 1_000 + net_investment_income: 1_000 + # Lines 15 and 16 are both gains. + has_qdiv_or_ltcg: true + +- name: A loss beyond the distributions is limited to 3,000 + period: 2025 + input: + people: + person1: + age: 45 + long_term_capital_gains: -10_000 + non_sch_d_capital_gains: 4_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Line 16 = -10,000 + 4,000 = -6,000; line 21 = -3,000. + loss_limited_net_capital_gains: -3_000 + capital_losses_allowed_against_gains: 4_000 + limited_capital_loss: 3_000 + adjusted_gross_income: -3_000 + +- name: Distributions net against a short-term loss + period: 2025 + input: + people: + person1: + age: 45 + short_term_capital_gains: -5_000 + non_sch_d_capital_gains: 3_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Line 7 = -5,000; line 15 = 3,000; line 16 = -2,000; line 21 = -2,000. + loss_limited_net_capital_gains: -2_000 + adjusted_gross_income: -2_000 + # Line 15 is a gain but line 16 is a loss, so neither tax worksheet + # applies (Form 1040 instructions, line 16). + has_qdiv_or_ltcg: false + +- name: Married filing separately, the limit is 1,500 + period: 2025 + input: + people: + person1: + age: 45 + long_term_capital_gains: -5_000 + non_sch_d_capital_gains: 1_000 + tax_units: + tax_unit: + members: [person1] + filing_status: SEPARATE + households: + household: + members: [person1] + state_code: TX + output: + # Line 16 = -4,000; line 21 = -1,500. + loss_limited_net_capital_gains: -1_500 + capital_losses_allowed_against_gains: 1_000 + limited_capital_loss: 1_500 + adjusted_gross_income: -1_500 + +- name: Distributions without Schedule D are unchanged + period: 2025 + input: + people: + person1: + age: 45 + non_sch_d_capital_gains: 3_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Exception 1: no capital losses and only capital gain distributions, so + # the 3,000 goes directly on Form 1040 line 7a. + loss_limited_net_capital_gains: 3_000 + capital_losses_allowed_against_gains: 0 + limited_capital_loss: 0 + adjusted_gross_income: 3_000 + net_investment_income: 3_000 + # Form 1040 instructions, line 16: no Schedule D and distributions on + # line 7a. + has_qdiv_or_ltcg: true + +- name: A short-term gain alone does not use a capital gain tax worksheet + period: 2025 + input: + people: + person1: + age: 45 + short_term_capital_gains: 5_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Line 15 = 0, so lines 15 and 16 are not both more than zero. + has_qdiv_or_ltcg: false + adjusted_gross_income: 5_000 + +- name: Spouses' gains and losses net on one joint Schedule D + period: 2025 + input: + people: + person1: + age: 45 + long_term_capital_gains: 10_000 + person2: + age: 45 + long_term_capital_gains: -5_000 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + filing_status: JOINT + # Line 15 = 10,000 - 5,000 = 5,000 = line 16. Before, gross income took + # person1's 10,000 and only 3,000 of person2's loss was deducted (7,000). + loss_limited_net_capital_gains: 5_000 + capital_losses_allowed_against_gains: 5_000 + limited_capital_loss: 0 + adjusted_gross_income: 5_000 + +- name: One spouse's distributions net against the other's short-term loss + period: 2025 + input: + people: + person1: + age: 45 + non_sch_d_capital_gains: 2_000 + person2: + age: 45 + short_term_capital_gains: -6_000 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + # Line 7 = -6,000; line 15 = 2,000; line 16 = -4,000; line 21 = -3,000. + loss_limited_net_capital_gains: -3_000 + capital_losses_allowed_against_gains: 2_000 + limited_capital_loss: 3_000 + adjusted_gross_income: -3_000 + +- name: Wages with distributions and a long-term loss + period: 2025 + input: + people: + person1: + age: 45 + employment_income: 50_000 + long_term_capital_gains: -5_000 + non_sch_d_capital_gains: 3_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Form 1040 line 9 = 50,000 + (-2,000) = 48,000 = line 11. + adjusted_gross_income: 48_000 + # Line 15 = 48,000 - 15,750 standard deduction. + taxable_income: 32_250 + +- name: A dependent's distributions net against their capital loss on their own return + period: 2022 + input: + people: + head: + age: 45 + is_tax_unit_head: true + employment_income: 50_000 + parent: + age: 70 + is_tax_unit_dependent: true + is_tax_unit_spouse: false + social_security_retirement: 20_000 + taxable_interest_income: 18_000 + long_term_capital_gains: -5_000 + non_sch_d_capital_gains: 3_000 + tax_units: + tax_unit: + members: [head, parent] + households: + household: + members: [head, parent] + output: + # The dependent's own Schedule D: line 16 = -5,000 + 3,000 = -2,000, so + # modified AGI (IRC 86(b)(2)) = 18,000 - 2,000 = 16,000. Before, the + # distributions were added on top of a 3,000 loss (18,000). + dependent_taxable_ss_magi: [0, 16_000] + # The head's return has none of the dependent's capital gains or losses. + capital_losses_allowed_against_gains: 0 + limited_capital_loss: 0 + adjusted_gross_income: 50_000 + +- name: Each person's share includes the distributions + period: 2025 + input: + people: + person1: + age: 45 + long_term_capital_gains: -5_000 + non_sch_d_capital_gains: 3_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + loss_limited_net_capital_gains_person: -2_000 + +- name: DC still isolates the self-employment loss when capital losses offset gains + absolute_error_margin: 0.01 + period: 2024 + input: + people: + person1: + age: 45 + self_employment_income: -50_000 + long_term_capital_gains: -5_000 + non_sch_d_capital_gains: 3_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: DC + output: + # loss_ald = 50,000 self-employment + 3,000 capital loss against the + # distributions + 2,000 net capital loss. + loss_ald: 55_000 + # Only the self-employment loss counts: max(0, 50,000 - 12,000). + dc_self_employment_loss_addition: 38_000 diff --git a/policyengine_us/tests/test_capital_gain_distributions_loss_limit.py b/policyengine_us/tests/test_capital_gain_distributions_loss_limit.py new file mode 100644 index 00000000000..af0c7dc51e1 --- /dev/null +++ b/policyengine_us/tests/test_capital_gain_distributions_loss_limit.py @@ -0,0 +1,311 @@ +"""Property tests: capital gain distributions net inside the capital loss limit. + +A filer with capital losses or other capital gains files Schedule D and enters +capital gain distributions on line 13 (2025 Instructions for Schedule D, +"Capital Gain Distributions"). Line 16 combines the short-term and long-term +totals, and if it is a loss, line 21 limits it to $3,000 ($1,500 married +filing separately), as 26 U.S.C. 1211(b) does. Form 1040 line 7a is line 16, +or line 21. A filer whose only capital gains are distributions skips +Schedule D and reports them on line 7a directly (Form 1040 instructions, line +7a, Exception 1); the amount is the same. + +So for every combination of long-term (LT) and short-term (ST) gains and +losses and distributions (D) of the filers on a return: + +1. The capital gain or loss in adjusted gross income, and + loss_limited_net_capital_gains, equal max(-limit, LT + ST + D). +2. capital_losses_allowed_against_gains and limited_capital_loss split the + 1211(b) allowance: the losses allowed up to the gains, and the net loss, + which is between 0 and the limit. +3. Net investment income (Form 8960 line 5a) is the same Form 1040 line 7a + amount. +4. has_qdiv_or_ltcg is true exactly when Schedule D lines 15 (LT + D) and 16 + (LT + D + ST) are both more than zero. +5. The people's shares in loss_limited_net_capital_gains_person add up to the + tax unit amount (to within single-precision rounding of the proportional + split of a net loss). +6. Without Schedule D gains or losses the results are the distributions, as + before the change. +7. A dependent's modified AGI for Social Security (IRC 86(b)(2)), figured on + the dependent's own return, nets the same way. + +Amounts are whole dollars of at most $2,000,000, so every sum is exact in +single precision and the comparisons are exact, apart from the person shares +in 5. +""" + +import itertools + +import numpy as np +import pytest + +from policyengine_us import Simulation + +YEAR = 2025 +LIMIT = {"SINGLE": 3_000, "JOINT": 3_000, "SEPARATE": 1_500} + +OUTPUTS = [ + "adjusted_gross_income", + "loss_limited_net_capital_gains", + "capital_losses_allowed_against_gains", + "limited_capital_loss", + "net_investment_income", + "has_qdiv_or_ltcg", +] + + +def line_7a(h): + """Form 1040 line 7a from the return's totals.""" + total = sum( + p["long_term"] + p["short_term"] + p["distributions"] for p in h["people"] + ) + return max(-LIMIT[h["filing_status"]], total) + + +def schedule_d_lines_15_16(h): + line_15 = sum(p["long_term"] + p["distributions"] for p in h["people"]) + line_16 = line_15 + sum(p["short_term"] for p in h["people"]) + return line_15, line_16 + + +def build_situation(households): + """One tax unit per household, with only capital gains, losses and + distributions, so adjusted gross income is the capital gain or loss.""" + people, tax_units, marital_units, households_out = {}, {}, {}, {} + for i, h in enumerate(households): + members = [] + for j, p in enumerate(h["people"]): + name = f"person_{i}_{j}" + people[name] = { + "age": {YEAR: 45}, + "long_term_capital_gains": {YEAR: p["long_term"]}, + "short_term_capital_gains": {YEAR: p["short_term"]}, + "non_sch_d_capital_gains": {YEAR: p["distributions"]}, + } + members.append(name) + # Set on every unit: a variable input for some units gives the + # others its default value, not its formula. + tax_units[f"tax_unit_{i}"] = { + "members": members, + "filing_status": {YEAR: h["filing_status"]}, + } + marital_units[f"marital_unit_{i}"] = {"members": members} + households_out[f"household_{i}"] = { + "members": members, + "state_code": {YEAR: "TX"}, + } + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + "households": households_out, + } + + +def calculate(households): + simulation = Simulation(situation=build_situation(households)) + results = {v: np.asarray(simulation.calculate(v, YEAR)) for v in OUTPUTS} + results["filing_status"] = np.asarray( + simulation.calculate("filing_status", YEAR).decode_to_str() + ) + results["person_share"] = np.asarray( + simulation.calculate("loss_limited_net_capital_gains_person", YEAR) + ) + return results + + +def assert_properties(households): + model = calculate(households) + person = 0 + for i, h in enumerate(households): + fs = h["filing_status"] + assert model["filing_status"][i] == fs, h + limit = LIMIT[fs] + expected = line_7a(h) + # 1. AGI and Form 1040 line 7a. + assert model["adjusted_gross_income"][i] == expected, h + assert model["loss_limited_net_capital_gains"][i] == expected, h + # 2. The 1211(b) split, and the identity it gives: gross income + # includes the gains, and the two parts deduct the losses. + allowed_against_gains = model["capital_losses_allowed_against_gains"][i] + net_loss = model["limited_capital_loss"][i] + assert 0 <= net_loss <= limit, h + assert net_loss == max(0, -expected), h + gains = sum( + max(0, p["long_term"] + p["short_term"]) + p["distributions"] + for p in h["people"] + ) + losses = sum(max(0, -(p["long_term"] + p["short_term"])) for p in h["people"]) + assert allowed_against_gains == min(losses, gains), h + assert allowed_against_gains + net_loss == min(losses, gains + limit), h + assert gains - allowed_against_gains - net_loss == expected, h + # 3. Form 8960 line 5a. + assert model["net_investment_income"][i] == expected, h + # 4. Form 1040 line 16 routing (no qualified dividends here). + line_15, line_16 = schedule_d_lines_15_16(h) + assert bool(model["has_qdiv_or_ltcg"][i]) == (line_15 > 0 and line_16 > 0), h + # 5. Person shares. A net loss is allocated in proportion to each + # person's amount, a division that single precision rounds (for + # example -3,000 split as 12/17 and 5/17 sums to -3,000.0002). + n = len(h["people"]) + shares = model["person_share"][person : person + n].astype(float) + assert abs(shares.sum() - expected) <= 0.01, h + person += n + # 6. The path without Schedule D. + if all(p["long_term"] == 0 and p["short_term"] == 0 for p in h["people"]): + distributions = sum(p["distributions"] for p in h["people"]) + assert expected == distributions + assert allowed_against_gains == 0 and net_loss == 0, h + + +# --------------------------------------------------------------------------- +# A deterministic grid (runs without Hypothesis). +# --------------------------------------------------------------------------- + +GRID_AMOUNTS = [-12_000, -5_000, -2_500, 0, 1_000, 4_000] +GRID_DISTRIBUTIONS = [0, 1_000, 3_000, 9_000] + + +def grid_households(): + households = [] + for lt, st_, d in itertools.product(GRID_AMOUNTS, GRID_AMOUNTS, GRID_DISTRIBUTIONS): + person = {"long_term": lt, "short_term": st_, "distributions": d} + households.append({"filing_status": "SINGLE", "people": [person]}) + households.append({"filing_status": "SEPARATE", "people": [person]}) + # The same totals split between spouses: the long-term amount and the + # distributions with one spouse, the short-term amount with the other. + households.append( + { + "filing_status": "JOINT", + "people": [ + {"long_term": lt, "short_term": 0, "distributions": d}, + {"long_term": 0, "short_term": st_, "distributions": 0}, + ], + } + ) + return households + + +def test_grid(): + assert_properties(grid_households()) + + +def test_example_from_the_schedule_d_instructions(): + """Long-term loss 5,000 and distributions 3,000: line 16 is -2,000, so + the deductible loss is 2,000 (not 3,000 against gross income that still + includes the distributions, which gave 0).""" + h = { + "filing_status": "SINGLE", + "people": [{"long_term": -5_000, "short_term": 0, "distributions": 3_000}], + } + model = calculate([h]) + assert model["adjusted_gross_income"][0] == -2_000 + assert model["limited_capital_loss"][0] == 2_000 + + +# --------------------------------------------------------------------------- +# Hypothesis. +# --------------------------------------------------------------------------- + +# Hypothesis is a dev extra; skip rather than fail collection without it. +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") + +AMOUNT = st.integers(min_value=-2_000_000, max_value=2_000_000) +SMALL = st.integers(min_value=-20_000, max_value=20_000) +DISTRIBUTIONS = st.one_of( + st.just(0), + st.integers(min_value=0, max_value=20_000), + st.integers(min_value=0, max_value=2_000_000), +) + + +@st.composite +def person(draw): + amounts = draw(st.sampled_from([AMOUNT, SMALL])) + return { + "long_term": draw(st.one_of(st.just(0), amounts)), + "short_term": draw(st.one_of(st.just(0), amounts)), + "distributions": draw(DISTRIBUTIONS), + } + + +@st.composite +def household(draw): + filing_status = draw(st.sampled_from(["SINGLE", "SEPARATE", "JOINT"])) + n = 2 if filing_status == "JOINT" else 1 + return { + "filing_status": filing_status, + "people": [draw(person()) for _ in range(n)], + } + + +@hypothesis.settings(max_examples=60, deadline=None) +@hypothesis.given(st.lists(household(), min_size=1, max_size=12)) +def test_properties(households): + assert_properties(households) + + +@hypothesis.settings(max_examples=40, deadline=None) +@hypothesis.given( + st.lists(household(), min_size=1, max_size=8), + st.integers(min_value=1, max_value=50_000), +) +def test_more_distributions_never_lower_agi(households, extra): + """Adjusted gross income does not fall when distributions rise.""" + more = [ + { + "filing_status": h["filing_status"], + "people": [ + { + **h["people"][0], + "distributions": h["people"][0]["distributions"] + extra, + } + ] + + h["people"][1:], + } + for h in households + ] + base = calculate(households)["adjusted_gross_income"] + raised = calculate(more)["adjusted_gross_income"] + assert (raised >= base).all() + # And by no more than the extra distributions. + assert (raised - base <= extra).all() + + +@hypothesis.settings(max_examples=40, deadline=None) +@hypothesis.given(st.lists(person(), min_size=1, max_size=10)) +def test_dependent_modified_agi(dependents): + """A dependent's own-return modified AGI (IRC 86(b)(2)) nets the + distributions inside the single-filer limit.""" + people, tax_units, households_out = {}, {}, {} + for i, p in enumerate(dependents): + head, dep = f"head_{i}", f"dependent_{i}" + people[head] = {"age": {YEAR: 45}, "is_tax_unit_head": {YEAR: True}} + people[dep] = { + "age": {YEAR: 70}, + "is_tax_unit_dependent": {YEAR: True}, + "is_tax_unit_spouse": {YEAR: False}, + "long_term_capital_gains": {YEAR: p["long_term"]}, + "short_term_capital_gains": {YEAR: p["short_term"]}, + "non_sch_d_capital_gains": {YEAR: p["distributions"]}, + } + tax_units[f"tax_unit_{i}"] = {"members": [head, dep]} + households_out[f"household_{i}"] = { + "members": [head, dep], + "state_code": {YEAR: "TX"}, + } + simulation = Simulation( + situation={ + "people": people, + "tax_units": tax_units, + "households": households_out, + } + ) + magi = np.asarray(simulation.calculate("dependent_taxable_ss_magi", YEAR)) + agi = np.asarray(simulation.calculate("adjusted_gross_income", YEAR)) + for i, p in enumerate(dependents): + total = p["long_term"] + p["short_term"] + p["distributions"] + assert magi[2 * i + 1] == max(-3_000, total), p + # The dependent's capital gains and losses stay off the head's return. + assert agi[i] == 0, p diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/capital_losses_allowed_against_gains.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/capital_losses_allowed_against_gains.py new file mode 100644 index 00000000000..a44fb1635b0 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/capital_losses_allowed_against_gains.py @@ -0,0 +1,56 @@ +from policyengine_us.model_api import * + + +class capital_losses_allowed_against_gains(Variable): + value_type = float + entity = TaxUnit + label = "Capital losses allowed against capital gains" + unit = USD + documentation = ( + "Capital losses deducted to the extent of capital gains, the first " + "part of the allowance in 26 U.S.C. 1211(b). Gross income includes " + "each filer's net capital gain and capital gain distributions, but " + "not losses; this deduction nets the losses against those gains, as " + "Schedule D does with capital gain distributions on line 13. " + "limited_capital_loss is the second part, the net loss up to the " + "$3,000 limit." + ) + definition_period = YEAR + reference = ( + dict( + title="26 U.S. Code § 1211(b) - Limitation on capital losses", + href="https://www.law.cornell.edu/uscode/text/26/1211#b", + ), + dict( + title="26 U.S. Code § 62(a)(3) - Losses from sale or exchange of property", + href="https://www.law.cornell.edu/uscode/text/26/62#a_3", + ), + dict( + title="26 U.S. Code § 852(b)(3)(B) - Capital gain dividends", + href="https://www.law.cornell.edu/uscode/text/26/852#b_3_B", + ), + dict( + title="2025 Instructions for Schedule D (Form 1040), Capital Gain Distributions", + href="https://www.irs.gov/pub/irs-prior/i1040sd--2025.pdf#page=2", + ), + ) + + def formula(tax_unit, period, parameters): + sources = parameters(period).gov.irs.gross_income.sources + person = tax_unit.members + # A tax-unit dependent's capital gains and losses belong on the + # dependent's own return, as in irs_gross_income. + not_dependent = ~person("is_tax_unit_dependent", period) + # The capital gains irs_gross_income counts: each filer's net gain + # from sales and exchanges, and capital gain distributions reported + # without Schedule D. Capital gain distributions are long-term capital + # gains (26 U.S.C. 852(b)(3)(B)), and a filer with capital losses + # enters them on Schedule D line 13, where they net against the + # losses. + gains = 0 + for source in ["capital_gains", "non_sch_d_capital_gains"]: + if source in sources: + gains += max_(0, person(source, period)) + gains_in_gross_income = tax_unit.sum(not_dependent * gains) + capital_losses = tax_unit_non_dep_sum("capital_losses", tax_unit, period) + return min_(capital_losses, gains_in_gross_income) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss.py index 0206b428c2f..858a0a11da0 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss.py @@ -6,9 +6,24 @@ class limited_capital_loss(Variable): entity = TaxUnit label = "Limited capital loss deduction" unit = USD - documentation = "The capital loss deductible from gross income." + documentation = ( + "The net capital loss deductible from gross income: the excess of " + "capital losses over capital gains, including capital gain " + "distributions, up to $3,000 ($1,500 if married filing separately). " + "Schedule D line 21, as a positive number. Losses up to the gains " + "are in capital_losses_allowed_against_gains." + ) definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/26/1211" + reference = ( + dict( + title="26 U.S. Code § 1211(b) - Limitation on capital losses", + href="https://www.law.cornell.edu/uscode/text/26/1211#b", + ), + dict( + title="2025 Schedule D (Form 1040), lines 16 and 21", + href="https://www.irs.gov/pub/irs-prior/f1040sd--2025.pdf#page=2", + ), + ) def formula(tax_unit, period, parameters): p = parameters(period).gov.irs @@ -17,4 +32,8 @@ def formula(tax_unit, period, parameters): # A tax-unit dependent's capital losses belong on the dependent's own # return, as in irs_gross_income. capital_losses = tax_unit_non_dep_sum("capital_losses", tax_unit, period) - return min_(max_loss, capital_losses) + # 26 U.S.C. 1211(b)(2): the excess of the losses over the gains. + excess = capital_losses - tax_unit( + "capital_losses_allowed_against_gains", period + ) + return min_(max_loss, excess) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py index 8eedf2bcb62..5e199a624bc 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py @@ -13,6 +13,7 @@ class loss_ald(Variable): reference = ( "https://www.law.cornell.edu/uscode/text/26/165", "https://www.law.cornell.edu/uscode/text/26/461#l", + "https://www.law.cornell.edu/uscode/text/26/1211#b", ) def formula(tax_unit, period, parameters): @@ -91,7 +92,15 @@ def formula(tax_unit, period, parameters): max_deductible_business_loss = total_business_income + threshold_amount limited_business_loss = min_(total_business_loss, max_deductible_business_loss) - # Capital losses have separate limit under Section 1211 ($3,000) + # Section 1211(b) allows capital losses to the extent of capital + # gains, plus a net loss of up to $3,000. + capital_losses_allowed_against_gains = tax_unit( + "capital_losses_allowed_against_gains", period + ) limited_capital_loss = tax_unit("limited_capital_loss", period) - return limited_business_loss + limited_capital_loss + return ( + limited_business_loss + + capital_losses_allowed_against_gains + + limited_capital_loss + ) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/has_qdiv_or_ltcg.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/has_qdiv_or_ltcg.py index 2db8bf45bfc..07761ee07bc 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/has_qdiv_or_ltcg.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/has_qdiv_or_ltcg.py @@ -5,22 +5,35 @@ class has_qdiv_or_ltcg(Variable): value_type = bool entity = TaxUnit label = "Has qualified dividends or long-term capital gains" - documentation = "Whether this tax unit has qualified dividend income or long-term capital gains income" + documentation = ( + "Whether this tax unit figures its tax with the Qualified Dividends " + "and Capital Gain Tax Worksheet or the Schedule D Tax Worksheet: it " + "has qualified dividends, or Schedule D lines 15 and 16 are both more " + "than zero. Capital gain distributions are on Schedule D line 13, so " + "a filer who reports them without Schedule D has lines 15 and 16 " + "equal to them." + ) definition_period = YEAR + reference = ( + dict( + title="2025 Instructions for Form 1040, line 16", + href="https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=36", + ), + dict( + title="2025 Schedule D (Form 1040), lines 15 to 22", + href="https://www.irs.gov/pub/irs-prior/f1040sd--2025.pdf#page=2", + ), + ) def formula(tax_unit, period, parameters): - # negatives amounts cannot offset other income sources - INCOME_SOURCES = [ - "loss_limited_net_capital_gains", - "net_capital_gains", - "long_term_capital_gains", - "non_sch_d_capital_gains", - "qualified_dividend_income", - ] - return np.any( - [ - add(tax_unit, period, [income_source]) > 0 - for income_source in INCOME_SOURCES - ], - axis=0, + qualified_dividends = add(tax_unit, period, ["qualified_dividend_income"]) + # Schedule D line 15: net long-term capital gain or loss, including + # capital gain distributions (line 13). + line_15 = add( + tax_unit, + period, + ["long_term_capital_gains", "non_sch_d_capital_gains"], ) + # Schedule D line 16: line 15 plus the net short-term gain or loss. + line_16 = line_15 + add(tax_unit, period, ["short_term_capital_gains"]) + return (qualified_dividends > 0) | ((line_15 > 0) & (line_16 > 0)) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains.py index 6aabd824a34..8306853e817 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains.py @@ -6,11 +6,40 @@ class loss_limited_net_capital_gains(Variable): entity = TaxUnit definition_period = YEAR label = "Loss-limited net capital gains" + documentation = ( + "The capital gain or loss on Form 1040 line 7a: Schedule D line 16 " + "(long-term and short-term gains and losses, with capital gain " + "distributions on line 13), or line 21 if line 16 is a loss. A filer " + "with no other capital gains or losses reports the distributions " + "without Schedule D, and the amount is the same." + ) unit = USD + reference = ( + dict( + title="26 U.S. Code § 1211(b) - Limitation on capital losses", + href="https://www.law.cornell.edu/uscode/text/26/1211#b", + ), + dict( + title="2025 Schedule D (Form 1040), lines 13, 16 and 21", + href="https://www.irs.gov/pub/irs-prior/f1040sd--2025.pdf#page=2", + ), + dict( + title="2025 Instructions for Schedule D (Form 1040), Capital Gain Distributions", + href="https://www.irs.gov/pub/irs-prior/i1040sd--2025.pdf#page=2", + ), + dict( + title="2025 Instructions for Form 1040, line 7a, Exception 1", + href="https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=31", + ), + ) def formula(tax_unit, period, parameters): p = parameters(period).gov.irs filing_status = tax_unit("filing_status", period) loss_limit = p.capital_gains.loss_limit[filing_status] net_capital_gains = tax_unit("net_capital_gains", period) - return max_(-loss_limit, net_capital_gains) + # Capital gain distributions reported without Schedule D. A filer with + # capital losses or other capital gains files Schedule D and enters + # them on line 13, so they net against losses before the limit. + distributions = add(tax_unit, period, ["non_sch_d_capital_gains"]) + return max_(-loss_limit, net_capital_gains + distributions) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains_person.py index 4354058a947..2e4632639bf 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains_person.py @@ -7,11 +7,19 @@ class loss_limited_net_capital_gains_person(Variable): label = "Loss-limited capital gains (person)" unit = USD definition_period = YEAR - reference = "https://www.irs.gov/pub/irs-pdf/f1040sd.pdf" + documentation = ( + "Each person's share of loss_limited_net_capital_gains: the person's " + "capital gains and losses plus capital gain distributions, with a " + "tax-unit net loss limited and allocated in proportion." + ) + reference = "https://www.irs.gov/pub/irs-prior/f1040sd--2025.pdf#page=2" def formula(person, period, parameters): - # Get person's raw capital gains - person_capital_gains = person("capital_gains", period) + # The person's capital gains and losses, plus capital gain + # distributions, which go on Schedule D line 13 with them. + person_capital_gains = add( + person, period, ["capital_gains", "non_sch_d_capital_gains"] + ) # Get tax unit totals tax_unit = person.tax_unit diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py index 932e2011bb4..64d607bff9e 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py @@ -58,10 +58,18 @@ def formula(person, period, parameters): ald = [deduction for deduction in p.ald.deductions if deduction not in revoked] deduct_losses = "loss_ald" in ald + # Capital gain distributions net with capital gains and losses on + # Schedule D (line 13), inside the IRC 1211(b) limit, below. + net_distributions = ( + "capital_gains" in sources and "non_sch_d_capital_gains" in sources + ) + income = 0 for source in sources: components = DEPENDENT_GROSS_INCOME_SOURCE_OVERRIDES.get(source, [source]) for component in components: + if net_distributions and component == "non_sch_d_capital_gains": + continue amount = add(person, period, [component]) if deduct_losses and component in BUSINESS_LOSS_SOURCES: income += amount @@ -78,7 +86,12 @@ def formula(person, period, parameters): capital_loss_limit["SEPARATE"], capital_loss_limit["SINGLE"], ) - income += max_(person("capital_gains", period), -loss_limit) + capital_gains = person("capital_gains", period) + if net_distributions: + capital_gains = capital_gains + max_( + 0, person("non_sch_d_capital_gains", period) + ) + income += max_(capital_gains, -loss_limit) deductions = [ PERSON_ABOVE_THE_LINE_DEDUCTIONS[deduction] diff --git a/policyengine_us/variables/gov/states/dc/tax/income/additions/dc_self_employment_loss_addition.py b/policyengine_us/variables/gov/states/dc/tax/income/additions/dc_self_employment_loss_addition.py index 998e9695a38..45ae2cb2b7d 100644 --- a/policyengine_us/variables/gov/states/dc/tax/income/additions/dc_self_employment_loss_addition.py +++ b/policyengine_us/variables/gov/states/dc/tax/income/additions/dc_self_employment_loss_addition.py @@ -19,8 +19,12 @@ def formula(person, period, parameters): # Cap at SE loss actually deducted in federal AGI via loss_ald. # loss_ald includes both SE and capital losses; isolate SE portion. loss_ald = person.tax_unit("loss_ald", period) - limited_capital_loss = person.tax_unit("limited_capital_loss", period) - se_loss_in_ald = max_(0, loss_ald - limited_capital_loss) + capital_loss_in_ald = add( + person.tax_unit, + period, + ["capital_losses_allowed_against_gains", "limited_capital_loss"], + ) + se_loss_in_ald = max_(0, loss_ald - capital_loss_in_ald) effective_loss = min_(loss_taxunit, se_loss_in_ald) p = parameters(period).gov.states.dc.tax.income.additions addition_taxunit = max_(0, effective_loss - p.self_employment_loss.threshold) diff --git a/policyengine_us/variables/household/income/person/capital_gains/non_sch_d_capital_gains.py b/policyengine_us/variables/household/income/person/capital_gains/non_sch_d_capital_gains.py index 3d1649588fb..ad4d944c6b7 100644 --- a/policyengine_us/variables/household/income/person/capital_gains/non_sch_d_capital_gains.py +++ b/policyengine_us/variables/household/income/person/capital_gains/non_sch_d_capital_gains.py @@ -6,5 +6,17 @@ class non_sch_d_capital_gains(Variable): entity = Person label = "Capital gains not reported on Schedule D" unit = USD + documentation = ( + "Capital gain distributions (Form 1099-DIV box 2a) reported on Form " + "1040 line 7a without Schedule D. A filer who also has capital losses " + "or other capital gains enters them on Schedule D line 13, so the " + "model nets them with those gains and losses before the capital loss " + "limit (loss_limited_net_capital_gains, " + "capital_losses_allowed_against_gains and limited_capital_loss)." + ) + reference = dict( + title="2025 Instructions for Schedule D (Form 1040), Capital Gain Distributions", + href="https://www.irs.gov/pub/irs-prior/i1040sd--2025.pdf#page=2", + ) definition_period = YEAR uprating = "calibration.gov.irs.soi.long_term_capital_gains" diff --git a/pyproject.toml b/pyproject.toml index 3c6db03d87f..1234463cd98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ dev = [ "setuptools>=80.9.0", "build>=1.2.2.post1", "towncrier>=24.8.0", + "hypothesis>=6.100.0", ] [tool.hatch.build.targets.wheel] diff --git a/uv.lock b/uv.lock index de61653569a..61b455fea5d 100644 --- a/uv.lock +++ b/uv.lock @@ -766,6 +766,94 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/07/3d0c34c345043c6a398a5882e196b2220dc5861adfa18322448b90908f26/huggingface_hub-1.3.4-py3-none-any.whl", hash = "sha256:a0c526e76eb316e96a91e8a1a7a93cf66b0dd210be1a17bd5fc5ae53cba76bfd", size = 536611, upload-time = "2026-01-26T14:05:08.549Z" }, ] +[[package]] +name = "hypothesis" +version = "6.168.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/b7/13118bbc45d6d8b9d04e2de779e2a4ff23145ea39efa692b994fb874ca72/hypothesis-6.168.3.tar.gz", hash = "sha256:a43388f9067678fef6e13bdff325b6cfa6961a590498bb37f7ff31589c83bc75", size = 511022, upload-time = "2026-09-28T05:20:58.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/cd/746a2fc1e5e5ba34f07ea6ee1ad07525a3dde5ff4836db3a7980af49a891/hypothesis-6.168.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d20972ca134e652e9928ecd200966a8a2857adc2812c1d74b12a872e5cd503eb", size = 791536, upload-time = "2026-09-28T05:18:57.162Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0f/7a158e377b69556c8e12c25c8fd811d0103e54c24a12dab1f3b9819f202e/hypothesis-6.168.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:eafbec09d3e87d13d8242411d1f5868b5e879f1bd6d95e233e9ff80c527bea1c", size = 787313, upload-time = "2026-09-28T05:20:18.122Z" }, + { url = "https://files.pythonhosted.org/packages/45/c6/7df9104ee359e8fcd77791781c47e70794964ca69673665e0de2a6590f4c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73c5627497968cc62d140e9fde1ea21e12cac7b64dc419fea8286cd34ff1ad4e", size = 1124036, upload-time = "2026-09-28T05:20:04.372Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/8a61715404a73b9a82cb1f976803428d603cdea976a9c30266c72ad6a7ce/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dc56e6dc6eeb0aeb08ac463f847279bde2336c4786faf7ee0af4b93cd031a7", size = 1147875, upload-time = "2026-09-28T05:19:15.495Z" }, + { url = "https://files.pythonhosted.org/packages/e4/3f/2b16e95cc3b9a069afa23830a012aefe377ce4a457a227e5a5026eb827ff/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:753bb501f8560d2e321ed3b62596b4496c56f15668b0a0669231ccc3e6c4e80d", size = 1149489, upload-time = "2026-09-28T05:19:18.886Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e7/d7cf6dc068bb02732b2a6e38a35a0dcb7f2137608198fc79d740f69d197c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71ab606c472449cb872ef2a7acaec679ee4f651b7f0a477a04ebc85d8933ef8c", size = 1191992, upload-time = "2026-09-28T05:19:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/5f/26/f1e5b25dec15998e8375d722825ec3dae1075cbe7cb1f74221b2312a2e33/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26928956c54748e4dfa587333741ab750246123b93484955646ff316cca27eef", size = 1169911, upload-time = "2026-09-28T05:19:27.996Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/901234a49147e6bf8a5b9e54b775706a223c2b52befea1106ea2aaf15d48/hypothesis-6.168.3-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:0bdfc53c041b61c854fa3761735736b991bc2bddafee45a361cfe4d43027c1ef", size = 1129406, upload-time = "2026-09-28T05:18:42.632Z" }, + { url = "https://files.pythonhosted.org/packages/55/c1/5fc9ff91ec9fba6bb527833386c66812524b5cc75fe7d4a4f9122ee1c420/hypothesis-6.168.3-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:650528e2b1e2a45e95c2df624d4b9364b8ac5a027ba84e1eea2bc5398dd01dcd", size = 1160399, upload-time = "2026-09-28T05:20:23.911Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d2/49ad1ef5c55547c8abfd0fc571b3493806dda530fd5cb3d8263dbad86a72/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:209dc54cdb1b4d6d7020ad8d09e44c49756361b7183adacea4d6f5705085b595", size = 1299906, upload-time = "2026-09-28T05:18:58.417Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5f/f98a26094c4f462c4215807bed0f9a6b5508b27a3c329bcba1bc8adc7ac7/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:af8ca98cd11dc7f9427bb90483abcd9688d4df2e8e63893a30a2423b027ebb11", size = 1425538, upload-time = "2026-09-28T05:20:49.074Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b8/c5b4406e3a6591e51a42f85469351d1d824e0a54b84167aec9b2353759fe/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:f8122cfdd0bc0ba843063effa22ac310bdebdb3a1319bf1e63f14baa119c72ab", size = 1377084, upload-time = "2026-09-28T05:20:06.618Z" }, + { url = "https://files.pythonhosted.org/packages/e0/01/0d84a8ea469d024c15f602799c0b8410fbe03d99fdb0370449b33c3c916f/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4bedbb379eab34f792af7ee9a05aae04e9c08bbb52e0f90f5a2110d4fe4b2fbd", size = 1281162, upload-time = "2026-09-28T05:19:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/22/b5/ea6435038de1a795f005cb603531e8ccc053febc50180f3ab55583c9c60e/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:5a953115b9f5c95133ab2d04efffeec96e5658c3207923dca7285f7db3e6bbef", size = 1300433, upload-time = "2026-09-28T05:18:55.634Z" }, + { url = "https://files.pythonhosted.org/packages/6c/78/fa6c77d9f64b6d592e69e582d83cbce7d7e56897faa4762885a2123b0166/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6173558e676ad25ed1e20507fa4024a0d816dd90f715f77006e5a28f19109026", size = 1336273, upload-time = "2026-09-28T05:19:08.273Z" }, + { url = "https://files.pythonhosted.org/packages/f1/56/4acd0d778bab818c9ea336fcba768bf43bc70db7f84cc373579298df4641/hypothesis-6.168.3-cp310-abi3-win32.whl", hash = "sha256:dc66390fb12d80585aa9222bf538ce8b7aa22cf5d118250647355a1c9e8f62f4", size = 678211, upload-time = "2026-09-28T05:19:23.355Z" }, + { url = "https://files.pythonhosted.org/packages/b0/db/0a02b146ad1c30f9716362f2e1a379dfd6b0f7be03ac1e22c9da24c9e2a9/hypothesis-6.168.3-cp310-abi3-win_amd64.whl", hash = "sha256:92325b276360fe86c5bf71a568c0d53a6d140b0de36dcf029f9164a17803bb24", size = 684906, upload-time = "2026-09-28T05:18:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c0/958deaf726848f96f52250740bf39f13f476b068e22f73e2b358eaa07532/hypothesis-6.168.3-cp310-abi3-win_arm64.whl", hash = "sha256:3cf6f1eeaf41cd8d60cf1f88fde905ca1dd77c906929a507d6ac7f66f2ccba2a", size = 683292, upload-time = "2026-09-28T05:19:57.404Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ab/443208c2988a24eaafbfbd6791b897ad9a8a1dd7f870d5b455d7d15a28ad/hypothesis-6.168.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9029775dc2e25e3e8e596c4306926f0079158353631c7c52c285bbc5c8073c2b", size = 792239, upload-time = "2026-09-28T05:19:59.144Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7b/619c1a76c52de2d3cfd68a2f9876c09464d63097db5bf86a28b62dc37bb1/hypothesis-6.168.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e1d91530cefdb9e0b6467c203eb509c61a11d70480a37db59cb53a2f9913102", size = 788113, upload-time = "2026-09-28T05:19:17.145Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/33841e8833105f850214a486d83740ba52041892ca94e687a05829c0ce83/hypothesis-6.168.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0df00cbe8133aa220308d11fa28e3add996cf5102a39b279c1d711015fd9114b", size = 1124241, upload-time = "2026-09-28T05:18:37.589Z" }, + { url = "https://files.pythonhosted.org/packages/4b/43/c84a7900feced6a420e824ab61d316e4f59a6887d991bc143ecc294a09ed/hypothesis-6.168.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7f75a2e23288ee82ee965a952473d9c5447c2cbc1afc94be09d2402201774e", size = 1170406, upload-time = "2026-09-28T05:19:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/47/5d/6e2a32b9c3020af7a06b00fe446a2c5155ae76ac468c0b68d7188e524002/hypothesis-6.168.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc2b1c37631f2231dd0d077225aa5908fc2bd34a4c3faf475053b0bc2eb24c95", size = 1300339, upload-time = "2026-09-28T05:20:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f9/78/19f3c9eb9a5c215b9fbde57be18ef7c1c78afa509b460d4e8f86026d741a/hypothesis-6.168.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:73960a58f6efc8cbc57d8ad0647955f7c5b25f3d562e3eaa57afa1c69894f7aa", size = 1336484, upload-time = "2026-09-28T05:19:02.841Z" }, + { url = "https://files.pythonhosted.org/packages/88/bb/d46c4519e989e022667705d8a56e83a09ad4ea50eae07ca4bb0f4708dd23/hypothesis-6.168.3-cp311-cp311-win_amd64.whl", hash = "sha256:3f1122223759acc0fe5301c1ae505d762ee61af91db7b191a791ccdbcd965462", size = 684646, upload-time = "2026-09-28T05:20:00.842Z" }, + { url = "https://files.pythonhosted.org/packages/7a/56/44a17266bcf1add5fba7c3df5c3920396a5e924d917fb0534a6f9e2b9b9a/hypothesis-6.168.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bff12e036b67abc5ded682f724f4d7afaff5223190270a0763695f218c079068", size = 793313, upload-time = "2026-09-28T05:20:22.045Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/1a25b11e9133cb54c02ee6cf99a61296ad461aabc7aae3399e1d0ff1f095/hypothesis-6.168.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c73c6188056e6dc110260e39d141c69c3d02f23e3ba1d8610c7a8b6f2510ec96", size = 784855, upload-time = "2026-09-28T05:19:34.496Z" }, + { url = "https://files.pythonhosted.org/packages/8b/da/728d3210f0b0089337f37206a100d31ef546d1f99839e709f0a9ca443f8e/hypothesis-6.168.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb1063cb794765097b8c0add598bd34a904f44dc15db81275dd1f0ed1ac567ed", size = 1123043, upload-time = "2026-09-28T05:19:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/53/86/bfb9ec344a8b3ef2fa6f59bb3d30c5b3a7d86d594107670539c34ee53d1b/hypothesis-6.168.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b2d47f5d9060b049039bef0b267e88480f6468f95ee394e514e84a81ccdc5f5", size = 1169121, upload-time = "2026-09-28T05:20:40.512Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e0/5a76dbafbcaf8fff075f09377cddefa17289ef05dbf51878dde88385b966/hypothesis-6.168.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8367f623a98cbf90f33fd2a43b100671b152b5975bc343a578290945f52f7018", size = 1298749, upload-time = "2026-09-28T05:19:32.883Z" }, + { url = "https://files.pythonhosted.org/packages/28/15/0e65af96b353f2c4bdc283c53ed0980e5ac5009afe97ef3b23c77b364664/hypothesis-6.168.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd81241f4cd76fb18d481e87b9688b30a0cb5c30841730513323ba1c7aba1837", size = 1335271, upload-time = "2026-09-28T05:20:54.005Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c5/37eab2155664aa544df321f6d3940b89c6702d79a72fdc98d7b5a2286f8c/hypothesis-6.168.3-cp312-cp312-win_amd64.whl", hash = "sha256:377438de53afb94347d9845b7d90db5905c6d2a8d612108e9938e0f80503bb90", size = 682211, upload-time = "2026-09-28T05:18:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a2/6787da846d929e52fc3344d803299c45782dbeac287528af08380e984bf8/hypothesis-6.168.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b230a850de63334c16654a34a2d547e0179d36b9071d4439b3e7237f6d077e7", size = 793254, upload-time = "2026-09-28T05:19:36.294Z" }, + { url = "https://files.pythonhosted.org/packages/89/ba/2893f5ca42501f4d562cba3229c8994c3a3e5fac66ef60c1e0e0b5220a5d/hypothesis-6.168.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d63b0226cd3e0d8bdd97c3384b22a21934ed4d53246c1c93575dada616672499", size = 784699, upload-time = "2026-09-28T05:18:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/b7/aa/7d7349daf75b71f6f35876f8de115e974c5c04d600e86df1b2779b0883ab/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0369f5df055f96e117ab12e5f249668ff144731ab5280bc7a205fdf81f990b89", size = 1123016, upload-time = "2026-09-28T05:20:36.372Z" }, + { url = "https://files.pythonhosted.org/packages/00/f0/7774e1ea072708ea46cb25c4aeee5f9978b6c271764809069e8a6789858e/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f076bcd0f77fdcdb7797826c879099573d03a02e228ebe649ea917081b962ac0", size = 1168989, upload-time = "2026-09-28T05:20:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7d/113992abed9efbd7944e3a496a382da58152dce126fddf6419ff31a0a57d/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b823ba1fcec8da730f29316d010b06d3f7e0c3828dcf91020e24c55e7d24652a", size = 1298626, upload-time = "2026-09-28T05:19:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/d5/65/3659fa5e733027e5b37a27486e57f4053535e40853d23501c422bb275043/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dd2849c269d674e4618590f3b48d443bd4c06b5aef3d8d869086c2e6d213d248", size = 1335184, upload-time = "2026-09-28T05:20:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/b4/6c/35aab2221b5ea65125340f236e1a765a9e9f3b28d89a389ea0033fed29f7/hypothesis-6.168.3-cp313-cp313-win_amd64.whl", hash = "sha256:3ef7d26f5789e691401d5f87eafed9bd2763f0dbe47af6d6b66012a509404766", size = 682173, upload-time = "2026-09-28T05:19:12.762Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/27b0cb56ff5d2bc92458bf6fcebdb1f0dc01f57f043a178e9f9d74498169/hypothesis-6.168.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e2d4c68729a13df9af4998d2652cfb5d541c5609b88c306880a5dfb284938ec2", size = 793287, upload-time = "2026-09-28T05:20:38.522Z" }, + { url = "https://files.pythonhosted.org/packages/e8/bd/5342f95c3bc36586777ef8cb8eba87ac7acf0184210327fb0e6d8654e295/hypothesis-6.168.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b345f818083ec99966a43ca4f7b38feb62c6920ce28572bc2bde4948a8b7eaba", size = 784857, upload-time = "2026-09-28T05:20:42.609Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5f/fc774241518a0588d362680a3084275058aab86d6bc02a2c61e1073142d0/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0608c610fc002978fc5de0f471770d8817e9455cb8649a47983c9f8bccfc1897", size = 1123330, upload-time = "2026-09-28T05:19:29.735Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/131f16775a3dca5f4fe27f0d6ad9a9851600098a54a872d163f6ebf0b664/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dcf6448b1ecc37f2b23f2d1b3ddfc9ff6b6910614c15a642dc82f419b96037b", size = 1169178, upload-time = "2026-09-28T05:19:53.436Z" }, + { url = "https://files.pythonhosted.org/packages/06/61/c9f5bff8b73321c9fd12f2d69666c6e4861b97b60f5f24fdd6ac293007fd/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f9f094041dcce5119ebd7bab71062743ab02b6e654d056b370beda78c19e2", size = 1299127, upload-time = "2026-09-28T05:20:14.486Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6d/d4618f8ab12dd76c4d58ea052252759aa2bf0c4e71f4b502ba5d577e0b9f/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d479985fe73af97badfb72dc6d20c6a353e486a36f6d065f600026e0cf954a86", size = 1335390, upload-time = "2026-09-28T05:20:10.326Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/727161e17cc297df1aeea945de05c532033b66751625bec192bc5b5ff707/hypothesis-6.168.3-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:785e2c45f8c08e274b4bf1ccae97f1a4e09407e9a68e80790cfab1d17a4a45fa", size = 624291, upload-time = "2026-09-28T05:18:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/77/23/2f9b506392a0b8712440bcb781abc5713be9ffac1303b6d10f3669050882/hypothesis-6.168.3-cp314-cp314-win_amd64.whl", hash = "sha256:320920b1e3dae8611eee8a03d063cf2187446f2a17c38cfb8a7fc1466f71eee2", size = 682064, upload-time = "2026-09-28T05:20:16.336Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/efabfd95eb2b69c0c9fa1e1c83c8290aa2715d46baeb5a7764faf09c133a/hypothesis-6.168.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:35380baa981108a7f60c4eab71e46acd8d8f58440520346a1a6aba06dca7e074", size = 791875, upload-time = "2026-09-28T05:19:37.789Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fc/2a0ada1623a9a33048bbf9f00a7c92513398c6b8865cba5efb854262ce30/hypothesis-6.168.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f0aaaed00438fa6673d856aaee12a4a8afbde82a9f3c5ff487cacfb064239afe", size = 783412, upload-time = "2026-09-28T05:18:48.266Z" }, + { url = "https://files.pythonhosted.org/packages/41/31/73c615d37eeb12da0209555612c32ced628d860267c23cfd2691c6207aea/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27e6df1576bf838e7f484d4ae5cba92114497ca30afb917056bf1ea33e95675", size = 1121662, upload-time = "2026-09-28T05:19:26.225Z" }, + { url = "https://files.pythonhosted.org/packages/92/54/1893344a3b8bbf83fa7c9bb7e96f6836580213854f46671cb749f7b4706c/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3bc85014577982ec6d2e266edc5cd6e7a7d3674c648791ca983c67a52f89a0c4", size = 1167761, upload-time = "2026-09-28T05:18:49.85Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/a61d82968febd25a0f08ffbbfabfaeead66f68fc5fa73f3c9ece1c36fc8f/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ccfc29505aa1cdcc254cb9cd701fd0811fef5480addd97d4127a00df023410f7", size = 1297309, upload-time = "2026-09-28T05:19:51.711Z" }, + { url = "https://files.pythonhosted.org/packages/bc/01/fe8e4cf6d39d02f4efadaa351427fa13722086cfc54a2da3579741c5177a/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32d0699566aaa93f9e97a44705d7164386f1e91de78d277f53bada35e78bcc96", size = 1334260, upload-time = "2026-09-28T05:19:00.121Z" }, + { url = "https://files.pythonhosted.org/packages/39/6a/09177ebc62f4778f94379ecade6a51299d3d8c4f4b75ba636bf3a0202364/hypothesis-6.168.3-cp314-cp314t-win_amd64.whl", hash = "sha256:28d88fa174ecbd4ecbd7bb290f06d0db084a3971c3f511ae2830b65b5e25500f", size = 681992, upload-time = "2026-09-28T05:19:48.043Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f6/890bf33d608cd63348d3146a3ca83362fbf09e589c5e5230a00c403070f4/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:61f5782d807b1e6aa5c9beef1054cb2037e7d1add48a64972cd6ee447778781f", size = 791231, upload-time = "2026-09-28T05:18:43.998Z" }, + { url = "https://files.pythonhosted.org/packages/03/dd/fc36b204f8aa7437c676576d9437f0422de46aa8a729e6cd5bbb0224e759/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:7aacf3cf40c7ce8f9e4924d5b57bc0b068beafdfed2347bcf160a28b4cfbba7b", size = 783171, upload-time = "2026-09-28T05:19:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d2/3cd087d577db67c404991d7723e64d7cd75570cdc82a0f7329aed6f2086c/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01768a03a30dc54df7fe457c0b34016c84d598fa00d5965ededab93ba4eb3408", size = 1121152, upload-time = "2026-09-28T05:18:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/42/6f/a05a68cc66e7a22701c50bf94fb9ddeb36f4f5d0de3cc130066709eda621/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1a8a4ffc6c6e6e577f2bfbcfebf7cffbb310283ba2532c729570a0a752cebaaa", size = 1144069, upload-time = "2026-09-28T05:20:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f4/fdd7a093fb2fb3a0ce24256c92ad5bf67936f4669bd06d5ceae4298150ec/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b987d73eba95183a7e59cca6d1925c588aa4307922d9852cc2b4d282a2ae4128", size = 1146692, upload-time = "2026-09-28T05:20:25.83Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/6dbd4377e935505ae4fc8ee4c7b18c69994c4bbaca015447c470218bdbee/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4569c39bd97d9573e946429ed676f3b55a7c8ed80920addd67d384a47d59b38", size = 1189480, upload-time = "2026-09-28T05:20:46.734Z" }, + { url = "https://files.pythonhosted.org/packages/ba/bf/ff288b496b690000d2686dcfa7f67855e4c5a6dddb464ed8e4880be83bb2/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6042b8707a4b25bbbfe20b110258fb7549a68951e5525608a8ce06091039e9fc", size = 1167109, upload-time = "2026-09-28T05:19:24.758Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d1/3fee2bc29fc747fabbb27e174515592e390809ca465d3bbbabbcfa3235a8/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:f413629de94d38a7a2ad259697a6752526143cb49c2e2d7bdba19381c80693f6", size = 1126823, upload-time = "2026-09-28T05:18:52.636Z" }, + { url = "https://files.pythonhosted.org/packages/58/6a/585294392fa6a6d9719446a042a777ba98ecca265d9e48cbedc10c88df0f/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f071737e4e775bebba07e319eb645a880d1e0186b4d24bad23255e849d86d483", size = 1155773, upload-time = "2026-09-28T05:20:51.268Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4d/fa283ff79996debf1ff08593e01f8b773eb8211b19b3d6d5d491317a65bf/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:f59a3912858d0609c26054aa1c474847c797937f5e0e1c77f0d3f08032e50f09", size = 1296671, upload-time = "2026-09-28T05:18:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/42/05/98f9c2f628da5afabb6c3e8df5d2d299d2d50bf306b14a6e26d3954c4951/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:f09c05a23a8025dd5cad07c2ed299a46778e72d49ff0dd5bf353bcc0f7dc1580", size = 1422001, upload-time = "2026-09-28T05:19:04.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c9/f2109ade29a7ec284d55bca328d784743e8b13e321576fd34ee7e65f99af/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:44ace770bda3a0301739fc5a413c764de790c739df1f1a7218dd049f8594d9f5", size = 1374182, upload-time = "2026-09-28T05:20:27.764Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6d/e05d5f72441564a3bebc71fa155deafd0fd3b015d6014ec8a00edf6e42bb/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:03b131043608f94a2578a2a896a1a72092acb7079815eef5b8513b08447e0e62", size = 1278402, upload-time = "2026-09-28T05:20:56.332Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/6988a7f1f69c5c530687dce03a32c157e2d878e3a2e32af9269ce016b78a/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:e9784aca26eddfe99b03a0292320db742cc8a74200ef864fdce949f526f973cd", size = 1297770, upload-time = "2026-09-28T05:19:11.387Z" }, + { url = "https://files.pythonhosted.org/packages/a2/28/5bb82b60b836bd2329e1fe01ad94efc2b14dfa806f8e50ed14b795d78770/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:2b52ac363096232bebc2add117e9178d91f4248f4cdb919fd1026b5f86a4bb16", size = 1333977, upload-time = "2026-09-28T05:20:02.483Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7d/d419841b8f65481ea1a50c4ba36f2670d48b8c7a51e4e8586089564cf7f6/hypothesis-6.168.3-cp315-abi3.abi3t-win32.whl", hash = "sha256:d28e3a6b511a74ce37df5274b51f36c2b274fea7365e00b16f0c81e22acd5957", size = 675394, upload-time = "2026-09-28T05:19:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/1de6a2ad100e44817f2e9a8e8ba3eeaf4769621f4f87f2d4966c4971fcc4/hypothesis-6.168.3-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:7b9638789548361a57d984f56619ac694a914c328181d911271618409261ff4a", size = 681699, upload-time = "2026-09-28T05:19:09.957Z" }, + { url = "https://files.pythonhosted.org/packages/c0/79/be3370fd02734d6b1d950183ae9224580d19fa8d356b95348d60f1e3eeb7/hypothesis-6.168.3-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:65d78e4357ec48ed2c67825f06740ee3599be4cfe770a6092bed07108d679ac5", size = 679849, upload-time = "2026-09-28T05:19:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0e/27960db1d45a94497e1681fb9d7b9140b44da4e655ba20f0d82f95d10230/hypothesis-6.168.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:101531b4ccf7fa12965a6b295d10b64c2f8a8fab61e1f9b18c55335d8fb02575", size = 793135, upload-time = "2026-09-28T05:19:14.056Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cb/be08379286c1925fcd651b92ed835f470a83b859b5c484b3431da9d851f5/hypothesis-6.168.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:41e0120814de5c3a6b58d8cb27cb11b8873ab33fba130cd09855a27f5a12a1ca", size = 788995, upload-time = "2026-09-28T05:20:34.164Z" }, + { url = "https://files.pythonhosted.org/packages/38/7b/6d885d139b2ed76e2f4908cf10eae56acc73bf05cbef286357c4559c9a04/hypothesis-6.168.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d120009d145697909f2f0c532f2d568558ddd6990707f8cba6576bfb9770696", size = 1124974, upload-time = "2026-09-28T05:19:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/05/cc/b302ae809b48254830cae57801d0af0b66783df515b5dd56b9f21c8dfa86/hypothesis-6.168.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:818b3d09bba60ef90463e47a4bc87275dba1e8aeb184d3102888440ca7c7837d", size = 1171967, upload-time = "2026-09-28T05:20:12.295Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ab/51031080a6600f5b07adf5f9dfa117345c2adda07a5c24bda523e70bd1e9/hypothesis-6.168.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9e3c36eba80e089ecfa28ec08049723223cb41d3cfe4d9988169cf98440e402b", size = 685641, upload-time = "2026-09-28T05:19:44.609Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -1746,6 +1834,7 @@ dev = [ { name = "build" }, { name = "coverage" }, { name = "furo" }, + { name = "hypothesis" }, { name = "jupyter-book" }, { name = "ruff" }, { name = "setuptools" }, @@ -1757,6 +1846,7 @@ requires-dist = [ { name = "build", marker = "extra == 'dev'", specifier = ">=1.2.2.post1" }, { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.9.2" }, { name = "furo", marker = "extra == 'dev'", specifier = ">=2024.8.6" }, + { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.100.0" }, { name = "jupyter-book", marker = "extra == 'dev'", specifier = ">=1.0.4.post1" }, { name = "microdf-python", specifier = ">=1.0.0" }, { name = "pandas", specifier = ">=3.0" }, From bb6eb56a6cd335527b53cd1291beccfea260501f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 15:25:37 -0400 Subject: [PATCH 2/6] Count distributions inside Schedule D in the NII invariant reference filer_loss_limited_net_capital_gains (from #9784) now nets the head and spouse's capital gain distributions with their Schedule D gains and losses before the 1211(b) limit, and net investment income no longer adds the distributions separately. The invariant test's reference computation follows: distributions move from the person-level Form 8960 amounts to the Schedule D inputs. Co-Authored-By: Claude Opus 5.5 --- ...est_dependent_net_investment_income_invariants.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/policyengine_us/tests/core/test_dependent_net_investment_income_invariants.py b/policyengine_us/tests/core/test_dependent_net_investment_income_invariants.py index 4be5e561e74..7c7798c1ec5 100644 --- a/policyengine_us/tests/core/test_dependent_net_investment_income_invariants.py +++ b/policyengine_us/tests/core/test_dependent_net_investment_income_invariants.py @@ -18,8 +18,8 @@ AGI, NIIT MAGI or `net_investment_income_tax`. 2. `net_investment_income` equals an independent numpy computation over the head and spouse: their interest, dividends, rents, passive pass-through, - estate and trust income and capital gain distributions, plus their netted - Schedule D gain or loss, limited under 26 USC 1211(b). + estate and trust income, plus their netted Schedule D gain or loss with + capital gain distributions on line 13, limited under 26 USC 1211(b). 3. -loss limit <= `filer_loss_limited_net_capital_gains`, and it equals `loss_limited_net_capital_gains` when the unit has no dependents. 4. For tax units without dependents, `net_investment_income` equals the @@ -56,7 +56,12 @@ + NONNEGATIVE_INPUTS + ["partnership_s_corp_income", "passive_partnership_s_corp_income"] ) -CAPITAL_INPUTS = ["short_term_capital_gains", "long_term_capital_gains"] +# Schedule D gains and losses, with capital gain distributions (line 13). +CAPITAL_INPUTS = [ + "short_term_capital_gains", + "long_term_capital_gains", + "non_sch_d_capital_gains", +] # Every person-level amount Form 8960 counts, other than Schedule D gains. NII_PERSON_INPUTS = [ "taxable_interest_income", @@ -65,7 +70,6 @@ "rental_income", "passive_partnership_s_corp_income", "estate_income", - "non_sch_d_capital_gains", ] OUTPUTS = [ "net_investment_income", From 6f0349149750f3a8ed28226112eaf65d95666429 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 15:50:51 -0400 Subject: [PATCH 3/6] Test the netting when a reform drops a gross income source capital_losses_allowed_against_gains counts only the capital gains that gov.irs.gross_income.sources includes, and dependent_taxable_ss_magi nets distributions only when both sources are listed. Cover both branches with reforms that drop non_sch_d_capital_gains or capital_gains. Co-Authored-By: Claude Opus 5.5 --- ...t_capital_gain_distributions_loss_limit.py | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/policyengine_us/tests/test_capital_gain_distributions_loss_limit.py b/policyengine_us/tests/test_capital_gain_distributions_loss_limit.py index af0c7dc51e1..fa81e20cb38 100644 --- a/policyengine_us/tests/test_capital_gain_distributions_loss_limit.py +++ b/policyengine_us/tests/test_capital_gain_distributions_loss_limit.py @@ -39,6 +39,9 @@ import numpy as np import pytest +from policyengine_core.periods import instant +from policyengine_core.reforms import Reform + from policyengine_us import Simulation YEAR = 2025 @@ -203,6 +206,83 @@ def test_example_from_the_schedule_d_instructions(): assert model["limited_capital_loss"][0] == 2_000 +# --------------------------------------------------------------------------- +# Reforms that drop a source from gross income. +# --------------------------------------------------------------------------- + + +def without_gross_income_source(name): + """A reform that removes one source from gov.irs.gross_income.sources.""" + + class reform(Reform): + def apply(self): + def modify(parameters): + sources = parameters.gov.irs.gross_income.sources + values = [s for s in sources(f"{YEAR}-01-01") if s != name] + sources.update( + start=instant(f"{YEAR}-01-01"), + stop=instant(f"{YEAR}-12-31"), + value=values, + ) + return parameters + + self.modify_parameters(modify) + + return reform + + +@pytest.mark.parametrize( + "dropped, agi, allowed_against_gains, net_loss, dependent_magi", + [ + # Baseline: Schedule D line 16 = -5,000 + 3,000 = -2,000. + (None, -2_000, 3_000, 2_000, -2_000), + # Distributions out of gross income: nothing to net the loss against, + # so the full 3,000 limit applies, on the dependent's return too. + ("non_sch_d_capital_gains", -3_000, 0, 3_000, -3_000), + # Capital gains out of gross income: the losses still net against the + # distributions on the head's return. The dependent's modified AGI + # then counts the distributions alone, as gross income does. + ("capital_gains", -2_000, 3_000, 2_000, 3_000), + ], +) +def test_netting_follows_the_gross_income_sources( + dropped, agi, allowed_against_gains, net_loss, dependent_magi +): + people = { + "head": { + "age": {YEAR: 45}, + "is_tax_unit_head": {YEAR: True}, + "long_term_capital_gains": {YEAR: -5_000}, + "non_sch_d_capital_gains": {YEAR: 3_000}, + }, + "dependent": { + "age": {YEAR: 70}, + "is_tax_unit_dependent": {YEAR: True}, + "is_tax_unit_spouse": {YEAR: False}, + "long_term_capital_gains": {YEAR: -5_000}, + "non_sch_d_capital_gains": {YEAR: 3_000}, + }, + } + members = list(people) + simulation = Simulation( + situation={ + "people": people, + "tax_units": {"tax_unit": {"members": members}}, + "households": { + "household": {"members": members, "state_code": {YEAR: "TX"}} + }, + }, + reform=without_gross_income_source(dropped) if dropped else None, + ) + assert simulation.calculate("adjusted_gross_income", YEAR)[0] == agi + assert ( + simulation.calculate("capital_losses_allowed_against_gains", YEAR)[0] + == allowed_against_gains + ) + assert simulation.calculate("limited_capital_loss", YEAR)[0] == net_loss + assert simulation.calculate("dependent_taxable_ss_magi", YEAR)[1] == dependent_magi + + # --------------------------------------------------------------------------- # Hypothesis. # --------------------------------------------------------------------------- From 9c2029429a489714774a4b84e0dbde5a1342ee33 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 19:05:19 -0400 Subject: [PATCH 4/6] Address review: Michigan adjustments, filer-only worksheet test, floors From the independent review of #9788: - mi_household_resources subtracted all above-the-line deductions, including the capital loss already netted in the capital gains line (MI-1040CR line 30 is U.S. Schedule 1 adjustments only). It now leaves out capital_losses_allowed_against_gains and limited_capital_loss. The two existing loss-limit cases double counted the loss: 44,000 -> 47,000 and 47,000 -> 48,500. - has_qdiv_or_ltcg figures Schedule D lines 15 and 16 over the head and spouse, so a dependent's loss cannot switch off the filer's worksheet. - Distributions are floored at zero per person in every line 7a variable (box 2a is not negative), as irs_gross_income does. - Capital losses are deductible only while capital_gains is a gross income source, matching dependent_taxable_ss_magi under such a reform. - Test that the two loss-limit parameters agree; rename the #9784 invariant test that no longer compares with the pre-change formula. - loss_ald label: "Business and capital loss ALD". Co-Authored-By: Claude Opus 5.5 --- ...endent_net_investment_income_invariants.py | 8 ++- ...schedule_d_capital_gain_distributions.yaml | 55 +++++++++++++++++ .../mi/tax/income/mi_household_resources.yaml | 60 +++++++++++++++++-- ...t_capital_gain_distributions_loss_limit.py | 30 ++++++++-- .../capital_losses_allowed_against_gains.py | 4 ++ .../limited_capital_loss.py | 4 ++ .../above_the_line_deductions/loss_ald.py | 2 +- .../filer_loss_limited_net_capital_gains.py | 20 +++---- .../irs_gross_income/has_qdiv_or_ltcg.py | 32 ++++++---- .../loss_limited_net_capital_gains.py | 6 +- .../loss_limited_net_capital_gains_person.py | 4 +- .../mi/tax/income/mi_household_resources.py | 14 ++++- 12 files changed, 199 insertions(+), 40 deletions(-) diff --git a/policyengine_us/tests/core/test_dependent_net_investment_income_invariants.py b/policyengine_us/tests/core/test_dependent_net_investment_income_invariants.py index 7c7798c1ec5..484e9d1ce60 100644 --- a/policyengine_us/tests/core/test_dependent_net_investment_income_invariants.py +++ b/policyengine_us/tests/core/test_dependent_net_investment_income_invariants.py @@ -23,7 +23,9 @@ 3. -loss limit <= `filer_loss_limited_net_capital_gains`, and it equals `loss_limited_net_capital_gains` when the unit has no dependents. 4. For tax units without dependents, `net_investment_income` equals the - pre-change all-member formula, so filers without dependents see no change. + all-member formula (person-level sources plus + `loss_limited_net_capital_gains`), so leaving dependents out changes + nothing for filers without dependents. 5. 0 <= NIIT = 3.8% x min(max(0, NII), max(0, MAGI - threshold)). """ @@ -303,8 +305,8 @@ def test_filer_capital_gains_within_loss_limit(runs, units): ) -def test_units_without_dependents_match_pre_change_formula(runs, units): - """Differential check against the all-member formula used before.""" +def test_units_without_dependents_match_all_member_formula(runs, units): + """Differential check against the all-member formula.""" person_nii = sum(_by_person(units, name) for name in NII_PERSON_INPUTS) old = _unit_sum(runs, person_nii) + runs["with"]["loss_limited_net_capital_gains"] no_dependents = np.array([not u["dependents"] for u in units]) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml index 6925c15a5e5..681e96b22f0 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml @@ -333,3 +333,58 @@ loss_ald: 55_000 # Only the self-employment loss counts: max(0, 50,000 - 12,000). dc_self_employment_loss_addition: 38_000 + +- name: A dependent's capital loss does not change the head's worksheet test + period: 2025 + input: + people: + head: + age: 45 + is_tax_unit_head: true + employment_income: 300_000 + non_sch_d_capital_gains: 5_000 + child: + age: 20 + is_tax_unit_dependent: true + is_tax_unit_spouse: false + long_term_capital_gains: -10_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + # The head's own Form 1040 line 7a is the 5,000 of distributions (no + # Schedule D needed), so line 16 routes to the Qualified Dividends and + # Capital Gain Tax Worksheet. The dependent's loss is on the + # dependent's own return. + has_qdiv_or_ltcg: true + filer_loss_limited_net_capital_gains: 5_000 + adjusted_gross_income: 305_000 + +- name: A negative distributions input counts as zero + period: 2025 + input: + people: + person1: + age: 45 + non_sch_d_capital_gains: -2_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Form 1099-DIV box 2a is not negative; gross income and Form 1040 line + # 7a both floor the input at zero. + adjusted_gross_income: 0 + loss_limited_net_capital_gains: 0 + filer_loss_limited_net_capital_gains: 0 + loss_limited_net_capital_gains_person: 0 + net_investment_income: 0 + has_qdiv_or_ltcg: false + diff --git a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml index f04cbe65ec2..629963b3f8d 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml @@ -87,9 +87,10 @@ output: # Per MI-1040CR-7 Line 24: losses cannot exceed $3,000 for MFJ # loss_limited_net_capital_gains: max(-150,000, -3,000) = -3,000 - # above_the_line_deductions includes loss_ald of 3,000 - # Total: 50,000 + (-3,000) - 3,000 = 44,000 - mi_household_resources: 44_000 + # The capital loss is not a U.S. Schedule 1 adjustment (MI-1040CR line + # 30), so it is not subtracted a second time. + # Total: 50,000 + (-3,000) = 47,000 + mi_household_resources: 47_000 - name: Capital gains loss limited to 1500 for MFS period: 2024 @@ -101,6 +102,53 @@ output: # Per MI-1040CR-7 Line 24: losses cannot exceed $1,500 for MFS # loss_limited_net_capital_gains: max(-100,000, -1,500) = -1,500 - # above_the_line_deductions includes loss_ald of 1,500 - # Total: 50,000 + (-1,500) - 1,500 = 47,000 - mi_household_resources: 47_000 + # Not subtracted again as a Schedule 1 adjustment (line 30). + # Total: 50,000 + (-1,500) = 48,500 + mi_household_resources: 48_500 + +- name: Capital gain distributions net against a capital loss + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + long_term_capital_gains: -20_000 + non_sch_d_capital_gains: 20_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MI + output: + # 2025 MI-1040 book, MI-1040CR line 19: "net capital gains and losses ... + # from your U.S. Schedule D". Schedule D line 16 = -20,000 + 20,000 + # (line 13) = 0. Line 30 (U.S. Schedule 1 adjustments) has none. + mi_household_resources: 50_000 + +- name: Spouses' capital gains and losses net on a joint Schedule D + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + long_term_capital_gains: 40_000 + person2: + age: 45 + long_term_capital_gains: -40_000 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 19: Schedule D line 16 = 40,000 - 40,000 = 0. + mi_household_resources: 50_000 diff --git a/policyengine_us/tests/test_capital_gain_distributions_loss_limit.py b/policyengine_us/tests/test_capital_gain_distributions_loss_limit.py index fa81e20cb38..398df7e19db 100644 --- a/policyengine_us/tests/test_capital_gain_distributions_loss_limit.py +++ b/policyengine_us/tests/test_capital_gain_distributions_loss_limit.py @@ -239,10 +239,10 @@ def modify(parameters): # Distributions out of gross income: nothing to net the loss against, # so the full 3,000 limit applies, on the dependent's return too. ("non_sch_d_capital_gains", -3_000, 0, 3_000, -3_000), - # Capital gains out of gross income: the losses still net against the - # distributions on the head's return. The dependent's modified AGI - # then counts the distributions alone, as gross income does. - ("capital_gains", -2_000, 3_000, 2_000, 3_000), + # Capital gains out of gross income: capital losses from sales are + # not deductible either, on the head's return or the dependent's; + # the distributions stay in gross income. + ("capital_gains", 3_000, 0, 0, 3_000), ], ) def test_netting_follows_the_gross_income_sources( @@ -283,6 +283,28 @@ def test_netting_follows_the_gross_income_sources( assert simulation.calculate("dependent_taxable_ss_magi", YEAR)[1] == dependent_magi +def test_the_two_loss_limit_parameters_agree(): + """limited_capital_loss and dependent_taxable_ss_magi read + gov.irs.ald.loss.capital.max; loss_limited_net_capital_gains and + filer_loss_limited_net_capital_gains read gov.irs.capital_gains.loss_limit. + AGI's capital amount equals Form 1040 line 7a only while they agree.""" + from policyengine_us import CountryTaxBenefitSystem + + parameters = CountryTaxBenefitSystem().parameters + for year in range(2013, 2036): + irs = parameters(f"{year}-01-01").gov.irs + for status in [ + "SINGLE", + "JOINT", + "SEPARATE", + "HEAD_OF_HOUSEHOLD", + "SURVIVING_SPOUSE", + ]: + assert ( + irs.ald.loss.capital.max[status] == irs.capital_gains.loss_limit[status] + ), (year, status) + + # --------------------------------------------------------------------------- # Hypothesis. # --------------------------------------------------------------------------- diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/capital_losses_allowed_against_gains.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/capital_losses_allowed_against_gains.py index a44fb1635b0..745a37c4552 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/capital_losses_allowed_against_gains.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/capital_losses_allowed_against_gains.py @@ -52,5 +52,9 @@ def formula(tax_unit, period, parameters): if source in sources: gains += max_(0, person(source, period)) gains_in_gross_income = tax_unit.sum(not_dependent * gains) + # Losses from sales and exchanges are deductible while their gains + # are in gross income (a reform can drop capital_gains from it). + if "capital_gains" not in sources: + return 0 * gains_in_gross_income capital_losses = tax_unit_non_dep_sum("capital_losses", tax_unit, period) return min_(capital_losses, gains_in_gross_income) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss.py index 858a0a11da0..2009c72a419 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss.py @@ -29,6 +29,10 @@ def formula(tax_unit, period, parameters): p = parameters(period).gov.irs filing_status = tax_unit("filing_status", period) max_loss = p.ald.loss.capital.max[filing_status] + # As in capital_losses_allowed_against_gains: no capital loss + # deduction when a reform takes capital gains out of gross income. + if "capital_gains" not in p.gross_income.sources: + return 0 * max_loss # A tax-unit dependent's capital losses belong on the dependent's own # return, as in irs_gross_income. capital_losses = tax_unit_non_dep_sum("capital_losses", tax_unit, period) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py index 5e199a624bc..d51b44af919 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py @@ -4,7 +4,7 @@ class loss_ald(Variable): value_type = float entity = TaxUnit - label = "Business loss ALD" + label = "Business and capital loss ALD" unit = USD documentation = ( "Above-the-line deduction from gross income for business and capital losses." diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py index dcc7ae68f6b..120ca814d65 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py @@ -25,15 +25,15 @@ def formula(tax_unit, period, parameters): p = parameters(period).gov.irs filing_status = tax_unit("filing_status", period) loss_limit = p.capital_gains.loss_limit[filing_status] - # Capital gain distributions go on Schedule D line 13 with the other - # gains and losses, so they net before the limit. net_capital_gains = tax_unit_non_dep_add( - tax_unit, - period, - [ - "long_term_capital_gains", - "short_term_capital_gains", - "non_sch_d_capital_gains", - ], + tax_unit, period, ["long_term_capital_gains", "short_term_capital_gains"] + ) + # Capital gain distributions go on Schedule D line 13 with the other + # gains and losses, so they net before the limit. Each filer's input + # is floored at zero, as in irs_gross_income. + person = tax_unit.members + not_dependent = ~person("is_tax_unit_dependent", period) + distributions = tax_unit.sum( + not_dependent * max_(0, person("non_sch_d_capital_gains", period)) ) - return max_(-loss_limit, net_capital_gains) + return max_(-loss_limit, net_capital_gains + distributions) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/has_qdiv_or_ltcg.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/has_qdiv_or_ltcg.py index 07761ee07bc..ac66f03c566 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/has_qdiv_or_ltcg.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/has_qdiv_or_ltcg.py @@ -8,10 +8,10 @@ class has_qdiv_or_ltcg(Variable): documentation = ( "Whether this tax unit figures its tax with the Qualified Dividends " "and Capital Gain Tax Worksheet or the Schedule D Tax Worksheet: it " - "has qualified dividends, or Schedule D lines 15 and 16 are both more " - "than zero. Capital gain distributions are on Schedule D line 13, so " - "a filer who reports them without Schedule D has lines 15 and 16 " - "equal to them." + "has qualified dividends, or the head and spouse's Schedule D lines " + "15 and 16 are both more than zero. Capital gain distributions are on " + "Schedule D line 13, so a filer who reports them without Schedule D " + "has lines 15 and 16 equal to them." ) definition_period = YEAR reference = ( @@ -27,13 +27,21 @@ class has_qdiv_or_ltcg(Variable): def formula(tax_unit, period, parameters): qualified_dividends = add(tax_unit, period, ["qualified_dividend_income"]) - # Schedule D line 15: net long-term capital gain or loss, including - # capital gain distributions (line 13). - line_15 = add( - tax_unit, - period, - ["long_term_capital_gains", "non_sch_d_capital_gains"], + # Schedule D lines 15 and 16 of the head and spouse; a tax unit + # dependent's gains and losses are on the dependent's own return. + person = tax_unit.members + not_dependent = ~person("is_tax_unit_dependent", period) + distributions = tax_unit.sum( + not_dependent * max_(0, person("non_sch_d_capital_gains", period)) + ) + # Line 15: net long-term capital gain or loss, including capital gain + # distributions (line 13). + line_15 = ( + tax_unit_non_dep_add(tax_unit, period, ["long_term_capital_gains"]) + + distributions + ) + # Line 16: line 15 plus the net short-term gain or loss. + line_16 = line_15 + tax_unit_non_dep_add( + tax_unit, period, ["short_term_capital_gains"] ) - # Schedule D line 16: line 15 plus the net short-term gain or loss. - line_16 = line_15 + add(tax_unit, period, ["short_term_capital_gains"]) return (qualified_dividends > 0) | ((line_15 > 0) & (line_16 > 0)) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains.py index 8306853e817..f7693680121 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains.py @@ -41,5 +41,9 @@ def formula(tax_unit, period, parameters): # Capital gain distributions reported without Schedule D. A filer with # capital losses or other capital gains files Schedule D and enters # them on line 13, so they net against losses before the limit. - distributions = add(tax_unit, period, ["non_sch_d_capital_gains"]) + # Form 1099-DIV box 2a amounts are not negative; floor each person's + # input at zero, as irs_gross_income does. + distributions = tax_unit.sum( + max_(0, tax_unit.members("non_sch_d_capital_gains", period)) + ) return max_(-loss_limit, net_capital_gains + distributions) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains_person.py index 2e4632639bf..74c84afb65a 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/loss_limited_net_capital_gains_person.py @@ -17,8 +17,8 @@ class loss_limited_net_capital_gains_person(Variable): def formula(person, period, parameters): # The person's capital gains and losses, plus capital gain # distributions, which go on Schedule D line 13 with them. - person_capital_gains = add( - person, period, ["capital_gains", "non_sch_d_capital_gains"] + person_capital_gains = person("capital_gains", period) + max_( + 0, person("non_sch_d_capital_gains", period) ) # Get tax unit totals diff --git a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py index 5ea1d3c7c5f..7364f38af3f 100644 --- a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py +++ b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py @@ -14,6 +14,10 @@ class mi_household_resources(Variable): "section-206-508/", "https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/" "Forms/IIT/TY2024/BOOK_MI-1040CR-7.pdf", + # 2025 MI-1040 book, MI-1040CR lines 19 (capital gains) and 30 + # (adjustments from U.S. Schedule 1). + "https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/" + "Forms/IIT/TY2025/MI-1040-Book.pdf#page=32", ) def formula(tax_unit, period, parameters): @@ -45,7 +49,15 @@ def formula(tax_unit, period, parameters): total += add(tax_unit, period, [source]) health_insurance_premiums = add(tax_unit, period, ["health_insurance_premiums"]) - above_the_line_deductions = tax_unit("above_the_line_deductions", period) + # Line 30: "total adjustments from your U.S. Form 1040, Schedule 1". + # Capital losses are not Schedule 1 adjustments: they are already + # netted and limited in the capital gains line (Form 1040 line 7a), + # so the capital parts of loss_ald are not subtracted again. + above_the_line_deductions = tax_unit("above_the_line_deductions", period) - add( + tax_unit, + period, + ["capital_losses_allowed_against_gains", "limited_capital_loss"], + ) return max_( 0, total - health_insurance_premiums - above_the_line_deductions, From b047c783abc21bf5e5494c3e4296e8d68f1a1922 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 21:28:27 -0400 Subject: [PATCH 5/6] Follow 1211(b) in the #9647 invariant and a Maryland surtax test CI caught two tests written against the old capital loss formula: - test_dependent_losses_agi_invariants (#9647) compared limited_capital_loss with min(cap, sum of losses), which does not net one spouse's gains against the other's losses. Its reference now splits 1211(b) the same way: losses allowed up to the gains, plus the excess up to the cap. Renamed ..._match_all_member_formula. - md_capital_gains_surtax "partial offset" set capital_losses (a formula variable, max(0, -capital_gains)) to 50,000 next to 150,000 of net gains, a state no inputs can produce, and its comment applied the $3,000 cap to losses within gains. It now inputs the short-term loss directly (30,000 gains less 50,000 losses = -20,000): Schedule D line 16 and Form 502CG line 1 are 100,000, surtax 2,000 (same on main). Co-Authored-By: Claude Opus 5.5 --- .../test_dependent_losses_agi_invariants.py | 28 +++++++++++-------- .../md_capital_gains_surtax.yaml | 15 +++++----- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/policyengine_us/tests/core/test_dependent_losses_agi_invariants.py b/policyengine_us/tests/core/test_dependent_losses_agi_invariants.py index 467f3a2e155..53fc73fc7f1 100644 --- a/policyengine_us/tests/core/test_dependent_losses_agi_invariants.py +++ b/policyengine_us/tests/core/test_dependent_losses_agi_invariants.py @@ -20,9 +20,12 @@ 2. 0 <= limited_capital_loss <= the filing-status cap, and it never exceeds the non-dependents' own capital losses (26 USC 1211(b)). 3. loss_ald >= limited_capital_loss >= 0. -4. For tax units without dependents, both variables equal the pre-change - all-member formula (a reference implementation below), so filers without - dependents see no change. +4. For tax units without dependents, both variables equal the all-member + formula (a reference implementation below), so leaving dependents out + changes nothing for filers without dependents. The capital part follows + 26 USC 1211(b): losses are allowed up to the gains + (`capital_losses_allowed_against_gains`), plus the excess up to the cap + (`limited_capital_loss`). """ import numpy as np @@ -258,8 +261,8 @@ def test_loss_ald_at_least_limited_capital_loss(runs): ) -def test_units_without_dependents_match_pre_change_formula(runs, units): - """Differential check against the all-member formula used before.""" +def test_units_without_dependents_match_all_member_formula(runs, units): + """Differential check against the all-member formula.""" p = CountryTaxBenefitSystem().parameters(f"{runs['year']}-01-01").gov.irs filing_status = runs["with"]["filing_status"] se = _by_person(units, "self_employment_income") + _by_person( @@ -269,12 +272,13 @@ def test_units_without_dependents_match_pre_change_formula(runs, units): income = sum(_unit_sum(runs, np.maximum(x, 0)) for x in person_business) loss = sum(_unit_sum(runs, np.maximum(-x, 0)) for x in person_business) limited_business_loss = np.minimum(loss, income + p.ald.loss.max[filing_status]) - capital_losses = _unit_sum( - runs, - np.maximum(0, -sum(_by_person(units, name) for name in CAPITAL_INPUTS)), - ) + person_capital = sum(_by_person(units, name) for name in CAPITAL_INPUTS) + capital_losses = _unit_sum(runs, np.maximum(0, -person_capital)) + capital_gains = _unit_sum(runs, np.maximum(0, person_capital)) + allowed_against_gains = np.minimum(capital_losses, capital_gains) limited_capital_loss = np.minimum( - p.ald.loss.capital.max[filing_status], capital_losses + p.ald.loss.capital.max[filing_status], + capital_losses - allowed_against_gains, ) no_dependents = np.array([not u["dependents"] for u in units]) assert no_dependents.sum() > 100 @@ -285,6 +289,8 @@ def test_units_without_dependents_match_pre_change_formula(runs, units): ) np.testing.assert_allclose( runs["with"]["loss_ald"][no_dependents], - (limited_business_loss + limited_capital_loss)[no_dependents], + (limited_business_loss + allowed_against_gains + limited_capital_loss)[ + no_dependents + ], atol=TOLERANCE, ) diff --git a/policyengine_us/tests/policy/baseline/gov/states/md/tax/income/capital_gains/md_capital_gains_surtax.yaml b/policyengine_us/tests/policy/baseline/gov/states/md/tax/income/capital_gains/md_capital_gains_surtax.yaml index a42e7f34eb7..1e93b88aea1 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/md/tax/income/capital_gains/md_capital_gains_surtax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/md/tax/income/capital_gains/md_capital_gains_surtax.yaml @@ -149,8 +149,8 @@ people: person1: long_term_capital_gains: 120_000 - short_term_capital_gains: 30_000 - capital_losses: 50_000 + # $30,000 of short-term gains less $50,000 of short-term losses. + short_term_capital_gains: -20_000 tax_units: tax_unit: members: [person1] @@ -160,11 +160,12 @@ members: [person1] state_code: MD output: - # Gross gains: $120,000 + $30,000 = $150,000 - # Capital losses limited to $3,000 federal cap (was $50,000) - # Net gains after losses: max($150,000 - $3,000, 0) = $147,000 - # Surtax: $147,000 * 2% = $2,940 - md_capital_gains_surtax: 2_940 + # Federal Schedule D line 16 = $120,000 - $20,000 = $100,000. Losses up + # to the gains are allowed in full (26 U.S.C. 1211(b)); the $3,000 cap + # applies only to losses in excess of gains. + # 2025 Form 502CG line 1 (Form 502 line 1c, federal line 7a): $100,000 + # Surtax: $100,000 * 2% = $2,000 + md_capital_gains_surtax: 2_000 - name: MD Capital gains surtax 2025 - High earner with large gains period: 2025 From 638c794a18e4a664af48b2926e6061e54761fd66 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 22:46:49 -0400 Subject: [PATCH 6/6] Add the combined #9781 regression case, a joint Maryland surtax case, and move the distributions comment Co-Authored-By: Claude Opus 5.5 --- .../gov/irs/gross_income/sources.yaml | 2 +- ...schedule_d_capital_gain_distributions.yaml | 32 +++++++++++++++++++ .../md_capital_gains_surtax.yaml | 31 ++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/policyengine_us/parameters/gov/irs/gross_income/sources.yaml b/policyengine_us/parameters/gov/irs/gross_income/sources.yaml index d5ed2911232..af8391cacdf 100644 --- a/policyengine_us/parameters/gov/irs/gross_income/sources.yaml +++ b/policyengine_us/parameters/gov/irs/gross_income/sources.yaml @@ -9,10 +9,10 @@ values: - farm_rent_income - other_net_gain_gross_income - capital_gains - - non_sch_d_capital_gains # Capital gain distributions reported on Form 1040 line 7a. With capital # losses they go on Schedule D line 13; loss_ald nets the losses against # them (capital_losses_allowed_against_gains). + - non_sch_d_capital_gains - taxable_interest_income - rental_income # Royalties included in rental income diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml index 681e96b22f0..7110f79b8fa 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/schedule_d_capital_gain_distributions.yaml @@ -388,3 +388,35 @@ net_investment_income: 0 has_qdiv_or_ltcg: false + +- name: Netted distributions are taxed at ordinary rates + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + employment_income: 100_000 + long_term_capital_gains: -8_000 + non_sch_d_capital_gains: 5_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # With PolicyEngine/policyengine-us#9781 (section 1(h)) on main, AGI and + # the rate schedule agree. Schedule D line 13 = 5,000; line 15 = -3,000 + # = line 16; line 21 = -3,000. + adjusted_gross_income: 97_000 + # 97,000 - 15,750. + taxable_income: 81_250 + # Lines 15 and 16 are losses: no net capital gain (26 U.S.C. 1222(11)), + # so the distributions get no preferential rate. + net_capital_gain: 0 + capital_gains_tax: 0 + # 2025 single brackets: 1,192.50 + 12% x 36,550 + 22% x (81,250 - 48,475) + # = 1,192.50 + 4,386 + 7,210.50. + income_tax_main_rates: 12_789 diff --git a/policyengine_us/tests/policy/baseline/gov/states/md/tax/income/capital_gains/md_capital_gains_surtax.yaml b/policyengine_us/tests/policy/baseline/gov/states/md/tax/income/capital_gains/md_capital_gains_surtax.yaml index 1e93b88aea1..1e12d9fed73 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/md/tax/income/capital_gains/md_capital_gains_surtax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/md/tax/income/capital_gains/md_capital_gains_surtax.yaml @@ -167,6 +167,37 @@ # Surtax: $100,000 * 2% = $2,000 md_capital_gains_surtax: 2_000 +- name: MD Capital gains surtax 2025 - One spouse's loss within the other's gains + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + long_term_capital_gains: 100_000 + person2: + age: 48 + short_term_capital_gains: -20_000 + tax_units: + tax_unit: + members: [person1, person2] + adjusted_gross_income: 450_000 + marital_units: + marital_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MD + output: + # Joint federal Schedule D line 16 = $100,000 - $20,000 = $80,000. The + # loss is within the gains, so 26 U.S.C. 1211(b) allows it in full and + # no $3,000 capped loss is left to subtract again. + # 2025 Form 502CG line 1 (federal line 7a): $80,000 + # Surtax: $80,000 * 2% = $1,600 (subtracting a $3,000 capped loss again + # gave $1,540) + md_capital_gains_surtax: 1_600 + - name: MD Capital gains surtax 2025 - High earner with large gains period: 2025 absolute_error_margin: 0.01