diff --git a/Makefile b/Makefile index 0b15d4afae5..dbc821bb65b 100644 --- a/Makefile +++ b/Makefile @@ -41,7 +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. -# core (142 s), policy (185 s), remaining (239 s). +# 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 @@ -55,7 +57,8 @@ REST_CACHED_ARRAY_TESTS := $(TESTS)/test_formulas_do_not_write_into_cached_array REST_HEAVY_A_TESTS := $(TESTS)/test_md_poverty_line_credit_invariants.py \ $(TESTS)/core/test_ald_determinism.py \ $(TESTS)/core/test_dependent_net_investment_income_invariants.py \ - $(TESTS)/core/test_ssi_state_supplement_medicaid_dependency.py + $(TESTS)/core/test_ssi_state_supplement_medicaid_dependency.py \ + $(TESTS)/core/test_capital_gains_mtr_measurement.py REST_HEAVY_B_TESTS := $(TESTS)/core/test_marginal_tax_rate_coverage.py \ $(TESTS)/core/test_multi_year_simulation.py \ $(TESTS)/core/test_behavioral_response_measurements.py \ diff --git a/changelog.d/fix-cg-mtr-measurement.fixed.md b/changelog.d/fix-cg-mtr-measurement.fixed.md new file mode 100644 index 00000000000..5e94885c5b1 --- /dev/null +++ b/changelog.d/fix-cg-mtr-measurement.fixed.md @@ -0,0 +1 @@ +Measure the capital gains marginal tax rate on long-term gains, so capital gains realization responses see the preferential rates and changes to them, and scale the measured rise with income so the rate keeps its precision for large households. diff --git a/docs-quarto/methodology/capital-gains-responses.qmd b/docs-quarto/methodology/capital-gains-responses.qmd index 843bf710eb5..6281086e580 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 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(β)`. @@ -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 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 new file mode 100644 index 00000000000..cd19d3c850e --- /dev/null +++ b/policyengine_us/tests/core/test_capital_gains_mtr_measurement.py @@ -0,0 +1,587 @@ +"""Household checks of ``marginal_tax_rate_on_capital_gains``. + +The capital gains realization response scales a person's long-term gains with +the change in this rate between baseline and reform policy +(``capital_gains_responses.py``), so it has to be the rate on long-term gains. +It used to raise ``capital_gains``, the sum of short- and long-term gains. +That reaches adjusted gross income but not net capital gain, so outside the +cases where taxable income was below net capital gain the rise was taxed as +ordinary income, and raising the top capital gains rate from 20% to 28% left +a top-bracket filer's measured rate unchanged. + +Invariants, for 2026: + +1. Statutory rate. A Texas single filer whose only income is long-term gains + in the 15% or 20% bracket faces that bracket's rate (26 U.S.C. 1(h)(1)), + plus the 3.8% net investment income tax once modified adjusted gross + income passes its threshold (26 U.S.C. 1411), plus half that rate where + each dollar of gains also removes 50 cents of alternative minimum tax + exemption (26 U.S.C. 55(d)(2) and (d)(4)). Texas has no income tax. +2. Reform. With the top capital gains rate set to r, a top-bracket filer's + rate moves by r less the baseline rate, both measured directly and through + the behavioral response measurement branches. That holds until the tax at + the capital gains rates would exceed the tax on all taxable income at the + ordinary rates, which 26 U.S.C. 1(h)(1) then imposes instead: for r = 40%, + a filer with $20 million of gains faces the 37% ordinary rate and one with + $1 million still faces 40%. +3. Differential. The rate measured in a branch equals the finite difference + between new simulations of the same households, across states, filing + statuses, wages and gains, including households that give a + ``capital_gains`` total as an input at construction. 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. 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 +examples keep clear of the bracket edges. The expectations for 2 also leave +out two narrow ranges of the top rate that ``top_bracket_rate`` does not +model (see ``TOP_RATES``). Fixed household examples of each +bracket, wages, joint filers, given ``capital_gains`` totals, an input given +for another year and a state cliff are YAML tests in ``tests/policy/baseline/household/ +marginal_tax_rate_on_capital_gains.yaml``. These tests cover what YAML +cannot: properties over generated households, reforms read through the +behavioral response measurement branches, and the differential against new +simulations. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings, strategies as st + +from policyengine_us import Simulation +from policyengine_us.system import system +from policyengine_us.variables.gov.simulation.capital_gains_responses import ( + CAPITAL_GAINS_MTR_MINIMUM_RISE, + CAPITAL_GAINS_MTR_RELATIVE_RISE, + capital_gains_mtr_rise, +) + +YEAR = "2026" +AGE = 50 +IRS = system.parameters(f"{YEAR}-01-01").gov.irs +CAPITAL_GAINS_RATES = [IRS.capital_gains.rates[bracket] for bracket in "123"] +STANDARD_DEDUCTION = IRS.deductions.standard.amount["SINGLE"] +# Long-term gains at which a single filer with no other income leaves the 0% +# and the 15% brackets. +FIFTEEN_PERCENT_START = int( + STANDARD_DEDUCTION + IRS.capital_gains.thresholds["1"]["SINGLE"] +) +TWENTY_PERCENT_START = int( + STANDARD_DEDUCTION + IRS.capital_gains.thresholds["2"]["SINGLE"] +) +ORDINARY_RATES = [IRS.income.bracket.rates[str(bracket)] for bracket in range(1, 8)] +ORDINARY_THRESHOLDS = [ + IRS.income.bracket.thresholds[str(bracket)]["SINGLE"] for bracket in range(1, 7) +] +TOP_ORDINARY_RATE = ORDINARY_RATES[-1] +NIIT = IRS.investment.net_investment_income_tax +AMT_EXEMPTION = IRS.income.amt.exemption +# The alternative minimum tax does not allow the standard deduction, so a +# filer owes it once the exemption has phased out to less than the standard +# deduction. From there until the exemption is gone, each dollar of gains +# removes ``phase_out.rate`` dollars of exemption, taxed at the gains rate. +AMT_PHASE_OUT_RATE = AMT_EXEMPTION.phase_out.rate +AMT_BINDS_FROM = ( + AMT_EXEMPTION.phase_out.start["SINGLE"] + + (AMT_EXEMPTION.amount["SINGLE"] - STANDARD_DEDUCTION) / AMT_PHASE_OUT_RATE +) +AMT_EXEMPTION_GONE = ( + AMT_EXEMPTION.phase_out.start["SINGLE"] + + AMT_EXEMPTION.amount["SINGLE"] / AMT_PHASE_OUT_RATE +) +RATE_EDGES = ( + FIFTEEN_PERCENT_START, + NIIT.threshold["SINGLE"], + TWENTY_PERCENT_START, + AMT_BINDS_FROM, + AMT_EXEMPTION_GONE, +) +# household_net_income is a float32, which resolves 2 ** -23 of its size, and +# the rise is at least 0.1% of it, so the difference alone resolves the rate +# to 1.2e-4. The tax itself is rounded along the way. +RATE_RESOLUTION = 2.5e-4 +TOP_RATE = "gov.irs.capital_gains.rates.3" +REFORM_PERIOD = f"{YEAR}-01-01.{YEAR}-12-31" +TOP_BRACKET_GAINS = (1_000_000, 20_000_000, 100_000_000) +SIMULATION_SETTINGS = dict( + deadline=None, + derandomize=True, + database=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def statutory_rate(gains): + """Federal rate on one more dollar of a single filer's only income.""" + if gains < FIFTEEN_PERCENT_START: + rate = CAPITAL_GAINS_RATES[0] + elif gains < TWENTY_PERCENT_START: + rate = CAPITAL_GAINS_RATES[1] + else: + rate = CAPITAL_GAINS_RATES[2] + net_investment_income_tax = NIIT.rate if gains > NIIT.threshold["SINGLE"] else 0 + alternative_minimum_tax = ( + AMT_PHASE_OUT_RATE * rate if AMT_BINDS_FROM < gains < AMT_EXEMPTION_GONE else 0 + ) + return rate + net_investment_income_tax + alternative_minimum_tax + + +def top_bracket_rate(gains, top_rate): + """Regular tax rate on the next dollar of a top-bracket filer's gains. + + 26 U.S.C. 1(h)(1): the tax "shall not exceed" the tax at the capital gains + rates, so it is the smaller of that and the tax on all taxable income at + the ordinary rates (Schedule D Tax Worksheet, line 47). + """ + taxable_income = gains - STANDARD_DEDUCTION + assert taxable_income > ORDINARY_THRESHOLDS[-1] + tax_at_capital_gains_rates = CAPITAL_GAINS_RATES[1] * ( + TWENTY_PERCENT_START - FIFTEEN_PERCENT_START + ) + top_rate * (gains - TWENTY_PERCENT_START) + edges = [0, *ORDINARY_THRESHOLDS, taxable_income] + tax_at_ordinary_rates = sum( + rate * (upper - lower) + for rate, lower, upper in zip(ORDINARY_RATES, edges, edges[1:]) + ) + if tax_at_capital_gains_rates < tax_at_ordinary_rates: + return top_rate + return TOP_ORDINARY_RATE + + +def clear_of_rate_edges(gains): + rise = max(CAPITAL_GAINS_MTR_MINIMUM_RISE, CAPITAL_GAINS_MTR_RELATIVE_RISE * gains) + return all(not edge - 2 * rise <= gains <= edge + rise for edge in RATE_EDGES) + + +def simulate(households, reform=None): + """A simulation of ``households``, each a state and its adults. + + An adult is their wages and long-term gains, optionally followed by a + ``capital_gains`` total given as an input. Two adults are married and file + jointly. + """ + situation = { + key: {} + for key in ( + "people", + "tax_units", + "marital_units", + "families", + "spm_units", + "households", + ) + } + for index, (state, adults) in enumerate(households): + members = [] + for adult, (wages, gains, *capital_gains) in enumerate(adults): + name = f"adult_{index}_{adult}" + members.append(name) + situation["people"][name] = { + "age": {YEAR: AGE}, + "employment_income": {YEAR: wages}, + "long_term_capital_gains": {YEAR: gains}, + } + if capital_gains: + situation["people"][name]["capital_gains"] = {YEAR: capital_gains[0]} + for key in ("tax_units", "marital_units", "families", "spm_units"): + situation[key][f"{key}_{index}"] = {"members": members} + situation["households"][f"household_{index}"] = { + "members": members, + "state_name": {YEAR: state}, + } + return Simulation( + situation=situation, reform=reform, spm={"geography_kind": "national"} + ) + + +def texas_single_filers(gains): + return [("TX", [(0, amount)]) for amount in gains] + + +def measured_rate(simulation): + return simulation.calculate("marginal_tax_rate_on_capital_gains", YEAR) + + +def top_rate_reform(top_rate): + return {TOP_RATE: {REFORM_PERIOD: top_rate}} + + +def test_statutory_rate_is_derived_from_the_2026_schedule(): + # The expectations below come from parameters; these pin what they are. + assert CAPITAL_GAINS_RATES == [0, 0.15, 0.2] + assert (FIFTEEN_PERCENT_START, TWENTY_PERCENT_START) == (65_550, 561_600) + assert (NIIT.rate, NIIT.threshold["SINGLE"]) == (0.038, 200_000) + assert (AMT_BINDS_FROM, AMT_EXEMPTION_GONE) == (648_000, 680_200) + assert statutory_rate(100_000) == pytest.approx(0.15) + assert statutory_rate(300_000) == pytest.approx(0.188) + assert statutory_rate(660_000) == pytest.approx(0.338) + assert statutory_rate(20_000_000) == pytest.approx(0.238) + assert TOP_ORDINARY_RATE == 0.37 + assert top_bracket_rate(20_000_000, 0.3) == 0.3 + assert top_bracket_rate(1_000_000, 0.4) == 0.4 + assert top_bracket_rate(20_000_000, 0.4) == 0.37 + + +@settings(max_examples=8, **SIMULATION_SETTINGS) +@given( + gains=st.lists( + st.one_of( + st.integers(FIFTEEN_PERCENT_START, 800_000), + st.integers(800_000, 3_000_000_000), + ).filter(clear_of_rate_edges), + min_size=1, + max_size=12, + ) +) +def test_rate_on_long_term_gains_is_the_statutory_rate(gains): + measured = measured_rate(simulate(texas_single_filers(gains))) + + expected = [statutory_rate(amount) for amount in gains] + np.testing.assert_allclose(measured, expected, rtol=0, atol=RATE_RESOLUTION) + + +# Two ranges of the top rate are left out, where top_bracket_rate, which +# models only the 1(h)(1) limit, does not give every filer here their rate: +# - 28% to 29%. Once the tax at the capital gains rates passes the alternative +# minimum tax's flat 26% and 28% computation, that computation is the +# tentative minimum tax, and for a sliver of rates the filer still owes +# alternative minimum tax and faces 28%: 28.38% to 28.40% with $20 million +# of gains, 28.074% to 28.078% with $100 million. +# - 37% to 38%. The tax on all taxable income at the ordinary rates becomes +# the smaller regular tax at 37.08% with $100 million of gains and 37.43% +# with $20 million, and within the rise of that point the rate is a blend. +TOP_RATES = st.one_of( + st.floats(CAPITAL_GAINS_RATES[2], 0.28), + st.floats(0.29, TOP_ORDINARY_RATE), + st.floats(0.38, 0.4), +) + + +@settings(max_examples=3, **SIMULATION_SETTINGS) +@given(top_rate=TOP_RATES) +def test_rate_follows_the_top_capital_gains_rate(top_rate): + simulation = simulate( + texas_single_filers(TOP_BRACKET_GAINS), reform=top_rate_reform(top_rate) + ) + + np.testing.assert_allclose( + measured_rate(simulation), + [top_bracket_rate(gains, top_rate) + NIIT.rate for gains in TOP_BRACKET_GAINS], + rtol=0, + atol=RATE_RESOLUTION, + ) + + +# 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) + ) + + simulation.calculate("relative_capital_gains_mtr_change", YEAR) + measurements = simulation._behavioral_response_measurements[YEAR] + baseline_rate = measurements["baseline_capital_gains_mtr"] + reform_rate = measurements["reform_capital_gains_mtr"] + + np.testing.assert_allclose( + baseline_rate, + CAPITAL_GAINS_RATES[2] + NIIT.rate, + rtol=0, + atol=RATE_RESOLUTION, + ) + np.testing.assert_allclose( + reform_rate - baseline_rate, + [ + top_bracket_rate(gains, top_rate) - CAPITAL_GAINS_RATES[2] + for gains in TOP_BRACKET_GAINS + ], + rtol=0, + atol=2 * RATE_RESOLUTION, + ) + + +def fresh_simulation_rate(households): + """The rate as a difference between new simulations of ``households``. + + The new simulations have the raised long-term gains as an input. Where + any adult gives a ``capital_gains`` total, the simulation stores that + variable for everyone (zero where not given), so the new simulations give + every adult their stored total, raised by the same amount. + """ + base = simulate(households) + net_income = base.person.household("household_net_income", YEAR) + gains = base.calculate("long_term_capital_gains", YEAR) + capital_gains = base.calculate("capital_gains", YEAR) + adult_index = base.calculate("adult_index_cg", YEAR) + rise = capital_gains_mtr_rise(net_income, gains) + gives_total = any(len(adult) > 2 for _, adults in households for adult in adults) + rates = np.zeros_like(net_income) + for index in (1, 2): + raised = adult_index == index + raised_gains = (gains + raised * rise).astype(np.float32) + raised_capital_gains = (capital_gains + raised * rise).astype(np.float32) + people = iter(zip(raised_gains.tolist(), raised_capital_gains.tolist())) + higher_households = [] + for state, adults in households: + higher_adults = [] + for wages, *_ in adults: + person_gains, person_capital_gains = next(people) + total = [person_capital_gains] if gives_total else [] + higher_adults.append((wages, person_gains, *total)) + higher_households.append((state, higher_adults)) + higher = simulate(higher_households) + increase = higher.person.household("household_net_income", YEAR) - net_income + stored_rise = np.where(raised, raised_gains - gains, 1) + rates += np.where(raised, 1 - increase / stored_rise, 0) + return base, rates + + +WAGES = st.sampled_from([0, 20_000, 60_000, 250_000, 2_000_000]) +GAINS = st.one_of(st.integers(-50_000, 700_000), st.integers(700_000, 200_000_000)) +ADULT = st.one_of( + st.tuples(WAGES, GAINS), + # A capital_gains total given as an input, whether or not it is the sum + # of the person's short- and long-term gains. + st.tuples(WAGES, GAINS, st.integers(-50_000, 5_000_000)), +) + + +# capital_gains totals given as inputs: with long-term gains, without them, +# and for one spouse only, which stores a total of zero for the other. +@example(households=[("TX", [(0, 300_000, 300_000)]), ("TX", [(0, 0, 300_000)])]) +@example(households=[("TX", [(0, 0), (0, 0, 0)])]) +@settings(max_examples=5, **SIMULATION_SETTINGS) +@given( + households=st.lists( + st.tuples( + st.sampled_from(["TX", "CA", "NY", "MA", "PA"]), + st.lists(ADULT, min_size=1, max_size=2), + ), + min_size=1, + max_size=6, + ) +) +def test_branch_rate_matches_new_simulations(households): + base, expected = fresh_simulation_rate(households) + + np.testing.assert_allclose(measured_rate(base), expected, rtol=0, atol=1e-6) + + +def test_rate_keeps_a_capital_gains_total_set_after_construction(): + # A total set through set_input after the simulation is built is not + # among the construction inputs a branch keeps by name, so the branch + # used to drop it and rebuild the total from long-term gains alone. + gains, totals = [300_000, 0], [400_000, 300_000] + late = simulate(texas_single_filers(gains)) + late.set_input("capital_gains", YEAR, np.array(totals, dtype=np.float32)) + given = simulate( + [("TX", [(0, amount, total)]) for amount, total in zip(gains, totals)] + ) + + measured = measured_rate(late) + + np.testing.assert_allclose(measured, measured_rate(given), rtol=0, atol=1e-6) + # Each total puts the rise in the 15% bracket, over the net investment + # income tax threshold. + np.testing.assert_allclose( + measured, statutory_rate(300_000), rtol=0, atol=RATE_RESOLUTION + ) + + +def test_responses_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. + 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 = { + "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 + }, + "gov.simulation.labor_supply_responses.elasticities.substitution.all": { + REFORM_PERIOD: 0.25 + }, + } + responses = [ + "capital_gains_behavioral_response", + "labor_supply_behavioral_response", + ] + values, measurements = {}, {} + for order in (responses, responses[::-1]): + simulation = simulate(households, reform=reform) + values[order[0]] = { + response: simulation.calculate(response, YEAR) for response in order + } + 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: + np.testing.assert_allclose( + 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) + + +@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 + + +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) + + 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]) + + # The household's realizations and tax at the peak are the closed form's. + # 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={ + **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 + ) diff --git a/policyengine_us/tests/core/test_override_branches.py b/policyengine_us/tests/core/test_override_branches.py index 9e46a63644e..a5379432b5a 100644 --- a/policyengine_us/tests/core/test_override_branches.py +++ b/policyengine_us/tests/core/test_override_branches.py @@ -35,7 +35,9 @@ 5. When the branch drops what it copied, it keeps exactly the values set as inputs, each for the period it was set for: a variable that is an input in one year is calculated again in the others (Hypothesis, over random mixes - of input years and formula years). + of input years and formula years). Limited to some variables, it leaves + every other array as it was, and ``has_input`` reports the periods + ``set_input`` stored, from the situation or later. """ import numpy as np @@ -49,6 +51,7 @@ drop_inherited_values, get_branch_for_period, get_override_branch, + has_input, ) YEAR = 2026 @@ -541,6 +544,44 @@ def test_drop_inherited_values_keeps_exactly_the_input_keys( np.testing.assert_array_equal(head, heads) +def test_drop_inherited_values_limited_to_variables(households): + # Adjusted gross income is an input for 2025 only, and a later set_input + # gives taxable income for 2026; both are calculated in the other year. + situation = _with_tax_unit_inputs( + _situation(households[:4], years=YEARS), + {"adjusted_gross_income": (2025, 1_000)}, + ) + n = len(situation["tax_units"]) + simulation = Simulation(situation=situation) + simulation.set_input("taxable_income", YEAR, np.full(n, 2_000.0)) + for year in YEARS: + simulation.calculate("income_tax", year) + assert has_input(simulation, "adjusted_gross_income", 2025) + assert not has_input(simulation, "adjusted_gross_income", YEAR) + assert has_input(simulation, "taxable_income", YEAR) + assert not has_input(simulation, "taxable_income", 2025) + branch = simulation.get_branch("limited") + assert has_input(branch, "taxable_income", YEAR) + + drop_inherited_values(branch, ["adjusted_gross_income"]) + + np.testing.assert_array_equal( + branch.get_array("adjusted_gross_income", 2025), np.full(n, 1_000.0) + ) + assert branch.get_array("adjusted_gross_income", YEAR) is None + # Variables outside the list keep what the branch copied, calculated + # values included. + for year in YEARS: + np.testing.assert_array_equal( + branch.get_array("income_tax", year), + simulation.calculate("income_tax", year), + ) + np.testing.assert_array_equal( + branch.get_array("taxable_income", YEAR), np.full(n, 2_000.0) + ) + assert branch.get_array("taxable_income", 2025) is not None + + def test_later_year_branches_match_single_year_simulation(households): years = (2025, YEAR) simulation = Simulation(situation=_situation(households, years=years)) diff --git a/policyengine_us/tests/policy/baseline/household/marginal_tax_rate_on_capital_gains.yaml b/policyengine_us/tests/policy/baseline/household/marginal_tax_rate_on_capital_gains.yaml new file mode 100644 index 00000000000..3002bb12113 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/marginal_tax_rate_on_capital_gains.yaml @@ -0,0 +1,174 @@ +# marginal_tax_rate_on_capital_gains raises a person's long-term gains and +# measures how much of the rise household net income keeps. For 2026 single +# filers in Texas, which has no income tax, the rate is the 26 U.S.C. 1(h)(1) +# rate on the gains, plus the 3.8% net investment income tax once modified +# adjusted gross income passes $200,000 (26 U.S.C. 1411), plus half the gains +# rate while each dollar of gains removes 50 cents of alternative minimum tax +# exemption (26 U.S.C. 55(d)(2), with the 2026 rate in 55(d)(4)). With the +# $16,100 standard deduction, the 15% rate starts at $65,550 of gains and the +# 20% rate at $561,600. The alternative minimum tax applies from $648,000, +# because it does not allow the standard deduction; above $680,200 its +# $90,100 exemption is gone and it stays at a constant $3,220 (20% of the +# standard deduction), adding nothing at the margin. +# tests/core/test_capital_gains_mtr_measurement.py derives these edges from +# parameters and tests amounts Hypothesis draws between them. + +- name: Gains in the 15% bracket are taxed at 15%. + period: 2026 + absolute_error_margin: 0.00025 + input: + age: 50 + long_term_capital_gains: 100_000 + state_code: TX + output: + marginal_tax_rate_on_capital_gains: 0.15 + +- name: Above $200,000 the net investment income tax adds 3.8%. + period: 2026 + absolute_error_margin: 0.00025 + input: + age: 50 + long_term_capital_gains: 300_000 + state_code: TX + output: + marginal_tax_rate_on_capital_gains: 0.188 + +- name: Gains in the 20% bracket are taxed at 20% plus 3.8%. + period: 2026 + absolute_error_margin: 0.00025 + input: + age: 50 + long_term_capital_gains: 600_000 + state_code: TX + output: + marginal_tax_rate_on_capital_gains: 0.238 + +- name: While the alternative minimum tax exemption phases out, half of each dollar more is taxed at 20% too. + period: 2026 + absolute_error_margin: 0.00025 + input: + age: 50 + long_term_capital_gains: 660_000 + state_code: TX + output: + # 0.20 + 0.038 + 0.5 * 0.20 + marginal_tax_rate_on_capital_gains: 0.338 + +- name: $20 million of gains. + period: 2026 + absolute_error_margin: 0.00025 + input: + age: 50 + long_term_capital_gains: 20_000_000 + state_code: TX + output: + marginal_tax_rate_on_capital_gains: 0.238 + +- name: $100 million of gains, where float32 net income resolves only $8. + period: 2026 + absolute_error_margin: 0.00025 + input: + age: 50 + long_term_capital_gains: 100_000_000 + state_code: TX + output: + # A $1,000 rise measured 0.232; the rise is now 0.1% of the gains. + marginal_tax_rate_on_capital_gains: 0.238 + +- name: Wages put the gains in the 15% bracket and over the investment income tax threshold. + period: 2026 + absolute_error_margin: 0.00025 + input: + age: 50 + employment_income: 60_000 + long_term_capital_gains: 200_000 + state_code: TX + output: + # Raising the capital_gains total instead taxed the rise as ordinary + # income: 0.308. + marginal_tax_rate_on_capital_gains: 0.188 + +- name: Each spouse filing jointly faces the rate on their joint gains. + period: 2026 + absolute_error_margin: 0.00025 + input: + people: + head: + age: 50 + long_term_capital_gains: 500_000 + spouse: + age: 50 + long_term_capital_gains: 300_000 + tax_units: + tax_unit: + members: [head, spouse] + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # $767,800 of taxable income is past the $613,700 joint 20% threshold. + marginal_tax_rate_on_capital_gains: [0.238, 0.238] + +- name: A capital_gains total given as an input rises with the long-term gains. + period: 2026 + absolute_error_margin: 0.00025 + input: + age: 50 + long_term_capital_gains: 300_000 + capital_gains: 300_000 + state_code: TX + output: + marginal_tax_rate_on_capital_gains: 0.188 + +- name: A capital_gains total given without long-term gains is taxed as ordinary income, and the rise as long-term gain. + period: 2026 + absolute_error_margin: 0.00025 + input: + age: 50 + capital_gains: 300_000 + state_code: TX + output: + # The $300,000 fills the ordinary brackets, so the rise is taxed at 15% + # plus 3.8%. + marginal_tax_rate_on_capital_gains: 0.188 + +- name: An input given for another year does not hold this year's value. + period: 2026 + absolute_error_margin: 0.00025 + input: + people: + person: + age: 50 + long_term_capital_gains: 300_000 + tax_units: + tax_unit: + members: [person] + adjusted_gross_income: + 2025: 400_000 + households: + household: + members: [person] + state_code: TX + output: + # 2026 adjusted gross income is calculated, so the rise reaches it. + marginal_tax_rate_on_capital_gains: 0.188 + +- name: Within the rise of New York's $25 million threshold, the rate spreads the threshold's tax over the rise. + period: 2026 + absolute_error_margin: 0.001 + input: + age: 50 + long_term_capital_gains: 24_985_000 + state_code: NY + output: + # Above $25 million of adjusted gross income New York taxes all taxable + # income at its top rate (N.Y. Tax Law 601; ny_supplemental_tax). The + # $24,985 rise crosses that, adding about $152,600 of state tax. A $1,000 + # rise measures 0.342 here, and one that crossed the threshold would + # measure about 150; the wider rise puts more filers in the window, each + # with a smaller spike. + marginal_tax_rate_on_capital_gains: 6.345 diff --git a/policyengine_us/tools/period_branch.py b/policyengine_us/tools/period_branch.py index 1270a9490ce..dd70ee44056 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 @@ -102,19 +102,41 @@ def still_stored(key): simulation._user_input_keys = {key for key in input_keys if still_stored(key)} -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 f8daa8b8c89..269f0bf8dfd 100644 --- a/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py +++ b/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py @@ -11,6 +11,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 9c43a4ae784..5c85a12dd99 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, ) @@ -128,12 +129,44 @@ def formula(person, period, parameters): ) +# marginal_tax_rate_on_capital_gains raises long-term gains by at least $1,000. +# The rate is a difference in household_net_income, a float32 that resolves +# only about 1.2e-7 of its own size (8 dollars at $100 million), so a fixed +# $1,000 rise would resolve a $100 million household's rate only to 0.008. +# The rise is therefore 0.1% of the larger of the household's net income and +# the person's long-term gains, in absolute value, where that exceeds $1,000, +# which keeps the resolution near 1e-4. Below $1 million both give $1,000. +# Like any finite difference, the rate averages over the rise, so a person +# within the rise of a cliff gets the whole cliff spread over it. Above $1 +# million a wider rise puts more people in that window, each with a smaller +# spike: a New York filer with $24,985,000 of gains crosses the state's $25 +# million recapture threshold and measures 6.35, where a $1,000 rise measures +# 0.342 and one within $1,000 of the threshold would measure about 150. +CAPITAL_GAINS_MTR_MINIMUM_RISE = 1_000 +CAPITAL_GAINS_MTR_RELATIVE_RISE = 1e-3 + + +def capital_gains_mtr_rise(household_net_income, long_term_capital_gains): + """Rise in long-term gains that measures each person's rate.""" + scale = np.maximum( + np.abs(np.asarray(household_net_income, dtype=np.float64)), + np.abs(np.asarray(long_term_capital_gains, dtype=np.float64)), + ) + return np.maximum( + CAPITAL_GAINS_MTR_MINIMUM_RISE, CAPITAL_GAINS_MTR_RELATIVE_RISE * scale + ) + + class marginal_tax_rate_on_capital_gains(Variable): label = "capital gains marginal tax rate" documentation = ( - "Percent of marginal capital gains that do not increase household net income." + "Share of a rise in long-term capital gains that does not increase" + " household net income. The rise is $1,000, or 0.1% of the larger of the" + " absolute values of the household's net income and the person's" + " long-term gains where that is more." " Simulated only for the two adults in each household with the largest" - " long-term capital gains (adult_index_cg 1 and 2), regardless of" + " long-term capital gains before behavioral responses (adult_index_cg 1" + " and 2), regardless of" " simulation.marginal_tax_rate_adults; zero for everyone else." ) entity = Person @@ -142,34 +175,64 @@ class marginal_tax_rate_on_capital_gains(Variable): unit = "/1" def formula(person, period, parameters): # pragma: no cover - # Requires simulation branching - tested via microsim + # Requires simulation branching - tested via household simulations + # in tests/core/test_capital_gains_mtr_measurement.py mtr_values = np.zeros(person.count, dtype=np.float32) simulation = person.simulation - DELTA = 1_000 adult_index_values = person("adult_index_cg", period) + long_term_capital_gains = person("long_term_capital_gains", period) + capital_gains = person("capital_gains", period) + # capital_gains adds short- and long-term gains, and adjusted gross + # income reads it. A total given as an input, at construction or + # later, is stored as given rather than added up again. + capital_gains_is_input = has_input(simulation, "capital_gains", period) # Before branching, so a reform simulation has measured its behavioural # responses and each branch starts from those measurements, as # marginal_tax_rate's do; a branch made first measured its own. household_net_income = person.household("household_net_income", period) + rise = capital_gains_mtr_rise(household_net_income, long_term_capital_gains) + variables = simulation.tax_benefit_system.variables inputs = user_set_variables(simulation) + inputs_with_formulas = [ + variable + for variable in inputs + if variable in variables and not variables[variable].is_input_variable() + ] for adult_index in [1, 2]: alt_simulation = simulation.get_branch(f"adult_{adult_index}_cg_rise") mask = adult_index_values == adult_index - for variable in simulation.tax_benefit_system.variables: - variable_data = simulation.tax_benefit_system.variables[variable] + for variable, variable_data in variables.items(): if variable not in inputs and not variable_data.is_input_variable(): alt_simulation.delete_arrays(variable) + # A variable given as an input for one period keeps only the + # periods it was given for; what was calculated for the others, + # this one included, is calculated again. + drop_inherited_values(alt_simulation, inputs_with_formulas) + # Raise long-term gains, which net capital gain and so the + # preferential rates read. Raising capital_gains instead reached + # adjusted gross income but not net capital gain, so it measured + # the rate on ordinary income except where taxable income was + # below net capital gain. The override holds any behavioral + # response at its value here. alt_simulation.set_input( - "capital_gains", + "long_term_capital_gains", period, - person("capital_gains", period) + mask * DELTA, + long_term_capital_gains + mask * rise, ) + if capital_gains_is_input: + alt_simulation.set_input( + "capital_gains", period, capital_gains + mask * rise + ) alt_person = alt_simulation.person - household_net_income_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