diff --git a/changelog.d/fix-dwks-28-1250-netting.fixed.md b/changelog.d/fix-dwks-28-1250-netting.fixed.md new file mode 100644 index 00000000000..4ed9a0c9668 --- /dev/null +++ b/changelog.d/fix-dwks-28-1250-netting.fixed.md @@ -0,0 +1 @@ +Net collectibles loss, the net short-term capital loss and the long-term capital loss carryover against 28 percent rate gain, and the rest against unrecaptured section 1250 gain, per 26 U.S.C. 1(h)(4) and 1(h)(6), through new inputs for those amounts before netting; amounts entered as reported on Schedule D lines 18 and 19 pass through unchanged. diff --git a/policyengine_us/tests/core/test_uprating_series.py b/policyengine_us/tests/core/test_uprating_series.py index bc4630a242e..01b5a7e01b7 100644 --- a/policyengine_us/tests/core/test_uprating_series.py +++ b/policyengine_us/tests/core/test_uprating_series.py @@ -43,6 +43,10 @@ "self_employed_pension_contributions_desired": SELF_EMPLOYMENT, # Components of capital gains. "long_term_capital_gains_on_collectibles": CAPITAL_GAINS, + "long_term_capital_loss_carryover": CAPITAL_GAINS, + "collectibles_gain_or_loss": CAPITAL_GAINS, + "section_1202_gain": CAPITAL_GAINS, + "unrecaptured_section_1250_gain_before_losses": CAPITAL_GAINS, "unrecaptured_section_1250_gain": CAPITAL_GAINS, "non_sch_d_capital_gains": CAPITAL_GAINS, "schedule_d_capital_gain_distributions": CAPITAL_GAINS, diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/capital_gains_28_percent_rate_gain.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/capital_gains_28_percent_rate_gain.yaml new file mode 100644 index 00000000000..9d1375d0ee1 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/capital_gains_28_percent_rate_gain.yaml @@ -0,0 +1,181 @@ +# 26 U.S.C. 1(h)(4): collectibles gain and section 1202 gain less collectibles +# loss, the net short-term capital loss and the long-term capital loss carried +# to the year, if more than zero. Line references are to the 2025 28% Rate +# Gain Worksheet (Instructions for Schedule D, page 11); line 7 is Schedule D +# line 18. Amounts before netting are collectibles_gain_or_loss and +# section_1202_gain; amounts already netted, as the microdata carry Schedule D +# line 18, are long_term_capital_gains_on_collectibles and +# long_term_capital_gains_on_small_business_stock and pass through. +- name: Without losses the 28 percent rate gain is the collectibles gain + period: 2025 + input: + long_term_capital_gains: 50_000 + collectibles_gain_or_loss: 10_000 + output: + capital_gains_28_percent_rate_gain: 10_000 + +- name: A short-term loss reduces the 28 percent rate gain + period: 2025 + input: + long_term_capital_gains: 50_000 + collectibles_gain_or_loss: 10_000 + short_term_capital_gains: -8_000 + output: + # Line 1 = 10,000, line 6 = (8,000), line 7 = 2,000. + capital_gains_28_percent_rate_gain: 2_000 + +- name: A short-term loss larger than the gain leaves none + period: 2025 + input: + long_term_capital_gains: 50_000 + collectibles_gain_or_loss: 10_000 + short_term_capital_gains: -15_000 + output: + # Line 7 = 10,000 - 15,000, zero or less, so 0. + capital_gains_28_percent_rate_gain: 0 + +- name: A short-term gain does not reduce it + period: 2025 + input: + long_term_capital_gains: 50_000 + collectibles_gain_or_loss: 10_000 + short_term_capital_gains: 5_000 + output: + # Line 6 is zero when Schedule D line 7 is a gain. + capital_gains_28_percent_rate_gain: 10_000 + +- name: A long-term capital loss carryover reduces it + period: 2025 + input: + long_term_capital_gains: 47_000 + collectibles_gain_or_loss: 10_000 + long_term_capital_loss_carryover: 3_000 + output: + # Line 5 = (3,000), line 7 = 7,000. long_term_capital_gains is net of the + # carryover, as Schedule D line 15 is. + capital_gains_28_percent_rate_gain: 7_000 + +- name: Section 1202 gain, a short-term loss and a carryover together + period: 2025 + input: + long_term_capital_gains: 47_000 + collectibles_gain_or_loss: 10_000 + section_1202_gain: 4_000 + long_term_capital_loss_carryover: 3_000 + short_term_capital_gains: -5_000 + output: + # Lines 1 + 2 = 14,000, line 5 = (3,000), line 6 = (5,000), line 7 = + # 6,000. + capital_gains_28_percent_rate_gain: 6_000 + +- name: A collectibles loss offsets section 1202 gain + period: 2025 + input: + long_term_capital_gains: 20_000 + collectibles_gain_or_loss: -2_000 + section_1202_gain: 5_000 + output: + # Line 1 = (2,000), line 2 = 5,000, line 7 = 3,000. + capital_gains_28_percent_rate_gain: 3_000 + +- name: A net collectibles loss leaves no 28 percent rate gain + period: 2025 + input: + long_term_capital_gains: 20_000 + collectibles_gain_or_loss: -4_000 + output: + capital_gains_28_percent_rate_gain: 0 + +- name: Spouses' gains and losses are netted on the joint return + period: 2025 + input: + people: + head: + age: 50 + long_term_capital_gains: 30_000 + collectibles_gain_or_loss: 6_000 + spouse: + age: 50 + short_term_capital_gains: -2_000 + long_term_capital_gains: -1_000 + long_term_capital_loss_carryover: 1_000 + tax_units: + tax_unit: + members: [head, spouse] + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # Line 1 = 6,000, line 5 = (1,000), line 6 = (2,000), line 7 = 3,000. + capital_gains_28_percent_rate_gain: 3_000 + +- name: Section 1202 gain and collectibles gain net together against a short-term loss + period: 2025 + input: + long_term_capital_gains: 60_000 + collectibles_gain_or_loss: 10_000 + section_1202_gain: 15_000 + short_term_capital_gains: -8_000 + output: + # Line 1 = 10,000, line 2 = 15,000, line 6 = (8,000), line 7 = 17,000. + capital_gains_28_percent_rate_gain: 17_000 + +- name: A spouse's short-term gain offsets the other's short-term loss first + period: 2025 + input: + people: + head: + age: 50 + long_term_capital_gains: 30_000 + short_term_capital_gains: -3_000 + spouse: + age: 50 + long_term_capital_gains: 10_000 + collectibles_gain_or_loss: 10_000 + short_term_capital_gains: 1_000 + tax_units: + tax_unit: + members: [head, spouse] + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # Schedule D line 7 = -3,000 + 1,000 = (2,000); line 7 = 10,000 - 2,000. + capital_gains_28_percent_rate_gain: 8_000 + +- name: A negative section 1202 gain or carryover entry counts as zero + period: 2025 + input: + long_term_capital_gains: 50_000 + collectibles_gain_or_loss: 10_000 + section_1202_gain: -1_000 + long_term_capital_loss_carryover: -2_000 + output: + # Section 1202 gain is an excess of gain over excluded gain, and the + # carryover is a positive amount (Schedule D line 14). + capital_gains_28_percent_rate_gain: 10_000 + +- name: Default dataset unit 1053056 (2025) is unchanged without losses + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: JOINT + taxable_income: 1_215_443.5 + net_capital_gain: 1_110_012.88 + long_term_capital_gains_on_collectibles: 610_988.38 + unrecaptured_section_1250_gain: 208_514.11 + foreign_earned_income_exclusion: 0 + output: + # The dataset carries Schedule D lines 18 and 19 (PUF E24518 and E24515) + # and no capital losses, so netting leaves them as they are. + capital_gains_28_percent_rate_gain: 610_988.38 + schedule_d_unrecaptured_section_1250_gain: 208_514.11 + adjusted_net_capital_gain: 290_510.39 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/capital_gains_tax.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/capital_gains_tax.yaml index bf32cbc136d..084494fff14 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/capital_gains_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/capital_gains_tax.yaml @@ -91,3 +91,121 @@ foreign_earned_income_exclusion: 130_000 output: capital_gains_tax: 0 + +# 26 U.S.C. 1(h)(4) and 1(h)(6): losses net against 28 percent rate gain, then +# unrecaptured section 1250 gain, before the 2025 Schedule D Tax Worksheet +# uses them (lines 11 to 13). 2025 single rates: 22 percent to 103,350, 24 +# percent to 197,300, 32 percent to 250,525, 35 percent to 626,350; the 15 +# percent capital gains rate starts at 48,350. +- name: A short-term loss moves collectibles gain to the 15 percent rate + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: SINGLE + state_code: TX + employment_income: 100_000 + long_term_capital_gains: 50_000 + collectibles_gain_or_loss: 10_000 + short_term_capital_gains: -8_000 + output: + # Taxable income 142,000 - 15,750 = 126,250; line 10 = 42,000; Schedule + # D line 18 = 2,000 (not 10,000); line 13 = 40,000; line 14 = 86,250; + # line 18 = 84,250; line 20 = 86,250 = line 21. Tax on 86,250 at the + # rate schedule is 1,192.50 + 4,386 + 8,310.50 = 13,889 (the model uses + # the schedule; line 44's Tax Table row gives 13,895); 40,000 at 15% = + # 6,000. Without the netting line 21 is 94,250 and the tax 15,649 + + # 4,800. + capital_gains_28_percent_rate_gain: 2_000 + adjusted_net_capital_gain: 40_000 + dwks13: 40_000 + income_tax_main_rates: 13_889 + capital_gains_tax: 6_000 + +- name: A short-term loss reduces unrecaptured section 1250 gain + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: SINGLE + state_code: TX + employment_income: 100_000 + long_term_capital_gains: 50_000 + unrecaptured_section_1250_gain_before_losses: 20_000 + short_term_capital_gains: -6_000 + output: + # Taxable income 128,250; line 10 = 44,000; Schedule D line 19 = 14,000; + # line 13 = 30,000; line 21 = line 14 = 98,250, which takes the 1250 + # gain at the regular rates (below the top of the 24% bracket). Tax on + # 98,250 at the rate schedule = 13,889 + 2,640 = 16,529 (Tax Table row: + # 16,535); 30,000 at 15% = 4,500. + schedule_d_unrecaptured_section_1250_gain: 14_000 + adjusted_net_capital_gain: 30_000 + income_tax_main_rates: 16_529 + capital_gains_tax: 4_500 + +- name: A short-term loss halves the gain taxed at 28 percent + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: SINGLE + state_code: TX + employment_income: 300_000 + long_term_capital_gains: 50_000 + collectibles_gain_or_loss: 20_000 + short_term_capital_gains: -10_000 + output: + # Taxable income 340,000 - 15,750 = 324,250; net capital gain 40,000; + # Schedule D line 18 = 10,000 (not 20,000); line 13 = 30,000. Line 21 = + # 284,250, above the top of the 24% bracket. 30,000 at 15% = 4,500 and + # 10,000 at 28% = 2,800. Without the netting: 20,000 at 15% and 20,000 + # at 28%, 8,600. + capital_gains_28_percent_rate_gain: 10_000 + adjusted_net_capital_gain: 30_000 + dwks13: 30_000 + capital_gains_tax: 7_300 + +- name: Above the 24 percent bracket losses move gain from 28 and 25 percent + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: SINGLE + state_code: TX + employment_income: 300_000 + long_term_capital_gains: 100_000 + collectibles_gain_or_loss: 30_000 + unrecaptured_section_1250_gain_before_losses: 40_000 + short_term_capital_gains: -35_000 + output: + # Taxable income 349,250; line 10 = 65,000. Schedule D line 18 = 0 and + # line 19 = 40,000 - 5,000 = 35,000; line 13 = 30,000; line 21 = + # 284,250. 30,000 at 15% = 4,500; 35,000 at 25% = 8,750; nothing at + # 28%. Tax on 284,250 = 57,231 + 11,803.75 = 69,034.75. Without the + # netting the gains tax is 10,000 at 25% and 7,000 at 28%, 17,000. + capital_gains_28_percent_rate_gain: 0 + schedule_d_unrecaptured_section_1250_gain: 35_000 + adjusted_net_capital_gain: 30_000 + income_tax_main_rates: 69_034.75 + capital_gains_tax: 13_250 + +- name: 2026, losses move gain from 28 and 25 percent (Axiom companion N10) + absolute_error_margin: 0.01 + period: 2026 + input: + filing_status: SINGLE + state_code: TX + taxable_income: 349_250 + long_term_capital_gains: 100_000 + collectibles_gain_or_loss: 30_000 + unrecaptured_section_1250_gain_before_losses: 40_000 + short_term_capital_gains: -35_000 + output: + # Net capital gain 65,000; 28 percent rate gain 0; unrecaptured section + # 1250 gain 35,000; adjusted net capital gain 30,000. Section 1(h)(1): + # (A) = max(284,250, min(201,775, 319,250)) = 284,250 (Rev. Proc. + # 2025-32 section 4.01, single); (C) 15% x 30,000 = 4,500 (below the + # 545,500 maximum 15-percent rate amount, section 4.03); (E) 25% x 35,000 + # = 8,750; (F) 0. + capital_gains_28_percent_rate_gain: 0 + schedule_d_unrecaptured_section_1250_gain: 35_000 + adjusted_net_capital_gain: 30_000 + capital_gains_excluded_from_taxable_income: 65_000 + capital_gains_tax: 13_250 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/schedule_d_unrecaptured_section_1250_gain.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/schedule_d_unrecaptured_section_1250_gain.yaml new file mode 100644 index 00000000000..768fd73f9a1 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/schedule_d_unrecaptured_section_1250_gain.yaml @@ -0,0 +1,140 @@ +# 26 U.S.C. 1(h)(6)(A): unrecaptured section 1250 gain less the excess, if +# any, of the 1(h)(4)(B) losses over the 1(h)(4)(A) gains. Line references are +# to the 2025 Unrecaptured Section 1250 Gain Worksheet (Instructions for +# Schedule D, page 13); line 18 is Schedule D line 19. The gain before losses +# is unrecaptured_section_1250_gain_before_losses; unrecaptured_section_1250_gain +# is Schedule D line 19 as reported, already reduced, and passes through. +- name: Without losses Schedule D line 19 is the gain entered + period: 2025 + input: + long_term_capital_gains: 50_000 + unrecaptured_section_1250_gain_before_losses: 20_000 + output: + schedule_d_unrecaptured_section_1250_gain: 20_000 + +- name: A short-term gain does not reduce it + period: 2025 + input: + long_term_capital_gains: 50_000 + unrecaptured_section_1250_gain_before_losses: 20_000 + short_term_capital_gains: 5_000 + output: + # Line 15 is zero when Schedule D line 7 is a gain, so line 17 = 0. + schedule_d_unrecaptured_section_1250_gain: 20_000 + +- name: A short-term loss reduces it + period: 2025 + input: + long_term_capital_gains: 50_000 + unrecaptured_section_1250_gain_before_losses: 20_000 + short_term_capital_gains: -6_000 + output: + # Line 13 = 20,000, line 15 = (6,000), line 17 = 6,000, line 18 = 14,000. + schedule_d_unrecaptured_section_1250_gain: 14_000 + +- name: The 28 percent rate gain absorbs losses first + period: 2025 + input: + long_term_capital_gains: 50_000 + collectibles_gain_or_loss: 10_000 + unrecaptured_section_1250_gain_before_losses: 20_000 + short_term_capital_gains: -8_000 + output: + # Line 14 = 10,000, line 15 = (8,000), lines 14 to 16 combined = 2,000, + # a gain, so line 17 = 0. + capital_gains_28_percent_rate_gain: 2_000 + schedule_d_unrecaptured_section_1250_gain: 20_000 + +- name: Losses beyond the 28 percent rate gain reduce it + period: 2025 + input: + long_term_capital_gains: 50_000 + collectibles_gain_or_loss: 10_000 + unrecaptured_section_1250_gain_before_losses: 20_000 + short_term_capital_gains: -15_000 + output: + # Lines 14 to 16 combined = (5,000), line 17 = 5,000, line 18 = 15,000. + capital_gains_28_percent_rate_gain: 0 + schedule_d_unrecaptured_section_1250_gain: 15_000 + +- name: The 28 percent rate gain absorbs part of a short-term loss + period: 2025 + input: + long_term_capital_gains: 50_000 + collectibles_gain_or_loss: 5_000 + unrecaptured_section_1250_gain_before_losses: 20_000 + short_term_capital_gains: -10_000 + output: + # Line 13 = 20,000; lines 14 + 15 = 5,000 - 10,000, so line 17 = 5,000 + # and line 18 = 15,000. Net capital gain 40,000; adjusted net capital + # gain 40,000 - (0 + 15,000) = 25,000. + capital_gains_28_percent_rate_gain: 0 + schedule_d_unrecaptured_section_1250_gain: 15_000 + adjusted_net_capital_gain: 25_000 + +- name: A carryover and a short-term loss together + period: 2025 + input: + long_term_capital_gains: 49_000 + unrecaptured_section_1250_gain_before_losses: 20_000 + short_term_capital_gains: -6_000 + long_term_capital_loss_carryover: 1_000 + output: + # Line 15 = (6,000), line 16 = (1,000), line 17 = 7,000, line 18 = 13,000. + schedule_d_unrecaptured_section_1250_gain: 13_000 + +- name: A collectibles loss reduces it + period: 2025 + input: + long_term_capital_gains: 50_000 + collectibles_gain_or_loss: -4_000 + unrecaptured_section_1250_gain_before_losses: 20_000 + output: + # Line 14 = (4,000), line 17 = 4,000, line 18 = 16,000. + schedule_d_unrecaptured_section_1250_gain: 16_000 + +- name: Losses above the gain leave none + period: 2025 + input: + long_term_capital_gains: 50_000 + unrecaptured_section_1250_gain_before_losses: 20_000 + short_term_capital_gains: -30_000 + output: + # Line 17 = 30,000 is more than line 13, so line 18 = 0. + schedule_d_unrecaptured_section_1250_gain: 0 + +- name: A Schedule D line 18 entered directly means no excess loss + period: 2025 + input: + long_term_capital_gains: 50_000 + capital_gains_28_percent_rate_gain: 3_000 + unrecaptured_section_1250_gain_before_losses: 20_000 + short_term_capital_gains: -8_000 + output: + # With a 28 percent rate gain the gains exceeded the losses, so line 17 + # is zero whatever components produced it. + schedule_d_unrecaptured_section_1250_gain: 20_000 + +- name: Amounts reported as Schedule D lines 18 and 19 are not netted again + period: 2025 + input: + long_term_capital_gains: 50_000 + long_term_capital_gains_on_collectibles: 2_000 + unrecaptured_section_1250_gain: 20_000 + short_term_capital_gains: -8_000 + output: + # The microdata carry PUF E24518 and E24515, lines 18 and 19 after the + # worksheets; netting the 8,000 loss again would give 0 and 14,000. + capital_gains_28_percent_rate_gain: 2_000 + schedule_d_unrecaptured_section_1250_gain: 20_000 + +- name: A reported line 19 passes through when the loss exceeds line 18 + period: 2025 + input: + long_term_capital_gains: 50_000 + unrecaptured_section_1250_gain: 20_000 + short_term_capital_gains: -30_000 + long_term_capital_loss_carryover: 5_000 + output: + capital_gains_28_percent_rate_gain: 0 + schedule_d_unrecaptured_section_1250_gain: 20_000 diff --git a/policyengine_us/tests/test_capital_gain_rate_group_netting.py b/policyengine_us/tests/test_capital_gain_rate_group_netting.py new file mode 100644 index 00000000000..c7ba6671a51 --- /dev/null +++ b/policyengine_us/tests/test_capital_gain_rate_group_netting.py @@ -0,0 +1,576 @@ +"""Property and differential tests for the netting of 28 percent rate gain and +unrecaptured section 1250 gain. + +26 U.S.C. 1(h)(4): the 28-percent rate gain is the excess, if any, of +collectibles gain and section 1202 gain over collectibles loss, the net +short-term capital loss and the long-term capital loss carried to the year. +1(h)(6)(A): unrecaptured section 1250 gain is the gain before losses less the +excess, if any, of those losses over those gains. The 2025 Instructions for +Schedule D carry these out on the 28% Rate Gain Worksheet (page 11, line 7 is +Schedule D line 18) and the Unrecaptured Section 1250 Gain Worksheet (page 13, +line 18 is Schedule D line 19). A Form 2555 filer with a capital gain excess +then reduces Schedule D line 18 by the excess and enters the excess as a loss +on line 16 of the second worksheet (2025 Form 1040 instructions, Foreign +Earned Income Tax Worksheet, footnote, modifications 3 and 4). + +The model takes either family of inputs: + +- before netting: `collectibles_gain_or_loss`, `section_1202_gain`, + `unrecaptured_section_1250_gain_before_losses` and + `long_term_capital_loss_carryover`, which it nets as the worksheets do; +- as reported on Schedule D lines 18 and 19, already netted: + `long_term_capital_gains_on_collectibles`, + `long_term_capital_gains_on_small_business_stock` and + `unrecaptured_section_1250_gain`. The microdata carry these from the PUF + (E24518 and E24515) together with the short-term losses already netted + against them, so they pass through. + +Python rather than YAML because these check every household of random +batches against an independent transcription and against each other; the +worked examples are YAML (capital_gains_28_percent_rate_gain.yaml, +schedule_d_unrecaptured_section_1250_gain.yaml, capital_gains_tax.yaml). + +- Differential. Both worksheets are transcribed below line by line. For + every household entering amounts before netting, single or joint, the + model's `capital_gains_28_percent_rate_gain` and + `schedule_d_unrecaptured_section_1250_gain` must equal lines 7 and 18. + With a capital gain excess, `section_911_28_percent_rate_gain`, + `section_911_unrecaptured_section_1250_gain` and the lines 13 and 19 of + `schedule_d_tax_worksheet_after_capital_gain_excess` must equal the + worksheets completed a second time with modifications 1 to 4, with the + excess figured here from the taxable base passed in. +- Properties that hold for every household, from the model alone: + 1. Bounds: 0 <= line 18 <= the gains (worksheet lines 1 to 4, if more than + zero) and 0 <= line 19 <= the gain before losses. + 2. Conservation: line 18 + line 19 = the gains plus the gain before losses + less the losses, if more than zero. Losses absorb the 28 percent rate + gain first, then unrecaptured section 1250 gain, and nothing else. + 3. Order: losses reach line 19 only when line 18 is zero. + 4. Amounts reported as Schedule D lines 18 and 19 pass through unchanged + whatever the losses; amounts before netting without losses do too. + 5. More short-term loss or carryover never raises line 18 or 19; more + collectibles gain never lowers either; more section 1250 gain never + lowers line 19 or changes line 18. + 6. Netting is on the return: moving amounts between spouses changes + nothing. + 7. Identities with the Schedule D Tax Worksheet: adjusted net capital gain + is net capital gain less lines 18 and 19, if more than zero, and line + 13 is line 10 less the smaller of line 9 and lines 18 plus 19. + 8. A household's results do not depend on the other households in its + batch or their order. +""" + +import numpy as np +import pytest + +from policyengine_us import Simulation +from policyengine_us.variables.gov.irs.tax.federal_income.foreign_earned_income_exclusion.schedule_d_tax_worksheet_after_capital_gain_excess import ( + schedule_d_tax_worksheet_after_capital_gain_excess, +) + +# Hypothesis is a dev extra; skip rather than fail collection without it. +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") + +YEAR = 2025 +OUTPUTS = [ + "capital_gains_28_percent_rate_gain", + "schedule_d_unrecaptured_section_1250_gain", + "section_911_28_percent_rate_gain", + "section_911_unrecaptured_section_1250_gain", + "net_capital_gain", + "adjusted_net_capital_gain", + "dwks09", + "dwks10", + "dwks13", + "has_qdiv_or_ltcg", +] +# Person amounts and the model inputs they set. +PERSON_INPUTS = { + "long_term": "long_term_capital_gains", + "short_term": "short_term_capital_gains", + "collectibles": "collectibles_gain_or_loss", + "section_1202": "section_1202_gain", + "carryover": "long_term_capital_loss_carryover", + "reported_collectibles": "long_term_capital_gains_on_collectibles", + "reported_section_1202": "long_term_capital_gains_on_small_business_stock", +} + +# --------------------------------------------------------------------------- +# The two 2025 worksheets, transcribed. +# --------------------------------------------------------------------------- + + +def unit_total(h, amount): + return sum(p[amount] for p in h["people"]) + + +def rate_gain_worksheet(h, capital_gain_excess=0): + """28% Rate Gain Worksheet lines 1 to 7, for the amounts before netting. + Line 7 is Schedule D line 18. + + The model has one input for collectibles gain or (loss), which covers + lines 1, 3 and 4. A Form 2555 filer then reduces Schedule D line 18, but + not below zero, by any capital gain excess (modification 3). + """ + line = {} + line[1] = unit_total(h, "collectibles") + # "Enter as a positive number" the section 1202 amounts. + line[2] = max(0, unit_total(h, "section_1202")) + line[3] = 0 + line[4] = 0 + # "Enter your long-term capital loss carryovers from Schedule D, line + # 14": a loss, entered in parentheses. + line[5] = -max(0, unit_total(h, "carryover")) + # "If Schedule D, line 7, is a (loss), enter that (loss) here." + line[6] = min(0, unit_total(h, "short_term")) + combined = sum(line[i] for i in range(1, 7)) + # "If zero or less, enter -0-." + line[7] = combined if combined > 0 else 0 + line["schedule_d_18"] = max(0, line[7] - capital_gain_excess) + return line + + +def unrecaptured_gain_worksheet(h, rate, capital_gain_excess=0): + """Unrecaptured Section 1250 Gain Worksheet lines 13 to 18, for the gain + before losses. Line 18 is Schedule D line 19. A Form 2555 filer includes + any capital gain excess as a loss on line 16 (modification 4).""" + line = {} + line[13] = h["section_1250"] + # "If you had any section 1202 gain or collectibles gain or (loss), + # enter the total of lines 1 through 4 of the 28% Rate Gain Worksheet." + line[14] = rate[1] + rate[2] + rate[3] + rate[4] + line[15] = rate[6] + line[16] = rate[5] - capital_gain_excess + combined = line[14] + line[15] + line[16] + # "If the result is a (loss), enter it as a positive amount." + line[17] = -combined if combined < 0 else 0 + # "If zero or less, enter -0-." + line[18] = max(0, line[13] - line[17]) + return line + + +def schedule_d_lines_18_and_19(h, capital_gain_excess=0): + """Schedule D lines 18 and 19, after modifications 3 and 4 for any excess. + + For amounts reported as lines 18 and 19 the worksheets were completed + before: line 17 was zero when line 18 was positive, and line 19 was line + 13 less line 17. Adding the excess to line 16 then takes line 19 down by + the part of the excess line 18 does not absorb. + """ + if h["reported"]: + line_18 = unit_total(h, "reported_collectibles") + unit_total( + h, "reported_section_1202" + ) + line_19 = h["reported_section_1250"] + return ( + max(0, line_18 - capital_gain_excess), + max(0, line_19 - max(0, capital_gain_excess - line_18)), + ) + rate = rate_gain_worksheet(h, capital_gain_excess) + gain = unrecaptured_gain_worksheet(h, rate, capital_gain_excess) + return rate["schedule_d_18"], gain[18] + + +def schedule_d_tax_worksheet(h, capital_gain_excess=0): + """2025 Schedule D Tax Worksheet lines 9, 10 and 13 for these households + (no qualified dividends, capital gain distributions or Form 4952 + election), completed a second time with modifications 1 to 4 for any + capital gain excess.""" + long_term = unit_total(h, "long_term") + schedule_d_16 = long_term + unit_total(h, "short_term") + # Lines 7 to 9; line 6 is zero. + line_9 = max(0, min(long_term, schedule_d_16)) + # Modification 1 (and 2, with line 6 zero). + line_9 = max(0, line_9 - capital_gain_excess) + line_10 = line_9 + line_18, line_19 = schedule_d_lines_18_and_19(h, capital_gain_excess) + line_13 = line_10 - min(line_9, line_18 + line_19) + # The model enters zero when the worksheet is not used: no qualified + # dividends and no long-term or net capital gain. + uses_worksheet = long_term > 0 or schedule_d_16 > 0 + return {9: line_9, 10: line_10, 13: line_13 if uses_worksheet else 0} + + +# --------------------------------------------------------------------------- +# The model. +# --------------------------------------------------------------------------- + + +def build_situation(households): + people, tax_units, marital_units = {}, {}, {} + groups = {"households": {}, "spm_units": {}, "families": {}} + for i, h in enumerate(households): + members = [] + for j, p in enumerate(h["people"]): + name = f"person_{i}_{j}" + people[name] = {"age": {YEAR: 45}} + for amount, variable in PERSON_INPUTS.items(): + people[name][variable] = {YEAR: p[amount]} + members.append(name) + marital_units[f"marital_unit_{i}"] = {"members": list(members)} + tax_units[f"tax_unit_{i}"] = { + "members": list(members), + # Set for every tax unit: an input given to only some of them + # leaves the rest at the default. + "filing_status": {YEAR: "JOINT" if len(members) == 2 else "SINGLE"}, + "unrecaptured_section_1250_gain_before_losses": {YEAR: h["section_1250"]}, + "unrecaptured_section_1250_gain": {YEAR: h["reported_section_1250"]}, + "section_911_capital_gain_excess": {YEAR: h["excess"]}, + "foreign_earned_income_exclusion": {YEAR: h["excluded"]}, + } + groups["households"][f"household_{i}"] = { + "members": list(members), + "state_code": {YEAR: "TX"}, + } + groups["spm_units"][f"spm_unit_{i}"] = {"members": list(members)} + groups["families"][f"family_{i}"] = {"members": list(members)} + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + **groups, + } + + +def taxable_base(h): + """The Form 6251 taxable base passed to the worksheet helper: worksheet + line 10 less the household's AMT capital gain excess.""" + return schedule_d_tax_worksheet(h)[10] - h["amt_excess"] + + +def calculate(households): + simulation = Simulation(situation=build_situation(households)) + results = {v: np.asarray(simulation.calculate(v, YEAR)) for v in OUTPUTS} + worksheet = schedule_d_tax_worksheet_after_capital_gain_excess( + simulation.populations["tax_unit"], + YEAR, + np.array([taxable_base(h) for h in households], dtype=float), + ) + results["helper_excess"] = np.asarray(worksheet.capital_gain_excess) + results["helper_line_13"] = np.asarray(worksheet.line_13) + results["helper_line_19"] = np.asarray(worksheet.unrecaptured_section_1250_gain) + return results + + +def close(a, b): + return abs(float(a) - float(b)) <= 0.01 + 5e-7 * max(abs(float(a)), abs(float(b))) + + +# --------------------------------------------------------------------------- +# Checks. +# --------------------------------------------------------------------------- + + +def assert_matches_worksheets(households, law): + for i, h in enumerate(households): + line_18, line_19 = schedule_d_lines_18_and_19(h) + context = (i, h) + assert close(law["capital_gains_28_percent_rate_gain"][i], line_18), context + assert close(law["schedule_d_unrecaptured_section_1250_gain"][i], line_19), ( + context + ) + # Modifications 3 and 4, with the regular tax excess. + line_18_911, line_19_911 = schedule_d_lines_18_and_19(h, h["excess"]) + assert close(law["section_911_28_percent_rate_gain"][i], line_18_911), context + assert close( + law["section_911_unrecaptured_section_1250_gain"][i], line_19_911 + ), context + # The worksheet helper as Form 6251 calls it: a filer excluding + # income has an excess when line 10 is more than the taxable base. + line_10 = schedule_d_tax_worksheet(h)[10] + amt_excess = ( + max(0, line_10 - max(0, taxable_base(h))) if h["excluded"] > 0 else 0 + ) + assert close(law["helper_excess"][i], amt_excess), context + assert close( + law["helper_line_13"][i], schedule_d_tax_worksheet(h, amt_excess)[13] + ), context + assert close( + law["helper_line_19"][i], schedule_d_lines_18_and_19(h, amt_excess)[1] + ), context + + +def assert_properties(households, law): + line_18 = law["capital_gains_28_percent_rate_gain"].astype(float) + line_19 = law["schedule_d_unrecaptured_section_1250_gain"].astype(float) + reported = np.array([h["reported"] for h in households], dtype=bool) + entered_1250 = np.array( + [h["reported_section_1250"] + h["section_1250"] for h in households], + dtype=float, + ) + gains = np.array( + [ + unit_total(h, "collectibles") + max(0, unit_total(h, "section_1202")) + for h in households + ], + dtype=float, + ) + reported_18 = np.array( + [ + unit_total(h, "reported_collectibles") + + unit_total(h, "reported_section_1202") + for h in households + ], + dtype=float, + ) + losses = np.array( + [ + max(0, -unit_total(h, "short_term")) + max(0, unit_total(h, "carryover")) + for h in households + ], + dtype=float, + ) + tol = 0.01 + 5e-7 * (np.abs(gains) + reported_18 + entered_1250 + losses) + netted = ~reported + # 1. Bounds. + assert (line_18 >= 0).all() + assert (line_18[netted] <= np.maximum(0, gains[netted]) + tol[netted]).all() + assert (line_19 >= 0).all() + assert (line_19 <= entered_1250 + tol).all() + # 2. Conservation. + assert np.allclose( + (line_18 + line_19)[netted], + np.maximum(0, gains + entered_1250 - losses)[netted], + rtol=0, + atol=tol.max(), + ) + # 3. Order. + reduced = netted & (line_19 < entered_1250 - tol) + assert (line_18[reduced] == 0).all() + # 4. Pass-through: reported amounts whatever the losses, and amounts + # before netting without losses. + unchanged = reported | ((losses == 0) & (gains >= 0)) + assert np.allclose( + line_18[unchanged], + np.where(reported, reported_18, gains)[unchanged], + rtol=0, + atol=0.01, + ) + assert np.allclose(line_19[unchanged], entered_1250[unchanged], rtol=0, atol=0.01) + # 7. Identities with the Schedule D Tax Worksheet. + net_gain = law["net_capital_gain"].astype(float) + assert np.allclose( + law["adjusted_net_capital_gain"].astype(float), + np.maximum(0, net_gain - (line_18 + line_19)), + rtol=0, + atol=0.02 + 5e-7 * net_gain.max(), + ) + uses_worksheet = law["has_qdiv_or_ltcg"].astype(bool) + line_9 = law["dwks09"].astype(float) + line_10 = law["dwks10"].astype(float) + expected_13 = line_10 - np.minimum(line_9, line_18 + line_19) + assert np.allclose( + law["dwks13"].astype(float)[uses_worksheet], + expected_13[uses_worksheet], + rtol=0, + atol=0.02 + 5e-7 * max(1, line_10.max()), + ) + + +# --------------------------------------------------------------------------- +# Households. +# --------------------------------------------------------------------------- + + +@st.composite +def person(draw, reported): + p = { + "long_term": draw(st.one_of(st.just(0), st.integers(-200_000, 2_000_000))), + # Schedule D line 7 for this person: a gain or a loss. + "short_term": draw(st.one_of(st.just(0), st.integers(-1_000_000, 500_000))), + "carryover": draw(st.one_of(st.just(0), st.just(0), st.integers(1, 800_000))), + "collectibles": 0, + "section_1202": 0, + "reported_collectibles": 0, + "reported_section_1202": 0, + } + if reported: + # Schedule D line 18 as the microdata carry it: never negative. + p["reported_collectibles"] = draw( + st.one_of(st.just(0), st.integers(1, 1_000_000)) + ) + p["reported_section_1202"] = draw( + st.one_of(st.just(0), st.just(0), st.integers(1, 300_000)) + ) + else: + # Collectibles gain or (loss): a net collectibles loss is negative. + p["collectibles"] = draw( + st.one_of(st.just(0), st.just(0), st.integers(-200_000, 1_000_000)) + ) + p["section_1202"] = draw( + st.one_of(st.just(0), st.just(0), st.integers(1, 500_000)) + ) + return p + + +@st.composite +def households(draw): + reported = draw(st.booleans()) + people = draw(st.lists(person(reported), min_size=1, max_size=2)) + section_1250 = draw(st.one_of(st.just(0), st.integers(1, 1_500_000))) + return { + "reported": reported, + "people": people, + "section_1250": 0 if reported else section_1250, + "reported_section_1250": section_1250 if reported else 0, + # A regular tax capital gain excess, entered as the section 911 + # amount, and an AMT one, for the worksheet helper. + "excess": draw(st.one_of(st.just(0), st.just(0), st.integers(1, 1_000_000))), + "amt_excess": draw(st.one_of(st.just(0), st.integers(1, 1_000_000))), + # The helper refigures only for a filer who excludes income. + "excluded": draw(st.sampled_from([0, 100_000])), + } + + +SETTINGS = dict( + max_examples=12, + deadline=None, + derandomize=True, + suppress_health_check=[ + hypothesis.HealthCheck.too_slow, + hypothesis.HealthCheck.data_too_large, + ], +) + +AMOUNTS = tuple(a for a in PERSON_INPUTS if a != "long_term") + ("long_term",) + + +def with_head(h, amount, more): + """`more` added to the head's `amount`, in whichever family the + household enters.""" + if h["reported"] and amount in ("collectibles", "section_1202"): + amount = "reported_" + amount + head = {**h["people"][0], amount: h["people"][0][amount] + more} + return {**h, "people": [head] + h["people"][1:]} + + +def on_one_person(h): + """The return's amounts all on the head.""" + head = {**h["people"][0], **{a: unit_total(h, a) for a in AMOUNTS}} + others = [{**p, **{a: 0 for a in AMOUNTS}} for p in h["people"][1:]] + return {**h, "people": [head] + others} + + +def more_section_1250(h, more): + key = "reported_section_1250" if h["reported"] else "section_1250" + return {**h, key: h[key] + more} + + +@hypothesis.settings(**SETTINGS) +@hypothesis.given(st.lists(households(), min_size=1, max_size=20)) +def test_random_households_match_worksheets(batch): + law = calculate(batch) + assert_matches_worksheets(batch, law) + assert_properties(batch, law) + + +@hypothesis.settings(**SETTINGS) +@hypothesis.given( + st.lists(households(), min_size=1, max_size=12), st.integers(1, 300_000) +) +def test_random_households_keep_the_properties(batch, more): + count = len(batch) + variants = ( + batch + + [with_head(h, "short_term", -more) for h in batch] + + [with_head(h, "carryover", more) for h in batch] + + [with_head(h, "collectibles", more) for h in batch] + + [more_section_1250(h, more) for h in batch] + + [on_one_person(h) for h in batch] + ) + law = calculate(variants) + assert_matches_worksheets(variants, law) + assert_properties(variants, law) + line_18 = law["capital_gains_28_percent_rate_gain"].astype(float).reshape(6, count) + line_19 = ( + law["schedule_d_unrecaptured_section_1250_gain"].astype(float).reshape(6, count) + ) + tol = 0.01 + 5e-7 * (np.abs(line_18).max() + np.abs(line_19).max()) + # 5. Monotonicity: rows 1 and 2 add losses, row 3 collectibles gain, row + # 4 section 1250 gain. + for k in (1, 2): + assert (line_18[k] <= line_18[0] + tol).all(), k + assert (line_19[k] <= line_19[0] + tol).all(), k + assert (line_18[3] >= line_18[0] - tol).all() + assert (line_19[3] >= line_19[0] - tol).all() + assert (line_19[4] >= line_19[0] - tol).all() + assert np.array_equal(line_18[4], line_18[0]) + # 4. Reported amounts ignore the added losses. + reported = np.array([h["reported"] for h in batch], dtype=bool) + for k in (1, 2): + assert np.array_equal(line_18[k][reported], line_18[0][reported]), k + assert np.array_equal(line_19[k][reported], line_19[0][reported]), k + # 6. Netting is on the return. + assert np.array_equal(line_18[5], line_18[0]) + assert np.array_equal(line_19[5], line_19[0]) + # 8. The same households in reverse order give the same results. + reverse = calculate(batch[::-1]) + for v in OUTPUTS: + assert np.array_equal(reverse[v][::-1], law[v][:count]), v + + +def test_worked_examples_match_worksheets(): + """The YAML examples, through both the model and the transcription.""" + + def h(section_1250=0, excess=0, reported=False, **head): + p = {"long_term": 50_000, "short_term": 0, "carryover": 0} + p.update( + { + "collectibles": 0, + "section_1202": 0, + "reported_collectibles": 0, + "reported_section_1202": 0, + }, + **head, + ) + return { + "reported": reported, + "people": [p], + "section_1250": 0 if reported else section_1250, + "reported_section_1250": section_1250 if reported else 0, + "excess": excess, + "amt_excess": 0, + "excluded": 0, + } + + batch = [ + h(collectibles=10_000, short_term=-8_000), + h(collectibles=10_000, short_term=-15_000, section_1250=20_000), + h(section_1202=4_000, collectibles=10_000, short_term=-5_000, carryover=3_000), + h(collectibles=-4_000, section_1250=20_000), + h(short_term=-6_000, carryover=1_000, section_1250=20_000), + # An excess of 9,000 takes line 18 from 2,000 to 0 and the 7,000 + # left reduces line 19 from 20,000 to 13,000. + h(collectibles=10_000, short_term=-8_000, section_1250=20_000, excess=9_000), + # The microdata's PUF lines 18 and 19 with the loss already netted. + h( + reported=True, + reported_collectibles=2_000, + short_term=-8_000, + section_1250=20_000, + ), + ] + law = calculate(batch) + assert_matches_worksheets(batch, law) + assert_properties(batch, law) + assert law["capital_gains_28_percent_rate_gain"].tolist() == [ + 2_000, + 0, + 6_000, + 0, + 0, + 2_000, + 2_000, + ] + assert law["schedule_d_unrecaptured_section_1250_gain"].tolist() == [ + 0, + 15_000, + 0, + 16_000, + 13_000, + 20_000, + 20_000, + ] + assert law["section_911_28_percent_rate_gain"][5] == 0 + assert law["section_911_unrecaptured_section_1250_gain"][5] == 13_000 diff --git a/policyengine_us/tests/test_section_911_tax_stacking.py b/policyengine_us/tests/test_section_911_tax_stacking.py index f0f22451084..536f1f8dd0d 100644 --- a/policyengine_us/tests/test_section_911_tax_stacking.py +++ b/policyengine_us/tests/test_section_911_tax_stacking.py @@ -686,8 +686,10 @@ def get(variable): "section_911_28_percent_rate_gain": get( "capital_gains_28_percent_rate_gain" ), + # Schedule D line 19, net of the losses the 28 percent rate gain + # does not absorb. "section_911_unrecaptured_section_1250_gain": get( - "unrecaptured_section_1250_gain" + "schedule_d_unrecaptured_section_1250_gain" ), "section_911_adjusted_net_capital_gain": get("adjusted_net_capital_gain"), } @@ -719,7 +721,10 @@ def get(variable): ("line_9", "dwks09"), ("line_10", "dwks10"), ("line_13", "dwks13"), - ("unrecaptured_section_1250_gain", "unrecaptured_section_1250_gain"), + ( + "unrecaptured_section_1250_gain", + "schedule_d_unrecaptured_section_1250_gain", + ), ]: assert np.array_equal( np.asarray(getattr(worksheet, line))[no_excess], diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/amt_part_iii_required.py b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/amt_part_iii_required.py index e3f91c7a716..f7c0d3b18dc 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/amt_part_iii_required.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/amt_part_iii_required.py @@ -18,7 +18,7 @@ def formula(tax_unit, period, parameters): "dwks13", "dwks14", "dwks19", - "unrecaptured_section_1250_gain", + "schedule_d_unrecaptured_section_1250_gain", ], ) return relevant_inputs > 0 diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/adjusted_net_capital_gain.py b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/adjusted_net_capital_gain.py index e43194e2cb6..d37d2d6c75c 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/adjusted_net_capital_gain.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/adjusted_net_capital_gain.py @@ -35,7 +35,9 @@ def formula(tax_unit, period, parameters): max_(0, add(tax_unit, period, ["qualified_dividend_income"])), net_capital_gain, ) - unrecaptured_s_1250_gain = tax_unit("unrecaptured_section_1250_gain", period) + unrecaptured_s_1250_gain = tax_unit( + "schedule_d_unrecaptured_section_1250_gain", period + ) cg_28_pct_rate_gain = tax_unit("capital_gains_28_percent_rate_gain", period) net_gains_less_dividends = net_capital_gain - qualified_dividend_income reduced_capital_gains = max_( diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/capital_gains_28_percent_rate_gain.py b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/capital_gains_28_percent_rate_gain.py index e56cca23820..596b96ba092 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/capital_gains_28_percent_rate_gain.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/capital_gains_28_percent_rate_gain.py @@ -1,19 +1,62 @@ from policyengine_us.model_api import * +def rate_gains_less_losses(tax_unit, period): + """Collectibles gain and section 1202 gain entered before netting, less + the losses netted against them (26 U.S.C. 1(h)(4)(A) less 1(h)(4)(B)), + which can be negative. + + Lines 1 to 6 of the 28% Rate Gain Worksheet combined: collectibles gain or + (loss) (lines 1, 3 and 4), section 1202 gain (line 2), the long-term + capital loss carryover (line 5) and the Schedule D line 7 loss (line 6). + The same amount is lines 14 to 16 of the Unrecaptured Section 1250 Gain + Worksheet combined. Amounts entered already netted, as Schedule D lines + 18 and 19 report them, are not part of it. + """ + # Collectibles gain or (loss); a net collectibles loss is negative. + collectibles = add(tax_unit, period, ["collectibles_gain_or_loss"]) + section_1202_gain = max_(0, add(tax_unit, period, ["section_1202_gain"])) + # 26 U.S.C. 1222(6), the same net short-term capital loss net_capital_gain + # subtracts: Schedule D line 7, if a loss. + net_short_term_capital_loss = max_( + 0, -add(tax_unit, period, ["short_term_capital_gains"]) + ) + carryover = max_(0, add(tax_unit, period, ["long_term_capital_loss_carryover"])) + return collectibles + section_1202_gain - net_short_term_capital_loss - carryover + + class capital_gains_28_percent_rate_gain(Variable): value_type = float entity = TaxUnit label = "28-percent rate gain" unit = USD - documentation = "Includes collectibles and certain small business stock gains. These are taxed at a higher (28-percent) rate than other capital gains, while a proportion is excluded from taxable income." - definition_period = YEAR - reference = dict( - title="26 U.S. Code § 1(h)(4)", - href="https://www.law.cornell.edu/uscode/text/26/1#h_4", + documentation = ( + "Schedule D line 18. The collectibles and section 1202 amounts " + "entered as already netted, as Schedule D line 18 reports them, plus " + "line 7 of the 28% Rate Gain Worksheet for the amounts entered " + "before netting: collectibles gain and section 1202 gain less " + "collectibles loss, the net short-term capital loss and the " + "long-term capital loss carried to the year, if more than zero." ) - - adds = [ - "long_term_capital_gains_on_collectibles", - "long_term_capital_gains_on_small_business_stock", + definition_period = YEAR + reference = [ + dict( + title="26 U.S. Code § 1(h)(4)", + href="https://www.law.cornell.edu/uscode/text/26/1#h_4", + ), + dict( + title="2025 Instructions for Schedule D (Form 1040), 28% Rate Gain Worksheet—Line 18", + href="https://www.irs.gov/pub/irs-prior/i1040sd--2025.pdf#page=11", + ), ] + + def formula(tax_unit, period, parameters): + reported = add( + tax_unit, + period, + [ + "long_term_capital_gains_on_collectibles", + "long_term_capital_gains_on_small_business_stock", + ], + ) + return reported + max_(0, rate_gains_less_losses(tax_unit, period)) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dwks13.py b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dwks13.py index 87753639b46..dc5e5e3b192 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dwks13.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dwks13.py @@ -9,10 +9,15 @@ class dwks13(Variable): unit = USD def formula(tax_unit, period, parameters): - e24515 = add(tax_unit, period, ["unrecaptured_section_1250_gain"]) - dwks11 = e24515 + add( - tax_unit, period, ["capital_gains_28_percent_rate_gain"] - ) # Sch D lines 18 and 19, respectively + # Line 11: Schedule D lines 18 and 19. + dwks11 = add( + tax_unit, + period, + [ + "capital_gains_28_percent_rate_gain", + "schedule_d_unrecaptured_section_1250_gain", + ], + ) dwks09 = tax_unit("dwks09", period) dwks12 = min_(dwks09, dwks11) dwks10 = tax_unit("dwks10", period) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/schedule_d_unrecaptured_section_1250_gain.py b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/schedule_d_unrecaptured_section_1250_gain.py new file mode 100644 index 00000000000..db1f6c835d8 --- /dev/null +++ b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/schedule_d_unrecaptured_section_1250_gain.py @@ -0,0 +1,46 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.tax.federal_income.capital_gains.capital_gains_28_percent_rate_gain import ( + rate_gains_less_losses, +) + + +class schedule_d_unrecaptured_section_1250_gain(Variable): + value_type = float + entity = TaxUnit + label = "Unrecaptured section 1250 gain (Schedule D line 19)" + unit = USD + documentation = ( + "Schedule D line 19. Unrecaptured section 1250 gain entered as " + "already reduced, as Schedule D line 19 reports it, plus line 18 of " + "the Unrecaptured Section 1250 Gain Worksheet for the gain entered " + "before losses: that gain less the amount by which collectibles " + "loss, the net short-term capital loss and the long-term capital " + "loss carried to the year exceed collectibles gain and section 1202 " + "gain, if more than zero." + ) + definition_period = YEAR + reference = [ + dict( + title="26 U.S. Code § 1(h)(6)(A)", + href="https://www.law.cornell.edu/uscode/text/26/1#h_6_A", + ), + dict( + title="2025 Instructions for Schedule D (Form 1040), Unrecaptured Section 1250 Gain Worksheet—Line 19", + href="https://www.irs.gov/pub/irs-prior/i1040sd--2025.pdf#page=13", + ), + ] + + def formula(tax_unit, period, parameters): + reported = tax_unit("unrecaptured_section_1250_gain", period) + # Worksheet line 13: 26 U.S.C. 1(h)(6)(A)(i). + gain = tax_unit("unrecaptured_section_1250_gain_before_losses", period) + # Line 17: the losses of section 1(h)(4)(B) in excess of the gains of + # section 1(h)(4)(A), 26 U.S.C. 1(h)(6)(A)(ii). A 28-percent rate gain + # means the gains exceed the losses, so there is no excess; reading + # it keeps line 19 consistent with a Schedule D line 18 entered + # directly. + rate_gain = tax_unit("capital_gains_28_percent_rate_gain", period) + excess_losses = where( + rate_gain > 0, 0, max_(0, -rate_gains_less_losses(tax_unit, period)) + ) + return reported + max_(0, gain - excess_losses) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/schedule_d_tax_worksheet_after_capital_gain_excess.py b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/schedule_d_tax_worksheet_after_capital_gain_excess.py index 4580d5ca61f..f38fc69b908 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/schedule_d_tax_worksheet_after_capital_gain_excess.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/schedule_d_tax_worksheet_after_capital_gain_excess.py @@ -53,7 +53,7 @@ def schedule_d_tax_worksheet_after_capital_gain_excess(tax_unit, period, taxable excludes_income, max_(0, line_10 - max_(0, taxable_base)), 0 ) has_excess = capital_gain_excess > 0 - unrecaptured_gain = tax_unit("unrecaptured_section_1250_gain", period) + unrecaptured_gain = tax_unit("schedule_d_unrecaptured_section_1250_gain", period) rate_gain = tax_unit("capital_gains_28_percent_rate_gain", period) # Modifications 1 and 2. reduced_line_9 = max_(0, line_9 - capital_gain_excess) @@ -64,6 +64,9 @@ def schedule_d_tax_worksheet_after_capital_gain_excess(tax_unit, period, taxable # Modifications 3 and 4. section_911_28_percent_rate_gain and # section_911_unrecaptured_section_1250_gain apply the regular tax excess; # this helper also runs with the AMT excess, so it applies them itself. + # Schedule D lines 18 and 19 are already net of the worksheets' losses, so + # reducing line 18 by the excess and line 19 by the part line 18 does not + # absorb is the same as adding the excess to those losses. reduced_rate_gain = max_(0, rate_gain - capital_gain_excess) reduced_unrecaptured_gain = max_( 0, unrecaptured_gain - max_(0, capital_gain_excess - rate_gain) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_unrecaptured_section_1250_gain.py b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_unrecaptured_section_1250_gain.py index ca4e8230efb..38f2a2c9949 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_unrecaptured_section_1250_gain.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_unrecaptured_section_1250_gain.py @@ -31,9 +31,13 @@ class section_911_unrecaptured_section_1250_gain(Variable): ] def formula(tax_unit, period, parameters): - unrecaptured_gain = tax_unit("unrecaptured_section_1250_gain", period) + unrecaptured_gain = tax_unit( + "schedule_d_unrecaptured_section_1250_gain", period + ) rate_gain = tax_unit("capital_gains_28_percent_rate_gain", period) excess = tax_unit("section_911_capital_gain_excess", period) + # Schedule D lines 18 and 19 are already net of the other losses, so + # line 19 falls by the part of the excess line 18 does not absorb. excess_over_rate_gain = max_(0, excess - rate_gain) return where( excess > 0, diff --git a/policyengine_us/variables/household/income/person/capital_gains/collectibles_gain_or_loss.py b/policyengine_us/variables/household/income/person/capital_gains/collectibles_gain_or_loss.py new file mode 100644 index 00000000000..cb70b80ffe4 --- /dev/null +++ b/policyengine_us/variables/household/income/person/capital_gains/collectibles_gain_or_loss.py @@ -0,0 +1,37 @@ +from policyengine_us.model_api import * + + +class collectibles_gain_or_loss(Variable): + value_type = float + entity = Person + label = "Collectibles gain or loss before netting" + unit = USD + documentation = ( + "Gain, or deductible loss, from the sale or exchange of collectibles " + "held as capital assets for more than one year, negative for a net " + "loss, before the losses the 28% Rate Gain Worksheet nets against " + "it: lines 1, 3 and 4 of that worksheet. capital_gains_28_percent_rate_gain " + "nets it with section 1202 gain, the net short-term capital loss and " + "the long-term capital loss carryover. Memo item: " + "long_term_capital_gains already includes it. For an amount already " + "netted, as on Schedule D line 18, use " + "long_term_capital_gains_on_collectibles; enter one or the other. " + "Federal only: Massachusetts' collectibles deduction reads " + "long_term_capital_gains_on_collectibles." + ) + definition_period = YEAR + uprating = "calibration.gov.irs.soi.long_term_capital_gains" + reference = [ + dict( + title="26 U.S. Code § 1(h)(4)", + href="https://www.law.cornell.edu/uscode/text/26/1#h_4", + ), + dict( + title="26 U.S. Code § 1(h)(5)", + href="https://www.law.cornell.edu/uscode/text/26/1#h_5", + ), + dict( + title="2025 Instructions for Schedule D (Form 1040), 28% Rate Gain Worksheet—Line 18, lines 1, 3 and 4", + href="https://www.irs.gov/pub/irs-prior/i1040sd--2025.pdf#page=11", + ), + ] diff --git a/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_on_collectibles.py b/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_on_collectibles.py index 6afd59a0fb0..2e8cc910ede 100644 --- a/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_on_collectibles.py +++ b/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_on_collectibles.py @@ -7,11 +7,23 @@ class long_term_capital_gains_on_collectibles(Variable): label = "Long-term capital gains on collectibles" unit = USD documentation = ( - "Portion of capital_gains_28_percent_rate_gain associated with collectibles." + "The collectibles part of the 28-percent rate gain as reported on " + "Schedule D line 18: already net of collectibles loss, the net " + "short-term capital loss and the long-term capital loss carryover, " + "so the model does not net it again. The microdata carry it from " + "the PUF's Schedule D line 18. Enter a collectibles gain or loss " + "before that netting as collectibles_gain_or_loss instead. Memo item: " + "long_term_capital_gains already includes it." ) definition_period = YEAR uprating = "calibration.gov.irs.soi.long_term_capital_gains" - reference = dict( - title="26 U.S. Code § 1(h)(4)", - href="https://www.law.cornell.edu/uscode/text/26/1#h_4", - ) + reference = [ + dict( + title="26 U.S. Code § 1(h)(4)", + href="https://www.law.cornell.edu/uscode/text/26/1#h_4", + ), + dict( + title="2025 Instructions for Schedule D (Form 1040), 28% Rate Gain Worksheet—Line 18", + href="https://www.irs.gov/pub/irs-prior/i1040sd--2025.pdf#page=11", + ), + ] diff --git a/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_on_small_business_stock.py b/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_on_small_business_stock.py index 88712fcaa25..406e9bb3257 100644 --- a/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_on_small_business_stock.py +++ b/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_on_small_business_stock.py @@ -6,10 +6,22 @@ class long_term_capital_gains_on_small_business_stock(Variable): entity = Person label = "Long-term capital gains on small business stock sales, etc" unit = USD - documentation = "Portion of capital_gains_28_percent_rate_gain not associated with collectibles." + documentation = ( + "The section 1202 part of the 28-percent rate gain as reported on " + "Schedule D line 18: already net of the losses the 28% Rate Gain " + "Worksheet subtracts, so the model does not net it again. Enter " + "section 1202 gain before that netting as section_1202_gain instead. " + "Memo item: long_term_capital_gains already includes it." + ) definition_period = YEAR uprating = "calibration.gov.irs.soi.long_term_capital_gains" - reference = dict( - title="26 U.S. Code § 1(h)(4)", - href="https://www.law.cornell.edu/uscode/text/26/1#h_4", - ) + reference = [ + dict( + title="26 U.S. Code § 1(h)(4)", + href="https://www.law.cornell.edu/uscode/text/26/1#h_4", + ), + dict( + title="2025 Instructions for Schedule D (Form 1040), 28% Rate Gain Worksheet—Line 18", + href="https://www.irs.gov/pub/irs-prior/i1040sd--2025.pdf#page=11", + ), + ] diff --git a/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_loss_carryover.py b/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_loss_carryover.py new file mode 100644 index 00000000000..ae642e1df84 --- /dev/null +++ b/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_loss_carryover.py @@ -0,0 +1,37 @@ +from policyengine_us.model_api import * + + +class long_term_capital_loss_carryover(Variable): + value_type = float + entity = Person + label = "Long-term capital loss carryover" + unit = USD + documentation = ( + "Long-term capital loss carried to the year under 26 U.S.C. " + "1212(b)(1)(B), as a positive amount: Schedule D line 14 plus any " + "carryover on Schedule K-1 (Form 1041), box 11, code D, from the " + "final year of an estate or trust (line 5 of the 28% Rate Gain " + "Worksheet and line 16 of the Unrecaptured Section 1250 Gain " + "Worksheet). Memo item: long_term_capital_gains already includes it " + "as a loss, as Schedule D line 15 does, so it does not change gross " + "income. It reduces collectibles_gain_or_loss and section_1202_gain " + "in the 28-percent rate gain, and what they do not absorb reduces " + "unrecaptured_section_1250_gain_before_losses. Zero, no carryover, " + "unless entered." + ) + definition_period = YEAR + uprating = "calibration.gov.irs.soi.long_term_capital_gains" + reference = [ + dict( + title="26 U.S. Code § 1(h)(4)(B)(iii)", + href="https://www.law.cornell.edu/uscode/text/26/1#h_4_B_iii", + ), + dict( + title="26 U.S. Code § 1212(b)(1)(B)", + href="https://www.law.cornell.edu/uscode/text/26/1212#b_1_B", + ), + dict( + title="2025 Instructions for Schedule D (Form 1040), 28% Rate Gain Worksheet—Line 18, line 5", + href="https://www.irs.gov/pub/irs-prior/i1040sd--2025.pdf#page=11", + ), + ] diff --git a/policyengine_us/variables/household/income/person/capital_gains/section_1202_gain.py b/policyengine_us/variables/household/income/person/capital_gains/section_1202_gain.py new file mode 100644 index 00000000000..ede66c5f080 --- /dev/null +++ b/policyengine_us/variables/household/income/person/capital_gains/section_1202_gain.py @@ -0,0 +1,31 @@ +from policyengine_us.model_api import * + + +class section_1202_gain(Variable): + value_type = float + entity = Person + label = "Section 1202 gain before netting" + unit = USD + documentation = ( + "The gain on qualified small business stock that section 1202 would " + "exclude but for its percentage limit, less the gain it excludes, " + "before the losses the 28% Rate Gain Worksheet nets against it: line " + "2 of that worksheet. capital_gains_28_percent_rate_gain nets it with " + "collectibles gain or loss and the losses of 26 U.S.C. 1(h)(4)(B). " + "Memo item: long_term_capital_gains already includes it. For an " + "amount already netted, as on Schedule D line 18, use " + "long_term_capital_gains_on_small_business_stock; enter one or the " + "other." + ) + definition_period = YEAR + uprating = "calibration.gov.irs.soi.long_term_capital_gains" + reference = [ + dict( + title="26 U.S. Code § 1(h)(7)", + href="https://www.law.cornell.edu/uscode/text/26/1#h_7", + ), + dict( + title="2025 Instructions for Schedule D (Form 1040), 28% Rate Gain Worksheet—Line 18, line 2", + href="https://www.irs.gov/pub/irs-prior/i1040sd--2025.pdf#page=11", + ), + ] diff --git a/policyengine_us/variables/household/income/person/capital_gains/unrecaptured_section_1250_gain.py b/policyengine_us/variables/household/income/person/capital_gains/unrecaptured_section_1250_gain.py index d6076fcce5b..462da71a703 100644 --- a/policyengine_us/variables/household/income/person/capital_gains/unrecaptured_section_1250_gain.py +++ b/policyengine_us/variables/household/income/person/capital_gains/unrecaptured_section_1250_gain.py @@ -6,9 +6,24 @@ class unrecaptured_section_1250_gain(Variable): entity = TaxUnit label = "Un-recaptured section 1250 gain" unit = USD + documentation = ( + "Unrecaptured section 1250 gain as reported on Schedule D line 19: " + "already reduced by the losses the 28-percent rate gain does not " + "absorb (line 17 of the Unrecaptured Section 1250 Gain Worksheet), " + "so the model does not reduce it again. The microdata carry it from " + "the PUF's Schedule D line 19. Enter the gain before those losses as " + "unrecaptured_section_1250_gain_before_losses instead. Memo item: " + "long_term_capital_gains already includes it." + ) definition_period = YEAR uprating = "calibration.gov.irs.soi.long_term_capital_gains" - reference = dict( - title="26 U.S. Code § 1250(a)", - href="https://www.law.cornell.edu/uscode/text/26/1250#a", - ) + reference = [ + dict( + title="26 U.S. Code § 1(h)(6)(A)", + href="https://www.law.cornell.edu/uscode/text/26/1#h_6_A", + ), + dict( + title="2025 Instructions for Schedule D (Form 1040), Unrecaptured Section 1250 Gain Worksheet—Line 19", + href="https://www.irs.gov/pub/irs-prior/i1040sd--2025.pdf#page=13", + ), + ] diff --git a/policyengine_us/variables/household/income/person/capital_gains/unrecaptured_section_1250_gain_before_losses.py b/policyengine_us/variables/household/income/person/capital_gains/unrecaptured_section_1250_gain_before_losses.py new file mode 100644 index 00000000000..da856ba35f0 --- /dev/null +++ b/policyengine_us/variables/household/income/person/capital_gains/unrecaptured_section_1250_gain_before_losses.py @@ -0,0 +1,31 @@ +from policyengine_us.model_api import * + + +class unrecaptured_section_1250_gain_before_losses(Variable): + value_type = float + entity = TaxUnit + label = "Unrecaptured section 1250 gain before losses" + unit = USD + documentation = ( + "Long-term capital gain, not otherwise ordinary income, that would be " + "ordinary income if section 1250(b)(1) included all depreciation and " + "the section 1250(a) percentage were 100 percent, before the losses " + "the 28-percent rate gain does not absorb reduce it: line 13 of the " + "Unrecaptured Section 1250 Gain Worksheet. " + "schedule_d_unrecaptured_section_1250_gain subtracts those losses. " + "Memo item: long_term_capital_gains already includes it. For an " + "amount already reduced, as on Schedule D line 19, use " + "unrecaptured_section_1250_gain; enter one or the other." + ) + definition_period = YEAR + uprating = "calibration.gov.irs.soi.long_term_capital_gains" + reference = [ + dict( + title="26 U.S. Code § 1(h)(6)(A)(i)", + href="https://www.law.cornell.edu/uscode/text/26/1#h_6_A_i", + ), + dict( + title="2025 Instructions for Schedule D (Form 1040), Unrecaptured Section 1250 Gain Worksheet—Line 19, line 13", + href="https://www.irs.gov/pub/irs-prior/i1040sd--2025.pdf#page=13", + ), + ]