1. Home
  2. Article Catalog
  3. Sustainable Portfolio Selection - Markowitz Goes ESG

Article

Sustainable Portfolio Selection -- Markowitz goes ESG

Authors:
Dr. Jennifer Rasch
Founder, Goldmarie Finanzen Founder, Goldmarie Finanzen
Dr. Caroline Loebhard
Founder, Goldmarie Finanzen Founder, Goldmarie Finanzen

Ecological, social and governance measures are gaining importance in the realm of financial services. On the one hand, investors' principles and their recognition of non-financial risks connected to exploitative business lead to a growing green capital market. On the other hand, allocating capital for ecological and social companies promotes sustainable development as it is aimed for e.g. in UN's 17 SDGs (sustainable development goals). As a reaction to climate change and human rights movements ESG measures will become legally relevant in the forseeable future.

This article shows how ESG-Data can be used in portfolio decisions. The present text is standalone and targets readers who are interested, but not neccessarily familiar with portfolio optimization.

Overview

We follow roughly the steps in Jason Ramchandani's Portfolio Optimisation and build up an effective frontier from Markowitz' classical portfolio selection theory. But in difference to the classical risk-return-approach, we aim at a low risk combined with high ESG score.

For details on ESG measures we refer to Gurpreet Bal's article "How to integrate ESG data into investment decisions". We enhance his approach of selecting an ESG subset of a given index by requiring a lower bound on the TR ESG Score.

Technical prerequisites

  • Refinitiv Eikon / Workspace with access to Eikon Data APIs (Free Trial Available)
  • Python 3.x
  • Required Python Packages: eikon, pandas 0.17.0 or higher, numpy, scipy, matplotlib

Portfolio selection at a glance

In portfolio selection, a set of shares is given, e.g. an index, for example all shares listed in DAX. From this so-called universe, a combination of shares is selected into a portfolio. The selection follows some conditions and optimization goals. A classical condition on portfolios is an upper bound on the weight of a single share preventing high risks due to one very strong component. Classical goals are for instance a maximal expected return of the portfolio, a minimum mean variance, or low risk measures. In this tutorial, we will include a high ESG-Score into the optimization goal.

    	
            

import eikon as ek

import pandas

import numpy

import matplotlib.pyplot as plt

import scipy.optimize as sco 

import os

ek.set_app_key('YOUR API KEY HERE')

Getting instruments and data

Our portfolio will be built from a universe (or a pre-selection) of shares. We will work based on ETF iShares Core MSCI World UCITS ETF USD which is based on the MSCI World index.

Reading a universe from Eikon

We use Eikon to get the constituents of the index. For simplicity we will reduce the list of entries here to 100.

    	
            

N=100

constituents, err = ek.get_data(['IWDA.L'], ['TR.ETPConstituentRIC', 'TR.ETPConstituentName'])

constituents.rename(columns={'Constituent RIC': 'ric', 'Constituent Name': 'name'}, inplace=True)

constituents = constituents[['ric','name']][0:N]

print(constituents)

  ric name
0 FITB.OQ FIFTH THIRD BANCORP ORD
1 HOLX.OQ HOLOGIC ORD
2 8630.T SOMPO HLDGS INC ORD
3 CTSH.OQ COGNIZANT TECHNOLOGY SOLUTN CL A ORD
4 6965.T HAMAMATSU PHOTONICS ORD
.. ... ...
95 CBOE.Z CBOE GLOBAL MARKETS INC ORD
96 8031.T MITSUI ORD
97 OREP.PA L OREAL S.A.
98 BDEV.L BARRATT DEVLPMNT
99 DAST.PA DASSAULT SYSTEMES ORD

[100 rows x 2 columns]

Reading instruments time series price data

We get the time series of our chosen instruments for one year. We need to remove the NA data in order to prevent problems with calculating the covariance matrix.

    	
            

start='2020-01-01'

end='2020-12-31'

instruments = constituents['ric'].astype(str).values.tolist()

ts =pandas.DataFrame()

for r in instruments:

    try:

        ts1 = ek.get_timeseries(r,'CLOSE',start_date=start,end_date=end,interval='daily')

        ts1.rename(columns = {'CLOSE' : r}, inplace=True)

        ts =pandas.concat([ts, ts1], axis=1)

    except:

        continue

ts = ts.dropna()

print(ts)

  FITB.OQ HOLX.OQ 8630.T CTSH.OQ 6965.T AVV.L WCN.N  \
Date              
07/01/2020 29.85 52.49 4280 60.32 4555 3789.54556 92.58
08/01/2020 29.92 52.29 4188 60.73 4490 3815.21564 93.27
09/01/2020 30.25 53.275 4228 61.11 4545 3828.05068 93.75
10/01/2020 29.76 53.17 4239 60.64 4530 3805.58936 93.82
14/01/2020 29.74 53.53 4213 61.37 4575 3905.06092 95.13
... ... ... ... ... ... ... ...
21/12/2020 27.165 75.23 4038 80.47 6030 3199 101.64
22/12/2020 26.93 74.67 4034 81.02 5880 3213 102.72
23/12/2020 27.73 75.06 4068 80.99 5920 3209 101.23
29/12/2020 27.28 71.74 4186 80.5 6060 3281 101.48
30/12/2020 27.28 71.75 4173 81.07 5900 3225 101.84
  BAMa.TO FERG.L BCE.TO ... 5101.T 7259.T G24n.DE TRYG.CO  \
Date       ...        
07/01/2020 50.093358 6948 60.48 ... 2112 4045 59.45 200
08/01/2020 50.300025 6980 60.64 ... 2054 3985 59.35 197.5
09/01/2020 50.746692 7146 60.29 ... 2088 3945 60.4 197.5
10/01/2020 50.846692 7078 60.52 ... 2066 3900 60.25 199.1
14/01/2020 52.35336 7216 60.89 ... 2049 3905 60.45 204.4
... ... ... ... ... ... ... ... ...
21/12/2020 50.98 8818 54.75 ... 1565 3125 67.05 188.1
22/12/2020 51.11 8890 54.6 ... 1560 3140 67.4 187.3
23/12/2020 51.81 8858 54.66 ... 1530 3110 67.35 187.8
29/12/2020 52.88 9004 54.69 ... 1558 3120 66.8 192
30/12/2020 52.72 8890 54.6 ... 1534 3095 67.05 192.1
  8411.T CBOE.Z 8031.T OREP.PA BDEV.L DAST.PA
Date            
07/01/2020 1682 118.22 1971 259.6 762.2 148.7
08/01/2020 1668 117.01 1949 261.2 761.2 150.5
09/01/2020 1677 116.01 1981.5 262.8 758 152.45
10/01/2020 1672 115.85 1971.5 263.9 756.2 154
14/01/2020 1666 115.49 1972.5 263.8 781 154
... ... ... ... ... ... ...
21/12/2020 1315.5 90.26 1904.5 295.6 636.4 159.85
22/12/2020 1312 90.71 1876 301.8 649 163.25
23/12/2020 1293 91.1 1864.5 303.7 676 165
29/12/2020 1315 91.66 1911 312.2 681.6 166.85
30/12/2020 1307.5 92.62 1889.5 313.2 680.2 167.55

[208 rows x 100 columns]

Reading ESG-data from Eikon

Refinitiv provides a large bunch of data connected to ESG-evaluation of companies. In this exemplary portfolio selection, we use the TR.TRESGScore which ranges from 0 to 100.

    	
            

df_esg, err = ek.get_data(instruments, ['TR.TRESGScore','TR.BusinessSummary'])

df_esg = df_esg.rename(columns={'Instrument':'ric', 'ESG Score':'esg'}).set_index('ric')

df_esg = pandas.concat([constituents.set_index('ric'), df_esg], join='inner', axis=1)

df_esg

    ric    name esg Business Description
FITB.OQ FIFTH THIRD BANCORP ORD 69.891816 Fifth Third Bancorp is a bank holding company ...
HOLX.OQ HOLOGIC ORD 73.698711 Hologic, Inc. is a developer, manufacturer and...
8630.T SOMPO HLDGS INC ORD 71.080134 Sompo Holdings, Inc. is a Japan-based company ...
CTSH.OQ COGNIZANT TECHNOLOGY SOLUTN CL A ORD 58.391002 Cognizant Technology Solutions Corporation is ...
6965.T HAMAMATSU PHOTONICS ORD 60.111937 HAMAMATSU PHOTONICS K.K. is a Japan-based comp...
... ... ... ...
CBOE.Z CBOE GLOBAL MARKETS INC ORD 46.769310 Cboe Global Markets, Inc. is a holding company...
8031.T MITSUI ORD 71.280614 Mitsui & Co., Ltd. is a general trading compan...
OREP.PA L OREAL S.A. 79.008395 L'Oreal SA is a France-based cosmetics company...
BDEV.L BARRATT DEVLPMNT 69.361833 Barratt Developments PLC is a holding company....
DAST.PA DASSAULT SYSTEMES ORD 57.005719 Dassault Systemes SE is a France-based softwar...


100 rows × 3 columns

Unconstrained minimum volatility portfolio (MVP)

As a first step, we calculate the classic minimum volatility portfolio.

Calculate past returns and covariance matrix

We calculate the past returns using pandas. We need to remove the NA numbers, that will be occuring in the first row -and possibly elsewhere.

    	
            

returns = ts.pct_change().replace(numpy.inf, numpy.nan).dropna()

covMatrix = returns.cov()

Define risk measure

Using the covariance matrix, we define a risk measure based on volatility:

    	
            

def risk_measure(covMatrix, weights):

    return numpy.dot(weights, numpy.dot(covMatrix, weights))

First, we set the boundary conditions for the single weights that should range between 0 and 1.

    	
            constraints = {'type': 'eq', 'fun': lambda weights: weights.sum() - 1}
        
        
    

The minimum volatility portfolio (MVP) portfolio is computed by scipy's optimize function, which returns a result 'res' that includes the portfolio weights 'res['x']' and the minimized risk measure 'res['fun']'. We evaluate the number of instruments in the portfolio, the minimum and maximum weights, the MVP's (historical) annual return, it's volatility, and also the ESG score.

    	
            

mvp = sco.minimize(lambda x: risk_measure(covMatrix, x),  # function to be minized

                   len(instruments) * [1 / len(instruments)],  # initial guess

                   bounds=bounds,  # boundary conditions

                   constraints =constraints,  # equality constraints

                   )

mvp_weights = list(mvp['x'])

mvp_esg = numpy.dot(mvp_weights, df_esg['esg'])

mvp_risk = mvp['fun']

print('\nMVP in a universe with {N} instruments\nNumber of selected instruments: {n}\nMinimum weight: {minw}\nMaximum weight: {maxw}\nHistorical risk measure: {risk}\nHistorical return p.a.: {r}\nESG score: {esg}'.format(N=N,n=numpy.sum(mvp['x']>0),minw=numpy.min(mvp['x'][numpy.nonzero(mvp['x'])]),maxw=numpy.max(mvp['x']),risk=mvp_risk,r=numpy.dot(mvp_weights,returns.sum()),esg=mvp_esg))

MVP in a universe with 100 instruments
Number of selected instruments: 65
Minimum weight: 2.44826270139076e-20
Maximum weight: 0.06396593250127255
Historical risk measure: 0.0001294899285868813
Historical return p.a.: 0.07872510570868128
ESG score: 55.31860881168306

Results

We use a pandas DataFrame to assign the weights to the instruments, sort them by size and remove the very small ones for the plot (note that in order to use the sort_values function your pandas version needs to be higher than 0.17.0).

    	
            

df_weights =pandas.DataFrame(data=mvp_weights, index=instruments)

df_weights =df_weights.sort_values(by=[0], ascending=False)

df_weights =df_weights[df_weights > 1e-4].dropna()

df_weights =df_weights.T

    	
            

#plotting the weights 

plt.figure(figsize=(15, 5))

ypos = numpy.linspace(0, 100, num=len(df_weights.iloc[0,:]))

plt.bar(ypos, df_weights.values[0], width =1)

plt.xticks(ypos, df_weights.columns, rotation =30, ha ='right')

plt.xlabel('RICs', fontsize=12)

plt.ylabel('Weights', fontsize=12)

plt.title('Portfolio Weights (Minimum Volatility Portfolio)', fontsize=12)

plt.show()

MVP for prescribed ESG score

We are now interested in selecting a portfolio with a higher ESG score than the MVP. This additional restriction is implemented via a second constraint in scipy's optimize. Note that the prescribed ESG score should be higher than the MVP's ESG -- we are the good guys, and want more ecological, social and governance value! On the other hand, the maximum ESG can be achieved by simply investing all the money into the highest rated instrument -but that would be too much of a risk, we want more diversity. This yields an upper bound on a meaningful setting for the prescribed ESG.

    	
            

prescribed_esg = 80

 

esg_constraint = {'type': 'eq', 'fun': lambda weights: numpy.dot(weights, df_esg['esg']) - prescribed_esg}

esgmvp = sco.minimize(lambda x: risk_measure(covMatrix, x),  # function to be minized

                   len(instruments) * [1 / len(instruments)],  # initial guess

                   bounds=bounds,  # boundary conditions

                   constraints =[constraints, esg_constraint],  # equality constraints

                   )

esgmvp_weights = list(esgmvp['x'])

esgmvp_esg = numpy.dot(esgmvp_weights, df_esg['esg'])

esgmvp_risk = esgmvp['fun']

 

print('\nMVP with prescribes ESG = {pe} in a universe with {N} instruments\nNumber of selected instruments: {n}\nMinimum weight: {minw}\nMaximum weight: {maxw}\nHistorical risk measure: {risk}\nHistorical return p.a.: {r}\nESG score: {esg}'.format(N=N,n=numpy.sum(esgmvp['x']>0),minw=numpy.min(esgmvp['x'][numpy.nonzero(esgmvp['x'])]),maxw=numpy.max(esgmvp['x']),risk=esgmvp_risk,r=numpy.dot(esgmvp_weights,returns.sum()),esg=esgmvp_esg,pe=prescribed_esg))

MVP with prescribes ESG = 80 in a universe with 100 instruments
Number of selected instruments: 66
Minimum weight: 2.0241858872983442e-20
Maximum weight: 0.09328007383074229
Historical risk measure: 0.00024141858003072668
Historical return p.a.: 0.15745991254900046
ESG score: 80.00000000021717

Results

We use a pandas DataFrame to assign the weights to the instruments, sort them by size and delete the very small ones:

    	
            

df_weights =pandas.DataFrame(data=esgmvp_weights, index=instruments)

df_weights =df_weights.sort_values(by=[0], ascending=False)

df_weights =df_weights[df_weights > 1e-4].dropna()

df_weights =df_weights.T

    	
            

#plotting the weights

plt.figure(figsize=(15, 5))

ypos = numpy.linspace(0, 100, num=len(df_weights.iloc[0,:]))

plt.bar(ypos, df_weights.values[0], width =1)

plt.xticks(ypos, df_weights.columns, rotation =30, ha ='right')

plt.xlabel('RICs', fontsize=12)

plt.ylabel('Weights', fontsize=12)

plt.title('Portfolio Weights (Minimal Risk with ESG Score 80)', fontsize=12)

plt.show()

The risk measure of the resulting portfolio is increased compared to the MVP, because we optimize over a more restricted set -- namely, only those portfolios providing the prescribed ESG score instead of all possible portfolios. But nothing is said about the historical return: In this example, it is increased by a factor of about 2 compared to the MVP. Demanding more value for the climate, the environment and for humans can thus lead to higher return.

ESG-efficient frontier

Portfolios can be evaluated with respect to return and volatility, and plotted as dots into a respective coordinate system. In the classical context of H. Markowitz, the efficient frontier is a line that consists of all those portfolio-dots, which are efficient in the following sense: There is no other portfolio which has the same return at a lower risk.

We adjust this idea to the ESG context by replacing Markowitz's return with the ESG score. From a mathematical viewpoint, both measures are very similar: The return/ESG of a portfolio is computed as a scalar product of a vector with return/ESG scores of the instruments with the weights vector.

To build up the ESG-efficient frontier, we calculate several optimal portfolios with different prescribed ESG values. The prescribed ESG values will be in the range between the MVP's ESG, and the maximum ESG of all instruments in the underlying universe. We save the resulting portfolio weights, their ESG value and their risk measure in our results dictionary.

    	
            

max_esg=numpy.floor(max(df_esg['esg'].values.tolist())) #max esg value to be achieved dependent on the universe

min_esg=mvp_esg #min esg value which is interesting for portfolio selection

 

results = {'esg_val':[],'weights':[],'risk':[],'return':[]}

for rho in numpy.linspace(min_esg,max_esg,num=25):

    constraints2 = {'type': 'eq', 'fun': lambda weights: numpy.dot(weights, df_esg['esg']) - rho}

    res = sco.minimize(lambda x: risk_measure(covMatrix, x),  # function to be minized

                   len(instruments) * [1 / len(instruments)],  # initial guess

                   bounds=bounds,  # boundary conditions

                   constraints =[constraints, constraints2],  # equality constraints

                       )

    weights = list(res['x'])

    esg_val= numpy.dot(weights, df_esg['esg'])

    results['esg_val'].append(esg_val)

    results['weights'].append(weights)

    results['risk'].append(res['fun'])

    results['return'].append(numpy.dot(weights,returns.sum()))

Results

Plotting our result:

    	
            

plt.plot(results['risk'],results['esg_val'], 'o')

plt.xlabel('risk')

plt.ylabel('esg_val')

plt.show()

Remark on the trade-off between risk and ESG rating in this model

The fact that a higher ESG score comes with higher risks is intrinsic to this model, but it does not fully recover reality (that's always the problem with mathematical models . . . ). One just minimizes over a subset, and the minimum will thus increase. As a remedy, one can enlarge or change the universe. In this way, almost any combination of ESG score, risk measure and (expected) return can be achieved.

A happier fact (also kind of model-intrinsic) is that higher ESG scores come with higher returns within the ESG-efficient portfolios:

    	
            

plt.plot(results['esg_val'],results['return'],'o')

plt.xlabel('esg_val')

plt.ylabel('return')

plt.show()

Outlook

This tutorial focuses on employing ESG-data in portfolio selection. For this purpose, we chose simple models for returns and risks. One can enhance both measures, e.g. by forecasting methods, weighted or non-smooth risk measures, and more. Furthermore, we did not consider the expected return in our optimization and utilized only one out of a bunch of ESG data that is available via Eikon. One can include more constraints or add terms to the objective functional of the optimization problem.

For more details on portfolio optimization see Portfolio Selection by Dr. Yves J. Hilpisch and Jason Ramchandani's Portfolio Optimisation Part II.

For more information about the authors, check out https://goldmarie-finanzen.de!

Further Resources for Eikon Data API

For Content Navigation in Eikon - please use the Data Item Browser Application: Type 'DIB' into Eikon Search Bar.