From 097b4eed5827046a6b04d8c22b0626d7a5ec1acd Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Tue, 17 Mar 2026 11:45:03 -0400 Subject: [PATCH 1/5] Add decomposed marginal tax rate variables (federal, state, FICA) Closes #7790 Co-Authored-By: Claude Opus 4.6 --- ...add-decomposed-marginal-tax-rates.added.md | 1 + .../marginal_tax_rates_by_component.yaml | 40 +++++ .../marginal_tax_rates_by_component.py | 145 ++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 changelog.d/add-decomposed-marginal-tax-rates.added.md create mode 100644 policyengine_us/tests/policy/baseline/household/marginal_tax_rates_by_component.yaml create mode 100644 policyengine_us/variables/household/marginal_tax_rates_by_component.py 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/marginal_tax_rates_by_component.yaml b/policyengine_us/tests/policy/baseline/household/marginal_tax_rates_by_component.yaml new file mode 100644 index 00000000000..b810b08ddbd --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/marginal_tax_rates_by_component.yaml @@ -0,0 +1,40 @@ +- name: Federal MTR for single filer 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: + federal_marginal_tax_rate: 0.22 + +- 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: 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: + fica_marginal_tax_rate: 0.0765 + +- name: FICA MTR above SS wage base (Medicare + Additional Medicare Tax) + absolute_error_margin: 0.001 + period: 2026 + input: + age: 40 + employment_income: 200_000 + state_fips: 48 # TX + output: + # 1.45% Medicare + 0.9% Additional Medicare Tax (threshold is $200k) + fica_marginal_tax_rate: 0.0235 diff --git a/policyengine_us/variables/household/marginal_tax_rates_by_component.py b/policyengine_us/variables/household/marginal_tax_rates_by_component.py new file mode 100644 index 00000000000..a34febc4829 --- /dev/null +++ b/policyengine_us/variables/household/marginal_tax_rates_by_component.py @@ -0,0 +1,145 @@ +from policyengine_us.model_api import * + + +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): + base_tax = person.tax_unit("income_tax", period) + 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): + alt_sim = sim.get_branch(f"federal_mtr_for_adult_{adult_index}") + 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_tax = alt_person.tax_unit("income_tax", period) + increase = alt_tax - base_tax + mtr_values += where(mask, increase / delta, 0) + del sim.branches[f"federal_mtr_for_adult_{adult_index}"] + return mtr_values + + +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): + base_tax = person.tax_unit("state_income_tax", period) + 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): + alt_sim = sim.get_branch(f"state_mtr_for_adult_{adult_index}") + 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_tax = alt_person.tax_unit("state_income_tax", period) + increase = alt_tax - base_tax + mtr_values += where(mask, increase / delta, 0) + del sim.branches[f"state_mtr_for_adult_{adult_index}"] + return mtr_values + + +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): + base_tax = person.tax_unit("employee_payroll_tax", period) + 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): + alt_sim = sim.get_branch(f"fica_mtr_for_adult_{adult_index}") + 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_tax = alt_person.tax_unit("employee_payroll_tax", period) + increase = alt_tax - base_tax + mtr_values += where(mask, increase / delta, 0) + del sim.branches[f"fica_mtr_for_adult_{adult_index}"] + return mtr_values From f79cc59ab0b9c0473788339074e28a074d270afd Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Tue, 17 Mar 2026 11:48:12 -0400 Subject: [PATCH 2/5] Split into one variable per .py file with separate .yaml tests Co-Authored-By: Claude Opus 4.6 --- .../household/federal_marginal_tax_rate.yaml | 9 ++ ...onent.yaml => fica_marginal_tax_rate.yaml} | 20 --- .../household/state_marginal_tax_rate.yaml | 9 ++ .../household/federal_marginal_tax_rate.py | 49 ++++++ .../household/fica_marginal_tax_rate.py | 49 ++++++ .../marginal_tax_rates_by_component.py | 145 ------------------ .../household/state_marginal_tax_rate.py | 49 ++++++ 7 files changed, 165 insertions(+), 165 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/household/federal_marginal_tax_rate.yaml rename policyengine_us/tests/policy/baseline/household/{marginal_tax_rates_by_component.yaml => fica_marginal_tax_rate.yaml} (54%) create mode 100644 policyengine_us/tests/policy/baseline/household/state_marginal_tax_rate.yaml create mode 100644 policyengine_us/variables/household/federal_marginal_tax_rate.py create mode 100644 policyengine_us/variables/household/fica_marginal_tax_rate.py delete mode 100644 policyengine_us/variables/household/marginal_tax_rates_by_component.py create mode 100644 policyengine_us/variables/household/state_marginal_tax_rate.py 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..d7b0953a5cd --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/federal_marginal_tax_rate.yaml @@ -0,0 +1,9 @@ +- name: Federal MTR for single filer 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: + federal_marginal_tax_rate: 0.22 diff --git a/policyengine_us/tests/policy/baseline/household/marginal_tax_rates_by_component.yaml b/policyengine_us/tests/policy/baseline/household/fica_marginal_tax_rate.yaml similarity index 54% rename from policyengine_us/tests/policy/baseline/household/marginal_tax_rates_by_component.yaml rename to policyengine_us/tests/policy/baseline/household/fica_marginal_tax_rate.yaml index b810b08ddbd..b5948540edd 100644 --- a/policyengine_us/tests/policy/baseline/household/marginal_tax_rates_by_component.yaml +++ b/policyengine_us/tests/policy/baseline/household/fica_marginal_tax_rate.yaml @@ -1,23 +1,3 @@ -- name: Federal MTR for single filer 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: - federal_marginal_tax_rate: 0.22 - -- 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: FICA MTR for single filer below SS wage base absolute_error_margin: 0.001 period: 2026 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..5d7f02031a6 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/state_marginal_tax_rate.yaml @@ -0,0 +1,9 @@ +- 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 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..638813ffab5 --- /dev/null +++ b/policyengine_us/variables/household/federal_marginal_tax_rate.py @@ -0,0 +1,49 @@ +from policyengine_us.model_api import * + + +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): + base_tax = person.tax_unit("income_tax", period) + 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): + alt_sim = sim.get_branch(f"federal_mtr_for_adult_{adult_index}") + 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_tax = alt_person.tax_unit("income_tax", period) + increase = alt_tax - base_tax + mtr_values += where(mask, increase / delta, 0) + del sim.branches[f"federal_mtr_for_adult_{adult_index}"] + return mtr_values 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..c8368d78f32 --- /dev/null +++ b/policyengine_us/variables/household/fica_marginal_tax_rate.py @@ -0,0 +1,49 @@ +from policyengine_us.model_api import * + + +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): + base_tax = person.tax_unit("employee_payroll_tax", period) + 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): + alt_sim = sim.get_branch(f"fica_mtr_for_adult_{adult_index}") + 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_tax = alt_person.tax_unit("employee_payroll_tax", period) + increase = alt_tax - base_tax + mtr_values += where(mask, increase / delta, 0) + del sim.branches[f"fica_mtr_for_adult_{adult_index}"] + return mtr_values diff --git a/policyengine_us/variables/household/marginal_tax_rates_by_component.py b/policyengine_us/variables/household/marginal_tax_rates_by_component.py deleted file mode 100644 index a34febc4829..00000000000 --- a/policyengine_us/variables/household/marginal_tax_rates_by_component.py +++ /dev/null @@ -1,145 +0,0 @@ -from policyengine_us.model_api import * - - -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): - base_tax = person.tax_unit("income_tax", period) - 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): - alt_sim = sim.get_branch(f"federal_mtr_for_adult_{adult_index}") - 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_tax = alt_person.tax_unit("income_tax", period) - increase = alt_tax - base_tax - mtr_values += where(mask, increase / delta, 0) - del sim.branches[f"federal_mtr_for_adult_{adult_index}"] - return mtr_values - - -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): - base_tax = person.tax_unit("state_income_tax", period) - 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): - alt_sim = sim.get_branch(f"state_mtr_for_adult_{adult_index}") - 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_tax = alt_person.tax_unit("state_income_tax", period) - increase = alt_tax - base_tax - mtr_values += where(mask, increase / delta, 0) - del sim.branches[f"state_mtr_for_adult_{adult_index}"] - return mtr_values - - -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): - base_tax = person.tax_unit("employee_payroll_tax", period) - 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): - alt_sim = sim.get_branch(f"fica_mtr_for_adult_{adult_index}") - 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_tax = alt_person.tax_unit("employee_payroll_tax", period) - increase = alt_tax - base_tax - mtr_values += where(mask, increase / delta, 0) - del sim.branches[f"fica_mtr_for_adult_{adult_index}"] - 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..f8d3e5a981b --- /dev/null +++ b/policyengine_us/variables/household/state_marginal_tax_rate.py @@ -0,0 +1,49 @@ +from policyengine_us.model_api import * + + +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): + base_tax = person.tax_unit("state_income_tax", period) + 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): + alt_sim = sim.get_branch(f"state_mtr_for_adult_{adult_index}") + 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_tax = alt_person.tax_unit("state_income_tax", period) + increase = alt_tax - base_tax + mtr_values += where(mask, increase / delta, 0) + del sim.branches[f"state_mtr_for_adult_{adult_index}"] + return mtr_values From 7be72404d5970ce9ca0b7d9e0c11a7f812ae02f9 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Tue, 17 Mar 2026 11:56:17 -0400 Subject: [PATCH 3/5] Add more tests: bracket coverage, state MTR for CA/NY, FICA thresholds Co-Authored-By: Claude Opus 4.6 --- .../household/federal_marginal_tax_rate.yaml | 22 ++++++++++++++++++- .../household/fica_marginal_tax_rate.yaml | 18 ++++++++++++--- .../household/state_marginal_tax_rate.yaml | 20 +++++++++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) 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 index d7b0953a5cd..e3822aaf292 100644 --- a/policyengine_us/tests/policy/baseline/household/federal_marginal_tax_rate.yaml +++ b/policyengine_us/tests/policy/baseline/household/federal_marginal_tax_rate.yaml @@ -1,4 +1,14 @@ -- name: Federal MTR for single filer in TX (no state income tax) +- 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: @@ -7,3 +17,13 @@ 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 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 index b5948540edd..3238b1ef282 100644 --- a/policyengine_us/tests/policy/baseline/household/fica_marginal_tax_rate.yaml +++ b/policyengine_us/tests/policy/baseline/household/fica_marginal_tax_rate.yaml @@ -6,15 +6,27 @@ 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 (Medicare + Additional Medicare Tax) +- name: FICA MTR above SS wage base but below Additional Medicare threshold absolute_error_margin: 0.001 period: 2026 input: age: 40 - employment_income: 200_000 + employment_income: 190_000 state_fips: 48 # TX output: - # 1.45% Medicare + 0.9% Additional Medicare Tax (threshold is $200k) + # 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 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 index 5d7f02031a6..8ac23623507 100644 --- a/policyengine_us/tests/policy/baseline/household/state_marginal_tax_rate.yaml +++ b/policyengine_us/tests/policy/baseline/household/state_marginal_tax_rate.yaml @@ -7,3 +7,23 @@ 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 From c3cf90c31b25e66f331e671b3f457e614af57f50 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Tue, 17 Mar 2026 12:00:48 -0400 Subject: [PATCH 4/5] Extract compute_component_mtr helper to eliminate code duplication Co-Authored-By: Claude Opus 4.6 --- .../household/federal_marginal_tax_rate.py | 41 +++----------- .../household/fica_marginal_tax_rate.py | 41 +++----------- .../household/marginal_tax_rate_helpers.py | 54 +++++++++++++++++++ .../household/state_marginal_tax_rate.py | 41 +++----------- 4 files changed, 72 insertions(+), 105 deletions(-) create mode 100644 policyengine_us/variables/household/marginal_tax_rate_helpers.py diff --git a/policyengine_us/variables/household/federal_marginal_tax_rate.py b/policyengine_us/variables/household/federal_marginal_tax_rate.py index 638813ffab5..5d6da27865a 100644 --- a/policyengine_us/variables/household/federal_marginal_tax_rate.py +++ b/policyengine_us/variables/household/federal_marginal_tax_rate.py @@ -1,4 +1,7 @@ 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): @@ -12,38 +15,6 @@ class federal_marginal_tax_rate(Variable): unit = "/1" def formula(person, period, parameters): - base_tax = person.tax_unit("income_tax", period) - 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): - alt_sim = sim.get_branch(f"federal_mtr_for_adult_{adult_index}") - 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_tax = alt_person.tax_unit("income_tax", period) - increase = alt_tax - base_tax - mtr_values += where(mask, increase / delta, 0) - del sim.branches[f"federal_mtr_for_adult_{adult_index}"] - return mtr_values + 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 index c8368d78f32..d434d34cdd3 100644 --- a/policyengine_us/variables/household/fica_marginal_tax_rate.py +++ b/policyengine_us/variables/household/fica_marginal_tax_rate.py @@ -1,4 +1,7 @@ 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): @@ -12,38 +15,6 @@ class fica_marginal_tax_rate(Variable): unit = "/1" def formula(person, period, parameters): - base_tax = person.tax_unit("employee_payroll_tax", period) - 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): - alt_sim = sim.get_branch(f"fica_mtr_for_adult_{adult_index}") - 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_tax = alt_person.tax_unit("employee_payroll_tax", period) - increase = alt_tax - base_tax - mtr_values += where(mask, increase / delta, 0) - del sim.branches[f"fica_mtr_for_adult_{adult_index}"] - return mtr_values + 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..e8cc1b3a5a2 --- /dev/null +++ b/policyengine_us/variables/household/marginal_tax_rate_helpers.py @@ -0,0 +1,54 @@ +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. + + 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. + """ + base_tax = person.tax_unit(tax_variable, period) + 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_tax = alt_person.tax_unit(tax_variable, period) + 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 index f8d3e5a981b..68071d3516f 100644 --- a/policyengine_us/variables/household/state_marginal_tax_rate.py +++ b/policyengine_us/variables/household/state_marginal_tax_rate.py @@ -1,4 +1,7 @@ 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): @@ -12,38 +15,6 @@ class state_marginal_tax_rate(Variable): unit = "/1" def formula(person, period, parameters): - base_tax = person.tax_unit("state_income_tax", period) - 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): - alt_sim = sim.get_branch(f"state_mtr_for_adult_{adult_index}") - 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_tax = alt_person.tax_unit("state_income_tax", period) - increase = alt_tax - base_tax - mtr_values += where(mask, increase / delta, 0) - del sim.branches[f"state_mtr_for_adult_{adult_index}"] - return mtr_values + return compute_component_mtr( + person, period, parameters, "state_income_tax", "state_mtr" + ) From be7934b762e202a9d1eb48823b557c8b82d9107d Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Tue, 17 Mar 2026 12:17:19 -0400 Subject: [PATCH 5/5] Aggregate taxes to household level and add integration tests - Use person.household.sum() with is_tax_unit_head filter for consistency with how marginal_tax_rate uses household_net_income - Add married couple and multi-tax-unit household tests for each variable (15 tests total) Co-Authored-By: Claude Opus 4.6 --- .../household/federal_marginal_tax_rate.yaml | 56 ++++++++++++++++++ .../household/fica_marginal_tax_rate.yaml | 57 +++++++++++++++++++ .../household/state_marginal_tax_rate.yaml | 56 ++++++++++++++++++ .../household/marginal_tax_rate_helpers.py | 11 +++- 4 files changed, 177 insertions(+), 3 deletions(-) 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 index e3822aaf292..42e72cb4e48 100644 --- a/policyengine_us/tests/policy/baseline/household/federal_marginal_tax_rate.yaml +++ b/policyengine_us/tests/policy/baseline/household/federal_marginal_tax_rate.yaml @@ -27,3 +27,59 @@ 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 index 3238b1ef282..72373619112 100644 --- a/policyengine_us/tests/policy/baseline/household/fica_marginal_tax_rate.yaml +++ b/policyengine_us/tests/policy/baseline/household/fica_marginal_tax_rate.yaml @@ -30,3 +30,60 @@ 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 index 8ac23623507..7594ed99f45 100644 --- a/policyengine_us/tests/policy/baseline/household/state_marginal_tax_rate.yaml +++ b/policyengine_us/tests/policy/baseline/household/state_marginal_tax_rate.yaml @@ -27,3 +27,59 @@ 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/marginal_tax_rate_helpers.py b/policyengine_us/variables/household/marginal_tax_rate_helpers.py index e8cc1b3a5a2..3ac38a5a532 100644 --- a/policyengine_us/variables/household/marginal_tax_rate_helpers.py +++ b/policyengine_us/variables/household/marginal_tax_rate_helpers.py @@ -6,7 +6,8 @@ def compute_component_mtr(person, period, parameters, tax_variable, branch_prefi 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. + 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. @@ -19,7 +20,8 @@ def compute_component_mtr(person, period, parameters, tax_variable, branch_prefi Returns: Array of marginal tax rates per person. """ - base_tax = person.tax_unit(tax_variable, period) + 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 @@ -47,7 +49,10 @@ def compute_component_mtr(person, period, parameters, tax_variable, branch_prefi self_employment_income + mask * delta * (1 - emp_self_emp_ratio), ) alt_person = alt_sim.person - alt_tax = alt_person.tax_unit(tax_variable, period) + 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]