4% Safe Withdrawal Rate Is Valid For India

I have been thinking about retirement for the past decade.

“If you don’t find a way to make money while you sleep, you will work until you die.” _ Warren Buffet

That is the quote that got me obsessed with it — I didn't want to work till I die. I suppose nobody does.

Even though I have the dream to retire early and obsess over it, the standard advice I hear everywhere is to work till you die.

Isn't that a bummer?

They defend the idea by putting a positive spin on it, claiming it is your fault for not choosing work that you like.

I suppose the argument has some merit, since life is known to be a bitch!

I concede that safety lies in having an active income, but the most valuable asset you can ever own is time—the ability to do whatever you want, whenever you want to do it.

Now, conquering time is not possible if you are dependent on an active income, because an active income requires your performance, your productivity, your politics etc.

Don't get me wrong, if you are bored of sitting around doing nothing, then you should get to work - but where I disagree is what kind of work.

You can work on yourself, you can work on a hobby that you suck at, you can do anything you want.

Heck if you are a funny guy - work can be attending job interviews, just to troll the interviewer for a change.

And if you get bored of trolling the interviewer too, you can get a job, and quit it the next week.

The point is you shouldn't be constrained by money - you can do whatever you want to do.

Unfortunately, the standard Indian advice of retirement doesn't provide you the path for it, as their expectation is for you to work till you die.

The model that is popular in India for retirement planning is the safe-withdrawal-rate.

While William Bengen's original 4% withdrawal rate is great, the Indian financial space seems to have forgotten that SWR is actually the maximum withdrawal rate you can use safely - the idea is not to be blindly conservative.

Without understanding the "maximum" aspect of the safe withdrawal rate, the industry has put itself in reverse.

I have heard withdrawal rates starting from 3.5% all the way down to 1.5%, with the excuse that India is different from the US and that the higher inflation rates demand lower SWRs.

I call BS!

The 4% rule is built with real growth, not nominal growth.

It was stress-tested against the absolute worst periods in American history.

It assumes a person retired at the exact moment the market crashed, followed immediately by a decade of double-digit inflation and stagnant markets.

India has so far not seen such a scenario in recorded history.

Even when India experienced double-digit inflation, it was accompanied by double-digit market growth.

Even in the modern context, the real growth of both markets is too similar to justify modifying the 4% rule.

Heck, it can even be argued that India can have a higher withdrawal rate than 4% because the Indian market recovers faster than the American counter-part.

Please refer to the image below:



After the dot-com bubble, the S&P 500 (represented in green) took 13 painful years to recover.

Meanwhile, the Nifty 500 (in blue) recovered in just two years, went on a massive bull run, absorbed the 2008 financial crash, and recovered from that in 7 years.

And so I do believe that in a fast growing economy, we can allow higher withdrawal rates than 4% since we tend to recover faster than the American counter-part.

However, for the sake of this post - I will stick to the original 4% rule and will try to establish how both countries are identical in regards to performance without factoring in the recovery period.

To prove this, I will demonstrate the similarity of the Nifty 500 against the S&P 500 adjusted for inflation.

And for the debt component, I will also compare the real returns of the SBI Magnum Income Fund with the Vanguard Total Bond Market.

Below is the inflation-adjusted growth of the equity section:


Looking at the chart, you can see how closely they track one another. They are constantly pushing and pulling, almost like a dance.

Now you may look at the chart above and say, "The 2008 peak, it took 12 years to recover in real terms."

Yes it did, but the 4% rule is designed for even worse situations. Take a look at the real returns of the S&P 500 during the late 1960s to early 1990s:


A painful 30+ years of no returns in real terms.

Coming back to the comparison of the Nifty 500 and S&P 500, to make the relationship between the two even clearer, below is the exact same data represented as a 1-year rolling return. I chose the 1-year window because the original Trinity Study assumes you adjust your withdrawals for inflation annually.


And finally the debt portion of the equation:

As you can see both of them move more or less in tandem with each other.

But what I would like to point out more is the 2020 Covid scenario, where America took the policy of quantitative easing and turned on their money printer.

The inflation absolutely wrecked their debt market during the time in terms of real returns - but nobody in America started to demand a lower safe withdrawal rate.

What makes the scenario more dire is that William Bengen himself revised the 4% rule, stating that his 4% rule was never meant to be used as a hard rule - it was meant to survive the 30+ years of no real returns that occurred in the 1960s.

That is how you get the 4% for 30-year retirement math - it was never a math meant to last during a normal economic cycle.

Can India hit a similar 30-year period of no real return? Absolutely it can, but it hasn't happened so far in recorded history to consider it - imo.

If we are going to imagine scenarios which could possibly happen, there wouldn't be any reason to plan things - we could easily imagine scenarios where India becomes a country like Cuba.

We can never guarantee a 100% success - no matter how conservative we get, unless we go for an absurd withdrawal rate like 0.5%.

And if you plan for those - even if you plan for 1.5% or 2%, chances are you traded decades in work for a scenario which has a very low probability of occurring.

But luckily the people who does actual research has a similar (but not same) conclusion as me.

I disagree with their methodology of using taxes to find the Safe withdrawal rates, as they use the taxation to reach the withdrwal rates, something which the original US paper didnt do.

And without the taxation, their recomendation was 4.2% Withdrawal rate -  similar to the 4.1%  that William bengen found.

This is the qoute from their study : 

The findings of this study provide clear and actionable insights for retirement planningin India, addressing the challenges posed by high inflation, volatile asset returns, and evolving tax policies. The proposed Budget 2025 reforms, particularly the increase in the tax-free income threshold to |12 lakh, significantly enhance safe withdrawal rates (SWRs), allowing retirees to withdraw 3.5% to 4.2% of their starting corpus annually. For retirees with taxable incomes between 16 and 20 lakh, a more conservative SWR of 3.0% to 3.5% is recommended. Those in higher tax brackets face lower SWRs, emphasising the importance of tax-aware strategies to sustain portfolio longevity

But as mentioned before, I disagree with the researcher that it should be planned based on tax-aware strategies. 

I have not seen any other retirement model which considers Tax for their retirement model.

And the reason why they don't is because we cannot reliably predict what the government will decide in the future.

Take indian Governent itself for example,  Initially they started with LTCG tax, then they replaced it with STT making LTCG 0% and now we have LTCG+STT, what gaurantee do you have that they wont add an another tax thing to it? Is it not possible that they will remove all LTCG again?

And so the only rational thing to do would be to consider the taxation part of things as expense, just like the original study.


Please find the attached full study below : 


If there is any deficiency in my thought process or if you would like a deep-dive on what to expect with a 4% withdrawal rate from our history, please do let me know.

Below are the scripts used to generate the inflation adjusted returns, these were generated by AI - feel free to review them:


1. Real Return Base-100 (Nifty 500 vs S&P 500)

import yfinance as yf
import pandas as pd
import matplotlib.pyplot as plt
import pandas_datareader.data as web
from datetime import datetime, timedelta
import warnings

# Suppress future warnings from pandas for cleaner terminal output
warnings.simplefilter(action='ignore', category=FutureWarning)

# 1. Define Parameters
tickers = {'NIFTY 500': '^CRSLDX', 'S&P 500': '^GSPC'} 
end_date = datetime.today()
start_date = end_date - timedelta(days=20*365) # Approx 20 years

# 2. Fetch Stock Data (Fixed to download individually and use 'Close')
print("Downloading stock market data...")
df_nifty = yf.download('^CRSLDX', start=start_date, end=end_date)
df_sp500 = yf.download('^GSPC', start=start_date, end=end_date)

# Combine into a single DataFrame using the 'Close' prices
stock_data = pd.DataFrame({
    'NIFTY 500': df_nifty['Close'].squeeze(),
    'S&P 500': df_sp500['Close'].squeeze()
})
stock_data = stock_data.dropna()

# 3. Fetch CPI Data from FRED
print("Downloading historical CPI data from FRED...")
# CPIAUCSL: US Consumer Price Index for All Urban Consumers
# INDCPIALLMINMEI: India Consumer Price Index (OECD data via FRED)
cpi_data = web.DataReader(['CPIAUCSL', 'INDCPIALLMINMEI'], 'fred', start_date, end_date)
cpi_data.rename(columns={'CPIAUCSL': 'US_CPI', 'INDCPIALLMINMEI': 'INDIA_CPI'}, inplace=True)

# 4. Align CPI (Monthly) with Stock Data (Daily)
print("Aligning inflation data with market returns...")
combined_data = stock_data.join(cpi_data, how='left')
# Forward-fill and backward-fill the monthly CPI values to daily dates
combined_data['US_CPI'] = combined_data['US_CPI'].ffill().bfill()
combined_data['INDIA_CPI'] = combined_data['INDIA_CPI'].ffill().bfill()

# 5. Calculate Dynamic Real Returns (Base = 100)
# Formula: Real Value = Initial_Investment * (Current_Stock / Initial_Stock) * (Initial_CPI / Current_CPI)
initial_stock_nifty = combined_data['NIFTY 500'].iloc[0]
initial_stock_sp500 = combined_data['S&P 500'].iloc[0]

initial_cpi_india = combined_data['INDIA_CPI'].iloc[0]
initial_cpi_us = combined_data['US_CPI'].iloc[0]

combined_data['NIFTY_Real_100'] = 100 * (combined_data['NIFTY 500'] / initial_stock_nifty) * (initial_cpi_india / combined_data['INDIA_CPI'])
combined_data['SP500_Real_100'] = 100 * (combined_data['S&P 500'] / initial_stock_sp500) * (initial_cpi_us / combined_data['US_CPI'])

# 6. Plot the Graph
print("Generating graph...")
plt.figure(figsize=(12, 6))

plt.plot(combined_data.index, combined_data['NIFTY_Real_100'], label='NIFTY 500 (Real Return, adjusted via India CPI)', color='green', linewidth=2)
plt.plot(combined_data.index, combined_data['SP500_Real_100'], label='S&P 500 (Real Return, adjusted via US CPI)', color='blue', linewidth=2)

# Graph Formatting
plt.title('20-Year Actual Real Return: Nifty 500 vs. S&P 500\n(Growth of 100 Base Investment Adjusted Dynamically for Historical CPI)', fontsize=14, fontweight='bold')
plt.xlabel('Year', fontsize=12)
plt.ylabel('Real Purchasing Power (Base = 100)', fontsize=12)
plt.legend(loc='upper left', fontsize=10)
plt.grid(True, linestyle='--', alpha=0.7)
plt.tight_layout()

# Save and Show
plt.savefig('dynamic_real_returns_comparison.png', dpi=300)
print("Graph generated and saved as 'dynamic_real_returns_comparison.png'")

plt.show()

2. Rolling 1-Year Real Return (Nifty 500 vs S&P 500)

import yfinance as yf
import pandas as pd
import matplotlib.pyplot as plt
import pandas_datareader.data as web
from datetime import datetime, timedelta
import warnings

# Suppress future warnings from pandas
warnings.simplefilter(action='ignore', category=FutureWarning)

# 1. Define Parameters
tickers = {'NIFTY 500': '^CRSLDX', 'S&P 500': '^GSPC'}
end_date = datetime.today()
start_date = end_date - timedelta(days=20*365) # Approx 20 years

# 2. Fetch Stock Data
print("Downloading stock market data...")
df_nifty = yf.download('^CRSLDX', start=start_date, end=end_date)
df_sp500 = yf.download('^GSPC', start=start_date, end=end_date)

stock_data = pd.DataFrame({
    'NIFTY 500': df_nifty['Close'].squeeze(),
    'S&P 500': df_sp500['Close'].squeeze()
})
stock_data = stock_data.dropna()

# 3. Fetch CPI Data from FRED
print("Downloading historical CPI data from FRED...")
# CPIAUCSL: US Consumer Price Index
# INDCPIALLMINMEI: India Consumer Price Index
cpi_data = web.DataReader(['CPIAUCSL', 'INDCPIALLMINMEI'], 'fred', start_date, end_date)
cpi_data.rename(columns={'CPIAUCSL': 'US_CPI', 'INDCPIALLMINMEI': 'INDIA_CPI'}, inplace=True)

# 4. Align CPI (Monthly) with Stock Data (Daily)
print("Aligning inflation data with market returns...")
combined_data = stock_data.join(cpi_data, how='left')
combined_data['US_CPI'] = combined_data['US_CPI'].ffill().bfill()
combined_data['INDIA_CPI'] = combined_data['INDIA_CPI'].ffill().bfill()

# 5. Calculate 1-Year Rolling Real Returns
print("Calculating rolling returns...")
# 252 trading days is approximately 1 year
rolling_window = 252 

# Shift data back 252 days to get the values from exactly one year prior
combined_data['NIFTY_1yr_ago'] = combined_data['NIFTY 500'].shift(rolling_window)
combined_data['SP500_1yr_ago'] = combined_data['S&P 500'].shift(rolling_window)
combined_data['INDIA_CPI_1yr_ago'] = combined_data['INDIA_CPI'].shift(rolling_window)
combined_data['US_CPI_1yr_ago'] = combined_data['US_CPI'].shift(rolling_window)

# Formula for 1-Year Real Return %:
# [ (Current_Stock / 1yr_Ago_Stock) * (1yr_Ago_CPI / Current_CPI) - 1 ] * 100
combined_data['NIFTY_1yr_Real_Return'] = (
    (combined_data['NIFTY 500'] / combined_data['NIFTY_1yr_ago']) * 
    (combined_data['INDIA_CPI_1yr_ago'] / combined_data['INDIA_CPI']) - 1
) * 100

combined_data['SP500_1yr_Real_Return'] = (
    (combined_data['S&P 500'] / combined_data['SP500_1yr_ago']) * 
    (combined_data['US_CPI_1yr_ago'] / combined_data['US_CPI']) - 1
) * 100

# Drop the first 252 rows which will have NaN values due to the shift
rolling_data = combined_data.dropna(subset=['NIFTY_1yr_Real_Return', 'SP500_1yr_Real_Return'])

# 6. Plot the Graph
print("Generating graph...")
plt.figure(figsize=(14, 7))

plt.plot(rolling_data.index, rolling_data['NIFTY_1yr_Real_Return'], label='NIFTY 500 (1-Year Real Return, India CPI adjusted)', color='green', alpha=0.8, linewidth=1.5)
plt.plot(rolling_data.index, rolling_data['SP500_1yr_Real_Return'], label='S&P 500 (1-Year Real Return, US CPI adjusted)', color='blue', alpha=0.8, linewidth=1.5)

# Add a horizontal line at 0% to clearly show when real returns are negative
plt.axhline(0, color='red', linestyle='--', linewidth=1, label='0% Real Return (Break-Even)')

# Graph Formatting
plt.title('20-Year Rolling 1-Year Real Returns: Nifty 500 vs. S&P 500\n(Adjusted Dynamically for Historical CPI)', fontsize=15, fontweight='bold')
plt.xlabel('Year', fontsize=12)
plt.ylabel('1-Year Real Return (%)', fontsize=12)
plt.legend(loc='upper right', fontsize=10)
plt.grid(True, linestyle='--', alpha=0.5)
plt.tight_layout()

# Save and Show
plt.savefig('rolling_1yr_real_returns.png', dpi=300)
print("Graph generated and saved as 'rolling_1yr_real_returns.png'")

plt.show()

3. Rolling 1-Year Real Return (SBI Magnum Income vs Vanguard Total Bond)

import yfinance as yf
import pandas as pd
import matplotlib.pyplot as plt
import pandas_datareader.data as web
import requests
from datetime import datetime, timedelta
import warnings

warnings.simplefilter(action='ignore', category=FutureWarning)

# 1. Define Parameters
end_date = datetime.today()
start_date = end_date - timedelta(days=20*365) # Approx 20 years

# 2. Fetch US Bond Fund (VBTLX)
print("Downloading US Bond Fund data (VBTLX)...")
df_us_bond = yf.download('VBTLX', start=start_date, end=end_date, auto_adjust=True)

if isinstance(df_us_bond.columns, pd.MultiIndex):
    us_bond_data = df_us_bond['Close']['VBTLX']
else:
    us_bond_data = df_us_bond['Close']

us_bond_data = us_bond_data.squeeze()
us_bond_data.name = 'US_Debt_Fund'

# FIX 1: Normalize index to exactly midnight to prevent time-mismatches causing NaN rows
us_bond_data.index = pd.to_datetime(us_bond_data.index).tz_localize(None).normalize()

# 3. Fetch CPI Data from FRED
print("Downloading historical CPI data from FRED...")
# FIX 2: Fetching two different India CPI tickers in case one is deprecated or empty
cpi_tickers = ['CPIAUCSL', 'INDCPIALLMINMEI', 'INDCPALTT01IXNBM']
cpi_data = web.DataReader(cpi_tickers, 'fred', start_date, end_date)

# Combine the India CPI tickers (uses first one, falls back to second if missing)
cpi_data['INDIA_CPI'] = cpi_data['INDCPIALLMINMEI'].combine_first(cpi_data['INDCPALTT01IXNBM'])
cpi_data.rename(columns={'CPIAUCSL': 'US_CPI'}, inplace=True)
cpi_data = cpi_data[['US_CPI', 'INDIA_CPI']] # Drop the raw uncombined columns
cpi_data.index = pd.to_datetime(cpi_data.index).normalize()

# 4. Fetch Indian Debt Fund Data via MFapi.in
print("Downloading Indian Debt Fund NAV via MFapi.in...")
scheme_code = '100639' 
api_url = f'https://api.mfapi.in/mf/{scheme_code}'
response = requests.get(api_url)
mf_json = response.json()

india_bond_data = pd.DataFrame(mf_json['data'])
india_bond_data['date'] = pd.to_datetime(india_bond_data['date'], format='%d-%m-%Y').dt.normalize()
india_bond_data.set_index('date', inplace=True)
india_bond_data.rename(columns={'nav': 'INDIA_Debt_Fund'}, inplace=True)
# Safely convert to numeric, turning bad strings into NaN
india_bond_data['INDIA_Debt_Fund'] = pd.to_numeric(india_bond_data['INDIA_Debt_Fund'], errors='coerce')

# 5. Merge Everything Together
print("Aligning all datasets...")
combined_data = pd.DataFrame(us_bond_data).join([cpi_data, india_bond_data], how='outer')

# FIX 3: Group by Date taking the last valid observation, then forward fill
# This acts as a safety net if there are duplicate dates from the mutual fund API
combined_data = combined_data.resample('D').last().ffill().dropna()

# 6. Calculate 1-Year Rolling Real Returns
print("Calculating 1-Year rolling real returns...")

rolling_window = 365 
combined_data['US_Debt_1yr_ago'] = combined_data['US_Debt_Fund'].shift(rolling_window)
combined_data['INDIA_Debt_1yr_ago'] = combined_data['INDIA_Debt_Fund'].shift(rolling_window)
combined_data['US_CPI_1yr_ago'] = combined_data['US_CPI'].shift(rolling_window)
combined_data['INDIA_CPI_1yr_ago'] = combined_data['INDIA_CPI'].shift(rolling_window)

# Total Real Return Formula
combined_data['US_1yr_Real_Return'] = (
    (combined_data['US_Debt_Fund'] / combined_data['US_Debt_1yr_ago']) * 
    (combined_data['US_CPI_1yr_ago'] / combined_data['US_CPI']) - 1
) * 100

combined_data['INDIA_1yr_Real_Return'] = (
    (combined_data['INDIA_Debt_Fund'] / combined_data['INDIA_Debt_1yr_ago']) * 
    (combined_data['INDIA_CPI_1yr_ago'] / combined_data['INDIA_CPI']) - 1
) * 100

rolling_data = combined_data.dropna(subset=['US_1yr_Real_Return', 'INDIA_1yr_Real_Return'])

if rolling_data.empty:
    print("\n[!] ERROR: The calculated dataset is STILL empty!")
    print("Diagnostic - Here is the raw aligned data before real returns were calculated:")
    print(combined_data.tail(15))
else:
    # 7. Plot the Graph
    print("Generating graph...")
    plt.figure(figsize=(14, 7))

    plt.plot(rolling_data.index, rolling_data['INDIA_1yr_Real_Return'], 
             label='SBI Magnum Income / Medium-to-Long (Regular Growth)', 
             color='green', alpha=0.8, linewidth=1.5)

    plt.plot(rolling_data.index, rolling_data['US_1yr_Real_Return'], 
             label='Vanguard Total Bond Market Index [VBTLX]', 
             color='blue', alpha=0.8, linewidth=1.5)

    plt.axhline(0, color='red', linestyle='--', linewidth=1, label='0% Real Return (Break-Even)')

    plt.title('Rolling 1-Year Real Returns: SBI Magnum Income (Regular) vs. Vanguard Total Bond\n(Total Return Adjusted Dynamically for CPI)', 
              fontsize=14, fontweight='bold')
    plt.xlabel('Year', fontsize=12)
    plt.ylabel('1-Year Real Return (%)', fontsize=12)
    plt.legend(loc='upper right', fontsize=10)
    plt.grid(True, linestyle='--', alpha=0.5)
    plt.tight_layout()

    plt.savefig('debt_fund_real_returns_fixed.png', dpi=300)
    print("Graph generated and saved as 'debt_fund_real_returns_fixed.png'")

    plt.show()

4. The 1966 US Retirement Disaster (Nominal vs. Real)

import yfinance as yf
import pandas as pd
import matplotlib.pyplot as plt
import pandas_datareader.data as web
import warnings

# Suppress future warnings from pandas for cleaner terminal output
warnings.simplefilter(action='ignore', category=FutureWarning)

# 1. Define Parameters (1966 to 1995 captures the stagnation and the eventual recovery)
start_date = '1966-01-01'
end_date = '1995-01-01'

# 2. Fetch S&P 500 Data (Price Return Index)
print("Downloading S&P 500 data (1966-1995)...")
df_sp500 = yf.download('^GSPC', start=start_date, end=end_date)
sp500_data = pd.DataFrame({'SP500_Nominal': df_sp500['Close'].squeeze()}).dropna()

# 3. Fetch CPI Data from FRED
print("Downloading historical US CPI data from FRED...")
# CPIAUCSL: US Consumer Price Index for All Urban Consumers
cpi_data = web.DataReader('CPIAUCSL', 'fred', start_date, end_date)
cpi_data.rename(columns={'CPIAUCSL': 'US_CPI'}, inplace=True)

# 4. Align CPI (Monthly) with Stock Data (Daily)
print("Aligning inflation data with market returns...")
combined_data = sp500_data.join(cpi_data, how='left')
# Forward-fill and backward-fill the monthly CPI values to daily dates
combined_data['US_CPI'] = combined_data['US_CPI'].ffill().bfill()

# 5. Calculate Dynamic Base-100 Returns
# Calculate both Nominal (No inflation) and Real (Adjusted for inflation)
initial_stock = combined_data['SP500_Nominal'].iloc[0]
initial_cpi = combined_data['US_CPI'].iloc[0]

# Nominal: Just the price change
combined_data['Nominal_100'] = 100 * (combined_data['SP500_Nominal'] / initial_stock)

# Real: Price change dynamically divided by CPI inflation
combined_data['Real_100'] = 100 * (combined_data['SP500_Nominal'] / initial_stock) * (initial_cpi / combined_data['US_CPI'])

# 6. Plot the Graph
print("Generating graph...")
plt.figure(figsize=(14, 7))

# Plot Nominal Line
plt.plot(combined_data.index, combined_data['Nominal_100'], 
         label='S&P 500 (Nominal Return - No Inflation Adj)', 
         color='blue', alpha=0.6, linewidth=1.5, linestyle='--')

# Plot Real Line
plt.plot(combined_data.index, combined_data['Real_100'], 
         label='S&P 500 (Real Return - Adjusted for 1970s Inflation)', 
         color='red', linewidth=2.5)

# Add a horizontal break-even line at 100
plt.axhline(100, color='black', linestyle='-', linewidth=1.5, label='Break-Even (Initial Purchasing Power)')

# Graph Formatting
plt.title("The 1966 US Retirement Disaster: Nominal Stagnation vs. Real Purchasing Power Destruction\n(Why the 4% Rule is Bulletproof)", 
          fontsize=14, fontweight='bold')
plt.xlabel('Year', fontsize=12)
plt.ylabel('Purchasing Power (Base = 100)', fontsize=12)
plt.legend(loc='lower right', fontsize=11)
plt.grid(True, linestyle='--', alpha=0.5)
plt.tight_layout()

# Save and Show
plt.savefig('1966_sp500_real_vs_nominal.png', dpi=300)
print("Graph generated and saved as '1966_sp500_real_vs_nominal.png'")

plt.show()

Comments

Popular posts from this blog

How to start Investing

I Bought Hundreds Of Funds & It Didn't Diworsify