From defa292e3bea87e654058a3b4406070e36f6a696 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 18:43:39 -0400 Subject: [PATCH] Strengthen QBID net capital gain regression and parity coverage --- .../hub-qbid-net-capital-gain-1010.changed.md | 1 + ...ection_199a_net_capital_gain_invariants.py | 58 ++++++++++++++++++- .../qualified_business_income_deduction.yaml | 34 +++++++++++ .../qbid/section_199a_net_capital_gain.yaml | 14 +++++ .../section_199a_net_capital_gain.py | 6 ++ 5 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 changelog.d/hub-qbid-net-capital-gain-1010.changed.md diff --git a/changelog.d/hub-qbid-net-capital-gain-1010.changed.md b/changelog.d/hub-qbid-net-capital-gain-1010.changed.md new file mode 100644 index 00000000000..b65e0aebca6 --- /dev/null +++ b/changelog.d/hub-qbid-net-capital-gain-1010.changed.md @@ -0,0 +1 @@ +Strengthen qualified business income deduction income-limit coverage for net short-term losses, capital gain distributions, and comparison with the former adjusted-capital-gain cap. Cite Treasury's explicit treatment of elected investment income under Section 199A. diff --git a/policyengine_us/tests/core/test_section_199a_net_capital_gain_invariants.py b/policyengine_us/tests/core/test_section_199a_net_capital_gain_invariants.py index 3425312fbf1..bc0d37f1ccc 100644 --- a/policyengine_us/tests/core/test_section_199a_net_capital_gain_invariants.py +++ b/policyengine_us/tests/core/test_section_199a_net_capital_gain_invariants.py @@ -22,18 +22,27 @@ neither the gain nor the QBI deduction. 4. Cap and floor: the deduction is never negative and never above 20% of taxable income less the gain, or the 199A(i) minimum when larger. +5. Gain and deduction ordering: for the same component-derived inputs, + the 199A gain is nonnegative and at least adjusted net capital gain, so + its deduction never exceeds the deduction using the former adjusted-gain + cap, including when the 199A(i) minimum applies. """ import numpy as np from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st +from policyengine_core.periods import period from policyengine_us import Simulation +from policyengine_us.variables.gov.irs.income.taxable_income.deductions.qualified_business_income_deduction.qualified_business_income_deduction import ( + qualified_business_income_deduction, +) TOLERANCE = 0.01 # dollars GAIN_INPUTS = [ "long_term_capital_gains", "short_term_capital_gains", + "schedule_d_capital_gain_distributions", "non_sch_d_capital_gains", "qualified_dividend_income", "long_term_capital_gains_on_collectibles", @@ -59,10 +68,14 @@ def _maybe(strategy): @st.composite def person_amounts(draw): - long_term = draw(_maybe(signed)) + distributions = draw(_maybe(st.integers(1, 5_000).map(float))) + # Schedule D line 13 is already inside line 15. The separately stored + # distribution is a memo component, never an additional gain. + long_term = draw(_maybe(signed)) + distributions return { "long_term_capital_gains": long_term, "short_term_capital_gains": draw(_maybe(signed)), + "schedule_d_capital_gain_distributions": distributions, "non_sch_d_capital_gains": draw(_maybe(st.integers(1, 5_000).map(float))), "qualified_dividend_income": draw(_maybe(positive)), # Collectibles gain is part of the long-term gain. @@ -127,7 +140,22 @@ def by_year(values): } -def _run(units, year, **kwargs): +class _AdjustedGainCap: + """Run the production deduction with only its former gain input restored.""" + + def __init__(self, tax_unit): + self.tax_unit = tax_unit + + def __call__(self, variable, formula_period): + if variable == "section_199a_net_capital_gain": + variable = "adjusted_net_capital_gain" + return self.tax_unit(variable, formula_period) + + def __getattr__(self, name): + return getattr(self.tax_unit, name) + + +def _run(units, year, *, compare_legacy=False, **kwargs): sim = Simulation(situation=_situation(units, year, **kwargs)) out = { name: np.asarray(sim.calculate(name, year), dtype=float) @@ -141,6 +169,21 @@ def _run(units, year, **kwargs): out["filer"] = ~np.asarray(sim.calculate("is_tax_unit_dependent", year), bool) out["unit"] = sim.populations["tax_unit"].members_entity_id out["p"] = sim.tax_benefit_system.parameters(year).gov.irs.deductions.qbi + if compare_legacy: + out["adjusted_net_capital_gain"] = np.asarray( + sim.calculate("adjusted_net_capital_gain", year), dtype=float + ) + # Reuse this simulation's population and parameters. This evaluates + # the actual deduction formula, including its floor and filer rules, + # without another model build or a duplicate implementation of QBID. + out["legacy_qualified_business_income_deduction"] = np.asarray( + qualified_business_income_deduction.formula( + _AdjustedGainCap(sim.populations["tax_unit"]), + period(year), + sim.tax_benefit_system.parameters, + ), + dtype=float, + ) return out @@ -150,7 +193,7 @@ def _filer_sum(run, name): def _check(units, year): - run = _run(units, year) + run = _run(units, year, compare_legacy=True) gain = run["section_199a_net_capital_gain"] deduction = run["qualified_business_income_deduction"] @@ -193,6 +236,15 @@ def _check(units, year): assert (deduction >= -TOLERANCE).all() assert (deduction <= ceiling + TOLERANCE).all() + # 5. Preferential-rate exclusions and elections can only reduce the + # former gain input. Replacing it cannot increase the deduction, even + # when the common statutory minimum exceeds either ordinary cap. + assert (gain >= -TOLERANCE).all() + assert (gain + TOLERANCE >= run["adjusted_net_capital_gain"]).all() + assert ( + deduction <= run["legacy_qualified_business_income_deduction"] + TOLERANCE + ).all() + # Each example is one vectorized batch of tax units. SETTINGS = dict( diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income_deduction.yaml index cb7e7713312..8265d10a334 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income_deduction.yaml @@ -307,3 +307,37 @@ section_199a_net_capital_gain: 45_000 # 20% x (100,000 - 45,000) = 11,000. qualified_business_income_deduction: 11_000 + +- name: Income limitation nets short-term loss before retaining section 1250 gain + period: 2025 + input: + qbid_amount: 20_000 + taxable_income_less_qbid: 100_000 + long_term_capital_gains: 30_000 + short_term_capital_gains: -10_000 + qualified_dividend_income: 5_000 + # Schedule D line 19, already reduced for losses. + unrecaptured_section_1250_gain: 15_000 + output: + # 2025 Form 8995 instructions, page 4, line 12: + # Schedule D line 15 = $30,000; line 16 = $30,000 - $10,000 = $20,000. + # Form 8995 line 12 = min($30,000, $20,000) + $5,000 = $25,000. + section_199a_net_capital_gain: 25_000 + adjusted_net_capital_gain: 10_000 + # Lines 13-15: min($20,000, 20% x ($100,000 - $25,000)) = $15,000. + # The former adjusted-gain cap would allow $18,000. + qualified_business_income_deduction: 15_000 + +- name: Income limitation binds with capital gain distributions without Schedule D + period: 2025 + input: + qbid_amount: 8_000 + taxable_income_less_qbid: 40_000 + non_sch_d_capital_gains: 8_000 + qualified_dividend_income: 2_000 + output: + # 2025 Form 8995 instructions, page 4, line 12: without Schedule D, + # use Form 1040 line 7a ($8,000) plus line 3a ($2,000) = $10,000. + section_199a_net_capital_gain: 10_000 + # Lines 13-15: min($8,000, 20% x ($40,000 - $10,000)) = $6,000. + qualified_business_income_deduction: 6_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/section_199a_net_capital_gain.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/section_199a_net_capital_gain.yaml index 9aeaefef571..8408febd2e6 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/section_199a_net_capital_gain.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/section_199a_net_capital_gain.yaml @@ -140,3 +140,17 @@ output: # 10,000 + max(0, 5,000 - 2,000). section_199a_net_capital_gain: 13_000 + +- name: Schedule D distributions are included in long-term gain exactly once + period: 2025 + input: + # The $8,000 Schedule D line 13 distribution is already in line 15. + long_term_capital_gains: 30_000 + schedule_d_capital_gain_distributions: 8_000 + short_term_capital_gains: -10_000 + qualified_dividend_income: 5_000 + output: + # 2025 Form 8995 instructions, page 4, line 12: + # line 15 = $30,000; line 16 = $20,000; line 3a = $5,000. + # Adding the memo distribution again would incorrectly produce $33,000. + section_199a_net_capital_gain: 25_000 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/section_199a_net_capital_gain.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/section_199a_net_capital_gain.py index f0ca6fb51f5..8466dd97f87 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/section_199a_net_capital_gain.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/section_199a_net_capital_gain.py @@ -39,6 +39,10 @@ class section_199a_net_capital_gain(Variable): title="2025 Instructions for Form 8995-A, line 34", href="https://www.irs.gov/pub/irs-prior/i8995a--2025.pdf#page=7", ), + dict( + title="TD 9847, Net Capital Gain, 84 FR 2954", + href="https://www.govinfo.gov/content/pkg/FR-2019-02-08/pdf/2019-01025.pdf#page=3", + ), ] def formula(tax_unit, period, parameters): @@ -47,6 +51,8 @@ def formula(tax_unit, period, parameters): # 1(h)(11)(B))". Neither part is reduced by a Form 4952 line 4g # election: the form uses Form 1040 line 3a, which the Form 4952 # instructions say not to reduce, and Schedule D lines 15 and 16. + # TD 9847's preamble explicitly confirms that elected gains and + # dividends remain net capital gain for the section 199A deduction. # Schedule D line 15: net long-term gain, including capital gain # distributions (long-term under 26 U.S.C. 852(b)(3)(B)). Without # Schedule D, net capital gain is Form 1040 line 7a.