Skip to content
Draft
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/split-capital-loss-carryover-k1.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Split the capital loss carryover memo inputs into the person's own carryover and one from a terminated estate or trust (Schedule K-1 (Form 1041), box 11, codes C and D), add a short-term carryover memo, and add the short-term carryover back in Arizona property tax credit household income (ITR 12-1 item (7)).
5 changes: 5 additions & 0 deletions policyengine_us/data/economic_assumptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
"foreign_tax_credit": DEFAULT_MICRODATA_UPRATING,
"interest_deduction": DEFAULT_MICRODATA_UPRATING,
"long_term_capital_gains": "calibration.gov.irs.soi.long_term_capital_gains",
# long_term_capital_loss_carryover and short_term_capital_loss_carryover
# add their own and estate-or-trust leaves. A dataset that carries either
# sum keeps the series its leaves follow.
"long_term_capital_loss_carryover": "calibration.gov.irs.soi.long_term_capital_gains",
"misc_deduction": DEFAULT_MICRODATA_UPRATING,
"partnership_income": "calibration.gov.irs.soi.partnership_s_corp_income",
"partnership_s_corp_income": "calibration.gov.irs.soi.partnership_s_corp_income",
Expand All @@ -41,6 +45,7 @@
"self_employment_income": "calibration.gov.irs.soi.self_employment_income",
"self_employed_health_insurance_ald": DEFAULT_MICRODATA_UPRATING,
"self_employed_pension_contribution_ald": DEFAULT_MICRODATA_UPRATING,
"short_term_capital_loss_carryover": "calibration.gov.irs.soi.short_term_capital_gains",
"social_security": "calibration.gov.irs.soi.social_security",
"s_corp_income": "calibration.gov.irs.soi.partnership_s_corp_income",
"spm_unit_weight": "calibration.gov.census.populations.total",
Expand Down
63 changes: 63 additions & 0 deletions policyengine_us/system.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import hashlib
from functools import lru_cache
from pathlib import Path

import numpy as np
from policyengine_core.taxbenefitsystems import TaxBenefitSystem
from policyengine_us.entities import *
from policyengine_us.parameters.gov.irs.uprating import (
Expand Down Expand Up @@ -217,6 +219,63 @@ def _backfill_state_code_from_str(simulation):
state_code_str.delete_arrays(known_period)


CAPITAL_LOSS_CARRYOVER_SUMS = {
"long_term_capital_loss_carryover": (
"own_long_term_capital_loss_carryover",
"estate_or_trust_long_term_capital_loss_carryover",
),
"short_term_capital_loss_carryover": (
"own_short_term_capital_loss_carryover",
"estate_or_trust_short_term_capital_loss_carryover",
),
}


def _file_capital_loss_carryover_sums(simulation):
"""File an entered capital loss carryover sum as the person's own carryover.

Each sum adds the person's own carryover and one from a terminated estate
or trust. Core does not uprate a variable that adds others, and a sum
entered for anyone in a simulation would otherwise be an input for
everyone in it. So, mirroring the ``employment_income`` ->
``employment_income_before_lsr`` moves, a sum entered for a period
replaces the leaves entered for that period for each person it is
nonzero for: their own carryover becomes the sum, which uprates to later
periods as that leaf does, and an estate or trust carryover entered for
the period becomes zero. Where the sum is zero, the leaves entered for the
period stay, and without any the person's own carryover is zero, as the
entered sum was. Leaves entered for other periods are left alone. The sum
is then computed from its leaves.
"""
for total, (own, estate_or_trust) in CAPITAL_LOSS_CARRYOVER_SUMS.items():
holder = simulation.get_holder(total)
for known_period in holder.get_known_periods():
amount = holder.get_array(known_period)
entered = amount != 0
for leaf, value in [(own, amount), (estate_or_trust, 0)]:
existing = simulation.get_holder(leaf).get_array(known_period)
if existing is None and leaf == estate_or_trust:
continue
simulation.set_input(
leaf,
known_period,
np.where(entered, value, 0 if existing is None else existing),
)
holder.delete_arrays(known_period)
if getattr(simulation, "input_variables", None) is not None:
names = [
name
for name in simulation.input_variables
if name not in CAPITAL_LOSS_CARRYOVER_SUMS
]
for leaves in CAPITAL_LOSS_CARRYOVER_SUMS.values():
for leaf in leaves:
known = simulation.get_holder(leaf).get_known_periods()
if known and leaf not in names:
names.append(leaf)
simulation.input_variables = names


class Simulation(SPMSimulationMixin, CoreSimulation):
"""
A simulation of the tax-benefit system for the United States,
Expand Down Expand Up @@ -296,6 +355,8 @@ def __init__(self, *args, **kwargs):
)
cg_holder.delete_arrays(known_period)

_file_capital_loss_carryover_sums(self)

# Geography backfill: state_code_str-only input -> state_code enum.
_backfill_state_code_from_str(self)

Expand Down Expand Up @@ -556,6 +617,8 @@ def __init__(self, *args, **kwargs):
)
cg_holder.delete_arrays(known_period)

_file_capital_loss_carryover_sums(self)

# Geography backfill: state_code_str-only input -> state_code enum.
# Datasets carry state_fips, so this only fires for situations that
# explicitly supply state_code_str.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
Port the specialty dependent cohorts from PR #9977's investment-interest
properties without its Form 4952 calculations. YAML covers policy examples
and regular tax; Hypothesis covers arbitrary vectorized populations, changes
to dependents' inputs, and redistribution between the head and spouse.
to dependents' inputs, and redistribution between the head and spouse. A
differential checks that entering long_term_capital_loss_carryover as its two
leaves, the person's own carryover and one from a terminated estate or trust
(Schedule K-1 (Form 1041), box 11, code D), gives the same worksheet lines as
entering their sum, as older callers and datasets do.

Each example constructs a small Core system containing the production
worksheet classes and their inputs, without reconstructing the full US
Expand Down Expand Up @@ -38,6 +42,9 @@
from policyengine_us.variables.household.income.person.capital_gains.collectibles_gain_or_loss import (
collectibles_gain_or_loss,
)
from policyengine_us.variables.household.income.person.capital_gains.estate_or_trust_long_term_capital_loss_carryover import (
estate_or_trust_long_term_capital_loss_carryover,
)
from policyengine_us.variables.household.income.person.capital_gains.long_term_capital_gains_on_collectibles import (
long_term_capital_gains_on_collectibles,
)
Expand All @@ -47,6 +54,9 @@
from policyengine_us.variables.household.income.person.capital_gains.long_term_capital_loss_carryover import (
long_term_capital_loss_carryover,
)
from policyengine_us.variables.household.income.person.capital_gains.own_long_term_capital_loss_carryover import (
own_long_term_capital_loss_carryover,
)
from policyengine_us.variables.household.income.person.capital_gains.section_1202_gain import (
section_1202_gain,
)
Expand All @@ -70,6 +80,11 @@
long_term_capital_gains_on_small_business_stock,
)
INPUTS = tuple(variable.__name__ for variable in INPUT_CLASSES)
# long_term_capital_loss_carryover adds these; an entered sum is used instead.
CARRYOVER_LEAVES = (
own_long_term_capital_loss_carryover,
estate_or_trust_long_term_capital_loss_carryover,
)
OUTPUTS = (
"rate_gains_less_losses",
"capital_gains_28_percent_rate_gain",
Expand All @@ -83,6 +98,7 @@ def _simulate(units, *, reverse_members=False):
system = TaxBenefitSystem(entities=[Person, TaxUnit])
system.add_variables(
*INPUT_CLASSES,
*CARRYOVER_LEAVES,
is_tax_unit_dependent,
unrecaptured_section_1250_gain,
unrecaptured_section_1250_gain_before_losses,
Expand Down Expand Up @@ -124,6 +140,10 @@ def _simulate(units, *, reverse_members=False):
for name in OUTPUTS
if name != "rate_gains_less_losses"
},
# Per person, in the order the units list them.
"long_term_capital_loss_carryover": simulation.calculate(
"long_term_capital_loss_carryover", PERIOD
),
}


Expand Down Expand Up @@ -262,3 +282,34 @@ def test_vectorized_worksheets_depend_only_on_non_dependents(units):
np.testing.assert_array_equal(
results[name][size : 2 * size], expected, err_msg=name
)


@settings(max_examples=60, deadline=None)
@given(units=UNITS, data=st.data())
def test_carryover_leaves_give_the_same_worksheets_as_their_sum(units, data):
"""Differential: each person's long-term carryover entered as its sum, or
split at random between the person's own carryover and one from a
terminated estate or trust, gives the same carryover and the same worksheet
lines. Dependents' carryovers are split too; the worksheets leave them out
either way. The two cohorts run in separate simulations: a sum entered for
anyone in a simulation is an input for everyone in it."""
as_leaves = deepcopy(units)
totals = []
for unit in as_leaves:
people = [unit["head"], *unit["dependents"]]
if unit["spouse"] is not None:
people.insert(1, unit["spouse"])
for person in people:
total = person.pop("long_term_capital_loss_carryover")
own = data.draw(st.integers(min_value=0, max_value=total))
person["own_long_term_capital_loss_carryover"] = own
person["estate_or_trust_long_term_capital_loss_carryover"] = total - own
totals.append(total)

summed, leaves = _simulate(units), _simulate(as_leaves)
for results in (summed, leaves):
np.testing.assert_array_equal(
results["long_term_capital_loss_carryover"], totals
)
for name in OUTPUTS:
np.testing.assert_array_equal(leaves[name], summed[name], err_msg=name)
144 changes: 144 additions & 0 deletions policyengine_us/tests/core/test_capital_loss_carryover_entered_sums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""A capital loss carryover sum entered as an input is filed as the person's
own carryover when a Simulation is built, as employment_income is filed as
employment_income_before_lsr (``policyengine_us.system``). YAML tests build
Core simulations, which skip that step, so these run through
``policyengine_us.Simulation``.

Invariants:

1. Uprating: a sum entered for 2024 grows to later years by the series its
leaves follow, as it did when the sum was an uprated input, and gives the
same results as entering the person's own carryover for 2024.
2. People: a sum entered for one person leaves another person's leaves for
the same year alone.
3. Replacement: a nonzero sum entered for a person replaces the leaves entered
for that person for the same year; a zero sum leaves them.
"""

import numpy as np
import pytest
from policyengine_core.parameters.operations.get_parameter import get_parameter

from policyengine_us import Simulation
from policyengine_us.system import system
from policyengine_us.tools.per_capita_uprating import per_capita_path

BASE_YEAR, LATER_YEARS = 2024, (2025, 2026, 2030)
TERMS = {
"long": per_capita_path("calibration.gov.irs.soi.long_term_capital_gains"),
"short": per_capita_path("calibration.gov.irs.soi.short_term_capital_gains"),
}


def _growth(path, year):
parameter = get_parameter(system.parameters, path)
return parameter(f"{year}-01-01") / parameter(f"{BASE_YEAR}-01-01")


def _renter(carryovers: dict, year=BASE_YEAR) -> dict:
"""An Arizona renter aged 70 whose gains are net of the carryovers."""
person = {
"age": {year: 70},
"rent": {year: 5_000},
"long_term_capital_gains": {year: 1_000},
"short_term_capital_gains": {year: -500},
}
person.update({name: {year: amount} for name, amount in carryovers.items()})
return {
"people": {"head": person},
"tax_units": {"tax_unit": {"members": ["head"]}},
"households": {"household": {"members": ["head"], "state_code": {year: "AZ"}}},
}


@pytest.mark.parametrize("term", TERMS)
def test_entered_sum_uprates_as_its_own_leaf(term):
sum_entered = Simulation(
situation=_renter({f"{term}_term_capital_loss_carryover": 3_000})
)
own_entered = Simulation(
situation=_renter({f"own_{term}_term_capital_loss_carryover": 3_000})
)
for year in LATER_YEARS:
np.testing.assert_allclose(
sum_entered.calculate(f"{term}_term_capital_loss_carryover", year),
[3_000 * _growth(TERMS[term], year)],
rtol=1e-6,
)
for name in [
f"{term}_term_capital_loss_carryover",
"az_property_tax_credit_capital_gains",
"az_property_tax_credit",
"capital_gains_28_percent_rate_gain",
"income_tax",
]:
np.testing.assert_array_equal(
sum_entered.calculate(name, year),
own_entered.calculate(name, year),
err_msg=f"{name} {year}",
)
# The sum is no longer an input; the own leaf holds it.
assert f"{term}_term_capital_loss_carryover" not in sum_entered.input_variables
assert f"own_{term}_term_capital_loss_carryover" in sum_entered.input_variables


@pytest.mark.parametrize("term", TERMS)
def test_entered_sum_leaves_other_peoples_leaves(term):
year = str(BASE_YEAR)
simulation = Simulation(
situation={
"people": {
"head": {
"age": {year: 40},
f"{term}_term_capital_loss_carryover": {year: 1_000},
},
"spouse": {
"age": {year: 40},
f"estate_or_trust_{term}_term_capital_loss_carryover": {
year: 2_000
},
},
"child": {
"age": {year: 10},
f"own_{term}_term_capital_loss_carryover": {year: 300},
},
}
}
)
np.testing.assert_array_equal(
simulation.calculate(f"{term}_term_capital_loss_carryover", year),
[1_000, 2_000, 300],
)


@pytest.mark.parametrize("term", TERMS)
def test_entered_sum_replaces_the_persons_leaves(term):
year = str(BASE_YEAR)
leaves = {
f"own_{term}_term_capital_loss_carryover": {year: 1_000},
f"estate_or_trust_{term}_term_capital_loss_carryover": {year: 500},
}
simulation = Simulation(
situation={
"people": {
"replaced": {
"age": {year: 40},
f"{term}_term_capital_loss_carryover": {year: 4_000},
**leaves,
},
"kept": {
"age": {year: 40},
f"{term}_term_capital_loss_carryover": {year: 0},
**leaves,
},
}
}
)
for name, expected in [
(f"{term}_term_capital_loss_carryover", [4_000, 1_500]),
(f"own_{term}_term_capital_loss_carryover", [4_000, 1_000]),
(f"estate_or_trust_{term}_term_capital_loss_carryover", [0, 500]),
]:
np.testing.assert_array_equal(
simulation.calculate(name, year), expected, err_msg=name
)
22 changes: 21 additions & 1 deletion policyengine_us/tests/core/test_uprating_series.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
EMPLOYMENT = per_capita_path("calibration.gov.irs.soi.employment_income")
SELF_EMPLOYMENT = per_capita_path("calibration.gov.irs.soi.self_employment_income")
CAPITAL_GAINS = per_capita_path("calibration.gov.irs.soi.long_term_capital_gains")
SHORT_TERM_CAPITAL_GAINS = per_capita_path(
"calibration.gov.irs.soi.short_term_capital_gains"
)
ALIMONY = per_capita_path("calibration.gov.irs.soi.alimony_income")
UNEMPLOYMENT = per_capita_path("calibration.gov.irs.soi.unemployment_compensation")
DEFAULT = per_capita_path("calibration.gov.cbo.income_by_source.adjusted_gross_income")
Expand Down Expand Up @@ -49,7 +52,10 @@
"self_employed_pension_contributions_desired": SELF_EMPLOYMENT,
# Components of capital gains.
"long_term_capital_gains_on_collectibles": CAPITAL_GAINS,
"long_term_capital_loss_carryover": CAPITAL_GAINS,
"own_long_term_capital_loss_carryover": CAPITAL_GAINS,
"estate_or_trust_long_term_capital_loss_carryover": CAPITAL_GAINS,
"own_short_term_capital_loss_carryover": SHORT_TERM_CAPITAL_GAINS,
"estate_or_trust_short_term_capital_loss_carryover": SHORT_TERM_CAPITAL_GAINS,
"collectibles_gain_or_loss": CAPITAL_GAINS,
"section_1202_gain": CAPITAL_GAINS,
"unrecaptured_section_1250_gain_before_losses": CAPITAL_GAINS,
Expand Down Expand Up @@ -158,6 +164,20 @@ def test_rent_override_matches_the_rent_input():
assert system.variables["pre_subsidy_rent"].uprating == CPI_U


@pytest.mark.parametrize(
"total", ["long_term_capital_loss_carryover", "short_term_capital_loss_carryover"]
)
def test_carryover_sum_override_matches_its_leaves(total):
"""A carryover sum adds its leaves, so Core cannot uprate it; a dataset
column holding the sum follows the series its leaves follow."""
leaves = system.variables[total].adds
assert len(leaves) == 2
for leaf in leaves:
assert system.variables[leaf].uprating == per_capita_path(
MICRODATA_UPRATING_OVERRIDES[total]
)


METHODOLOGY_PAGE = (
Path(__file__).parents[3] / "docs-quarto" / "methodology" / "spm-poverty.qmd"
)
Expand Down
Loading