Portfolio Construction Theory: Optimization

This is a continuation of the “Portfolio Construction Theory: Risk” article, which is diving into the optimization of a portfolio, called the efficient frontier. This will be done with the help of Python as I am currently not paying for Microsoft Office products, hence no access to Excel Solver. If you are more interested in Excel rather than Python, refer to this paper: Microsoft Word – Efficient Portfolios in Excel Using the Solver and Matrix Algebra (washington.edu)

The Efficient Frontier is a relatively old theory invented in 1952 for which H. Markowitz received a Nobel Price. The theory states that using historical data, we can formulate a portfolio, that has the highest return for the given risk.

Fig. 1 – Efficient Frontier

This is showing, that while we can construct a vast majority of portfolios, the optimal ones will be the ones that exhibit the highest return for the given risk. This is done by changing the weights for held securities. But what if one stock historically moved up on average of 20% and the other at 10%? Wouldn’t the optimal portfolio be going 100% the first stock? Well, if we care about the risk, then no. Portfolio risk decreases when the assets it holds increases. Think of it this way. If you own 1 stock and it moves down by 1%, then your portfolio is 1% down. But if you add a second stock (so portfolio is 50% A stock / 50% B stock) which then moves 0.2%- portfolio has lost -0.6% instead of the 1%. Risk decreased. This is illustrated in the Fig. 2 below. We will not run away from the market risk, but we can run away from the Unsystematic (“Unique”) risk. Efficient frontier optimizes portfolio for the risk/reward given X number of securities.

Fig. 2

Sharpe Ratio

The Sharpe ratio compares the return of an investment with its risk. It’s a mathematical expression of the insight that excess returns over a period of time may signify more volatility and risk, rather than investing skill.

Where:

Rp = Portfolio Return
Rf = Risk Free Rate
Qp = Standard deviation of portfolio’s excess return

In short- a bigger Sharpe Ratio means that the portfolio had a better return when compared to its risk. When optimizing, we should maximize the Sharpe Ratio.

Data Preparation

For the data I am using yfinance library to fetch a year worth of stock prices. This code gets the stock prices of 5 securities, and then converts the prices to daily returns; drops the first value which is NaN.

import yfinance as yf
import pandas as pd
def fetch_stock_data(symbol, start_date, end_date):
    stock_data = yf.download(symbol, start=start_date, end=end_date)
    return stock_data['Adj Close']
def calculate_daily_returns(data):
    return data.pct_change().dropna()
# Define stock symbols and date range
stocks = ['AAPL', 'NVDA', 'GM', 'TSLA', 'GOOGL']
start_date = '2023-01-01'
end_date = '2023-12-31'
# Fetch stock data
stock_prices = {symbol: fetch_stock_data(symbol, start_date, end_date) for symbol in stocks}
# Create a DataFrame with daily returns
daily_returns = pd.DataFrame(stock_prices).pct_change().dropna()
# Get annualized returns for each & covars matrix
annualized_returns = (1+daily_returns.mean())**252 - 1
covariances = daily_returns.cov()*252 # Covariances matrix

Common Functions

Rate of Return: It is a weighted sum of the individual stock returns in the portfolio. Financially, this is the expected rate of return for a portfolio.

def rate_of_return(stock_returns: "Vector of returns of each stock", stock_weights: "Vector of weights of each stock"):
    return stock_returns.dot(stock_weights)

Volatility: function calculates the volatility of a portfolio given the weights of each stock in the portfolio and the covariance matrix of the returns of those stocks.

The dot product with the covariance matrix is a way to calculate the portfolio variance. The outer dot product with the stock weights vector and the square root give the portfolio volatility, which is the standard deviation of the portfolio’s returns. In simple terms, the function computes a measure of how much the returns of a portfolio are expected to vary from their average. See below:

def volatility(stock_weights: "Vector of weights of each stock", covariance_matrix):
    return np.sqrt(np.dot(stock_weights,np.dot(stock_weights,covariance_matrix)))

Sharpe Ratio: Function simple divides rate of return by the volatility

def sharpe (rate_of_return,volatility):
    return ret/vol

Optimization for Sharpe & minimal variance

Optimization is essentially just finding some minimum or maximum. Let’s optimize for the smallest volatility, but before jumping to it, let’s set a couple of boundaries.

  1. Weights have to be from 0 to 1. We can’t buy more than 100% of our portfolio (although in practice we can)
  2. Sum of all weights are always equal to 1

As the author from this article adviced, we can use the Trust-Region Constrained Algorithm as it is suitable for multivariate scalar functions

# All weights, must lie between 0 and 1. Thus we set 0 and 1 as the boundaries.
from scipy.optimize import Bounds
bounds = Bounds(0, 1)
# The second boundary is the sum of weights. It can only get to 1, meaning 100% of portfolio. 
# In theory, we could give it 2, which would mean we can take a 2x leverage
from scipy.optimize import LinearConstraint
linear_constraint = LinearConstraint(np.ones((daily_returns.shape[1],), dtype=int),1,1)
# Find a portfolio with the minimum risk.
from scipy.optimize import minimize
#Create x0, the first guess at the values of each stock's weight.
weights = np.ones(daily_returns.shape[1])
x0 = weights/np.sum(weights)
#Define a function to calculate volatility
fun1 = lambda w: np.sqrt(np.dot(w,np.dot(w,covariances)))
res = minimize(fun1,x0,method='trust-constr',constraints = linear_constraint,bounds = bounds)
#These are the weights of the stocks in the portfolio with the lowest level of risk possible.
w_min = res.x
np.set_printoptions(suppress = True, precision=2)
print(w_min)
print('return: % .2f'% (rate_of_return(annualized_returns,w_min)*100), 'risk: % .3f'% volatility(w_min,covariances))

This code will print us the weights for the portfolio, that has the minimum variance. In my case, weights = [0.73 0. 0.17 0. 0.1 ], meaning the minimum variance can be achieved by not holding any NVDA and TSLA. But I know that these stocks produced the greatest return! With the minimal variance optimization, the return is 51.74 risk: 0.189.

By Optimizing for the best Sharpe, we can achieve better results. See the code below:

#Define 1/Sharpe_ratio
fun2 = lambda w: np.sqrt(np.dot(weights,np.dot(weights,covariances)))/annualized_returns.dot(weights)
res_sharpe = minimize(fun2,x0,method='trust-constr',constraints = linear_constraint,bounds = bounds)
#These are the weights of the stocks in the portfolio with the highest Sharpe ratio.
w_sharpe = res_sharpe.x
print(res_sharpe.x)
print('return: % .2f'% (rate_of_return(annualized_returns,w_sharpe)*100), 'risk: % .3f'% volatility(w_sharpe,covariances))

It suggests these weights: [0.32 0.16 0.19 0.16 0.18]. Return would have been 104.77 risk: 0.240. Much better than the first one! We achieved a 2x larger return while taking only 5% of additional risk.

Drawing Efficient Frontier

Now we just need to calculate the best sharpe given risk. We can loop through the axis bit by a bit and optimize each value. We can re-use algorithm with a little tweak.

import warnings
warnings.filterwarnings("ignore", category=UserWarning)
w = w_min
num_ports = 100
gap = (np.amax(annualized_returns) - rate_of_return(annualized_returns, w_min)) / num_ports
all_weights = np.zeros((num_ports, len(daily_returns.columns)))
ret_arr = np.zeros(num_ports)
vol_arr = np.zeros(num_ports)
for i in range(num_ports):
    port_ret = rate_of_return(annualized_returns, w_min) + i * gap
    double_constraint = LinearConstraint([np.ones(len(daily_returns.columns)), annualized_returns],
                                         [1, port_ret], [1, port_ret])
    # Create x0: initial guesses for weights.
    x0 = w_min
    # Define a function for portfolio volatility.
    fun = lambda w: np.sqrt(np.dot(w, np.dot(w, covariances)))
    a = minimize(fun, x0, method='trust-constr', constraints=double_constraint, bounds=bounds)
    all_weights[i, :] = a.x
    ret_arr[i] = port_ret
    vol_arr[i] = volatility(a.x, covariances)
sharpe_arr = ret_arr / vol_arr
import matplotlib.pyplot as plt
plt.figure(figsize=(20, 10))
plt.scatter(vol_arr, ret_arr, c=sharpe_arr, cmap='brg', marker='o', s=50)
plt.colorbar(label='Sharpe Ratio')
plt.xlabel('Volatility')
plt.ylabel('Return')
plt.show()

The code will generate a chart. See below:

Fig. 3 – Efficient Frontier, optimal Sharpe circled

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *