📊 Data source: yfinance (free)
🔧 Difficulty: Intermediate
📈 Output: VaR metrics + 1,000-path simulation statistics
Python Investor’s Toolkit: Part 1: Momentum · Part 2: Monte Carlo · Part 3: Mean Reversion · Part 4: Earnings Quality · Part 5: Macro Regime
Part 2 of 5 — The Python Investor’s Toolkit: Practical quant tools built with free data.
Most investors know their portfolio is down when markets fall — but almost none of them know how much it could fall before it happens. Value at Risk (VaR) and Monte Carlo simulation are the tools professional risk managers use to quantify this. They’re built into Bloomberg terminals that cost $24,000 a year. This post builds the same analysis for free.
By the end, you’ll have a script that downloads your portfolio’s historical returns, simulates 1,000 possible one-year outcomes, and tells you exactly how bad your worst 5% of scenarios look.
What You’ll Need
pip install yfinance numpy pandas matplotlib
💡 How Monte Carlo Works: The simulation draws thousands of random daily returns from a distribution calibrated to your portfolio’s historical volatility and drift. Each draw produces a possible “path” for your portfolio over the next year. After 1,000 paths, you have a probability distribution of outcomes — which is far more informative than a single-point expected return.
Understanding the Risk Metrics
| Metric | What It Means | Healthy Range | When to Act |
|---|---|---|---|
| VaR (95%) | Worst loss in 19 of 20 years | -10% to -20% for 60/40 | If it exceeds your sleep threshold |
| VaR (99%) | Worst loss in 99 of 100 years | -25% to -40% for equity-heavy | Tail risk hedge consideration |
| Median Return | The most likely 1-year outcome | Inflation + 3–5% for diversified | If below cash rate, rebalance |
| Sharpe Ratio | Return per unit of risk taken | >0.5 acceptable, >1.0 good | Low Sharpe = high risk for low reward |
The Code
import yfinance as yf
import numpy as np
import pandas as pd
from datetime import datetime, timedelta
def download_portfolio_data(tickers, years=3):
"""Download historical price data for portfolio holdings via yfinance."""
end = datetime.today()
start = end - timedelta(days=365 * years)
data = yf.download(tickers, start=start, end=end, progress=False)['Adj Close']
if isinstance(data, pd.Series):
data = data.to_frame(name=tickers[0])
return data.dropna()
def run_monte_carlo(tickers, weights, portfolio_value=100000,
n_simulations=1000, horizon_days=252):
"""
Monte Carlo simulation for 1-year portfolio risk analysis.
Parameters:
tickers : list of ticker symbols
weights : numpy array of portfolio weights (must sum to 1.0)
portfolio_value : starting portfolio value in USD
n_simulations : number of simulation paths (1000 is sufficient)
horizon_days : trading days to simulate (252 = 1 year)
"""
print(f"Downloading price data for: {tickers}")
prices = download_portfolio_data(tickers)
returns = prices.pct_change().dropna()
# Portfolio daily return series
weights = np.array(weights)
port_returns = (returns * weights).sum(axis=1)
mu = port_returns.mean()
sigma = port_returns.std()
print(f"Daily return — Mean: {mu:.4f} ({mu*252*100:.1f}% ann.), "
f"Std Dev: {sigma:.4f} ({sigma*np.sqrt(252)*100:.1f}% ann.)")
# Simulate portfolio value paths
np.random.seed(42)
simulations = np.zeros((horizon_days, n_simulations))
simulations[0] = portfolio_value
for t in range(1, horizon_days):
random_returns = np.random.normal(mu, sigma, n_simulations)
simulations[t] = simulations[t-1] * (1 + random_returns)
final_values = simulations[-1]
pct_returns = (final_values - portfolio_value) / portfolio_value * 100
# --- Risk metrics ---
var_95 = np.percentile(pct_returns, 5)
var_99 = np.percentile(pct_returns, 1)
worst_05 = np.percentile(pct_returns, 0.5)
median_ret = np.median(pct_returns)
mean_ret = np.mean(pct_returns)
prob_loss = (pct_returns < 0).mean() * 100
ann_vol = sigma * np.sqrt(252) * 100
sharpe = (mu * 252) / (sigma * np.sqrt(252)) if sigma > 0 else 0
print(f"{'='*55}")
print(f" Starting Value: ${portfolio_value:>12,.0f}")
print(f" Median 1-Year Return: {median_ret:>+11.1f}% "
f"(${portfolio_value*(1+median_ret/100):,.0f})")
print(f" Expected Return: {mean_ret:>+11.1f}%")
print(f" Annualised Volatility: {ann_vol:>11.1f}%")
print(f" Sharpe Ratio: {sharpe:>11.2f}")
print(f" Probability of Loss: {prob_loss:>11.1f}%")
print(f" VaR (95%): {var_95:>+11.1f}% "
f"(${portfolio_value*var_95/100:,.0f})")
print(f" VaR (99%): {var_99:>+11.1f}% "
f"(${portfolio_value*var_99/100:,.0f})")
print(f" Worst 0.5% Scenario: {worst_05:>+11.1f}% "
f"(${portfolio_value*worst_05/100:,.0f})")
print(f"{'='*55}")
return simulations, pct_returns
# -------------------------------------------------------
# EXAMPLE 1: Classic 60/40 portfolio
# -------------------------------------------------------
print("n>>> Portfolio 1: 60/40 Equity/Bond")
run_monte_carlo(
tickers = ['SPY', 'QQQ', 'IWM', 'TLT', 'AGG'],
weights = [0.30, 0.20, 0.10, 0.25, 0.15],
portfolio_value = 100_000
)
# -------------------------------------------------------
# EXAMPLE 2: Growth-tilted individual stocks
# -------------------------------------------------------
print("n>>> Portfolio 2: Tech-heavy individual stocks")
run_monte_carlo(
tickers = ['AAPL', 'MSFT', 'NVDA', 'GOOGL', 'AMZN'],
weights = [0.25, 0.25, 0.20, 0.15, 0.15],
portfolio_value = 100_000
)
Interpreting Your Results
The most actionable number is VaR (95%). If it reads -18.3%, that means in a bad-but-not-catastrophic year (which happens roughly 1 in 20 years), you can expect to lose at least 18.3% of your portfolio value. On a $100,000 portfolio, that’s $18,300 you need to be comfortable sitting through without selling.
📈 Key Insight: Most investors overestimate their risk tolerance. Running this simulation before a drawdown — not during one — is what lets you make rational decisions. If your VaR (95%) is larger than you can stomach, reduce equity weight now, not after a 20% drop when emotions take over.
Compare Portfolios Side by Side
The real power of this tool is running it on multiple portfolio configurations and comparing the output. Try running it with different equity/bond splits (80/20, 60/40, 40/60) to see how much VaR improves with more defensive positioning vs. the median return you give up.
⚠️ Model Limitation: This simulation assumes daily returns are normally distributed and that future volatility resembles the past 3 years. In practice, equity returns have fat tails — extreme losses happen more often than a normal distribution predicts. For a more conservative estimate, increase sigma by 20–30% when running worst-case scenarios: sigma_stressed = sigma * 1.25.
📊 Portfolio Takeaway
Run this stress-test before a market downturn, not during one. If your VaR (95%) is larger than you can emotionally sit through, reduce equity exposure now: shift 10–15% to short-duration bonds or cash until the number is in your comfort zone. A portfolio you’d panic-sell at −20% will cost you far more in realized losses than a slightly lower-returning allocation you can hold through the cycle.
What’s Next
Now you can quantify downside. Part 3 builds the other side of the toolkit: a mean reversion alert system that tells you when individual stocks are statistically oversold and due for a bounce.
Series: The Python Investor’s Toolkit
Part 1: Momentum Scanner
Part 2: Monte Carlo Portfolio Stress-Test (this post)
Part 3: Mean Reversion Alert System
Part 4: Earnings Quality Analyzer (SEC EDGAR)
Part 5: Macro Regime Detector (FRED API)
