Skip to content
Open
9 changes: 7 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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 \
Expand Down
1 change: 1 addition & 0 deletions changelog.d/fix-cg-mtr-measurement.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
454 changes: 454 additions & 0 deletions policyengine_us/tests/core/test_capital_gains_mtr_measurement.py

Large diffs are not rendered by default.

43 changes: 42 additions & 1 deletion policyengine_us/tests/core/test_override_branches.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -49,6 +51,7 @@
drop_inherited_values,
get_branch_for_period,
get_override_branch,
has_input,
)

YEAR = 2026
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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
26 changes: 24 additions & 2 deletions policyengine_us/tools/period_branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading