diff --git a/changelog.d/use-pe-us-retirement-limits.changed.md b/changelog.d/use-pe-us-retirement-limits.changed.md new file mode 100644 index 00000000..cdd331fe --- /dev/null +++ b/changelog.d/use-pe-us-retirement-limits.changed.md @@ -0,0 +1 @@ +Read IRS retirement contribution limits from policyengine-us parameters instead of hard-coding them. \ No newline at end of file diff --git a/policyengine_us_data/datasets/cps/cps.py b/policyengine_us_data/datasets/cps/cps.py index 58d34909..fc6b559b 100644 --- a/policyengine_us_data/datasets/cps/cps.py +++ b/policyengine_us_data/datasets/cps/cps.py @@ -649,51 +649,11 @@ def add_personal_income_variables( # Disregard reported pension contributions from people who report neither wage and salary # nor self-employment income. # Assume no 403(b) or 457 contributions for now. - # IRS retirement contribution limits by year. - RETIREMENT_LIMITS = { - 2020: { - "401k": 19_500, - "401k_catch_up": 6_500, - "ira": 6_000, - "ira_catch_up": 1_000, - }, - 2021: { - "401k": 19_500, - "401k_catch_up": 6_500, - "ira": 6_000, - "ira_catch_up": 1_000, - }, - 2022: { - "401k": 20_500, - "401k_catch_up": 6_500, - "ira": 6_000, - "ira_catch_up": 1_000, - }, - 2023: { - "401k": 22_500, - "401k_catch_up": 7_500, - "ira": 6_500, - "ira_catch_up": 1_000, - }, - 2024: { - "401k": 23_000, - "401k_catch_up": 7_500, - "ira": 7_000, - "ira_catch_up": 1_000, - }, - 2025: { - "401k": 23_500, - "401k_catch_up": 7_500, - "ira": 7_000, - "ira_catch_up": 1_000, - }, - } - # Clamp to the nearest available year for out-of-range values. - clamped_year = max( - min(year, max(RETIREMENT_LIMITS)), - min(RETIREMENT_LIMITS), + from policyengine_us_data.utils.retirement_limits import ( + get_retirement_limits, ) - limits = RETIREMENT_LIMITS[clamped_year] + + limits = get_retirement_limits(year) LIMIT_401K = limits["401k"] LIMIT_401K_CATCH_UP = limits["401k_catch_up"] LIMIT_IRA = limits["ira"] diff --git a/policyengine_us_data/tests/test_retirement_limits.py b/policyengine_us_data/tests/test_retirement_limits.py new file mode 100644 index 00000000..aa84bda1 --- /dev/null +++ b/policyengine_us_data/tests/test_retirement_limits.py @@ -0,0 +1,35 @@ +"""Tests for retirement contribution limits utility.""" + +import pytest +from policyengine_us_data.utils.retirement_limits import ( + get_retirement_limits, +) + +# Expected values sourced from IRS announcements and policyengine-us +# parameter tree. +EXPECTED = { + 2020: { + "401k": 19_500, + "401k_catch_up": 6_500, + "ira": 6_000, + "ira_catch_up": 1_000, + }, + 2023: { + "401k": 22_500, + "401k_catch_up": 7_500, + "ira": 6_500, + "ira_catch_up": 1_000, + }, + 2025: { + "401k": 23_500, + "401k_catch_up": 7_500, + "ira": 7_000, + "ira_catch_up": 1_000, + }, +} + + +@pytest.mark.parametrize("year", EXPECTED.keys()) +def test_retirement_limits(year): + limits = get_retirement_limits(year) + assert limits == EXPECTED[year] diff --git a/policyengine_us_data/utils/retirement_limits.py b/policyengine_us_data/utils/retirement_limits.py new file mode 100644 index 00000000..c4d2cfa9 --- /dev/null +++ b/policyengine_us_data/utils/retirement_limits.py @@ -0,0 +1,36 @@ +"""Retirement contribution limits from policyengine-us parameters. + +Reads IRS contribution limits from the policyengine-us parameter tree +instead of hard-coding them. +""" + +from functools import lru_cache + + +@lru_cache(maxsize=16) +def get_retirement_limits(year: int) -> dict: + """Return contribution limits for the given tax year. + + Reads from policyengine-us parameters at: + gov.irs.gross_income.retirement_contributions.limit.{401k, ira} + gov.irs.gross_income.retirement_contributions.catch_up.limit.{k401, ira} + + The k401 catch-up parameter is a SingleAmountTaxScale with age + brackets (SECURE 2.0); we use the age-50 bracket for the standard + catch-up amount. + + Returns: + Dict with keys: 401k, 401k_catch_up, ira, ira_catch_up. + """ + from policyengine_us import CountryTaxBenefitSystem + + tbs = CountryTaxBenefitSystem() + p = tbs.parameters.gov.irs.gross_income.retirement_contributions + d = f"{year}-01-01" + + return { + "401k": int(p.limit.children["401k"](d)), + "401k_catch_up": int(p.catch_up.limit.children["k401"](d).calc(50)), + "ira": int(p.limit.ira(d)), + "ira_catch_up": int(p.catch_up.limit.ira(d)), + } diff --git a/pyproject.toml b/pyproject.toml index ed776212..72a36a7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dependencies = [ - "policyengine-us>=1.516.0", + "policyengine-us>=1.572.5", "policyengine-core>=3.23.6", "pandas>=2.3.1", "requests>=2.25.0", diff --git a/uv.lock b/uv.lock index 977a0c87..5654be7c 100644 --- a/uv.lock +++ b/uv.lock @@ -637,6 +637,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" }, { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" }, { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" }, { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" }, { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" }, { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" }, @@ -644,6 +645,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, + { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" }, { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, @@ -1842,7 +1844,7 @@ wheels = [ [[package]] name = "policyengine-us" -version = "1.570.7" +version = "1.590.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "microdf-python" }, @@ -1850,9 +1852,9 @@ dependencies = [ { name = "policyengine-core" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6a/eb/291b3085aa0fa97fcce4987d54991118f21aead49647b3f475998459f46b/policyengine_us-1.570.7.tar.gz", hash = "sha256:a2967af86a61468a0bdb6b2dc7af2fd0bb0f0064203fa557b6fee8023058360a", size = 8668680, upload-time = "2026-02-19T07:17:11.264Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/96/b53c2c3f766fbc6a750eb7b802fcb1c073b58a6d311d0625abd794760e48/policyengine_us-1.590.2.tar.gz", hash = "sha256:44bce7db8a515d74a81056ee7326dbac4c48b92bf86e0c1c40a7aefa228ee966", size = 8674022, upload-time = "2026-03-03T20:17:30.386Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/36/0213955310076e4dec2781baeabf96b6d6937f99cd19c373b363bfbd7152/policyengine_us-1.570.7-py3-none-any.whl", hash = "sha256:374fd5357d6cb3734b900bd08dfdb61760dfc5b913ed686f57a40239565b0edd", size = 7825404, upload-time = "2026-02-19T07:17:08.877Z" }, + { url = "https://files.pythonhosted.org/packages/d7/16/2167ea98f16b36c5f47851752300d7d9c63cad9b3988dc633b94ae0b3710/policyengine_us-1.590.2-py3-none-any.whl", hash = "sha256:d9b7ea7671704c5c831d24680e92fe249529ef9b0203b7d915e4aa7708f2576a", size = 8016168, upload-time = "2026-03-03T20:17:27.187Z" }, ] [[package]] @@ -1919,7 +1921,7 @@ requires-dist = [ { name = "pandas", specifier = ">=2.3.1" }, { name = "pip-system-certs", specifier = ">=3.0" }, { name = "policyengine-core", specifier = ">=3.23.6" }, - { name = "policyengine-us", specifier = ">=1.516.0" }, + { name = "policyengine-us", specifier = ">=1.572.5" }, { name = "requests", specifier = ">=2.25.0" }, { name = "samplics", marker = "extra == 'calibration'" }, { name = "scipy", specifier = ">=1.15.3" },