diff --git a/Makefile b/Makefile index a7af971e0a0..748fcbadd10 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,11 @@ REST_SPM_TESTS := $(TESTS)/core/test_spm_policy_family.py \ # cached-arrays (403 s): test_formulas_do_not_write_into_cached_arrays. # heavy-a (350 s): md_poverty_line_credit_invariants (134 s), # ald_determinism (94 s), dependent_net_investment_income_invariants -# (64 s), ssi_state_supplement_medicaid_dependency (59 s). +# (64 s), ssi_state_supplement_medicaid_dependency (59 s); since then +# capital_gains_mtr_measurement (246 s in core in run 38056548883, +# before its fixed cases moved to YAML), placed here because heavy-a +# had the lowest time and peak memory of the groups in that run (357 s, +# 5.4 GB; core 838 s and 7.1 GB with it). # heavy-b (345 s): marginal_tax_rate_coverage (124 s), # multi_year_simulation (88 s), behavioral_response_measurements (84 s), # dependent_losses_agi_invariants (49 s). @@ -48,7 +52,8 @@ REST_CACHED_ARRAY_TESTS := $(TESTS)/test_formulas_do_not_write_into_cached_array REST_HEAVY_A_TESTS := $(TESTS)/test_md_poverty_line_credit_invariants.py \ $(TESTS)/core/test_ald_determinism.py \ $(TESTS)/core/test_dependent_net_investment_income_invariants.py \ - $(TESTS)/core/test_ssi_state_supplement_medicaid_dependency.py + $(TESTS)/core/test_ssi_state_supplement_medicaid_dependency.py \ + $(TESTS)/core/test_capital_gains_mtr_measurement.py REST_HEAVY_B_TESTS := $(TESTS)/core/test_marginal_tax_rate_coverage.py \ $(TESTS)/core/test_multi_year_simulation.py \ $(TESTS)/core/test_behavioral_response_measurements.py \ diff --git a/changelog.d/fix-cg-mtr-measurement.fixed.md b/changelog.d/fix-cg-mtr-measurement.fixed.md new file mode 100644 index 00000000000..5e94885c5b1 --- /dev/null +++ b/changelog.d/fix-cg-mtr-measurement.fixed.md @@ -0,0 +1 @@ +Measure the capital gains marginal tax rate on long-term gains, so capital gains realization responses see the preferential rates and changes to them, and scale the measured rise with income so the rate keeps its precision for large households. diff --git a/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py b/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py new file mode 100644 index 00000000000..f83d5d516a4 --- /dev/null +++ b/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py @@ -0,0 +1,454 @@ +"""Household checks of ``marginal_tax_rate_on_capital_gains``. + +The capital gains realization response scales a person's long-term gains with +the change in this rate between baseline and reform policy +(``capital_gains_responses.py``), so it has to be the rate on long-term gains. +It used to raise ``capital_gains``, the sum of short- and long-term gains. +That reaches adjusted gross income but not net capital gain, so outside the +cases where taxable income was below net capital gain the rise was taxed as +ordinary income, and raising the top capital gains rate from 20% to 28% left +a top-bracket filer's measured rate unchanged. + +Invariants, for 2026: + +1. Statutory rate. A Texas single filer whose only income is long-term gains + in the 15% or 20% bracket faces that bracket's rate (26 U.S.C. 1(h)(1)), + plus the 3.8% net investment income tax once modified adjusted gross + income passes its threshold (26 U.S.C. 1411), plus half that rate where + each dollar of gains also removes 50 cents of alternative minimum tax + exemption (26 U.S.C. 55(d)(2) and (d)(4)). Texas has no income tax. +2. Reform. With the top capital gains rate set to r, a top-bracket filer's + rate moves by r less the baseline rate, both measured directly and through + the behavioral response measurement branches. That holds until the tax at + the capital gains rates would exceed the tax on all taxable income at the + ordinary rates, which 26 U.S.C. 1(h)(1) then imposes instead: for r = 40%, + a filer with $20 million of gains faces the 37% ordinary rate and one with + $1 million still faces 40%. +3. Differential. The rate measured in a branch equals the finite difference + between new simulations of the same households, across states, filing + statuses, wages and gains, including households that give a + ``capital_gains`` total as an input, at construction or later. The new + simulations use the same rise, so this checks the branch, not the size of + the rise. +4. Order. Requesting the capital gains response before the labor supply + response gives both the values they have when requested the other way. +5. Rise. The rise in gains is at least $1,000, is $1,000 for households under + $1 million, and survives float32 rounding to within 1.2e-4 of itself. This + one is checked on the function alone. + +The filers in 1 and 2 have 0.1% of their gains, or $1,000 if more, added to +them, so a rate is exact only where that rise stays inside one bracket; the +examples keep clear of the bracket edges. The expectations for 2 also leave +out two narrow ranges of the top rate that ``top_bracket_rate`` does not +model (see ``TOP_RATES``). Fixed household examples of each +bracket, wages, joint filers, given ``capital_gains`` totals, an input given +for another year and a state cliff are YAML tests in ``tests/policy/baseline/household/ +marginal_tax_rate_on_capital_gains.yaml``. These tests cover what YAML +cannot: properties over generated households, reforms read through the +behavioral response measurement branches, and the differential against new +simulations. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings, strategies as st + +from policyengine_us import Simulation +from policyengine_us.system import system +from policyengine_us.variables.gov.simulation.capital_gains_responses import ( + CAPITAL_GAINS_MTR_MINIMUM_RISE, + CAPITAL_GAINS_MTR_RELATIVE_RISE, + capital_gains_mtr_rise, +) + +YEAR = "2026" +AGE = 50 +IRS = system.parameters(f"{YEAR}-01-01").gov.irs +CAPITAL_GAINS_RATES = [IRS.capital_gains.rates[bracket] for bracket in "123"] +STANDARD_DEDUCTION = IRS.deductions.standard.amount["SINGLE"] +# Long-term gains at which a single filer with no other income leaves the 0% +# and the 15% brackets. +FIFTEEN_PERCENT_START = int( + STANDARD_DEDUCTION + IRS.capital_gains.thresholds["1"]["SINGLE"] +) +TWENTY_PERCENT_START = int( + STANDARD_DEDUCTION + IRS.capital_gains.thresholds["2"]["SINGLE"] +) +ORDINARY_RATES = [IRS.income.bracket.rates[str(bracket)] for bracket in range(1, 8)] +ORDINARY_THRESHOLDS = [ + IRS.income.bracket.thresholds[str(bracket)]["SINGLE"] for bracket in range(1, 7) +] +TOP_ORDINARY_RATE = ORDINARY_RATES[-1] +NIIT = IRS.investment.net_investment_income_tax +AMT_EXEMPTION = IRS.income.amt.exemption +# The alternative minimum tax does not allow the standard deduction, so a +# filer owes it once the exemption has phased out to less than the standard +# deduction. From there until the exemption is gone, each dollar of gains +# removes ``phase_out.rate`` dollars of exemption, taxed at the gains rate. +AMT_PHASE_OUT_RATE = AMT_EXEMPTION.phase_out.rate +AMT_BINDS_FROM = ( + AMT_EXEMPTION.phase_out.start["SINGLE"] + + (AMT_EXEMPTION.amount["SINGLE"] - STANDARD_DEDUCTION) / AMT_PHASE_OUT_RATE +) +AMT_EXEMPTION_GONE = ( + AMT_EXEMPTION.phase_out.start["SINGLE"] + + AMT_EXEMPTION.amount["SINGLE"] / AMT_PHASE_OUT_RATE +) +RATE_EDGES = ( + FIFTEEN_PERCENT_START, + NIIT.threshold["SINGLE"], + TWENTY_PERCENT_START, + AMT_BINDS_FROM, + AMT_EXEMPTION_GONE, +) +# household_net_income is a float32, which resolves 2 ** -23 of its size, and +# the rise is at least 0.1% of it, so the difference alone resolves the rate +# to 1.2e-4. The tax itself is rounded along the way. +RATE_RESOLUTION = 2.5e-4 +TOP_RATE = "gov.irs.capital_gains.rates.3" +REFORM_PERIOD = f"{YEAR}-01-01.{YEAR}-12-31" +TOP_BRACKET_GAINS = (1_000_000, 20_000_000, 100_000_000) +SIMULATION_SETTINGS = dict( + deadline=None, + derandomize=True, + database=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def statutory_rate(gains): + """Federal rate on one more dollar of a single filer's only income.""" + if gains < FIFTEEN_PERCENT_START: + rate = CAPITAL_GAINS_RATES[0] + elif gains < TWENTY_PERCENT_START: + rate = CAPITAL_GAINS_RATES[1] + else: + rate = CAPITAL_GAINS_RATES[2] + net_investment_income_tax = NIIT.rate if gains > NIIT.threshold["SINGLE"] else 0 + alternative_minimum_tax = ( + AMT_PHASE_OUT_RATE * rate if AMT_BINDS_FROM < gains < AMT_EXEMPTION_GONE else 0 + ) + return rate + net_investment_income_tax + alternative_minimum_tax + + +def top_bracket_rate(gains, top_rate): + """Regular tax rate on the next dollar of a top-bracket filer's gains. + + 26 U.S.C. 1(h)(1): the tax "shall not exceed" the tax at the capital gains + rates, so it is the smaller of that and the tax on all taxable income at + the ordinary rates (Schedule D Tax Worksheet, line 47). + """ + taxable_income = gains - STANDARD_DEDUCTION + assert taxable_income > ORDINARY_THRESHOLDS[-1] + tax_at_capital_gains_rates = CAPITAL_GAINS_RATES[1] * ( + TWENTY_PERCENT_START - FIFTEEN_PERCENT_START + ) + top_rate * (gains - TWENTY_PERCENT_START) + edges = [0, *ORDINARY_THRESHOLDS, taxable_income] + tax_at_ordinary_rates = sum( + rate * (upper - lower) + for rate, lower, upper in zip(ORDINARY_RATES, edges, edges[1:]) + ) + if tax_at_capital_gains_rates < tax_at_ordinary_rates: + return top_rate + return TOP_ORDINARY_RATE + + +def clear_of_rate_edges(gains): + rise = max(CAPITAL_GAINS_MTR_MINIMUM_RISE, CAPITAL_GAINS_MTR_RELATIVE_RISE * gains) + return all(not edge - 2 * rise <= gains <= edge + rise for edge in RATE_EDGES) + + +def simulate(households, reform=None): + """A simulation of ``households``, each a state and its adults. + + An adult is their wages and long-term gains, optionally followed by a + ``capital_gains`` total given as an input. Two adults are married and file + jointly. + """ + situation = { + key: {} + for key in ( + "people", + "tax_units", + "marital_units", + "families", + "spm_units", + "households", + ) + } + for index, (state, adults) in enumerate(households): + members = [] + for adult, (wages, gains, *capital_gains) in enumerate(adults): + name = f"adult_{index}_{adult}" + members.append(name) + situation["people"][name] = { + "age": {YEAR: AGE}, + "employment_income": {YEAR: wages}, + "long_term_capital_gains": {YEAR: gains}, + } + if capital_gains: + situation["people"][name]["capital_gains"] = {YEAR: capital_gains[0]} + for key in ("tax_units", "marital_units", "families", "spm_units"): + situation[key][f"{key}_{index}"] = {"members": members} + situation["households"][f"household_{index}"] = { + "members": members, + "state_name": {YEAR: state}, + } + return Simulation( + situation=situation, reform=reform, spm={"geography_kind": "national"} + ) + + +def texas_single_filers(gains): + return [("TX", [(0, amount)]) for amount in gains] + + +def measured_rate(simulation): + return simulation.calculate("marginal_tax_rate_on_capital_gains", YEAR) + + +def top_rate_reform(top_rate): + return {TOP_RATE: {REFORM_PERIOD: top_rate}} + + +def test_statutory_rate_is_derived_from_the_2026_schedule(): + # The expectations below come from parameters; these pin what they are. + assert CAPITAL_GAINS_RATES == [0, 0.15, 0.2] + assert (FIFTEEN_PERCENT_START, TWENTY_PERCENT_START) == (65_550, 561_600) + assert (NIIT.rate, NIIT.threshold["SINGLE"]) == (0.038, 200_000) + assert (AMT_BINDS_FROM, AMT_EXEMPTION_GONE) == (648_000, 680_200) + assert statutory_rate(100_000) == pytest.approx(0.15) + assert statutory_rate(300_000) == pytest.approx(0.188) + assert statutory_rate(660_000) == pytest.approx(0.338) + assert statutory_rate(20_000_000) == pytest.approx(0.238) + assert TOP_ORDINARY_RATE == 0.37 + assert top_bracket_rate(20_000_000, 0.3) == 0.3 + assert top_bracket_rate(1_000_000, 0.4) == 0.4 + assert top_bracket_rate(20_000_000, 0.4) == 0.37 + + +@settings(max_examples=8, **SIMULATION_SETTINGS) +@given( + gains=st.lists( + st.one_of( + st.integers(FIFTEEN_PERCENT_START, 800_000), + st.integers(800_000, 3_000_000_000), + ).filter(clear_of_rate_edges), + min_size=1, + max_size=12, + ) +) +def test_rate_on_long_term_gains_is_the_statutory_rate(gains): + measured = measured_rate(simulate(texas_single_filers(gains))) + + expected = [statutory_rate(amount) for amount in gains] + np.testing.assert_allclose(measured, expected, rtol=0, atol=RATE_RESOLUTION) + + +# Two ranges of the top rate are left out, where top_bracket_rate, which +# models only the 1(h)(1) limit, does not give every filer here their rate: +# - 28% to 29%. Once the tax at the capital gains rates passes the alternative +# minimum tax's flat 26% and 28% computation, that computation is the +# tentative minimum tax, and for a sliver of rates the filer still owes +# alternative minimum tax and faces 28%: 28.38% to 28.40% with $20 million +# of gains, 28.074% to 28.078% with $100 million. +# - 37% to 38%. The tax on all taxable income at the ordinary rates becomes +# the smaller regular tax at 37.08% with $100 million of gains and 37.43% +# with $20 million, and within the rise of that point the rate is a blend. +TOP_RATES = st.one_of( + st.floats(CAPITAL_GAINS_RATES[2], 0.28), + st.floats(0.29, TOP_ORDINARY_RATE), + st.floats(0.38, 0.4), +) + + +@settings(max_examples=3, **SIMULATION_SETTINGS) +@given(top_rate=TOP_RATES) +def test_rate_follows_the_top_capital_gains_rate(top_rate): + simulation = simulate( + texas_single_filers(TOP_BRACKET_GAINS), reform=top_rate_reform(top_rate) + ) + + np.testing.assert_allclose( + measured_rate(simulation), + [top_bracket_rate(gains, top_rate) + NIIT.rate for gains in TOP_BRACKET_GAINS], + rtol=0, + atol=RATE_RESOLUTION, + ) + + +@pytest.mark.parametrize("top_rate", [0.28, 0.4]) +def test_measured_rate_change_is_the_statutory_change(top_rate): + simulation = simulate( + texas_single_filers(TOP_BRACKET_GAINS), reform=top_rate_reform(top_rate) + ) + + simulation.calculate("relative_capital_gains_mtr_change", YEAR) + measurements = simulation._behavioral_response_measurements[YEAR] + baseline_rate = measurements["baseline_capital_gains_mtr"] + reform_rate = measurements["reform_capital_gains_mtr"] + + np.testing.assert_allclose( + baseline_rate, + CAPITAL_GAINS_RATES[2] + NIIT.rate, + rtol=0, + atol=RATE_RESOLUTION, + ) + np.testing.assert_allclose( + reform_rate - baseline_rate, + [ + top_bracket_rate(gains, top_rate) - CAPITAL_GAINS_RATES[2] + for gains in TOP_BRACKET_GAINS + ], + rtol=0, + atol=2 * RATE_RESOLUTION, + ) + + +def fresh_simulation_rate(households): + """The rate as a difference between new simulations of ``households``. + + The new simulations have the raised long-term gains as an input. Where + any adult gives a ``capital_gains`` total, the simulation stores that + variable for everyone (zero where not given), so the new simulations give + every adult their stored total, raised by the same amount. + """ + base = simulate(households) + net_income = base.person.household("household_net_income", YEAR) + gains = base.calculate("long_term_capital_gains", YEAR) + capital_gains = base.calculate("capital_gains", YEAR) + adult_index = base.calculate("adult_index_cg", YEAR) + rise = capital_gains_mtr_rise(net_income, gains) + gives_total = any(len(adult) > 2 for _, adults in households for adult in adults) + rates = np.zeros_like(net_income) + for index in (1, 2): + raised = adult_index == index + raised_gains = (gains + raised * rise).astype(np.float32) + raised_capital_gains = (capital_gains + raised * rise).astype(np.float32) + people = iter(zip(raised_gains.tolist(), raised_capital_gains.tolist())) + higher_households = [] + for state, adults in households: + higher_adults = [] + for wages, *_ in adults: + person_gains, person_capital_gains = next(people) + total = [person_capital_gains] if gives_total else [] + higher_adults.append((wages, person_gains, *total)) + higher_households.append((state, higher_adults)) + higher = simulate(higher_households) + increase = higher.person.household("household_net_income", YEAR) - net_income + stored_rise = np.where(raised, raised_gains - gains, 1) + rates += np.where(raised, 1 - increase / stored_rise, 0) + return base, rates + + +WAGES = st.sampled_from([0, 20_000, 60_000, 250_000, 2_000_000]) +GAINS = st.one_of(st.integers(-50_000, 700_000), st.integers(700_000, 200_000_000)) +ADULT = st.one_of( + st.tuples(WAGES, GAINS), + # A capital_gains total given as an input, whether or not it is the sum + # of the person's short- and long-term gains. + st.tuples(WAGES, GAINS, st.integers(-50_000, 5_000_000)), +) + + +# capital_gains totals given as inputs: with long-term gains, without them, +# and for one spouse only, which stores a total of zero for the other. +@example(households=[("TX", [(0, 300_000, 300_000)]), ("TX", [(0, 0, 300_000)])]) +@example(households=[("TX", [(0, 0), (0, 0, 0)])]) +@settings(max_examples=5, **SIMULATION_SETTINGS) +@given( + households=st.lists( + st.tuples( + st.sampled_from(["TX", "CA", "NY", "MA", "PA"]), + st.lists(ADULT, min_size=1, max_size=2), + ), + min_size=1, + max_size=6, + ) +) +def test_branch_rate_matches_new_simulations(households): + base, expected = fresh_simulation_rate(households) + + np.testing.assert_allclose(measured_rate(base), expected, rtol=0, atol=1e-6) + + +def test_rate_keeps_a_capital_gains_total_set_after_construction(): + # A total set through set_input after the simulation is built is not + # among the construction inputs a branch keeps by name, so the branch + # used to drop it and rebuild the total from long-term gains alone. + gains, totals = [300_000, 0], [400_000, 300_000] + late = simulate(texas_single_filers(gains)) + late.set_input("capital_gains", YEAR, np.array(totals, dtype=np.float32)) + given = simulate( + [("TX", [(0, amount, total)]) for amount, total in zip(gains, totals)] + ) + + measured = measured_rate(late) + + np.testing.assert_allclose(measured, measured_rate(given), rtol=0, atol=1e-6) + # Each total puts the rise in the 15% bracket, over the net investment + # income tax threshold. + np.testing.assert_allclose( + measured, statutory_rate(300_000), rtol=0, atol=RATE_RESOLUTION + ) + + +def test_responses_do_not_depend_on_which_is_requested_first(): + # Requested first, the capital gains response measures in branches made + # outside the labor supply response's re-entry guard, so a program there + # that reads hours (Texas child care does) used to calculate the + # labor supply response, and its measurements, again: a CycleError. + # Taxable income of $93,900, so the last gains are taxed at 15%. + situation = [("TX", [(60_000, 50_000)])] + reform = { + # Raises the rate on these gains, and so the response to it, and + # lowers net income, and so labor supply through the income effect. + "gov.irs.capital_gains.rates.2": {REFORM_PERIOD: 0.2}, + "gov.simulation.capital_gains_responses.elasticity": {REFORM_PERIOD: -0.7}, + "gov.simulation.labor_supply_responses.elasticities.income": { + REFORM_PERIOD: -0.05 + }, + "gov.simulation.labor_supply_responses.elasticities.substitution.all": { + REFORM_PERIOD: 0.25 + }, + } + responses = [ + "capital_gains_behavioral_response", + "labor_supply_behavioral_response", + ] + values = {} + for order in (responses, responses[::-1]): + simulation = simulate(situation, reform=reform) + values[order[0]] = { + response: simulation.calculate(response, YEAR) for response in order + } + + capital_gains_first, labor_supply_first = values.values() + for response in responses: + assert capital_gains_first[response][0] != 0, response + np.testing.assert_allclose( + capital_gains_first[response], + labor_supply_first[response], + rtol=1e-6, + err_msg=response, + ) + + +FLOAT32_AMOUNT = st.floats(-(2.0**36), 2.0**36, width=32) + + +@settings(max_examples=300, deadline=None, derandomize=True, database=None) +@given(net_income=FLOAT32_AMOUNT, gains=FLOAT32_AMOUNT) +def test_rise_survives_float32_rounding(net_income, gains): + net_income, gains = np.float32(net_income), np.float32(gains) + + rise = capital_gains_mtr_rise(net_income, gains) + + assert rise >= CAPITAL_GAINS_MTR_MINIMUM_RISE + if max(abs(net_income), abs(gains)) <= 1_000_000: + assert rise == CAPITAL_GAINS_MTR_MINIMUM_RISE + stored_rise = np.float64(np.float32(gains + rise)) - np.float64(gains) + assert stored_rise > 0 + # Rounding the raised gains moves them by at most half of 2 ** -23 of + # their size, and the rise is at least 0.1% of the gains. + assert abs(stored_rise - rise) <= 1.2e-4 * rise diff --git a/policyengine_us/tests/core/test_override_branches.py b/policyengine_us/tests/core/test_override_branches.py index 9e46a63644e..a5379432b5a 100644 --- a/policyengine_us/tests/core/test_override_branches.py +++ b/policyengine_us/tests/core/test_override_branches.py @@ -35,7 +35,9 @@ 5. When the branch drops what it copied, it keeps exactly the values set as inputs, each for the period it was set for: a variable that is an input in one year is calculated again in the others (Hypothesis, over random mixes - of input years and formula years). + of input years and formula years). Limited to some variables, it leaves + every other array as it was, and ``has_input`` reports the periods + ``set_input`` stored, from the situation or later. """ import numpy as np @@ -49,6 +51,7 @@ drop_inherited_values, get_branch_for_period, get_override_branch, + has_input, ) YEAR = 2026 @@ -541,6 +544,44 @@ def test_drop_inherited_values_keeps_exactly_the_input_keys( np.testing.assert_array_equal(head, heads) +def test_drop_inherited_values_limited_to_variables(households): + # Adjusted gross income is an input for 2025 only, and a later set_input + # gives taxable income for 2026; both are calculated in the other year. + situation = _with_tax_unit_inputs( + _situation(households[:4], years=YEARS), + {"adjusted_gross_income": (2025, 1_000)}, + ) + n = len(situation["tax_units"]) + simulation = Simulation(situation=situation) + simulation.set_input("taxable_income", YEAR, np.full(n, 2_000.0)) + for year in YEARS: + simulation.calculate("income_tax", year) + assert has_input(simulation, "adjusted_gross_income", 2025) + assert not has_input(simulation, "adjusted_gross_income", YEAR) + assert has_input(simulation, "taxable_income", YEAR) + assert not has_input(simulation, "taxable_income", 2025) + branch = simulation.get_branch("limited") + assert has_input(branch, "taxable_income", YEAR) + + drop_inherited_values(branch, ["adjusted_gross_income"]) + + np.testing.assert_array_equal( + branch.get_array("adjusted_gross_income", 2025), np.full(n, 1_000.0) + ) + assert branch.get_array("adjusted_gross_income", YEAR) is None + # Variables outside the list keep what the branch copied, calculated + # values included. + for year in YEARS: + np.testing.assert_array_equal( + branch.get_array("income_tax", year), + simulation.calculate("income_tax", year), + ) + np.testing.assert_array_equal( + branch.get_array("taxable_income", YEAR), np.full(n, 2_000.0) + ) + assert branch.get_array("taxable_income", 2025) is not None + + def test_later_year_branches_match_single_year_simulation(households): years = (2025, YEAR) simulation = Simulation(situation=_situation(households, years=years)) diff --git a/policyengine_us/tests/policy/baseline/household/marginal_tax_rate_on_capital_gains.yaml b/policyengine_us/tests/policy/baseline/household/marginal_tax_rate_on_capital_gains.yaml new file mode 100644 index 00000000000..3002bb12113 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/marginal_tax_rate_on_capital_gains.yaml @@ -0,0 +1,174 @@ +# marginal_tax_rate_on_capital_gains raises a person's long-term gains and +# measures how much of the rise household net income keeps. For 2026 single +# filers in Texas, which has no income tax, the rate is the 26 U.S.C. 1(h)(1) +# rate on the gains, plus the 3.8% net investment income tax once modified +# adjusted gross income passes $200,000 (26 U.S.C. 1411), plus half the gains +# rate while each dollar of gains removes 50 cents of alternative minimum tax +# exemption (26 U.S.C. 55(d)(2), with the 2026 rate in 55(d)(4)). With the +# $16,100 standard deduction, the 15% rate starts at $65,550 of gains and the +# 20% rate at $561,600. The alternative minimum tax applies from $648,000, +# because it does not allow the standard deduction; above $680,200 its +# $90,100 exemption is gone and it stays at a constant $3,220 (20% of the +# standard deduction), adding nothing at the margin. +# tests/core/test_capital_gains_mtr_measurement.py derives these edges from +# parameters and tests amounts Hypothesis draws between them. + +- name: Gains in the 15% bracket are taxed at 15%. + period: 2026 + absolute_error_margin: 0.00025 + input: + age: 50 + long_term_capital_gains: 100_000 + state_code: TX + output: + marginal_tax_rate_on_capital_gains: 0.15 + +- name: Above $200,000 the net investment income tax adds 3.8%. + period: 2026 + absolute_error_margin: 0.00025 + input: + age: 50 + long_term_capital_gains: 300_000 + state_code: TX + output: + marginal_tax_rate_on_capital_gains: 0.188 + +- name: Gains in the 20% bracket are taxed at 20% plus 3.8%. + period: 2026 + absolute_error_margin: 0.00025 + input: + age: 50 + long_term_capital_gains: 600_000 + state_code: TX + output: + marginal_tax_rate_on_capital_gains: 0.238 + +- name: While the alternative minimum tax exemption phases out, half of each dollar more is taxed at 20% too. + period: 2026 + absolute_error_margin: 0.00025 + input: + age: 50 + long_term_capital_gains: 660_000 + state_code: TX + output: + # 0.20 + 0.038 + 0.5 * 0.20 + marginal_tax_rate_on_capital_gains: 0.338 + +- name: $20 million of gains. + period: 2026 + absolute_error_margin: 0.00025 + input: + age: 50 + long_term_capital_gains: 20_000_000 + state_code: TX + output: + marginal_tax_rate_on_capital_gains: 0.238 + +- name: $100 million of gains, where float32 net income resolves only $8. + period: 2026 + absolute_error_margin: 0.00025 + input: + age: 50 + long_term_capital_gains: 100_000_000 + state_code: TX + output: + # A $1,000 rise measured 0.232; the rise is now 0.1% of the gains. + marginal_tax_rate_on_capital_gains: 0.238 + +- name: Wages put the gains in the 15% bracket and over the investment income tax threshold. + period: 2026 + absolute_error_margin: 0.00025 + input: + age: 50 + employment_income: 60_000 + long_term_capital_gains: 200_000 + state_code: TX + output: + # Raising the capital_gains total instead taxed the rise as ordinary + # income: 0.308. + marginal_tax_rate_on_capital_gains: 0.188 + +- name: Each spouse filing jointly faces the rate on their joint gains. + period: 2026 + absolute_error_margin: 0.00025 + input: + people: + head: + age: 50 + long_term_capital_gains: 500_000 + spouse: + age: 50 + long_term_capital_gains: 300_000 + tax_units: + tax_unit: + members: [head, spouse] + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # $767,800 of taxable income is past the $613,700 joint 20% threshold. + marginal_tax_rate_on_capital_gains: [0.238, 0.238] + +- name: A capital_gains total given as an input rises with the long-term gains. + period: 2026 + absolute_error_margin: 0.00025 + input: + age: 50 + long_term_capital_gains: 300_000 + capital_gains: 300_000 + state_code: TX + output: + marginal_tax_rate_on_capital_gains: 0.188 + +- name: A capital_gains total given without long-term gains is taxed as ordinary income, and the rise as long-term gain. + period: 2026 + absolute_error_margin: 0.00025 + input: + age: 50 + capital_gains: 300_000 + state_code: TX + output: + # The $300,000 fills the ordinary brackets, so the rise is taxed at 15% + # plus 3.8%. + marginal_tax_rate_on_capital_gains: 0.188 + +- name: An input given for another year does not hold this year's value. + period: 2026 + absolute_error_margin: 0.00025 + input: + people: + person: + age: 50 + long_term_capital_gains: 300_000 + tax_units: + tax_unit: + members: [person] + adjusted_gross_income: + 2025: 400_000 + households: + household: + members: [person] + state_code: TX + output: + # 2026 adjusted gross income is calculated, so the rise reaches it. + marginal_tax_rate_on_capital_gains: 0.188 + +- name: Within the rise of New York's $25 million threshold, the rate spreads the threshold's tax over the rise. + period: 2026 + absolute_error_margin: 0.001 + input: + age: 50 + long_term_capital_gains: 24_985_000 + state_code: NY + output: + # Above $25 million of adjusted gross income New York taxes all taxable + # income at its top rate (N.Y. Tax Law 601; ny_supplemental_tax). The + # $24,985 rise crosses that, adding about $152,600 of state tax. A $1,000 + # rise measures 0.342 here, and one that crossed the threshold would + # measure about 150; the wider rise puts more filers in the window, each + # with a smaller spike. + marginal_tax_rate_on_capital_gains: 6.345 diff --git a/policyengine_us/tools/period_branch.py b/policyengine_us/tools/period_branch.py index 5e47797cf20..819db8f3c48 100644 --- a/policyengine_us/tools/period_branch.py +++ b/policyengine_us/tools/period_branch.py @@ -31,7 +31,7 @@ and deletes the variables it recalculates. """ -from typing import Dict, Set, Tuple, Union +from typing import Dict, Iterable, Optional, Set, Tuple, Union import numpy as np from policyengine_core.periods import ETERNITY, Period @@ -73,19 +73,41 @@ def _input_keys(branch: Simulation) -> Set[Tuple[str, str, Period]]: } -def drop_inherited_values(branch: Simulation) -> None: +def has_input(simulation: Simulation, variable: str, period: Period) -> bool: + """Whether ``set_input`` stored ``variable`` for ``period``. + + That is, on ``simulation`` or a branch it reads, whether from the dataset, + the situation or a later call. An eternal variable matches any period. + """ + period = to_period(period) + eternal = ( + simulation.tax_benefit_system.variables[variable].definition_period == ETERNITY + ) + return any( + name == variable and (eternal or input_period == period) + for name, _, input_period in _input_keys(simulation) + ) + + +def drop_inherited_values( + branch: Simulation, variables: Optional[Iterable[str]] = None +) -> None: """Delete every array ``branch`` holds except inputs. An array is kept only if ``set_input`` stored it, on this branch or one it reads, for that variable and period. A value calculated for one period is dropped even when the same variable is an input for another period. + ``variables``, if given, limits this to those variables. """ input_keys = _input_keys(branch) + only = None if variables is None else set(variables) # An eternal variable stores every period under one key, while the input # key records the period it was set for, so match it by branch only. eternal_inputs = {(name, branch_name) for name, branch_name, _ in input_keys} for population in branch.populations.values(): for name, holder in population._holders.items(): + if only is not None and name not in only: + continue eternal = holder.variable.definition_period == ETERNITY for branch_name, known_period in holder.get_known_branch_periods(): if ( diff --git a/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py b/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py index 34503291a64..5d2fdad20a5 100644 --- a/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py +++ b/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py @@ -10,6 +10,11 @@ "self_employment_income_behavioral_response", "sstb_self_employment_income_behavioral_response", "capital_gains_behavioral_response", + # Hours read the labor supply response. A branch measured while that + # response is being calculated returns zero for it (its re-entry guard), + # but one measured first for the capital gains response would calculate + # it, and its measurements, again, wherever a program reads hours. + "weekly_hours_worked_behavioural_response", ) BEHAVIORAL_RESPONSE_INPUT_VARIABLES = ( "employment_income_before_lsr", diff --git a/policyengine_us/variables/gov/simulation/capital_gains_responses.py b/policyengine_us/variables/gov/simulation/capital_gains_responses.py index bc9bfc51d2c..79df9bab4ba 100644 --- a/policyengine_us/variables/gov/simulation/capital_gains_responses.py +++ b/policyengine_us/variables/gov/simulation/capital_gains_responses.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.period_branch import drop_inherited_values, has_input from policyengine_us.variables.household.marginal_tax_rate_helpers import ( user_set_variables, ) @@ -85,12 +86,44 @@ def formula(person, period, parameters): ) +# marginal_tax_rate_on_capital_gains raises long-term gains by at least $1,000. +# The rate is a difference in household_net_income, a float32 that resolves +# only about 1.2e-7 of its own size (8 dollars at $100 million), so a fixed +# $1,000 rise would resolve a $100 million household's rate only to 0.008. +# The rise is therefore 0.1% of the larger of the household's net income and +# the person's long-term gains, in absolute value, where that exceeds $1,000, +# which keeps the resolution near 1e-4. Below $1 million both give $1,000. +# Like any finite difference, the rate averages over the rise, so a person +# within the rise of a cliff gets the whole cliff spread over it. Above $1 +# million a wider rise puts more people in that window, each with a smaller +# spike: a New York filer with $24,985,000 of gains crosses the state's $25 +# million recapture threshold and measures 6.35, where a $1,000 rise measures +# 0.342 and one within $1,000 of the threshold would measure about 150. +CAPITAL_GAINS_MTR_MINIMUM_RISE = 1_000 +CAPITAL_GAINS_MTR_RELATIVE_RISE = 1e-3 + + +def capital_gains_mtr_rise(household_net_income, long_term_capital_gains): + """Rise in long-term gains that measures each person's rate.""" + scale = np.maximum( + np.abs(np.asarray(household_net_income, dtype=np.float64)), + np.abs(np.asarray(long_term_capital_gains, dtype=np.float64)), + ) + return np.maximum( + CAPITAL_GAINS_MTR_MINIMUM_RISE, CAPITAL_GAINS_MTR_RELATIVE_RISE * scale + ) + + class marginal_tax_rate_on_capital_gains(Variable): label = "capital gains marginal tax rate" documentation = ( - "Percent of marginal capital gains that do not increase household net income." + "Share of a rise in long-term capital gains that does not increase" + " household net income. The rise is $1,000, or 0.1% of the larger of the" + " absolute values of the household's net income and the person's" + " long-term gains where that is more." " Simulated only for the two adults in each household with the largest" - " long-term capital gains (adult_index_cg 1 and 2), regardless of" + " long-term capital gains before behavioral responses (adult_index_cg 1" + " and 2), regardless of" " simulation.marginal_tax_rate_adults; zero for everyone else." ) entity = Person @@ -99,31 +132,61 @@ class marginal_tax_rate_on_capital_gains(Variable): unit = "/1" def formula(person, period, parameters): # pragma: no cover - # Requires simulation branching - tested via microsim + # Requires simulation branching - tested via household simulations + # in tests/core/test_capital_gains_mtr_measurement.py mtr_values = np.zeros(person.count, dtype=np.float32) simulation = person.simulation - DELTA = 1_000 adult_index_values = person("adult_index_cg", period) + long_term_capital_gains = person("long_term_capital_gains", period) + capital_gains = person("capital_gains", period) + # capital_gains adds short- and long-term gains, and adjusted gross + # income reads it. A total given as an input, at construction or + # later, is stored as given rather than added up again. + capital_gains_is_input = has_input(simulation, "capital_gains", period) + household_net_income = person.household("household_net_income", period) + rise = capital_gains_mtr_rise(household_net_income, long_term_capital_gains) + variables = simulation.tax_benefit_system.variables inputs = user_set_variables(simulation) + inputs_with_formulas = [ + variable + for variable in inputs + if variable in variables and not variables[variable].is_input_variable() + ] for adult_index in [1, 2]: alt_simulation = simulation.get_branch(f"adult_{adult_index}_cg_rise") mask = adult_index_values == adult_index - for variable in simulation.tax_benefit_system.variables: - variable_data = simulation.tax_benefit_system.variables[variable] + for variable, variable_data in variables.items(): if variable not in inputs and not variable_data.is_input_variable(): alt_simulation.delete_arrays(variable) + # A variable given as an input for one period keeps only the + # periods it was given for; what was calculated for the others, + # this one included, is calculated again. + drop_inherited_values(alt_simulation, inputs_with_formulas) + # Raise long-term gains, which net capital gain and so the + # preferential rates read. Raising capital_gains instead reached + # adjusted gross income but not net capital gain, so it measured + # the rate on ordinary income except where taxable income was + # below net capital gain. The override holds any behavioral + # response at its value here. alt_simulation.set_input( - "capital_gains", + "long_term_capital_gains", period, - person("capital_gains", period) + mask * DELTA, + long_term_capital_gains + mask * rise, ) + if capital_gains_is_input: + alt_simulation.set_input( + "capital_gains", period, capital_gains + mask * rise + ) alt_person = alt_simulation.person - household_net_income = person.household("household_net_income", period) - household_net_income_higher_earnings = alt_person.household( + # Divide by the rise as stored, after rounding to float32. + stored_rise = ( + alt_person("long_term_capital_gains", period) - long_term_capital_gains + ) + household_net_income_higher_gains = alt_person.household( "household_net_income", period ) - increase = household_net_income_higher_earnings - household_net_income - mtr_values += where(mask, 1 - increase / DELTA, 0) + increase = household_net_income_higher_gains - household_net_income + mtr_values += where(mask, 1 - increase / where(mask, stored_rise, 1), 0) del simulation.branches[f"adult_{adult_index}_cg_rise"] return mtr_values