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..af8391cacdf 100644 --- a/policyengine_us/parameters/gov/irs/gross_income/sources.yaml +++ b/policyengine_us/parameters/gov/irs/gross_income/sources.yaml @@ -9,8 +9,10 @@ values: - farm_rent_income - other_net_gain_gross_income - 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 - # Capital gain distributions received without filing Schedule D (Form 1040 line 7). - 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 3ec92696433..9b1f6303618 100644 --- a/policyengine_us/parameters/gov/irs/investment/income/sources.yaml +++ b/policyengine_us/parameters/gov/irs/investment/income/sources.yaml @@ -20,11 +20,11 @@ values: # code H input says otherwise, and a dependent's amounts stay out of the # filer's net investment income. - estate_net_investment_income - # Schedule D gain or loss of the head and spouse, limited under § 1211(b) - # (the Schedule D part of Form 8960 line 5a). + # Form 8960 line 5a: the head and spouse's Form 1040 line 7a capital gain + # or loss, Schedule D gains and losses with capital gain distributions + # (line 13), limited under § 1211(b). Distributions reported without + # Schedule D are the same amount. - filer_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/core/test_dependent_losses_agi_invariants.py b/policyengine_us/tests/core/test_dependent_losses_agi_invariants.py index 4a8cf88db83..432e36b895f 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 @@ -268,8 +271,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 = runs["irs"] filing_status = runs["with"]["filing_status"] se = _by_person(units, "self_employment_income") + _by_person( @@ -279,12 +282,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 @@ -295,6 +299,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/core/test_dependent_net_investment_income_invariants.py b/policyengine_us/tests/core/test_dependent_net_investment_income_invariants.py index 71deb84188a..f9c54139074 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,12 +18,14 @@ 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 - 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)). """ @@ -56,7 +58,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 +72,6 @@ "rental_income", "passive_partnership_s_corp_income", "estate_income", - "non_sch_d_capital_gains", ] OUTPUTS = [ "net_investment_income", @@ -305,8 +311,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 new file mode 100644 index 00000000000..7110f79b8fa --- /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,422 @@ +# 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 + +- 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 + + +- 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 a42e7f34eb7..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 @@ -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,43 @@ 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 - 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 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 new file mode 100644 index 00000000000..398df7e19db --- /dev/null +++ b/policyengine_us/tests/test_capital_gain_distributions_loss_limit.py @@ -0,0 +1,413 @@ +"""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_core.periods import instant +from policyengine_core.reforms import Reform + +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 + + +# --------------------------------------------------------------------------- +# 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: 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( + 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 + + +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. +# --------------------------------------------------------------------------- + +# 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..745a37c4552 --- /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,60 @@ +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) + # 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 0206b428c2f..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 @@ -6,15 +6,38 @@ 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 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) - 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..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." @@ -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/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 6fbfd3451a7..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 @@ -8,15 +8,17 @@ class filer_loss_limited_net_capital_gains(Variable): label = "Filer's loss-limited net capital gains" unit = USD documentation = ( - "Schedule D net capital gain or loss of the head and spouse, with a " - "net loss limited under 26 USC 1211(b): the Schedule D part of Form " - "8960 line 5a. A tax unit dependent's gains and losses are on the " - "dependent's own return. Unlike loss_limited_net_capital_gains, this " - "leaves dependents out." + "Form 1040 line 7a of the head and spouse: their Schedule D net " + "capital gain or loss, including capital gain distributions (line " + "13), with a net loss limited under 26 USC 1211(b). This is Form 8960 " + "line 5a without Schedule 1 line 4. A tax unit dependent's gains and " + "losses are on the dependent's own return. Unlike " + "loss_limited_net_capital_gains, this leaves dependents out." ) reference = ( "https://www.law.cornell.edu/uscode/text/26/1211#b", "https://www.irs.gov/pub/irs-prior/i8960--2024.pdf#page=7", + "https://www.irs.gov/pub/irs-prior/i1040sd--2025.pdf#page=2", ) def formula(tax_unit, period, parameters): @@ -26,4 +28,12 @@ def formula(tax_unit, period, parameters): net_capital_gains = tax_unit_non_dep_add( tax_unit, period, ["long_term_capital_gains", "short_term_capital_gains"] ) - return max_(-loss_limit, net_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 + 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 2db8bf45bfc..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 @@ -5,22 +5,43 @@ 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 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 = ( + 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 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"] + ) + 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..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 @@ -6,11 +6,44 @@ 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. + # 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 4354058a947..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 @@ -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 = person("capital_gains", period) + max_( + 0, person("non_sch_d_capital_gains", period) + ) # 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 1dbd86a21d7..9348a9f1b1e 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/gov/states/mi/tax/income/mi_household_resources.py b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py index 90853e92375..7bb62ea0b73 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 @@ -13,6 +13,10 @@ class mi_household_resources(Variable): "statute-act-281-of-1967/division-281-1967-1/division-281-1967-1-9/" "section-206-508/", "https://web.archive.org/web/20250202150154/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): @@ -44,7 +48,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, 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"