# Introduction to Portfolio Optimization and Modern Portfolio Theory

Portfolio optimization is an important area of quantitative finance. It was introduced by Harry Markowitz in 1952 in his paper ‘Portfolio Selection’ and was further developed into a whole research field. Now there exists a lot of different portfolio optimization frameworks and new papers on the topic are being published almost every day.

The main goal of portfolio optimization in its simplest form is creating portfolios that maximize (expected) returns at a given level of risk. Here’s a broad definition from wikipedia:

Portfolio optimization is the process of selecting the best portfolio (asset distribution), out of the set of all portfolios being considered, according to some objective. The objective typically maximizes factors such as expected return, and minimizes costs like financial risk. Factors being considered may range from tangible (such as assets, liabilities, earnings or other fundamentals) to intangible (such as selective divestment).

Portfolio optimization techniques can be applied not only to different assets, but also to trading strategies. For example, if we have several trading strategies (with different risks), we can use portfolio optimization techniques to figure out how to allocate our capital among them to maximize expected return or minimize risk.

In this article I will describe Modern Portfolio Theory (MPT), which was introduced by Markowitz in his 1952 paper. I am going to demonstrate how to implement it in Python and how to use it to build a simple trading strategy. In future articles I plan to explore other portfolio optimization techniques.

First let me describe the reasoning behind MPT from the original paper. Several possible rules for portfolio construction are considered:

- Assume that investors only care about maximizing expected return. If that was the case, then they will end up investing all their money in one instrument with the highest expected return. Given that the markets are imperfect and future is uncertain, it is clear that this approach is not optimal.
- Another approach would be to assume that we need to have a diversified portfolio. Some investors might think that because of the Law of Large Numbers (LLN) the actual return of the diversified portfolio will be equal to the expected return. The problem with this reasoning is that LLN only works for independent and identically distributed (iid) random variables, whereas asset returns are clearly dependent and intercorrelated.

It follows that we can’t completely eliminate all the risk by using diversification. What we can do is maximize expected return for a given level of risk (or minimize risk for a given level of return). It is called mean-variance optimization.

Now let’s state this problem of portfolio optimization mathematically and then I will demonstrate how it works in practice. Suppose we have *N* assets with expected return *mu* and covariance matrix *sigma*. We want to construct a portfolio by choosing portfolio weights *w*.

The expected return and variance of the resulting portfolio can be calculated as follows:

In general we will be interested in solving two problems. First one is finding minimum variance portfolio. In that case we just need to find portfolio weights *w* that minimize portfolio variance *sigma*. Since each weight *w_i* represents a fraction of portfolio invested in asset *i*, we add the constraint that the sum of all weights is equal to 1.

Another problem we typically solve is maximizing portfolio return for a given level of risk (variance).

Both problems can be solved analytically (using Lagrange multipliers), but I am not going to describe how to do it. It will be easier for us to solve these problems numerically in Python.

I am going to start with only four assets: AAPL, BRK.B, MMM, GLD. Time period will be from *2018–01–01* to *2022–12–31*. Code for downloading data is shown below.

```
import yfinance as yf
stocks = ['AAPL', 'BRK-B', 'MMM', 'GLD']
tmpdf = yf.download(stocks[0], start='2018-01-01', end='2022-12-31')
prices = pd.DataFrame(index=tmpdf.index, columns=stocks)
prices[stocks[0]] = tmpdf['Adj Close']
for stock in stocks[1:]:
tmpdf = yf.download(stock, start='2018-01-01', end='2022-12-31')
prices[stock] = tmpdf['Adj Close']
returns = prices.pct_change().dropna() # calculate returns
```

First let’s try to learn how portfolio optimization works using the whole set of historical data. Look at the correlation matrix below. We can see that most assets are indeed highly correlated to each other.

One way to approach our problem is by using some kind of Monte Carlo method. We can generate some random portfolio weights and then calculate return and volatility of the resulting portfolio. Let’s try to do it.

```
portfolios = pd.DataFrame(columns=list(stocks)+['return', 'sd'])
for i in range(10000):
np.random.seed(i)
weights = np.random.uniform(size=len(stocks)) # generate random weights
weights /= weights.sum() # make weights sum to 1
ret = weights.T @ returns.mean() * 252 # annualized return
sd = np.sqrt(weights.T @ returns.cov() @ weights * 252) # annualized sd
portfolios.loc[i] = list(weights) + [ret, sd]
```

Below you can see the plot of returns and volatilities of those random portfolios. Each dot represents a portfolio. The top edge of this graph is called efficient frontier. It represents the maximum return that can be achieved for a given level of volatility. We can see that as volatility increases, maximum return also increases. We can also notice that the minimum volatility we can achieve is around 12%.

Now we can use generated portfolios to solve the two problems stated earlier. We can find minimum variance portfolio as follows:

Or we can find a portfolio with maximum return for a given level of risk (let’s say 20%):

Now let’s compare portfolios to individual assets.

AAPL is an outlier and we don’t have portfolios with the same level of return. Let’s look at BRB-B. Its return is about 10% and its volatility is about 23%. But if we look at random portfolios we can see that for the same level of risk we can achieve better return — around 20% (more than double the return of BRK-B). That’s the main advantage of having a diversified portfolio.

Generating random portfolios to solve portfolio optimization problems works well only for a small number of assets. The number of random portfolios we need to generate grows exponentially with the number of assets. That’s why we need to come up with a different solution. I am going to use numerical methods from scipy library in Python.

Let’s start with finding minimum variance portfolio. We need to define a function that we want to minimize and any constraints we want to use. Code for doing it is shown below.

```
from scipy.optimize import minimize
# function to minimize
def volatility(weights, returns):
return np.sqrt(weights.T @ returns.cov() @ weights * 252)
constraints = ({'type':'eq', 'fun': lambda x: np.sum(x)-1})
```

First parameter of the function to minimize must be the variable that we are trying to find. After it we can specify any additional parameters as needed.

In this problem we have only one constraint: sum of portfolio weights must be equal to 1. The type of constraint I’m using here requires us to specify it as a function that returns 0 if constraint is satisfied.

Next we need to define initial guess *x0* and bounds for each variable. I am using equal weights as initial guess. Bounds are defined so that the weight of each asset is between 0 and 1 (for now we only consider long positions).

Minimization is performed with one line of code. Results are shown below.

Minimum variance we can achieve is about 12.2% with weights *x*. Let’s compare this solution with the solution we got using Monte Carlo method (random portfolios).

As you can see above, the weights we get using both methods are close to each other.

Now let’s solve another problem — maximizing returns for a given level of risk. Maximizing returns is the same as minimizing negative returns. Below we define a function to minimize and we also add another constraint — portfolio volatility must be equal to some predefined value.

```
target_vol = 0.2
constraints = ({'type':'eq', 'fun': lambda x: volatility(x,returns)-target_vol},
{'type':'eq', 'fun': lambda x: np.sum(x)-1})
def negative_annual_return(weights, returns):
ret = weights.T @ returns.mean() * 252
return -ret
```

Our bounds and initial guess stay the same. Solving the problem we get the following results:

For 20% volatility the maximum portfolio return that we can achieve is 19%.

Note that we can easily change constraints and objective functions used above according to our needs. For example, we can change objective function to select a portfolio with the highest Sharpe ratio. Or modify constraints on weights to allow short-selling.

Now let’s plot the efficient frontier. We just need to find the maximum return for a set of volatilities in a given range in then plot the results.

```
vols = np.linspace(0.122, 0.3)
rets = []
for target_vol in vols:
res = minimize(negative_annual_return, x0, args=(returns),
bounds=bounds, constraints=constraints)
rets.append(-res.fun)
plt.scatter(portfolios['sd'], portfolios['return'], alpha=0.1)
plt.xlabel('Volatility')
plt.ylabel('Return')
plt.plot(vols, rets, color='r', label='efficient frontier')
plt.legend()
```

Next thing we can do is allow short selling. It can be done easily by changing the bounds. Now we want our weights to be in the range between -1 and 1. One of the constraints also needs to be changed. Now we check that the sum of absolute values of weights equals to one. The rest is the same.

```
constraints = ({'type':'eq', 'fun': lambda x: np.sum(np.abs(x))-1},)
bounds=[[-1,1]]*len(stocks)
```

First we find minimum variance portfolio.

Now the minimum variance we can achieve is 7.5% (compared to 12.2% without short-selling). Let’s try to maximize return for a target volatility of 20%.

```
target_vol = 0.2
constraints = ({'type':'eq', 'fun': lambda x: volatility(x,returns)-target_vol},
{'type':'eq', 'fun': lambda x: np.sum(np.abs(x))-1})
```

Now we can achieve a return of about 21.46% (compared to 18.56% without short-selling). Below I am going to plot the new efficient frontier and compare it to the previous one (without short-selling).

```
vols_ss = np.linspace(0.08, 0.3)
rets_ss = []
for target_vol in vols_ss:
res = minimize(negative_annual_return, x0, args=(returns),
bounds=bounds, constraints=constraints)
rets_ss.append(-res.fun)
plt.scatter(portfolios['sd'], portfolios['return'], alpha=0.1)
plt.xlabel('Volatility')
plt.ylabel('Return')
plt.plot(vols_ss, rets_ss, color='c', label='efficient frontier (with short-selling)')
plt.plot(vols, rets, color='r', label='efficient frontier (without short-selling)')
plt.legend()
```

As you can see, allowing short-selling significantly improves efficient frontier — we are able to get better returns for the same level of risk (or lower risk for the same level of returns).

Note that above I used all available data to calculate returns and covariance matrix. Basically we solved portfolio optimization problems for a given set of historical data. Portfolios that we found work for this particular historical period, but are not guaranteed to work in future. This is not very useful. In practice we want to construct an optimal portfolio now and we want it to hold its properties (expected return and volatility) for some period of time in future. To achieve that we need some estimates of future returns and covariance matrix.

For now let’s try using the simplest possible estimators — sample mean and covariance matrix. I am going to use the same four assets and try to create a trading strategy based on the techniques described above. I’m going to use one year of historical data to estimate expected return and covariance matrix and I will rebalance my portfolio weekly.

Code for performing a backtest is shown below. Some things to note:

- I’m trying to achieve maximum returns for a volatility of 20%.
- Short-selling is not allowed.
- Portfolio optimization problem is solved using daily data (in order to have more datapoints for estimating covariance matrix).
- Transaction costs are not included.

```
# calculate weekly prices
prices_w = prices.resample('1W').last()
returns_w = prices_w.pct_change().dropna()
positions = pd.DataFrame(index=prices_w.loc['2019-01-01':].index,
columns=prices_w.columns)
target_vol = 0.2
constraints = ({'type':'eq', 'fun': lambda x: volatility(x,returns_tmp)-target_vol},
{'type':'eq', 'fun': lambda x: np.sum(x)-1})
bounds=[[0,1]]*len(stocks)
x0 = np.ones(len(stocks)) / len(stocks)
for t in tqdm(returns_w.loc['2019-01-01':].index):
prices_tmp = prices.loc[:t].iloc[-252:]
returns_tmp = prices_tmp.pct_change().dropna()
res = minimize(negative_annual_return, x0, args=(returns_tmp),
bounds=bounds, constraints=constraints)
positions.loc[t] = res.x
cumret_mpt = (1 + (positions.shift() * returns_w.loc['2019-01-01':]).sum(axis=1)).cumprod()
cumret_eqw = (1 + returns_w.loc['2019-01-01':].sum(axis=1)/returns_w.shape[1]).cumprod()
```

Now let’s plot the results.

Surprisingly this simple strategy performs better than equally weighted portfolio. Let’s see if we were able to achieve the required level of volatility.

Actual volatility is only slightly higher than the required value of 20%. That is a good result, but I believe it’s just luck. If we try to backtest the same strategy with different assets, the results probably won’t be as good. You can easily try it on your own — just change the list of assets defined in the beginning of the notebook.

The basic version of MPT presented in this article has many limitations. One of the main criticisms is that it assumes that returns are normally distributed, which is usually not the case. Another big problem is using expected values for future returns and covariance matrix. How we estimate these values has a big effect on the results we achieve. Here I used the simplest possible estimators — sample mean and covariance matrix, but they usually have high estimation error and lead to poor-performing optimal portfolios. In the next article I will try to test different estimators and see how they affect the performance of optimal portfolios.

Jupyter notebook with source code is available here.

If you have any questions, suggestions or corrections please post them in the comments. Thanks for reading.

**References**

[1] Portfolio Selection (Markowitz, 1952)

[2] Quantitative Equity Investing (Fabozzi, 2010)

[3] https://en.wikipedia.org/wiki/Portfolio_optimization

[4] https://www.kaggle.com/code/trangthvu/efficient-frontier-optimization

[5] https://colab.research.google.com/drive/1ulDSw7DEJH1SYRVwvtJXYU0naFgaaBiR