5 Risikobasierte Faktoren: Idiosyncratic Volatility - IVOL
5.1 Hintergrund und Motivation
Das CAPM impliziert, dass das Gesamtrisiko (Renditevarianz) eines Wertpapiers aus zwei Summanden besteht, von denen der erste Summand die Renditeschwankungen des Marktes und der zweite Summand die wertpapierspezifischen Renditeschwankungen beinhaltet.
\[Var(R_{i,t}) = \beta^{2}_{i} Var(R_{m,t}) + Var(\epsilon_{i})\]
\(\epsilon_{i}\) kennzeichnet dabei die Renditeresiduen des Wertpapiers \(i\), und \(Var(\epsilon_{i})\) wird auch als idiosynkratische Varianz (unsystematisches Risiko) bezeichnet. Die Wurzel daraus ist die idiosynkratische Volatilität (IVOL). Verallgemeinert man den obigen Zusammenhang wird offensichtlich, dass sich IVOL auf Basis unterschiedlicher (Ein-) oder Mehrfaktormodelle berechnen läßt, immer als realisierte (empirische) Standardabweichung der sich aus der Faktorregression ergebenden Renditeresiduen.
In zwei einflussreichen Arbeiten dokumentieren Ang, Hodrick, Xing und Zhang (2006, 2009) einen negativen Zusammenhang zwischen der idiosynkratischen Volatilität und zukünftigen Aktienrenditen. In dem Maße, in dem die realisierte idiosynkratische Volatilität für die erwartete idiosynkratische Volatilität steht, ist dieses Ergebnis unerwartet (ein „Puzzle“), da traditionelle Asset-Pricing-Theorien (z.B. das CAPM) entweder keinen Zusammenhang zwischen der erwarteten idiosynkratischen Volatilität und erwarteten Renditen vorhersagen wenn Märkte vollständig und friktionslos sind und Anleger sich gut diversifizieren können, oder eine positive Beziehung prognostizieren unter der Annahme, dass Märkte unvollständig und mit Friktionen behaftet sind und Anleger schlecht diversifizierte Portfolios halten.
Eine bekannte Form der Low Risk Anomaly ist folglich das Idiosyncratic Volatility Puzzle (IVP). Hou und Loh (2016) untersuchen empirisch zahlreiche Erklärungsansätze für die Existenz des IVP.
Wir werden im folgenden eine Faktorstrategie mit IVOL als renditeprognostizierenden Faktor implementieren.
5.2 Beginn der Fallstudie
Wir beginnen mit dem Laden der notwendigen Pakete. Zur Durchführung von OLS Regressionen (mit einer Konstanten) importieren wir die Module linear_model und toolsaus dem Paket statsmodels.
Wir setzen die IVOL Faktorstrategie am Beispiel des S&P500 Universums um. Zunächst laden wir zwei Datensätze in Form von DataFrames, eines für die täglichen Aktienkurshistorien der S&P500 Mitglieder und eines für die Zeitreihe der Indexwerte des S&P500. Wir mergen beide Datensätze in dem DataFrame joined_df und berechnen diskrete Tagesrenditen.
Code
# load of data: df with S&P500 constituents and df with S&P500 index values
%cd "C:\Users\Galina\Documents\Thomas\Python Projekte\examplefiles"
constituents = pd.read_csv('s&p_500_15112019.csv',
parse_dates=True, index_col=0)
index = pd.read_csv('s&p_500_012000_102019.csv',parse_dates=True, index_col=0)
sp_index = index['Adj Close']
sp_index.name = 'SP_Index'
# adjustment for different frequency of constituents and index
joined_df = pd.merge(constituents, sp_index, how='inner', on='Date')
df_returns = joined_df.pct_change().dropna(how='all') C:\Users\Galina\Documents\Thomas\Python Projekte\examplefiles
| ABT | ABBV | ABMD | ACN | ATVI | ADBE | AMD | AAP | AES | AMG | ... | WYNN | XEL | XRX | XLNX | XYL | YUM | ZBH | ZION | ZTS | SP_Index | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Date | |||||||||||||||||||||
| 2017-01-04 | 0.007939 | 0.014100 | 0.030082 | 0.002404 | 0.019651 | 0.006378 | 0.000000 | 0.008206 | -0.008576 | 0.024831 | ... | 0.032243 | 0.004431 | 0.037736 | -0.007279 | 0.014904 | 0.003639 | 0.009194 | 0.014359 | 0.009703 | 0.005722 |
| 2017-01-05 | 0.008638 | 0.007584 | -0.008035 | -0.014991 | 0.015525 | 0.016996 | -0.016623 | -0.000698 | -0.012976 | -0.012248 | ... | 0.012849 | 0.000000 | -0.004196 | -0.012108 | -0.009129 | 0.003310 | 0.006425 | -0.016210 | -0.003326 | -0.000771 |
| 2017-01-06 | 0.027204 | 0.000314 | 0.005313 | 0.011392 | -0.000791 | 0.022566 | 0.007117 | -0.013090 | 0.035933 | -0.002236 | ... | 0.010827 | 0.002941 | -0.015449 | 0.019334 | -0.007010 | 0.012097 | 0.000095 | 0.006498 | 0.003152 | 0.003517 |
| 2017-01-09 | -0.000981 | 0.006584 | 0.014642 | -0.011178 | -0.005539 | 0.002493 | 0.015018 | -0.000589 | -0.024535 | -0.025943 | ... | 0.003462 | -0.015152 | -0.005706 | 0.000170 | -0.004236 | 0.002794 | 0.019436 | -0.010837 | -0.002773 | -0.003549 |
| 2017-01-10 | 0.013500 | -0.002180 | -0.041585 | 0.000522 | 0.018037 | -0.002855 | -0.004352 | 0.002300 | -0.017346 | -0.001116 | ... | 0.022426 | -0.000248 | 0.008608 | -0.008974 | -0.004658 | 0.005883 | 0.062337 | 0.015384 | -0.000371 | 0.000000 |
5 rows × 503 columns
5.2.1 IVOL als Faktor
Wir berechnen die IVOL einer Aktie auf Basis eines Einfaktor-Modells (Single-Index Model), wobei die S&P Indexrendite den (Markt-) Faktor darstellt. Durch die Regression von Aktienrenditen auf die Indexrendite erhalten wir Residuen, deren Streuung (Standardabweichung) die idiosynkratische Volatilität (IVOL) einer Aktie symbolisiert. Wir berechnen die IVOL täglich auf Basis eines rollierenden Zeitfensters dessen Länge wir mit dem Argument window festlegen.
Lassen Sie uns zunächst eine Funktion idiovola_single() schreiben, die die rollierende IVOL für eine einzelne Aktie berechnet. Das Argument col bezeichnet hierbei die Historie der täglichen Aktienrenditen (y-Variable der Regression) und die Spalte ‘SP_Index’ des DataFrames df_returns enthält die x-Variable (Indexrenditen). Wichtig: Wollen wir IVOLs auf Basis von Multifaktoren-Modellen (z.B. dem Fama/French 3-Faktor Modell oder Erweiterungen hiervon) berechnen, muss df_returns entsprechende Zeitreihen der Faktorrenditen enthalten.
Über die Funktion add_constant fügen wir zum Vektor der Indexrenditen einen gleichlangen Vektor mit “Einsen”, die den Achsenabschnitt (Konstante der Regression) representieren, hinzu.
Für jedes Zeitfenster der Länge window (von iStart bis iEnd) führen wir über sm.OLS(y-Variable, (Konstante, x-Variable)).fit() eine OLS Regression durch, berechnen die Standardabweichung der sich ergebenden Residuen (std_resids = IVOL) und speichern alle IVOLs in der Liste empty_list.
Diese Liste wird anschließend in ein einspaltiges DataFrame idio_vola transformiert, wobei der Zeilenindex den Zeitpunkten (df_new['Date']) entspricht, für die jeweils die täglichen rollierenden IVOLs berechnet wurden. Beachten: Durch reset_index('Date') wird der Zeilenindex ‘Date’ des DataFrames df_returns in eine normale Spalte transformiert, die dann wiederum über pd.concat() mit Spalten anderer DataFrames verkettet werden kann.
Code
# function to calculate rolling IVOL for a df of stock and factor returns
# first a function for a single stock
def idiovola_single(col, df_returns, window=250):
# col =y-variable; df_returns.SP_Index = x-variable
x1 = df_returns.SP_Index # adjustment necessary for multi-factors!
x2 = sm2.add_constant(x1)
empty_list = list()
# rolling window:[iStart,iEnd]
for iStart in range(0, len(df_returns)-window):
iEnd = iStart+window
# Calculate regression residuals std
reg = sm.OLS(col[iStart:iEnd],x2[iStart:iEnd]).fit()
std_resids = np.std(reg.resid)
empty_list.append(std_resids)
idio_vola0 = pd.DataFrame(empty_list)
df_new = df_returns[window:len(df_returns)].reset_index('Date')
idio_vola = pd.concat([df_new['Date'], idio_vola0], axis=1)
idio_vola = idio_vola.set_index('Date')
return idio_volaNun schreiben wir eine Funktion, die rollierende IVOLs für eine Vielzahl von Aktien berechnet. Das DataFrame df_returns muss herbei zwingend eine Spalte mit der Bezeichnung ‘SP_Index’ enthalten, in der die Indexrenditen stehen. In den übrigen Spalten stehen die Tagesrenditen unseres Anlageuniversums.
Wir generieren durch den wiederholten Aufruf von idiovola_single() eine Liste dfs, die einspaltige DataFrames mit den jeweiligen IVOL Historien der Aktien enthält. Aus dieser Liste erzeugen wir das finale IVOL DataFrame df_idiovola.
Code
# now, the function for all stocks
def df_idiovola(df_returns):
dfs = []
for column in df_returns.columns:
dfs.append(idiovola_single(df_returns[column], df_returns))
df_idiovola = pd.concat(dfs, axis=1).sort_index()
df_idiovola.columns = df_returns.columns
df_idiovola.drop('SP_Index', axis=1, inplace=True)
return df_idiovolaBerechnen wir nun die Zeitreihe täglicher rollierender IVOLs für unser S&P500 Anlageuniversum für ein gleitendes Zeitfenster von 250 Handelstagen.
| ABT | ABBV | ABMD | ACN | ATVI | ADBE | AMD | AAP | AES | AMG | ... | WLTW | WYNN | XEL | XRX | XLNX | XYL | YUM | ZBH | ZION | ZTS | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Date | |||||||||||||||||||||
| 2018-01-02 | 0.007889 | 0.010438 | 0.013948 | 0.007202 | 0.017943 | 0.012580 | 0.035296 | 0.024660 | 0.013050 | 0.010132 | ... | 0.007843 | 0.016110 | 0.006841 | 0.011884 | 0.012397 | 0.009113 | 0.007934 | 0.011642 | 0.012232 | 0.008758 |
| 2018-01-03 | 0.008003 | 0.010442 | 0.013915 | 0.007202 | 0.017936 | 0.012579 | 0.035424 | 0.024952 | 0.013032 | 0.010187 | ... | 0.008065 | 0.016213 | 0.006856 | 0.011713 | 0.012366 | 0.009132 | 0.007945 | 0.011709 | 0.012272 | 0.008780 |
| 2018-01-04 | 0.007992 | 0.010448 | 0.013912 | 0.007141 | 0.017909 | 0.012548 | 0.035490 | 0.024955 | 0.013011 | 0.010199 | ... | 0.008090 | 0.016244 | 0.006874 | 0.011722 | 0.012369 | 0.009119 | 0.007952 | 0.011701 | 0.012260 | 0.008777 |
| 2018-01-05 | 0.007869 | 0.010466 | 0.013927 | 0.007141 | 0.017939 | 0.012508 | 0.035581 | 0.025043 | 0.012829 | 0.010297 | ... | 0.008099 | 0.016241 | 0.006895 | 0.011673 | 0.012360 | 0.009090 | 0.007943 | 0.011704 | 0.012261 | 0.008778 |
| 2018-01-08 | 0.007874 | 0.010471 | 0.013879 | 0.007121 | 0.017956 | 0.012503 | 0.035621 | 0.025045 | 0.012749 | 0.010214 | ... | 0.008095 | 0.016237 | 0.006843 | 0.011673 | 0.012648 | 0.009114 | 0.007937 | 0.011616 | 0.012277 | 0.008785 |
5 rows × 502 columns
5.2.2 Implementierung 1: gleichgewichtete Dezil-Portfolios
Zunächst bringen wir die Aktien über die Methode rank(axis=1, pct=True) in eine Perzentil-Rankordnung gemäß der vergangenen IVOL. 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) IVOL in Gruppe 1 (10) sortiert. Äquivalent werden bei ascending=True die riskanten Aktien in Gruppe 10 und die risikoarmen Aktien in Gruppe 1 sortiert.
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!
Wir führen nun alles zusammen in der Funktion weights_dc.
Code
# 'df_returns' = DataFrame with security daily returns and factor returns
def weights_dc(df_returns):
stdev = df_idiovola(df_returns)
# Important: False = long in low IVOL stocks
rank_df = np.ceil(stdev.rank(axis=1, pct=True, ascending=True).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_dfZudem 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;
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. 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.
Dann berechnen wir die kumulativen Renditen jeder Aktie für die gewählte Portfoliohaltedauer. Hierzu verwenden wir die vorher definierte Lambda-Funktion 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.
Lassen Sie uns nun die obigen Schritte in der Strategie-Funktion strat_dc zusammenfassen. Wir benötigen ein DataFrame (df_returns) mit den Renditezeitreihen unseres Anlageuniversums und der gewünschten Risikofaktoren zur Berechnung der IVOL, und die Länge der Portfoliohaltedauer (hold).
Code
# strategy function
def strat_dc(df_returns, hold):
# calculation of security weights: (-1: short, 0: no, 1: long)
freq = '%dB' % hold # holding period
port = weights_dc(df_returns) # security weights at business day freq
# daily returns without index; adjustment if more than one index!
df_returns0 = df_returns.drop('SP_Index', axis=1)
# adjustment of frequency for returns and weight time series
temp0 = port.iloc[:, 0]
temp0.name = 'temp'
daily_rets = pd.merge(df_returns0, temp0, how='inner', on='Date').drop('temp', axis=1)
# 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 beispielhaft die Strategie als eine High-Risk Strategie (ascending=True) mit einer Halteperiode von 20 Handelstagen durch.
5.2.3 Implementierung 2: Gewichte nach Frazzini and Pedersen (2014)
Zur Implementierung der FP Gewichte erstellen wir das gewohnte (tägliche) DataFrame ranks mit den Perzentil-Rängen der Aktien gemäß ihrer IVOL ü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
def weights_fp(df_returns):
# weights according to Frazzini and Pedersen (2014), equation (16), p. 9
stdev = df_idiovola(df_returns)
# percentile ranks; "False"= long in stocks with low IVOL!
ranks = stdev.rank(ascending=True, axis=1, pct=True)
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
# strategie returns (main function)
def strat_fp(df_returns, hold):
# portfolio weights
freq = '%dB' % hold # holding period in number of business days
port = weights_fp(df_returns) # weights for each business day
# daily returns without index; adjustment if more than one index!
df_returns0 = df_returns.drop('SP_Index', axis=1)
# adjustment of frequency for returns and weight time series
temp0 = port.iloc[:, 0]
temp0.name = 'temp'
daily_rets = pd.merge(df_returns0, temp0, how='inner', on='Date').drop('temp', axis=1)
# strategy returns, shift(1) to adjust 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)
Führen wir beispielhaft die Strategie als eine High-Risk Strategie (ascending=True) mit einer Halteperiode von 20 Handelstagen durch.