diff --git a/changelog.d/aca-ptc-2027-applicable-percentage.fixed.md b/changelog.d/aca-ptc-2027-applicable-percentage.fixed.md new file mode 100644 index 00000000000..0fe021f0726 --- /dev/null +++ b/changelog.d/aca-ptc-2027-applicable-percentage.fixed.md @@ -0,0 +1 @@ +Add the 2027 premium tax credit applicable percentage table from Rev. Proc. 2026-26, so 2027 no longer reuses the 2026 table. diff --git a/policyengine_us/parameters/gov/aca/required_contribution_percentage/final.yaml b/policyengine_us/parameters/gov/aca/required_contribution_percentage/final.yaml index da40e2b5427..59f07007aa8 100644 --- a/policyengine_us/parameters/gov/aca/required_contribution_percentage/final.yaml +++ b/policyengine_us/parameters/gov/aca/required_contribution_percentage/final.yaml @@ -13,7 +13,9 @@ metadata: - title: IRS Revenue Procedure 2019-29 - Applicable percentage table for 2020 href: https://www.irs.gov/pub/irs-drop/rp-19-29.pdf#page=3 - title: IRS Revenue Procedure 2025-25 - Applicable percentage table for 2026 - href: https://www.irs.gov/pub/irs-drop/rp-25-25.pdf + href: https://www.irs.gov/pub/irs-drop/rp-25-25.pdf#page=3 + - title: IRS Revenue Procedure 2026-26 - Applicable percentage table for 2027 + href: https://www.irs.gov/pub/irs-drop/rp-26-26.pdf#page=3 values: # Original ACA: 6 brackets 2015-01-01: @@ -59,3 +61,10 @@ values: - 0.0844 # 200-250% - 0.0996 # 250-300% - 0.0996 # 300-400%: flat 9.96% + 2027-01-01: + - 0.0215 # 0-133%: flat 2.15% + - 0.043 # 133-150% + - 0.0678 # 150-200% + - 0.0866 # 200-250% + - 0.1022 # 250-300% + - 0.1022 # 300-400%: flat 10.22% diff --git a/policyengine_us/parameters/gov/aca/required_contribution_percentage/initial.yaml b/policyengine_us/parameters/gov/aca/required_contribution_percentage/initial.yaml index 74805496be5..ce3460458f4 100644 --- a/policyengine_us/parameters/gov/aca/required_contribution_percentage/initial.yaml +++ b/policyengine_us/parameters/gov/aca/required_contribution_percentage/initial.yaml @@ -13,7 +13,9 @@ metadata: - title: IRS Revenue Procedure 2019-29 - Applicable percentage table for 2020 href: https://www.irs.gov/pub/irs-drop/rp-19-29.pdf#page=3 - title: IRS Revenue Procedure 2025-25 - Applicable percentage table for 2026 - href: https://www.irs.gov/pub/irs-drop/rp-25-25.pdf + href: https://www.irs.gov/pub/irs-drop/rp-25-25.pdf#page=3 + - title: IRS Revenue Procedure 2026-26 - Applicable percentage table for 2027 + href: https://www.irs.gov/pub/irs-drop/rp-26-26.pdf#page=3 values: # Original ACA: 6 brackets 2015-01-01: @@ -59,3 +61,10 @@ values: - 0.066 # 200-250% - 0.0844 # 250-300% - 0.0996 # 300-400%: flat 9.96% + 2027-01-01: + - 0.0215 # 0-133%: flat 2.15% + - 0.0323 # 133-150% + - 0.043 # 150-200% + - 0.0678 # 200-250% + - 0.0866 # 250-300% + - 0.1022 # 300-400%: flat 10.22% diff --git a/policyengine_us/parameters/gov/aca/required_contribution_percentage/threshold.yaml b/policyengine_us/parameters/gov/aca/required_contribution_percentage/threshold.yaml index 537e6c0bbb5..0aa76208635 100644 --- a/policyengine_us/parameters/gov/aca/required_contribution_percentage/threshold.yaml +++ b/policyengine_us/parameters/gov/aca/required_contribution_percentage/threshold.yaml @@ -6,7 +6,9 @@ metadata: - title: 26 U.S. Code ยง 36B(b)(3)(A) - Refundable credit for coverage under a qualified health plan href: https://www.law.cornell.edu/uscode/text/26/36B#b_3_A - title: IRS Revenue Procedure 2025-25 - Applicable percentage table for 2026 - href: https://www.irs.gov/pub/irs-drop/rp-25-25.pdf + href: https://www.irs.gov/pub/irs-drop/rp-25-25.pdf#page=3 + - title: IRS Revenue Procedure 2026-26 - Applicable percentage table for 2027 + href: https://www.irs.gov/pub/irs-drop/rp-26-26.pdf#page=3 values: 2015-01-01: - 0 diff --git a/policyengine_us/tests/core/test_aca_required_contribution_percentage_properties.py b/policyengine_us/tests/core/test_aca_required_contribution_percentage_properties.py new file mode 100644 index 00000000000..df356d4cd2a --- /dev/null +++ b/policyengine_us/tests/core/test_aca_required_contribution_percentage_properties.py @@ -0,0 +1,187 @@ +"""Properties of the ACA applicable percentage table. + +`aca_required_contribution_percentage` is the applicable percentage of +26 U.S.C. 36B(b)(3)(A): within each income tier of the table it rises "on a +sliding scale in a linear manner" from the tier's initial percentage to its +final percentage (26 CFR 1.36B-3(g)(1)). The IRS indexes the table each year; +Rev. Proc. 2026-26 sets it for 2027. Hypothesis draws incomes as fractions of +the poverty line from 0 to 400 percent. Each drawn example is one simulation, +calculated for every year from 2018 through 2027 against that year's table. +For every year: + +1. At each tier's lower edge the percentage equals the tier's initial + percentage, and at exactly 400 percent it equals the top tier's final + percentage. +2. Within a tier the percentage is the straight line from the tier's initial + to its final percentage, which is continuous, rounded to the nearest + hundredth of a percent (26 CFR 1.36B-3(g)(1)). So it stays between the + tier's initial and final percentages, it reaches the final percentage just + below the next edge, and at every income it is within half a rounding step + of the line. The bound holds whether or not the model rounds. +3. The percentage never decreases as income rises from 0 to 400 percent, + including across tier edges. Before rounding, its only jumps are at tier + edges; rounding adds steps of 0.01 percentage point. +4. The required contribution (household income times the percentage) never + decreases as income rises, so for a fixed benchmark premium the credit never + rises with income up to 400 percent (checked for 2026 and 2027, from income + in dollars through the poverty-line fraction). + +Why Python: the YAML cases pin the 2027 table at chosen points; this checks +every year's table at its edges and at drawn incomes, so a mistyped edge, an +interpolation that runs past a tier's final percentage, or a new table row +entered in the wrong order fails wherever it falls. Cost, measured +locally (macOS, shared host): about 6 s of test calls, 12 s in pytest and 36 s +wall time including the model import, peak memory footprint 1.7 GB. It runs in +the `core` group of the rest Python tests. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +YEARS = tuple(range(2018, 2028)) +CONTRIBUTION_YEARS = (2026, 2027) +# The model holds incomes and percentages in float32 (about seven significant +# digits), so edges are approached from 1e-5 away and percentages compared to +# within 1e-6. +EPSILON = 1e-5 +TOLERANCE = 1e-6 +# 26 CFR 1.36B-3(g)(1) rounds the interpolated percentage to the nearest +# hundredth of a percent, which moves it off the line by up to half of 0.0001. +ROUNDING = 1e-4 +# Every tier edge in any year's table, approached from both sides. +EDGES = (0.0, 1.33, 1.5, 2.0, 2.5, 3.0, 4.0) +KNOTS = sorted( + { + min(max(edge + offset, 0.0), 4.0) + for edge in EDGES + for offset in (-EPSILON, 0.0, EPSILON) + } +) + +fractions = st.lists( + st.one_of( + st.floats(0, 4, allow_nan=False, allow_infinity=False), + # Whole percents, as aca_magi_fraction produces from income. + st.integers(0, 400).map(lambda percent: percent / 100), + ), + min_size=1, + max_size=60, +) + + +def build(tax_unit_inputs, years): + people, tax_units, households = {}, {}, {} + groups = {"marital_units": {}, "spm_units": {}, "families": {}} + for index, inputs in enumerate(tax_unit_inputs): + person, members = f"person_{index}", [f"person_{index}"] + people[person] = { + "age": {year: 40 for year in years}, + "is_aca_ptc_eligible": {year: True for year in years}, + } + tax_units[f"tax_unit_{index}"] = { + "members": members, + **{name: {year: value for year in years} for name, value in inputs.items()}, + } + households[f"household_{index}"] = { + "members": members, + "state_code": {year: "TX" for year in years}, + } + for name in groups: + groups[name][f"{name}_{index}"] = {"members": members} + return Simulation( + situation={ + "people": people, + "tax_units": tax_units, + "households": households, + **groups, + } + ) + + +def table(simulation, year): + p = simulation.tax_benefit_system.parameters( + f"{year}-01-01" + ).gov.aca.required_contribution_percentage + return np.array(p.threshold), np.array(p.initial), np.array(p.final) + + +@settings( + max_examples=8, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(fractions.map(lambda drawn: sorted(KNOTS + drawn))) +def test_applicable_percentage_table(points): + simulation = build([{"aca_magi_fraction": x} for x in points], YEARS) + x = np.array(points) + # The fractions the model sees, after float32 storage. + seen = x.astype(np.float32).astype(float) + for year in YEARS: + thresholds, initial, final = table(simulation, year) + # Every edge is probed; a new or mistyped edge must be added to EDGES. + assert set(thresholds) <= set(EDGES), (year, thresholds) + rate = np.asarray( + simulation.calculate("aca_required_contribution_percentage", year) + ) + # Property 1. + for tier, edge in enumerate(thresholds[:-1]): + np.testing.assert_allclose( + rate[x == edge], initial[tier], atol=TOLERANCE, err_msg=str(year) + ) + np.testing.assert_allclose( + rate[x == thresholds[-1]], final[-1], atol=TOLERANCE, err_msg=str(year) + ) + # Property 2. + tier = np.clip( + np.searchsorted(thresholds, seen, side="right") - 1, 0, len(initial) - 1 + ) + slope = (final - initial) / np.diff(thresholds) + assert (rate >= initial[tier] - TOLERANCE).all(), year + assert (rate <= final[tier] + TOLERANCE).all(), year + for upper, top, rise in zip(thresholds[1:], final, slope): + below = rate[x == upper - EPSILON] + assert (top - below >= -TOLERANCE).all(), year + assert (top - below <= rise * EPSILON + TOLERANCE).all(), year + line = initial[tier] + slope[tier] * (seen - thresholds[tier]) + assert (np.abs(rate - line) <= ROUNDING / 2 + TOLERANCE).all(), year + # Property 3. + assert (np.diff(rate) >= -TOLERANCE).all(), year + + +@settings( + max_examples=6, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given( + # Fixed incomes in four tiers, so every example compares distinct incomes. + st.lists(st.integers(0, 70_000), max_size=60).map( + lambda drawn: sorted([10_000, 25_000, 40_000, 60_000, *drawn]) + ), + st.integers(1_000, 20_000), +) +def test_required_contribution_rises_with_income(incomes, slcsp): + simulation = build( + [ + {"aca_magi": income, "slcsp": slcsp, "tax_unit_is_filer": True} + for income in incomes + ], + CONTRIBUTION_YEARS, + ) + income = np.array(incomes, dtype=float) + for year in CONTRIBUTION_YEARS: + fraction = np.asarray(simulation.calculate("aca_magi_fraction", year)) + rate = np.asarray( + simulation.calculate("aca_required_contribution_percentage", year) + ) + credit = np.asarray(simulation.calculate("aca_ptc_before_filing_gate", year)) + within = fraction <= 4 + contribution = (income * rate)[within] + # Property 4. + assert (np.diff(contribution) >= -1e-6).all(), year + assert (np.diff(credit[within]) <= 1e-6).all(), year diff --git a/policyengine_us/tests/policy/baseline/gov/aca/ptc/aca_ptc.yaml b/policyengine_us/tests/policy/baseline/gov/aca/ptc/aca_ptc.yaml index 2214672a9e5..ea6ec85cef9 100644 --- a/policyengine_us/tests/policy/baseline/gov/aca/ptc/aca_ptc.yaml +++ b/policyengine_us/tests/policy/baseline/gov/aca/ptc/aca_ptc.yaml @@ -276,3 +276,148 @@ output: is_aca_ptc_eligible: [false] aca_ptc: 0 + +# 2027 cases derive aca_required_contribution_percentage from the Rev. Proc. +# 2026-26 section 3.01 table. Household income is a multiple of the 2026 HHS +# poverty guideline ($15,960 for one person), the prior-year guideline that +# applies to 2027 coverage. +- name: 2027 ACA PTC at 100 percent FPL uses the flat 2.15 percent. + absolute_error_margin: 0.01 + period: 2027 + input: + is_aca_ptc_eligible: true + slcsp: 8_256 + aca_magi: 15_960 + aca_magi_fraction: 1.00 + tax_unit_is_filer: true + output: + aca_required_contribution_percentage: 0.0215 + aca_ptc: 7_912.86 # 8_256 - 0.0215 * 15_960 = 8_256 - 343.14 + +- name: 2027 ACA PTC at 133 percent FPL uses the 3.23 percent initial percentage. + absolute_error_margin: 0.01 + period: 2027 + input: + is_aca_ptc_eligible: true + slcsp: 8_256 + aca_magi: 21_226.80 + aca_magi_fraction: 1.33 + tax_unit_is_filer: true + output: + aca_required_contribution_percentage: 0.0323 + aca_ptc: 7_570.37 # 8_256 - 0.0323 * 21_226.80 = 8_256 - 685.63 + +- name: 2027 ACA PTC at 150 percent FPL uses 4.3 percent. + absolute_error_margin: 0.01 + period: 2027 + input: + is_aca_ptc_eligible: true + slcsp: 8_256 + aca_magi: 23_940 + aca_magi_fraction: 1.50 + tax_unit_is_filer: true + output: + aca_required_contribution_percentage: 0.043 + aca_ptc: 7_226.58 # 8_256 - 0.043 * 23_940 = 8_256 - 1_029.42 + +- name: 2027 ACA PTC at 200 percent FPL uses 6.78 percent (Covered California OE27 example). + absolute_error_margin: 0.01 + period: 2027 + input: + is_aca_ptc_eligible: true + slcsp: 8_256 + aca_magi: 31_920 + aca_magi_fraction: 2.00 + tax_unit_is_filer: true + output: + aca_required_contribution_percentage: 0.0678 + aca_ptc: 6_091.82 # 8_256 - 0.0678 * 31_920 = 8_256 - 2_164.18 + +- name: 2027 ACA PTC at 275 percent FPL interpolates to 9.44 percent. + absolute_error_margin: 0.01 + period: 2027 + input: + is_aca_ptc_eligible: true + slcsp: 8_256 + aca_magi: 43_890 + aca_magi_fraction: 2.75 + tax_unit_is_filer: true + output: + aca_required_contribution_percentage: 0.0944 + aca_ptc: 4_112.78 # 8_256 - 0.0944 * 43_890 = 8_256 - 4_143.22 + +- name: 2027 ACA PTC at 350 percent FPL uses the flat 10.22 percent. + absolute_error_margin: 0.01 + period: 2027 + input: + is_aca_ptc_eligible: true + slcsp: 8_256 + aca_magi: 55_860 + aca_magi_fraction: 3.50 + tax_unit_is_filer: true + output: + aca_required_contribution_percentage: 0.1022 + aca_ptc: 2_547.11 # 8_256 - 0.1022 * 55_860 = 8_256 - 5_708.89 + +- name: 2027 ACA PTC floors at zero when the 10.22 percent contribution exceeds the benchmark. + absolute_error_margin: 0.01 + period: 2027 + input: + is_aca_ptc_eligible: true + slcsp: 6_000 + aca_magi: 62_244 + aca_magi_fraction: 3.90 + tax_unit_is_filer: true + output: + aca_required_contribution_percentage: 0.1022 + aca_ptc: 0 # 0.1022 * 62_244 = 6_361.34 > 6_000 + +- name: 2027 ACA PTC derives 200 percent FPL from income and the 2026 poverty guideline. + absolute_error_margin: 0.01 + period: 2027 + input: + people: + person1: + age: 40 + is_aca_ptc_eligible: true + tax_units: + tax_unit: + members: [person1] + aca_magi: 31_920 + slcsp: 8_256 + tax_unit_is_filer: true + households: + household: + members: [person1] + state_code: TX + output: + aca_magi_fraction: 2.00 # 31_920 / 15_960 (2026 guideline, one person) + aca_required_contribution_percentage: 0.0678 + aca_ptc: 6_091.82 # 8_256 - 0.0678 * 31_920 + +- name: 2027 ACA PTC for a married couple at 250 percent FPL uses 8.66 percent. + absolute_error_margin: 0.01 + period: 2027 + input: + people: + person1: + age: 40 + is_aca_ptc_eligible: true + person2: + age: 40 + is_aca_ptc_eligible: true + tax_units: + tax_unit: + members: [person1, person2] + aca_magi: 54_100 + slcsp: 15_000 + tax_unit_is_filer: true + filing_status: JOINT + households: + household: + members: [person1, person2] + state_code: TX + output: + aca_magi_fraction: 2.50 # 54_100 / 21_640 (2026 guideline, two people) + aca_required_contribution_percentage: 0.0866 + aca_ptc: 10_314.94 # 15_000 - 0.0866 * 54_100 = 15_000 - 4_685.06 diff --git a/policyengine_us/tests/policy/baseline/gov/aca/ptc/aca_required_contribution_percentage.yaml b/policyengine_us/tests/policy/baseline/gov/aca/ptc/aca_required_contribution_percentage.yaml index 2bb9eedbbe6..0516ccdc7c5 100644 --- a/policyengine_us/tests/policy/baseline/gov/aca/ptc/aca_required_contribution_percentage.yaml +++ b/policyengine_us/tests/policy/baseline/gov/aca/ptc/aca_required_contribution_percentage.yaml @@ -197,3 +197,133 @@ aca_magi_fraction: 4.01 output: aca_required_contribution_percentage: 0 + +# 2027: Rev. Proc. 2026-26 section 3.01 table (initial -> final percentage): +# under 133% 2.15 -> 2.15; 133-150% 3.23 -> 4.3; 150-200% 4.3 -> 6.78; +# 200-250% 6.78 -> 8.66; 250-300% 8.66 -> 10.22; 300-400% 10.22 -> 10.22. +- name: 2027 ACA contribution percentage is flat 2.15 percent at 100 percent FPL. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 1.0 + output: + aca_required_contribution_percentage: 0.0215 + +- name: 2027 ACA contribution percentage is still 2.15 percent at 132 percent FPL. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 1.32 + output: + aca_required_contribution_percentage: 0.0215 + +- name: 2027 ACA contribution percentage jumps to 3.23 percent at 133 percent FPL. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 1.33 + output: + aca_required_contribution_percentage: 0.0323 + +- name: 2027 ACA contribution percentage interpolates within the 133-150 percent band. + # 3.23 + (1.40 - 1.33) / (1.50 - 1.33) * (4.3 - 3.23) = 3.6706%, which + # 26 CFR 1.36B-3(g)(1) rounds to 3.67%. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 1.40 + output: + aca_required_contribution_percentage: 0.0367 + +- name: 2027 ACA contribution percentage is 4.3 percent at 150 percent FPL. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 1.50 + output: + aca_required_contribution_percentage: 0.043 + +- name: 2027 ACA contribution percentage at the midpoint of the 150-200 percent band. + # 4.3 + 0.5 * (6.78 - 4.3) = 5.54%. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 1.75 + output: + aca_required_contribution_percentage: 0.0554 + +- name: 2027 ACA contribution percentage at 199 percent FPL, the top of the 150-200 percent band. + # 4.3 + (1.99 - 1.50) / (2.00 - 1.50) * (6.78 - 4.3) = 6.7304%, which + # 26 CFR 1.36B-3(g)(1) rounds to 6.73%. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 1.99 + output: + aca_required_contribution_percentage: 0.0673 + +- name: 2027 ACA contribution percentage is 6.78 percent at 200 percent FPL. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 2.00 + output: + aca_required_contribution_percentage: 0.0678 + +- name: 2027 ACA contribution percentage at the midpoint of the 200-250 percent band. + # 6.78 + 0.5 * (8.66 - 6.78) = 7.72%. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 2.25 + output: + aca_required_contribution_percentage: 0.0772 + +- name: 2027 ACA contribution percentage is 8.66 percent at 250 percent FPL. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 2.50 + output: + aca_required_contribution_percentage: 0.0866 + +- name: 2027 ACA contribution percentage at the midpoint of the 250-300 percent band. + # 8.66 + 0.5 * (10.22 - 8.66) = 9.44%. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 2.75 + output: + aca_required_contribution_percentage: 0.0944 + +- name: 2027 ACA contribution percentage is 10.22 percent at 300 percent FPL. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 3.00 + output: + aca_required_contribution_percentage: 0.1022 + +- name: 2027 ACA contribution percentage is flat 10.22 percent at 350 percent FPL. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 3.50 + output: + aca_required_contribution_percentage: 0.1022 + +- name: 2027 ACA contribution percentage is 10.22 percent at 400 percent FPL. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 4.0 + output: + aca_required_contribution_percentage: 0.1022 + +- name: 2027 ACA contribution percentage is zero above 400 percent FPL. + period: 2027 + absolute_error_margin: 0.00001 + input: + aca_magi_fraction: 4.01 + output: + aca_required_contribution_percentage: 0