Saturday, March 28, 2026
HomeAI ToolsMonte Carlo Portfolio Stress-Test in Python: Know Your Real Downside Risk

Monte Carlo Portfolio Stress-Test in Python: Know Your Real Downside Risk

Build time: ~20 min
📊 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)

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here