diff --git a/docs/inflation_indexing.md b/docs/inflation_indexing.md new file mode 100644 index 0000000..d14e48a --- /dev/null +++ b/docs/inflation_indexing.md @@ -0,0 +1,83 @@ +# Inflation Indexing in FinSim + +FinSim properly accounts for inflation in retirement planning simulations using different inflation indices for different purposes, matching real-world practices. + +## Inflation Rates Used + +### 1. Social Security COLA (Cost-of-Living Adjustment) +- **Index**: CPI-W (Consumer Price Index for Urban Wage Earners and Clerical Workers) +- **Default Rate**: 2.3% annually +- **Application**: Social Security benefits are adjusted annually +- **Parameter**: `social_security_cola_rate` +- **Source**: Based on SSA's uprating schedule from PolicyEngine-US + +### 2. General Consumption Inflation +- **Index**: C-CPI-U (Chained Consumer Price Index for All Urban Consumers) +- **Default Rate**: 2.5% annually +- **Application**: Annual consumption/expenses grow with this rate +- **Parameter**: `consumption_inflation_rate` +- **Source**: Based on long-term C-CPI-U averages from BLS + +### 3. Employment Income Growth +- **Index**: Nominal wage growth (includes inflation + real wage growth) +- **Default Rate**: 0% (user-specified) +- **Application**: Wages before retirement +- **Parameter**: `employment_growth_rate` +- **Note**: Typically 3-4% to include both inflation and productivity gains + +## Why Different Rates? + +The U.S. government uses different inflation measures for different purposes: + +- **CPI-W** for Social Security: Measures inflation experienced by wage earners and clerical workers. Historically runs slightly lower than CPI-U. + +- **C-CPI-U** for general inflation: A more accurate measure that accounts for consumer substitution between goods when prices change. Used for tax brackets since 2018. + +- **Nominal wage growth**: Includes both inflation and real wage growth from productivity improvements. + +## Historical Differences + +Over the long term: +- CPI-W averages around 2.3% annually +- C-CPI-U averages around 2.5% annually +- Nominal wage growth averages 3-4% annually + +These small differences compound significantly over a 30+ year retirement. + +## Implementation Details + +```python +# Year-by-year adjustments in portfolio_simulation.py + +# Social Security with COLA (CPI-W based) +years_of_cola = year - 1 +cola_factor = (1 + social_security_cola_rate / 100) ** years_of_cola +current_social_security = social_security * cola_factor + +# Consumption with inflation (C-CPI-U based) +years_of_inflation = year - 1 +inflation_factor = (1 + consumption_inflation_rate / 100) ** years_of_inflation +current_consumption = annual_consumption * inflation_factor + +# Employment income growth (nominal) +years_of_growth = year - 1 +growth_factor = (1 + employment_growth_rate / 100) ** years_of_growth +wages = employment_income * growth_factor +``` + +## Customization + +Users can override the default rates based on their expectations: + +```python +simulate_portfolio( + # ... other parameters ... + social_security_cola_rate=2.0, # More conservative COLA + consumption_inflation_rate=3.0, # Higher inflation expectation + employment_growth_rate=4.0, # Nominal wage growth +) +``` + +## Future Enhancements + +The `inflation.py` module includes support for using actual CPI data from PolicyEngine-US when available, allowing simulations to use historical inflation rates for backtesting or more sophisticated projections based on current economic conditions. \ No newline at end of file diff --git a/finsim/cola.py b/finsim/cola.py new file mode 100644 index 0000000..3a516dd --- /dev/null +++ b/finsim/cola.py @@ -0,0 +1,176 @@ +"""Social Security COLA calculations using actual SSA uprating from PolicyEngine-US. + +Uses the actual SSA uprating schedule which includes: +- Historical CPI-W based adjustments through 2024 +- CBO projections for 2025-2035 +""" + +import numpy as np +from typing import Optional + + +# Hardcoded SSA uprating values from PolicyEngine-US +# Source: policyengine_us/parameters/gov/ssa/uprating.yaml +SSA_UPRATING = { + 2022: 268.421, + 2023: 291.901, + 2024: 301.236, + 2025: 310.866, + 2026: 318.155, + 2027: 326.149, + 2028: 332.241, + 2029: 339.431, + 2030: 346.917, + 2031: 354.6, + 2032: 362.579, + 2033: 370.656, + 2034: 379.028, + 2035: 387.598, +} + +# Hardcoded C-CPI-U values from PolicyEngine-US +# Source: policyengine_us/parameters/gov/bls/cpi/c_cpi_u.yaml +C_CPI_U = { + 2024: 171.910, + 2025: 176.7, # February 2025 value (for 2026 tax parameters) + 2026: 180.5, + 2027: 184.1, + 2028: 187.8, + 2029: 191.5, + 2030: 195.3, + 2031: 199.1, + 2032: 203.1, + 2033: 207.1, + 2034: 211.2, + 2035: 215.4, +} + +def get_ssa_cola_factors(start_year: int, n_years: int) -> np.ndarray: + """Get Social Security COLA factors using actual SSA uprating schedule. + + Args: + start_year: Starting year of simulation + n_years: Number of years to simulate + + Returns: + Array of cumulative COLA factors (1.0 for year 1, then compounding) + """ + cola_factors = np.ones(n_years) + + # Use hardcoded values first, then try PolicyEngine-US for extended years + base_uprating = None + + for year_idx in range(n_years): + current_year = start_year + year_idx + + if current_year in SSA_UPRATING: + # Use hardcoded value + uprating = SSA_UPRATING[current_year] + else: + # Try to get from PolicyEngine-US for years beyond 2035 + try: + from policyengine_us import Microsimulation + from policyengine_core.periods import instant + + # Only create simulation once + if 'sim' not in locals(): + sim = Microsimulation(dataset="cps_2024") + parameters = sim.tax_benefit_system.parameters + + period = instant(f"{current_year}-01-01") + uprating = parameters.gov.ssa.uprating(period) + except: + # For years beyond available data, use 2.2% annual growth (long-term average) + if year_idx > 0: + prev_year = start_year + year_idx - 1 + if prev_year in SSA_UPRATING: + uprating = SSA_UPRATING[prev_year] * 1.022 + else: + uprating = cola_factors[year_idx - 1] * 1.022 + else: + uprating = SSA_UPRATING.get(2035, 387.598) * ((current_year - 2035) * 0.022 + 1) + + if base_uprating is None: + base_uprating = uprating + cola_factors[year_idx] = 1.0 + else: + # Calculate cumulative factor from base year + cola_factors[year_idx] = uprating / base_uprating + + return cola_factors + + +def get_consumption_inflation_factors(start_year: int, n_years: int) -> np.ndarray: + """Get consumption inflation factors using C-CPI-U from PolicyEngine-US. + + Args: + start_year: Starting year of simulation + n_years: Number of years to simulate + + Returns: + Array of cumulative inflation factors (1.0 for year 1, then compounding) + """ + inflation_factors = np.ones(n_years) + + # Use hardcoded values first, then extrapolate if needed + base_cpi = None + + for year_idx in range(n_years): + current_year = start_year + year_idx + + if current_year in C_CPI_U: + # Use hardcoded value + cpi = C_CPI_U[current_year] + else: + # For years beyond available data, use 2.0% annual growth (Fed target) + if year_idx > 0: + prev_year = start_year + year_idx - 1 + if prev_year in C_CPI_U: + cpi = C_CPI_U[prev_year] * 1.020 + else: + # Use previous calculated value + cpi = base_cpi * inflation_factors[year_idx - 1] * 1.020 + else: + cpi = C_CPI_U.get(2035, 215.4) * ((current_year - 2035) * 0.020 + 1) + + if base_cpi is None: + base_cpi = cpi + inflation_factors[year_idx] = 1.0 + else: + # Calculate cumulative factor from base year + inflation_factors[year_idx] = cpi / base_cpi + + return inflation_factors + + +if __name__ == "__main__": + print("Testing SSA COLA and C-CPI-U from PolicyEngine-US") + print("=" * 60) + + try: + # Test SSA COLA + cola_factors = get_ssa_cola_factors(2025, 10) + print("\nSSA COLA factors (2025-2034):") + for i in range(10): + year = 2025 + i + if i == 0: + print(f" {year}: {cola_factors[i]:.3f} (base year)") + else: + annual_rate = (cola_factors[i] / cola_factors[i-1] - 1) * 100 + print(f" {year}: {cola_factors[i]:.3f} ({annual_rate:.1f}% annual, {(cola_factors[i]-1)*100:.1f}% cumulative)") + + # Test C-CPI-U + inflation_factors = get_consumption_inflation_factors(2025, 10) + print("\nC-CPI-U inflation factors (2025-2034):") + for i in range(10): + year = 2025 + i + if i == 0: + print(f" {year}: {inflation_factors[i]:.3f} (base year)") + else: + annual_rate = (inflation_factors[i] / inflation_factors[i-1] - 1) * 100 + print(f" {year}: {inflation_factors[i]:.3f} ({annual_rate:.1f}% annual, {(inflation_factors[i]-1)*100:.1f}% cumulative)") + + except ImportError as e: + print(f"Error: {e}") + except Exception as e: + print(f"Error: {e}") \ No newline at end of file diff --git a/finsim/inflation.py b/finsim/inflation.py new file mode 100644 index 0000000..7669cac --- /dev/null +++ b/finsim/inflation.py @@ -0,0 +1,143 @@ +"""Inflation utilities for FinSim. + +This module provides inflation adjustment using either: +1. A fixed annual rate (default) +2. Actual C-CPI-U data from PolicyEngine-US (if available) +""" + +import numpy as np +from typing import Optional, List + + +def get_inflation_factors( + start_year: int, + n_years: int, + fixed_rate: float = 2.5, + use_actual_cpi: bool = False +) -> np.ndarray: + """Get inflation factors for each year of simulation. + + Args: + start_year: Starting year of simulation + n_years: Number of years to simulate + fixed_rate: Fixed annual inflation rate (as percentage, e.g., 2.5 for 2.5%) + use_actual_cpi: Whether to use actual C-CPI-U data from PolicyEngine + + Returns: + Array of cumulative inflation factors (1.0 for year 1, then compounding) + """ + inflation_factors = np.ones(n_years) + + if use_actual_cpi: + try: + # Try to get actual C-CPI-U data from PolicyEngine + from policyengine_us import Microsimulation + from policyengine_core.periods import instant + + # Create a minimal simulation to access parameters + sim = Microsimulation(dataset="cps_2024") + parameters = sim.tax_benefit_system.parameters + + # Get C-CPI-U values for each year + base_cpi = None + for year in range(n_years): + current_year = start_year + year + + # Get December CPI for each year (or latest available) + try: + # Try December of the year + period = instant(f"{current_year}-12-01") + cpi = parameters.gov.bls.cpi.c_cpi_u(period) + except: + # Fall back to January of next year if December not available + try: + period = instant(f"{current_year+1}-01-01") + cpi = parameters.gov.bls.cpi.c_cpi_u(period) + except: + # If future year, use fixed rate + if year > 0: + inflation_factors[year] = inflation_factors[year-1] * (1 + fixed_rate / 100) + continue + + if base_cpi is None: + base_cpi = cpi + inflation_factors[year] = 1.0 + else: + inflation_factors[year] = cpi / base_cpi + + print(f"Using actual C-CPI-U data from {start_year}") + return inflation_factors + + except ImportError: + print("PolicyEngine-US not available, using fixed inflation rate") + except Exception as e: + print(f"Could not load C-CPI-U data: {e}, using fixed rate") + + # Use fixed rate + for year in range(1, n_years): + inflation_factors[year] = inflation_factors[year-1] * (1 + fixed_rate / 100) + + return inflation_factors + + +def inflate_value( + base_value: float, + year_index: int, + inflation_factors: np.ndarray +) -> float: + """Inflate a base value to a specific year. + + Args: + base_value: Value in base year dollars + year_index: Year index (0-based) + inflation_factors: Array of cumulative inflation factors + + Returns: + Inflated value + """ + if year_index < 0 or year_index >= len(inflation_factors): + return base_value + + return base_value * inflation_factors[year_index] + + +def calculate_real_return( + nominal_return: float, + inflation_rate: float +) -> float: + """Calculate real return from nominal return and inflation. + + Uses the Fisher equation: (1 + r_real) = (1 + r_nominal) / (1 + inflation) + + Args: + nominal_return: Nominal return rate (as decimal, e.g., 0.07 for 7%) + inflation_rate: Inflation rate (as decimal, e.g., 0.025 for 2.5%) + + Returns: + Real return rate (as decimal) + """ + return (1 + nominal_return) / (1 + inflation_rate) - 1 + + +if __name__ == "__main__": + # Test inflation calculations + print("Testing inflation calculations") + print("=" * 50) + + # Test fixed rate + factors_fixed = get_inflation_factors(2025, 10, fixed_rate=2.5) + print(f"\nFixed 2.5% inflation over 10 years:") + for i, factor in enumerate(factors_fixed): + print(f" Year {i+1}: {factor:.3f} ({(factor-1)*100:.1f}% cumulative)") + + # Test with actual CPI if available + factors_actual = get_inflation_factors(2020, 5, use_actual_cpi=True) + print(f"\nActual C-CPI-U from 2020-2024:") + for i, factor in enumerate(factors_actual): + print(f" Year {2020+i}: {factor:.3f} ({(factor-1)*100:.1f}% cumulative)") + + # Test real return calculation + nominal = 0.07 # 7% nominal + inflation = 0.025 # 2.5% inflation + real = calculate_real_return(nominal, inflation) + print(f"\nReal return: {nominal*100:.1f}% nominal - {inflation*100:.1f}% inflation = {real*100:.2f}% real") \ No newline at end of file diff --git a/finsim/portfolio_simulation.py b/finsim/portfolio_simulation.py index a74bd05..d7fcea0 100644 --- a/finsim/portfolio_simulation.py +++ b/finsim/portfolio_simulation.py @@ -11,6 +11,7 @@ USE_MORTALITY_PACKAGE = False from .mortality_enhanced import EnhancedMortality from .return_generator import ReturnGenerator +from .cola import get_ssa_cola_factors, get_consumption_inflation_factors def simulate_portfolio( @@ -86,6 +87,12 @@ def simulate_portfolio( filing_status = "JOINT" if has_spouse else "SINGLE" tax_calc = TaxCalculator(state=state, year=2025) + # Get inflation factors from PolicyEngine-US projections + # These use actual SSA uprating (CPI-W) and C-CPI-U schedules + START_YEAR = 2025 # TODO: Make this configurable + cola_factors = get_ssa_cola_factors(START_YEAR, n_years) + inflation_factors = get_consumption_inflation_factors(START_YEAR, n_years) + # Get mortality rates if needed if USE_MORTALITY_PACKAGE and include_mortality: # Use the mortality package for clean SSA tables @@ -253,18 +260,30 @@ def simulate_portfolio( spouse_ss = np.where(spouse_alive_mask[:, year], spouse_social_security, 0) spouse_pens = np.where(spouse_alive_mask[:, year], spouse_pension, 0) + # Apply COLA to Social Security using actual SSA uprating schedule + # Note: Pensions typically don't have COLA unless specified + cola_factor = cola_factors[year - 1] # Get pre-calculated factor + + # Apply COLA to Social Security (but not pensions, which typically don't have COLA) + current_social_security = social_security * cola_factor + current_spouse_ss = spouse_ss * cola_factor + # Total household income total_employment = wages + spouse_wages - total_ss_pension = social_security + pension + spouse_ss + spouse_pens + total_ss_pension = current_social_security + pension + current_spouse_ss + spouse_pens guaranteed_income = total_ss_pension + annuity_income[:, year-1] + total_employment total_income_available = guaranteed_income + dividends - # What we need to withdraw = consumption + last year's taxes - available income + # Calculate inflation-adjusted consumption using actual C-CPI-U projections + inflation_factor = inflation_factors[year - 1] # Get pre-calculated factor + current_consumption = annual_consumption * inflation_factor + + # What we need to withdraw = inflation-adjusted consumption + last year's taxes - available income withdrawal_need = np.zeros(n_simulations) withdrawal_need[active] = np.maximum( 0, - annual_consumption + prior_year_tax_liability[active] - total_income_available[active] + current_consumption + prior_year_tax_liability[active] - total_income_available[active] ) # This is our actual gross withdrawal (no tax gross-up needed!) diff --git a/issues/use_policyengine_projections.md b/issues/use_policyengine_projections.md new file mode 100644 index 0000000..862ccb6 --- /dev/null +++ b/issues/use_policyengine_projections.md @@ -0,0 +1,75 @@ +# Issue: Replace hardcoded inflation values with PolicyEngine-US API + +## Current State +Currently, `finsim/cola.py` hardcodes SSA uprating and C-CPI-U values from PolicyEngine-US because: +1. The current PolicyEngine-US parameters only extend to 2035 +2. Many retirement simulations need 30+ year projections (to 2055+) + +## Hardcoded Values +```python +# From cola.py +SSA_UPRATING = { + 2022: 268.421, + 2023: 291.901, + ... + 2035: 387.598, +} + +C_CPI_U = { + 2024: 171.910, + 2025: 176.7, + ... + 2035: 215.4, +} +``` + +## Solution +Once PolicyEngine/policyengine-us#6384 is merged (extends projections to 2100), update `cola.py` to: + +1. Remove hardcoded dictionaries +2. Use PolicyEngine-US API directly: + +```python +def get_ssa_cola_factors(start_year: int, n_years: int) -> np.ndarray: + from policyengine_us import Microsimulation + from policyengine_core.periods import instant + + sim = Microsimulation(dataset="cps_2024") + parameters = sim.tax_benefit_system.parameters + + cola_factors = np.ones(n_years) + base_uprating = None + + for year_idx in range(n_years): + current_year = start_year + year_idx + period = instant(f"{current_year}-01-01") + uprating = parameters.gov.ssa.uprating(period) + + if base_uprating is None: + base_uprating = uprating + cola_factors[year_idx] = 1.0 + else: + cola_factors[year_idx] = uprating / base_uprating + + return cola_factors +``` + +## Benefits +- Always uses latest projections from PolicyEngine-US +- Automatically incorporates CBO forecast updates +- Consistent with tax calculations +- No manual maintenance needed + +## Dependencies +- Requires PolicyEngine-US with PR #6384 merged +- PR extends uprating/CPI projections to 2100 + +## Files to Update +- `finsim/cola.py`: Remove hardcoded values, use API +- `docs/inflation_indexing.md`: Update to note dynamic sourcing + +## Testing +Ensure that: +1. Projections match for years 2025-2035 (current hardcoded range) +2. Projections extend smoothly beyond 2035 +3. Performance is acceptable (may need caching if API calls are slow) \ No newline at end of file