Hide code cell content

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline

import pandas_datareader.data as web

def get_factors(factors='CAPM',freq='daily'):   
    
    if freq=='monthly':
        freq_label=''
    else:
        freq_label='_'+freq


    if factors=='CAPM':
        fama_french = web.DataReader("F-F_Research_Data_Factors"+freq_label, "famafrench",start="1921-01-01")
        daily_data = fama_french[0]
    
     
        df_factor = daily_data[['RF','Mkt-RF']] 
    elif factors=='FF3':
        fama_french = web.DataReader("F-F_Research_Data_Factors"+freq_label, "famafrench",start="1921-01-01")
        daily_data = fama_french[0]

        df_factor = daily_data[['RF','Mkt-RF','SMB','HML']]
    elif factors=='FF5':

        fama_french = web.DataReader("F-F_Research_Data_Factors"+freq_label, "famafrench",start="1921-01-01")
        daily_data = fama_french[0]

        df_factor = daily_data[['RF','Mkt-RF','SMB','HML']]
        fama_french2 = web.DataReader("F-F_Research_Data_5_Factors_2x3"+freq_label, "famafrench",start="1921-01-01")
        daily_data2 = fama_french2[0]

        df_factor2 = daily_data2[['RMW','CMA']]
        df_factor=df_factor.merge(df_factor2,on='Date',how='outer')    
        
    else:
        fama_french = web.DataReader("F-F_Research_Data_Factors"+freq_label, "famafrench",start="1921-01-01")
        daily_data = fama_french[0]

        df_factor = daily_data[['RF','Mkt-RF','SMB','HML']]
        fama_french2 = web.DataReader("F-F_Research_Data_5_Factors_2x3"+freq_label, "famafrench",start="1921-01-01")
        daily_data2 = fama_french2[0]

        df_factor2 = daily_data2[['RMW','CMA']]
        df_factor=df_factor.merge(df_factor2,on='Date',how='outer')   
        fama_french = web.DataReader("F-F_Momentum_Factor"+freq_label, "famafrench",start="1921-01-01")
        df_factor=df_factor.merge(fama_french[0],on='Date')
        df_factor.columns=['RF','Mkt-RF','SMB','HML','RMW','CMA','MOM']    
    if freq=='monthly':
        df_factor.index = pd.to_datetime(df_factor.index.to_timestamp())
    else:
        df_factor.index = pd.to_datetime(df_factor.index)
        


    return df_factor/100

10.2. Volatility Timing#

Recall our earlier formula

\[x_t=\frac{E_t[r_{t+1}-r^f_{t+1}]}{\gamma Var_t(r_{t+1})}\]

Before we had \(x_t=\frac{E_t[r_{t+1}-r^f_{t+1}]}{\gamma Var(r_{t+1})}\)

Now we will look at the other extreme \(x_t=\frac{E[r_{t+1}-r^f_{t+1}]}{\gamma Var_t(r_{t+1})}\)

(note the subscripts!!! )

Important

The crucial necessary condition for this to work is that there is no time-series relationship between alphas and vols (or at the very least that this relationship is weak enough)

\[cov(\alpha_{i,t},\sigma^2_{i,t})=0\]
  • This is key. Might or might not be true depending on asset/strategy.

  • But tends to be true.

  • This only works if the alpha/premium is positive to begin with

  • If Sharpe ratio is zero, you can’t increase it by managing risk!

  • This is also true for market timing! You can see that if variance and expected returns moved tightly together you would never trade!

  • Note that both things can happen at the same time, as long they don’t move exactly together you can time both!

One can think of minimum-variance investing in the time-series–just like if you believed expected are constant across assets

For any trading strategy the volatility timing portfolio is \(w_i=\frac{\alpha_i}{\sigma_{i,t}^2}\)

Note

If Alphas are time-varying but Sharpe ratios are constant, i.e. \(\alpha_{i,t}=k\sigma_{i,t}\), then the optimal allocation becomes proportion to inverse volatility \(w_i=\frac{\alpha_i}{\sigma_{i,t}}\).

10.3. Volatility Timing the market#

ALAN MOREIRA, TYLER MUIR, Volatility-Managed Portfolios, Journal of Finance, August 2017 https://doi.org/10.1111/jofi.12513

Show that this logic works for the market and many other factors.

Since then a huge literature has emerged showing that volatility managing is a great way to boost Sharpe Ratios

The construct portfolios

\[x_t=c\frac{E[r^e_{t+1}]}{Var_t(r^e_{t+1})}\]

The key is to construct a good measure for \( Var_t(r^e_{t+1})\)

  • We will do something simple and use past vol as a proxy for future vol

  • Of course whether is a good proxy or not depends of how it plays out in the data

  • But we already saw that for the market past vol dos a really good job

  • Using daily data for month t, construct the market return “realized variance” during month t like we did in our estimation sample lecture

    \[rv_t=\sum_{d \in days~ in ~month ~t}\frac{(r_d- \overline{r})^2}{N_{days}},\]

where \(\overline{r}\) is the average return within the month

We will need daily data to do this

df_factor = get_factors()
df_factor.head()
c:\Users\alan.moreira\Anaconda3\lib\site-packages\pandas_datareader\famafrench.py:114: FutureWarning: The argument 'date_parser' is deprecated and will be removed in a future version. Please use 'date_format' instead, or read your data in as 'object' dtype and then call 'to_datetime'.
  df = read_csv(StringIO("Date" + src[start:]), **params)
RF Mkt-RF
Date
1926-07-01 0.00009 0.0009
1926-07-02 0.00009 0.0045
1926-07-06 0.00009 0.0017
1926-07-07 0.00009 0.0009
1926-07-08 0.00009 0.0022

Constructing monthly realized variance from daily data

You basically use pandas time series function that shifts all dates to the end of the month, so this way you are technically grouping by the end of the month day. We did this before!

We then will do the same with our daily returns to get monthly returns

RV=df_factor[['Mkt-RF']].groupby(df_factor.index+pd.offsets.MonthEnd(0)).var()*252

RV=RV.rename(columns={'Mkt-RF':'RV'})

# aggregate daily returns to monthly returns
Ret=(1+df_factor).groupby(df_factor.index+pd.offsets.MonthEnd(0)).prod()-1
# Merge Ret (monthly return) with RV (realized variance and weights)
df=RV.merge(Ret,how='left',left_index=True,right_index=True)
# construct excess returns

# lag RV by one month
df['RV_lag']=df['RV'].shift(1)

df.mean()
RV        0.028825
RF        0.002691
Mkt-RF    0.006896
RV_lag    0.028841
dtype: float64

10.3.1. Intuition#

  • Before build the strategy it is useful to see WHY it works

  • For Vol timing to payoff we need that

    • when vol goes up the risk-return trade-off goes down

    • When vol goes dow the risk-return trade-off goes up

\[Cov(RV_t,\frac{r_{t+1}-r^f_{t+1}}{RV_{t+1}})<0\]

One way to see if that is true in the data is to sort all months by past vol in buckets

  • In bucket 1 go the really low vol months

  • In bucket 5 the really high vol months

We then look at how vol and returns were the month after–i.e. we group the month vol and the month return by past vol!

# Create quantile groups based on the RV variable

# pd.qcut splits the 'RV_lag' column into 5 quantile-based groups,
# meaning they group the data into 5 equal-sized bins based on the values of 'RV_lag'.
# ensuring that each group has approximately the same number of observations.
df['Quantile'] = pd.qcut(df['RV_lag'], q=5)

# Group by quantile and calculate the mean for each column
quantile_means = df.groupby('Quantile').mean()
# label the index according the volatility of the highest vol month in the quantile
quantile_means.index = quantile_means.index.map(lambda x: round((x.right)**0.5, 3))

# compute the price of risk--i.e. the ratio of the mean excess return to the mean realized variance
# across the months in each quantile
# importantly these are for the future months, i.e. the mean excess return  and variance for month t+1
# while we used the variance for month t
quantile_means ['priceofrisk']=quantile_means['Mkt-RF']*12/(quantile_means['RV'])


# Display the mean of quantiles for each column

fig, axs = plt.subplots(3, 1, figsize=(10, 12), sharex=True)
((quantile_means.RV*12)**0.5).plot(kind='bar', ax=axs[0])
axs[0].set_title('Realized Variance (RV(t+1)) by RV(t) Quantile ')
axs[0].set_ylabel('Vol(t+1)')

(quantile_means['Mkt-RF']*12).plot(kind='bar', ax=axs[1])
axs[1].set_title('Market Excess Return (t+1) by RV(t) Quantile')
axs[1].set_ylabel('Mkt-RF')

quantile_means.priceofrisk.plot(kind='bar', ax=axs[2])
axs[2].set_title('Price of Risk by Quantile by RV(t) Quantile')
axs[2].set_ylabel('Price of Risk')
axs[2].set_xlabel('Quantile')

plt.tight_layout()
plt.show()
C:\Users\alan.moreira\AppData\Local\Temp\ipykernel_29908\2575752801.py:9: FutureWarning: The default of observed=False is deprecated and will be changed to True in a future version of pandas. Pass observed=False to retain current behavior or observed=True to adopt the future default and silence this warning.
  quantile_means = df.groupby('Quantile').mean()
../../_images/e638c9af9c0c2fc877ca6e3f5c7f276dfe215e583406d51727064c321f2343ac.png

We see that following months of high variance–on the right– you don’t really get higher returns

if you focus on the ratio – the price of risk

\[E[\frac{r^e_{t+1}}{RV_{t+1}(r^e)}|RV_t]\]

you see that it is massively decreasing with variance

Average returns are not constant–but are close to–

so the natural strategy to exploit this pattern is to lever up when vol is low and reduce exposure when vol is high.

10.3.2. From signal to weights#

Specifically we buy the market at the closing price of month t according to the rule:

\[w_t=\frac{c}{rv_t},\]

where \(c\) is some constant.

If you want to be very formal you could set \(c=\frac{E[r^{MKT}_{t+1}-r^f_{t+1}]}{\gamma}\)

But this only controls you average position in the market–and doesn’t say anything about timing

  • Hold the position for a month

  • The returns of the strategy are given by

\[ r^{VolTiming}_{t+1}=r^f_{t+1}+\frac{c}{rv_t}(r^{MKT}_{t+1}-r^f_{t+1})\]
  • \(c\) controls how levered is the strategy on average.

  • Here lets keep it simple and simply choose it so that the position on the market is 1 on average

\[E[x_t]=E[c\frac{1}{rv_t}]=1\]

implies \(c=\frac{1}{E[\frac{1}{rv_t}]}\)

df['Weight']=1/df.RV_lag
c=1/df['Weight'].mean()
df['Weight']=c*df['Weight']
df.Weight.plot()
df.Weight.mean()
1.0
../../_images/9a1e603b49789ceb92eeb5f5e457f960a23ff224f605d04579156bf6b6b920d7.png

You see that leverage gets really high.

As high as 10!

You can see that in your position in the risk-free rate which exactly mirrors that

# plot the weights on the risk-free rate
(1-df.Weight).plot()
plt.show()
../../_images/4a7ea2f1769688145f0ce2929c5566f4c323585a13e25459d391674ce4afef95.png

10.3.3. Construct strategy returns#

  • Now to construct the strategy return recall that we use the realized variance in month t to buy the market at the closing of month t and earn the return accrued in month t+1

  • I will call the strategy as \(\textbf{VMS}\) (Volatility Managed Strategy)

# now construct the return of the strategy
df['VMS']=df.Weight*df['Mkt-RF']

We can see the cumulative returns of the market and the volatility managed strategy

df[['Mkt-RF','VMS']].add(df['RF']+1,axis=0).cumprod().plot(logy=True)
<Axes: xlabel='Date'>
../../_images/8e16e0a5a3719c00ee9aafcdff7678ff7c2433be1bc84bcb21300e1158f6d699.png

Does it work or not?

How to evaluate?

What should you look at?

Things to try

  • How well it works with VIX instead of RV?

  • What If we use a forecasting model to predict variance?

  • How well it works with standard deviation instead of variance

  • How well it works if we put leverage limits

  • How well it works if we combine an expected return signal with the volatility signal?

  • What about the other factors? Does it work there as well?

  • Before copying and pasting this code 20 times, simply put it all together in a function

  • As you try different things adjust the function to be more flexible

Below is the basic vol managed strategy

It returns the time-series of the strategy returns, but you should all the outcomes you are interest on

def volmanaged(df):
    factor=df.name
    df=df.dropna()
    endofmonth=df.index+MonthEnd(0)
    Signal=df.groupby(endofmonth).apply(lambda x: ((x-x.mean())**2).sum())
    Signal=1/Signal.shift(1)
    Signal.name='signal'
    Ret=(1+df).groupby(endofmonth).prod()-1
 
    df=pd.DataFrame(Signal).merge(Ret,how='left',left_index=True,right_index=True)
    df['Weight']=df.signal
    df['Weight']=df['Weight']/df['Weight'].mean()
    df[factor+'_volmanaged']=df.Weight*df[factor]

    return df


df_factor = get_factors('FF6')
volmanaged(df_factor['MOM'])    
c:\Users\alan.moreira\Anaconda3\lib\site-packages\pandas_datareader\famafrench.py:114: FutureWarning: The argument 'date_parser' is deprecated and will be removed in a future version. Please use 'date_format' instead, or read your data in as 'object' dtype and then call 'to_datetime'.
  df = read_csv(StringIO("Date" + src[start:]), **params)
c:\Users\alan.moreira\Anaconda3\lib\site-packages\pandas_datareader\famafrench.py:114: FutureWarning: The argument 'date_parser' is deprecated and will be removed in a future version. Please use 'date_format' instead, or read your data in as 'object' dtype and then call 'to_datetime'.
  df = read_csv(StringIO("Date" + src[start:]), **params)
c:\Users\alan.moreira\Anaconda3\lib\site-packages\pandas_datareader\famafrench.py:114: FutureWarning: The argument 'date_parser' is deprecated and will be removed in a future version. Please use 'date_format' instead, or read your data in as 'object' dtype and then call 'to_datetime'.
  df = read_csv(StringIO("Date" + src[start:]), **params)
(MOM               0.462232
 MOM_volmanaged    0.906813
 dtype: float64,
 MOM              -0.159845
 MOM_volmanaged   -0.085163
 Name: 0.005, dtype: float64,
                  signal       MOM    Weight  MOM_volmanaged
 Date                                                       
 1926-11-30          NaN  0.014683       NaN             NaN
 1926-12-31  2277.092450  0.002114  0.589074        0.001245
 1927-01-31  2662.927643  0.035187  0.688888        0.024240
 1927-02-28  2568.855606  0.000668  0.664552        0.000444
 1927-03-31  3769.446919  0.045621  0.975140        0.044487
 ...                 ...       ...       ...             ...
 2024-08-31   599.322929  0.048827  0.155042        0.007570
 2024-09-30  1049.077765 -0.000230  0.271392       -0.000062
 2024-10-31  1164.418175  0.036818  0.301230        0.011091
 2024-11-30  1582.413470  0.005364  0.409363        0.002196
 2024-12-31   908.504923  0.000585  0.235026        0.000138
 
 [1178 rows x 4 columns])