You Can Code! Python with ChatGPT & Google Colab
Never coded before? You can do this. Follow this step-by-step FIN310 lesson to ask for code, run it in Google Colab, and understand the results. Pause the video and try each step yourself.
What this video helps you do
- Write a clear ChatGPT prompt for your business or finance question.
- Copy complete Python code and run it in a Google Colab code cell.
- Upload the course notebook, use Run all, and find the tables, charts, and downloaded files.
- Use + Code to add a new cell, paste revised code from ChatGPT, then press ▶ Run to calculate new results.
- Change an assumption and compare the original and new outcomes, including a worked example increasing crypto from 5% to 10% while portfolio weights still total 100%.
- Check results, troubleshoot errors, and try an idea of your own.
The portfolio comparisons are illustrative simulations, not forecasts or investment recommendations.
One-page investment summary
Compare all 16 investment options: 10th-percentile, median, and 90th-percentile outcomes in future dollars and inflation-adjusted purchasing power.
Fixed baseline: age 22 to 62; deposits start at $1,000 per month and rise 5% yearly; 2.5% annual inflation; 0.10% annual fee. This handout is a snapshot of the starting assumptions; changing the calculator does not change the PDF.
Start here: choose an investment branch, then compare future dollars with buying power.
Investing from 22 to 62: Which Path Would You Choose?
Watch the instructor explain how to read the choices tree, compare the potential outcomes of all 16 options, and think through choices for cautious, balanced, and adventurous investors.
As you watch: What is the difference between a future balance and its buying power? Which risk would you accept, and which assumption would you challenge?
Return to the tree below to explore the results, then use “Test Your Decision” for the three-student activity.
Investing During Inflation: Stocks, Bonds, Gold & Crypto Compared
Join a professor and four students as they compare stocks, bonds, gold, and crypto and explore how inflation changes purchasing power. Watch their discussion, then work with your own group of three to make and defend an investment choice.
Watch the professor and four students
Play the discussion here or open it on YouTube. Then complete your group activity below.
As you watch, consider:
- How does a future dollar balance differ from its purchasing power?
- What trade-offs would make you prefer one investment choice over another?
- Which assumption would you challenge before making your group’s recommendation?
See the calculations step by step · expand for all details
Companion to the professor-and-four-students video. These worked examples use this course calculator’s stated assumptions. They are reference calculations, not a transcript of every number spoken in the video. The baseline tables stay fixed while you experiment with the calculator.
1. Check the inputs before comparing
This worked example starts at age 22 and ends at 62: 40 years, or 480 monthly deposits. The first monthly deposit is $1,000; deposits rise 5% each year. The baseline uses 2.5% annual inflation, a 0.10% annual fee, equal deposits, and a 20% effective tax on pre-tax withdrawals. A 22% current tax rate is used only for the equal-take-home-cost comparison.
These are selected classroom inputs, not current yields or forecasts. All returns include reinvested income before the modeled fee. This savings example does not enforce account contribution limits or eligibility.
| Asset input | Expected annual simple return | Annual volatility input |
|---|---|---|
| S&P 500 index | 8% | 16% |
| Blue-chip US stocks | 7.5% | 14% |
| QQQ-style growth | 8% | 23% |
| US + international stocks | 7.7% | 15% |
| High-growth mutual fund | 8% | 25% |
| US Treasuries | 4% | 6% |
| Investment-grade corporate bonds | 5% | 8% |
| TIPS (return is REAL) | 1.5% | 5% |
| Gold | 4% | 18% |
| Crypto / Bitcoin-like exposure | 0% | 65% |
The TIPS input is a real return. The other return inputs are nominal. Volatility approximates the standard deviation of annual log returns; it is not a maximum loss.
2. Grow the monthly deposits — and calculate total money saved
The annual 5% increase applies to the deposit, not a guaranteed salary raise or investment return.
Year 1: $1,000 per month
Year 2: $1,050 per month
Year 3: $1,102.50 per month
Year 40: $1,000 × 1.05^39 = $6,704.75 per month
Total deposits = 12 × $1,000 × (1.05^40 − 1) ÷ 0.05
= $1,449,597.29
Total deposits are not the final investment balance. Gains or losses are added by the investment model. All 16 choices use the same baseline savings habit.
3. Follow one month of compounding
Deposits arrive at the end of each month. Money already invested experiences that month’s return first; then the new deposit is added.
Month 1: $0 × growth factor + $1,000 = $1,000
For a simple arithmetic demonstration only, suppose an investment earned exactly 8% every year with no fee or volatility. Its equivalent monthly growth factor would be 1.08^(1/12) ≈ 1.006434.
The actual calculator does not assume a fixed monthly return. It draws a different modeled return each month and includes the fee, as explained in step 10.
4. Build a mixed portfolio — returns and risk are different calculations
Portfolio weights specify how much of each investment the group chooses. For example:
60/40 = 60% S&P 500 + 40% Treasuries
80/20 = 80% S&P 500 + 20% Treasuries
Gold mix = 55% S&P 500 + 35% Treasuries + 10% gold
Crypto mix = 60% S&P 500 + 35% Treasuries + 5% crypto
The selected expected returns combine by weight:
= 0.60 × 8% + 0.40 × 4% = 6.4% per year, before fees
Volatility is not simply the weighted average of the two volatilities. It also depends on how the investments move together.
Portfolio variance = w₁²σ₁² + w₂²σ₂² + 2w₁w₂ρσ₁σ₂
Portfolio volatility = square root of portfolio variance
For this 60/40 example, modeled stock–Treasury correlation ρ = 0:
variance = 0.60² × 0.16² + 0.40² × 0.06² = 0.009792
volatility = √0.009792 ≈ 9.90% per year
These correlations are teaching assumptions, not claims about future markets. Portfolio weights are held constant by the model; it does not simulate an actual trading or rebalancing schedule.
5. Read lower, median, and higher outcomes
For each investment choice, the app simulates 3,000 complete 40-year paths, sorts the ending balances, and finds the 10th, 50th, and 90th percentiles.
- Lower: 10th percentile of the simulated ending balances.
- Median: 50th percentile; the middle of those ending balances.
- Higher: 90th percentile.
The app interpolates between neighboring sorted values when needed. These are not three guaranteed market scenarios, ten-year periods, or worst/best cases. Outcomes below the lower and above the higher value can occur.
Lower: $2,475,478
Median: $3,950,073
Higher: $6,629,328
There is no single fixed-interest formula that reproduces a simulated median for a volatile investment. The value comes from the distribution of the modeled paths.
6. Compare all 16 options — full worked-example results
Fixed baseline: 2.5% annual inflation for 40 years; 0.10% annual fee; qualified Roth treatment; the deposits and return/risk inputs in steps 1–2. This table is a reference example and stays fixed while you change the calculator. Amounts are rounded from the unrounded simulation results.
| Investment choice | Lower future balance | Median future balance | Higher future balance | Median buying power |
|---|---|---|---|---|
| S&P 500 index | $2,241,846 | $4,777,007 | $11,586,097 | $1,779,104 |
| Blue-chip US stocks | $2,345,186 | $4,548,034 | $9,785,452 | $1,693,827 |
| QQQ-style growth | $1,422,572 | $3,807,028 | $13,225,507 | $1,417,854 |
| US + international stocks | $2,276,328 | $4,616,386 | $10,521,949 | $1,719,283 |
| High-growth mutual fund | $1,263,062 | $3,522,486 | $13,557,087 | $1,311,882 |
| US Treasuries | $2,036,964 | $2,629,894 | $3,485,001 | $979,453 |
| Investment-grade corporate bonds | $2,173,585 | $3,092,168 | $4,588,070 | $1,151,618 |
| Inflation-linked Treasuries (TIPS) | $2,151,745 | $2,666,853 | $3,368,029 | $993,218 |
| Gold | $1,115,903 | $2,181,121 | $5,036,548 | $812,316 |
| Crypto / Bitcoin-like exposure | $147,456 | $429,599 | $2,184,143 | $159,996 |
| Treasury + corporate bond blend | $2,175,681 | $2,867,834 | $3,891,741 | $1,068,069 |
| Balanced 60/40 | $2,475,478 | $3,950,073 | $6,629,328 | $1,471,128 |
| Conservative 40/60 | $2,484,639 | $3,487,602 | $5,062,028 | $1,298,890 |
| Aggressive 80/20 | $2,387,255 | $4,391,604 | $8,806,002 | $1,635,568 |
| Balanced + 10% gold | $2,485,975 | $3,833,827 | $6,196,311 | $1,427,835 |
| Balanced + 5% crypto | $2,202,079 | $3,700,362 | $6,706,601 | $1,378,128 |
Future balances are nominal dollars at age 62. Buying power is measured in starting-year dollars. A larger median alone does not make an investment the right choice for every saver.
7. Remove inflation — compare what the dollars can buy
To translate a future balance into starting-year purchasing power, divide it by the cumulative price-level increase.
Constant inflation factor = (1 + annual inflation)^40
Use the same balanced 60/40 median to isolate inflation’s effect:
| Inflation path | Calculation of price factor | Price factor | Median future balance | Median buying power |
|---|---|---|---|---|
| 2.5% for 40 years | 1.025^40 | 2.6851× | $3,950,073 | $1,471,128 |
| 4% for 10 years, then 2.5% for 30 | 1.04^10 × 1.025^30 | 3.1049× | $3,950,073 | $1,272,201 |
| 4% for 40 years | 1.04^40 | 4.8010× | $3,950,073 | $822,757 |
The 60/40 nominal balance stays the same here because this calculator holds nominal return inputs fixed when inflation changes. It isolates the purchasing-power effect. It does not forecast how inflation might change company earnings, interest rates, valuations, or asset prices. TIPS are the modeled exception: their nominal return changes with inflation.
8. Compare Roth and pre-tax accounts fairly
Roth and pre-tax describe tax treatment; the investment is a separate choice. The Roth example assumes qualified tax-free withdrawals. The pre-tax example assumes contributions receive the modeled current tax benefit.
At a 20% withdrawal rate: about $3.95M × 0.80 ≈ $3.16M
With equal deposits, both start at $1,000 per month, but Roth costs more take-home pay. For equal household cost, the current tax rate matters:
= $1,282.05
After-tax pre-tax outcome at equal take-home cost
= Roth-model balance × 1/(1 − 0.22) × (1 − 0.20)
≈ $4.05M for this 60/40 example
If the current and withdrawal tax rates are the same, those two tax factors cancel in this simplified model. Account limits, eligibility, deductibility, tax-law changes, and taxable-account overflow are not enforced. The app calculates with unrounded values, so displayed arithmetic can differ by a dollar after rounding.
9. Understand the TIPS and crypto calculations
TIPS: the input return is real. The app converts it into a nominal assumption for each year’s inflation path:
At 1.5% real return and 2.5% inflation:
1.015 × 1.025 − 1 = 4.0375%
At 1.5% real return and 4% inflation:
1.015 × 1.04 − 1 = 5.56%
These are model inputs before fees, not a guarantee for a specific TIPS bond or fund. Market prices can fluctuate.
Crypto crash stress: the app separately applies an immediate loss to the crypto component at age 62.
For 5% crypto and an 80% crypto crash:
factor = 1 − 0.05 × 0.80 = 0.96
For complete loss of that 5% component:
factor = 1 − 0.05 = 0.95
This extra stress is not part of the percentile simulation. It assumes no subsequent recovery. The main model does not capture fraud, lost access, or permanent catastrophic failure, and a 0% expected annual crypto return input does not guarantee a stable ending balance.
10. Advanced: reproduce the Monte Carlo simulation
For expected annual simple return r, annual log-return volatility input σ, annual fee f, and a standard-normal draw z:
Monthly log-return volatility = σ / √12
Monthly growth factor = exp(monthly log drift + (σ/√12) × z)
Balance next month = balance this month × growth factor + deposit
Start each path with balance = 0 and deposit = $1,000.
Repeat for 480 months; increase the deposit 5% after each 12 months.
Repeat for 3,000 paths; sort ending balances and interpolate percentiles.
The log adjustment connects the selected expected simple return to the lognormal return model. The fee is compounded monthly using its annual factor. For a TIPS allocation, the nominal expected return is recomputed for the inflation assumed in each year.
Mixed-portfolio covariance: equity assets load 0.9 on a shared equity factor; bonds load 0.8 on a shared bond factor; gold has its own independent factor; crypto loads 0.4 on the equity factor, with the rest independent. Residual asset-specific risks are independent. The app squares and sums weighted factor exposures and weighted residual risks, then takes the square root. These are chosen classroom correlations.
Fixed random seeds keep repeats reproducible. Outcomes are distributions for each choice rather than simultaneous realized investment paths. The model excludes changing market regimes, changing correlations, fat tails, employer matching, unplanned withdrawals, and many real-world disruptions.
11. Trace the group certificate back to the evidence
- Use one pair of investments and record two different inflation scenarios with the same return/risk inputs and fee.
- Choose one investment from that pair and select which recorded run the certificate should use.
- The certificate retrieves that run’s saved outcomes, inflation factor, account comparison, fees, returns, and taxes. It does not substitute later calculator edits.
- For Roth, the displayed amounts use the simulated ending balances. For pre-tax, they also apply the recorded household-cost scaling and withdrawal tax.
- Median buying power equals the selected after-tax median divided by the saved inflation factor.
- The certificate includes your group’s reason, accepted risks, and conditions for reconsidering.
The certificate congratulates the group’s decision-making. Its projected amount is not a guarantee. Smooth U.S. or global economic conditions would not ensure that result, and inflation, recessions, lost income, or other disruptions can affect actual outcomes.
12. Explain your decision with two numbers
Try this comparison using the fixed baseline: 60/40 has median buying power of $1,471,128, versus $1,635,568 for 80/20. But its lower nominal outcome is $2,475,478, versus $2,387,255 for 80/20. Those are different kinds of evidence; name which column and units you are comparing.
Then test inflation: for the same 60/40 nominal median, buying power falls from $1,272,201 under 4% for ten years then 2.5%, to $822,757 under 4% for all forty years.
Your group’s report: “We choose ___ over ___. Our two numerical comparisons are ___. We accept ___ risk. If ___ changes, we would reconsider.”
One saver. Three decisions.
1 · Your savings habit
Start at age 22 with $1,000 each month. Increase that contribution by 5% every year until age 62.
2 · What you own
Stocks, bonds, or a mix? Different choices expose you to different risks. A higher median alone does not make a choice better.
3 · How it is taxed
Roth and pre-tax are account treatments. Either can hold investments, subject to provider and plan rules.
Starting classroom assumptions: 2.5% annual inflation, a 0.10% annual fee, equal deposits, and a 20% effective tax on pre-tax withdrawals. These are teaching inputs. You can change them in view 2.
This is an uncapped savings illustration. Account eligibility and annual contribution limits are not enforced. The annual 5% increase is in deposits, not an assumed salary raise.
Set the classroom scenario
The inflation paths are teaching scenarios, not live economic data or forecasts. Change one assumption at a time to explain its effect.
View and edit return and risk assumptions
Selected teaching assumptions, not current yields, historical estimates, or investment recommendations. Return inputs are expected annual simple returns; volatility inputs approximate annual log-return standard deviations. All returns include reinvested income and are before the fee above. Crypto's 0% default is a neutral teaching placeholder, not a valuation forecast.
| Investment | Return % | Volatility % |
|---|
Your investment choices tree
Each choice below compares a Roth wrapper with a pre-tax wrapper. Investments and account types are separate decisions. Availability depends on your plan or provider.
Account lens: choose a column to emphasize. This highlights the same results without changing calculations.
Stocks: ownership in companies. Bonds: lending to governments or companies. A mix: several kinds of investments in one portfolio.
Follow either account branch to stocks, bonds, or a mix. Click an investment leaf to see its results and emphasize that account’s column. Both account branches use the same investment assumptions.
What inflation does to the same dollars
Explore outcomes at age 62
Lower = 10th percentile; median = 50th; higher = 90th, across full 40-year simulations. They are not ten-year periods, worst/best cases, or guaranteed probability bounds. Values outside this range can occur. Purchasing power is measured in starting-year dollars.
Ready to defend a choice?
Before looking at more controls, explain which choice you prefer, the risk you accept, and why future dollars buy less than the same number today.
Investment Choices: True or False?
Test your understanding of the benefits and risks of crypto, gold, bonds-only portfolios, stocks-only portfolios, and balanced portfolios.
Take the quiz here · True / False
Choose True or False for each statement. An explanation appears after each answer. Each question counts once; use “Try Again” to restart.
What would your group choose—and why?
Your task: Recommend an investment choice for a hypothetical 22-year-old saving toward age 62. Compare two choices from this calculator, then test whether your reasoning holds if inflation stays higher for longer.
Use the same savings plan, fees, return assumptions, and qualified-Roth column for both choices. Treat this as the calculator's uncapped teaching example. You are comparing investments here; account eligibility and contribution limits still require a separate check.
1 · Investigator
Operate the calculator and record the evidence. Check that both choices use the same inputs.
2 · Risk challenger
Question the recommendation. Examine the lower outcome, lost buying power, and a risk the model leaves out.
3 · Reporter
Keep the group moving, summarize disagreements, and deliver your one-minute explanation.
1. Set your decision rule · 2 minutes
Agree on the saver’s priority and tolerance for losses. What trade-off would make you reject a choice even if its median balance were higher?
2. Compare two choices · 4 minutes
Start with 4% inflation for 10 years, then 2.5%. Choose two alternatives worth comparing. At least one must be a mixed portfolio. Record the results below.
Consider an all-stock choice versus a mix, two different stock/bond mixes, or a mix with gold or crypto versus one without. You do not need to include gold or crypto.
3. Challenge your decision · 4 minutes
Rotate roles. In the scenario controls, switch to 4% inflation for all 40 years and record the same two choices again. What changed in future balances? What changed in purchasing power? Explain why this calculator may show one changing while the other stays the same.
These buttons change inflation only; your other assumptions and recorded evidence stay in place. Load the first scenario and record it, then load the stress scenario and record again.
Records the two choices and the current calculated inputs. Changing the calculator later does not overwrite your evidence. Values below use qualified-Roth treatment and are rounded as displayed. The 10th percentile is not a worst case; outcomes below it can occur.
| Run / investment choice | Median future balance | Median purchasing power | Lower future balance (10th) |
|---|---|---|---|
| No results recorded yet. Select your choices, recompute if needed, then record. | |||
4. Choose and defend · 4 minutes
Success means sound reasoning: use two numerical comparisons, distinguish future dollars from buying power, and explain one limitation. There is no single required investment choice.
Optional extension: does the account wrapper change the answer?
After choosing an investment mix, compare Roth with pre-tax using equal cost to take-home pay. State the current and withdrawal tax assumptions. Explain why choosing an account wrapper is a separate decision from choosing an investment.
5. Choose your route. Earn your certificate.
Agree on one of the two investments you compared. Your certificate celebrates your reasoning and shows a model projection at age 62.
Before generating: enter your group members, complete the four reflection boxes above, and record two different inflation scenarios using the same fees and return/risk assumptions.
The selected recorded run supplies the inflation path, returns, fees, tax assumptions, and outcome figures. A later change to the calculator does not alter a recorded run.
Your decision stays in this open browser tab. It is not sent to your instructor automatically. Print or save your certificate to submit through your course.
Entries remain in this open tab. Print or save the worksheet before closing or reloading the page.
Congratulations on choosing your route!
Your group compared the evidence, discussed the risks, and chose:
Your route from age 22 to 62
The inflation, return, and tax assumptions
What your group values
Your group’s reason for this choice
The risks you accept
When you would reconsider
This certificate is a snapshot of your chosen recorded run. Regenerate it after changing your group’s decision.
Plain-English guide & model details
Read this before comparing
- Account limits: This is an uncapped savings illustration, not a contribution plan for one account. Annual limits, income eligibility, deductibility, plan access, and future changes are not enforced. Check applicable rules before applying these amounts to an account. Overflow into taxable accounts would require different tax modeling.
- Roth vs pre-tax: Roth uses after-tax money; qualified withdrawals are tax-free. At age 62 this illustration assumes the applicable five-year requirement is satisfied. Pre-tax treatment assumes contributions are eligible for a current deduction/exclusion; traditional IRA deductibility is not automatic. The withdrawal rate is a simplified effective tax assumption, not a flat statutory rate on all retirement wealth. IRS IRA rules.
- Gold: Gold exposure here means unleveraged bullion-price exposure, not mining shares. It produces no interest or dividends and is not guaranteed to track consumer prices. Physical storage, spreads, product expenses, and account restrictions are not modeled individually.
- Crypto: A Bitcoin-like speculative exposure, not a stablecoin or a diversified asset class. A 40-year return assumption is highly uncertain. Volatility alone cannot capture fraud, loss of access, technological failure, or permanent total loss. No borrowing or leverage is modeled. Crypto may fall during inflation. SEC investor education.
- Treasuries and TIPS: Government backing does not prevent market-price losses when yields rise. The TIPS illustration models inflation-linked returns with price volatility; it is not a specific bond, fund, or guarantee of a real return over your holding period. TreasuryDirect: TIPS.
- Inflation modeling: Changing inflation leaves nominal return assumptions unchanged for all assets except TIPS. It isolates purchasing-power effects; it does not simulate how inflation changes stock valuations, interest rates, gold demand, or crypto prices. Use the editable returns to test those possibilities separately.
How the Monte Carlo simulation works
This calculator uses parametric Monte Carlo simulation, not historical bootstrap sampling. It generates random normal shocks and converts them into lognormal monthly growth factors using the selected expected return and volatility. Bootstrap sampling would instead resample observed historical returns, individually or in blocks. For each choice, the calculator runs 3,000 paths and reports the 10th, 50th (median), and 90th percentiles of ending balances.
Monthly lognormal returns and end-of-month deposits for 480 months. An expected annual simple return r is converted to monthly log drift [ln(1+r) − volatility²/2]/12. The annual fee is compounded monthly. Contributions increase each year. Portfolios are approximated with weighted annual expected returns and a covariance model, held at constant target weights; they do not simulate individual holdings or an actual rebalancing schedule.
For mixed portfolios, asset risk is derived from common factors: equities load 0.9 on the equity factor; bonds load 0.8 on the bond factor; gold loads 1.0 on its independent factor; crypto loads 0.4 on the equity factor with the balance independent. Other asset-specific risk is independent. These selected correlations are teaching assumptions and can change in real markets. Broad world equity is given lower volatility as an assumption, not a guaranteed diversification benefit.
Fixed random seeds make repeated comparisons reproducible, including when inflation changes. Outcomes are marginal distributions for each choice, not simultaneous realized paths across all investments. The model excludes market regimes, changing correlations, fat tails, unplanned withdrawals, employer match, income limits, changing tax law, and catastrophic crypto failures. The separate crypto crash calculation is a stress test applied to the median ending balance, not part of the simulated percentile distribution. It assumes an immediate end-of-horizon shock with no recovery or replacement contributions.
Education only. Illustrative scenarios, not personalized investment advice. Prepared October 21, 2026.
Reproduce the results in Python
Explore how monthly saving from age 22 to 62 becomes a distribution of possible retirement balances. Use the complete Python program to reproduce the study tables and figures, then explain how the assumptions affect your conclusions.
Open the complete Python code, downloads, and instructions
Run the notebook in Google Colab
- Download the complete Colab notebook above.
- Open Google Colab and choose File → Upload notebook, then upload the downloaded .ipynb file.
- Choose Runtime → Run all. Wait for the calculations to finish.
- Scroll down to see 12 study tables, 3 journal tables, supporting tables, and all 5 figures. The notebook also prepares a ZIP download containing every result.
To run the .py file on your own computer, install NumPy, Matplotlib, pandas, and IPython, then run the downloaded script with Python. Colab already supplies these packages.
Check your baseline result
The notebook uses 3,000 simulated paths, 480 months, an initial monthly deposit of $1,000 rising 5% each year, and a 0.10% annual fee. It includes inflation, fee, return, contribution-scale, and random-seed comparisons.
Undergraduate research prompts
- Reproduce the baseline and identify the nominal median, real median, and lower percentile.
- Use the fee and return sensitivity tables to explain which assumptions change your recommendation.
- Explain why different random seeds produce slightly different outcomes.
- State the model’s limitations before defending your investment choice.
Show the full Python program
This is the complete program used by the notebook, including table display, chart display, and ZIP export.
"""Reproduce the retirement paper's 12 tables and five figures.
Python 3.9+; install dependencies once:
python -m pip install numpy matplotlib
Run from a terminal:
python retirement_paper_all_tables_graphs.py --output-dir C:/ai
Or open this file in Spyder/IDLE/VS Code and Run. OUTPUT_DIR below is the
default. The folder is created if necessary; same-named outputs are replaced.
No external data, Stata, JavaScript runtime, or internet access is required
after installing NumPy and Matplotlib. This script uses the original
JavaScript random-number algorithm, not NumPy's default random generator.
All plotted values are calculated. Reference values are used ONLY to test
replication. Baseline 60/40 median rounds to $3,950,073, as in the paper.
Small floating-point differences across systems may remain below rounding.
Plots reproduce values and meaning; fonts/layout can vary by computer.
CSV numbers are unrounded; format currency in Excel for paper-style tables.
PNG figures are 300 dpi; PDFs provide vector versions for publication.
The journal's three numerical tables are also exported.
Model: illustrative assumptions, parametric Monte Carlo, 3,000 paths,
40 years, monthly end-of-period deposits beginning at $1,000 and rising
5% annually. No retirement withdrawals, spending target, or success rate.
No real-world account limits or detailed taxes; no historical calibration.
"""
import argparse
import csv
import json
from pathlib import Path
import sys
import numpy as np
import matplotlib
matplotlib.use("Agg") # Save figures without needing an interactive window.
import matplotlib.pyplot as plt
OUTPUT_DIR = Path("/content/retirement_results") if Path("/content").exists() else Path.cwd() / "retirement_results"
PATHS = 3000
MONTHS = 480
SEED = 424242
FEE = 0.001
INITIAL_MONTHLY = 1000.0
DEPOSIT_GROWTH = 0.05
TAX_NOW = 0.22
TAX_LATER = 0.20
ASSET_NAMES = [
"S&P 500 index", "Blue-chip US stocks", "QQQ-style growth",
"US + international stocks", "High-growth mutual fund", "US Treasuries",
"Investment-grade corporate bonds", "Inflation-linked Treasuries (TIPS)",
"Gold", "Crypto / Bitcoin-like exposure",
]
PORTFOLIO_NAMES = ASSET_NAMES + [
"Treasury + corporate bond blend", "Balanced 60/40", "Conservative 40/60",
"Aggressive 80/20", "Balanced + 10% gold", "Balanced + 5% crypto",
]
ASSET_MU = np.array([.08, .075, .08, .077, .08, .04, .05, .015, .04, 0.0])
ASSET_SD = np.array([.16, .14, .23, .15, .25, .06, .08, .05, .18, .65])
WEIGHTS = np.vstack((np.eye(10), np.zeros((6, 10))))
WEIGHTS[10, [5, 6]] = [.5, .5]
WEIGHTS[11, [0, 5]] = [.6, .4]
WEIGHTS[12, [0, 5]] = [.4, .6]
WEIGHTS[13, [0, 5]] = [.8, .2]
WEIGHTS[14, [0, 5, 8]] = [.55, .35, .10]
WEIGHTS[15, [0, 5, 9]] = [.6, .35, .05]
LOADINGS = np.zeros((10, 4))
RESIDUAL = np.zeros(10)
LOADINGS[:5, 0] = .9
RESIDUAL[:5] = np.sqrt(.19)
LOADINGS[5:8, 1] = .8
RESIDUAL[5:8] = np.sqrt(.36)
LOADINGS[8, 2] = 1
LOADINGS[9, 0] = .4
LOADINGS[9, 3] = np.sqrt(.84)
WEIGHTED_SD = WEIGHTS * ASSET_SD
SIGMA = np.sqrt(np.sum((WEIGHTED_SD @ LOADINGS)**2, axis=1)
+ np.sum((WEIGHTED_SD * RESIDUAL)**2, axis=1))
SCENARIOS = [
("2.5% for 40 years", np.full(40, .025)),
("4% for 10 years then 2.5%", np.r_[np.full(10, .04), np.full(30, .025)]),
("4% for 40 years", np.full(40, .04)),
("6% for 10 years then 3%", np.r_[np.full(10, .06), np.full(30, .03)]),
]
def paper_uniforms(count, seed):
"""JavaScript Mulberry32, using exact unsigned integer operations.
Math.imul's low 32 bits are preserved; a sign conversion is unnecessary
because every subsequent XOR/shift uses the same 32-bit pattern.
This function does not modify global NumPy random state.
"""
mask = np.uint64(0xFFFFFFFF)
state = (np.uint64(seed) + np.arange(1, count + 1, dtype=np.uint64)
* np.uint64(0x6D2B79F5)) & mask
t = ((state ^ (state >> np.uint64(15))) * (state | np.uint64(1))) & mask
product = (((t ^ (t >> np.uint64(7))) * (t | np.uint64(61))) & mask)
t = t ^ ((t + product) & mask)
return ((t ^ (t >> np.uint64(14))) & mask).astype(np.float64) / 4294967296.0
def paper_shocks(seed=SEED):
"""Box-Muller normals in the original path-first, then month order."""
z = np.empty((PATHS, MONTHS))
state = int(seed)
for start in range(0, PATHS, 100):
n = min(100, PATHS - start)
count = 2 * n * MONTHS
u = paper_uniforms(count, state)
state = (state + count * 0x6D2B79F5) % 4294967296
normal = (np.sqrt(-2 * np.log(np.maximum(u[0::2], 1e-12)))
* np.cos(2 * np.pi * u[1::2]))
z[start:start + n] = normal.reshape(n, MONTHS)
return z
def simulate(z, portfolio_id, inflation, fee=FEE, stock_return=.08):
"""Return nominal Roth-style terminal percentiles P10, P50 and P90.
portfolio_id is zero-based. Sigma is an annualized log-return diffusion,
not the exact SD of annual simple returns. We simulate the aggregate
fixed-weight portfolio approximation, not individual traded holdings.
"""
w = WEIGHTS[portfolio_id]
mu = ASSET_MU.copy()
mu[0] = stock_return
annual_means = []
for pi in inflation:
mu[7] = (1 + ASSET_MU[7]) * (1 + pi) - 1
annual_means.append(float(w @ mu))
sigma = SIGMA[portfolio_id]
drift = (np.log1p(annual_means) - .5 * sigma**2 + np.log1p(-fee)) / 12
b = np.zeros(z.shape[0])
deposit = INITIAL_MONTHLY
for month in range(MONTHS):
b = b * np.exp(drift[month // 12] + sigma / np.sqrt(12) * z[:, month]) + deposit
if (month + 1) % 12 == 0:
deposit *= 1 + DEPOSIT_GROWTH
return np.quantile(b, [.1, .5, .9], method="linear")
def save_csv(folder, filename, columns, rows):
with (folder / filename).open("w", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f)
writer.writerow(columns)
writer.writerows(rows)
def save_figure(fig, folder, name):
fig.tight_layout()
for extension in ("png", "pdf"):
fig.savefig(folder / f"{name}.{extension}", dpi=300, bbox_inches="tight")
plt.close(fig)
def make_figures(folder, base, real, fees, returns):
teal, dark, gold = "#146f70", "#214b50", "#ae812b"
plt.rcParams.update({"font.size": 10, "axes.spines.top": False,
"axes.spines.right": False, "axes.titleweight": "bold",
"axes.labelcolor": dark, "text.color": dark})
for ids, title, filename in [
([0, 1, 2, 3, 4], "Stock strategies: 10th to 90th percentile ranges", "figure1_stock_strategies"),
([12, 11, 13, 14, 15], "Mixed portfolios: lower outcomes and upper outcomes", "figure2_mixed_portfolios"),
]:
fig, ax = plt.subplots(figsize=(9, 4.4))
q = base[ids] / 1e6
y = np.arange(len(ids))
ax.hlines(y, q[:, 0], q[:, 2], color="#79a99f", linewidth=4)
ax.scatter(q[:, 0], y, color=dark, s=18, zorder=3)
ax.scatter(q[:, 2], y, color=dark, s=18, zorder=3)
ax.scatter(q[:, 1], y, color=teal, s=42, zorder=4)
ax.set_yticks(y)
ax.set_yticklabels([PORTFOLIO_NAMES[i] for i in ids])
ax.invert_yaxis()
ax.set_xlabel("Ending balance at age 62 ($ millions)")
ax.set_title(title)
ax.grid(axis="x", alpha=.15)
ax.set_axisbelow(True)
save_figure(fig, folder, filename)
fig, ax = plt.subplots(figsize=(8.5, 4.5))
heights = np.array([real[s, 11, 1] for s in range(3)]) / 1e6
bars = ax.bar(["2.5% throughout", "4% for 10 years\nthen 2.5%", "4% throughout"],
heights, color=[teal, "#60998e", "#b8ceca"])
for bar, value in zip(bars, heights):
ax.text(bar.get_x() + bar.get_width()/2, value + .025,
f"${value:.3f}m", ha="center", weight="bold")
ax.set_ylim(0, max(heights) * 1.22)
ax.set_title("The same 60/40 nominal median buys less")
ax.set_ylabel("Median buying power ($ millions)")
save_figure(fig, folder, "figure3_inflation")
fig, ax = plt.subplots(figsize=(8.5, 4.5))
for j, (label, color) in enumerate([(ASSET_NAMES[0], dark), (PORTFOLIO_NAMES[11], teal)]):
ax.plot([.1, .5, 1], fees[:, j, 1]/1e6, "o-", label=label, color=color)
ax.set_xlabel("Annual fee (%)")
ax.set_ylabel("Median ending balance ($ millions)")
ax.set_title("Annual fees compound over the savings horizon")
ax.grid(alpha=.15)
ax.legend(frameon=False)
save_figure(fig, folder, "figure4_fee_sensitivity")
fig, ax = plt.subplots(figsize=(8.5, 4.5))
for j, pid in enumerate([0, 11, 13]):
ax.plot([6, 8, 10], returns[:, j, 1]/1e6, "o-",
label=PORTFOLIO_NAMES[pid], color=[dark, teal, gold][j])
ax.set_xlabel("Assumed annual S&P 500 simple return (%)")
ax.set_ylabel("Median ending balance ($ millions)")
ax.set_title("Changing expected returns changes the conclusion")
ax.grid(alpha=.15)
ax.legend(frameon=False)
save_figure(fig, folder, "figure5_return_sensitivity")
def main(output_dir=OUTPUT_DIR):
folder = Path(output_dir).expanduser()
folder.mkdir(parents=True, exist_ok=True)
print("Generating the original seeded shocks...", flush=True)
z = paper_shocks()
factors = np.array([np.prod(1 + p) for _, p in SCENARIOS])
nominal = np.array([[simulate(z, j, p) for j in range(16)] for _, p in SCENARIOS])
real = nominal / factors[:, None, None]
base = nominal[0]
fee_values = [.001, .005, .01]
fees = np.array([[simulate(z, j, SCENARIOS[0][1], fee=f) for j in [0, 11]] for f in fee_values])
return_values = [.06, .08, .10]
returns = np.array([[simulate(z, j, SCENARIOS[0][1], stock_return=r)
for j in [0, 11, 13]] for r in return_values])
seed_values = [424242, 12345, 67890, 13579, 24680]
seeds = np.array([simulate(z if seed == SEED else paper_shocks(seed), 11, SCENARIOS[0][1])
for seed in seed_values])
# Replication gates are reference checks, not inputs to the simulation.
np.testing.assert_allclose(paper_uniforms(3, SEED),
[.1287079993635416, .23631658218801022, .18376533640548587], rtol=0, atol=0)
np.testing.assert_allclose(base[11],
[2475478.4676766302, 3950073.4406768735, 6629328.1098863855], rtol=0, atol=.02)
np.testing.assert_allclose(fees[-1, 1, 1], 3278581.89431493, rtol=0, atol=.02)
np.testing.assert_allclose(returns[-1, 1, 1], 5040026.35460735, rtol=0, atol=.02)
np.testing.assert_allclose(seeds[:, 1],
[3950073.4406768735, 3933419.5919726267, 3934369.398448094,
3961692.221452865, 3905098.581126632], rtol=0, atol=.02)
np.testing.assert_allclose(WEIGHTS.sum(axis=1), 1)
assert np.all(np.diff(nominal, axis=2) >= 0)
non_tips = [i for i in range(16) if i != 7]
np.testing.assert_array_equal(nominal[0, non_tips], nominal[2, non_tips])
print("Replication checks passed; exporting computed tables and figures.", flush=True)
total = 12000 * ((1.05**40 - 1) / .05)
save_csv(folder, "table01_savings_schedule.csv", ["Saving year", "Monthly deposit", "Annual deposits"],
[[y, 1000*1.05**(y-1), 12000*1.05**(y-1)] for y in [1, 5, 10, 20, 30, 40]])
asset_rows = [[ASSET_NAMES[j], ASSET_MU[j], "real" if j == 7 else "nominal", ASSET_SD[j]] for j in range(10)]
save_csv(folder, "table02_asset_assumptions.csv", ["Asset", "Annual return input", "Return units", "Annual log diffusion"], asset_rows)
weight_text = ["50% Treasury / 50% corporate", "60% SP500 / 40% Treasury", "40% SP500 / 60% Treasury",
"80% SP500 / 20% Treasury", "55% SP500 / 35% Treasury / 10% gold", "60% SP500 / 35% Treasury / 5% crypto"]
save_csv(folder, "table03_mixed_portfolio_assumptions.csv", ["Strategy", "Weights", "Annual return input", "Annual log diffusion"],
[[PORTFOLIO_NAMES[j], weight_text[j-10], WEIGHTS[j] @ ASSET_MU, SIGMA[j]] for j in range(10, 16)])
save_csv(folder, "table04_inflation_price_factors.csv", ["Inflation scenario", "Price factor", "Buying power of 1 million"],
[[SCENARIOS[s][0], factors[s], 1e6/factors[s]] for s in range(3)])
qcols = ["Strategy", "10th percentile", "Median", "90th percentile"]
save_csv(folder, "table05_baseline_nominal.csv", qcols, [[PORTFOLIO_NAMES[j], *base[j]] for j in range(16)])
save_csv(folder, "table06_baseline_real.csv", qcols, [[PORTFOLIO_NAMES[j], *real[0, j]] for j in range(16)])
save_csv(folder, "table07_bonds_TIPS_gold.csv", ["Choice", "Real median", "Nominal P90 minus P10"],
[[PORTFOLIO_NAMES[j], real[0, j, 1], base[j, 2]-base[j, 0]] for j in [5, 6, 10, 7, 8]])
save_csv(folder, "table08_crypto_terminal_stress.csv", ["Crypto component loss", "Nominal median after stress", "Real median after stress"],
[[loss, base[15, 1]*(1-.05*loss), real[0, 15, 1]*(1-.05*loss)] for loss in [0, .8, 1]])
save_csv(folder, "table09_fee_sensitivity.csv", ["Annual fee percent", "SP500 median", "60/40 median"],
[[100*f, *fees[i, :, 1]] for i, f in enumerate(fee_values)])
save_csv(folder, "table10_return_sensitivity.csv", ["SP500 return percent", "All-stock median", "60/40 median", "80/20 median"],
[[100*r, *returns[i, :, 1]] for i, r in enumerate(return_values)])
save_csv(folder, "table11_savings_scale.csv", ["Initial monthly deposit", "Total nominal deposits", "60/40 median", "Real median"],
[[d, total*d/1000, base[11, 1]*d/1000, real[0, 11, 1]*d/1000] for d in [250, 500, 1000]])
save_csv(folder, "table12_seed_sensitivity.csv", ["Seed", "10th percentile", "Median", "90th percentile"],
[[seed, *seeds[i]] for i, seed in enumerate(seed_values)])
# The journal manuscript's three numerical tables.
settings = {"paths": PATHS, "seed": SEED, "years": 40, "months": MONTHS,
"starting_age": 22, "retirement_age": 62, "initial_balance": 0,
"initial_monthly": INITIAL_MONTHLY, "annual_deposit_growth": DEPOSIT_GROWTH,
"annual_fee": FEE, "current_tax_rate": TAX_NOW, "withdrawal_tax_rate": TAX_LATER,
"equal_cost": 0, "total_nominal_deposits": total,
"rng": "JavaScript Mulberry32 with Box-Muller", "draw_order": "path then month",
"deposit_timing": "after monthly return", "quantiles": "linear interpolation"}
save_csv(folder, "journal_table01_core_assumptions.csv", ["Input", "Setting"], list(settings.items()))
save_csv(folder, "journal_table02_baseline.csv", ["Alternative", "Nominal P10 millions", "Nominal P50 millions", "Nominal P90 millions", "Real P50 millions"],
[[PORTFOLIO_NAMES[j], *(base[j]/1e6), real[0, j, 1]/1e6] for j in [0, 5, 7, 11, 13, 14, 15]])
save_csv(folder, "journal_table03_inflation.csv", ["Inflation path", "Price factor", "60/40 nominal median millions", "60/40 real median millions", "TIPS real median millions"],
[[SCENARIOS[s][0], factors[s], nominal[s, 11, 1]/1e6, real[s, 11, 1]/1e6, real[s, 7, 1]/1e6] for s in range(3)])
save_csv(folder, "paper_run_settings.csv", list(settings.keys()), [list(settings.values())])
save_csv(folder, "paper_portfolio_weights.csv", ["Strategy", *ASSET_NAMES],
[[PORTFOLIO_NAMES[j], *WEIGHTS[j]] for j in range(16)])
# One combined results file, including simplified account comparisons.
# With equal deposits, pre-tax after-tax values are 80% of Roth values.
# At equal household cost, the multiplier is .80/.78 instead.
records = []
def record(experiment, scenario, pid, q, fac, fee, stock, seed):
records.append([experiment, scenario, PORTFOLIO_NAMES[pid], pid+1, fee, stock, seed,
fac, *q, *(q/fac), *(q*(1-TAX_LATER)),
*(q*(1-TAX_LATER)/(1-TAX_NOW))])
for s, (name, _) in enumerate(SCENARIOS):
for j in range(16): record("baseline/inflation", name, j, nominal[s, j], factors[s], FEE, .08, SEED)
for i, fee in enumerate(fee_values):
for k, j in enumerate([0, 11]): record("fee", SCENARIOS[0][0], j, fees[i, k], factors[0], fee, .08, SEED)
for i, r in enumerate(return_values):
for k, j in enumerate([0, 11, 13]): record("return", SCENARIOS[0][0], j, returns[i, k], factors[0], FEE, r, SEED)
for i, seed in enumerate(seed_values): record("seed", SCENARIOS[0][0], 11, seeds[i], factors[0], FEE, .08, seed)
columns = ["experiment", "scenario", "portfolio", "portfolio_id", "annual_fee", "stock_return", "seed", "price_factor",
"roth_p10", "roth_p50", "roth_p90", "real_roth_p10", "real_roth_p50", "real_roth_p90",
"pretax_equal_deposits_p10", "pretax_equal_deposits_p50", "pretax_equal_deposits_p90",
"pretax_equal_cost_p10", "pretax_equal_cost_p50", "pretax_equal_cost_p90"]
assert len(records) == 84
save_csv(folder, "paper_simulation_results.csv", columns, records)
make_figures(folder, base, real, fees, returns)
(folder / "replication_check.json").write_text(json.dumps({
"checks_passed": True, "baseline_6040_nominal_percentiles": base[11].tolist(),
"baseline_6040_real_median": float(real[0, 11, 1]),
"numpy_version": np.__version__, "matplotlib_version": matplotlib.__version__,
"python_version": sys.version.split()[0], "settings": settings,
}, indent=2), encoding="utf-8")
print(f"Baseline 60/40 median: ${base[11, 1]:,.0f}")
print(f"Baseline 60/40 real median: ${real[0, 11, 1]:,.0f}")
print(f"Saved 12 study tables, 3 journal tables, 5 PNG/PDF figures, and supporting CSV files to {folder.resolve()}")
# Run the complete simulation, then show every exported table and figure.
main(OUTPUT_DIR)
import pandas as pd
from IPython.display import display, Image, Markdown, FileLink
import shutil
print("\nALL SAVED TABLES (including supporting results)")
for file in sorted(OUTPUT_DIR.glob("*.csv")):
display(Markdown("### " + file.stem.replace("_", " ")))
with pd.option_context("display.max_rows", None,
"display.max_columns", None,
"display.max_colwidth", None):
display(pd.read_csv(file))
print("\nALL FIVE FIGURES")
for file in sorted(OUTPUT_DIR.glob("figure*.png")):
display(Markdown("### " + file.stem.replace("_", " ")))
display(Image(filename=str(file), width=1000))
archive = shutil.make_archive(str(OUTPUT_DIR.parent / "retirement_results_all"), "zip", OUTPUT_DIR)
print("\nDownload all CSV tables, PNG/PDF figures, and replication checks:")
try:
from google.colab import files
files.download(archive)
except ImportError:
display(FileLink(archive))
Change an assumption. Get new results.
This separate, self-contained program compares the study’s original assumptions with your changes for all 16 strategies. It produces a new comparison table, two charts, and saved CSV and assumptions files. You can run it without running the original notebook first.
Show what to change, examples, and the separate code
- Download the separate Python code below, or expand it and copy all of it into one new Colab code cell.
- At the top, find the box headed CHANGE ONLY THE VALUES IN THIS BOX.
- Change one value using an example below. Use decimals: 0.06 = 6%, and 0.001 = 0.10%.
- Click ▶ beside that cell or press Shift + Enter to rerun the entire cell. Each run rebuilds the original comparison and applies your edited assumptions.
- Read the Original, New, and Change in real median columns. Try one change at a time to explain what caused the difference.
Download separate assumption-change code (.py)
| Assumption | Find this code | Change it to | New results you get |
|---|---|---|---|
| Expected S&P 500 return | NEW_STOCK_RETURN = 0.08 | NEW_STOCK_RETURN = 0.06 | New wealth percentiles for strategies containing S&P 500 exposure. |
| Stock volatility | NEW_STOCK_VOLATILITY = 0.16 | NEW_STOCK_VOLATILITY = 0.20 | New modeled risk, medians, and percentile ranges for strategies containing S&P 500 exposure. |
| Monthly saving | NEW_MONTHLY_DEPOSIT = 1000.0 | NEW_MONTHLY_DEPOSIT = 500.0 | New contributions and ending balances for all 16 strategies. |
| Annual saving increase | NEW_DEPOSIT_GROWTH = 0.05 | NEW_DEPOSIT_GROWTH = 0.03 | New deposit schedule, total deposits, and ending wealth. |
| Annual fee | NEW_ANNUAL_FEE = 0.001 | NEW_ANNUAL_FEE = 0.01 | New results after increasing the annual fee from 0.10% to 1.00%. |
| Inflation | NEW_INFLATION = 0.025 | NEW_INFLATION = 0.04 | New purchasing power at 4% inflation; TIPS nominal returns also adjust. Other nominal returns remain fixed. |
| Withdrawal tax | NEW_WITHDRAWAL_TAX = 0.20 | NEW_WITHDRAWAL_TAX = 0.25 | New simplified after-tax pre-tax-account medians; Roth outcomes stay the same. |
| Portfolio allocation | NEW_STOCK_WEIGHT = 0.60 | NEW_STOCK_WEIGHT = 0.40 | New results for the custom stock/Treasury mix; the other 15 allocations stay the same. |
| Random seed | NEW_SEED = 424242 | NEW_SEED = 12345 | Another set of Monte Carlo draws under your assumptions, to examine simulation variability. |
The experiment keeps the original 3,000 paths and ages 22–62. The custom mix is compared with the original 60/40 portfolio. The original study’s 12-table notebook remains a separate baseline replication; this experiment produces its own comparison outputs. New dollar results are recalculated, not forced to match the original study.
Show the separate, complete editable Python code
"""Standalone assumption experiment. Paste ALL code into one new Colab cell."""
# CHANGE ONLY THE VALUES IN THIS BOX, THEN RUN THE ENTIRE CELL AGAIN.
# Use decimals: 0.06 means 6%; 0.005 means 0.50%.
NEW_STOCK_RETURN = 0.08 # Try 0.06: lower expected S&P 500 return
NEW_STOCK_VOLATILITY = 0.16 # Try 0.20: greater modeled S&P 500 volatility
NEW_MONTHLY_DEPOSIT = 1000.0 # Try 500.0: smaller initial monthly saving
NEW_DEPOSIT_GROWTH = 0.05 # Try 0.03: deposits rise 3% per year
NEW_ANNUAL_FEE = 0.001 # Try 0.01: a 1% annual fee
NEW_INFLATION = 0.025 # Try 0.04: 4% inflation every year
NEW_WITHDRAWAL_TAX = 0.20 # Try 0.25: simplified pre-tax withdrawal tax
NEW_STOCK_WEIGHT = 0.60 # Try 0.40: custom mix becomes 40% stock / 60% Treasury
NEW_SEED = 424242 # Try 12345: different random draws
# Keep 3,000 paths and ages 22 to 62 for comparisons with the study.
import argparse
import csv
import json
from pathlib import Path
import sys
import numpy as np
import matplotlib
matplotlib.use("Agg") # Save figures without needing an interactive window.
import matplotlib.pyplot as plt
OUTPUT_DIR = Path("/content/retirement_results") if Path("/content").exists() else Path.cwd() / "retirement_results"
PATHS = 3000
MONTHS = 480
SEED = 424242
FEE = 0.001
INITIAL_MONTHLY = 1000.0
DEPOSIT_GROWTH = 0.05
TAX_NOW = 0.22
TAX_LATER = 0.20
ASSET_NAMES = [
"S&P 500 index", "Blue-chip US stocks", "QQQ-style growth",
"US + international stocks", "High-growth mutual fund", "US Treasuries",
"Investment-grade corporate bonds", "Inflation-linked Treasuries (TIPS)",
"Gold", "Crypto / Bitcoin-like exposure",
]
PORTFOLIO_NAMES = ASSET_NAMES + [
"Treasury + corporate bond blend", "Balanced 60/40", "Conservative 40/60",
"Aggressive 80/20", "Balanced + 10% gold", "Balanced + 5% crypto",
]
ASSET_MU = np.array([.08, .075, .08, .077, .08, .04, .05, .015, .04, 0.0])
ASSET_SD = np.array([.16, .14, .23, .15, .25, .06, .08, .05, .18, .65])
WEIGHTS = np.vstack((np.eye(10), np.zeros((6, 10))))
WEIGHTS[10, [5, 6]] = [.5, .5]
WEIGHTS[11, [0, 5]] = [.6, .4]
WEIGHTS[12, [0, 5]] = [.4, .6]
WEIGHTS[13, [0, 5]] = [.8, .2]
WEIGHTS[14, [0, 5, 8]] = [.55, .35, .10]
WEIGHTS[15, [0, 5, 9]] = [.6, .35, .05]
LOADINGS = np.zeros((10, 4))
RESIDUAL = np.zeros(10)
LOADINGS[:5, 0] = .9
RESIDUAL[:5] = np.sqrt(.19)
LOADINGS[5:8, 1] = .8
RESIDUAL[5:8] = np.sqrt(.36)
LOADINGS[8, 2] = 1
LOADINGS[9, 0] = .4
LOADINGS[9, 3] = np.sqrt(.84)
WEIGHTED_SD = WEIGHTS * ASSET_SD
SIGMA = np.sqrt(np.sum((WEIGHTED_SD @ LOADINGS)**2, axis=1)
+ np.sum((WEIGHTED_SD * RESIDUAL)**2, axis=1))
SCENARIOS = [
("2.5% for 40 years", np.full(40, .025)),
("4% for 10 years then 2.5%", np.r_[np.full(10, .04), np.full(30, .025)]),
("4% for 40 years", np.full(40, .04)),
("6% for 10 years then 3%", np.r_[np.full(10, .06), np.full(30, .03)]),
]
def paper_uniforms(count, seed):
"""JavaScript Mulberry32, using exact unsigned integer operations.
Math.imul's low 32 bits are preserved; a sign conversion is unnecessary
because every subsequent XOR/shift uses the same 32-bit pattern.
This function does not modify global NumPy random state.
"""
mask = np.uint64(0xFFFFFFFF)
state = (np.uint64(seed) + np.arange(1, count + 1, dtype=np.uint64)
* np.uint64(0x6D2B79F5)) & mask
t = ((state ^ (state >> np.uint64(15))) * (state | np.uint64(1))) & mask
product = (((t ^ (t >> np.uint64(7))) * (t | np.uint64(61))) & mask)
t = t ^ ((t + product) & mask)
return ((t ^ (t >> np.uint64(14))) & mask).astype(np.float64) / 4294967296.0
def paper_shocks(seed=SEED):
"""Box-Muller normals in the original path-first, then month order."""
z = np.empty((PATHS, MONTHS))
state = int(seed)
for start in range(0, PATHS, 100):
n = min(100, PATHS - start)
count = 2 * n * MONTHS
u = paper_uniforms(count, state)
state = (state + count * 0x6D2B79F5) % 4294967296
normal = (np.sqrt(-2 * np.log(np.maximum(u[0::2], 1e-12)))
* np.cos(2 * np.pi * u[1::2]))
z[start:start + n] = normal.reshape(n, MONTHS)
return z
def simulate(z, portfolio_id, inflation, fee=FEE, stock_return=.08):
"""Return nominal Roth-style terminal percentiles P10, P50 and P90.
portfolio_id is zero-based. Sigma is an annualized log-return diffusion,
not the exact SD of annual simple returns. We simulate the aggregate
fixed-weight portfolio approximation, not individual traded holdings.
"""
w = WEIGHTS[portfolio_id]
mu = ASSET_MU.copy()
mu[0] = stock_return
annual_means = []
for pi in inflation:
mu[7] = (1 + ASSET_MU[7]) * (1 + pi) - 1
annual_means.append(float(w @ mu))
sigma = SIGMA[portfolio_id]
drift = (np.log1p(annual_means) - .5 * sigma**2 + np.log1p(-fee)) / 12
b = np.zeros(z.shape[0])
deposit = INITIAL_MONTHLY
for month in range(MONTHS):
b = b * np.exp(drift[month // 12] + sigma / np.sqrt(12) * z[:, month]) + deposit
if (month + 1) % 12 == 0:
deposit *= 1 + DEPOSIT_GROWTH
return np.quantile(b, [.1, .5, .9], method="linear")
import pandas as pd
from IPython.display import display, Image
for value in [NEW_STOCK_RETURN, NEW_DEPOSIT_GROWTH]:
assert np.isfinite(value) and value > -1
assert NEW_STOCK_VOLATILITY >= 0 and np.isfinite(NEW_STOCK_VOLATILITY)
assert NEW_MONTHLY_DEPOSIT >= 0 and np.isfinite(NEW_MONTHLY_DEPOSIT)
assert 0 <= NEW_ANNUAL_FEE < 1
assert NEW_INFLATION > -1 and np.isfinite(NEW_INFLATION)
assert 0 <= NEW_WITHDRAWAL_TAX <= 1
assert 0 <= NEW_STOCK_WEIGHT <= 1
assert isinstance(NEW_SEED, int) and 0 <= NEW_SEED < 2**32
folder = Path('/content/retirement_custom') if Path('/content').exists() else Path.cwd() / 'retirement_custom'
folder.mkdir(parents=True, exist_ok=True)
print('Calculating original and changed assumptions...')
original_z = paper_shocks(424242)
baseline = np.array([simulate(original_z, j, np.full(40, .025), fee=.001, stock_return=.08) for j in range(16)])
base_factor = 1.025**40
# Apply your changes, then recompute risk and all 16 portfolio distributions.
ASSET_MU[0] = NEW_STOCK_RETURN
ASSET_SD[0] = NEW_STOCK_VOLATILITY
INITIAL_MONTHLY = NEW_MONTHLY_DEPOSIT
DEPOSIT_GROWTH = NEW_DEPOSIT_GROWTH
WEIGHTS[11] = 0
WEIGHTS[11, [0, 5]] = [NEW_STOCK_WEIGHT, 1-NEW_STOCK_WEIGHT]
weighted_sd = WEIGHTS * ASSET_SD
SIGMA = np.sqrt(np.sum((weighted_sd @ LOADINGS)**2, axis=1)
+ np.sum((weighted_sd * RESIDUAL)**2, axis=1))
z = original_z if NEW_SEED == 424242 else paper_shocks(NEW_SEED)
inflation = np.full(40, NEW_INFLATION)
changed = np.array([simulate(z, j, inflation, fee=NEW_ANNUAL_FEE,
stock_return=NEW_STOCK_RETURN) for j in range(16)])
factor = (1 + NEW_INFLATION)**40
assert np.isfinite(changed).all()
assert (np.diff(changed, axis=1) >= 0).all()
assert np.allclose(WEIGHTS.sum(axis=1), 1)
# Changed assumptions are intentionally NOT tested against the original dollar results.
labels = PORTFOLIO_NAMES.copy()
labels[11] = f'Custom mix: {NEW_STOCK_WEIGHT:.0%} S&P 500 / {1-NEW_STOCK_WEIGHT:.0%} Treasury'
deposits = sum(12 * INITIAL_MONTHLY * (1 + DEPOSIT_GROWTH)**y for y in range(40))
rows=[]
for j, label in enumerate(labels):
rows.append({
'Strategy':label,
'Original nominal P10':baseline[j,0], 'Original nominal median':baseline[j,1],
'Original nominal P90':baseline[j,2], 'Original real median':baseline[j,1]/base_factor,
'New nominal P10':changed[j,0], 'New nominal median':changed[j,1],
'New nominal P90':changed[j,2], 'New real median':changed[j,1]/factor,
'Change in real median':changed[j,1]/factor-baseline[j,1]/base_factor,
'New pre-tax after-tax nominal median':changed[j,1]*(1-NEW_WITHDRAWAL_TAX),
'New pre-tax after-tax real median':changed[j,1]*(1-NEW_WITHDRAWAL_TAX)/factor,
'New total nominal deposits':deposits,
})
results=pd.DataFrame(rows)
print('For the custom mix, the original comparison is the study 60/40 portfolio.')
with pd.option_context('display.max_rows',None,'display.max_columns',None):
display(results.round(2))
results.to_csv(folder/'original_vs_changed_assumptions.csv',index=False)
settings={k:v for k,v in list(globals().items()) if k.startswith('NEW_')}
(folder/'changed_assumptions.json').write_text(json.dumps(settings,indent=2))
for title, values, fname in [
('Nominal median at age 62', (baseline[:,1], changed[:,1]), 'custom_nominal'),
('Median purchasing power in starting-year dollars', (baseline[:,1]/base_factor,changed[:,1]/factor), 'custom_real')]:
fig,ax=plt.subplots(figsize=(11,9))
positions=np.arange(16)
ax.barh(positions-.18,values[0]/1e6,height=.36,label='Original assumptions',color='#b8ceca')
ax.barh(positions+.18,values[1]/1e6,height=.36,label='Your changed assumptions',color='#126b68')
ax.set_yticks(positions);ax.set_yticklabels(labels);ax.invert_yaxis()
ax.set_xlabel('Millions of dollars');ax.set_title(title);ax.legend()
fig.tight_layout();fig.savefig(folder/(fname+'.png'),dpi=160);plt.close(fig)
display(Image(filename=str(folder/(fname+'.png'))))
print('New custom-mix nominal median: $'+format(changed[11,1],',.0f'))
print('New custom-mix real median: $'+format(changed[11,1]/factor,',.0f'))
print('Saved comparison CSV, assumptions JSON, and two charts to',folder)
print('To try another assumption: edit the values at the TOP and rerun THIS ENTIRE CELL.')