No of Post Views:

26 hits

In our previous article, we built a Monte Carlo simulation to forecast the future price of a single stock. While useful, most investors don’t put all their eggs in one basket. They build a portfolio to manage risk and improve returns. This leads to the question: out of all the possible combinations of assets, which is the best one?

Welcome to the sequel in our hands-on quantitative finance series. Today, we move from analyzing a single instrument to optimizing an entire portfolio. We will apply the Modern Portfolio Theory (MPT) using Python to find the “optimal” portfolio that offers the best return for a given level of risk.

With theory and the code, this guide will explain a practical framework for building your own portfolio optimizer.

The Theory: Modern Portfolio Theory (MPT)

MPT says that by combining assets that don’t move perfectly in sync, we can reduce the overall risk without sacrificing returns.

MPT is built on a few key statistical concepts:

  1. Expected Return: The expected return of a portfolio is the weighted average of the expected returns of the individual assets.
  2. Portfolio Volatility (Risk): This is where it gets interesting. The risk of a portfolio is not just the weighted average of the individual asset risks. It also depends on how the assets move with respect to each other. This relationship is measured by covariance. If two assets move in opposite directions (-ve covariance), combining them can lower the portfolio’s volatility.
  3. The Efficient Frontier: Imagine plotting every possible combination of assets on a chart of risk vs. return. The Efficient Frontier is a curve representing the set of “best” portfolios. For a given level of risk, no portfolio offers a higher return than the one on the Efficient Frontier. Conversely, for any given level of return, there is no portfolio with lower risk.
  4. The Sharpe Ratio: So how do we choose the single “best” portfolio from all the options on the Efficient Frontier? We use the Sharpe Ratio. It measures the portfolio’s return in excess of the risk-free rate, per unit of risk. The portfolio on the Efficient Frontier with the highest Sharpe Ratio is considered the “optimal” risky portfolio.

Sharpe Ratio = (Portfolio Return – Risk-Free Rate) / Portfolio Volatility

Our goal is to use Python to find this maximum Sharpe Ratio portfolio.

Let’s Get Coding: Building the Optimizer

We will build our optimizer in two steps. First, we simulate thousands of random portfolios to visualize the Efficient Frontier. Then, we find the optimal one.

Prerequisites

Make sure you have the necessary libraries installed:

pip install numpy pandas yfinance matplotlib scipy
  • scipy: This is a new one for this article. It’s a powerful library for scientific and technical computing. We’ll use its optimization module to find the optimal portfolio.
Step 1: Select Assets and Fetch Data

A good portfolio is a diverse one. Let’s select a handful of well-known stocks from different sectors of the economy.

import pandas as pd
import numpy as np
import yfinance as yf
import matplotlib.pyplot as plt
from datetime import datetime

# Define the list of tickers for our portfolio
tickers = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'JPM', 'V', 'PG', 'JNJ']

# Define the time period for our historical data
start_date = datetime(2018, 1, 1)
end_date = datetime(2024, 12, 31)

# Fetch the 'Adj Close' prices using yfinance
adj_close_df = pd.DataFrame()
for ticker in tickers:
    # Use yf.download to fetch data
    data = yf.download(ticker, start=start_date, end=end_date)
    adj_close_df[ticker] = data['Close']

print("Last 5 days of historical data:")
print(adj_close_df.tail())

This script fetches the adjusted closing prices for our chosen stocks and stores them in a single pandas DataFrame.

Step 2: Calculate Returns and Covariance

Calculate the daily log returns, which we’ll use to derive the expected returns and the covariance matrix.

# Calculate daily log returns
log_returns = np.log(adj_close_df / adj_close_df.shift(1))

# Remove the first row of NaN values
log_returns = log_returns.dropna()

# Calculate the mean of daily log returns (our expected returns)
mean_log_returns = log_returns.mean()

# Calculate the covariance of daily log returns
cov_matrix = log_returns.cov()

print("nAnnualized Expected Returns:")
print(mean_log_returns * 252) # 252 trading days in a year

The covariance matrix is crucial. It’s a square matrix where each element (i, j) represents the covariance between asset i and asset j. The diagonal elements are the variances of each individual asset. This matrix captures the inter-relationships that are at the heart of MPT.

Step 3: Simulate Thousands of Random Portfolios

Now, we’ll use a Monte Carlo approach. We’ll generate a large number of random portfolio weightings. For each portfolio, we’ll calculate its total expected return. Additionally, we’ll calculate its volatility and Sharpe Ratio.

# Set up our simulation parameters
num_portfolios = 25000
risk_free_rate = 0.02 # A placeholder for the risk-free rate (e.g., a 10-year US Treasury)

# Set up arrays to store our results
portfolio_returns = []
portfolio_volatility = []
portfolio_weights = []
sharpe_ratios = []

# Loop to generate random portfolios
for _ in range(num_portfolios):
    # Generate random weights
    weights = np.random.random(len(tickers))
    # Normalize weights so they sum to 1
    weights /= np.sum(weights)

    # Calculate portfolio return
    # We multiply by 252 to annualize it
    returns = np.sum(mean_log_returns * weights) * 252

    # Calculate portfolio volatility (risk)
    # We multiply by sqrt(252) to annualize it
    volatility = np.sqrt(np.dot(weights.T, np.dot(cov_matrix, weights))) * np.sqrt(252)

    # Calculate Sharpe Ratio
    sharpe = (returns - risk_free_rate) / volatility

    # Store the results
    portfolio_returns.append(returns)
    portfolio_volatility.append(volatility)
    sharpe_ratios.append(sharpe)
    portfolio_weights.append(weights)

# Convert lists to a DataFrame for easier analysis
portfolio_data = {
    'Return': portfolio_returns,
    'Volatility': portfolio_volatility,
    'Sharpe Ratio': sharpe_ratios
}
for i, ticker in enumerate(tickers):
    portfolio_data[f'Weight_{ticker}'] = [w[i] for w in portfolio_weights]

portfolios_df = pd.DataFrame(portfolio_data)
Step 4: Visualize the Efficient Frontier

With our simulated data, we can now create the classic risk-return scatter plot.

# Find the portfolios with the maximum Sharpe ratio and minimum volatility
max_sharpe_portfolio = portfolios_df.loc[portfolios_df['Sharpe Ratio'].idxmax()]
min_vol_portfolio = portfolios_df.loc[portfolios_df['Volatility'].idxmin()]

# Plot the Efficient Frontier
plt.figure(figsize=(12, 8))
plt.scatter(portfolios_df['Volatility'], portfolios_df['Return'], c=portfolios_df['Sharpe Ratio'], cmap='viridis', marker='o', s=10, alpha=0.5)
plt.colorbar(label='Sharpe Ratio')
plt.title('Portfolio Optimization - Efficient Frontier')
plt.xlabel('Annualized Volatility (Risk)')
plt.ylabel('Annualized Return')

# Highlight the two key portfolios
plt.scatter(max_sharpe_portfolio['Volatility'], max_sharpe_portfolio['Return'], marker='*', color='r', s=200, label='Max Sharpe Ratio')
plt.scatter(min_vol_portfolio['Volatility'], min_vol_portfolio['Return'], marker='*', color='b', s=200, label='Min Volatility')

plt.legend()
plt.grid(True, linestyle='--', alpha=0.6)
plt.show()

This plot is the visual representation of MPT. Each tiny dot is one of our 25,000 random portfolios. The colorful cloud they form is the “feasible region.” The upper-left edge of this cloud is the Efficient Frontier. The red star shows our optimal portfolio (max Sharpe ratio), and the blue star shows the safest portfolio (minimum volatility).

Step 5: Analyze the Optimal Portfolios

Let’s print out the details of these two important portfolios to see their asset allocations.

print("--- Maximum Sharpe Ratio Portfolio ---")
print(f"Annualized Return: {max_sharpe_portfolio['Return']:.2%}")
print(f"Annualized Volatility: {max_sharpe_portfolio['Volatility']:.2%}")
print(f"Sharpe Ratio: {max_sharpe_portfolio['Sharpe Ratio']:.2f}")
print("nOptimal Weights:")
print(max_sharpe_portfolio.filter(like='Weight').to_string())

print("n--- Minimum Volatility Portfolio ---")
print(f"Annualized Return: {min_vol_portfolio['Return']:.2%}")
print(f"Annualized Volatility: {min_vol_portfolio['Volatility']:.2%}")
print(f"Sharpe Ratio: {min_vol_portfolio['Sharpe Ratio']:.2f}")
print("nOptimal Weights:")
print(min_vol_portfolio.filter(like='Weight').to_string())

The output shows the percentage you should allocate to each stock. This is how we achieve either the highest risk-adjusted return or the lowest possible risk.

--- Maximum Sharpe Ratio Portfolio ---
Annualized Return: 19.81%
Annualized Volatility: 21.93%
Sharpe Ratio: 0.81

Optimal Weights:
Weight_AAPL     0.264878
Weight_MSFT     0.311262
Weight_GOOGL    0.007074
Weight_AMZN     0.017830
Weight_JPM      0.039432
Weight_V        0.119076
Weight_PG       0.219251
Weight_JNJ      0.021197

--- Minimum Volatility Portfolio ---
Annualized Return: 11.05%
Annualized Volatility: 16.98%
Sharpe Ratio: 0.53

Optimal Weights:
Weight_AAPL     0.045707
Weight_MSFT     0.015907
Weight_GOOGL    0.002250
Weight_AMZN     0.089960
Weight_JPM      0.036559
Weight_V        0.128360
Weight_PG       0.375811
Weight_JNJ      0.305446
Step 6: Using SciPy for a Precise Solution

The Monte Carlo method gives a great approximation. But for a precise answer, we use a numerical solver from the scipy library.

from scipy.optimize import minimize

def get_portfolio_stats(weights):
    returns = np.sum(mean_log_returns * weights) * 252
    volatility = np.sqrt(np.dot(weights.T, np.dot(cov_matrix, weights))) * np.sqrt(252)
    return np.array([returns, volatility, (returns - risk_free_rate) / volatility])

# Objective function to minimize (we minimize the NEGATIVE Sharpe Ratio)
def minimize_negative_sharpe(weights):
    return -get_portfolio_stats(weights)[2]

# Constraints and bounds
constraints = ({'type': 'eq', 'fun': lambda weights: np.sum(weights) - 1})
bounds = tuple((0, 1) for _ in range(len(tickers)))
initial_weights = np.array([1./len(tickers)]*len(tickers))

# Run the optimizer
optimal_sharpe = minimize(minimize_negative_sharpe, initial_weights, method='SLSQP', bounds=bounds, constraints=constraints)

print("n--- SciPy Optimized Max Sharpe Portfolio ---")
optimal_weights = optimal_sharpe.x
stats = get_portfolio_stats(optimal_weights)
print(f"Annualized Return: {stats[0]:.2%}")
print(f"Annualized Volatility: {stats[1]:.2%}")
print(f"Sharpe Ratio: {stats[2]:.2f}")
print("nOptimal Weights:")
for i, ticker in enumerate(tickers):
    print(f"{ticker}: {optimal_weights[i]:.2%}")
--- SciPy Optimized Max Sharpe Portfolio ---
Annualized Return: 22.42%
Annualized Volatility: 24.17%
Sharpe Ratio: 0.84

Optimal Weights:
AAPL: 45.13%
MSFT: 34.30%
GOOGL: 0.00%
AMZN: 0.00%
JPM: 0.78%
V: 0.00%
PG: 19.79%
JNJ: 0.00%

The scipy.optimize.minimize function is a powerful tool that finds the best weights , subject to our constraints. The results is better than the best portfolio we found via simulation.

Conclusion and Next Steps

You have now built a sophisticated portfolio optimizer from the ground up. This is a powerful tool, but it’s important to remember its limitations. MPT is based on historical data and makes assumptions that may not hold in the future. The world of finance is ever-changing.

However, the framework you’ve learned is fundamental. From here, you can expand on this project in many ways:

  • Incorporate different asset classes, like bonds, commodities, or cryptocurrencies.
  • Add more complex constraints, such as limiting the max/min allocation to any single asset.
  • Use a forward-looking model for expected returns instead of relying purely on historical averages.
  • Implement a backtest to see how your “optimal” portfolio would have actually performed over time.

You’ve taken another major step on your quantitative finance journey. By mastering these foundational techniques, you are well on your way to understand the tools that power the financial industry.

Happy optimizing!


Leave a Reply

Discover more from SimplifiedZone

Subscribe now to keep reading and get access to the full archive.

Continue reading

Discover more from SimplifiedZone

Subscribe now to keep reading and get access to the full archive.

Continue reading