diff --git a/changelog.d/add-decomposed-marginal-tax-rates.added.md b/changelog.d/add-decomposed-marginal-tax-rates.added.md new file mode 100644 index 00000000000..553d9f16441 --- /dev/null +++ b/changelog.d/add-decomposed-marginal-tax-rates.added.md @@ -0,0 +1 @@ +Add decomposed marginal tax rate variables: federal_marginal_tax_rate, state_marginal_tax_rate, and fica_marginal_tax_rate. diff --git a/policyengine_us/tests/policy/baseline/household/federal_marginal_tax_rate.yaml b/policyengine_us/tests/policy/baseline/household/federal_marginal_tax_rate.yaml new file mode 100644 index 00000000000..42e72cb4e48 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/federal_marginal_tax_rate.yaml @@ -0,0 +1,85 @@ +- name: Federal MTR in 12% bracket + absolute_error_margin: 0.001 + period: 2026 + input: + age: 40 + employment_income: 30_000 + state_fips: 48 # TX + output: + federal_marginal_tax_rate: 0.12 + +- name: Federal MTR in 22% bracket + absolute_error_margin: 0.001 + period: 2026 + input: + age: 40 + employment_income: 100_000 + state_fips: 48 # TX + output: + federal_marginal_tax_rate: 0.22 + +- name: Federal MTR in 32% bracket + absolute_error_margin: 0.001 + period: 2026 + input: + age: 40 + employment_income: 250_000 + state_fips: 48 # TX + output: + federal_marginal_tax_rate: 0.32 + +- name: Federal MTR for married couple filing jointly + absolute_error_margin: 0.001 + period: 2026 + input: + people: + filer: + age: 40 + employment_income: 120_000 + spouse: + age: 38 + employment_income: 50_000 + tax_units: + tax_unit: + members: [filer, spouse] + filing_status: JOINT + spm_units: + spm_unit: + members: [filer, spouse] + households: + household: + members: [filer, spouse] + state_fips: 48 # TX + output: + # Joint filers: $170k combined, 22% bracket; default marginal_tax_rate_adults=2 + federal_marginal_tax_rate: [0.22, 0.22] + +- name: Federal MTR in two tax-unit household + absolute_error_margin: 0.001 + period: 2026 + input: + simulation.marginal_tax_rate_adults: 2 + people: + person1: + age: 43 + employment_income: 100_000 + person2: + age: 30 + employment_income: 40_000 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + spm_units: + spm_unit1: + members: [person1] + spm_unit2: + members: [person2] + households: + household: + members: [person1, person2] + state_fips: 48 # TX + output: + # person1 at $100k in 22% bracket, person2 at $40k in 12% bracket + federal_marginal_tax_rate: [0.22, 0.12] diff --git a/policyengine_us/tests/policy/baseline/household/fica_marginal_tax_rate.yaml b/policyengine_us/tests/policy/baseline/household/fica_marginal_tax_rate.yaml new file mode 100644 index 00000000000..72373619112 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/fica_marginal_tax_rate.yaml @@ -0,0 +1,89 @@ +- name: FICA MTR for single filer below SS wage base + absolute_error_margin: 0.001 + period: 2026 + input: + age: 40 + employment_income: 100_000 + state_fips: 48 # TX + output: + # 6.2% SS + 1.45% Medicare + fica_marginal_tax_rate: 0.0765 + +- name: FICA MTR above SS wage base but below Additional Medicare threshold + absolute_error_margin: 0.001 + period: 2026 + input: + age: 40 + employment_income: 190_000 + state_fips: 48 # TX + output: + # SS wage base is $186k in 2026, so only 1.45% Medicare applies + fica_marginal_tax_rate: 0.0145 + +- name: FICA MTR above Additional Medicare Tax threshold + absolute_error_margin: 0.001 + period: 2026 + input: + age: 40 + employment_income: 250_000 + state_fips: 48 # TX + output: + # 1.45% Medicare + 0.9% Additional Medicare Tax + fica_marginal_tax_rate: 0.0235 + +- name: FICA MTR for married couple filing jointly + absolute_error_margin: 0.001 + period: 2026 + input: + people: + filer: + age: 40 + employment_income: 80_000 + spouse: + age: 38 + employment_income: 50_000 + tax_units: + tax_unit: + members: [filer, spouse] + filing_status: JOINT + spm_units: + spm_unit: + members: [filer, spouse] + households: + household: + members: [filer, spouse] + state_fips: 48 # TX + output: + # Both below SS wage base ($186k), 7.65%; default marginal_tax_rate_adults=2 + fica_marginal_tax_rate: [0.0765, 0.0765] + +- name: FICA MTR in two tax-unit household with different thresholds + absolute_error_margin: 0.001 + period: 2026 + input: + simulation.marginal_tax_rate_adults: 2 + people: + person1: + age: 43 + employment_income: 100_000 + person2: + age: 30 + employment_income: 190_000 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + spm_units: + spm_unit1: + members: [person1] + spm_unit2: + members: [person2] + households: + household: + members: [person1, person2] + state_fips: 48 # TX + output: + # person1 at $100k: below SS wage base, 7.65% + # person2 at $190k: above SS wage base ($186k), only Medicare 1.45% + fica_marginal_tax_rate: [0.0765, 0.0145] diff --git a/policyengine_us/tests/policy/baseline/household/state_marginal_tax_rate.yaml b/policyengine_us/tests/policy/baseline/household/state_marginal_tax_rate.yaml new file mode 100644 index 00000000000..7594ed99f45 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/state_marginal_tax_rate.yaml @@ -0,0 +1,85 @@ +- name: State MTR is zero in TX (no state income tax) + absolute_error_margin: 0.001 + period: 2026 + input: + age: 40 + employment_income: 100_000 + state_fips: 48 # TX + output: + state_marginal_tax_rate: 0 + +- name: State MTR for CA filer in 9.3% bracket + absolute_error_margin: 0.01 + period: 2026 + input: + age: 40 + employment_income: 100_000 + state_fips: 6 # CA + output: + state_marginal_tax_rate: 0.093 + +- name: State MTR for NY filer in 5.9% bracket + absolute_error_margin: 0.01 + period: 2026 + input: + age: 40 + employment_income: 100_000 + state_fips: 36 # NY + output: + state_marginal_tax_rate: 0.059 + +- name: State MTR for married couple filing jointly in CA + absolute_error_margin: 0.01 + period: 2026 + input: + people: + filer: + age: 40 + employment_income: 120_000 + spouse: + age: 38 + employment_income: 50_000 + tax_units: + tax_unit: + members: [filer, spouse] + filing_status: JOINT + spm_units: + spm_unit: + members: [filer, spouse] + households: + household: + members: [filer, spouse] + state_fips: 6 # CA + output: + # CA joint filers at $170k combined — 9.3% bracket; default marginal_tax_rate_adults=2 + state_marginal_tax_rate: [0.093, 0.093] + +- name: State MTR in two tax-unit household in TX + absolute_error_margin: 0.001 + period: 2026 + input: + simulation.marginal_tax_rate_adults: 2 + people: + person1: + age: 43 + employment_income: 100_000 + person2: + age: 30 + employment_income: 40_000 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + spm_units: + spm_unit1: + members: [person1] + spm_unit2: + members: [person2] + households: + household: + members: [person1, person2] + state_fips: 48 # TX + output: + # TX has no state income tax — both should be 0 + state_marginal_tax_rate: [0, 0] diff --git a/policyengine_us/variables/household/federal_marginal_tax_rate.py b/policyengine_us/variables/household/federal_marginal_tax_rate.py new file mode 100644 index 00000000000..5d6da27865a --- /dev/null +++ b/policyengine_us/variables/household/federal_marginal_tax_rate.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.household.marginal_tax_rate_helpers import ( + compute_component_mtr, +) + + +class federal_marginal_tax_rate(Variable): + label = "federal marginal tax rate" + documentation = ( + "Marginal change in federal income tax per dollar of additional earnings." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = "/1" + + def formula(person, period, parameters): + return compute_component_mtr( + person, period, parameters, "income_tax", "federal_mtr" + ) diff --git a/policyengine_us/variables/household/fica_marginal_tax_rate.py b/policyengine_us/variables/household/fica_marginal_tax_rate.py new file mode 100644 index 00000000000..d434d34cdd3 --- /dev/null +++ b/policyengine_us/variables/household/fica_marginal_tax_rate.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.household.marginal_tax_rate_helpers import ( + compute_component_mtr, +) + + +class fica_marginal_tax_rate(Variable): + label = "FICA marginal tax rate" + documentation = ( + "Marginal change in employee payroll tax per dollar of additional earnings." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = "/1" + + def formula(person, period, parameters): + return compute_component_mtr( + person, period, parameters, "employee_payroll_tax", "fica_mtr" + ) diff --git a/policyengine_us/variables/household/marginal_tax_rate_helpers.py b/policyengine_us/variables/household/marginal_tax_rate_helpers.py new file mode 100644 index 00000000000..3ac38a5a532 --- /dev/null +++ b/policyengine_us/variables/household/marginal_tax_rate_helpers.py @@ -0,0 +1,59 @@ +from policyengine_us.model_api import * + + +def compute_component_mtr(person, period, parameters, tax_variable, branch_prefix): + """Compute the marginal tax rate for a specific tax component. + + Uses the same counterfactual branch pattern as marginal_tax_rate: + perturbs earnings by delta and measures the change in the given + tax_unit-level tax variable, aggregated to the household level + for consistency with how marginal_tax_rate uses household_net_income. + + Args: + person: The person entity. + period: The simulation period. + parameters: The parameter tree. + tax_variable: Name of the tax_unit variable to measure + (e.g. "income_tax", "state_income_tax", "employee_payroll_tax"). + branch_prefix: Unique prefix for branch names to avoid conflicts. + + Returns: + Array of marginal tax rates per person. + """ + is_head = person("is_tax_unit_head", period) + base_tax = person.household.sum(person.tax_unit(tax_variable, period) * is_head) + delta = parameters(period).simulation.marginal_tax_rate_delta + adult_count = parameters(period).simulation.marginal_tax_rate_adults + sim = person.simulation + mtr_values = np.zeros(person.count, dtype=np.float32) + adult_indexes = person("adult_earnings_index", period) + employment_income = person("employment_income", period) + self_employment_income = person("self_employment_income", period) + emp_self_emp_ratio = person("emp_self_emp_ratio", period) + + for adult_index in range(1, 1 + adult_count): + branch_name = f"{branch_prefix}_for_adult_{adult_index}" + alt_sim = sim.get_branch(branch_name) + for variable in sim.tax_benefit_system.variables: + if variable not in sim.input_variables or variable == "employment_income": + alt_sim.delete_arrays(variable) + mask = adult_index == adult_indexes + alt_sim.set_input( + "employment_income", + period, + employment_income + mask * delta * emp_self_emp_ratio, + ) + alt_sim.set_input( + "self_employment_income", + period, + self_employment_income + mask * delta * (1 - emp_self_emp_ratio), + ) + alt_person = alt_sim.person + alt_is_head = alt_person("is_tax_unit_head", period) + alt_tax = alt_person.household.sum( + alt_person.tax_unit(tax_variable, period) * alt_is_head + ) + increase = alt_tax - base_tax + mtr_values += where(mask, increase / delta, 0) + del sim.branches[branch_name] + return mtr_values diff --git a/policyengine_us/variables/household/state_marginal_tax_rate.py b/policyengine_us/variables/household/state_marginal_tax_rate.py new file mode 100644 index 00000000000..68071d3516f --- /dev/null +++ b/policyengine_us/variables/household/state_marginal_tax_rate.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.household.marginal_tax_rate_helpers import ( + compute_component_mtr, +) + + +class state_marginal_tax_rate(Variable): + label = "state marginal tax rate" + documentation = ( + "Marginal change in state income tax per dollar of additional earnings." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = "/1" + + def formula(person, period, parameters): + return compute_component_mtr( + person, period, parameters, "state_income_tax", "state_mtr" + )