From 192825d934c4416d291d19adda2a0a3bb98441a2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 09:26:47 -0400 Subject: [PATCH 1/9] Measure the capital gains marginal tax rate on long-term gains marginal_tax_rate_on_capital_gains raised capital_gains, the sum of short- and long-term gains. That reaches adjusted gross income but not net capital gain, so the rise was taxed as ordinary income: a Texas filer with $1 million of long-term gains measured 0.298 instead of 0.238, and raising the top capital gains rate from 20% to 28% left a top-bracket filer's rate, and so the realization response, unchanged. Raise long_term_capital_gains instead (the change PR #9698 makes inside its wider rework of marginal-rate branches), and scale the rise to 0.1% of the household's net income or the person's gains above $1 million, because a $1,000 rise resolves a $100 million household's float32 net income only to 0.008 of rate. Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-cg-mtr-measurement.fixed.md | 1 + .../test_capital_gains_mtr_measurement.py | 392 ++++++++++++++++++ .../gov/simulation/capital_gains_responses.py | 53 ++- 3 files changed, 437 insertions(+), 9 deletions(-) create mode 100644 changelog.d/fix-cg-mtr-measurement.fixed.md create mode 100644 policyengine_us/tests/core/test_capital_gains_mtr_measurement.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..095f90fc398 --- /dev/null +++ b/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py @@ -0,0 +1,392 @@ +"""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 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, each checked through household simulations 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)). 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. +4. 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. + +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. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, 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 a pair of wages and long-term gains. 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) 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}, + } + 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) + + +def test_rate_on_long_term_gains_in_each_bracket(): + # 15%; 15% with the net investment income tax; 20% with it; 20% with it + # and the alternative minimum tax exemption phasing out; then 20% with it + # at sizes where float32 resolved a $1,000 rise only to 0.002 and 0.008. + gains = [100_000, 300_000, 600_000, 660_000, 20_000_000, 100_000_000] + + measured = measured_rate(simulate(texas_single_filers(gains))) + + np.testing.assert_allclose( + measured, + [0.15, 0.188, 0.238, 0.338, 0.238, 0.238], + rtol=0, + atol=RATE_RESOLUTION, + ) + + +def test_rate_on_long_term_gains_stacks_on_wages_and_covers_both_spouses(): + simulation = simulate( + [ + # $60,000 of wages put $200,000 of gains in the 15% bracket and + # over the net investment income tax threshold. + ("TX", [(60_000, 200_000)]), + # Joint filers whose combined gains reach the 20% bracket. + ("TX", [(0, 500_000), (0, 300_000)]), + ] + ) + + np.testing.assert_allclose( + measured_rate(simulation), + [0.188, 0.238, 0.238], + rtol=0, + atol=RATE_RESOLUTION, + ) + + +# Two ranges of the top rate are left out, where a filer's rate is not the +# smaller of the top rate and the top ordinary rate for every filer here: +# - 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.09% 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=4, **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``.""" + base = simulate(households) + net_income = base.person.household("household_net_income", YEAR) + gains = base.calculate("long_term_capital_gains", YEAR) + adult_index = base.calculate("adult_index_cg", YEAR) + rise = capital_gains_mtr_rise(net_income, gains) + rates = np.zeros_like(net_income) + for index in (1, 2): + raised = adult_index == index + raised_gains = (gains + raised * rise).astype(np.float32) + people = iter(raised_gains) + higher = simulate( + [ + (state, [(wages, float(next(people))) for wages, _ in adults]) + for state, adults in 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 + + +ADULT = st.tuples( + st.sampled_from([0, 20_000, 60_000, 250_000, 2_000_000]), + st.one_of( + st.integers(-50_000, 700_000), + st.integers(700_000, 200_000_000), + ), +) + + +@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) + + +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/variables/gov/simulation/capital_gains_responses.py b/policyengine_us/variables/gov/simulation/capital_gains_responses.py index bc9bfc51d2c..f5c42961945 100644 --- a/policyengine_us/variables/gov/simulation/capital_gains_responses.py +++ b/policyengine_us/variables/gov/simulation/capital_gains_responses.py @@ -85,10 +85,34 @@ 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. +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 household's" + " net income or of 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" " simulation.marginal_tax_rate_adults; zero for everyone else." @@ -99,11 +123,14 @@ 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) + household_net_income = person.household("household_net_income", period) + rise = capital_gains_mtr_rise(household_net_income, long_term_capital_gains) inputs = user_set_variables(simulation) for adult_index in [1, 2]: alt_simulation = simulation.get_branch(f"adult_{adult_index}_cg_rise") @@ -112,18 +139,26 @@ def formula(person, period, parameters): # pragma: no cover variable_data = simulation.tax_benefit_system.variables[variable] if variable not in inputs and not variable_data.is_input_variable(): alt_simulation.delete_arrays(variable) + # Raise long-term gains, which net capital gain and so the + # preferential rates read. Raising capital_gains, the sum of + # short- and long-term gains, reached adjusted gross income but + # not net capital gain, so the rise was taxed as ordinary income. + # 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, ) 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 From a926b5209ac036a7cb0ade3ec102f471a22184db Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 09:38:52 -0400 Subject: [PATCH 2/9] Correct the top rate at which the ordinary-rate tax takes over for $100 million of gains Co-Authored-By: Claude Opus 5.5 --- .../tests/core/test_capital_gains_mtr_measurement.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py b/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py index 095f90fc398..894645eca02 100644 --- a/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py +++ b/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py @@ -270,7 +270,7 @@ def test_rate_on_long_term_gains_stacks_on_wages_and_covers_both_spouses(): # 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.09% with $100 million of gains and 37.43% +# 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), From 7382d27da42e6d7c27ce4f18a5efb7484a38d89a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 08:50:57 -0400 Subject: [PATCH 3/9] Raise a capital_gains total given as an input along with long-term gains capital_gains adds short- and long-term gains, and adjusted gross income reads it. A situation that gives the total keeps it as given, so raising only long_term_capital_gains never reached such a household's income and its rate measured 0.84 to 1.0 instead of 0.188. Raise the given total by the same amount, and cover it in the branch-versus-new-simulation property. Co-Authored-By: Claude Opus 5.5 --- .../test_capital_gains_mtr_measurement.py | 66 +++++++++++++++---- .../gov/simulation/capital_gains_responses.py | 8 +++ 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py b/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py index 894645eca02..9d271a41032 100644 --- a/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py +++ b/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py @@ -25,7 +25,8 @@ $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. + statuses, wages and gains, including households that give a + ``capital_gains`` total as an input. 4. 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. @@ -146,8 +147,9 @@ def clear_of_rate_edges(gains): def simulate(households, reform=None): """A simulation of ``households``, each a state and its adults. - An adult is a pair of wages and long-term gains. Two adults are married - and file jointly. + 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: {} @@ -162,7 +164,7 @@ def simulate(households, reform=None): } for index, (state, adults) in enumerate(households): members = [] - for adult, (wages, gains) in enumerate(adults): + for adult, (wages, gains, *capital_gains) in enumerate(adults): name = f"adult_{index}_{adult}" members.append(name) situation["people"][name] = { @@ -170,6 +172,8 @@ def simulate(households, reform=None): "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}"] = { @@ -323,20 +327,36 @@ def test_measured_rate_change_is_the_statutory_change(top_rate): def fresh_simulation_rate(households): - """The rate as a difference between new simulations of ``households``.""" + """The rate as a difference between new simulations of ``households``. + + The new simulations have the raised long-term gains as an input, and + where a household gives a ``capital_gains`` total, that 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) rates = np.zeros_like(net_income) for index in (1, 2): raised = adult_index == index raised_gains = (gains + raised * rise).astype(np.float32) - people = iter(raised_gains) + raised_capital_gains = (capital_gains + raised * rise).astype(np.float32) + people = zip(raised_gains.tolist(), raised_capital_gains.tolist()) higher = simulate( [ - (state, [(wages, float(next(people))) for wages, _ in adults]) + ( + state, + [ + (wages, person_gains, *[person_capital_gains] * len(given)) + for (wages, _, *given), ( + person_gains, + person_capital_gains, + ) in zip(adults, people) + ], + ) for state, adults in households ] ) @@ -346,12 +366,13 @@ def fresh_simulation_rate(households): return base, rates -ADULT = st.tuples( - st.sampled_from([0, 20_000, 60_000, 250_000, 2_000_000]), - st.one_of( - st.integers(-50_000, 700_000), - st.integers(700_000, 200_000_000), - ), +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)), ) @@ -372,6 +393,25 @@ def test_branch_rate_matches_new_simulations(households): np.testing.assert_allclose(measured_rate(base), expected, rtol=0, atol=1e-6) +def test_rate_counts_a_capital_gains_total_given_as_an_input(): + # capital_gains adds short- and long-term gains, and adjusted gross income + # reads it. A situation that gives the total keeps it as given, so the + # rise has to be added to it as well as to the long-term gains. + households = [ + # $300,000 of long-term gains, with the total also given. + ("TX", [(0, 300_000, 300_000)]), + # The total given alone, so the model has no long-term gains to tax + # at the preferential rates until the rise adds some. + ("TX", [(0, 0, 300_000)]), + ] + base, expected = fresh_simulation_rate(households) + + measured = measured_rate(base) + + np.testing.assert_allclose(measured, expected, rtol=0, atol=1e-6) + assert measured[0] == pytest.approx(statutory_rate(300_000), abs=RATE_RESOLUTION) + + FLOAT32_AMOUNT = st.floats(-(2.0**36), 2.0**36, width=32) diff --git a/policyengine_us/variables/gov/simulation/capital_gains_responses.py b/policyengine_us/variables/gov/simulation/capital_gains_responses.py index f5c42961945..8e565794768 100644 --- a/policyengine_us/variables/gov/simulation/capital_gains_responses.py +++ b/policyengine_us/variables/gov/simulation/capital_gains_responses.py @@ -149,6 +149,14 @@ def formula(person, period, parameters): # pragma: no cover period, long_term_capital_gains + mask * rise, ) + # A capital_gains amount given as an input is kept as given, not + # added up again from the raised long-term gains, so raise it too. + if "capital_gains" in inputs: + alt_simulation.set_input( + "capital_gains", + period, + person("capital_gains", period) + mask * rise, + ) alt_person = alt_simulation.person # Divide by the rise as stored, after rounding to float32. stored_rise = ( From d692879443611f5377636873186c2cfe6b4cd855 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 09:05:31 -0400 Subject: [PATCH 4/9] Move fixed capital gains rate cases to YAML and run the property tests in heavy-a Fixed household examples (each 2026 Texas bracket, the alternative minimum tax band, $20M and $100M of gains, wages under gains, joint filers, and capital_gains totals given as inputs) are YAML cases now, per the repo's test design rules. The Python file keeps what YAML cannot do: the Hypothesis properties, reforms read through the measurement branches, and the differential against new simulations, whose reference now raises a stored capital_gains total for every adult when any adult gives one (it found a household where one spouse gave a zero total). The direct reform property runs three examples instead of four. The file took 246 s of the core Rest group in CI run 38056548883, so it moves to heavy-a, which had the lowest time and peak memory of the groups there. Co-Authored-By: Claude Opus 5.5 --- Makefile | 9 +- .../test_capital_gains_mtr_measurement.py | 104 ++++---------- .../marginal_tax_rate_on_capital_gains.yaml | 134 ++++++++++++++++++ 3 files changed, 169 insertions(+), 78 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/household/marginal_tax_rate_on_capital_gains.yaml 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/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py b/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py index 9d271a41032..210232d0d13 100644 --- a/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py +++ b/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py @@ -32,12 +32,18 @@ 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. +examples keep clear of the bracket edges. Fixed household examples of each +bracket, wages, joint filers and given ``capital_gains`` totals 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, given, settings, strategies as st +from hypothesis import HealthCheck, example, given, settings, strategies as st from policyengine_us import Simulation from policyengine_us.system import system @@ -231,41 +237,6 @@ def test_rate_on_long_term_gains_is_the_statutory_rate(gains): np.testing.assert_allclose(measured, expected, rtol=0, atol=RATE_RESOLUTION) -def test_rate_on_long_term_gains_in_each_bracket(): - # 15%; 15% with the net investment income tax; 20% with it; 20% with it - # and the alternative minimum tax exemption phasing out; then 20% with it - # at sizes where float32 resolved a $1,000 rise only to 0.002 and 0.008. - gains = [100_000, 300_000, 600_000, 660_000, 20_000_000, 100_000_000] - - measured = measured_rate(simulate(texas_single_filers(gains))) - - np.testing.assert_allclose( - measured, - [0.15, 0.188, 0.238, 0.338, 0.238, 0.238], - rtol=0, - atol=RATE_RESOLUTION, - ) - - -def test_rate_on_long_term_gains_stacks_on_wages_and_covers_both_spouses(): - simulation = simulate( - [ - # $60,000 of wages put $200,000 of gains in the 15% bracket and - # over the net investment income tax threshold. - ("TX", [(60_000, 200_000)]), - # Joint filers whose combined gains reach the 20% bracket. - ("TX", [(0, 500_000), (0, 300_000)]), - ] - ) - - np.testing.assert_allclose( - measured_rate(simulation), - [0.188, 0.238, 0.238], - rtol=0, - atol=RATE_RESOLUTION, - ) - - # Two ranges of the top rate are left out, where a filer's rate is not the # smaller of the top rate and the top ordinary rate for every filer here: # - 28% to 29%. Once the tax at the capital gains rates passes the alternative @@ -283,7 +254,7 @@ def test_rate_on_long_term_gains_stacks_on_wages_and_covers_both_spouses(): ) -@settings(max_examples=4, **SIMULATION_SETTINGS) +@settings(max_examples=3, **SIMULATION_SETTINGS) @given(top_rate=TOP_RATES) def test_rate_follows_the_top_capital_gains_rate(top_rate): simulation = simulate( @@ -329,9 +300,10 @@ def test_measured_rate_change_is_the_statutory_change(top_rate): 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, and - where a household gives a ``capital_gains`` total, that total raised by - the same amount. + 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) @@ -339,27 +311,22 @@ def fresh_simulation_rate(households): 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 = zip(raised_gains.tolist(), raised_capital_gains.tolist()) - higher = simulate( - [ - ( - state, - [ - (wages, person_gains, *[person_capital_gains] * len(given)) - for (wages, _, *given), ( - person_gains, - person_capital_gains, - ) in zip(adults, people) - ], - ) - for state, adults in households - ] - ) + 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) @@ -376,6 +343,10 @@ def fresh_simulation_rate(households): ) +# 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( @@ -393,25 +364,6 @@ def test_branch_rate_matches_new_simulations(households): np.testing.assert_allclose(measured_rate(base), expected, rtol=0, atol=1e-6) -def test_rate_counts_a_capital_gains_total_given_as_an_input(): - # capital_gains adds short- and long-term gains, and adjusted gross income - # reads it. A situation that gives the total keeps it as given, so the - # rise has to be added to it as well as to the long-term gains. - households = [ - # $300,000 of long-term gains, with the total also given. - ("TX", [(0, 300_000, 300_000)]), - # The total given alone, so the model has no long-term gains to tax - # at the preferential rates until the rise adds some. - ("TX", [(0, 0, 300_000)]), - ] - base, expected = fresh_simulation_rate(households) - - measured = measured_rate(base) - - np.testing.assert_allclose(measured, expected, rtol=0, atol=1e-6) - assert measured[0] == pytest.approx(statutory_rate(300_000), abs=RATE_RESOLUTION) - - FLOAT32_AMOUNT = st.floats(-(2.0**36), 2.0**36, width=32) 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..d9f573c5d4b --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/marginal_tax_rate_on_capital_gains.yaml @@ -0,0 +1,134 @@ +# 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)(3)). 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 until its $90,100 exemption +# is gone at $680,200. tests/core/test_capital_gains_mtr_measurement.py +# derives these edges from parameters and checks every amount 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 net income. + 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 From aec36d83990cd370ff5894c32421b47c1030e511 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:40:34 -0400 Subject: [PATCH 5/9] Keep late capital_gains totals and period-specific inputs in the rate's branch Review r1 of #10058 found two ways the branch lost an input: - A capital_gains total set through set_input after construction is not among the construction inputs the branch keeps, so the branch dropped it and rebuilt the total from long-term gains: 73.4 and 231.9 instead of 0.188. The branch now raises the parent's total whenever set_input stored one for the period, at construction or later (tools.period_branch.has_input). - A variable given for one period kept every period's array, so a 2026 value calculated before branching survived when the input was for 2025 (adjusted gross income for 2025 measured 0.000). The branch now drops what was calculated for periods the input was not given for (drop_inherited_values limited to those variables). Also neutralize the hours response in the behavioral response measurement branches, as #9698 does: a capital-gains-first request with labor supply elasticities otherwise re-entered the measurements through hours and raised a CycleError. In labor-first order those branches already returned zero for it through the labor supply response's re-entry guard. Tests: a late-set total (Python), an input for another year and New York's $25 million threshold, which the scaled rise spreads over $24,985 (YAML). Co-Authored-By: Claude Opus 5.5 --- .../test_capital_gains_mtr_measurement.py | 55 ++++++++++++++----- .../marginal_tax_rate_on_capital_gains.yaml | 52 ++++++++++++++++-- policyengine_us/tools/period_branch.py | 26 ++++++++- .../behavioral_response_measurements.py | 5 ++ .../gov/simulation/capital_gains_responses.py | 50 ++++++++++++----- 5 files changed, 152 insertions(+), 36 deletions(-) diff --git a/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py b/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py index 210232d0d13..3ba1a75dd85 100644 --- a/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py +++ b/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py @@ -4,18 +4,19 @@ 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 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. +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, each checked through household simulations for 2026: +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)). Texas has no income 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 @@ -26,15 +27,22 @@ 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. -4. 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. + ``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. Fixed household examples of each -bracket, wages, joint filers and given ``capital_gains`` totals are YAML -tests in ``tests/policy/baseline/household/ +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 @@ -237,8 +245,8 @@ def test_rate_on_long_term_gains_is_the_statutory_rate(gains): np.testing.assert_allclose(measured, expected, rtol=0, atol=RATE_RESOLUTION) -# Two ranges of the top rate are left out, where a filer's rate is not the -# smaller of the top rate and the top ordinary rate for every filer here: +# 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 @@ -364,6 +372,27 @@ def test_branch_rate_matches_new_simulations(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 + ) + + FLOAT32_AMOUNT = st.floats(-(2.0**36), 2.0**36, width=32) 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 index d9f573c5d4b..3002bb12113 100644 --- 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 @@ -4,11 +4,14 @@ # 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)(3)). 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 until its $90,100 exemption -# is gone at $680,200. tests/core/test_capital_gains_mtr_measurement.py -# derives these edges from parameters and checks every amount between them. +# 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 @@ -69,7 +72,7 @@ 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 net income. + # 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. @@ -132,3 +135,40 @@ # 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 8e565794768..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, ) @@ -92,6 +93,12 @@ def formula(person, period, parameters): # 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 @@ -111,10 +118,12 @@ class marginal_tax_rate_on_capital_gains(Variable): label = "capital gains marginal tax rate" documentation = ( "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 household's" - " net income or of the person's long-term gains where that is more." + " 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 @@ -129,33 +138,44 @@ def formula(person, period, parameters): # pragma: no cover simulation = person.simulation 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, the sum of - # short- and long-term gains, reached adjusted gross income but - # not net capital gain, so the rise was taxed as ordinary income. - # The override holds any behavioral response at its value here. + # 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( "long_term_capital_gains", period, long_term_capital_gains + mask * rise, ) - # A capital_gains amount given as an input is kept as given, not - # added up again from the raised long-term gains, so raise it too. - if "capital_gains" in inputs: + if capital_gains_is_input: alt_simulation.set_input( - "capital_gains", - period, - person("capital_gains", period) + mask * rise, + "capital_gains", period, capital_gains + mask * rise ) alt_person = alt_simulation.person # Divide by the rise as stored, after rounding to float32. From 2881f93749d74c3e0dacf2608bae96865fae2f5e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:50:43 -0400 Subject: [PATCH 6/9] Test that response order does not matter and that the branch helpers keep inputs by period - test_responses_do_not_depend_on_which_is_requested_first: with labor supply and capital gains elasticities set, requesting either response first gives both the same nonzero values. At the previous head the capital-gains-first request raised CycleError. - test_drop_inherited_values_limited_to_variables: limited to some variables, drop_inherited_values leaves every other array alone, and has_input reports the periods set_input stored, from the situation or later. - Correct comments: SNAP reads hours before the labor supply response, so Texas child care is the program named. Co-Authored-By: Claude Opus 5.5 --- .../test_capital_gains_mtr_measurement.py | 41 ++++++++++++++++++ .../tests/core/test_override_branches.py | 43 ++++++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py b/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py index 3ba1a75dd85..f83d5d516a4 100644 --- a/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py +++ b/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py @@ -393,6 +393,47 @@ def test_rate_keeps_a_capital_gains_total_set_after_construction(): ) +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) 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)) From 41fc0cf851641820f0da15b8cdf6c6e0366e1218 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 18:21:52 -0400 Subject: [PATCH 7/9] Document how the capital gains rate is measured and test the revenue peak #10054 merged first, so this PR carries what was agreed for whichever merged second: - docs-quarto/methodology/capital-gains-responses.qmd: replace "Known limitation: how the rate is measured" with "How the rate is measured" (the rise and its scaling, the cliff trade-off, a Texas rate table with the AMT band, the 1(h)(1) cap); add a filer's own revenue peak to the semi-elasticity section; call the single-rate analytical peak the unique maximum when beta(1 - s) - ln beta > 1, with a 100% federal rate tying at equality (#10054 round-3 review). - test_federal_income_tax_peaks_at_the_revenue_maximizing_top_rate: with semi_elasticity 1/0.30, a Texas filer with $20M of gains pays the most federal income tax at the top rate solving r + 3.8% = (1 - 545,500/A)/beta, 25.23%; realizations and tax match their closed forms. - Fold the 28% measurement-branch case into the response-order test, which now also checks the measured rate changes for four filers, saving one reformed simulation with full measurements in CI. Co-Authored-By: Claude Opus 5.5 --- .../methodology/capital-gains-responses.qmd | 24 ++- .../test_capital_gains_mtr_measurement.py | 166 +++++++++++++++--- 2 files changed, 165 insertions(+), 25 deletions(-) diff --git a/docs-quarto/methodology/capital-gains-responses.qmd b/docs-quarto/methodology/capital-gains-responses.qmd index 843bf710eb5..c96e83ec435 100644 --- a/docs-quarto/methodology/capital-gains-responses.qmd +++ b/docs-quarto/methodology/capital-gains-responses.qmd @@ -16,7 +16,7 @@ long_term_capital_gains = long_term_capital_gains_before_response + ca so gains after the response are the pre-response gains times the multiplier `1 + f`. The response is zero unless the simulation is a reform with a baseline and one of the three parameters under `gov.simulation.capital_gains_responses` is nonzero. All three default to zero. -`τ0` and `τ1` are the person's marginal tax rates on capital gains under baseline and reform policy. Both come from `marginal_tax_rate_on_capital_gains`, measured in two branches that hold the same pre-response inputs and have every behavioral response switched off (`get_behavioral_response_measurements`). The rate is a household finite difference, `1 − Δ household_net_income / $1,000`, for an extra $1,000 of gains. It is measured for the two adults in each household with the largest long-term gains and is zero for everyone else, so other members do not respond. Because `household_net_income` includes benefits and refundable credits, the rate can fall outside [0, 1] where the extra $1,000 crosses a benefit cliff or phase-in. +`τ0` and `τ1` are the person's marginal tax rates on capital gains under baseline and reform policy. Both come from `marginal_tax_rate_on_capital_gains`, measured in two branches that hold the same pre-response inputs and have every behavioral response switched off (`get_behavioral_response_measurements`). The rate is a household finite difference: the share of a rise in the person's long-term gains that does not reach `household_net_income` (see [How the rate is measured](#how-the-rate-is-measured)). It is measured for the two adults in each household with the largest long-term gains before responses and is zero for everyone else, so other members do not respond. Because `household_net_income` includes benefits and refundable credits, the rate can fall outside [0, 1] where the rise crosses a benefit cliff or phase-in. ## The three forms @@ -52,7 +52,9 @@ The log of a 0% rate is undefined, so both rates are floored at 0.001. A person The response depends only on the change in the rate, so it is defined at a 0% baseline without a floor. The same person loses 29% of their gains at β = 3.4. β is roughly the percent fall in realizations per percentage-point rise in the rate. -Single-rate revenue `τ · A · exp(−βτ)` has derivative `A · exp(−βτ) · (1 − βτ)`, so it peaks at `τ = 1/β` when that is below 1. Without the clip described below, a fixed state rate `s` that adds to the federal rate `t` multiplies realizations by the constant `exp(−βs)`, so the analytical peak of single-rate federal revenue `t · R` stays at a federal rate (including the net investment income tax) of `1/β`. With the clip, realizations stop falling once `t + s` reaches 100%, and federal revenue then rises with `t`. The analytical peak is the maximum only when `β(1 − s) − ln β > 1`, which for β from 3.1 to 3.51 means a state rate below about 31% to 36%; otherwise revenue at a 100% federal rate is higher. With no state rate the condition holds for every β above 1. +Single-rate revenue `τ · A · exp(−βτ)` has derivative `A · exp(−βτ) · (1 − βτ)`, so it peaks at `τ = 1/β` when that is below 1. Without the clip described below, a fixed state rate `s` that adds to the federal rate `t` multiplies realizations by the constant `exp(−βs)`, so the analytical peak of single-rate federal revenue `t · R` stays at a federal rate (including the net investment income tax) of `1/β`. With the clip, realizations stop falling once `t + s` reaches 100%, and federal revenue then rises with `t`. The analytical peak is the unique maximum when `β(1 − s) − ln β > 1` (at equality a 100% federal rate ties), which for β from 3.1 to 3.51 means a state rate below about 31% to 36%; where the left side is less than 1, revenue at a 100% federal rate is higher. With no state rate the condition holds for every β above 1. + +A filer's own federal tax peaks at a lower top rate than `1/β`, because the gains below the top bracket are not taxed at it. With β = 1/0.30, a Texas single filer with $20 million of gains realizes `A = $20 million × exp(−β · (r − 20%))` at a top rate `r`, and the part of their federal income tax that moves with `r` is `r · (A − $545,500) + 3.8% · (A − $200,000)`: the top rate applies from $545,500 of gains, not of taxable income, because the alternative minimum tax, which this filer owes, taxes the standard deduction at it too. That peaks where `r + 3.8% = (1 − $545,500 / A) / β`, a top rate of 25.2%: 30% less the 3.8-point net investment income tax and about 1 point for the gains below the top bracket. In household simulations the tax is highest at 25.25% on a quarter-point grid, and `policyengine_us/tests/core/test_capital_gains_mtr_measurement.py` checks the peak. Rates are clipped to [0, 1] before the change is taken. Without the clip, a household whose measured baseline rate is 10 because of a benefit cliff, and whose reform removes the cliff, would have its realizations multiplied by `exp(9.75β)`. With it, the multiplier `1 + f` stays between `exp(−β)` and `exp(β)`. @@ -75,8 +77,20 @@ A target revenue-maximizing rate `τ*` maps to `β = 1/τ*`: 28.5% is 3.51, 29% JCT's coefficient applies to the marginal rate from its individual income tax model. PolicyEngine applies `β` to the change in a household's marginal rate on gains, which also includes state income tax, the net investment income tax and benefit interactions. Because only the change enters, a federal rate change that passes through one-for-one produces the response JCT's equation would. -## Known limitation: how the rate is measured +## How the rate is measured + +`marginal_tax_rate_on_capital_gains` raises the person's `long_term_capital_gains` in a branch of the simulation, calculates the household again and reports `1 − Δ household_net_income / rise`. The rise goes through net capital gain, so the preferential rates, the net investment income tax, the alternative minimum tax and state income taxes apply to it as they do to the rest of the person's long-term gains. Where a situation or dataset gives a `capital_gains` total, which adjusted gross income reads, the branch raises that total by the same amount. The branch holds any behavioral response at its value in the simulation. The test suite checks that the result equals the difference between two new simulations of the same household, across states, filing statuses, wages and gains. + +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. `household_net_income` is a 32-bit float, which resolves about $8 at $100 million, so a $1,000 rise would resolve that household's rate only to 0.008. 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: a New York single filer with $24,985,000 of gains crosses the state's $25 million threshold, above which all income is taxed at the top rate, and measures 6.345, where a $1,000 rise measures 0.342 and one that crossed the threshold would measure about 150. Below $1 million, where the benefit cliffs are, the rise is $1,000. + +For a Texas single filer in 2026 whose only income is long-term gains, the measured rate is: -`marginal_tax_rate_on_capital_gains` raises the person's `capital_gains` input by $1,000. Gross income counts `capital_gains`, but the preferential-rate computation reads `long_term_capital_gains`, which that override does not change. So the extra $1,000 reaches adjusted gross income but not net capital gain, and the measured rate can reflect ordinary rates, deductions and how gains stack on other income rather than the long-term rate. In household tests for 2026, the measured rate for a Texas filer with $100 million of long-term gains is 0.296 rather than about 0.24 (20% plus the 3.8% net investment income tax) from raising long-term gains directly, with the alternative minimum tax making up the difference; for a Texas filer with $60,000 of wages and $200,000 of gains it is 0.308 rather than 0.188. Raising the top capital gains rate from 20% to 28% leaves the first filer's measured rate unchanged, so no form produces a response to that reform. All three forms take their rates from this measurement. [PolicyEngine US #10058](https://github.com/PolicyEngine/policyengine-us/pull/10058) raises long-term gains instead. +| Long-term gains | Rate | Made up of | +|---|---|---| +| $65,550 to $200,000 | 15% | The 15% bracket | +| $200,000 to $561,600 | 18.8% | The 15% bracket and the 3.8% net investment income tax | +| $561,600 to $648,000 | 23.8% | The 20% bracket and the net investment income tax | +| $648,000 to $680,200 | 33.8% | The same, plus 10 points while each dollar of gains removes 50 cents of alternative minimum tax exemption | +| Above $680,200 | 23.8% | The 20% bracket and the net investment income tax | -Separately, the measured rate stops rising once a top capital gains rate exceeds the top ordinary rate. 26 U.S.C. 1(h)(1) caps the tax on gains at the tax at ordinary rates (`limit_to_tax_at_main_rates`), so raising only the capital gains rates cannot tax large gains above about 37% plus the net investment income tax. +Raising the top rate (`gov.irs.capital_gains.rates.3`) raises the last row one for one, so a rise from 20% to 28% is a `τ1 − τ0` of 0.08 for those filers. That stops near the top ordinary rate of 37%: 26 U.S.C. 1(h)(1) caps the tax on gains at the tax on all taxable income at the ordinary rates (`limit_to_tax_at_main_rates`), so once the tax at the capital gains rates exceeds that, the ordinary rates apply. With a 40% top capital gains rate, a filer with $20 million of gains faces 37% plus the net investment income tax, while a filer with $1 million, whose tax at the capital gains rates is still the smaller, faces 40% plus it. Raising only the capital gains rates cannot tax large gains above about 37% plus the net investment income tax. diff --git a/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py b/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py index f83d5d516a4..eab4f5ffac0 100644 --- a/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py +++ b/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py @@ -27,14 +27,20 @@ 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. + ``capital_gains`` total as an input at construction. A total set after + construction gives the rate of a household that gave the same total at + construction. The new simulations use the same rise, so this checks the + branch, not the size of the rise. +4. Order. With both elasticities set, requesting the capital gains response + before the labor supply response gives the same measurements and both + responses as the other order (shown for four Texas filers). 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. +6. Revenue peak. Under the semi-elasticity form with beta = 1/0.30, a Texas + filer with $20 million of gains pays the most federal income tax at the + top rate r where r + 3.8% = (1 - $545,500 / A) / beta, A being what they + realize at r: about 25.2%, below the 30% a single-rate model gives. 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 @@ -277,7 +283,9 @@ def test_rate_follows_the_top_capital_gains_rate(top_rate): ) -@pytest.mark.parametrize("top_rate", [0.28, 0.4]) +# The order test below covers a 28% top rate through the measurement +# branches; this covers 40%, above the top ordinary rate. +@pytest.mark.parametrize("top_rate", [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) @@ -393,17 +401,23 @@ def test_rate_keeps_a_capital_gains_total_set_after_construction(): ) -def test_responses_do_not_depend_on_which_is_requested_first(): +def test_responses_and_measured_rates_do_not_depend_on_request_order(): # 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)])] + # that reads hours (Texas child care does) used to calculate the labor + # supply response, and its measurements, again: a CycleError. + households = [ + # Taxable income of $93,900, so the last gains are taxed at 15%: + # raising that rate moves this filer's realizations, and labor supply + # through the income effect. + ("TX", [(60_000, 50_000)]), + # Top-bracket filers, whose rate follows the top rate. + *texas_single_filers(TOP_BRACKET_GAINS), + ] + top_rate = 0.28 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}, + **top_rate_reform(top_rate), "gov.simulation.capital_gains_responses.elasticity": {REFORM_PERIOD: -0.7}, "gov.simulation.labor_supply_responses.elasticities.income": { REFORM_PERIOD: -0.05 @@ -416,22 +430,51 @@ def test_responses_do_not_depend_on_which_is_requested_first(): "capital_gains_behavioral_response", "labor_supply_behavioral_response", ] - values = {} + values, measurements = {}, {} for order in (responses, responses[::-1]): - simulation = simulate(situation, reform=reform) + simulation = simulate(households, reform=reform) values[order[0]] = { response: simulation.calculate(response, YEAR) for response in order } - - capital_gains_first, labor_supply_first = values.values() + measurements[order[0]] = simulation._behavioral_response_measurements[YEAR] + + # Through the measurement branches, the rates move as the statute does. + # The raised 15% rate is below the top bracket, so it moves only the + # first filer's rate; 28% is short of the AMT and ordinary-rate limits. + capital_gains_first = measurements[responses[0]] + baseline_rate = capital_gains_first["baseline_capital_gains_mtr"] + reform_rate = capital_gains_first["reform_capital_gains_mtr"] + top_baseline = CAPITAL_GAINS_RATES[2] + NIIT.rate + np.testing.assert_allclose( + baseline_rate, + [CAPITAL_GAINS_RATES[1]] + [top_baseline] * len(TOP_BRACKET_GAINS), + rtol=0, + atol=RATE_RESOLUTION, + ) + np.testing.assert_allclose( + reform_rate - baseline_rate, + [0.2 - CAPITAL_GAINS_RATES[1]] + + [ + top_bracket_rate(gains, top_rate) - CAPITAL_GAINS_RATES[2] + for gains in TOP_BRACKET_GAINS + ], + rtol=0, + atol=2 * RATE_RESOLUTION, + ) + # Either order gives the same measurements and responses. + for key, value in capital_gains_first.items(): + np.testing.assert_allclose( + value, measurements[responses[1]][key], rtol=1e-6, err_msg=key + ) for response in responses: - assert capital_gains_first[response][0] != 0, response np.testing.assert_allclose( - capital_gains_first[response], - labor_supply_first[response], + values[responses[0]][response], + values[responses[1]][response], rtol=1e-6, err_msg=response, ) + assert (values[responses[0]]["capital_gains_behavioral_response"] < 0).all() + assert values[responses[0]]["labor_supply_behavioral_response"][0] != 0 FLOAT32_AMOUNT = st.floats(-(2.0**36), 2.0**36, width=32) @@ -452,3 +495,86 @@ def test_rise_survives_float32_rounding(net_income, gains): # 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 + + +SEMI_ELASTICITY = "gov.simulation.capital_gains_responses.semi_elasticity" +REVENUE_SEMI_ELASTICITY = 1 / 0.3 +REVENUE_GAINS = 20_000_000 +# Taxable income at which the top capital gains rate starts. +TOP_RATE_START = IRS.capital_gains.thresholds["2"]["SINGLE"] + + +def realizations_at(top_rate): + """Gains a top-bracket filer realizes under the semi-elasticity form.""" + return REVENUE_GAINS * np.exp( + -REVENUE_SEMI_ELASTICITY * (top_rate - CAPITAL_GAINS_RATES[2]) + ) + + +def income_tax_on(realizations, top_rate): + """Federal income tax of a single filer with only these long-term gains. + + The 15% bracket, the top rate on the rest and the net investment income + tax. The top rate applies from ``TOP_RATE_START`` of gains, not of taxable + income, because this filer also owes alternative minimum tax, which + taxes the standard deduction at the top rate. That holds up to a top rate + of about 28.4% with $20 million of gains, above the rates tested here, + where the alternative minimum tax's flat 26% and 28% computation becomes + the smaller. + """ + return ( + CAPITAL_GAINS_RATES[1] * (TWENTY_PERCENT_START - FIFTEEN_PERCENT_START) + + top_rate * (realizations - TOP_RATE_START) + + NIIT.rate * (realizations - NIIT.threshold["SINGLE"]) + ) + + +def revenue_maximizing_top_rate(): + """Top rate at which ``income_tax_on(realizations_at(r), r)`` peaks. + + Its derivative in r is A - T - b * A * (r + n), for realizations A, + ``TOP_RATE_START`` T, semi-elasticity b and net investment income tax + rate n, so the peak is where r + n = (1 - T / A) / b. A falls as r rises, + so the derivative has one root. + """ + low, high = CAPITAL_GAINS_RATES[2], TOP_ORDINARY_RATE + for _ in range(60): + top_rate = (low + high) / 2 + realizations = realizations_at(top_rate) + slope = ( + realizations + - TOP_RATE_START + - REVENUE_SEMI_ELASTICITY * realizations * (top_rate + NIIT.rate) + ) + low, high = (top_rate, high) if slope > 0 else (low, top_rate) + return top_rate + + +def test_federal_income_tax_peaks_at_the_revenue_maximizing_top_rate(): + peak = revenue_maximizing_top_rate() + # 1 / semi-elasticity is 30%. The net investment income tax takes 3.8 + # points of it and the gains taxed below the top rate about 1 more. + assert peak == pytest.approx(0.252, abs=0.0005) + + income_tax = [] + for top_rate in (peak - 0.025, peak, peak + 0.025): + simulation = simulate( + texas_single_filers([REVENUE_GAINS]), + reform={ + **top_rate_reform(top_rate), + SEMI_ELASTICITY: {REFORM_PERIOD: REVENUE_SEMI_ELASTICITY}, + }, + ) + realizations = simulation.calculate("long_term_capital_gains", YEAR)[0] + income_tax.append(simulation.calculate("income_tax", YEAR)[0]) + + # The response sees the statutory change in the rate. + assert realizations == pytest.approx( + realizations_at(top_rate), + rel=REVENUE_SEMI_ELASTICITY * 2 * RATE_RESOLUTION, + ) + assert income_tax[-1] == pytest.approx( + income_tax_on(realizations, top_rate), abs=25 + ) + + assert income_tax[1] > max(income_tax[0], income_tax[2]) From 3e23c080755d07f2df45e6c3596f9e4f735b42bb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 20:34:57 -0400 Subject: [PATCH 8/9] Run heavy-a in Rest shard 1 and simulate the revenue peak once Main's core group grew to 43:46 (run 40031737740), putting shard 2's groups at 69.0 minutes before this PR's file, which made heavy-a 16:18 in run 39507589106. heavy-a now runs in shard 1 (48.8 minutes on main). The revenue-peak test simulates only the peak and checks the closed form at the neighbouring rates, cutting about 150 s. The docs say how a higher top rate moves each row of the Texas table and note the alternative minimum tax sliver just above 28%. Co-Authored-By: Claude Opus 5.5 --- Makefile | 16 +++--- .../methodology/capital-gains-responses.qmd | 4 +- .../test_capital_gains_mtr_measurement.py | 49 ++++++++++--------- 3 files changed, 39 insertions(+), 30 deletions(-) diff --git a/Makefile b/Makefile index 28f07f834eb..eafbbc3a30b 100644 --- a/Makefile +++ b/Makefile @@ -41,10 +41,10 @@ REST_SPM_TESTS := $(TESTS)/core/test_spm_policy_family.py \ # JUnit measures per-file elapsed time, not memory. The extra boundaries # below isolate retained full models and target <= ~10 GB per process; # verify those estimates against each group's GNU time report in CI. -# heavy-a also holds capital_gains_mtr_measurement (#10058): 463 s of -# reformed household simulations in run 38157151942, 150 s of it the -# response-order test, with heavy-a at 8.1 GB/15:19 in that slower run. -# core (142 s), policy (185 s), remaining (239 s). +# heavy-a also holds capital_gains_mtr_measurement (#10058): 632 s of +# reformed household simulations in run 39507589106, with heavy-a at +# 8.9 GB/16:18. Its revenue-peak test, 231 s of that, has since gone from +# three simulations to one. # SSI resource deeming retains a reformed system at import and builds another # in its grid fixture. Core reached 13.3 GB in CI run 38141803955 after this # and three smaller modules arrived; isolate SSI rather than filling heavy-a @@ -57,8 +57,12 @@ REST_PYTHON_GROUPS := cached-arrays heavy-a heavy-b core-heavy core policy-param # Both stay near 45 minutes, while the old serial job hit its 90-minute limit. # No shard runs more than one Python group at a time. Locally, leaving # REST_PYTHON_SHARD unset still runs every group in the original order. -REST_PYTHON_SHARD_1_GROUPS := cached-arrays core-heavy policy-parameters remaining-heavy remaining -REST_PYTHON_SHARD_2_GROUPS := heavy-a heavy-b core policy +# Core has since grown: in main's run 40031737740 it took 43:46, and the +# groups summed to 48.8 minutes in shard 1 and 69.0 in shard 2, where heavy-a +# took 4:22. With #10058's file heavy-a would put shard 2 near 80 minutes, so +# it runs in shard 1. +REST_PYTHON_SHARD_1_GROUPS := cached-arrays heavy-a core-heavy policy-parameters remaining-heavy remaining +REST_PYTHON_SHARD_2_GROUPS := heavy-b core policy REST_PYTHON_SHARD ?= ifeq ($(REST_PYTHON_SHARD),) REST_PYTHON_SELECTED_GROUPS := $(REST_PYTHON_GROUPS) diff --git a/docs-quarto/methodology/capital-gains-responses.qmd b/docs-quarto/methodology/capital-gains-responses.qmd index c96e83ec435..8678b76316f 100644 --- a/docs-quarto/methodology/capital-gains-responses.qmd +++ b/docs-quarto/methodology/capital-gains-responses.qmd @@ -54,7 +54,7 @@ The response depends only on the change in the rate, so it is defined at a 0% ba Single-rate revenue `τ · A · exp(−βτ)` has derivative `A · exp(−βτ) · (1 − βτ)`, so it peaks at `τ = 1/β` when that is below 1. Without the clip described below, a fixed state rate `s` that adds to the federal rate `t` multiplies realizations by the constant `exp(−βs)`, so the analytical peak of single-rate federal revenue `t · R` stays at a federal rate (including the net investment income tax) of `1/β`. With the clip, realizations stop falling once `t + s` reaches 100%, and federal revenue then rises with `t`. The analytical peak is the unique maximum when `β(1 − s) − ln β > 1` (at equality a 100% federal rate ties), which for β from 3.1 to 3.51 means a state rate below about 31% to 36%; where the left side is less than 1, revenue at a 100% federal rate is higher. With no state rate the condition holds for every β above 1. -A filer's own federal tax peaks at a lower top rate than `1/β`, because the gains below the top bracket are not taxed at it. With β = 1/0.30, a Texas single filer with $20 million of gains realizes `A = $20 million × exp(−β · (r − 20%))` at a top rate `r`, and the part of their federal income tax that moves with `r` is `r · (A − $545,500) + 3.8% · (A − $200,000)`: the top rate applies from $545,500 of gains, not of taxable income, because the alternative minimum tax, which this filer owes, taxes the standard deduction at it too. That peaks where `r + 3.8% = (1 − $545,500 / A) / β`, a top rate of 25.2%: 30% less the 3.8-point net investment income tax and about 1 point for the gains below the top bracket. In household simulations the tax is highest at 25.25% on a quarter-point grid, and `policyengine_us/tests/core/test_capital_gains_mtr_measurement.py` checks the peak. +A filer's own federal tax peaks at a lower top rate than `1/β`, because the gains below the top bracket are not taxed at it. With β = 1/0.30, a Texas single filer with $20 million of gains realizes `A = $20 million × exp(−β · (r − 20%))` at a top rate `r`, and the part of their federal income tax that moves with `r` is `r · (A − $545,500) + 3.8% · (A − $200,000)`: the top rate applies from $545,500 of gains, not of taxable income, because the alternative minimum tax, which this filer owes, taxes the standard deduction at it too. That peaks where `r + 3.8% = (1 − $545,500 / A) / β`, a top rate of 25.2%: 30% less the 3.8-point net investment income tax and about 1 point for the gains below the top bracket. In household simulations the tax is highest at 25.25% on a quarter-point grid, and `policyengine_us/tests/core/test_capital_gains_mtr_measurement.py` checks the peak and the simulated tax at it. Rates are clipped to [0, 1] before the change is taken. Without the clip, a household whose measured baseline rate is 10 because of a benefit cliff, and whose reform removes the cliff, would have its realizations multiplied by `exp(9.75β)`. With it, the multiplier `1 + f` stays between `exp(−β)` and `exp(β)`. @@ -93,4 +93,4 @@ For a Texas single filer in 2026 whose only income is long-term gains, the measu | $648,000 to $680,200 | 33.8% | The same, plus 10 points while each dollar of gains removes 50 cents of alternative minimum tax exemption | | Above $680,200 | 23.8% | The 20% bracket and the net investment income tax | -Raising the top rate (`gov.irs.capital_gains.rates.3`) raises the last row one for one, so a rise from 20% to 28% is a `τ1 − τ0` of 0.08 for those filers. That stops near the top ordinary rate of 37%: 26 U.S.C. 1(h)(1) caps the tax on gains at the tax on all taxable income at the ordinary rates (`limit_to_tax_at_main_rates`), so once the tax at the capital gains rates exceeds that, the ordinary rates apply. With a 40% top capital gains rate, a filer with $20 million of gains faces 37% plus the net investment income tax, while a filer with $1 million, whose tax at the capital gains rates is still the smaller, faces 40% plus it. Raising only the capital gains rates cannot tax large gains above about 37% plus the net investment income tax. +Raising the top rate (`gov.irs.capital_gains.rates.3`) raises the two 23.8% rows one for one and the alternative minimum tax row by one and a half times as much, so a rise from 20% to 28% is a `τ1 − τ0` of 0.08 for filers with more than $680,200 of gains. Just above 28% there is a narrow range of top rates, 28.38% to 28.40% for a filer with $20 million of gains, where the alternative minimum tax's flat 26% and 28% computation sets the tax and the rate is 28% plus the net investment income tax. The rate follows the top rate only up to about the top ordinary rate of 37%: 26 U.S.C. 1(h)(1) caps the tax on gains at the tax on all taxable income at the ordinary rates (`limit_to_tax_at_main_rates`), so once the tax at the capital gains rates exceeds that, the ordinary rates apply. With a 40% top capital gains rate, a filer with $20 million of gains faces 37% plus the net investment income tax, while a filer with $1 million, whose tax at the capital gains rates is still the smaller, faces 40% plus it. Raising only the capital gains rates cannot tax large gains above about 37% plus the net investment income tax. diff --git a/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py b/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py index eab4f5ffac0..0d3791bfd12 100644 --- a/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py +++ b/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py @@ -40,7 +40,8 @@ 6. Revenue peak. Under the semi-elasticity form with beta = 1/0.30, a Texas filer with $20 million of gains pays the most federal income tax at the top rate r where r + 3.8% = (1 - $545,500 / A) / beta, A being what they - realize at r: about 25.2%, below the 30% a single-rate model gives. + realize at r: about 25.2%, below the 30% a single-rate model gives. A + simulation at that rate gives the realizations and tax of the closed form. 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 @@ -556,25 +557,29 @@ def test_federal_income_tax_peaks_at_the_revenue_maximizing_top_rate(): # points of it and the gains taxed below the top rate about 1 more. assert peak == pytest.approx(0.252, abs=0.0005) - income_tax = [] - for top_rate in (peak - 0.025, peak, peak + 0.025): - simulation = simulate( - texas_single_filers([REVENUE_GAINS]), - reform={ - **top_rate_reform(top_rate), - SEMI_ELASTICITY: {REFORM_PERIOD: REVENUE_SEMI_ELASTICITY}, - }, - ) - realizations = simulation.calculate("long_term_capital_gains", YEAR)[0] - income_tax.append(simulation.calculate("income_tax", YEAR)[0]) - - # The response sees the statutory change in the rate. - assert realizations == pytest.approx( - realizations_at(top_rate), - rel=REVENUE_SEMI_ELASTICITY * 2 * RATE_RESOLUTION, - ) - assert income_tax[-1] == pytest.approx( - income_tax_on(realizations, top_rate), abs=25 - ) + revenue = [ + income_tax_on(realizations_at(top_rate), top_rate) + for top_rate in (peak - 0.025, peak, peak + 0.025) + ] + assert revenue[1] > max(revenue[0], revenue[2]) - assert income_tax[1] > max(income_tax[0], income_tax[2]) + # The household's realizations and tax at the peak are the closed form's. + # At other top rates they are too: the measured rate follows the top rate + # (test_rate_follows_the_top_capital_gains_rate) and the response is the + # semi-elasticity form of the measured change. Each simulation took 77 s + # in CI, so the test simulates the peak only. + simulation = simulate( + texas_single_filers([REVENUE_GAINS]), + reform={ + **top_rate_reform(peak), + SEMI_ELASTICITY: {REFORM_PERIOD: REVENUE_SEMI_ELASTICITY}, + }, + ) + realizations = simulation.calculate("long_term_capital_gains", YEAR)[0] + # The response sees the statutory change in the rate. + assert realizations == pytest.approx( + realizations_at(peak), rel=REVENUE_SEMI_ELASTICITY * 2 * RATE_RESOLUTION + ) + assert simulation.calculate("income_tax", YEAR)[0] == pytest.approx( + income_tax_on(realizations, peak), abs=25 + ) From 436ac286c9d517dcee4f7399b00dbd28d870e1c8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 22:43:25 -0400 Subject: [PATCH 9/9] Qualify the revenue-peak closed form and refresh heavy-a's CI figures The closed form holds at the top rates near the peak, not in the alternative minimum tax sliver or past the 1(h)(1) cap. heavy-a's figures are from run 40045994536, in GiB. Co-Authored-By: Claude Opus 5.5 --- Makefile | 7 +++---- docs-quarto/methodology/capital-gains-responses.qmd | 2 +- .../tests/core/test_capital_gains_mtr_measurement.py | 10 ++++++---- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index d3075cfc3c2..dbc821bb65b 100644 --- a/Makefile +++ b/Makefile @@ -41,10 +41,9 @@ REST_SPM_TESTS := $(TESTS)/core/test_spm_policy_family.py \ # JUnit measures per-file elapsed time, not memory. The extra boundaries # below isolate retained full models and target <= ~10 GB per process; # verify those estimates against each group's GNU time report in CI. -# heavy-a also holds capital_gains_mtr_measurement (#10058): 632 s of -# reformed household simulations in run 39507589106, with heavy-a at -# 8.9 GB/16:18. Its revenue-peak test, 231 s of that, has since gone from -# three simulations to one. +# heavy-a also holds capital_gains_mtr_measurement (#10058): 396 s of +# reformed household simulations in run 40045994536, with heavy-a at +# 8.1 GiB/11:16. # SSI resource deeming retains a reformed system at import and builds another # in its grid fixture. Core reached 13.3 GB in CI run 38141803955 after this # and three smaller modules arrived; isolate SSI rather than filling heavy-a diff --git a/docs-quarto/methodology/capital-gains-responses.qmd b/docs-quarto/methodology/capital-gains-responses.qmd index 8678b76316f..6281086e580 100644 --- a/docs-quarto/methodology/capital-gains-responses.qmd +++ b/docs-quarto/methodology/capital-gains-responses.qmd @@ -93,4 +93,4 @@ For a Texas single filer in 2026 whose only income is long-term gains, the measu | $648,000 to $680,200 | 33.8% | The same, plus 10 points while each dollar of gains removes 50 cents of alternative minimum tax exemption | | Above $680,200 | 23.8% | The 20% bracket and the net investment income tax | -Raising the top rate (`gov.irs.capital_gains.rates.3`) raises the two 23.8% rows one for one and the alternative minimum tax row by one and a half times as much, so a rise from 20% to 28% is a `τ1 − τ0` of 0.08 for filers with more than $680,200 of gains. Just above 28% there is a narrow range of top rates, 28.38% to 28.40% for a filer with $20 million of gains, where the alternative minimum tax's flat 26% and 28% computation sets the tax and the rate is 28% plus the net investment income tax. The rate follows the top rate only up to about the top ordinary rate of 37%: 26 U.S.C. 1(h)(1) caps the tax on gains at the tax on all taxable income at the ordinary rates (`limit_to_tax_at_main_rates`), so once the tax at the capital gains rates exceeds that, the ordinary rates apply. With a 40% top capital gains rate, a filer with $20 million of gains faces 37% plus the net investment income tax, while a filer with $1 million, whose tax at the capital gains rates is still the smaller, faces 40% plus it. Raising only the capital gains rates cannot tax large gains above about 37% plus the net investment income tax. +Raising the top rate (`gov.irs.capital_gains.rates.3`) raises the two 23.8% rows one for one and the alternative minimum tax row by one and a half times as much, so a rise from 20% to 28% is a `τ1 − τ0` of 0.08 for filers with more than $680,200 of gains. Just above 28% there is a narrow range of top rates, 28.38% to 28.40% for a filer with $20 million of gains, where the alternative minimum tax's 26% and 28% rate schedule, applied to all of the filer's income, sets the tax and the rate is 28% plus the net investment income tax. The rate follows the top rate only up to about the top ordinary rate of 37%: 26 U.S.C. 1(h)(1) caps the tax on gains at the tax on all taxable income at the ordinary rates (`limit_to_tax_at_main_rates`), so once the tax at the capital gains rates exceeds that, the ordinary rates apply. With a 40% top capital gains rate, a filer with $20 million of gains faces 37% plus the net investment income tax, while a filer with $1 million, whose tax at the capital gains rates is still the smaller, faces 40% plus it. Raising only the capital gains rates cannot tax large gains above about 37% plus the net investment income tax. diff --git a/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py b/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py index 0d3791bfd12..cd19d3c850e 100644 --- a/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py +++ b/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py @@ -564,10 +564,12 @@ def test_federal_income_tax_peaks_at_the_revenue_maximizing_top_rate(): assert revenue[1] > max(revenue[0], revenue[2]) # The household's realizations and tax at the peak are the closed form's. - # At other top rates they are too: the measured rate follows the top rate - # (test_rate_follows_the_top_capital_gains_rate) and the response is the - # semi-elasticity form of the measured change. Each simulation took 77 s - # in CI, so the test simulates the peak only. + # They are at the neighbouring top rates too, since there the measured + # rate follows the top rate (test_rate_follows_the_top_capital_gains_rate) + # and the response is the semi-elasticity form of the measured change. + # That does not hold at every top rate: not in the alternative minimum tax + # sliver just above 28%, nor past the 1(h)(1) cap near 37%. A simulation + # takes about 70 s in CI, so the test simulates the peak only. simulation = simulate( texas_single_filers([REVENUE_GAINS]), reform={