Notebook

# An updated method to analyze alpha factors¶

by Thomas Wiecki, Quantopian, 2019.

We recently released a great alphalens tutorial. While that represents the perfect introduction for analyzing factors, we are also constantly evolving our thinking and analyses. In this post, I want to give people an updated but less polished way of analyzing factors. In addition, this notebook contains some updated thoughts on what constitutes a good factor and tips on how to build it that we have not shared before. Thus, if you want to increase your chances of scoring well in the contest or getting an allocation, I think this is a good resource to study.

While these new analyses use alphalens functionality they do go beyond it. At the same time, they are much more succinct, so if before you refrained from using alphalens because it seemed daunting, check out these few plots which hopefully give you a sense of what we look for in a factor. At some point in the future, we will probably add this functionality to alphalens as well.

In [1]:
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns

import empyrical as ep
import alphalens as al
import pyfolio as pf

from quantopian.pipeline.data import EquityPricing
from quantopian.research.experimental import get_factor_returns, get_factor_loadings

In [2]:
bt = get_backtest('5debf9ce078ce14c28056e55') #Fin Chall - 2019-12-07 - bt2

100% Time:  0:00:35|##########################################################|

In [3]:
results = bt.pyfolio_positions.drop('cash', axis=1)
results.columns = results.columns.str.split('-').to_series().apply(lambda x: int(x[1]))
results = results.div(results.abs().sum(axis=1), axis=0)
results = results.stack().dropna()

In [4]:
#ajjc: Validate Total Returns
bt.daily_performance.pnl.cumsum().plot()
st=bt.daily_performance.ending_portfolio_value[0]
ed=bt.daily_performance.ending_portfolio_value[-1]
pct_tot=100.0*((ed-st)/st)
print ("TotReturnsPct={:,.2f}%".format(pct_tot))
#bt.pyfolio_positions.drop('cash', axis=1)
#print(results)

TotReturnsPct=18.91%


Load pricing data:

In [5]:
start = results.index.levels[0][0]
end = results.index.levels[0][-1] + pd.Timedelta(days=30)

end_to_today = end - pd.Timestamp.today(tz='UTC')
# If date goes into the future, cut it
if end_to_today.days > 2:
end = pd.Timestamp.today(tz='UTC') - pd.offsets.BDay() * 2
end = end.normalize()

In [6]:
assets = results.index.levels[1]
pricing = get_pricing(assets, start, end, fields="close_price")
stock_rets = pricing.pct_change()

# Load risk factor loadings and returns
factor_loadings = get_factor_loadings(assets, start, end ) #+ pd.Timedelta(days=30))
factor_returns = get_factor_returns(start, end) # + pd.Timedelta(days=30))
# Fix a bug in the risk returns
factor_returns.loc[factor_returns.value.idxmax(), 'value'] = 0

/venvs/py27/local/lib/python2.7/site-packages/pandas/core/indexing.py:132: SettingWithCopyWarning:
A value is trying to be set on a copy of a slice from a DataFrame

See the caveats in the documentation: http://pandas.pydata.org/pandas-docs/stable/indexing.html#indexing-view-versus-copy
self._setitem_with_indexer(indexer, value)
/venvs/py27/lib/python2.7/site-packages/ipykernel_launcher.py:9: SettingWithCopyWarning:
A value is trying to be set on a copy of a slice from a DataFrame

See the caveats in the documentation: http://pandas.pydata.org/pandas-docs/stable/indexing.html#indexing-view-versus-copy
if __name__ == '__main__':

In [7]:
#factor_returns

In [8]:
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns

import empyrical as ep
import alphalens as al
import pyfolio as pf

def calc_perf_attrib(portfolio_returns, portfolio_pos, factor_returns, factor_loadings):
import empyrical as ep
start = portfolio_returns.index[0]
end = portfolio_returns.index[-1]
factor_loadings.index = factor_loadings.index.set_names(['dt', 'ticker'])
portfolio_pos.index = portfolio_pos.index.set_names(['dt'])

portfolio_pos = portfolio_pos.drop('cash', axis=1)
portfolio_pos.columns.name = 'ticker'
portfolio_pos.columns = portfolio_pos.columns.astype('int')

return ep.perf_attrib(
portfolio_returns,
portfolio_pos.stack().dropna(),
factor_returns.loc[start:end],
factor_loadings.loc[start:end])

def plot_exposures(risk_exposures, ax=None):
rep = risk_exposures.stack().reset_index()
rep.columns = ['dt', 'factor', 'exposure']
sns.boxplot(x='exposure', y='factor', data=rep, orient='h', ax=ax, order=risk_exposures.columns[::-1])

def compute_turnover(df):
return df.dropna().unstack().dropna(how='all').fillna(0).diff().abs().sum(1)

def get_max_median_position_concentration(expos):
longs = expos.loc[expos > 0]
shorts = expos.loc[expos < 0]

return expos.groupby(level=0).quantile([.05, .25, .5, .75, .95]).unstack()
#     alloc_summary = pd.DataFrame()
#     alloc_summary['95_long'] = longs.perc(95, axis=1)
#     alloc_summary['75_long'] = longs.perc(75, axis=1)
#     alloc_summary['median_long'] = longs.median(axis=1)

#     alloc_summary['median_short'] = shorts.median(axis=1)
#     alloc_summary['25_short'] = shorts.perc(25, axis=1)
#     alloc_summary['5_short'] = shorts.perc(5, axis=1)

#     return alloc_summary

def compute_factor_stats(factor, pricing, factor_returns, factor_loadings, periods=range(1, 15), view=None):
factor_data_total = al.utils.get_clean_factor_and_forward_returns(
factor,
pricing,
quantiles=None,
bins=(-np.inf, 0, np.inf),
periods=periods,
cumulative_returns=False,
)

portfolio_returns_total = al.performance.factor_returns(factor_data_total)
portfolio_returns_total.columns = portfolio_returns_total.columns.map(lambda x: int(x[:-1]))
for i in portfolio_returns_total.columns:
portfolio_returns_total[i] = portfolio_returns_total[i].shift(i)
#portfolio_returns_specific = al.performance.factor_returns(factor_data_specific)
#portfolio_returns_specific.columns = portfolio_returns_specific.columns.map(lambda x: int(x[:-1]))
#for i in portfolio_returns_specific.columns:
#    portfolio_returns_specific[i] = portfolio_returns_specific[i].shift(i)

portfolio_returns_specific = pd.DataFrame(columns=portfolio_returns_total.columns, index=portfolio_returns_total.index)

# closure
def calc_perf_attrib_c(i, portfolio_returns_total=portfolio_returns_total,
factor_data_total=factor_data_total, factor_returns=factor_returns,
factor_loadings=factor_loadings):
return calc_perf_attrib(portfolio_returns_total[i],
factor_data_total['factor'].unstack().assign(cash=0).shift(i),
factor_returns, factor_loadings)

if view is None:
perf_attrib = map(calc_perf_attrib_c, portfolio_returns_total.columns)
else:
perf_attrib = view.map_sync(calc_perf_attrib_c, portfolio_returns_total.columns)

for i, pa in enumerate(perf_attrib):
if i == 0:
risk_exposures_portfolio = pa[0]
perf_attribution = pa[1]
portfolio_returns_specific[i + 1] = pa[1]['specific_returns']

delay_sharpes_total = portfolio_returns_total.apply(ep.sharpe_ratio)
delay_sharpes_specific = portfolio_returns_specific.apply(ep.sharpe_ratio)

turnover = compute_turnover(factor)
n_holdings = factor.groupby(level=0).count()
perc_holdings = get_max_median_position_concentration(factor)

return {'factor_data_total': factor_data_total,
'portfolio_returns_total': portfolio_returns_total,
'portfolio_returns_specific': portfolio_returns_specific,
'risk_exposures_portfolio': risk_exposures_portfolio,
'perf_attribution': perf_attribution,
'delay_sharpes_total': delay_sharpes_total,
'delay_sharpes_specific': delay_sharpes_specific,
'turnover': turnover,
'n_holdings': n_holdings,
'perc_holdings': perc_holdings,
}

def plot_overview_tear_sheet(factor, pricing, factor_returns, factor_loadings, periods=range(1, 15), view=None):
fig = plt.figure(figsize=(16, 16))
gs = plt.GridSpec(5, 4)
ax1 = plt.subplot(gs[0:2, 0:2])

factor_stats = compute_factor_stats(factor, pricing, factor_returns, factor_loadings, periods=periods, view=view)

pd.DataFrame({'specific': factor_stats['delay_sharpes_specific'],
'total': factor_stats['delay_sharpes_total']}).plot.bar(ax=ax1)
ax1.set(xlabel='delay', ylabel='IR')

ax2a = plt.subplot(gs[0, 2:4])
delay_cum_rets_total = factor_stats['portfolio_returns_total'][list(range(1, 5))].apply(ep.cum_returns)
delay_cum_rets_total.plot(ax=ax2a)
ax2a.set(title='Total returns', ylabel='Cumulative returns')

ax2b = plt.subplot(gs[1, 2:4])
delay_cum_rets_specific = factor_stats['portfolio_returns_specific'][list(range(1, 5))].apply(ep.cum_returns)
delay_cum_rets_specific.plot(ax=ax2b)
ax2b.set(title='Specific returns', ylabel='Cumulative returns')

ax3 = plt.subplot(gs[2:4, 0:2])
plot_exposures(factor_stats['risk_exposures_portfolio'].reindex(columns=factor_stats['perf_attribution'].columns),
ax=ax3)

ax4 = plt.subplot(gs[2:4, 2])
ep.cum_returns_final(factor_stats['perf_attribution']).plot.barh(ax=ax4)
ax4.set(xlabel='Cumulative returns')

ax5 = plt.subplot(gs[2:4, 3], sharey=ax4)
factor_stats['perf_attribution'].apply(ep.annual_volatility).plot.barh(ax=ax5)
ax5.set(xlabel='Ann. volatility')

ax6 = plt.subplot(gs[-1, 0:2])
factor_stats['n_holdings'].plot(color='b', ax=ax6)
ax6.set_ylabel('# holdings', color='b')
ax6.tick_params(axis='y', labelcolor='b')

ax62 = ax6.twinx()
factor_stats['turnover'].plot(color='r', ax=ax62)
ax62.set_ylabel('turnover', color='r')
ax62.tick_params(axis='y', labelcolor='r')

ax7 = plt.subplot(gs[-1, 2:4])
factor_stats['perc_holdings'].plot(ax=ax7)
ax7.set(ylabel='Long/short perc holdings')

gs.tight_layout(fig)

return fig, factor_stats

In [9]:
#pd.DataFrame(results).cumsum().plot()

In [10]:
factor_data_total = al.utils.get_clean_factor_and_forward_returns(
results,
pricing,
quantiles=None,
bins=(-np.inf, 0, np.inf),
periods=range(1, 15),
cumulative_returns=True,
)

#portfolio_returns_total = al.performance.factor_returns(factor_data_total)

#factor_stats = compute_factor_stats(results, pricing, factor_returns, factor_loadings, periods=range(1, 15), view=None)

#portfolio_returns_total = al.performance.factor_returns(factor_data_total)

Dropped 0.3% entries from factor data: 0.3% in forward returns computation and 0.0% in binning phase (set max_loss=0 to see potentially suppressed Exceptions).
max_loss is 35.0%, not exceeded: OK!

In [11]:
#factor_data_total.head()
#portfolio_returns_total.head()
#factor_stats['factor_data_total']
#portfolio_returns_total[['1D','2D','3D','4D']].cumsum().plot()
#factor_data_total[['1D','2D','3D','4D']].cumsum().plot()
###factor_data_total['factor'].cumsum().plot()

In [12]:
#factor_returns.apply(ep.cum_returns).plot()
#factor_stats['portfolio_returns_total'].apply(ep.cum_returns_final).plot()
#fs = factor_stats['portfolio_returns_total']
#fs

In [13]:
_, factor_stats = plot_overview_tear_sheet(results,
pricing,
factor_returns,
factor_loadings);

Dropped 0.3% entries from factor data: 0.3% in forward returns computation and 0.0% in binning phase (set max_loss=0 to see potentially suppressed Exceptions).
max_loss is 35.0%, not exceeded: OK!

In [14]:
#ajjc: Validate Performance Attribution:
bt.create_perf_attrib_tear_sheet()


## Performance Relative to Common Risk Factors¶

Summary Statistics
Annualized Specific Return 2.96%
Annualized Common Return 2.27%
Annualized Total Return 5.29%
Specific Sharpe Ratio 1.11
Exposures Summary Average Risk Factor Exposure Annualized Return Cumulative Return
basic_materials 0.00 0.03% 0.10%
consumer_cyclical 0.01 0.39% 1.32%
financial_services -0.03 -0.19% -0.62%
real_estate -0.02 0.68% 2.28%
consumer_defensive 0.00 0.00% 0.00%
health_care -0.00 -0.00% -0.00%
utilities 0.00 0.00% 0.00%
communication_services -0.00 -0.00% -0.00%
energy -0.00 -0.01% -0.04%
industrials 0.01 0.63% 2.14%
technology 0.02 0.30% 1.00%
momentum 0.05 0.16% 0.54%
size -0.01 0.15% 0.49%
value -0.08 0.13% 0.44%
short_term_reversal -0.08 -0.01% -0.03%
volatility 0.04 -0.03% -0.11%