From 58285eb190f612e9df367eaf56b9c95e40872bd3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 24 May 2026 09:14:42 -0400 Subject: [PATCH 1/3] Add capital gains basis input variables --- .../long_term_capital_gains_basis.yaml | 18 ++++++++++++++++++ .../long_term_capital_gains_years_held.yaml | 18 ++++++++++++++++++ .../long_term_capital_gains_basis.py | 13 +++++++++++++ .../long_term_capital_gains_years_held.py | 13 +++++++++++++ 4 files changed, 62 insertions(+) create mode 100644 policyengine_us/tests/variables/household/income/person/capital_gains/long_term_capital_gains_basis.yaml create mode 100644 policyengine_us/tests/variables/household/income/person/capital_gains/long_term_capital_gains_years_held.yaml create mode 100644 policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_basis.py create mode 100644 policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_years_held.py diff --git a/policyengine_us/tests/variables/household/income/person/capital_gains/long_term_capital_gains_basis.yaml b/policyengine_us/tests/variables/household/income/person/capital_gains/long_term_capital_gains_basis.yaml new file mode 100644 index 00000000000..136cb0d85f2 --- /dev/null +++ b/policyengine_us/tests/variables/household/income/person/capital_gains/long_term_capital_gains_basis.yaml @@ -0,0 +1,18 @@ +- name: Long-term capital gains basis is a person-level input + period: 2026 + input: + people: + person1: + long_term_capital_gains_basis: 80_000 + person2: + long_term_capital_gains_basis: 20_000 + output: + long_term_capital_gains_basis: [80_000, 20_000] + +- name: Long-term capital gains basis defaults to zero + period: 2026 + input: + people: + person1: {} + output: + long_term_capital_gains_basis: [0] diff --git a/policyengine_us/tests/variables/household/income/person/capital_gains/long_term_capital_gains_years_held.yaml b/policyengine_us/tests/variables/household/income/person/capital_gains/long_term_capital_gains_years_held.yaml new file mode 100644 index 00000000000..caf3982a304 --- /dev/null +++ b/policyengine_us/tests/variables/household/income/person/capital_gains/long_term_capital_gains_years_held.yaml @@ -0,0 +1,18 @@ +- name: Long-term capital gains years held is a person-level input + period: 2026 + input: + people: + person1: + long_term_capital_gains_years_held: 8.5 + person2: + long_term_capital_gains_years_held: 21.25 + output: + long_term_capital_gains_years_held: [8.5, 21.25] + +- name: Long-term capital gains years held defaults to zero + period: 2026 + input: + people: + person1: {} + output: + long_term_capital_gains_years_held: [0] diff --git a/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_basis.py b/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_basis.py new file mode 100644 index 00000000000..41fec57a918 --- /dev/null +++ b/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_basis.py @@ -0,0 +1,13 @@ +from policyengine_us.model_api import * + + +class long_term_capital_gains_basis(Variable): + value_type = float + entity = Person + label = "long-term capital gains basis" + unit = USD + documentation = ( + "Cost basis associated with realized long-term capital gains, " + "stored for capital-gains basis indexation analysis." + ) + definition_period = YEAR diff --git a/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_years_held.py b/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_years_held.py new file mode 100644 index 00000000000..076b733a8c6 --- /dev/null +++ b/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_years_held.py @@ -0,0 +1,13 @@ +from policyengine_us.model_api import * + + +class long_term_capital_gains_years_held(Variable): + value_type = float + entity = Person + label = "long-term capital gains years held" + unit = "year" + documentation = ( + "Representative holding period, in years, associated with realized " + "long-term capital gains for capital-gains basis indexation analysis." + ) + definition_period = YEAR From 827c7e161fef1bfe1edf5526b04fd81849e16487 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 24 May 2026 13:36:20 -0400 Subject: [PATCH 2/3] Uprate capital gains basis input --- policyengine_us/tests/test_system_import.py | 11 +++++++++++ .../capital_gains/long_term_capital_gains_basis.py | 1 + 2 files changed, 12 insertions(+) diff --git a/policyengine_us/tests/test_system_import.py b/policyengine_us/tests/test_system_import.py index df73dc9ef18..87804832320 100644 --- a/policyengine_us/tests/test_system_import.py +++ b/policyengine_us/tests/test_system_import.py @@ -82,3 +82,14 @@ def test_computed_default_uprated_variables_have_microdata_overrides(): missing_overrides.append(name) assert missing_overrides == [] + + +def test_capital_gains_indexation_inputs_have_expected_uprating(): + from policyengine_us.system import CountryTaxBenefitSystem + + system = CountryTaxBenefitSystem() + assert ( + system.variables["long_term_capital_gains_basis"].uprating + == "calibration.gov.irs.soi.long_term_capital_gains" + ) + assert system.variables["long_term_capital_gains_years_held"].uprating is None diff --git a/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_basis.py b/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_basis.py index 41fec57a918..0f28017f092 100644 --- a/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_basis.py +++ b/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_basis.py @@ -11,3 +11,4 @@ class long_term_capital_gains_basis(Variable): "stored for capital-gains basis indexation analysis." ) definition_period = YEAR + uprating = "calibration.gov.irs.soi.long_term_capital_gains" From 2a09ae8d5fa1621d341d0cabd92596e6132ab05d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 24 May 2026 12:15:37 -0400 Subject: [PATCH 3/3] Add capital gains basis indexation reform --- .../irs/capital_gains/indexation/applies.yaml | 10 ++ .../indexation/cap_adjustment_to_gain.yaml | 10 ++ .../chained_cpi_transition_year.yaml | 11 ++ .../indexation/minimum_holding_period.yaml | 10 ++ .../indexation/purchase_year_threshold.yaml | 10 ++ .../reforms/capital_gains_indexation.py | 44 +++++++ .../contrib/capital_gains_indexation.yaml | 123 ++++++++++++++++++ ...erm_capital_gains_indexation_adjustment.py | 79 +++++++++++ .../capital_gains/long_term_capital_gains.py | 12 +- ...erm_capital_gains_indexation_adjustment.py | 33 +++++ 10 files changed, 338 insertions(+), 4 deletions(-) create mode 100644 policyengine_us/parameters/gov/irs/capital_gains/indexation/applies.yaml create mode 100644 policyengine_us/parameters/gov/irs/capital_gains/indexation/cap_adjustment_to_gain.yaml create mode 100644 policyengine_us/parameters/gov/irs/capital_gains/indexation/chained_cpi_transition_year.yaml create mode 100644 policyengine_us/parameters/gov/irs/capital_gains/indexation/minimum_holding_period.yaml create mode 100644 policyengine_us/parameters/gov/irs/capital_gains/indexation/purchase_year_threshold.yaml create mode 100644 policyengine_us/reforms/capital_gains_indexation.py create mode 100644 policyengine_us/tests/policy/contrib/capital_gains_indexation.yaml create mode 100644 policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/tax_unit_long_term_capital_gains_indexation_adjustment.py create mode 100644 policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_indexation_adjustment.py diff --git a/policyengine_us/parameters/gov/irs/capital_gains/indexation/applies.yaml b/policyengine_us/parameters/gov/irs/capital_gains/indexation/applies.yaml new file mode 100644 index 00000000000..fc9df9ab6f5 --- /dev/null +++ b/policyengine_us/parameters/gov/irs/capital_gains/indexation/applies.yaml @@ -0,0 +1,10 @@ +description: Whether long-term capital gains cost basis is indexed to inflation. +values: + 2013-01-01: false +metadata: + unit: bool + period: year + label: Capital gains basis indexation applies + reference: + - title: Yale Budget Lab, Indexing Capital Gains to Inflation + href: https://budgetlab.yale.edu/research/indexing-capital-gains-inflation diff --git a/policyengine_us/parameters/gov/irs/capital_gains/indexation/cap_adjustment_to_gain.yaml b/policyengine_us/parameters/gov/irs/capital_gains/indexation/cap_adjustment_to_gain.yaml new file mode 100644 index 00000000000..991601dc78b --- /dev/null +++ b/policyengine_us/parameters/gov/irs/capital_gains/indexation/cap_adjustment_to_gain.yaml @@ -0,0 +1,10 @@ +description: Whether basis indexation is capped so it cannot turn a long-term capital gain into a loss. +values: + 2013-01-01: true +metadata: + unit: bool + period: year + label: Capital gains basis indexation no-loss cap + reference: + - title: Yale Budget Lab, Indexing Capital Gains to Inflation + href: https://budgetlab.yale.edu/research/indexing-capital-gains-inflation diff --git a/policyengine_us/parameters/gov/irs/capital_gains/indexation/chained_cpi_transition_year.yaml b/policyengine_us/parameters/gov/irs/capital_gains/indexation/chained_cpi_transition_year.yaml new file mode 100644 index 00000000000..c85957db801 --- /dev/null +++ b/policyengine_us/parameters/gov/irs/capital_gains/indexation/chained_cpi_transition_year.yaml @@ -0,0 +1,11 @@ +description: Capital gains basis indexation uses chained CPI-U from this calendar year onward. +values: + 2013-01-01: 2017 +metadata: + unit: year + period: year + label: Capital gains basis indexation chained CPI transition year + reference: + - title: Yale Budget Lab, Indexing Capital Gains to Inflation + href: https://budgetlab.yale.edu/research/indexing-capital-gains-inflation + diff --git a/policyengine_us/parameters/gov/irs/capital_gains/indexation/minimum_holding_period.yaml b/policyengine_us/parameters/gov/irs/capital_gains/indexation/minimum_holding_period.yaml new file mode 100644 index 00000000000..9d3a6351246 --- /dev/null +++ b/policyengine_us/parameters/gov/irs/capital_gains/indexation/minimum_holding_period.yaml @@ -0,0 +1,10 @@ +description: Minimum holding period required for long-term capital gains basis indexation. +values: + 2013-01-01: 1 +metadata: + unit: year + period: year + label: Capital gains basis indexation minimum holding period + reference: + - title: Yale Budget Lab, Indexing Capital Gains to Inflation + href: https://budgetlab.yale.edu/research/indexing-capital-gains-inflation diff --git a/policyengine_us/parameters/gov/irs/capital_gains/indexation/purchase_year_threshold.yaml b/policyengine_us/parameters/gov/irs/capital_gains/indexation/purchase_year_threshold.yaml new file mode 100644 index 00000000000..599e9afa861 --- /dev/null +++ b/policyengine_us/parameters/gov/irs/capital_gains/indexation/purchase_year_threshold.yaml @@ -0,0 +1,10 @@ +description: Assets must be purchased after this calendar year to qualify for long-term capital gains basis indexation. +values: + 2013-01-01: 0 +metadata: + unit: year + period: year + label: Capital gains basis indexation purchase year threshold + reference: + - title: Yale Budget Lab, Indexing Capital Gains to Inflation + href: https://budgetlab.yale.edu/research/indexing-capital-gains-inflation diff --git a/policyengine_us/reforms/capital_gains_indexation.py b/policyengine_us/reforms/capital_gains_indexation.py new file mode 100644 index 00000000000..f5a5a5b9438 --- /dev/null +++ b/policyengine_us/reforms/capital_gains_indexation.py @@ -0,0 +1,44 @@ +from policyengine_us.model_api import * +from policyengine_core.periods import instant + + +def create_capital_gains_indexation_reform( + purchase_year_threshold: int, + minimum_holding_period: int = 1, + cap_adjustment_to_gain: bool = True, +): + def modify_parameters(parameters): + indexation = parameters.gov.irs.capital_gains.indexation + start = instant("2026-01-01") + stop = instant("2100-12-31") + indexation.applies.update(start=start, stop=stop, value=True) + indexation.minimum_holding_period.update( + start=start, stop=stop, value=minimum_holding_period + ) + indexation.purchase_year_threshold.update( + start=start, stop=stop, value=purchase_year_threshold + ) + indexation.cap_adjustment_to_gain.update( + start=start, stop=stop, value=cap_adjustment_to_gain + ) + return parameters + + class reform(Reform): + def apply(self): + self.modify_parameters(modify_parameters) + + return reform + + +capital_gains_indexation_prospective = create_capital_gains_indexation_reform( + purchase_year_threshold=2025 +) +capital_gains_indexation_retrospective = create_capital_gains_indexation_reform( + purchase_year_threshold=0 +) +capital_gains_indexation_retrospective_losses_allowed = ( + create_capital_gains_indexation_reform( + purchase_year_threshold=0, + cap_adjustment_to_gain=False, + ) +) diff --git a/policyengine_us/tests/policy/contrib/capital_gains_indexation.yaml b/policyengine_us/tests/policy/contrib/capital_gains_indexation.yaml new file mode 100644 index 00000000000..1d2ace4bc19 --- /dev/null +++ b/policyengine_us/tests/policy/contrib/capital_gains_indexation.yaml @@ -0,0 +1,123 @@ +- name: Case 1, inactive capital gains basis indexation leaves long-term gains unchanged. + period: 2026 + input: + people: + person1: + long_term_capital_gains_before_response: 5_000 + long_term_capital_gains_basis: 10_000 + long_term_capital_gains_years_held: 10 + tax_units: + tax_unit: + members: [person1] + output: + tax_unit_long_term_capital_gains_indexation_adjustment: 0 + long_term_capital_gains_indexation_adjustment: [0] + long_term_capital_gains: [5_000] + +- name: Case 2, retrospective indexation applies the no-loss cap. + period: 2026 + reforms: policyengine_us.reforms.capital_gains_indexation.capital_gains_indexation_retrospective + input: + people: + person1: + long_term_capital_gains_before_response: 100 + long_term_capital_gains_basis: 1_000_000 + long_term_capital_gains_years_held: 10 + tax_units: + tax_unit: + members: [person1] + output: + tax_unit_long_term_capital_gains_indexation_adjustment: 100 + long_term_capital_gains_indexation_adjustment: [100] + long_term_capital_gains: [0] + +- name: Case 3, prospective indexation excludes assets purchased before 2026. + period: 2026 + reforms: policyengine_us.reforms.capital_gains_indexation.capital_gains_indexation_prospective + input: + people: + person1: + long_term_capital_gains_before_response: 5_000 + long_term_capital_gains_basis: 10_000 + long_term_capital_gains_years_held: 10 + tax_units: + tax_unit: + members: [person1] + output: + tax_unit_long_term_capital_gains_indexation_adjustment: 0 + long_term_capital_gains_indexation_adjustment: [0] + long_term_capital_gains: [5_000] + +- name: Case 4, prospective indexation applies to assets purchased after 2025. + period: 2027 + reforms: policyengine_us.reforms.capital_gains_indexation.capital_gains_indexation_prospective + input: + people: + person1: + long_term_capital_gains_before_response: 100 + long_term_capital_gains_basis: 1_000_000 + long_term_capital_gains_years_held: 1.1 + tax_units: + tax_unit: + members: [person1] + output: + tax_unit_long_term_capital_gains_indexation_adjustment: 100 + long_term_capital_gains_indexation_adjustment: [100] + long_term_capital_gains: [0] + +- name: Case 5, tax-unit indexation allocates a capped mixed-spouse adjustment to the gain holder. + period: 2026 + reforms: policyengine_us.reforms.capital_gains_indexation.capital_gains_indexation_retrospective + input: + people: + person1: + long_term_capital_gains_before_response: 500 + long_term_capital_gains_basis: 800_000 + long_term_capital_gains_years_held: 10 + person2: + long_term_capital_gains_before_response: -200 + long_term_capital_gains_basis: 200_000 + long_term_capital_gains_years_held: 10 + tax_units: + tax_unit: + members: [person1, person2] + output: + tax_unit_long_term_capital_gains_indexation_adjustment: 300 + long_term_capital_gains_indexation_adjustment: [300, 0] + long_term_capital_gains: [200, -200] + net_capital_gains: 0 + +- name: Case 6, no-loss cap prevents larger net long-term losses. + period: 2026 + reforms: policyengine_us.reforms.capital_gains_indexation.capital_gains_indexation_retrospective + input: + people: + person1: + long_term_capital_gains_before_response: -500 + long_term_capital_gains_basis: 1_000_000 + long_term_capital_gains_years_held: 10 + tax_units: + tax_unit: + members: [person1] + output: + tax_unit_long_term_capital_gains_indexation_adjustment: 0 + long_term_capital_gains_indexation_adjustment: [0] + long_term_capital_gains: [-500] + +- name: Case 7, losses-allowed benchmark can increase net long-term losses. + period: 2026 + absolute_error_margin: 0.01 + reforms: policyengine_us.reforms.capital_gains_indexation.capital_gains_indexation_retrospective_losses_allowed + input: + people: + person1: + long_term_capital_gains_before_response: -500 + long_term_capital_gains_basis: 1_000_000 + long_term_capital_gains_years_held: 10 + tax_units: + tax_unit: + members: [person1] + output: + tax_unit_long_term_capital_gains_indexation_adjustment: 334_063.66 + long_term_capital_gains_indexation_adjustment: [334_063.66] + long_term_capital_gains: [-334_563.66] diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/tax_unit_long_term_capital_gains_indexation_adjustment.py b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/tax_unit_long_term_capital_gains_indexation_adjustment.py new file mode 100644 index 00000000000..0da80a48ee8 --- /dev/null +++ b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/tax_unit_long_term_capital_gains_indexation_adjustment.py @@ -0,0 +1,79 @@ +from policyengine_us.model_api import * + + +FIRST_CPI_U_YEAR = 1913 + + +def capital_gains_indexation_price_level(parameters, year, transition_year): + if year <= transition_year: + return parameters(f"{year}-01-01").gov.bls.cpi.cpi_u + + cpi_u_transition = parameters(f"{transition_year}-01-01").gov.bls.cpi.cpi_u + chained_cpi_transition = parameters(f"{transition_year}-01-01").gov.bls.cpi.c_cpi_u + chained_cpi_year = parameters(f"{year}-01-01").gov.bls.cpi.c_cpi_u + return cpi_u_transition * chained_cpi_year / chained_cpi_transition + + +class tax_unit_long_term_capital_gains_indexation_adjustment(Variable): + value_type = float + entity = TaxUnit + label = "tax unit long-term capital gains basis indexation adjustment" + unit = USD + documentation = ( + "Reduction in taxable long-term capital gains from indexing basis to inflation." + ) + definition_period = YEAR + + def formula(tax_unit, period, parameters): + p = parameters(period).gov.irs.capital_gains.indexation + if not p.applies: + return np.zeros(tax_unit.count) + + person = tax_unit.members + year = period.start.year + gains = add(tax_unit, period, ["long_term_capital_gains_before_response"]) + basis = add(tax_unit, period, ["long_term_capital_gains_basis"]) + + person_gains = person("long_term_capital_gains_before_response", period) + person_years_held = person("long_term_capital_gains_years_held", period) + person_weights = abs(person_gains) + weight_sum = tax_unit.sum(person_weights) + weighted_years_held = tax_unit.sum(person_years_held * person_weights) + years_held = np.divide( + weighted_years_held, + weight_sum, + out=np.zeros_like(weighted_years_held), + where=weight_sum != 0, + ) + + rounded_years_held = np.rint(np.nan_to_num(years_held)).astype(int) + acquisition_year = year - rounded_years_held + + transition_year = p.chained_cpi_transition_year + current_price_level = capital_gains_indexation_price_level( + parameters, year, transition_year + ) + indexation_ratio = np.ones(tax_unit.count) + for purchase_year in np.unique(acquisition_year): + if purchase_year < FIRST_CPI_U_YEAR or purchase_year > year: + continue + purchase_price_level = capital_gains_indexation_price_level( + parameters, int(purchase_year), transition_year + ) + indexation_ratio = where( + acquisition_year == purchase_year, + current_price_level / purchase_price_level, + indexation_ratio, + ) + + eligible = ( + (basis > 0) + & (years_held > p.minimum_holding_period) + & (acquisition_year > p.purchase_year_threshold) + ) + adjustment = basis * max_(0, indexation_ratio - 1) + + if p.cap_adjustment_to_gain: + adjustment = where(gains > 0, min_(adjustment, gains), 0) + + return where(eligible, adjustment, 0) diff --git a/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains.py b/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains.py index 86d1e3e9d59..671b2e312b3 100644 --- a/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains.py +++ b/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains.py @@ -12,7 +12,11 @@ class long_term_capital_gains(Variable): title="26 U.S. Code ยง 1222(3)", href="https://www.law.cornell.edu/uscode/text/26/1222#3", ) - adds = [ - "long_term_capital_gains_before_response", - "capital_gains_behavioral_response", - ] + + def formula(person, period, parameters): + gains = person("long_term_capital_gains_before_response", period) + response = person("capital_gains_behavioral_response", period) + indexation_adjustment = person( + "long_term_capital_gains_indexation_adjustment", period + ) + return gains + response - indexation_adjustment diff --git a/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_indexation_adjustment.py b/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_indexation_adjustment.py new file mode 100644 index 00000000000..bd3c50c7b30 --- /dev/null +++ b/policyengine_us/variables/household/income/person/capital_gains/long_term_capital_gains_indexation_adjustment.py @@ -0,0 +1,33 @@ +from policyengine_us.model_api import * + + +class long_term_capital_gains_indexation_adjustment(Variable): + value_type = float + entity = Person + label = "long-term capital gains basis indexation adjustment" + unit = USD + documentation = ( + "Person-level allocation of tax-unit long-term capital gains basis indexation." + ) + definition_period = YEAR + + def formula(person, period, parameters): + tax_unit = person.tax_unit + tax_unit_adjustment = tax_unit( + "tax_unit_long_term_capital_gains_indexation_adjustment", period + ) + gains = person("long_term_capital_gains_before_response", period) + tax_unit_gains = add( + tax_unit, period, ["long_term_capital_gains_before_response"] + ) + positive_gains = max_(gains, 0) + absolute_gains = abs(gains) + weights = where(tax_unit_gains > 0, positive_gains, absolute_gains) + tax_unit_weights = tax_unit.sum(weights) + share = np.divide( + weights, + tax_unit_weights, + out=np.zeros_like(weights), + where=tax_unit_weights != 0, + ) + return tax_unit_adjustment * share