4  Risikobasierte Faktoren: Total Risk

4.1 Hintergrund und Motivation

Klassische (“rationale”) Finance-Theory (z.B. das CAPM) besagt, dass in einem effizienten Markt riskantere Aktien mit höherem erwarteten Risiko auch höhere erwartete Renditen versprechen sollten um Investoren für das größere Risiko zu kompensieren. Es gibt aber signifikante empirische Evidenz die diesen fundamentalen positiven Risiko-Rendite Zusammenhang in Frage stellt. Das empirische Phänomen in dem risikoarme (d.h., Low Risk) Aktien eine höhere Kompensation pro Einheit Risiko (das sogenannte Alpha) versprechen als riskante (d.h., High Risk) Aktien wird als die Low Risk Anomalie (LRA) bezeichnet. Ang (2014) und Baker, Bradley und Wurgler (2011) geben eine sehr lesenswerte Einführung in die empirische Signifikanz und in potentielle Erklärungen zur Existenz der LRA. Es kann dabei gründsätzlich zwischen den folgenden Erklärungsansätzen unterschieden werden:

  1. Lotterie-Präferenzen irrationaler Investoren kombiniert mit Arbitragebeschränkungen

Auf Basis der kumulativen Prospekttheorie (Barberis und Huang, 2008) wird argumentiert, dass insbesondere individuelle (“Retail”) Anleger kleine Chancen auf große Gewinne übergewichten. Sie besitzen demnach Lotteriepräferenzen. Infolgedessen bevorzugen sie Aktien mit positiver Schiefe (Skewness) in der Renditeverteilung. Diese Aktien sind dann aufgrund der erhöhten Nachfrage bei gegebenem Angebot überbewertet, was zu geringeren zukünftigen Renditen führt. Sind Aktienkursrisiko und Schiefe der Renditeverteilung positiv miteinander korreliert, können Lotteriepräferenzen die LRA erklären. Arbitragebeschränkungen in Form von Benchmarking und der Verzicht auf den Einsatz von Fremdkapital (siehe hierzu insbesondere Baker, Bradley und Wurgler, 2011) seitens institutioneller Investoren führen dazu, dass die LRA nicht “arbitriert” wird.

  1. Abweichungen vom Ideal eines friktionslosen Marktes (Market Frictions)

Die zweite Gruppe von Erklärungen der LRA basiert auf sogenannten Market Frictions, also Abweichungen vom theoretischen Idealbild eines vollkommenen Marktes. Zu den Friktionen zählen insbesondere Transaktionskosten, Marktmikrostrukturverzerrungen wie der Bid-Ask Bounce oder eine kurzfristige Renditeumkehr (Mean Reversion).

  1. Erklärungen basierend auf Unsicherheit, Variance Beta, Earnings Surprises, etc.

Im folgenden werden wir drei mögliche Wege im Form einer Faktorstrategie implementieren, mit dem Ziel eine potentiell vorhandene LRA auszunutzen. Im Kern geht es darum, High Risk (HR) und Low Risk (LR) Aktien zu identifizieren, und dann Long (Short) in die LR (HR) Aktien zu gehen. Die Strategierendite ergibt sich aus der Renditedifferenz zwischen dem Long und dem Short Portfolio. Wir messen die risikoadjustierte Strategierendite über die annualisierte Sharpe-Ratio.

Unsere drei risikobasierten Faktorstrategien unterscheiden sich darin, wie wir das Aktienkursrisiko messen. Wir suchen also genau nach dem Risikomaß, das zukunftige Renditen am besten prognostiziert. Im ersten Fall verwenden wir die historische Renditestandardabweichung (Total Risk), im Zweiten die historische Standardabweichung der idiosynkratischen Renditen bzw. Renditeresiduen (Idiosyncratic Volatility), und schließlich das historische Aktienbeta (Beta).

4.2 Beginn der Fallstudie

Code
import numpy as np
import pandas as pd

Für unsere beispielhafte Implementierung risikobasierter Faktorstrategien zur Ausnutzung der LRA wählen wir als Anlageuniversum die Aktien des S&P500 für einen täglichen Zeitraum vom 3.1.2017 bis zum 14.11.2019. Die Daten sind im File “s&p_500_15112019.csv” enthalten.

Code
cd "C:\Users\Galina\Documents\Thomas\Python Projekte\examplefiles"
C:\Users\Galina\Documents\Thomas\Python Projekte\examplefiles
Code
# load file from disk
px = pd.read_csv('s&p_500_15112019.csv', 
                   parse_dates=True, index_col=0)
px = px.asfreq('B').fillna(method='pad')
Code
px.head(2)
ABT ABBV ABMD ACN ATVI ADBE AMD AAP AES AMG ... WLTW WYNN XEL XRX XLNX XYL YUM ZBH ZION ZTS
Date
2017-01-03 36.866280 54.056015 112.360001 110.738106 35.941536 103.480003 11.43 169.755737 10.266887 141.927734 ... 118.256813 81.712685 37.324787 25.328613 55.914982 47.835201 60.320660 101.052895 40.778381 52.556099
2017-01-04 37.158947 54.818222 115.739998 111.004356 36.647812 104.139999 11.43 171.148819 10.178833 145.451904 ... 119.715340 84.347366 37.490185 26.284410 55.507954 48.548149 60.540157 101.981964 41.363899 53.066067

2 rows × 502 columns

4.2.1 Total Risk als Faktor

Lassen Sie uns beginnen und die Aktien des S&P500 an jedem Handelstag anhand ihrer vergangenen rollierenden historischen Renditestandardabweichung in Dezile sortieren. Zunächst berechnen wir für jeden Tag die diskrete Rendite. Dies geschieht durch px.pct_change(). Wir speichern die Renditen im DataFrame ret und berechnen über rolling(window, min_periods).std() ein neues DataFrame stdev mit rollierenden täglichen historischen Renditestandardabweichungen. Wir müssen die Länge für das rollierende Zeitfenster (Argument window) und die Mindestanzahl an Renditebeobachtungen (Argument min_periods) zur Berechnung der Standardabweichung festlegen.

4.2.2 Implementierung 1: gleichgewichtete Dezil-Portfolios

Danach bringen wir die Aktien über die Methode rank(axis=1, pct=True) in eine Perzentil-Rankordnung gemäß der vergangenen Renditestandardabweichung. Aus den Perzentilen können wir Dezile generieren indem wir mit 10 multiplizieren (mul(10)) und dann auf die nächste ganze Zahl aufrunden (np.ceil). Wollen wir Aktien alternativ in Quintile sortieren, multiplizieren wir einfach mit 5 (mul(5)). Wichtig: durch die Festlegung ascending=False werden Aktien mit den höchsten (geringsten) Standardabweichungen in Gruppe 1 (10) sortiert. Äquivalent werden bei ascending=True die riskanten Aktien in Gruppe 10 und die risikoarmen Aktien in Gruppe 1 sortiert.

Code
# calculation of daily returns
ret = px.pct_change().dropna(how='all')
# calculation of std over rolling window
stdev = ret.rolling(window=250, min_periods = 150).std() 
# important: ascending ='False': risky stocks in decile 1; riskless stocks in decile 10!
rank_df = np.ceil(stdev.rank(axis=1, pct=True, ascending=False).mul(10))
rank_df.tail(5)
ABT ABBV ABMD ACN ATVI ADBE AMD AAP AES AMG ... WLTW WYNN XEL XRX XLNX XYL YUM ZBH ZION ZTS
Date
2019-11-08 8.0 4.0 1.0 9.0 2.0 4.0 1.0 5.0 8.0 2.0 ... 9.0 1.0 10.0 2.0 1.0 6.0 10.0 7.0 5.0 7.0
2019-11-11 8.0 4.0 1.0 9.0 2.0 4.0 1.0 5.0 8.0 2.0 ... 9.0 1.0 10.0 2.0 1.0 6.0 10.0 7.0 5.0 7.0
2019-11-12 8.0 4.0 1.0 9.0 2.0 4.0 1.0 5.0 8.0 2.0 ... 9.0 1.0 10.0 2.0 1.0 6.0 10.0 7.0 5.0 7.0
2019-11-13 8.0 4.0 1.0 9.0 2.0 4.0 1.0 5.0 8.0 2.0 ... 9.0 1.0 10.0 2.0 1.0 6.0 10.0 7.0 5.0 7.0
2019-11-14 8.0 4.0 1.0 9.0 2.0 4.0 1.0 5.0 8.0 2.0 ... 9.0 1.0 10.0 2.0 1.0 6.0 10.0 7.0 5.0 7.0

5 rows × 502 columns

Wir transformieren das DataFrame rank_df nun in ein DataFrame mit Positionsindikatoren: -1 für eine Short Position in Dezil 1 Aktien, 0 für eine Flat (d.h. keine) Position in Aktien der Dezile 2 bis 9, und 1 für eine Long Position in Dezil 10 Aktien.

Beachten Sie: Da wir Long in Dezil 10 und Short in Dezil 1 gehen, implementieren wir durch die Wahl von ascending=True eine High-Risk Strategie, und durch ascending=False eine Low-Risk Strategie!

Code
for col in rank_df.columns:
    rank_df[col] = np.where(rank_df[col]>9, 1, np.where(rank_df[col]<2, -1, 0))
rank_df.tail(5)
ABT ABBV ABMD ACN ATVI ADBE AMD AAP AES AMG ... WLTW WYNN XEL XRX XLNX XYL YUM ZBH ZION ZTS
Date
2019-11-08 0 0 -1 0 0 0 -1 0 0 0 ... 0 -1 1 0 -1 0 1 0 0 0
2019-11-11 0 0 -1 0 0 0 -1 0 0 0 ... 0 -1 1 0 -1 0 1 0 0 0
2019-11-12 0 0 -1 0 0 0 -1 0 0 0 ... 0 -1 1 0 -1 0 1 0 0 0
2019-11-13 0 0 -1 0 0 0 -1 0 0 0 ... 0 -1 1 0 -1 0 1 0 0 0
2019-11-14 0 0 -1 0 0 0 -1 0 0 0 ... 0 -1 1 0 -1 0 1 0 0 0

5 rows × 502 columns

Führen wir nun alles zusammen in der Funktion weights_dc_equal. Diese Funktion erfordert als Eingabe ein DataFrame mit Aktienpreisen (price), die Angabe des Zeitraums (window) für die Berechnung der historischen Renditestandardabweichung als Ranking-Faktor, die Minimum-Anzahl (min_periods) an Renditebeobachtungen, und die Anzahl der Tage (lag) die bis zur Implementierung der Strategie vergehen (sollen).

Code
# 'price' = DataFrame with daily stock prices 
def weights_dc_equal(price, window=250, min_periods=50, lag=0):
    # calculation of daily returns
    ret = price.shift(lag).pct_change().dropna(how='all')
    # calculation of std over rolling window
    stdev = ret.rolling(window, min_periods = min_periods).std() 
    # important: ascending ='False': risky stocks in decile 1; riskless stocks in decile 10!
    rank_df = np.ceil(stdev.rank(axis=1, pct=True, ascending=False).mul(10))
    for col in rank_df.columns:
        rank_df[col] = np.where(rank_df[col]>9, 1, np.where(rank_df[col]<2, -1, 0))
    return rank_df

Zudem definieren wir wieder unsere zwei Lambda Funktionen:

1. "compound" berechnet aus dem Eingabe-Array x kumulative Mehr-Tagesrenditen;
2. "daily_sr" berechnet aus einem Array von Tagesrenditen die tägliche Sharpe-Ratio;
Code
# cumulative returns
compound = lambda x: (1 + x).prod() - 1

# daily Sharpe Ratio
daily_sr = lambda x: x.mean() / x.std()

Im folgenden berechnen wir zunächst für jeden Handelstag die nicht-normierten Aktiengewichte (-1: Short; 0: Flat; 1: Long) durch Anwendung unserer Funktion weights_dc_equal. Das resultierende DataFrame nennen wir port. Dann legen wir die Anzahl der Handelstage (hold) fest, für die wir das Portfolio halten wollen ohne die Gewichte umzuschichten.

Im letzten Schritt reduzieren wir die Zeitfrequenz von port auf die Länge (hold) der gewählten Portfoliohalteperiode, setzen die Gewichte auf die Werte die zu Beginn der Halteperiode gelten (über .first()), und wählen .shift(1), da in t nur die Gewichte die zum Zeitpunkt t-1 bekannt sind implementiert werden können.

Code
hold = 21
freq = '%dB' % hold # holding period 
port = weights_dc_equal(px) # security weights at business day freq
# calculation of portfolio returns
port = port.shift(1).resample(freq).first() # time series with 'freq' as frequency

Dann berechnen wir die kumulativen Renditen jeder Aktie für die gewählte Portfoliohaltedauer. Hierzu verwenden wir die vorher definierte Lambda-Funktion compound.

Code
# calculation of daily security returns
daily_rets = px.pct_change().dropna(how='all')
    
# calculation of portfolio returns, shift(1) for implementation lag
returns = daily_rets.resample(freq).apply(compound)

Danach multiplizieren wir für jede der einzelnen Halteperioden die kumulativen Aktienrenditen mit den Positionsindikatoren (-1, 0, 1), summieren die Produkte über alle Aktien auf, und teilen durch die Summe aller offen (Long und Short) Positionen um eine gleichgewichtete Portfoliorendite zu bekommen. Die resultierende Zeitreihe der kumulativen Portfoliorenditen nennen wir portf_rets. Sie hat dieselbe Zeitfrequenz wie freq, die Portfoliohaltedauer in Anzahl Handelstage. Wenden wir unsere Lambda-Funktion daily_srauf die Zeitreihe der Portfoliorenditen an und skalieren mit np.sqrt(252/hold) erhalten wir die annualisierte Sharpe-Ratio der Strategie.

Code
# security returns * position direction (-1, 0, 1)
portf = np.multiply(port, returns) 
# summing position returns divided by number of positions
portf_rets = portf.sum(axis = 1)/(portf != 0).sum(axis =1)

Lassen Sie uns nun die obigen Schritte in der Strategie-Funktion strat_dc_equal zusammenfassen. Wir benötigen ein DataFrame (prices) mit Aktienkursen, die Länge der lookback (window) Periode zur Berechnung der historischen Renditestandardabweichung (Rankingfaktor), und die Länge der Portfoliohaltedauer (hold).

Code
# strategy function
def strat_dc_equal(prices, window, hold):
    # calculation of security weights: (-1: short, 0: no, 1: long)
    # security weights at business day freq
    port = weights_dc_equal(prices, window, min_periods=40, lag=0)
    freq = '%dB' % hold # holding period 
    
    # calculation of daily security returns
    daily_rets = prices.pct_change().dropna(how='all')

    # calculation of portfolio returns
    port = port.shift(1).resample(freq).first() # time series with 'freq' as frequency
    returns = daily_rets.resample(freq).apply(compound)
    portf = np.multiply(port, returns) # security returns * position direction (-1, 0, 1)
    # summing position returns divided by number of positions
    portf_rets = portf.sum(axis = 1)/(portf != 0).sum(axis =1)
    
    return daily_sr(portf_rets) * np.sqrt(252/hold)

Führen wir die Strategie nun beispielhaft mit einer Halteperiode von einem Monat (21 Handelstage) aus. Die Aktien des S&P500 werden anhand ihrer vergangenen 40-Tage Renditestandardabweichung sortiert. Da in der Funktion weights_dc_equal ascending=False festgelegt ist, gehen wir also Long in die extremen 10% der vergangenen Low-Risk Aktien und Short in die extremen 10% der vergangenen High-Risk Aktien. Wir implementieren also eine Low-Risk Strategie!

Code
sharpe_equal = strat_dc_equal(px,40,21)
Code
sharpe_equal
0.6178071759738956

4.2.3 Implementierung 2: Gewichte nach Frazzini und Pedersen (2014)

Zur Implementierung der FP Gewichte erstellen wir das gewohnte (tägliche) DataFrame ranks mit den Perzentil-Rängen der Aktien gemäß ihrer Renditestandardabweichung über eine vergangene lookback (window) Periode. Daraus erstellen wir ein neues DataFrame demeaned mit Rangabweichungen indem wir jeweils den Zeilenmittelwert (ranks.mean(axis=1)) der Ränge vom Rang einer Aktie abziehen. Zusätzlich enthält das DataFrame abs_demeaned die absoluten Rangabweichungen. Wir bekommen die finalen FP Gewichte (DataFrame weights) indem wir die Zeilenwerte von demeaned durch die Hälfte der korrespondierenden Zeilensumme von abs_demeaned teilen.

Fassen wir diese Schritte nun in der Gewichtsfunktion weights_fp zusammen. Alle weiteren Schritte erfolgen analog zur obigen Vorgehensweise. Die Strategiefunktion nenne ich strat_fp.

Code
# weights according to Frazzini and Pedersen (2014), equation (16), p. 9
def weights_fp(price, window=250, min_periods=40):
    ret = price.pct_change().dropna(how='all')
    # calculation of std over rolling window
    stdev = ret.rolling(window, min_periods = min_periods).std() 
    
    # important: ascending ='False': risky stocks in decile 1; riskless stocks in decile 10!
    ranks = stdev.rank(ascending=False, axis=1, pct=True) # percentile ranks
    demeaned = ranks.sub(ranks.mean(axis=1), axis='index') # cross-sectional demeaned
    abs_demeaned = abs(demeaned) 
    # demeaned percentile ranks normalized by 0.5 * cross-sectional sum of abs. demeaned weights
    weights = demeaned.div(0.5 * abs_demeaned.sum(axis=1), axis='index')
    return weights

# cumulative returns
compound = lambda x: (1 + x).prod() - 1

# daily sharpe ratio
daily_sr = lambda x: x.mean() / x.std()

# strategie returns (main function)
def strat_fp(prices, window, hold):
    # portfolio weights
    freq = '%dB' % hold # holding period in number of business days
    port = weights_fp(prices, window) # weights for each business day
    
    # daily returns
    daily_rets = prices.pct_change().dropna(how='all')
    
    # strategy returns; shift(1) for implementation lag
    port = port.shift(1).resample(freq).first() # time series with holding period freq
    returns = daily_rets.resample(freq).apply(compound)
    port_rets = (port * returns).sum(axis=1) # weighted sum of security returns 
    
    return daily_sr(port_rets) * np.sqrt(252/hold)
    
Code
sharpe_fp = strat_fp(px,40,21)
Code
sharpe_fp
0.6354001379129001

4.2.4 Optimierung

Code
from collections import defaultdict

lookbacks = range(40, 120, 20)
holdings = range(20, 60, 5)
dd = defaultdict(dict)
for lb in lookbacks:
    for hold in holdings:
        dd[lb][hold] = strat_fp(px, lb, hold)
        
ddf = pd.DataFrame(dd)
ddf.index.name = 'Holding Period'
ddf.columns.name = 'Lookback Period'
Code
ddf
Lookback Period 40 60 80 100
Holding Period
20 0.547225 0.535322 0.566154 0.486525
25 0.640504 0.695662 0.634289 0.583816
30 0.565702 0.610219 0.584228 0.522113
35 0.574164 0.568566 0.501421 -0.133397
40 0.466834 0.516816 0.461860 0.409636
45 0.711387 0.706873 0.602465 0.578311
50 0.702979 0.696906 0.667072 -0.112067
55 0.708629 0.616248 0.579317 -0.114633
Code
# Heatmap zur Visualiserung
import matplotlib.pyplot as plt
%matplotlib notebook

def heatmap(df, cmap=plt.cm.gray_r):
    fig = plt.figure()
    ax = fig.add_subplot(1,1,1)
    axim = ax.imshow(df.values, cmap=cmap, interpolation='nearest')
    ax.set_xlabel(df.columns.name)
    ax.set_xticks(np.arange(len(df.columns)))
    ax.set_xticklabels(list(df.columns))
    ax.set_ylabel(df.index.name)
    ax.set_yticks(np.arange(len(df.index)))
    ax.set_yticklabels(list(df.index))
    plt.colorbar(axim)
    
heatmap(ddf)