Subscribe to the newsletter!
Subscribing you will be able to read a weekly summary with the new posts and updates.
We'll never share your data with anyone else.

Modern Portfolio Theory Applied in Python

Posted by Daniel Cano

Edited on May 15, 2024, 12:44 p.m.



What Is Modern Portfolio Theory (MPT)?

Modern Portfolio Theory, also known as Markowitz Portfolio Theory, is a revolutionary concept in the field of investment and finance that was introduced by Harry Markowitz in his paper "Portfolio Selection", published in the Journal of Finance in 1952. For his groundbreaking work, Markowitz was awarded the Nobel Prize in 1990. The core idea of MPT is to maximize portfolio expected return for a given amount of portfolio risk, or equivalently minimize portfolio risk for a given level of expected return, by carefully choosing the proportions of various assets. In this post I will explain the key components of MPT along with a Python implementation.

Diversification

At the heart of MPT lies the concept of diversification. This involves spreading investments across a wide range of assets to minimize the risks associated with any single investment. By diversifying, investors can achieve a more favorable risk-return trade-off than they could by concentrating their investments in a single asset or asset class. Diversifying across different assets with low or non-perfectly correlated returns, MPT seeks to reduce the overall volatility or risk of a portfolio. When some assets in the portfolio perform poorly, others may perform well, offsetting losses and stabilizing returns.

This is achieved through asset allocation. MPT emphasizes the importance of asset allocation as the primary driver of portfolio performance. Rather than trying to time the market or pick individual stocks, MPT suggests allocating assets across different asset classes (such as stocks, bonds, and real estate) based on their historical risk and return characteristics. By spreading investments across multiple assets, investors can reduce exposure to company-specific or industry-specific risks and minimize the impact of adverse events on the overall portfolio.

Risk and Return

MPT defines risk in terms of the standard deviation of portfolio returns, which measures the volatility or variability of those returns. The theory posits that some investors are willing to accept greater risk in pursuit of higher returns. Once a investor's risk tolerance is determined, they can construct a diversified portfolio that optimizes the potential return for the amount of risk they are willing to take.

Risk

Portfolio risk depends on each asset variance and correlations between each pair of assets. MPT considers these factors to quantify risk. Assets with low or negative correlations can provide greater diversification benefits because they tend to move independently of each other, reducing the overall risk of the portfolio in contrast to assets that move together, which can amplify risk.

Expected Return

The expected return of a portfolio is calculated as a weighted sum of individual asset expected returns. For instance, if a portfolio contains four equally weighted assets with expected returns of 4%, 6%, 10%, and 14%, the portfolio’s expected return would be:

(25*4+25*6+25*10+25*14)/100 = 8.5%

Being the formula:

E(Rp) = w1R1+w2R2+...+wnRn

Where wi would be the asset weight and Ri the expected return of the same asset.

Efficient Frontier

The efficient frontier is a fundamental concept in MPT, which helps identify the optimal combination of assets that offer the highest expected return for a defined level of risk or, conversely,  the lowest risk for a given level of expected return. This is depicted graphically as the efficient frontier, which represents the ideal mix of assets that investors should aim to construct to achieve their investment objectives. Portfolios that lie below the efficient frontier are considered sub-optimal because the do not provide enough return for the level of risk taken. These portfolios either have lower returns for the same level of risk or higher risk for the same level of return compared to portfolios on the efficient frontier. Investors seeking to maximize returns or minimize risk would typically avoid these sub-optimal portfolios in favor of those on the efficient frontier.

The efficient frontier is typically depicted graphically with risk (standard deviation or volatility) on the x-axis and return on the y-axis. Each point on the efficient frontier represents a unique portfolio allocation, with varying weights assigned to different assets. The shape of the efficient frontier is determined by the risk and return characteristics of the individual assets, as well as their correlations with each other.

Capital Market Line

The Capital Market Line (CML) is a fundamental concept in finance that extends the efficient frontier by introducing the risk-free asset. It represents a linear relationship between expected return and risk, where risk is measured by standard deviation or volatility. The CML is tangent to the efficient frontier at the market portfolio, which is the optimal portfolio of risky assets available to investors. This point of tangency represents the optimal combination of risky assets that offers the highest Sharpe ratio, which is a measure of risk-adjusted return. The Sharpe ratio is calculated as the excess return of the portfolio over the risk-free rate divided by the portfolio's standard deviation. Therefore, the slope of the CML at the market portfolio represents the Sharpe ratio of the market portfolio.

Limitations of MPT

While MPT has provided valuable insights into portfolio management and asset allocation, it is subject to several limitations that can impact its application in real-world investment scenarios:

  1. Assumption of Normal Distribution: It relies on the assumption that asset returns follow a normal distribution. However, in real-world markets, asset returns often exhibit non-normal distributions, such as fat tails or skewness. This can lead to underestimation of extreme events or tail risks, as MPT may not adequately capture the potential for large losses during market downturns. As a result, portfolios constructed using MPT may be less robust in extreme market conditions.

  2. Reliance on Historical Data: MPT uses historical data to estimate key parameters, such as expected returns, volatilities, and correlations among assets. However, historical data may not accurately reflect future market conditions or capture structural changes in the economy or financial markets. This can lead to estimation errors and suboptimal portfolio allocations, particularly during periods of market regime shifts or structural changes.

  3. Inability to Predict Future Returns: The theory also assumes that past returns are indicative of future performance, which may not always hold true. Market dynamics, investor sentiment, economic factors, and geopolitical events can all influence asset prices and returns in ways that are difficult to predict based on historical data alone. As a result, MPT may not provide reliable forecasts of future returns, leading to suboptimal investment decisions.

  4. Static Nature of the Model: MPT treats asset allocation as a static optimization problem, assuming that investors can determine an optimal portfolio mix and maintain it indefinitely. However, investment opportunities, market conditions, and investor preferences may change over time. MPT does not explicitly account for changes in investment opportunities, which can result in suboptimal portfolio allocations if the initial assumptions no longer hold true.

  5. Sensitivity to Input Parameters: MPT is sensitive to the input parameters used in the optimization process, such as expected returns, volatilities, and correlations. Small changes in these parameters can lead to significantly different portfolio allocations and performance outcomes. Estimation errors or uncertainties in these parameters can introduce additional risk and uncertainty into the portfolio construction process.

Application

We will start by programming the class responsible for performing portfolio simulations in order to obtain the Efficient Frontier. To do this, we will need the following libraries.

import numpy as np
import pandas as pd
import plotly.graph_objs as go

We will continue by defining the class and initializing it. There will be two ways to initialize it. The first one involves inputting a DataFrame with the prices of different assets so that the class itself calculates the expected returns and the covariance matrix of the assets during initialization. The second way is to directly input the expected returns and the covariance matrix, in which case it won't be necessary to add the asset prices. In any case, the number of iterations to be performed must be included, with a default value of 1000.

class PortfolioOptimizer:

    all_weights: list = []
    portfolio_returns: list = []
    portfolio_risks: list = []
    
    def __init__(self, prices:pd.DataFrame=None, expected_returns:pd.Series=None, 
                 cov_matrix:pd.DataFrame=None, num_portfolios: int=1000) -> None:

        '''
        Initializes the PortfolioOptimizer object. If prices are not specified expected_returns and cov_matrix must be.

        Parameters
        ----------
        prices: pandas.DataFrame
            Pandas DataFrame with the historic prices for each asset. The asset names will be the column names and there must be only one column per asset.
        expected_returns: pandas.Series
            Pandas Series with the historic returns mean for each asset.
        cov_matrix: pandas.DataFrame
            Pandas DataFrame with the covariance matrix of the assets based on the historic returns.
        num_portfolios: int
            Number of iterations (different portfolios) to use for the optimization.
        '''

        self.num_portfolios: int = num_portfolios
        if isinstance(prices, pd.DataFrame):
            self.prices: pd.DataFrame = prices
            self.calculateResources(prices=prices)
        else:
            if isinstance(cov_matrix, pd.DataFrame) and isinstance(expected_returns, pd.Series):
                self.expected_returns: pd.Series = expected_returns
                self.cov_matrix: pd.DataFrame = cov_matrix
            else:
                raise ValueError('If prices are not specified expected_returns and cov_matrix must be.')
 

The function "calculateResources" will be defined for the case where the asset prices are inputted. In this function, the expected return based only on negative returns has been included, but its calculation wouldn't be necessary.

def calculateResources(self, prices:pd.DataFrame) -> tuple[pd.Series, pd.Series, pd.DataFrame]:

    '''
    Calculates the expected returns (mean of historical returns) and the covariance matrix for the historic prices specified.

    Parameters
    ----------
    prices: pandas.DataFrame
        Pandas DataFrame with the historic prices for each asset. The asset names will be the column names and there must be only one column per asset.

    Returns
    -------
    expected_returns: pandas.Series
        Pandas Series with the historic returns mean for each asset.
    expected_risk: pandas.Series
        Pandas Series with the historic negative returns mean for each asset.
    cov_matrix: pandas.DataFrame
        Pandas DataFrame with the covariance matrix of the assets based on the historic returns.
    '''
    
    returns: pd.DataFrame = prices.pct_change().dropna()
    self.expected_returns: pd.Series = returns.mean(skipna=True)
    self.expected_risk: pd.Series = returns[returns < 0].mean().abs()
    self.cov_matrix: pd.DataFrame = returns.cov()
    
    return self.expected_returns, self.expected_risk, self.cov_matrix

The next step will be to define the functions responsible for calculating the expected return, standard deviation and Sharpe Ratio of the different portfolios that will be generated in each iteration.

def portfolioReturn(self, weights:np.ndarray) -> float:

    '''
    Calculates the portfolio expected return, this being the weighted average of expected returns.

    Parameters
    ----------
    weights: numpy.ndarray
        Numpy array containing the allocation in the portfolio for each asset.

    Returns
    -------
    return: float
        Total expected return of the portfolio.
    '''
    
    return np.dot(weights, self.expected_returns)

def portfolioRisk(self, weights:np.ndarray) -> float:

    '''
    Calculates the portfolio expected risk (standard deviation), this being calculated by the following formula.
    r = (w*(CM*w))**(1/2)
    Where w is the allocation to each asset of the portfolio and CM is the covariance matrix of the historic prices.

    Parameters
    ----------
    weights: numpy.ndarray
        Numpy array containing the allocation in the portfolio for each asset.

    Returns
    -------
    risk: float
        Total expected risk (standard deviation) of the portfolio.
    '''
    
    return np.sqrt(np.dot(weights.T, np.dot(self.cov_matrix, weights)))

def portfolioSharpe(self, weights:np.ndarray) -> float:

    '''
    Calculates the Sharpe Ratio based on the expected return and the standard deviation of the portfolio.

    Parameters
    ----------
    weights: numpy.ndarray
        Numpy array containing the allocation in the portfolio for each asset.

    Returns
    -------
    sharpe: float
        Total Sharpe Ratio of the portfolio.
    '''
    
    return self.portfolioReturn(weights) / self.portfolioRisk(weights)

The following function will be responsible for generating the portfolios. It will be necessary to iterate by generating random weights for the different assets of the portfolio. For each combination of weights, the expected return and the standard deviation of the portfolio will be calculated. It is worth noting that this would be for a situation where the assets are divisible since it does not take into account the account capital or the minimum capital required to acquire the asset. Nor does it consider the difference in commissions charged by the different assets, which can vary the return of each asset, although this could be included in the price history instead of modifying the function.

def generatePortfolios(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:

    '''
    Calculates the expected return and standard deviation for random allocations in the portfolio. The number of iterations is defined by the object's variable "num_portfolios".

    Returns
    -------
    all_weights: numpy.ndarray
        Numpy array containing all the allocations in the portfolio for all the iterations.
    portfolio_returns: numpy.ndarray
        Numpy array containing all the expected return of the portfolio for all the iterations.
    portfolio_risks: numpy.ndarray
        Numpy array containing all the standard deviation of the portfolio for all the iterations.
    '''
    
    n_assets: int = len(self.expected_returns)
    self.all_weights: np.ndarray = np.zeros((self.num_portfolios, n_assets))
    self.portfolio_returns: np.ndarray = np.zeros(self.num_portfolios)
    self.portfolio_risks: np.ndarray = np.zeros(self.num_portfolios)

    for i in range(self.num_portfolios):
        weights: np.ndarray = np.random.rand(n_assets)
        weights /= np.sum(weights)
        self.all_weights[i, :] = weights
        self.portfolio_returns[i] = self.portfolioReturn(weights)
        self.portfolio_risks[i] = self.portfolioRisk(weights)

    return self.all_weights, self.portfolio_returns, self.portfolio_risks

We will also add a function to obtain the Efficient Frontier composed of the different simulated portfolios.

def findEfficientFrontier(self) -> dict:

    '''
    Generates a dictionary with the expected returns and standard deviation of each portfolio iterated to generate the Efficient Frontier.

    Returns
    -------
    frontier: dict
        Dictionary containing the annual expected return and standard deviation for all the portfolios iterated in two separate arrays.
    '''
    
    self.all_weights, self.portfolio_returns, self.portfolio_risks = self.generatePortfolios()
    frontier: dict = {
        'Returns': self.portfolio_returns*252, 
        'Risk': self.portfolio_risks*np.sqrt(252)
    }
    
    return frontier

Additionally, functions will be included to select the most interesting portfolios, such as the one with the lowest volatility, the one with the highest return, or the one with the highest Sharpe ratio.

def findPortfolio(self, port_type:str, plot:bool=False) -> tuple[np.ndarray, float, float]:

    '''
    Selects a portfolio iteration based on the port_type.

    Parameters
    ----------
    port_type: str
        String from between this options to define which portfolio to select.
        - 'min_risk': Portfolio with less standard deviation.
        - 'max_return': Portfolio with highest expected return.
        - 'max_sharpe': Portfolio with highets Sharpe Ratio.
    plot: bool
        True to plot a pie chart with the allocation to each asset for the portfolio.

    Returns
    -------
    optimal_weights: numpy.ndarray
        Numpy array containing the allocations in the portfolio for each asset.
    optimal_return: float
        Annual expected return of the portfolio.
    optimal_risk: float
        Annual standard deviation of the portfolio.
    '''
    
    if len(self.all_weights) <= 0:
        self.all_weights, self.portfolio_returns, self.portfolio_risks = self.generatePortfolios()
    
    if port_type == 'min_risk':
        optimal_index: int = np.argmin(self.portfolio_risks)
        title: str = 'Minimal Risk Portfolio Composition'
    elif port_type == 'max_return':
        optimal_index: int = np.argmax(self.portfolio_returns)
        title: str = 'Maximum Return Portfolio Composition'
    elif port_type == 'max_sharpe':
        optimal_index: int = np.argmax(self.portfolio_returns/self.portfolio_risks)
        title: str = 'Maximum Sharpe Portfolio Composition'
    else:
        raise ValueError('Select a valid port_type from: min_risk, max_return, max_sharpe.')

    optimal_weights: np.ndarray = self.all_weights[optimal_index]
    optimal_return: float = self.portfolio_returns[optimal_index]
    optimal_risk: float = self.portfolio_risks[optimal_index]

    if plot:
        self.plotPortfolioAllocation(weights=optimal_weights, title=title)
        
    return optimal_weights, optimal_return*252, optimal_risk*np.sqrt(252)

We will also define a function to which we can input the desired maximum risk, and it will return the portfolio with the highest return for that risk.

def findOptimalPortfolio(self, max_risk:float=0.1, plot:bool=False) -> tuple[np.ndarray, float, float]:

    '''
    Selects the portfolio iteration with the higher expected return being it's standard deviation below or equal to the one defined in the arguments.

    Parameters
    ----------
    max_risk: float
        Per unit annual standard deviation defined as the maximum for the Optimal portfolio.
    plot: bool
        True to plot a pie chart with the allocation to each asset for the Optimal portfolio.

    Returns
    -------
    optimal_weights: numpy.ndarray
        Numpy array containing the allocations in the portfolio for each asset.
    optimal_return: float
        Annual expected return of the portfolio.
    optimal_risk: float
        Annual standard deviation of the portfolio.
    '''

    if len(self.all_weights) <= 0:
        self.all_weights, self.portfolio_returns, self.portfolio_risks = self.generatePortfolios()

    # Filter portfolios by risk
    feasible_portfolios: list = [(w, r) for w, r in zip(self.all_weights, self.portfolio_returns) if self.portfolioRisk(w) <= max_risk/np.sqrt(252)]

    # Select the portfolio with maximum return
    if feasible_portfolios:
        optimal_weights, optimal_return = max(feasible_portfolios, key=lambda x: x[1])
        optimal_risk: float = self.portfolioRisk(optimal_weights)

        if plot:
            self.plotPortfolioAllocation(weights=optimal_weights, title='Optimal Portfolio Composition')

        return optimal_weights, optimal_return*252, optimal_risk*np.sqrt(252)
    else:
        print('There are not portfolios matching the maximum risk condition')
        return None, None, None

Finally, we will include the functions responsible for graphing the results. First, we will create the one that displays the Efficient Frontier.

def plotEfficientFrontier(self, frontier:dict=None, optimal_risk:float=0.1) -> None:

    '''
    Plot the portfolio iterations and the main portfolios.

    Parameters
    ----------
    frontier: dict
        Dictionary containing the annual expected return and standard deviation for all the portfolios iterated in two separate arrays.
    '''

    if frontier == None:
        frontier: dict = self.findEfficientFrontier()
    
    dots: dict = {
        'Minimal Risk': self.findPortfolio(port_type='min_risk'),
        'Maximum Return': self.findPortfolio(port_type='max_return'),
        'Maximum Sharpe': self.findPortfolio(port_type='max_sharpe'),
        'Optimal Portfolio': self.findOptimalPortfolio(max_risk=optimal_risk)
    }

    fig: go.Figure = go.Figure(
        data=[
            go.Scatter(
                x = frontier['Risk'],
                y = frontier['Returns'],
                mode = 'markers',
                name = 'Efficient Frontier'
            )
        ] + [
            go.Scatter(
                x = [d[2]],
                y = [d[1]],
                mode = 'markers',
                name = k,
                marker=dict(size=10, color='red')
            ) for k, d in dots.items() if d[2] != None and d[1] != None
        ], 
        layout=go.Layout(
            title = 'Efficient Frontier',
            xaxis = dict(title = 'Annual Risk (%)'),
            yaxis = dict(title = 'Annual Return (%)')
        )
    )
    fig.show()

And then the function responsible for drawing a pie chart with the composition of different portfolios.

def plotPortfolioAllocation(self, weights:np.ndarray=None, title:str='Portfolio Composition') -> None:

    '''
    Pie chart with the allocation to each asset for the portfolio.

    Parameters
    ----------
    weights: numpy.ndarray
        Numpy array containing the allocation in the portfolio for each asset.
    title: str
        Title for the plot.
    '''

    if (not isinstance(weights, list) and not isinstance(weights, np.ndarray)) or len(weights) <= 0:
        print('Recalculating weights')
        weights: np.ndarray = self.findOptimalPortfolio()[0]
        
    fig: go.Figure = go.Figure(
        data=[
            go.Pie(
                labels=self.expected_returns.index.tolist(), 
                values=[round(w * 100, 2) for w in weights]
            )
        ], 
        layout=go.Layout(title=title)
    )
    fig.show()

As an example for the application of MPT, 3 stocks have been selected for each sector of the American market, and the date range for downloading historical data has been defined. Yahoo Finance will be used to obtain the stock prices.

import yfinance as yf

# Define tickers
tickers: list = [
    'AAPL', 'MSFT', 'NVDA', # Technology
    'PLD', 'AMT', 'EQIX', # Real State
    'LIN', 'BHP', 'SCCO', # Basic Materials
    'JPM', 'V', 'BAC', # Financial
    'GOOGL', 'VZ', 'NFLX', # Communication Services
    'NVO', 'JNJ', 'AZN', # Healthcare
    'AMZN', 'TSLA', 'NKE', #Consumer Cyclical
    'NEE', 'CEG', 'SRE', # Utilities
    'CAT', 'GE', 'UNP', # Industrials
    'XOM', 'TTE', 'CCJ', # Energy
    'WMT', 'UL', 'KO', # Consumer Defensive
]
start_date: str = '2020-01-01'
end_date: str = '2024-01-01'

# Get stocks data
stock_prices: pd.DataFrame = yf.download(tickers, start=start_date, end=end_date)['Adj Close']

# Initialize the portfolio optimizer
num_portfolios: int = 100000
popt: PortfolioOptimizer = PortfolioOptimizer(prices=stock_prices, num_portfolios=num_portfolios)

# Find the Efficient Frontier
frontier: dict = popt.findEfficientFrontier()

# Plot the Efficient Frontier and the Maximum Sharpe portfolio allocations
popt.plotEfficientFrontier(frontier=frontier, optimal_risk=0.2)
popt.findPortfolio(port_type='max_sharpe', plot=True)

Upon conducting the 100k iterations, the following graph has been obtained. The optimal portfolio and the maximum Sharpe coincide, yielding an expected annual return of 21% and a standard deviation of 18.6%. However, portfolios achieving the highest return and the lowest volatility can also be observed.

Additionally, below is the composition of the portfolio with maximum Sharpe ratio considering only positions that when rounded up equal or exceed 5%.

While it may feel like we are working with assets, MPT actually compares strategies and defines the weight each should have in our portfolio. In this example, buy-and-hold strategies for each asset are being considered separately, and the weight of each strategy is being chosen. Ideally, we should find strategies that outperform buy-and-hold in different assets or markets (as uncorrelated as possible) and apply MPT to choose the weights of these strategies.

Conclusion

Moder Portfolio Theory has had a profound impact on how investors approach portfolio construction. Its emphasis on diversification and the efficient frontier has helped investors understand the relationship between risk and return with the objective of making more informed investment decisions. However, like any theory, it has its limitations and should be applied with an understanding of its assumptions and the context of the investment environment.



Categories:
python trading programming