Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/add-marginal-rates.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add TAXSIM-compatible marginal tax rate computation (frate, srate, ficar) via wage perturbation.
2 changes: 1 addition & 1 deletion dashboard/public/config-data.json
Original file line number Diff line number Diff line change
Expand Up @@ -280,5 +280,5 @@
}
]
},
"lastUpdated": "2026-03-06T19:56:21.560Z"
"lastUpdated": "2026-03-17T17:29:16.416Z"
}
47 changes: 42 additions & 5 deletions dashboard/src/components/DocumentationContent.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import {
getMultipleVariables
} from '../constants';

const NON_LINKABLE = ['na_pe', 'taxsimid', 'get_year'];
const NON_LINKABLE = ['na_pe', 'taxsimid', 'get_year', 'marginal_rate_computed'];
const ADJUSTED_VARIABLES = ['federal_marginal_tax_rate', 'state_marginal_tax_rate', 'fica_marginal_tax_rate'];

const LANG_LABELS = {
cli: 'CLI',
Expand Down Expand Up @@ -276,7 +277,13 @@ policyengine_versions()
}

if (mapping.implemented && mapping.policyengine && !NON_LINKABLE.includes(mapping.policyengine)) {
return <VariableLink href={buildGithubUrl(mapping.policyengine)} label={mapping.policyengine} />;
const isAdjusted = ADJUSTED_VARIABLES.includes(mapping.policyengine);
return (
<span>
<VariableLink href={buildGithubUrl(mapping.policyengine)} label={mapping.policyengine} />
{isAdjusted && <span className="text-blue-600 font-bold" title="Adjusted to match TAXSIM methodology"> *</span>}
</span>
);
}

return mapping.policyengine || 'N/A';
Expand Down Expand Up @@ -333,11 +340,13 @@ policyengine_versions()
'actc': { implemented: true, variable: 'refundable_ctc' }, // Implemented as refundable CTC
'staxbc': { implemented: true, variable: 'state_income_tax_before_non_refundable_credits' },

// Marginal rates: based on PE variables, adjusted to match TAXSIM methodology
'frate': { implemented: true, variable: 'federal_marginal_tax_rate' },
'srate': { implemented: true, variable: 'state_marginal_tax_rate' },
'ficar': { implemented: true, variable: 'fica_marginal_tax_rate' },

// Not implemented: variable = 'na_pe' means not available in PolicyEngine
'fica': { implemented: false, variable: 'na_pe' },
'frate': { implemented: false, variable: 'na_pe' },
'srate': { implemented: false, variable: 'na_pe' },
'ficar': { implemented: false, variable: 'na_pe' },
'v15': { implemented: false, variable: 'na_pe' },
'v16': { implemented: false, variable: 'na_pe' },
'v20': { implemented: false, variable: 'na_pe' },
Expand Down Expand Up @@ -666,6 +675,29 @@ results = runner.run()`
})}
</div>

<div className="bg-blue-50 rounded-lg p-5 mb-4 border border-blue-200">
<div className="flex items-center gap-3 mb-2">
<code className="text-sm font-semibold text-gray-900">Marginal tax rates (frate, srate, ficar)</code>
</div>
<p className="text-sm text-gray-600 mb-3">
Marginal rates are based on PolicyEngine&apos;s <code className="bg-white px-1.5 py-0.5 rounded text-[13px]">federal_marginal_tax_rate</code>,{' '}
<code className="bg-white px-1.5 py-0.5 rounded text-[13px]">state_marginal_tax_rate</code>, and{' '}
<code className="bg-white px-1.5 py-0.5 rounded text-[13px]">fica_marginal_tax_rate</code> variables,
with adjustments to match TAXSIM-35 methodology:
</p>
<ul className="text-sm text-gray-600 space-y-1 ml-4 list-disc mb-3">
<li>Perturbs <strong>employment income (wages) only</strong> &mdash; self-employment income is not perturbed</li>
<li>Splits the perturbation between primary and spouse earners <strong>proportionally to their wage share</strong> (weighted average earnings, matching TAXSIM <code className="bg-white px-1.5 py-0.5 rounded text-[13px]">mtr=11</code>)</li>
<li>Measures the change in each tax component independently: federal income tax (<code className="bg-white px-1.5 py-0.5 rounded text-[13px]">frate</code>), state income tax (<code className="bg-white px-1.5 py-0.5 rounded text-[13px]">srate</code>), and employee payroll tax (<code className="bg-white px-1.5 py-0.5 rounded text-[13px]">ficar</code>)</li>
<li>Returns rates as <strong>percentages</strong> (e.g., 22.0 for 22%)</li>
</ul>
<p className="text-sm text-gray-500">
<strong>Note:</strong> PolicyEngine&apos;s upstream MTR variables perturb all earned income (including self-employment) and use a different delta.
The emulator adjusts the computation to match TAXSIM&apos;s wage-only perturbation and uses a $100 delta
(vs TAXSIM&apos;s $0.01 with Fortran float64) for numerical stability with PolicyEngine&apos;s float32 internals.
</p>
</div>

<div className="bg-gray-50 rounded-lg p-5 mb-4">
<div className="flex items-center gap-3 mb-2">
<code className="text-sm font-semibold text-gray-900">logs</code>
Expand Down Expand Up @@ -895,6 +927,11 @@ policyengine-taxsim policyengine input.csv --disable-salt --assume-w2-wages --lo
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{activeTab === 'input' ? renderInputVariables() : renderOutputVariables()}
</div>
{activeTab === 'output' && (
<p className="text-xs text-gray-500 mt-2 ml-1">
<span className="text-blue-600 font-bold">*</span> Adjusted to match TAXSIM methodology: perturbs employment income (wages) only, splits perturbation proportionally between spouses, and uses a $100 delta for numerical stability with PolicyEngine&apos;s float32 internals.
</p>
)}
</>}
</section>
)}
Expand Down
6 changes: 3 additions & 3 deletions dashboard/src/constants/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export const OUTPUT_VARIABLES = [
{ code: 'sctc', name: 'State child tax credit', policyengine: 'state_ctc' },
{ code: 'sptcr', name: 'State property tax credit', policyengine: 'state_property_tax_credit' },
{ code: 'samt', name: 'State alternative minimum tax', policyengine: 'state_amt' },
{ code: 'srate', name: 'State marginal rate', policyengine: null },
{ code: 'srate', name: 'State marginal rate', policyengine: 'state_marginal_tax_rate' },

// Additional Federal Results (v42-v46)
{ code: 'v42', name: 'Earned Self-Employment Income for FICA', policyengine: 'self_employment_income' },
Expand All @@ -152,8 +152,8 @@ export const OUTPUT_VARIABLES = [
// Additional Outputs (moved to end for proper section ordering)
{ code: 'fica', name: 'FICA (OADSI and HI, sum of employee AND employer including Additional Medicare Tax)', policyengine: null },
{ code: 'tfica', name: 'Taxpayer liability for FICA', policyengine: 'taxsim_tfica' },
{ code: 'frate', name: 'Federal marginal rate', policyengine: null },
{ code: 'ficar', name: 'FICA rate', policyengine: null },
{ code: 'frate', name: 'Federal marginal rate', policyengine: 'federal_marginal_tax_rate' },
{ code: 'ficar', name: 'FICA rate', policyengine: 'fica_marginal_tax_rate' },
];

// Input fields from TAXSIM
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
taxsimid,year,state,fiitax,siitax,fica,tfica,v10,v11,v12,v13,v14,v17,qbid,niit,v18,v19,v22,v24,v25,v26,v27,v28,v29,v32,v34,v35,v36,v37,v38,v39,v40,v42,v43,v44,cares
6,2023,39,3956.0,2486.7,0.0,6196.5,81000.0,0.0,0.0,27700.0,0.0,2486.7,0.0,0.0,53300.0,5956.0,2000.0,0.0,0.0,81000.0,0.0,5956.0,6196.5,81000.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1174.5,0.0
taxsimid,year,state,fiitax,siitax,fica,tfica,v10,v11,v12,v13,v14,v17,qbid,niit,v18,v19,v22,v24,v25,v26,v27,v28,v29,v32,v34,v35,v36,v37,v38,v39,v40,v42,v43,v44,cares,srate,frate
6,2023,39,3956.0,2486.7,0.0,6196.5,81000.0,0.0,0.0,27700.0,0.0,2486.7,0.0,0.0,53300.0,5956.0,2000.0,0.0,0.0,81000.0,0.0,5956.0,6196.5,81000.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1174.5,0.0,3.0701,12.0
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
taxsimid,year,state,fiitax,siitax,fica,tfica,v10,v11,v12,v13,v14,v17,qbid,niit,v18,v19,v22,v24,v25,v26,v27,v28,v29,v32,v34,v35,v36,v37,v38,v39,v40,v42,v43,v44,cares
6,2023,39,4945.0,2486.7,0.0,6196.5,81000.0,0.0,0.0,20800.0,0.0,2486.7,0.0,0.0,60200.0,6945.0,2000.0,0.0,0.0,81000.0,0.0,6945.0,6196.5,81000.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1174.5,0.0
taxsimid,year,state,fiitax,siitax,fica,tfica,v10,v11,v12,v13,v14,v17,qbid,niit,v18,v19,v22,v24,v25,v26,v27,v28,v29,v32,v34,v35,v36,v37,v38,v39,v40,v42,v43,v44,cares,srate,frate
6,2023,39,4945.0,2486.7,0.0,6196.5,81000.0,0.0,0.0,20800.0,0.0,2486.7,0.0,0.0,60200.0,6945.0,2000.0,0.0,0.0,81000.0,0.0,6945.0,6196.5,81000.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1174.5,0.0,3.0701,22.0
6 changes: 3 additions & 3 deletions output/policyengine_taxsim_joint_household_output.csv
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
taxsimid,year,state,fiitax,siitax,fica,tfica,v10,v11,v12,v13,v14,v17,qbid,niit,v18,v19,v22,v24,v25,v26,v27,v28,v29,v32,v34,v35,v36,v37,v38,v39,v40,v42,v43,v44,cares
999,2023,44,1716.0,0.0,0.0,6043.5,79000.0,0.0,0.0,27700.0,0.0,1927.2,0.0,0.0,51300.0,5716.0,4000.0,0.0,0.0,79000.0,0.0,5716.0,6043.5,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1145.5,0.0
11,2023,44,1476.0,0.0,0.0,5890.5,77000.0,0.0,0.0,27700.0,0.0,1927.2,0.0,0.0,49300.0,5476.0,4000.0,0.0,0.0,77000.0,0.0,5476.0,5890.5,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1116.5,0.0
taxsimid,year,state,fiitax,siitax,fica,tfica,v10,v11,v12,v13,v14,v17,qbid,niit,v18,v19,v22,v24,v25,v26,v27,v28,v29,v32,v34,v35,v36,v37,v38,v39,v40,v42,v43,v44,cares,frate,srate
999,2023,44,1716.0,0.0,0.0,6043.5,79000.0,0.0,0.0,27700.0,0.0,1927.2,0.0,0.0,51300.0,5716.0,4000.0,0.0,0.0,79000.0,0.0,5716.0,6043.5,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1145.5,0.0,12.0,0.0
11,2023,44,1476.0,0.0,0.0,5890.5,77000.0,0.0,0.0,27700.0,0.0,1927.2,0.0,0.0,49300.0,5476.0,4000.0,0.0,0.0,77000.0,0.0,5476.0,5890.5,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1116.5,0.0,12.0,0.0
6 changes: 3 additions & 3 deletions output/policyengine_taxsim_single_household_output.csv
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
taxsimid,year,state,fiitax,siitax,fica,tfica,v10,v11,v12,v13,v14,v17,qbid,niit,v18,v19,v22,v24,v25,v26,v27,v28,v29,v32,v34,v35,v36,v37,v38,v39,v40,v42,v43,v44,cares
999,2021,3,2775.0,1008.87,0.0,3748.5,49000.0,0.0,0.0,12550.0,0.0,1020.0,0.0,0.0,36450.0,4175.0,0.0,0.0,0.0,49000.0,0.0,4175.0,3748.5,49000.0,12550.0,1020.0,36450.0,0.0,0.0,0.0,0.0,0.0,0.0,710.5,1400.0
11,2021,3,2535.0,942.07,0.0,3595.5,47000.0,0.0,0.0,12550.0,0.0,1020.0,0.0,0.0,34450.0,3935.0,0.0,0.0,0.0,47000.0,0.0,3935.0,3595.5,47000.0,12550.0,1020.0,34450.0,0.0,0.0,0.0,0.0,0.0,0.0,681.5,1400.0
taxsimid,year,state,fiitax,siitax,fica,tfica,v10,v11,v12,v13,v14,v17,qbid,niit,v18,v19,v22,v24,v25,v26,v27,v28,v29,v32,v34,v35,v36,v37,v38,v39,v40,v42,v43,v44,cares,frate,srate
999,2021,3,2775.0,1008.87,0.0,3748.5,49000.0,0.0,0.0,12550.0,0.0,1020.0,0.0,0.0,36450.0,4175.0,0.0,0.0,0.0,49000.0,0.0,4175.0,3748.5,49000.0,12550.0,1020.0,36450.0,0.0,0.0,0.0,0.0,0.0,0.0,710.5,1400.0,12.0,3.34
11,2021,3,2535.0,942.07,0.0,3595.5,47000.0,0.0,0.0,12550.0,0.0,1020.0,0.0,0.0,34450.0,3935.0,0.0,0.0,0.0,47000.0,0.0,3935.0,3595.5,47000.0,12550.0,1020.0,34450.0,0.0,0.0,0.0,0.0,0.0,0.0,681.5,1400.0,12.0,3.34
10 changes: 5 additions & 5 deletions policyengine_taxsim/config/variable_mappings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ policyengine_to_taxsim:
full_text_group: "Basic Output"
group_column: 1
frate:
variable: na_pe
implemented: false
variable: marginal_rate_computed
implemented: true
idtl:
- standard: 0
- full: 2
Expand All @@ -77,8 +77,8 @@ policyengine_to_taxsim:
full_text_group: "Marginal Rates wrt Weighted Average Earnings"
group_column: 1
srate:
variable: na_pe
implemented: false
variable: marginal_rate_computed
implemented: true
idtl:
- standard: 0
- full: 2
Expand All @@ -88,7 +88,7 @@ policyengine_to_taxsim:
full_text_group: "Marginal Rates wrt Weighted Average Earnings"
group_column: 1
ficar:
variable: na_pe
variable: marginal_rate_computed
implemented: false
idtl:
- standard: 0
Expand Down
1 change: 0 additions & 1 deletion policyengine_taxsim/core/input_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@


def add_additional_units(state, year, situation, taxsim_vars):

additional_tax_units_config = load_variable_mappings()["taxsim_to_policyengine"][
"household_situation"
]["additional_tax_units"]
Expand Down
84 changes: 84 additions & 0 deletions policyengine_taxsim/core/marginal_rates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""TAXSIM-compatible marginal tax rate computation.

Matches TAXSIM-35 methodology:
- Perturbs wages split proportionally between primary and spouse
(weighted average earnings)
- Only perturbs employment income (wages), not self-employment
- Returns rates as percentages (22.0 for 22%)

Note: TAXSIM uses $0.01 delta with Fortran float64 precision.
PolicyEngine uses float32 internally, so we use $100 delta to
avoid precision loss while remaining small enough to stay within
a single tax bracket for most filers.
"""

import copy
from policyengine_us import Simulation


DELTA = 100.0 # $100: large enough for float32 precision, small for bracket safety


def compute_marginal_rates_single(simulation, situation, year, disable_salt):
"""Compute marginal rates for a single household (exe/output_mapper path).

Args:
simulation: Base PolicyEngine Simulation (already computed).
situation: The situation dict used to create the simulation.
year: Tax year string.
disable_salt: Whether SALT deduction is disabled.

Returns:
dict with 'frate', 'srate', 'ficar' as percentage values.
"""
people = situation["people"]

# Get base tax values from the existing simulation
# frate must match fiitax definition: income_tax + additional_medicare_tax
base_federal = float(simulation.calculate("income_tax", period=year)[0]) + float(
simulation.calculate("additional_medicare_tax", period=year)[0]
)
base_state = float(simulation.calculate("state_income_tax", period=year)[0])

# Get current wages
pwages = float(people["you"].get("employment_income", {}).get(year, 0))
swages = 0.0
if "your partner" in people:
swages = float(people["your partner"].get("employment_income", {}).get(year, 0))

total_wages = pwages + swages

# Compute proportional split (TAXSIM: weighted average earnings)
if total_wages > 0:
p_share = pwages / total_wages
s_share = swages / total_wages
else:
p_share = 0.5
s_share = 0.5 if "your partner" in people else 0.0

# Create perturbed situation
perturbed = copy.deepcopy(situation)
perturbed["people"]["you"]["employment_income"] = {year: pwages + DELTA * p_share}
if "your partner" in perturbed["people"]:
perturbed["people"]["your partner"]["employment_income"] = {
year: swages + DELTA * s_share
}

# Run perturbed simulation
perturbed_sim = Simulation(situation=perturbed)
if disable_salt:
perturbed_sim.set_input(
variable_name="state_and_local_sales_or_income_tax",
value=0.0,
period=year,
)

new_federal = float(perturbed_sim.calculate("income_tax", period=year)[0]) + float(
perturbed_sim.calculate("additional_medicare_tax", period=year)[0]
)
new_state = float(perturbed_sim.calculate("state_income_tax", period=year)[0])

return {
"frate": round(100.0 * (new_federal - base_federal) / DELTA, 4),
"srate": round(100.0 * (new_state - base_state) / DELTA, 4),
}
36 changes: 36 additions & 0 deletions policyengine_taxsim/core/output_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
)
from policyengine_us import Simulation
from .yaml_generator import generate_pe_tests_yaml
from .marginal_rates import compute_marginal_rates_single

disable_salt_variable = False

Expand All @@ -13,6 +14,9 @@ def generate_non_description_output(
taxsim_output, mappings, year, state_name, simulation, output_type, logs
):
outputs = []
mtr_computed = False
mtr_results = {}

for key, each_item in mappings.items():
if each_item["implemented"]:
if key == "taxsimid":
Expand All @@ -21,6 +25,22 @@ def generate_non_description_output(
taxsim_output[key] = int(year)
elif key == "state":
taxsim_output[key] = get_state_number(state_name)
elif each_item.get("variable") == "marginal_rate_computed":
# Marginal rates: compute once, apply per key
if not mtr_computed:
try:
mtr_results = compute_marginal_rates_single(
simulation,
simulation.situation_input,
year,
disable_salt_variable,
)
except Exception:
mtr_results = {"frate": 0.0, "srate": 0.0, "ficar": 0.0}
mtr_computed = True
for entry in each_item["idtl"]:
if output_type in entry.values():
taxsim_output[key] = mtr_results.get(key, 0.0)
elif "variables" in each_item and len(each_item["variables"]) > 0:
pe_variables = each_item["variables"]
taxsim_output[key] = simulate_multiple(simulation, pe_variables, year)
Expand Down Expand Up @@ -77,6 +97,9 @@ def generate_text_description_output(
lines = [""]
sorted_groups = sorted(groups.keys(), key=lambda x: group_orders[x])
outputs = []
mtr_computed = False
mtr_results = {}

for group_name in sorted_groups:
variables = groups[group_name]
if variables:
Expand All @@ -98,6 +121,19 @@ def generate_text_description_output(
value = (
f"{get_state_number(state_name)}{' ' * LEFT_MARGIN}{state_name}"
)
elif variable == "marginal_rate_computed":
if not mtr_computed:
try:
mtr_results = compute_marginal_rates_single(
simulation,
simulation.situation_input,
year,
disable_salt_variable,
)
except Exception:
mtr_results = {"frate": 0.0, "srate": 0.0, "ficar": 0.0}
mtr_computed = True
value = mtr_results.get(var_name, 0.0)
elif "variables" in each_item and len(each_item["variables"]) > 0:
value = simulate_multiple(simulation, each_item["variables"], year)
else:
Expand Down
Loading
Loading