diff --git a/changelog.d/rate-gain-election-tests.changed.md b/changelog.d/rate-gain-election-tests.changed.md new file mode 100644 index 00000000000..f1a2c4f878a --- /dev/null +++ b/changelog.d/rate-gain-election-tests.changed.md @@ -0,0 +1 @@ +Test the regular tax with 28-percent rate gain, unrecaptured section 1250 gain and a Form 4952 election against the 2025 Schedule D Tax Worksheet, extend the section 911(f) and Form 4952 tests to those gains, and pin a case where an election lawfully lowers the tax by moving gain from the 28 to the 25 percent rate. diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/rate_gain_with_form_4952_election.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/rate_gain_with_form_4952_election.yaml new file mode 100644 index 00000000000..39b3dca218b --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/rate_gain_with_form_4952_election.yaml @@ -0,0 +1,201 @@ +# 28-percent rate gain (here all collectibles gain, Schedule D line 18) with +# and without a Form 4952 line 4g election, for a single filer aged 45 in +# 2025. Line references are to the 2025 Schedule D Tax Worksheet +# (Instructions for Schedule D, pages 15 and 16): +# line 9 line 7 (net capital gain) less line 8 (the part of the line 4g +# election that comes from the gain) +# line 11 Schedule D lines 18 and 19 +# line 12 the smaller of line 9 or line 11 +# line 13 line 10 less line 12 +# line 18 line 1 less line 10 +# line 19 the smaller of line 1 or $197,300, the top of the 24% bracket +# line 20 the smaller of line 14 or line 19 +# line 21 the larger of line 18 or line 20, taxed at the regular rates +# line 42 line 1 less lines 21, 22, 30, 33 and 39, taxed at 28% +# line 47 the smaller of line 45 (the sum) or line 46 (the tax on line 1) +# 26 U.S.C. 1(h)(1)(F) taxes at 28% only "the amount of taxable income in +# excess of the sum of the amounts on which tax is determined under the +# preceding subparagraphs", and the election removes the elected gain from +# net capital gain (1(h)(2)), so the 28% amount is never more than line 9 or +# taxable income. The standard deduction is $15,750. Tax on an amount +# between $103,350 and $197,300 is 24% of it less $7,153; between $250,525 +# and $626,350, 35% of it less $30,452.75 (2025 Tax Computation Worksheet). + +- name: Collectibles gain with no taxable income bears no tax + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + employment_income: 0 + long_term_capital_gains: 10_000 + long_term_capital_gains_on_collectibles: 10_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Line 1 = 10,000 - 15,750, so zero. Lines 9 to 12 = 10,000; line 13 = + # 0; line 14 = 0; line 21 = 0; line 42 = 0. Line 47 = 0. + taxable_income: 0 + capital_gains_tax: 0 + income_tax_before_credits: 0 + +- name: Collectibles gain above taxable income is not taxed at 28% + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + employment_income: 10_750 + long_term_capital_gains: 10_000 + long_term_capital_gains_on_collectibles: 10_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Line 1 = 20,750 - 15,750 = 5,000. Lines 9 to 12 = 10,000; line 13 = 0; + # line 14 = 5,000; line 16 = 5,000, the same as line 1, so lines 23 to + # 43 are skipped. Line 21 = 5,000; line 44 = line 46 = 10% of 5,000 = + # 500 at the exact rates the model uses (the Tax Table row 5,000-5,050 + # gives 503). Taxing all 10,000 at 28% would add 2,800. + taxable_income: 5_000 + income_tax_main_rates: 500 + capital_gains_tax: 0 + income_tax_before_credits: 500 + +- name: Half the collectibles gain elected as investment income + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + employment_income: 100_000 + long_term_capital_gains: 100_000 + long_term_capital_gains_on_collectibles: 100_000 + investment_income_elected_form_4952: 50_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Line 1 = 184,250. Line 3 = 50,000; line 4 = 100,000; line 8 = 50,000; + # line 9 = 50,000; line 10 = 50,000. Line 11 = 100,000; line 12 = + # 50,000; line 13 = 0; line 14 = 184,250. Line 18 = 134,250; line 19 = + # 184,250; line 20 = 184,250; line 21 = 184,250, all of line 1, so line + # 42 = 0. Line 44 = line 46 = 24% of 184,250 less 7,153 = 37,067. + # Taxing line 18 at the regular rates (25,067) and 28% of the 50,000 left + # (14,000) would give 39,067, more than line 46. + taxable_income: 184_250 + dwks09: 50_000 + dwks10: 50_000 + dwks19: 184_250 + income_tax_main_rates: 37_067 + capital_gains_tax: 0 + income_tax_before_credits: 37_067 + +- name: All the collectibles gain elected as investment income + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + employment_income: 100_000 + long_term_capital_gains: 100_000 + long_term_capital_gains_on_collectibles: 100_000 + investment_income_elected_form_4952: 100_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Line 8 = 100,000; line 9 = 0; line 10 = 0; line 12 = 0; line 13 = 0. + # Line 18 = 184,250 = line 21; line 42 = 0. Line 47 = tax on 184,250. + taxable_income: 184_250 + dwks10: 0 + dwks19: 184_250 + income_tax_main_rates: 37_067 + capital_gains_tax: 0 + income_tax_before_credits: 37_067 + +- name: The same collectibles gain with no election + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + employment_income: 100_000 + long_term_capital_gains: 100_000 + long_term_capital_gains_on_collectibles: 100_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Lines 9 to 12 = 100,000; line 13 = 0; line 14 = 184,250. Line 18 = + # 84,250, but line 19 = 184,250 and line 20 = 184,250, so line 21 = + # 184,250: the gain falls below the top of the 24% bracket and is taxed + # at the regular rates (1(h)(1)(A)(ii)). Line 47 = 37,067. Taxing 84,250 + # at the regular rates (13,449) and all 100,000 at 28% would give 41,449. + taxable_income: 184_250 + dwks19: 184_250 + income_tax_main_rates: 37_067 + capital_gains_tax: 0 + income_tax_before_credits: 37_067 + +- name: An election above the 24% bracket leaves 28% on line 9 only + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + employment_income: 300_000 + long_term_capital_gains: 100_000 + long_term_capital_gains_on_collectibles: 100_000 + investment_income_elected_form_4952: 50_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Line 1 = 384,250. Line 9 = 50,000; line 10 = 50,000; line 11 = + # 100,000; line 12 = 50,000; line 13 = 0; line 14 = 384,250. Line 18 = + # 334,250; line 19 = 197,300; line 20 = 197,300; line 21 = 334,250. Line + # 22 = 0; lines 30 and 33 = 0; Schedule D line 19 is zero, so lines 35 + # to 40 are skipped. Line 41 = 334,250; line 42 = 50,000; line 43 = + # 14,000. Line 44 = 35% of 334,250 less 30,452.75 = 86,534.75. Line 45 = + # 100,534.75; line 46 = 104,034.75; line 47 = 100,534.75. The Form 6251 + # tentative minimum tax, 28% of 311,900 less 4,782 = 82,550, is lower, so + # there is no AMT. Taxing all 100,000 at 28% would tax the elected 50,000 + # twice: 114,534.75. + taxable_income: 384_250 + dwks19: 334_250 + income_tax_main_rates: 86_534.75 + capital_gains_tax: 14_000 + alternative_minimum_tax: 0 + income_tax_before_credits: 100_534.75 diff --git a/policyengine_us/tests/policy/contrib/additional_tax_bracket/additional_tax_bracket_reform.yaml b/policyengine_us/tests/policy/contrib/additional_tax_bracket/additional_tax_bracket_reform.yaml index 369dc5f9c36..a24a2270924 100644 --- a/policyengine_us/tests/policy/contrib/additional_tax_bracket/additional_tax_bracket_reform.yaml +++ b/policyengine_us/tests/policy/contrib/additional_tax_bracket/additional_tax_bracket_reform.yaml @@ -172,3 +172,28 @@ taxable_income_taxed_below_25_percent: 201_775 dwks19: 200_000 capital_gains_excluded_from_taxable_income: 100_000 + +# 26 U.S.C. 1(h)(1), "shall not exceed", on the reform's rate schedule. From +# 2026 the schedule taxes $49,300 to $119,400 (single) at 25 percent, below +# the 28 percent of subparagraph (F). Single, taxable income $100,000, all +# $50,000 of net capital gain 28-percent rate gain. Regular rate amount: +# the greater of $50,000 or the lesser of $49,300 and $100,000, $50,000; its +# tax is 10% of $12,150 plus 15% of $37,150 plus 25% of $700 = $6,962.50. +# Subparagraph (F): 28% of the other $50,000 = $14,000. The tax on all +# $100,000 is $6,787.50 plus 25% of $50,700 = $19,462.50, so the capital +# gains tax is $12,500, not $14,000. +- name: Additional tax bracket reform's own limit on the regular tax + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.additional_tax_bracket.additional_tax_bracket_reform.additional_tax_bracket + input: + gov.contrib.additional_tax_bracket.in_effect: true + filing_status: SINGLE + taxable_income: 100_000 + long_term_capital_gains: 50_000 + capital_gains_28_percent_rate_gain: 50_000 + output: + capital_gains_excluded_from_taxable_income: 50_000 + income_tax_main_rates: 6_962.50 + tax_on_taxable_income_at_main_rates: 19_462.50 + capital_gains_tax: 12_500 diff --git a/policyengine_us/tests/policy/contrib/biden/budget_2025/capital_gains_tax_increase.yaml b/policyengine_us/tests/policy/contrib/biden/budget_2025/capital_gains_tax_increase.yaml index 8ef857c3beb..2e452539778 100644 --- a/policyengine_us/tests/policy/contrib/biden/budget_2025/capital_gains_tax_increase.yaml +++ b/policyengine_us/tests/policy/contrib/biden/budget_2025/capital_gains_tax_increase.yaml @@ -28,3 +28,32 @@ output: taxable_income: 584_250 capital_gains_tax: 69_346 + +# 26 U.S.C. 1(h)(1), "shall not exceed": as in the baseline, the regular tax +# is at most the tax on all taxable income at the regular rates. 2025, +# single, $64,100 of wages and $125 of long-term gain: taxable income +# $48,475, of which $48,350 is ordinary income (tax $5,563.50). The gain +# lies above the $48,350 0% amount and is taxed at 15% ($18.75), but the tax +# on all $48,475 is $5,578.50, so the capital gains tax is $15. +- name: Reform active, the regular tax is limited to the tax on all taxable income + period: 2025 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.biden.budget_2025.capital_gains_tax_increase.capital_gains_tax_increase + input: + gov.contrib.biden.budget_2025.capital_gains.active: true + people: + head: + age: 45 + employment_income: 64_100 + long_term_capital_gains: 125 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: TX + output: + taxable_income: 48_475 + income_tax_main_rates: 5_563.50 + capital_gains_tax: 15 diff --git a/policyengine_us/tests/policy/contrib/harris/capital_gains/harris_capital_gains.yaml b/policyengine_us/tests/policy/contrib/harris/capital_gains/harris_capital_gains.yaml index 6cd59e14ac0..a9597c82040 100644 --- a/policyengine_us/tests/policy/contrib/harris/capital_gains/harris_capital_gains.yaml +++ b/policyengine_us/tests/policy/contrib/harris/capital_gains/harris_capital_gains.yaml @@ -117,3 +117,34 @@ taxable_income: 584_250 capital_gains_excluded_from_taxable_income: 386_950 capital_gains_tax: 69_346 + +# 26 U.S.C. 1(h)(1), "shall not exceed": as in the baseline, the regular tax +# is at most the tax on all taxable income at the regular rates. The +# reform's 0% amount for single filers ($47,025) ends $1,450 below the top +# of the 2025 12% bracket ($48,475). 2025, single, $62,775 of wages and +# $1,450 of long-term gain: taxable income $48,475, of which $47,025 is +# ordinary income (12% of it less $238.50 = $5,404.50). The reform taxes the +# gain at 15% ($217.50), but the tax on all $48,475 is $5,578.50, so the +# capital gains tax is $174. +- name: Reform applied, the regular tax is limited to the tax on all taxable income + period: 2025 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.harris.capital_gains.harris_capital_gains.harris_capital_gains + input: + gov.contrib.harris.capital_gains.in_effect: true + people: + head: + age: 45 + employment_income: 62_775 + long_term_capital_gains: 1_450 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: TX + output: + taxable_income: 48_475 + income_tax_main_rates: 5_404.50 + capital_gains_tax: 174 diff --git a/policyengine_us/tests/test_form_4952_section_911_interaction.py b/policyengine_us/tests/test_form_4952_section_911_interaction.py index 57bcad7642d..d608ab1b8c9 100644 --- a/policyengine_us/tests/test_form_4952_section_911_interaction.py +++ b/policyengine_us/tests/test_form_4952_section_911_interaction.py @@ -61,10 +61,14 @@ to the top of the 12% bracket (2025: $48,350 to $48,475 single), gain is taxed at 15% and ordinary income at 12%, so a dollar elected there lowers the tax by 3 cents, on the return as in the model - (test_election_can_lower_the_tax_where_15_percent_exceeds_12). The tax - (each of the three) falls by at most the 3 points times the width of that - band ($3.75 single, $7.50 joint in 2025), which `rate_overlap` computes - from the parameters. + (test_election_can_lower_the_tax_where_15_percent_exceeds_12). And where + the top of the 24% bracket sets the regular rate amount, an election can + move 28-percent rate gain to the 25% rate of unrecaptured section 1250 + gain (test_section_1h_regular_tax.py). The tax (each of the three) falls + by at most the 3 points times the width of that band ($3.75 single, $7.50 + joint in 2025), which `rate_overlap` computes from the parameters, plus 3 + points (28 less 25) of the smaller of the extra amount elected and the + unrecaptured section 1250 gain. 5. Worksheet lines. dwks10 equals net_capital_gain exactly for whole-dollar amounts and within single-precision rounding for amounts with cents; the model's lines 6, 9 and 10 equal the transcription in @@ -75,8 +79,10 @@ test_section_911_tax_stacking.py). With Schedule D lines 18 and 19 zero, Schedule D Tax Worksheet lines 14 to 47 are the Qualified Dividends and Capital Gain Tax Worksheet with worksheet line 6 for its line 2 and line 9 - for its line 3, and Form 6251 line 27 equals line 20. The model's regular - tax and AMT match. + for its line 3, and Form 6251 line 27 equals line 20. Otherwise the whole + Schedule D Tax Worksheet (transcribed in test_form_6251_part_iii.py) is + used, with the election on lines 3 and 4 and the footnote's four + modifications. The model's regular tax and AMT match. Amounts are whole dollars below 2**24 except where a household is drawn with cents. For every whole-dollar household the gains and the model's worksheet @@ -85,16 +91,12 @@ without itemized deductions. The rest are checked within single-precision rounding. -The scope limits of test_section_911_tax_stacking.py apply to the tax -comparisons (properties 4 and 6), which therefore use households without -28-percent rate gain or unrecaptured section 1250 gain. The model omits the -section 1(h)(1) cap at the tax on all taxable income at ordinary rates. It -uses the 26%/28% breakpoint of other filers for married filing separately on -Form 6251 line 18. And capital_gains_tax taxes 28-percent rate gain at 28% in -full, even where it exceeds taxable income or the net capital gain an election -leaves (Schedule D Tax Worksheet line 12 limits it to line 9): $10,000 of -collectibles gain and no taxable income gives $2,800 of tax. The households -are 45, so the exclusion does not reach the senior deduction. +The Schedule D Tax Worksheet transcription takes Schedule D lines 18 and 19 +as entered, without the short-term loss netting of the 28% Rate Gain and +Unrecaptured Section 1250 Gain Worksheets (1(h)(4)(B), 1(h)(6)), so property +6 compares households with either gain only when they have no short-term +loss. The +households are 45, so the exclusion does not reach the senior deduction. """ import numpy as np @@ -103,12 +105,15 @@ from policyengine_us import Simulation from policyengine_us.tests.test_form_4952_election_worksheet import ( + form_4952_line_4e, schedule_d_tax_worksheet, ) from policyengine_us.tests.test_section_911_tax_stacking import ( STATUSES, foreign_earned_income_tax_worksheet, + foreign_earned_income_tax_worksheet_schedule_d, form_6251_line_7, + form_6251_line_7_schedule_d, qualified_dividends_and_capital_gain_tax_worksheet, reduce_by_capital_gain_excess, tax_rate_schedule, @@ -331,6 +336,11 @@ def calculate(households, year, with_election=True, with_exclusion=True): results["rate_overlap"] = np.array( [overlaps[status] for status in results["filing_status"]] ) + gains = parameters.gov.irs.capital_gains + results["rate_gain_rate_difference"] = np.full( + len(households), + float(gains.other_cg_rate) - float(gains.unrecaptured_s_1250_rate), + ) return results @@ -568,54 +578,71 @@ def check(variable, expected, mask=exact): assert not law[variable][~excludes].any(), variable -def plain(households): - """Households inside the scope of the tax comparisons (see the module - docstring): no 28-percent rate or unrecaptured section 1250 gain.""" - return (column(households, "section_1250") == 0) & ( - column(households, "rate_gain_28") == 0 +def schedule_d_items(h): + """Schedule D Tax Worksheet amounts other than line 1 (see + test_form_6251_part_iii.py), with the election on lines 3 and 4.""" + schedule_d_line_15 = h["long_term"] + h["distributions"] + return dict( + qualified_dividends=h["dividends"], + form_4952_line_4g=h["election"], + form_4952_line_4e=form_4952_line_4e(h), + schedule_d_line_15=schedule_d_line_15, + schedule_d_line_16=schedule_d_line_15 + h["short_term"], + schedule_d_line_18=h["rate_gain_28"], + schedule_d_line_19=h["section_1250"], ) def assert_matches_2025_forms(households, law): """Property 6: the model against the transcribed 2025 forms.""" for i, h in enumerate(households): - if h["section_1250"] or h["rate_gain_28"]: + schedule_d = bool(h["section_1250"] or h["rate_gain_28"]) + if schedule_d and h["short_term"] < 0: + # See the module docstring. continue status = law["filing_status"][i] assert status == h["status"], (i, status) taxable_income = float(law["taxable_income"][i]) - # Schedule D Tax Worksheet lines 6 and 9 take the place of lines 2 - # and 3 of the Qualified Dividends and Capital Gain Tax Worksheet. - line_6, line_9, _ = schedule_d_tax_worksheet(h) - line_6_feitw, line_6_capped, ordinary_income = ( - foreign_earned_income_tax_worksheet( - taxable_income, h["exclusion"], line_6, line_9, status + taxable_excess = float(law["amt_income_less_exemptions"][i]) + if schedule_d: + items = schedule_d_items(h) + line_6_feitw, worksheet = foreign_earned_income_tax_worksheet_schedule_d( + taxable_income, h["exclusion"], items, status + ) + line_7 = form_6251_line_7_schedule_d( + taxable_excess, h["exclusion"], items, worksheet, status + ) + else: + # Schedule D Tax Worksheet lines 6 and 9 take the place of lines 2 + # and 3 of the Qualified Dividends and Capital Gain Tax Worksheet. + line_6, line_9, _ = schedule_d_tax_worksheet(h) + line_6_uncapped, line_6_feitw, ordinary_income = ( + foreign_earned_income_tax_worksheet( + taxable_income, h["exclusion"], line_6, line_9, status + ) + ) + # The line 47 cap binds by at most the rate overlap. + cap_gap = line_6_uncapped - line_6_feitw + cap_slack = tolerance(taxable_income + h["exclusion"]) + assert -cap_slack <= cap_gap <= law["rate_overlap"][i] + cap_slack, (i, h) + line_7 = form_6251_line_7( + taxable_excess, + h["exclusion"], + line_6, + line_9, + ordinary_income, + status, ) - ) stacked_income = taxable_income + h["exclusion"] slack = tolerance(stacked_income) + # Line 6 with the final line of the capital gains worksheet (line 25, + # or line 47 of the Schedule D Tax Worksheet). regular_tax = float(law["regular_tax"][i]) assert float(law["regular_tax_before_credits"][i]) == pytest.approx( regular_tax, abs=slack ), (i, h) - assert regular_tax == pytest.approx(line_6_capped, abs=slack), (i, h) - # The line 47 cap binds by at most the rate overlap. - cap_gap = line_6_feitw - line_6_capped - assert -slack <= cap_gap <= law["rate_overlap"][i] + slack, (i, h) - gain = section_1222_gain(h) - if status == "SEPARATE" and h["dividends"] + gain > 0: - # Form 6251 line 18 (see the module docstring). - continue - taxable_excess = float(law["amt_income_less_exemptions"][i]) - line_7 = form_6251_line_7( - taxable_excess, - h["exclusion"], - line_6, - line_9, - ordinary_income, - status, - ) - amt = max(0, line_7 - line_6_capped) + assert regular_tax == pytest.approx(line_6_feitw, abs=slack), (i, h) + amt = max(0, line_7 - line_6_feitw) assert float(law["alternative_minimum_tax"][i]) == pytest.approx( amt, abs=tolerance(stacked_income, taxable_excess) ), (i, h) @@ -710,7 +737,6 @@ def same_choice(lower, higher): fall = law["net_capital_gain"] - groups["more_elected"]["net_capital_gain"] assert (fall <= more + slack).all() - inside = plain(households) excluded = np.maximum( column(households, "exclusion"), groups["more_excluded"]["foreign_earned_income_exclusion"], @@ -721,7 +747,6 @@ def same_choice(lower, higher): "amt_income_less_exemptions_plus_section_911_exclusion" ], ) - overlap = law["rate_overlap"] no_dependents = np.array([h["status"] != "HEAD_OF_HOUSEHOLD" for h in households]) for v in ["regular_tax", "income_tax_before_credits", "income_tax"]: # tax_unit_itemizes treats liabilities within $0.01 as equal. @@ -731,14 +756,23 @@ def same_choice(lower, higher): def rows(lower, higher): if v == "income_tax": - return inside & no_dependents - return inside & same_choice(lower, higher) - - # More elected: the tax falls by at most the rate overlap. - for lower, higher in election_pairs: + return no_dependents + return same_choice(lower, higher) + + # More elected: the tax falls by at most the rate overlap plus 3 + # points of what the election can move from the 28 to the 25 percent + # rate (see the module docstring). The step from no election moves at + # most the election itself. + for (lower, higher), elected in zip( + election_pairs, [column(households, "election"), more] + ): dip = lower[v] - higher[v] mask = rows(lower, higher) - assert (dip[mask] <= (overlap + margin)[mask]).all(), v + bound = law["rate_overlap"] + law["rate_gain_rate_difference"] * np.minimum( + elected, column(households, "section_1250") + ) + bad = np.flatnonzero(mask & (dip > bound + margin)) + assert not bad.size, (v, bad[:3], dip[bad[:3]], bound[bad[:3]]) # Stacking never lowers the tax, and the tax never falls as the # excluded amount rises. for lower, higher in exclusion_pairs: @@ -747,7 +781,7 @@ def rows(lower, higher): assert (dip[mask] <= margin[mask]).all(), v # Section 911(f)(1)(A) applies "if such taxpayer has taxable income". for name in VARIANTS: - no_income = inside & (groups[name]["taxable_income"] == 0) + no_income = groups[name]["taxable_income"] == 0 assert not groups[name]["regular_tax"][no_income].any(), name @@ -833,6 +867,19 @@ def grid(): } for row in rows[::7] ] + # With no wages the capital gain excess (15,750, or less what is + # elected) is more than the 28-percent rate gain, so modification 4 + # lowers Schedule D line 19. + rows += [ + household( + long_term=50_000, + section_1250=30_000, + rate_gain_28=10_000, + election=election, + exclusion=40_025, + ) + for election in (0, 4_000) + ] return rows @@ -861,6 +908,21 @@ def test_grid(): assert (both & (law["capital_gains_tax"] > 0)).any() assert (both & (law["taxable_income"] == 0)).any() assert (both & (law["amt_section_911_capital_gain_excess"] > 0)).any() + # Modification 4 changes worksheet line 12, with and without an + # election, for households compared with the forms: the excess is beyond + # the 28-percent rate gain, and line 9 after modification 1 is above the + # unrecaptured section 1250 gain after modification 4. + rate_gain = column(GRID, "rate_gain_28") + line_9 = law["dwks09"] - excess + reduced_1250 = np.maximum(0, column(GRID, "section_1250") - (excess - rate_gain)) + reaches_1250 = ( + (column(GRID, "section_1250") > 0) + & (column(GRID, "short_term") >= 0) + & (excess > rate_gain) + & (line_9 > reduced_1250) + ) + assert (reaches_1250 & elects).any() + assert (reaches_1250 & ~elects).any() # Electing more and stacking each raise the tax of some households. assert (groups["more_elected"]["regular_tax"] > law["regular_tax"] + 1).any() assert (law["regular_tax"] > groups["unstacked"]["regular_tax"] + 1).any() diff --git a/policyengine_us/tests/test_form_6251_part_iii.py b/policyengine_us/tests/test_form_6251_part_iii.py index 1126bdcf26c..2f41a38b045 100644 --- a/policyengine_us/tests/test_form_6251_part_iii.py +++ b/policyengine_us/tests/test_form_6251_part_iii.py @@ -212,16 +212,78 @@ def schedule_d_tax_worksheet_2025(household, line_1, status): (collectibles) and unrecaptured section 1250 gain. Returns the lines. """ long_term = household["long_term_gains"] - schedule_d_18 = max(0, long_term) * household.get("collectibles_share", 0) / 100 - schedule_d_19 = max(0, long_term) * household.get("section_1250_share", 0) / 100 + return schedule_d_tax_worksheet_lines_2025( + line_1, + qualified_dividends=household["qualified_dividends"], + form_4952_line_4g=0, + form_4952_line_4e=0, + # Schedule D line 15 is the long-term gain, line 16 the net gain. + schedule_d_line_15=long_term, + schedule_d_line_16=long_term + household["short_term_gains"], + schedule_d_line_18=max(0, long_term) + * household.get("collectibles_share", 0) + / 100, + schedule_d_line_19=max(0, long_term) + * household.get("section_1250_share", 0) + / 100, + status=status, + ) + + +def schedule_d_tax_worksheet_lines_2025( + line_1, + qualified_dividends, + form_4952_line_4g, + form_4952_line_4e, + schedule_d_line_15, + schedule_d_line_16, + schedule_d_line_18, + schedule_d_line_19, + status, + capital_gain_excess=0, +): + """2025 Schedule D Tax Worksheet, lines 1 to 47. + + Lines 3 and 4 are Form 4952 lines 4g (the amount elected as investment + income) and 4e. Schedule D lines 18 and 19 are as figured with no + short-term loss or loss carryover, so line 18 is also line 14 of the + Unrecaptured Section 1250 Gain Worksheet (lines 1 to 4 of the 28% Rate + Gain Worksheet). + + `capital_gain_excess` is for a Form 2555 filer's second worksheet (2025 + Instructions for Form 1040, page 37, footnote to the Foreign Earned + Income Tax Worksheet): line 1 is then that worksheet's line 3, and the + four modifications apply. Returns the lines, with Schedule D line 19 as + used. + """ line = {1: line_1} - line[2] = household["qualified_dividends"] - line[3] = line[4] = line[5] = 0 + line[2] = qualified_dividends + line[3] = form_4952_line_4g + line[4] = form_4952_line_4e + line[5] = max(0, line[3] - line[4]) line[6] = max(0, line[2] - line[5]) - # Schedule D line 15 is the long-term gain, line 16 the net gain. - line[7] = min(long_term, long_term + household["short_term_gains"]) + line[7] = min(schedule_d_line_15, schedule_d_line_16) line[8] = min(line[3], line[4]) line[9] = max(0, line[7] - line[8]) + schedule_d_18, schedule_d_19 = schedule_d_line_18, schedule_d_line_19 + if capital_gain_excess > 0: + # "1. Reduce (but not below zero) the amount you would otherwise + # enter on ... line 9 of your Schedule D Tax Worksheet by your + # capital gain excess. 2. Reduce (but not below zero) the amount you + # would otherwise enter on ... line 6 ... by any of your capital gain + # excess not used in (1) above." + unused = max(0, capital_gain_excess - line[9]) + line[9] = max(0, line[9] - capital_gain_excess) + line[6] = max(0, line[6] - unused) + # "3. Reduce (but not below zero) the amount on your Schedule D, line + # 18, by your capital gain excess. 4. Include your capital gain + # excess as a loss on line 16 of your Unrecaptured Section 1250 Gain + # Worksheet": its line 17 nets the excess against line 14, the 28% + # rate gain, and line 18 subtracts what is left. + schedule_d_19 = max( + 0, schedule_d_19 - max(0, capital_gain_excess - schedule_d_18) + ) + schedule_d_18 = max(0, schedule_d_18 - capital_gain_excess) line[10] = line[6] + line[9] line[11] = schedule_d_18 + schedule_d_19 line[12] = min(line[9], line[11]) @@ -272,7 +334,7 @@ def schedule_d_tax_worksheet_2025(household, line_1, status): # Lines 21, 22, 30, 33, 39 and 42 split line 1: the amounts taxed at # the regular rates and at 0, 15, 20, 25 and 28 percent. split = line[21] + line[22] + line[30] + line[33] + line[39] + line[42] - assert split == pytest.approx(line[1], abs=0.01), (household, line) + assert split == pytest.approx(line[1], abs=0.01), line line[44] = regular_tax_2025(line[21], status) line[45] = line[31] + line[34] + line[40] + line[43] + line[44] line[46] = regular_tax_2025(line[1], status) @@ -436,7 +498,7 @@ def assert_matches_schedule_d_tax_worksheet(households, law): For households with no short-term loss, so that Schedule D lines 18 and 19 are the 28 percent rate gain and unrecaptured section 1250 gain as entered (a short-term loss would reduce them on the 28% Rate Gain and - Unrecaptured Section 1250 Gain Worksheets, which the model does not do). + Unrecaptured Section 1250 Gain Worksheets, which the transcription does not do). """ for i, h in enumerate(households): assert h["short_term_gains"] >= 0, h diff --git a/policyengine_us/tests/test_section_1h_regular_tax.py b/policyengine_us/tests/test_section_1h_regular_tax.py new file mode 100644 index 00000000000..37ffd3838de --- /dev/null +++ b/policyengine_us/tests/test_section_1h_regular_tax.py @@ -0,0 +1,543 @@ +"""The Form 4952 line 4g election and the regular tax under 26 U.S.C. 1(h). + +A Form 4952 line 4g election reduces net capital gain (section 1(h)(2)), so +the elected gain loses the capital gains rates. Section 1(h)(1) then taxes +the rest: subparagraph (A) at the regular rates the greater of taxable income +less net capital gain or the lesser of the taxable income taxed below 25 +percent and taxable income less adjusted net capital gain; (B) to (D) +adjusted net capital gain at 0, 15 and 20 percent; (E) unrecaptured section +1250 gain at 25 percent; and (F) "the amount of taxable income in excess of +the sum of the amounts on which tax is determined under the preceding +subparagraphs" at 28 percent. The 2025 Schedule D Tax Worksheet follows the +same steps: line 12 limits Schedule D lines 18 and 19 to line 9 (the gain, +other than qualified dividends, after the election), lines 41 to 43 tax the +rest of line 1 at 28 percent, and line 47 is "the smaller of line 45 or line +46". test_regular_tax_before_credits.py checks the regular tax against the +worksheet for 2025 and 2026 and its bounds across years; this file checks +what the election does, and the bounds with elections and both gains for +2018 to 2030. + +Properties, for every generated household and year (2018, 2022, 2025, 2026 +and 2030), with elections and both gains: + +1. The regular tax is never more than the tax on all taxable income at the + regular rates (line 46), figured here from the rate schedule parameters + rather than taken from the model. +2. With no taxable income the regular tax and the capital gains tax are + exactly zero. +3. Neither is negative. +4. Electing more never lowers the regular tax, or + income_tax_before_credits, by more than two amounts that the law itself + produces: + + - `rate_overlap` (test_form_4952_section_911_interaction.py): where the 0 + percent amount ends below the top of the 12 percent bracket, gain is + taxed at 15 percent and ordinary income at 12 percent ($3.75 single in + 2025); + - 3 percent (28 less 25) of the smaller of the extra amount elected and + the unrecaptured section 1250 gain. Subparagraph (E) taxes at 25 percent + the unrecaptured gain less "the excess (if any) of (I) the sum of the + amount on which tax is determined under subparagraph (A) plus the net + capital gain, over (II) taxable income" (worksheet lines 35 to 39). When + the top of the 24 percent bracket sets the (A) amount, that excess + shrinks with net capital gain, so an election moves gain from the 28 to + the 25 percent rate (test_election_can_move_gain_from_28_to_25_percent). + + Without unrecaptured section 1250 gain the first bound alone holds. + +Differential, for 2025: with the election on Schedule D Tax Worksheet lines 3 +and 4 (transcribed in test_form_6251_part_iii.py) and nonzero Schedule D lines +18 and 19, Form 6251 Part III (lines 13 to 15 and 27 from the worksheet after +the election) and line 11 match the model, and so does line 47. The +transcription takes Schedule D lines 18 and 19 as entered, without the +short-term loss netting of the 28% Rate Gain and Unrecaptured Section 1250 +Gain Worksheets (1(h)(4)(B), 1(h)(6)), so the households it compares have no +short-term loss. + +Households are 45, live in Texas and take the standard deduction, so the +election never changes taxable income. +""" + +import numpy as np +import pytest + +from policyengine_us import Simulation +from policyengine_us.tests.test_form_4952_section_911_interaction import ( + rate_overlap, +) +from policyengine_us.tests.test_form_6251_part_iii import ( + STATUSES, + form_6251_part_iii_2025, + schedule_d_tax_worksheet_lines_2025, + tolerance, +) + +# Hypothesis is a dev extra; skip rather than fail collection without it. +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") + +YEARS = [2018, 2022, 2025, 2026, 2030] + +OUTPUTS = [ + "filing_status", + "taxable_income", + "has_qdiv_or_ltcg", + "net_capital_gain", + "dwks10", + "dwks13", + "dwks19", + "income_tax_main_rates", + "tax_on_taxable_income_at_main_rates", + "capital_gains_tax", + "amt_income_less_exemptions", + "amt_tax_including_cg", + "amt_base_tax", + "alternative_minimum_tax", + "income_tax_before_credits", +] + + +def household(**amounts): + h = { + "status": "SINGLE", + "wages": 0, + "dividends": 0, + "long_term": 0, + "short_term": 0, + "collectibles": 0, + "section_1250": 0, + "election": 0, + "more_elected": 1_000, + } + h.update(amounts) + return h + + +def build_situation(households, year): + people, tax_units, marital_units = {}, {}, {} + groups = {"households": {}, "spm_units": {}, "families": {}} + for i, h in enumerate(households): + head = f"head_{i}" + people[head] = { + "age": {year: 45}, + "employment_income": {year: h["wages"]}, + "qualified_dividend_income": {year: h["dividends"]}, + "long_term_capital_gains": {year: h["long_term"]}, + "short_term_capital_gains": {year: h["short_term"]}, + "long_term_capital_gains_on_collectibles": {year: h["collectibles"]}, + "investment_income_elected_form_4952": {year: h["election"]}, + } + members = [head] + marital_units[f"marital_unit_{i}"] = {"members": [head]} + if h["status"] == "JOINT": + spouse = f"spouse_{i}" + people[spouse] = {"age": {year: 45}} + members.append(spouse) + marital_units[f"marital_unit_{i}"]["members"].append(spouse) + if h["status"] in ("HEAD_OF_HOUSEHOLD", "SURVIVING_SPOUSE"): + # Old enough to be a dependent without a child tax credit. + child = f"child_{i}" + people[child] = {"age": {year: 17}} + members.append(child) + marital_units[f"marital_unit_{i}_child"] = {"members": [child]} + tax_units[f"tax_unit_{i}"] = { + "members": members, + # Set for every tax unit: an input given to only some of them + # leaves the rest at the default (single). + "filing_status": {year: h["status"]}, + "unrecaptured_section_1250_gain": {year: h["section_1250"]}, + } + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: "TX"}, + } + groups["spm_units"][f"spm_unit_{i}"] = {"members": members} + groups["families"][f"family_{i}"] = {"members": members} + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + **groups, + } + + +def calculate(households, year): + simulation = Simulation(situation=build_situation(households, year)) + results = {} + for v in OUTPUTS: + values = simulation.calculate(v, year) + results[v] = ( + values.decode_to_str() if v == "filing_status" else np.asarray(values) + ) + results["regular_tax"] = results["income_tax_main_rates"].astype(float) + results[ + "capital_gains_tax" + ].astype(float) + parameters = simulation.tax_benefit_system.parameters(f"{year}-01-01") + results["parameters"] = parameters + return results + + +def tax_at_regular_rates(amount, parameters, status): + """The tax on an amount at the rate schedule in the parameters.""" + bracket = parameters.gov.irs.income.bracket + tax, bottom = 0.0, 0.0 + for i in range(1, len(list(bracket.rates.__iter__())) + 1): + top = max(bottom, float(bracket.thresholds[str(i)][status])) + if amount > bottom: + tax += float(bracket.rates[str(i)]) * (min(amount, top) - bottom) + bottom = top + return tax + + +def column(households, key): + return np.array([h[key] for h in households], dtype=float) + + +# --------------------------------------------------------------------------- +# The properties. +# --------------------------------------------------------------------------- + + +def assert_properties(households, law): + """Properties 1 to 3.""" + parameters = law["parameters"] + taxable_income = law["taxable_income"].astype(float) + regular_tax = law["regular_tax"] + slack = tolerance(taxable_income) + for i, h in enumerate(households): + status = str(law["filing_status"][i]) + assert status == h["status"], (i, status) + # 1. Never more than the tax on all taxable income (line 46), from + # the parameters, and the model's own line 46 is that tax. + line_46 = tax_at_regular_rates(taxable_income[i], parameters, status) + assert regular_tax[i] <= line_46 + slack[i], (i, h, regular_tax[i], line_46) + assert float(law["tax_on_taxable_income_at_main_rates"][i]) == pytest.approx( + line_46, abs=slack[i] + ), (i, h) + # 2. No taxable income, no regular tax. + none = taxable_income <= 0 + assert not regular_tax[none].any() + assert not law["capital_gains_tax"][none].any() + # 3. Not negative. + assert (law["capital_gains_tax"] >= 0).all() + assert (regular_tax >= 0).all() + + +def election_dip_bound(households, law): + """The largest fall in the regular tax that electing `more_elected` more + can cause (property 4).""" + parameters = law["parameters"] + gains = parameters.gov.irs.capital_gains + rate_difference = float(gains.other_cg_rate) - float(gains.unrecaptured_s_1250_rate) + overlaps = { + str(status): rate_overlap(parameters, str(status)) + for status in set(law["filing_status"]) + } + return np.array( + [ + overlaps[str(status)] + + rate_difference * min(h["more_elected"], h["section_1250"]) + for status, h in zip(law["filing_status"], households) + ] + ) + + +def assert_election_bound(households, year): + """Property 4, with each household and the same one electing more in one + batch.""" + count = len(households) + more = [{**h, "election": h["election"] + h["more_elected"]} for h in households] + results = calculate(households + more, year) + law = { + v: values[:count] if isinstance(values, np.ndarray) else values + for v, values in results.items() + } + higher = { + v: values[count:] if isinstance(values, np.ndarray) else values + for v, values in results.items() + } + assert_properties(households + more, results) + # The election never changes taxable income here (standard deduction). + assert np.array_equal(law["taxable_income"], higher["taxable_income"]) + slack = tolerance(law["taxable_income"].astype(float)) + bound = election_dip_bound(households, law) + for v in ["regular_tax", "income_tax_before_credits"]: + dip = law[v].astype(float) - higher[v].astype(float) + bad = np.flatnonzero(dip > bound + slack) + assert not bad.size, (v, [households[i] for i in bad[:3]], dip[bad[:3]]) + return law, higher + + +# --------------------------------------------------------------------------- +# The 2025 forms. +# --------------------------------------------------------------------------- + + +def worksheet_for(h, line_1, status): + """The 2025 Schedule D Tax Worksheet for a household. Form 4952 line 4e + is the smaller of line 4d (the net gain) and the net capital gain from + property held for investment, as the model reads it.""" + long_term, short_term = h["long_term"], h["short_term"] + net_gain = max(0, long_term + short_term) + investment_gain = max(0, long_term - max(0, -short_term)) + return schedule_d_tax_worksheet_lines_2025( + line_1, + qualified_dividends=h["dividends"], + form_4952_line_4g=h["election"], + form_4952_line_4e=min(net_gain, investment_gain), + schedule_d_line_15=long_term, + schedule_d_line_16=long_term + short_term, + schedule_d_line_18=h["collectibles"], + schedule_d_line_19=h["section_1250"], + status=status, + ) + + +def assert_matches_2025_forms(households, law): + for i, h in enumerate(households): + assert h["short_term"] >= 0, h + status = str(law["filing_status"][i]) + line_1 = float(law["taxable_income"][i]) + regular_tax = float(law["regular_tax"][i]) + if line_1 <= 0: + assert regular_tax == 0, (i, h) + continue + worksheet = worksheet_for(h, line_1, status) + slack = tolerance(line_1) + # The model fills its worksheet lines only for a filer with + # dividends or gains (dwks19 is zero otherwise); the tax is compared + # for everyone. + if law["has_qdiv_or_ltcg"][i]: + for model_line, worksheet_line in ( + ("dwks10", 10), + ("dwks13", 13), + ("dwks19", 21), + ): + assert float(law[model_line][i]) == pytest.approx( + worksheet[worksheet_line], abs=slack + ), (i, h, model_line, worksheet) + assert regular_tax == pytest.approx(worksheet[47], abs=slack), ( + i, + h, + worksheet, + ) + # Form 6251 Part III with lines 13 to 15 and 27 from the worksheet + # (after the election), then lines 9 to 11 with no foreign tax + # credit. + line_12 = float(law["amt_income_less_exemptions"][i]) + _, line_38, line_39, line_40 = form_6251_part_iii_2025( + line_12, + worksheet[13], + worksheet[14], + status, + line_14=worksheet["schedule_d_19"], + worksheet_line_10=worksheet[10], + line_27=worksheet[21], + ) + amt_slack = tolerance(line_12, line_1) + assert float(law["amt_tax_including_cg"][i]) == pytest.approx( + line_38, abs=amt_slack + ), (i, h) + assert float(law["amt_base_tax"][i]) == pytest.approx(line_39, abs=amt_slack), ( + i, + h, + ) + assert float(law["alternative_minimum_tax"][i]) == pytest.approx( + max(0, line_40 - worksheet[47]), abs=amt_slack + ), (i, h) + + +# --------------------------------------------------------------------------- +# Tests. +# --------------------------------------------------------------------------- + +# Wages, dividends, long-term gain, short-term gain, collectibles gain and +# unrecaptured section 1250 gain. +GRID_INCOMES = [ + (0, 0, 10_000, 0, 10_000, 0), + (100_000, 0, 100_000, 0, 100_000, 0), + (300_000, 0, 100_000, 0, 100_000, 0), + (115_750, 0, 200_000, 0, 100_000, 100_000), + (60_000, 5_000, 80_000, 10_000, 20_000, 30_000), + (20_000, 25_000, 900_000, 0, 90_000, 90_000), + (700_000, 10_000, 400_000, 0, 0, 150_000), + # Ordinary income at the 0 percent amount and gain in the 12 percent + # bracket, single, 2025. + (64_100, 0, 125, 0, 0, 0), + (64_100, 0, 10_000, 0, 0, 0), +] + + +def grid(): + rows = [] + for status in STATUSES: + for ( + wages, + dividends, + long_term, + short_term, + collectibles, + s1250, + ) in GRID_INCOMES: + gain = long_term + short_term + for election in sorted( + {0, gain // 2, gain, gain + dividends // 2, gain + dividends + 9_000} + ): + rows.append( + household( + status=status, + wages=wages, + dividends=dividends, + long_term=long_term, + short_term=short_term, + collectibles=collectibles, + section_1250=s1250, + election=election, + more_elected=50_000, + ) + ) + return rows + + +GRID = grid() + + +def test_grid_matches_the_2025_forms(): + law = calculate(GRID, 2025) + assert_properties(GRID, law) + assert_matches_2025_forms(GRID, law) + # The grid reaches the 28 percent rate on what is left of line 9 after + # an election, the line 47 limit, and the AMT with an election. + elects = column(GRID, "election") > 0 + rate_gain = column(GRID, "collectibles") > 0 + line_46 = law["tax_on_taxable_income_at_main_rates"] + taxed_at_28 = [ + i + for i, h in enumerate(GRID) + if elects[i] + and rate_gain[i] + and float(law["taxable_income"][i]) > 0 + and worksheet_for( + h, float(law["taxable_income"][i]), str(law["filing_status"][i]) + )[42] + > 0 + ] + assert taxed_at_28 + assert (np.abs(law["regular_tax"] - line_46) < 0.01)[ + law["capital_gains_tax"] > 0 + ].any() + assert (elects & (law["alternative_minimum_tax"] > 0)).any() + + +@pytest.mark.parametrize("year", YEARS) +def test_grid_keeps_the_properties(year): + law, higher = assert_election_bound(GRID, year) + # Electing more raises the tax of some households. + assert (higher["regular_tax"] > law["regular_tax"] + 1).any() + + +def test_election_can_move_gain_from_28_to_25_percent(): + """An intended exception to "electing more never lowers the tax". + + 2025, single: $115,750 of wages and $200,000 of long-term gain, $100,000 + of it collectibles gain and $100,000 unrecaptured section 1250 gain. + Taxable income is $300,000. Schedule D Tax Worksheet with no election: + lines 9 to 12 are $200,000, line 13 is $0, line 18 $100,000, line 20 + $197,300 and line 21 $197,300. Line 35 is $100,000; line 38 is $200,000 + + $197,300 - $300,000 = $97,300, the part of the 1250 gain the regular + rates tax; line 39 is $2,700 (25%: $675) and line 42 $100,000 (28%: + $28,000). Electing $50,000 lowers line 10 to $150,000 and line 38 to + $47,300, so line 39 is $52,700 ($13,175) and line 42 $50,000 ($14,000): + the tax falls by $1,500, 3% of $50,000. Section 1(h)(1)(E)(ii) measures + the same excess with net capital gain, which the election reduces + (section 1(h)(2)). + """ + households = [ + household( + wages=115_750, + long_term=200_000, + collectibles=100_000, + section_1250=100_000, + election=election, + ) + for election in (0, 50_000) + ] + law = calculate(households, 2025) + assert list(law["taxable_income"]) == [300_000, 300_000] + assert list(law["regular_tax"]) == pytest.approx([68_874, 67_374], abs=0.01) + worksheets = [worksheet_for(h, 300_000, "SINGLE") for h in households] + assert [w[39] for w in worksheets] == [2_700, 52_700] + assert [w[42] for w in worksheets] == [100_000, 50_000] + assert [w[47] for w in worksheets] == pytest.approx([68_874, 67_374]) + assert law["alternative_minimum_tax"].tolist() == [0, 0] + assert_matches_2025_forms(households, law) + # Within the bound of property 4: 3% of the $50,000 elected. + bound = election_dip_bound([{**households[0], "more_elected": 50_000}], law) + assert bound[0] == pytest.approx(3.75 + 1_500) + dip = law["regular_tax"][0] - law["regular_tax"][1] + assert dip <= bound[0] + tolerance(300_000) + + +@st.composite +def households(draw, short_term_loss=True): + h = household( + status=draw(st.sampled_from(STATUSES)), + wages=draw(st.one_of(st.just(0), st.integers(1, 1_000_000))), + dividends=draw(st.one_of(st.just(0), st.integers(1, 300_000))), + long_term=draw(st.one_of(st.just(0), st.integers(1, 1_500_000))), + short_term=draw( + st.one_of( + st.just(0), + st.integers(-100_000 if short_term_loss else 1, 200_000), + ) + ), + more_elected=draw(st.one_of(st.integers(1, 500), st.integers(1, 400_000))), + ) + # 28-percent rate gain and unrecaptured section 1250 gain, as parts of + # the long-term gain; together they may exceed it, as Schedule D lines 18 + # and 19 can exceed line 9. + h["collectibles"] = draw(st.one_of(st.just(0), st.integers(0, h["long_term"]))) + h["section_1250"] = draw(st.one_of(st.just(0), st.integers(0, h["long_term"]))) + gain = max(0, h["long_term"] + min(0, h["short_term"])) + # Elections at the gain and at the gain plus dividends are where the + # election starts and stops reducing qualified dividends. + h["election"] = draw( + st.one_of( + st.just(0), + st.integers(1, 2_000_000), + st.sampled_from([gain, gain + h["dividends"]]), + st.integers(max(0, gain - 5), gain + h["dividends"] + 5), + ) + ) + return h + + +# Fixed examples, so CI on an unrelated pull request draws the same households. +SETTINGS = dict( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[ + hypothesis.HealthCheck.too_slow, + hypothesis.HealthCheck.data_too_large, + ], +) + + +@hypothesis.settings(**SETTINGS) +@hypothesis.given( + st.lists(households(), min_size=1, max_size=25), st.sampled_from(YEARS) +) +def test_random_households_keep_the_properties(batch, year): + assert_election_bound(batch, year) + + +@hypothesis.settings(**SETTINGS) +@hypothesis.given(st.lists(households(short_term_loss=False), min_size=1, max_size=25)) +# Found by this test: an election with no gains or dividends. The model's +# worksheet lines are zero without dividends or gains; the tax still matches. +@hypothesis.example([household(wages=122_146, election=50_223)]) +def test_random_households_match_the_2025_forms(batch): + law = calculate(batch, 2025) + assert_properties(batch, law) + assert_matches_2025_forms(batch, law) diff --git a/policyengine_us/tests/test_section_911_tax_stacking.py b/policyengine_us/tests/test_section_911_tax_stacking.py index 379db143da5..f4c05a27fb5 100644 --- a/policyengine_us/tests/test_section_911_tax_stacking.py +++ b/policyengine_us/tests/test_section_911_tax_stacking.py @@ -17,33 +17,40 @@ (Foreign Earned Income Tax Worksheet, Qualified Dividends and Capital Gain Tax Worksheet, and Form 6251 with its own Foreign Earned Income Tax Worksheet and Part III) and compared with the model, household by - household. The model reaches the same tax by a different route: the - section 1(h) formulas in `capital_gains_excluded_from_taxable_income`, - `income_tax_main_rates` and `capital_gains_tax`. + household. A household with 28-percent rate gain or unrecaptured section + 1250 gain uses the Schedule D Tax Worksheet and the Form 6251 Part III + lines it feeds, transcribed in test_form_6251_part_iii.py, with the four + modifications of the Foreign Earned Income Tax Worksheet footnote and the + AMT capital gain excess. The model reaches the same tax by a different + route: the section 1(h) formulas in + `capital_gains_excluded_from_taxable_income`, `income_tax_main_rates` and + `capital_gains_tax`. - Properties that hold for every household: with nothing excluded, every amount that section 911(f) replaces is unchanged, bit for bit; the stacked tax is never below the unstacked tax; zero taxable income gives zero regular tax; and the tax never falls as the excluded amount rises. -Households live in Texas; some itemize. - -The model takes the section 1(h)(1) cap at the tax on all taxable income -at ordinary rates (worksheet line 25, "the smaller of line 23 or line 24"), -so it is compared with line 25. The cap binds only where the 15% rate -applies inside the 12% bracket, a band of $100 to $250 of income in 2025, -so line 25 is checked to be within $7.50 of line 23. - -One limit of the model sets the scope of these tests, and it does not come -from section 911: `capital_gains_tax` taxes 28-percent rate gain in full -even when it exceeds taxable income, so the households here have no -28-percent rate or unrecaptured section 1250 gain. YAML unit tests cover how -the capital gain excess reduces those two amounts. +Households live in Texas; some itemize. The model is compared with the +worksheets' final lines (line 25 of the capital gains worksheet, line 47 of +the Schedule D Tax Worksheet), which take the smaller of the section 1(h) sum +and the tax on all of line 1 (26 U.S.C. 1(h)(1), "shall not exceed"). The +cap binds only where the 15% rate applies inside the 12% bracket, a band of +$100 to $250 of income in 2025, so line 25 is checked to be within $7.50 of +line 23. The Schedule D Tax Worksheet transcription takes Schedule D lines +18 and 19 as entered, without the short-term loss netting of the 28% Rate +Gain and Unrecaptured Section 1250 Gain Worksheets (1(h)(4)(B), 1(h)(6)), so +households with either gain have no short-term loss in the worksheet +comparisons. """ import numpy as np import pytest from policyengine_us import Simulation +from policyengine_us.tests.test_form_6251_part_iii import ( + form_6251_part_iii_2025, + schedule_d_tax_worksheet_lines_2025, +) # Hypothesis is a dev extra; skip rather than fail collection without it. hypothesis = pytest.importorskip("hypothesis") @@ -166,9 +173,9 @@ def foreign_earned_income_tax_worksheet( """2025 Form 1040 instructions, page 37. Returns line 6 figured with line 23 of the capital gains worksheet (the - tax before the section 1(h)(1) cap), line 6 figured with its line 25 - (what the form says and the model computes), and line 5 of the capital - gains worksheet, which Form 6251 needs. + section 1(h) sum), line 6 figured with its line 25 (the smaller of that + sum and the tax on all of line 1, which is the form's and the model's + tax), and line 5 of the capital gains worksheet, which Form 6251 needs. A filer with no exclusion gets the ordinary line 16 tax. """ line_1 = taxable_income @@ -262,6 +269,95 @@ def form_6251_line_7( return max(0, line_4 - line_5) +def schedule_d_worksheet_items(household): + """Schedule D Tax Worksheet amounts other than line 1, for a household + with no short-term loss (so Schedule D lines 18 and 19 are the gains as + entered) and no Form 4952 election.""" + long_term = household["long_term_gains"] + return dict( + qualified_dividends=household["qualified_dividends"], + form_4952_line_4g=0, + form_4952_line_4e=0, + schedule_d_line_15=long_term, + schedule_d_line_16=long_term + household["short_term_gains"], + schedule_d_line_18=household.get("collectibles_gains", 0), + schedule_d_line_19=household.get("unrecaptured_section_1250_gain", 0), + ) + + +def uses_schedule_d_tax_worksheet(household): + return bool( + household.get("collectibles_gains", 0) + or household.get("unrecaptured_section_1250_gain", 0) + ) + + +def foreign_earned_income_tax_worksheet_schedule_d( + taxable_income, excluded, items, status +): + """2025 Form 1040 instructions, page 37, with the Schedule D Tax + Worksheet (`items` as schedule_d_worksheet_items returns them). + + The worksheet is completed through line 10 on line 3; the capital gain + excess is line 10 less taxable income; a second worksheet applies the + footnote's four modifications. Returns line 6 and the worksheet behind + line 4, whose lines 14 and 21 are Form 6251 lines 20 and 27 "as figured + for the regular tax" (they "take into account your regular tax capital + gain excess"). A filer with no exclusion gets the ordinary line 16 tax. + """ + line_1 = taxable_income + line_2c = max(0, excluded) + line_3 = line_1 + line_2c + worksheet = schedule_d_tax_worksheet_lines_2025(line_3, status=status, **items) + excess = max(0, worksheet[10] - line_1) if line_2c > 0 else 0 + if excess > 0: + worksheet = schedule_d_tax_worksheet_lines_2025( + line_3, status=status, capital_gain_excess=excess, **items + ) + # "If Form 1040 or 1040-SR, line 15, is zero, don't complete this + # worksheet." + if line_1 <= 0: + return 0.0, worksheet + line_5 = tax_rate_schedule(line_2c, status) + return max(0, worksheet[47] - line_5), worksheet + + +def form_6251_line_7_schedule_d( + taxable_excess, excluded, items, regular_worksheet, status +): + """Form 6251 line 7 through its Foreign Earned Income Tax Worksheet, for + a household that uses the Schedule D Tax Worksheet. + + 2025 Form 6251 instructions, pages 10 and 13. With no AMT adjustments + the AMT Schedule D Tax Worksheet is the regular one; the AMT capital gain + excess is its line 10 less Form 6251 line 6, and the four modifications + apply to it for lines 13 to 15 of Part III. Lines 20 and 27 come from + `regular_worksheet`, the worksheet figured for the regular tax. + """ + if taxable_excess <= 0: + return 0.0 + line_1 = taxable_excess + line_2c = max(0, excluded) + line_3 = line_1 + line_2c + amt_worksheet = schedule_d_tax_worksheet_lines_2025(line_3, status=status, **items) + excess = max(0, amt_worksheet[10] - line_1) if line_2c > 0 else 0 + if excess > 0: + amt_worksheet = schedule_d_tax_worksheet_lines_2025( + line_3, status=status, capital_gain_excess=excess, **items + ) + _, _, _, line_4 = form_6251_part_iii_2025( + line_3, + amt_worksheet[13], + regular_worksheet[14], + status, + line_14=amt_worksheet["schedule_d_19"], + worksheet_line_10=amt_worksheet[10], + line_27=regular_worksheet[21], + ) + line_5 = amt_rates(line_2c, status) + return max(0, line_4 - line_5) + + # --------------------------------------------------------------------------- # The model. # --------------------------------------------------------------------------- @@ -381,38 +477,54 @@ def tolerance(*amounts): def assert_matches_worksheets(households, law): """The 2025 model against the transcribed 2025 worksheets.""" - cap_gaps = [] for i, h in enumerate(households): status = law["filing_status"][i] assert status == h["status"], (i, status) taxable_income = float(law["taxable_income"][i]) gain = net_gain(h) dividends = h["qualified_dividends"] - line_6, line_6_capped, ordinary_income = foreign_earned_income_tax_worksheet( - taxable_income, h["exclusion"], dividends, gain, status - ) + taxable_excess = float(law["amt_income_less_exemptions"][i]) + if uses_schedule_d_tax_worksheet(h): + # See the module docstring. + assert h["short_term_gains"] >= 0, h + items = schedule_d_worksheet_items(h) + line_6, worksheet = foreign_earned_income_tax_worksheet_schedule_d( + taxable_income, h["exclusion"], items, status + ) + line_7 = form_6251_line_7_schedule_d( + taxable_excess, h["exclusion"], items, worksheet, status + ) + else: + line_6_uncapped, line_6, ordinary_income = ( + foreign_earned_income_tax_worksheet( + taxable_income, h["exclusion"], dividends, gain, status + ) + ) + # The form's line 25 cap takes at most $7.50 off line 23. + cap_gap = line_6_uncapped - line_6 + slack = tolerance(taxable_income + h["exclusion"]) + assert -slack <= cap_gap <= 7.5 + slack, (i, h) + line_7 = form_6251_line_7( + taxable_excess, + h["exclusion"], + dividends, + gain, + ordinary_income, + status, + ) regular_tax = float(law["regular_tax"][i]) stacked_income = taxable_income + h["exclusion"] assert float(law["regular_tax_before_credits"][i]) == pytest.approx( regular_tax, abs=tolerance(stacked_income) ), (i, h) - assert regular_tax == pytest.approx( - line_6_capped, abs=tolerance(stacked_income) - ), (i, h) - # The form's line 25 cap takes at most $7.50 off line 23. - cap_gaps.append(line_6 - line_6_capped) - line_7 = form_6251_line_7( - float(law["amt_income_less_exemptions"][i]), - h["exclusion"], - dividends, - gain, - ordinary_income, - status, + # Line 6 with line 25 of the capital gains worksheet or line 47 of + # the Schedule D Tax Worksheet. + assert regular_tax == pytest.approx(line_6, abs=tolerance(stacked_income)), ( + i, + h, ) - assert -tolerance(stacked_income) <= cap_gaps[-1], (i, h) - assert cap_gaps[-1] <= 7.5 + tolerance(stacked_income), (i, h) # Form 6251 lines 9 to 11 with no foreign tax credit. - amt = max(0, line_7 - line_6_capped) + amt = max(0, line_7 - line_6) taxable_excess = float( law["amt_income_less_exemptions_plus_section_911_exclusion"][i] ) @@ -496,18 +608,29 @@ def assert_invariants(households, year): "qualified_dividends": dividends, "long_term_gains": long_term, "short_term_gains": short_term, + "collectibles_gains": collectibles, + "unrecaptured_section_1250_gain": section_1250, "exclusion": exclusion, } for status in STATUSES for wages in (0, 20_000, 59_975, 140_000, 700_000) - for dividends, long_term, short_term in ( - (0, 0, 0), - (3_000, 0, 0), - (0, 50_000, 0), - (10_000, 40_000, -15_000), - (0, 30_000, 20_000), - (25_000, 900_000, 0), - (0, -8_000, 5_000), + for dividends, long_term, short_term, collectibles, section_1250 in ( + (0, 0, 0, 0, 0), + (3_000, 0, 0, 0, 0), + (0, 50_000, 0, 0, 0), + (10_000, 40_000, -15_000, 0, 0), + (0, 30_000, 20_000, 0, 0), + (25_000, 900_000, 0, 0, 0), + (0, -8_000, 5_000, 0, 0), + # 28-percent rate gain and unrecaptured section 1250 gain. + (0, 30_000, 0, 30_000, 0), + (5_000, 80_000, 10_000, 20_000, 15_000), + (0, 200_000, 0, 100_000, 100_000), + (20_000, 600_000, 40_000, 0, 100_000), + # With no wages the excess (15,750 single, more for other statuses) + # is more than the 28-percent rate gain, so modification 4 lowers + # Schedule D line 19, and line 12 with it. + (0, 50_000, 0, 10_000, 30_000), ) for exclusion in (0, 1, 40_025, 130_000, 260_000) ] @@ -523,6 +646,23 @@ def test_grid_matches_the_2025_worksheets(): assert (excludes & (law["capital_gains_tax"] > 0)).any() assert (excludes & (law["alternative_minimum_tax"] > 0)).any() assert (excludes & (law["taxable_income"] == 0)).any() + # And, with 28-percent rate gain or unrecaptured section 1250 gain, an + # excess that reaches each of them, and the AMT. + rate_gain = np.array([h["collectibles_gains"] > 0 for h in GRID]) + section_1250 = np.array([h["unrecaptured_section_1250_gain"] > 0 for h in GRID]) + excess = law["section_911_capital_gain_excess"] + collectibles = np.array([h["collectibles_gains"] for h in GRID]) + assert (rate_gain & (excess > 0)).any() + # Modification 4 changes worksheet line 12: the excess is beyond the + # 28-percent rate gain, and line 9 after modification 1 is above the + # unrecaptured section 1250 gain after modification 4. + unrecaptured = np.array([h["unrecaptured_section_1250_gain"] for h in GRID]) + line_9 = np.array([net_gain(h) for h in GRID]) - excess + reduced_1250 = np.maximum(0, unrecaptured - (excess - collectibles)) + assert (section_1250 & (excess > collectibles) & (line_9 > reduced_1250)).sum() >= 2 + assert ( + (rate_gain | section_1250) & excludes & (law["alternative_minimum_tax"] > 0) + ).any() @pytest.mark.parametrize("year", [2018, 2022, 2025, 2026]) @@ -757,6 +897,28 @@ def get(variable): } ) +# Adds 28-percent rate gain (collectibles) and unrecaptured section 1250 gain, +# up to the long-term gain, to households with no short-term loss (see the +# module docstring). +schedule_d_household_strategy = st.builds( + lambda h, collectibles, section_1250: { + **h, + "collectibles_gains": ( + min(collectibles, max(0, h["long_term_gains"])) + if h["short_term_gains"] >= 0 + else 0 + ), + "unrecaptured_section_1250_gain": ( + min(section_1250, max(0, h["long_term_gains"])) + if h["short_term_gains"] >= 0 + else 0 + ), + }, + household_strategy, + st.one_of(st.just(0), st.integers(1, 400_000)), + st.one_of(st.just(0), st.integers(1, 400_000)), +) + # Fixed examples, so CI on an unrelated pull request draws the same households. SETTINGS = dict( max_examples=10, @@ -771,7 +933,7 @@ def get(variable): @hypothesis.settings(**SETTINGS) @hypothesis.given( - st.lists(household_strategy, min_size=1, max_size=25), + st.lists(schedule_d_household_strategy, min_size=1, max_size=25), st.sampled_from([2018, 2022, 2025, 2026]), ) def test_random_households_keep_the_invariants(households, year): @@ -779,6 +941,6 @@ def test_random_households_keep_the_invariants(households, year): @hypothesis.settings(**SETTINGS) -@hypothesis.given(st.lists(household_strategy, min_size=1, max_size=25)) +@hypothesis.given(st.lists(schedule_d_household_strategy, min_size=1, max_size=25)) def test_random_households_match_the_2025_worksheets(households): assert_matches_worksheets(households, calculate(households, 2025))