File size: 30,579 Bytes
1a93ec0 dc5a308 e05250c b3aab3a e05250c ad8ae08 e05250c ad8ae08 e05250c ad8ae08 e05250c ad8ae08 82245fa ad8ae08 e05250c ad8ae08 e05250c ad8ae08 e05250c ad8ae08 e05250c 82245fa e05250c 82245fa e05250c ad8ae08 1a93ec0 982dccc b141331 1a93ec0 e05250c 982dccc b141331 dc5a308 1a93ec0 982dccc 3dafbb2 1a93ec0 dc5a308 b141331 dc5a308 1a93ec0 ad8ae08 1a93ec0 ad8ae08 1a93ec0 4bc0e33 1a93ec0 ad8ae08 1a93ec0 4bc0e33 1a93ec0 ad8ae08 60efde9 ad8ae08 60efde9 1a93ec0 ad8ae08 1a93ec0 b305b83 1a93ec0 82245fa 1a93ec0 ad8ae08 e05250c ad8ae08 e05250c ad8ae08 1a93ec0 1ad0985 ebfeb25 1ad0985 ebfeb25 1ad0985 ebfeb25 1ad0985 3dafbb2 1ad0985 ebfeb25 1ad0985 ebfeb25 1ad0985 ebfeb25 1ad0985 ebfeb25 1ad0985 ebfeb25 1ad0985 ebfeb25 1ad0985 ebfeb25 1ad0985 ebfeb25 1ad0985 ebfeb25 1ad0985 ebfeb25 1ad0985 ad8ae08 1ad0985 ad8ae08 1ad0985 ebfeb25 1ad0985 7c75048 1ad0985 883cc82 3dafbb2 883cc82 9c1ea6d 883cc82 e05250c 883cc82 60efde9 883cc82 e673560 883cc82 e673560 ad8ae08 e673560 ad8ae08 def30e8 883cc82 982dccc 883cc82 ad8ae08 883cc82 e05250c 9c1ea6d e05250c ad8ae08 e05250c ad8ae08 e05250c 883cc82 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 |
import numpy as np
import matplotlib.pyplot as plt
import gradio as gr
import time
import os
import json
import hashlib
import pathlib
from PIL import Image # Added for loading images from cache
import os # Import os module
# Disable Hugging Face Hub telemetry for local development
os.environ['HF_HUB_DISABLE_TELEMETRY'] = '1'
# --- Caching Setup ---
CACHE_DIR = pathlib.Path("cache")
CACHE_DIR.mkdir(exist_ok=True, parents=True)
def _generate_cache_key(*args, **kwargs):
"""Generates a unique hash for the given arguments."""
# Convert all arguments to a consistent string representation
# Exclude the 'progress' object from hashing as it's not part of the configuration
hash_input = []
for arg in args:
if not isinstance(arg, gr.Progress):
hash_input.append(str(arg))
for k, v in kwargs.items():
if k != 'progress':
hash_input.append(f"{k}={v}")
# Use a stable JSON representation for dictionary arguments if any
# For this specific function, all args are simple types, so direct string conversion is fine.
return hashlib.md5("".join(hash_input).encode('utf-8')).hexdigest()
def _save_to_cache(key, results_text, fig1, fig2, fig3):
"""Saves simulation results and plots to the cache."""
cache_path = CACHE_DIR / key
cache_path.mkdir(exist_ok=True, parents=True)
# Save plots as PNGs
fig1_path = cache_path / "fig1.png"
fig2_path = cache_path / "fig2.png"
fig3_path = cache_path / "fig3.png" # New fig3 path
fig1.savefig(fig1_path, bbox_inches='tight', pad_inches=0)
fig2.savefig(fig2_path, bbox_inches='tight', pad_inches=0)
fig3.savefig(fig3_path, bbox_inches='tight', pad_inches=0) # Save fig3
plt.close(fig1) # Close figures to free memory
plt.close(fig2)
plt.close(fig3) # Close fig3
# Save metadata (results_text and plot paths)
metadata = {
"results_text": results_text,
"fig1_path": str(fig1_path),
"fig2_path": str(fig2_path),
"fig3_path": str(fig3_path) # Add fig3 path to metadata
}
with open(cache_path / "metadata.json", "w") as f:
json.dump(metadata, f)
def _load_from_cache(key):
"""Loads simulation results from the cache."""
cache_path = CACHE_DIR / key
metadata_path = cache_path / "metadata.json"
if not metadata_path.exists():
return None
with open(metadata_path, "r") as f:
metadata = json.load(f)
# Load images from paths
try:
fig1_img = Image.open(metadata["fig1_path"])
fig2_img = Image.open(metadata["fig2_path"])
# Explicitly check for fig3_path to invalidate old cache entries
if "fig3_path" not in metadata:
print(f"Cached metadata for key: {key} is missing fig3_path. Invalidating cache entry.")
import shutil
shutil.rmtree(cache_path)
return None
fig3_img = Image.open(metadata["fig3_path"]) # Load fig3 image
except FileNotFoundError:
print(f"Cached image files not found for key: {key}. Deleting cache entry.")
# Clean up incomplete cache entry
import shutil
shutil.rmtree(cache_path)
return None
# Convert PIL Image objects back to matplotlib figures for Gradio's gr.Plot
# This is a workaround as gr.Plot expects matplotlib figures, not PIL Images directly.
fig1_recreated = plt.figure()
ax1_recreated = fig1_recreated.add_subplot(111)
ax1_recreated.imshow(fig1_img)
ax1_recreated.axis('off') # Hide axes for image display
fig1_recreated.subplots_adjust(left=0, right=1, top=1, bottom=0) # Make axes fill the figure
fig2_recreated = plt.figure()
ax2_recreated = fig2_recreated.add_subplot(111)
ax2_recreated.imshow(fig2_img)
ax2_recreated.axis('off') # Hide axes for image display
fig2_recreated.subplots_adjust(left=0, right=1, top=1, bottom=0) # Make axes fill the figure
fig3_recreated = plt.figure() # Recreate fig3
ax3_recreated = fig3_recreated.add_subplot(111)
ax3_recreated.imshow(fig3_img)
ax3_recreated.axis('off') # Hide axes for image display
fig3_recreated.subplots_adjust(left=0, right=1, top=1, bottom=0) # Make axes fill the figure
return "Loaded from cache!", metadata["results_text"], fig1_recreated, fig2_recreated, fig3_recreated # Return fig3
def run_simulation(
initial_investment: float,
num_years: int,
num_simulations: int,
target_success_rate: float,
stock_mean_return: float,
stock_std_dev: float,
bond_mean_return: float,
bond_std_dev: float,
stock_allocation: float,
correlation_stock_bond: float,
mean_inflation: float,
std_dev_inflation: float,
min_swr_test: float,
max_swr_test: float,
num_swr_intervals: int,
progress=gr.Progress()
):
# Generate cache key from all input parameters except 'progress'
cache_key = _generate_cache_key(
initial_investment, num_years, num_simulations, target_success_rate,
stock_mean_return, stock_std_dev, bond_mean_return, bond_std_dev,
stock_allocation, correlation_stock_bond, mean_inflation,
std_dev_inflation, min_swr_test, max_swr_test, num_swr_intervals
)
# Check if results are in cache
cached_results = _load_from_cache(cache_key)
if cached_results:
progress(1, desc="Loading from cache...")
return cached_results
swr_test_step = (max_swr_test - min_swr_test) / num_swr_intervals if num_swr_intervals > 0 else 0.1 # Calculate step
progress(0, desc="Starting simulation...")
start_time = time.time()
# --- Core Parameters ---
initial_investment = float(initial_investment)
num_years = int(num_years)
num_simulations = int(num_simulations)
target_success_rate = float(target_success_rate) / 100.0 # Convert percentage to decimal
# --- Financial Assumptions (NOMINAL) ---
stock_mean_return = float(stock_mean_return) / 100.0
stock_std_dev = float(stock_std_dev) / 100.0
bond_mean_return = float(bond_mean_return) / 100.0
bond_std_dev = float(bond_std_dev) / 100.0
stock_allocation = float(stock_allocation) / 100.0
bond_allocation = 1.0 - stock_allocation
correlation_stock_bond = float(correlation_stock_bond)
mean_inflation = float(mean_inflation) / 100.0
std_dev_inflation = float(std_dev_inflation) / 100.0
# --- Covariance Matrix for generating correlated asset returns ---
mean_asset_returns = np.array([stock_mean_return, bond_mean_return])
cov_matrix = np.array([
[stock_std_dev**2, correlation_stock_bond * stock_std_dev * bond_std_dev],
[correlation_stock_bond * stock_std_dev * bond_std_dev, bond_std_dev**2]
])
# --- SWRs to Test ---
# Calculate swr_test_step based on range and number of intervals
swr_test_step_calculated = (max_swr_test - min_swr_test) / num_swr_intervals if num_swr_intervals > 0 else 0.1
# Use linspace for more precise interval generation
withdrawal_rates_to_test = np.linspace(min_swr_test / 100.0, max_swr_test / 100.0, num_swr_intervals + 1)
all_results = []
portfolio_paths_for_plotting = {}
total_swr_tests = len(withdrawal_rates_to_test)
for idx, swr in enumerate(withdrawal_rates_to_test):
elapsed_time = time.time() - start_time
progress_ratio = (idx + 1) / total_swr_tests
if progress_ratio > 0:
estimated_total_time = elapsed_time / progress_ratio
estimated_remaining_time = estimated_total_time - elapsed_time
remaining_minutes = estimated_remaining_time / 60
progress_desc = f"Simulating SWR: {swr*100:.1f}% (Est. remaining: {remaining_minutes:.1f} min)"
else:
progress_desc = f"Simulating SWR: {swr*100:.1f}%"
progress((idx + 1) / total_swr_tests, desc=progress_desc)
success_count = 0
current_swr_paths = []
balance_or_more_count = 0 # Initialize counter for new metric
for i in range(num_simulations):
portfolio_value = float(initial_investment)
current_annual_withdrawal_nominal = initial_investment * swr
simulation_failed_this_run = False
path = [portfolio_value]
for year in range(num_years):
if year > 0:
yearly_inflation = np.random.normal(mean_inflation, std_dev_inflation)
yearly_inflation = max(yearly_inflation, -0.05)
current_annual_withdrawal_nominal *= (1 + yearly_inflation)
portfolio_value -= current_annual_withdrawal_nominal
if portfolio_value <= 0:
simulation_failed_this_run = True
portfolio_value = 0
path.append(portfolio_value)
break
asset_returns_this_year = np.random.multivariate_normal(mean_asset_returns, cov_matrix)
portfolio_return_this_year = (stock_allocation * asset_returns_this_year[0] +
bond_allocation * asset_returns_this_year[1])
portfolio_return_this_year = max(portfolio_return_this_year, -0.99)
portfolio_value *= (1 + portfolio_return_this_year)
path.append(portfolio_value)
if not simulation_failed_this_run:
success_count += 1
# Check if final balance is same or more than initial
if portfolio_value >= initial_investment:
balance_or_more_count += 1
# Always append path, we'll select a sample later
current_swr_paths.append(path)
success_probability = success_count / num_simulations
balance_or_more_probability = balance_or_more_count / num_simulations # Calculate new probability
all_results.append({'swr': swr, 'success_rate': success_probability, 'balance_or_more_prob': balance_or_more_probability}) # Add to results
# Store a sample of paths for this SWR
portfolio_paths_for_plotting[swr] = current_swr_paths[:100] # Store up to 100 paths
final_swr = 0.0
initial_annual_withdrawal_amount = 0.0
eligible_rates = [r for r in all_results if r['success_rate'] >= target_success_rate]
if eligible_rates:
final_swr = max(r['swr'] for r in eligible_rates)
initial_annual_withdrawal_amount = initial_investment * final_swr
results_text = ""
if final_swr > 0:
results_text += f"The highest Safe Withdrawal Rate for {target_success_rate*100:.0f}% success over {num_years} years is approximately: {final_swr*100:.2f}%\n"
results_text += f"This corresponds to an initial annual withdrawal of: ${initial_annual_withdrawal_amount:,.2f}\n"
else:
results_text += f"No tested SWR achieved the {target_success_rate*100:.0f}% success rate with the given assumptions.\n"
lowest_tested_swr = min(r['swr'] for r in all_results)
highest_success_at_lowest_swr = [r['success_rate'] for r in all_results if r['swr'] == lowest_tested_swr][0]
results_text += f"The lowest tested SWR ({lowest_tested_swr*100:.1f}%) had a success rate of {highest_success_at_lowest_swr*100:.2f}%.\n"
results_text += "Consider revising assumptions (e.g., higher returns, lower volatility/inflation, shorter horizon) or target success rate.\n"
results_text += "\n--- All Tested Withdrawal Rates, Success Probabilities, and Balance Retention ---\n"
for r in all_results:
results_text += f"SWR: {r['swr']*100:.2f}% -> Success Rate: {r['success_rate']*100:.2f}% -> Balance >= Initial Prob: {r['balance_or_more_prob']*100:.2f}%\n"
# --- Plotting Results ---
swrs_plot = [r['swr'] * 100 for r in all_results]
success_rates_plot = [r['success_rate'] * 100 for r in all_results]
balance_or_more_probs_plot = [r['balance_or_more_prob'] * 100 for r in all_results] # New data for plotting
fig1, ax1 = plt.subplots(figsize=(10, 6))
ax1.plot(swrs_plot, success_rates_plot, marker='o', linestyle='-')
ax1.axhline(y=target_success_rate * 100, color='r', linestyle='--', label=f'{target_success_rate*100:.0f}% Target Success')
if final_swr > 0:
ax1.axvline(x=final_swr * 100, color='g', linestyle=':', label=f'Calculated SWR: {final_swr*100:.2f}%')
ax1.set_title(f'Monte Carlo SWR Success Rates ({num_simulations:,} simulations)')
ax1.set_xlabel('Initial Withdrawal Rate (%)')
ax1.set_ylabel('Probability of Portfolio Lasting 30 Years (%)')
ax1.grid(True)
ax1.legend()
# Dynamically adjust y-axis limits for SWR Success Rates Plot
if success_rates_plot:
min_success = min(success_rates_plot)
max_success = max(success_rates_plot)
# Add a small buffer to the min/max for better visualization
ax1.set_ylim(max(0, min_success - 5), min(100, max_success + 5))
else:
ax1.set_ylim(0, 105) # Fallback if no success rates are plotted
fig2, ax2 = plt.subplots(figsize=(12, 7))
chosen_swr_for_path_plot = final_swr if final_swr > 0 else 0.035
if chosen_swr_for_path_plot in portfolio_paths_for_plotting and portfolio_paths_for_plotting[chosen_swr_for_path_plot]:
paths_to_plot_sample = portfolio_paths_for_plotting[chosen_swr_for_path_plot]
for i, path_data in enumerate(paths_to_plot_sample):
if len(path_data) == num_years + 1:
ax2.plot(range(num_years + 1), path_data, alpha=0.1, color='blue' if path_data[-1] > 0 else 'red')
ax2.set_title(f'Sample Portfolio Paths for {chosen_swr_for_path_plot*100:.2f}% SWR (100 simulations shown)')
# Collect all portfolio values for dynamic y-axis adjustment
all_portfolio_values = []
for path_data in paths_to_plot_sample:
all_portfolio_values.extend([val for val in path_data if val > 0]) # Only include positive values for log scale consideration
if all_portfolio_values:
min_val = min(all_portfolio_values)
max_val = max(all_portfolio_values)
# Add a small buffer to the min/max for better visualization
# Ensure min_val is not too close to zero for log scale
y_min = max(1, min_val * 0.9) if min_val > 0 else 1 # Ensure y_min is at least 1 for log scale
y_max = max_val * 1.1
ax2.set_ylim(y_min, y_max)
ax2.set_yscale('log') # Keep log scale if all values are positive
ax2.axhline(y=initial_investment, color='k', linestyle='--', label=f'Initial: ${initial_investment:,.0f}')
ax2.axhline(y=1, color='grey', linestyle=':', label='$1 (for log scale visibility near zero)')
else:
# Fallback if no positive values are found (e.g., all simulations failed immediately)
ax2.set_yscale('linear') # Switch to linear scale if no positive values
ax2.set_ylim(0, initial_investment * 1.1) # Default linear range
ax2.axhline(y=initial_investment, color='k', linestyle='--', label=f'Initial: ${initial_investment:,.0f}')
ax2.axhline(y=0, color='grey', linestyle=':', label='$0')
ax2.set_xlabel('Year')
ax2.set_ylabel('Portfolio Value ($)')
ax2.grid(True, which="both", ls="-", alpha=0.5)
ax2.legend()
else:
ax2.text(0.5, 0.5, f"No portfolio paths were stored for SWR {chosen_swr_for_path_plot*100:.2f}% to plot individual simulations.",
horizontalalignment='center', verticalalignment='center', transform=ax2.transAxes, fontsize=12)
ax2.set_title("Sample Portfolio Paths")
ax2.set_xlabel("Year")
ax2.set_ylabel("Portfolio Value ($)")
fig3, ax3 = plt.subplots(figsize=(10, 6))
ax3.plot(swrs_plot, balance_or_more_probs_plot, marker='o', linestyle='-', color='purple')
ax3.set_title(f'Monte Carlo SWR: Probability of End Balance >= Initial Investment ({num_simulations:,} simulations)')
ax3.set_xlabel('Initial Withdrawal Rate (%)')
ax3.set_ylabel('Probability of End Balance >= Initial Investment (%)')
ax3.grid(True)
ax3.legend(['Balance >= Initial Prob'])
# Dynamically adjust y-axis limits for Balance >= Initial Investment Plot
if balance_or_more_probs_plot:
min_balance_prob = min(balance_or_more_probs_plot)
max_balance_prob = max(balance_or_more_probs_plot)
ax3.set_ylim(max(0, min_balance_prob - 5), min(100, max_balance_prob + 5))
else:
ax3.set_ylim(0, 105) # Fallback if no balance retention rates are plotted
# Save results to cache before returning
_save_to_cache(cache_key, results_text, fig1, fig2, fig3)
return f"Simulation Complete! Results text length: {len(results_text)}", results_text, fig1, fig2, fig3
# Gradio Interface
# Explanation text for the modal
explanation_text = """
---
## Understanding the Safe Withdrawal Rate (SWR) Calculator
### 1. What is the Safe Withdrawal Rate (SWR)?
The Safe Withdrawal Rate (SWR) is a concept primarily used in retirement planning. It refers to the percentage of your initial retirement portfolio that you can withdraw each year, adjusted for inflation, without running out of money over a specified period (e.g., 30 years). The goal is to find a withdrawal rate that has a very high probability of success, even in adverse market conditions.
This calculator uses a **Monte Carlo Simulation** approach to determine the SWR. Instead of relying on historical averages, which might not repeat, Monte Carlo simulations run thousands of possible future market scenarios based on statistical distributions (mean and standard deviation) of asset returns and inflation. For each scenario, it checks if the portfolio lasts the entire retirement period.
**Performance Note**: The simulation can be computationally intensive. To speed up calculations, consider reducing the `Number of Simulations` and `Number of SWR Intervals`. Higher values provide more accuracy but take longer to compute.
### 2. Key Assumptions Used in This Calculator and Their Meaning
The accuracy and relevance of the calculated SWR heavily depend on the assumptions you provide.
* **Initial Investment**: Your starting portfolio value.
* **Number of Years**: The duration of your retirement (e.g., 30 years).
* **Number of Simulations**: How many different market scenarios the calculator runs. More simulations lead to more accurate results but take longer.
* **Target Success Rate**: The probability you want your portfolio to last (e.g., 95% means 95 out of 100 simulations succeed).
* **Stock Mean Return (%)**: The average annual nominal return you expect from your stock investments.
* **Stock Standard Deviation (%)**: A measure of how much the stock returns are expected to vary from the mean (volatility). Higher standard deviation means more volatile returns.
* **Bond Mean Return (%)**: The average annual nominal return you expect from your bond investments.
* **Bond Standard Deviation (%)**: A measure of how much the bond returns are expected to vary from the mean (volatility).
* **Correlation (Stocks vs. Bonds)**: How stock and bond returns move in relation to each other. A negative correlation means they tend to move in opposite directions, which can help diversify a portfolio.
* **Stock Allocation (%)**: The percentage of your portfolio invested in stocks (the rest is in bonds).
* **Mean Inflation (%)**: The average annual inflation rate you expect. Withdrawals are typically adjusted for inflation to maintain purchasing power.
* **Inflation Standard Deviation (%)**: How much the inflation rate is expected to vary.
* **SWR Test Range**: The range of withdrawal rates (e.g., 2.5% to 5.0%) that the simulation will test to find the optimal SWR.
### 3. Common Misunderstandings about SWR
* **It's a Guarantee**: The SWR is a probability, not a guarantee. A 95% success rate means 5% of scenarios still fail.
* **Fixed for Life**: The SWR is calculated based on initial assumptions. Life events, market shifts, or changes in spending can necessitate adjustments.
* **One Size Fits All**: The SWR is highly personal and depends on your specific portfolio, risk tolerance, and spending habits.
* **Only Initial Withdrawal Matters**: While the initial withdrawal rate is key, how you adjust withdrawals in response to market performance (e.g., reducing spending in down years) can significantly impact portfolio longevity. This calculator assumes inflation-adjusted withdrawals.
### 4. How the Calculator Works Internally
The core of this calculator is a Monte Carlo simulation that models thousands of possible future scenarios for your portfolio.
**Key Steps (Simplified):**
1. **Parameter Initialization**: All input parameters are converted to decimal form (e.g., 9% becomes 0.09).
2. **Covariance Matrix Calculation**: A covariance matrix is created using the mean returns, standard deviations, and correlation of stocks and bonds. This allows the simulation to generate realistic, correlated returns for both asset classes.
```python
mean_asset_returns = np.array([stock_mean_return, bond_mean_return])
cov_matrix = np.array([
[stock_std_dev**2, correlation_stock_bond * stock_std_dev * bond_std_dev],
[correlation_stock_bond * stock_std_dev * bond_std_dev, bond_std_dev**2]
])
```
3. **Iterating SWRs**: The calculator tests a range of SWRs (e.g., from 2.5% to 5.0%).
4. **Monte Carlo Loop (for each SWR)**:
* For each SWR, `num_simulations` (e.g., 10,000) independent scenarios are run.
* **Annual Simulation Loop**: For each year of the `num_years` retirement period:
* **Inflation Adjustment**: The annual withdrawal amount is adjusted for inflation based on a randomly generated inflation rate for that year.
```python
yearly_inflation = np.random.normal(mean_inflation, std_dev_inflation)
current_annual_withdrawal_nominal *= (1 + yearly_inflation)
```
* **Withdrawal**: The adjusted withdrawal amount is subtracted from the portfolio.
* **Ruin Check**: If the portfolio value drops to zero or below, the simulation for that scenario fails.
* **Generate Returns**: Random, correlated returns for stocks and bonds are generated for the year using the covariance matrix.
```python
asset_returns_this_year = np.random.multivariate_normal(mean_asset_returns, cov_matrix)
portfolio_return_this_year = (stock_allocation * asset_returns_this_year[0] +
bond_allocation * asset_returns_this_year[1])
```
* **Apply Returns**: The portfolio value is updated with the generated returns.
* **Success/Failure Tracking**: After `num_years`, if the portfolio is still positive, the simulation is counted as a success.
5. **Calculate Success Rate**: For each SWR, the `success_count` is divided by `num_simulations` to get the `success_probability`.
6. **Calculate Balance Retention Probability**: Additionally, the probability of ending the retirement period with a portfolio balance equal to or greater than the initial investment is calculated. This provides insight into how likely it is to not only avoid ruin but also to preserve or grow the initial capital.
7. **Find Optimal SWR**: The calculator then finds the highest SWR that meets or exceeds your `Target Success Rate`.
8. **Plotting**: Finally, the results are visualized in three plots:
* **SWR Success Rates**: Shows the success probability curve across all tested SWRs.
* **Sample Portfolio Paths**: Displays a subset of individual simulation paths to illustrate portfolio behavior over time.
* **Probability of End Balance >= Initial Investment**: Shows the probability of ending with a portfolio balance equal to or greater than the initial investment for each tested SWR.
"""
with gr.Blocks() as demo:
gr.Markdown(
"""
# Safe Withdrawal Rate Calculator
This application performs Monte Carlo simulations to determine a safe withdrawal rate from a retirement portfolio.
Adjust the parameters below and click "Run Simulation" to see the results and portfolio projections.
For a detailed explanation of SWR, assumptions, and internal workings, click the "Details" button below.
"""
)
with gr.Accordion("Details: Understanding the Safe Withdrawal Rate (SWR) Calculator", open=False):
gr.Markdown(explanation_text)
with gr.Row():
with gr.Column():
gr.Markdown("### Investment Details")
initial_investment = gr.Number(label="Initial Investment ($)", value=1_000_000.0, interactive=True)
num_years = gr.Slider(minimum=10, maximum=60, value=30, step=1, label="Number of Years", interactive=True)
target_success_rate = gr.Slider(minimum=70, maximum=100, value=95, step=1, label="Target Success Rate (%)", interactive=True)
num_simulations = gr.Slider(minimum=100, maximum=20000, value=5000, step=100, label="Number of Simulations", interactive=True)
gr.Markdown("### Market Assumptions (Annualized Nominal Returns)")
stock_mean_return = gr.Slider(minimum=0, maximum=20, value=9.0, step=0.1, label="Stock Mean Return (%)", interactive=True)
stock_std_dev = gr.Slider(minimum=5, maximum=30, value=15.0, step=0.1, label="Stock Standard Deviation (%)", interactive=True)
bond_mean_return = gr.Slider(minimum=0, maximum=10, value=4.0, step=0.1, label="Bond Mean Return (%)", interactive=True)
bond_std_dev = gr.Slider(minimum=1, maximum=15, value=5.0, step=0.1, label="Bond Standard Deviation (%)", interactive=True)
correlation_stock_bond = gr.Slider(minimum=-1.0, maximum=1.0, value=-0.2, step=0.01, label="Correlation (Stocks vs. Bonds)", interactive=True)
stock_allocation = gr.Slider(minimum=0, maximum=100, value=60, step=1, label="Stock Allocation (%)", interactive=True)
gr.Markdown("### Inflation Assumptions (Annualized)")
mean_inflation = gr.Slider(minimum=0, maximum=10, value=2.5, step=0.1, label="Mean Inflation (%)", interactive=True)
std_dev_inflation = gr.Slider(minimum=0, maximum=5, value=1.5, step=0.1, label="Inflation Standard Deviation (%)", interactive=True)
gr.Markdown("### SWR Test Range")
min_swr_test = gr.Slider(minimum=0.5, maximum=10.0, value=2.5, step=0.1, label="Min SWR to Test (%)", interactive=True)
max_swr_test = gr.Slider(minimum=0.5, maximum=10.0, value=5.0, step=0.1, label="Max SWR to Test (%)", interactive=True)
num_swr_intervals = gr.Slider(minimum=5, maximum=100, value=10, step=1, label="Number of SWR Intervals", interactive=True)
run_button = gr.Button("Run Simulation", variant="primary")
with gr.Column():
gr.Markdown("### Simulation Results")
status_output = gr.Textbox(label="Status", interactive=False, lines=1)
gr.Markdown("#### Calculated Safe Withdrawal Rate Summary")
results_output = gr.Textbox(label="Summary Text", interactive=False) # Removed lines=5 to allow auto-scrolling
gr.Markdown("#### SWR Success Rates Plot")
swr_plot_output = gr.Plot()
gr.Markdown("#### Sample Portfolio Paths Plot")
paths_plot_output = gr.Plot()
gr.Markdown("#### Probability of End Balance >= Initial Investment Plot")
balance_plot_output = gr.Plot() # New plot output
gr.Button("Buy Me a Coffee ☕", link="https://buymeacoffee.com/liaoch", variant="primary")
run_button.click(
fn=run_simulation,
inputs=[
initial_investment,
num_years,
num_simulations,
target_success_rate,
stock_mean_return,
stock_std_dev,
bond_mean_return,
bond_std_dev,
stock_allocation,
correlation_stock_bond,
mean_inflation,
std_dev_inflation,
min_swr_test,
max_swr_test,
num_swr_intervals
],
outputs=[status_output, results_output, swr_plot_output, paths_plot_output, balance_plot_output] # Add new output
)
# Define default parameters for initial load
DEFAULT_PARAMS = {
"initial_investment": 1_000_000.0,
"num_years": 30,
"num_simulations": 5000,
"target_success_rate": 95,
"stock_mean_return": 9.0,
"stock_std_dev": 15.0,
"bond_mean_return": 4.0,
"bond_std_dev": 5.0,
"stock_allocation": 60,
"correlation_stock_bond": -0.2,
"mean_inflation": 2.5,
"std_dev_inflation": 1.5,
"min_swr_test": 2.5,
"max_swr_test": 5.0,
"num_swr_intervals": 10
}
def load_default_results():
"""Loads cached results for default parameters if available."""
# Ensure the order of parameters matches _generate_cache_key
default_key = _generate_cache_key(
DEFAULT_PARAMS["initial_investment"],
DEFAULT_PARAMS["num_years"],
DEFAULT_PARAMS["num_simulations"],
DEFAULT_PARAMS["target_success_rate"],
DEFAULT_PARAMS["stock_mean_return"],
DEFAULT_PARAMS["stock_std_dev"],
DEFAULT_PARAMS["bond_mean_return"],
DEFAULT_PARAMS["bond_std_dev"],
DEFAULT_PARAMS["stock_allocation"],
DEFAULT_PARAMS["correlation_stock_bond"],
DEFAULT_PARAMS["mean_inflation"],
DEFAULT_PARAMS["std_dev_inflation"],
DEFAULT_PARAMS["min_swr_test"],
DEFAULT_PARAMS["max_swr_test"],
DEFAULT_PARAMS["num_swr_intervals"]
)
cached = _load_from_cache(default_key)
if cached:
return cached
else:
# Return empty/placeholder values if no cache hit
return "No default cache found. Run simulation.", "", None, None, None # Add None for new plot
# Load default results on app startup
demo.load(
fn=load_default_results,
outputs=[status_output, results_output, swr_plot_output, paths_plot_output, balance_plot_output] # Add new output
)
demo.launch()
|