Notebook

Researching & Developing a Market Neutral Strategy¶

The process involves the following steps:

  • Researching partner data.
  • Designing a pipeline.
  • Analyzing an alpha factor with Alphalens.
  • Implementing our factor in the IDE (see backtest in next comment).
  • Evaluating the backtest using Pyfolio.

Part 1 - Investigate the Data with Blaze¶

One way to get a leg up when researching a trading strategy is to look for alpha in datasets that might be used less often than pricing data. The data sets that receive the most attention are the least likely to have much signal left over. We believe that incorporating non-pricing datasets into your models is one of the single biggest improvements you can make towards finding trading signals. Rather than trying to incorporate the signals raw into a model, the best approach is to develop a hypothesis of how the data might be used to forecast returns. Towards that end let's show an example workflow that uses Blaze on a partner dataset

To start out, let's investigate a partner dataset using Blaze. Blaze allows you to define expressions for selecting and transforming data without loading all of the data into memory. This makes it a nice tool for interacting with large amounts of data in research.

In [1]:
import matplotlib.pyplot as plt
import pandas as pd

# http://blaze.readthedocs.io/en/latest/index.html
import blaze as bz

from zipline.utils.tradingcalendar import get_trading_days

from quantopian.interactive.data.alpha_vertex import precog_top_500 as dataset

Interactive datasets are Blaze expressions. Blaze expressions have a similar API to pandas, with some differences.

In [2]:
type(dataset)
Out[2]:
<class 'blaze.expr.expressions.Field'>

Let's start by looking at a sample of data from the Alpha Vertex PreCog dataset for AAPL. PreCog is a machine learning model that incorporates hundreds of data points covering the economy, company financial performance market data, and investor sentiment to generate its outlooks.

In [3]:
aapl_sid = symbols('AAPL').sid

# Look at a sample of AAPL sentiment data starting from 2013-12-01.
dataset[(dataset.sid == aapl_sid) & (dataset.asof_date >= '2016-01-01')].peek()
Out[3]:
symbol name sid predicted_five_day_log_return asof_date timestamp
0 AAPL APPLE INC 24 -0.017 2016-01-04 2016-01-05
1 AAPL APPLE INC 24 -0.013 2016-01-05 2016-01-06
2 AAPL APPLE INC 24 -0.018 2016-01-06 2016-01-07
3 AAPL APPLE INC 24 -0.027 2016-01-07 2016-01-08
4 AAPL APPLE INC 24 -0.025 2016-01-08 2016-01-09
5 AAPL APPLE INC 24 -0.014 2016-01-11 2016-01-12
6 AAPL APPLE INC 24 -0.022 2016-01-12 2016-01-13
7 AAPL APPLE INC 24 0.000 2016-01-13 2016-01-14
8 AAPL APPLE INC 24 0.027 2016-01-14 2016-01-15
9 AAPL APPLE INC 24 0.018 2016-01-15 2016-01-16
10 AAPL APPLE INC 24 0.013 2016-01-19 2016-01-20

Let's see how many securities are covered by this dataset since January 2016.

In [4]:
num_sids = bz.compute(dataset.sid.distinct().count())
print 'Number of sids in the data: %d' % num_sids
Number of sids in the data: 619

Let's go back to AAPL and let's look at the signal each day. To do this, we can create a Blaze expression that selects trading days and another for the AAPL sid (24).

In [5]:
# Mask for AAPL.
stock_mask = (dataset.sid == aapl_sid)

# Blaze expression for AAPL sentiment on trading days between 12/2013 and 12/2014
av_expr = dataset[stock_mask & (dataset.asof_date >= '2016-01-01')].sort('asof_date')

Compute the expression. This returns the result in a pandas DataFrame.

In [6]:
av_df = bz.compute(av_expr)

Plot the PreCog signal for AAPL.

In [7]:
av_df.plot(x='asof_date', y='predicted_five_day_log_return')
Out[7]:
<matplotlib.axes._subplots.AxesSubplot at 0x7fd60ee8ac10>

Great! Now let's use this data in a pipeline.

Part 2 - Define Our Hypothesis¶

Now that we have a dataset that we want to use, let's use it in a pipeline. In addition to the PreCog dataset, we will also use the EventVestor Earnings Calendar dataset to avoid trading around earnings announcements, and the EventVestor Mergers & Acquisitions dataset to avoid trading acquisition targets. We will work with the free versions of these datasets.

Specifically, let's build a pipeline that ranks stocks by the prediction they received from the PreCog model. Let's also add a filter where we only consider stocks in the Q1500US that have had the daily direction of their prediction (positive or negative) correct at least 8 out of the last 15 trading days (above 50%).

This should leave us with a large basket of stocks to trade, which is good for both risk management and capacity considerations.

In [8]:
from quantopian.pipeline import Pipeline, CustomFactor
from quantopian.research import run_pipeline

from quantopian.pipeline.factors import SimpleMovingAverage, RollingLinearRegressionOfReturns
from quantopian.pipeline.filters.morningstar import Q1500US

from quantopian.pipeline.classifiers.morningstar import Sector

# Sentdex Sentiment free from 15 Oct 2012 to 1 month ago.
from quantopian.pipeline.data.sentdex import sentiment
from quantopian.pipeline.data.builtin import USEquityPricing
from quantopian.pipeline.data.alpha_vertex import precog_top_500 as precog

# EventVestor Earnings Calendar free from 01 Feb 2007 to 1 year ago.
from quantopian.pipeline.factors.eventvestor import (
    BusinessDaysUntilNextEarnings,
    BusinessDaysSincePreviousEarnings,
)

# EventVestor Mergers & Acquisitions free from 01 Feb 2007 to 1 year ago.
from quantopian.pipeline.filters.eventvestor import IsAnnouncedAcqTarget

from quantopian.pipeline.factors import BusinessDaysSincePreviousEvent

import numpy as np
import pandas as pd
In [9]:
class PredictionQuality(CustomFactor):
    """
    Create a customized factor to calculate the prediction quality
    for each stock in the universe.
    
    Compares the percentage of predictions with the correct sign 
    over a rolling window (3 weeks) for each stock.
   
    """

    # data used to create custom factor
    inputs = [precog.predicted_five_day_log_return, USEquityPricing.close]

    # change this to what you want
    window_length = 15

    def compute(self, today, assets, out, pred_ret, px_close):

        log_ret5 = np.log(px_close) - np.log(np.roll(px_close, 5, axis=0))

        log_ret5 = log_ret5[5:]
        n = len(log_ret5)
        
        # predicted returns
        pred_ret = pred_ret[:n]

        # number of predictions with incorrect sign
        err = np.absolute((np.sign(log_ret5) - np.sign(pred_ret)))/2.0

        # custom quality measure
        pred_quality = (1 - pd.DataFrame(err).ewm(min_periods=n, com=n).mean()).iloc[-1].values
        
        out[:] = pred_quality
In [10]:
def make_pipeline():
    """
    Dynamically apply the custom factors defined below to 
    select candidate stocks from the PreCog universe 
    
    """
    
    pred_quality_thresh      = 0.5
    
     # Filter for stocks that are not within 2 days of an earnings announcement.
    not_near_earnings_announcement = ~((BusinessDaysUntilNextEarnings() <= 2)
                                | (BusinessDaysSincePreviousEarnings() <= 2))
    
    # Filter for stocks that are announced acquisition target.
    not_announced_acq_target = ~IsAnnouncedAcqTarget()
    
    # Our universe is made up of stocks that have a non-null sentiment & precog signal that was 
    # updated in the last day, are not within 2 days of an earnings announcement, are not announced 
    # acquisition targets, and are in the Q1500US.
    universe = (
        Q1500US() 
        & precog.predicted_five_day_log_return.latest.notnull()
        & not_near_earnings_announcement
        & not_announced_acq_target
    )
 
    # Prediction quality factor.
    prediction_quality = PredictionQuality(mask=universe)
    
    # Filter for stocks above the threshold quality.
    quality= prediction_quality > pred_quality_thresh

    latest_prediction = precog.predicted_five_day_log_return.latest
    
    non_outliers = latest_prediction.percentile_between(1,99, mask=quality)
    normalized_return = latest_prediction.zscore(mask=non_outliers)
    
    normalized_prediction_rank = normalized_return.rank()

    ## create pipeline
    columns = {
        'av_rank': normalized_prediction_rank,
    }
    pipe = Pipeline(columns=columns, screen=universe)
 
    return pipe
In [11]:
result = run_pipeline(make_pipeline(), start_date='2015-02-01', end_date='2017-03-01')
In [12]:
result.head()
Out[12]:
av_rank
2015-02-02 00:00:00+00:00 Equity(2 [ARNC]) 91.0
Equity(24 [AAPL]) 200.0
Equity(67 [ADSK]) 124.0
Equity(76 [TAP]) 46.0
Equity(114 [ADBE]) NaN

Part 3 - Test Our Hypothesis Using Alphalens¶

Now we can analyze our av_rank factor with Alphalens. To do this, we need to get pricing data using get_pricing.

In [13]:
# All assets that were returned in the pipeline result.
assets = result.index.levels[1].unique()

# We need to get a little more pricing data than the length of our factor so we 
# can compare forward returns. We'll tack on another month in this example.
pricing = get_pricing(assets, start_date='2015-02-01', end_date='2017-04-01', fields='open_price')

Then we run a factor tearsheet on our factor. We will analyze 3 quantiles, looking at 1, 5, and 10-day lookahead periods.

If you are interested in learning more about factor tearsheets and how to analyze them, check out the Factor Analysis lecture in the Quantopian lecture series.

In [14]:
import alphalens

factor_data = alphalens.utils.get_clean_factor_and_forward_returns(
    factor=result.av_rank, 
    prices=pricing,
    quantiles=5,
)

alphalens.tears.create_full_tear_sheet(
    factor_data,
)
Quantiles Statistics
min max mean std count count %
factor_quantile
1 1.0 74.0 24.396894 15.176296 23631 20.175880
2 16.0 148.0 71.951556 21.596404 23326 19.915475
3 30.0 222.0 119.179928 30.777963 23326 19.915475
4 44.0 296.0 166.434708 40.868666 23326 19.915475
5 58.0 370.0 213.762672 51.353281 23516 20.077695
Returns Analysis
1 5 10
Ann. alpha 0.098 0.023 -0.012
beta -0.024 -0.037 0.050
Mean Period Wise Return Top Quantile (bps) 5.819 9.146 -4.390
Mean Period Wise Return Bottom Quantile (bps) -3.998 -3.201 4.904
Mean Period Wise Spread (bps) 8.924 2.496 -0.160
/usr/local/lib/python2.7/dist-packages/alphalens/plotting.py:727: FutureWarning: pd.rolling_apply is deprecated for Series and will be removed in a future version, replace with 
	Series.rolling(center=False,min_periods=1,window=5).apply(args=<tuple>,func=<function>,kwargs=<dict>)
  min_periods=1, args=(period,))
/usr/local/lib/python2.7/dist-packages/alphalens/plotting.py:767: FutureWarning: pd.rolling_apply is deprecated for DataFrame and will be removed in a future version, replace with 
	DataFrame.rolling(center=False,min_periods=1,window=5).apply(args=<tuple>,func=<function>,kwargs=<dict>)
  min_periods=1, args=(period,))
/usr/local/lib/python2.7/dist-packages/alphalens/plotting.py:727: FutureWarning: pd.rolling_apply is deprecated for Series and will be removed in a future version, replace with 
	Series.rolling(center=False,min_periods=1,window=10).apply(args=<tuple>,func=<function>,kwargs=<dict>)
  min_periods=1, args=(period,))
/usr/local/lib/python2.7/dist-packages/alphalens/plotting.py:767: FutureWarning: pd.rolling_apply is deprecated for DataFrame and will be removed in a future version, replace with 
	DataFrame.rolling(center=False,min_periods=1,window=10).apply(args=<tuple>,func=<function>,kwargs=<dict>)
  min_periods=1, args=(period,))
/usr/local/lib/python2.7/dist-packages/alphalens/plotting.py:519: FutureWarning: pd.rolling_mean is deprecated for Series and will be removed in a future version, replace with 
	Series.rolling(window=22,center=False).mean()
  pd.rolling_mean(mean_returns_spread_bps, 22).plot(color='orangered',
Information Analysis
1 5 10
IC Mean 0.026 0.017 -0.005
IC Std. 0.123 0.139 0.139
t-stat(IC) 4.750 2.739 -0.863
p-value(IC) 0.000 0.006 0.388
IC Skew -0.104 0.052 0.134
IC Kurtosis 0.186 0.567 0.357
Ann. IR 3.294 1.899 -0.599
/usr/local/lib/python2.7/dist-packages/alphalens/plotting.py:215: FutureWarning: pd.rolling_mean is deprecated for Series and will be removed in a future version, replace with 
	Series.rolling(window=22,center=False).mean()
  pd.rolling_mean(ic, 22).plot(ax=a,
Turnover Analysis
1 5 10
Quantile 1 Mean Turnover 0.481 0.830 0.907
Quantile 2 Mean Turnover 0.664 0.868 0.911
Quantile 3 Mean Turnover 0.696 0.865 0.899
Quantile 4 Mean Turnover 0.667 0.871 0.911
Quantile 5 Mean Turnover 0.483 0.820 0.902
1 5 10
Mean Factor Rank Autocorrelation 0.7 0.148 -0.063
<matplotlib.figure.Figure at 0x7fd5f63f0090>

From this it looks like there's a relationship between the top quantile of our factor and positive returns as well as the bottom quantile and negative returns over a 1 day horizon.

Let's try to capitalize on this by implementing a strategy that opens long positions in the top quantile of stocks and short positions in the bottom quantile of stocks. Let's invest half of our portfolio long and half short, and equally weight our positions in each direction.

Before moving to the IDE, let's make some small changes to the pipeline we defined earlier. This will make it easier to order stocks based on quantile.

In [15]:
def make_pipeline():
    """
    Dynamically apply the custom factors defined below to 
    select candidate stocks from the PreCog universe 
    
    """
    
    pred_quality_thresh      = 0.5
    
     # Filter for stocks that are not within 2 days of an earnings announcement.
    not_near_earnings_announcement = ~((BusinessDaysUntilNextEarnings() <= 2)
                                | (BusinessDaysSincePreviousEarnings() <= 2))
    
    # Filter for stocks that are announced acquisition target.
    not_announced_acq_target = ~IsAnnouncedAcqTarget()
    
    # Our universe is made up of stocks that have a non-null sentiment & precog signal that was 
    # updated in the last day, are not within 2 days of an earnings announcement, are not announced 
    # acquisition targets, and are in the Q1500US.
    universe = (
        Q1500US() 
        & precog.predicted_five_day_log_return.latest.notnull()
        & not_near_earnings_announcement
        & not_announced_acq_target
    )
 
    # Prediction quality factor.
    prediction_quality = PredictionQuality(mask=universe)
    
    # Filter for stocks above the threshold quality.
    quality= prediction_quality > pred_quality_thresh

    latest_prediction = precog.predicted_five_day_log_return.latest
    
    non_outliers = latest_prediction.percentile_between(1,99, mask=quality)
    normalized_return = latest_prediction.zscore(mask=non_outliers)
    
    normalized_prediction_rank = normalized_return.rank()
    
    prediction_rank_quantiles = normalized_prediction_rank.quantiles(5)
    
    longs = prediction_rank_quantiles.eq(4)
    shorts = prediction_rank_quantiles.eq(0)
    
    # We will take market beta into consideration when placing orders in our algorithm.
    beta = RollingLinearRegressionOfReturns(
                    target=symbols('SPY'),
                    returns_length=5,
                    regression_length=260,
                    mask=(longs | shorts)
    ).beta
    
    # We will actually be using the beta computed using Bloomberg's computation.
    # Ref: https://www.lib.uwo.ca/business/betasbydatabasebloombergdefinitionofbeta.html
    bb_beta = (0.66 * beta) + (0.33 * 1.0)

    ## create pipeline
    columns = {
        'longs': longs,
        'shorts': shorts,
        'market_beta': bb_beta,
        'sector': Sector(),
    }
    pipe = Pipeline(columns=columns, screen=(longs | shorts))
 
    return pipe
In [16]:
df = run_pipeline(make_pipeline(), '2015-05-05', '2015-05-05')

Part 4 - Implement and Backtest the Strategy in the IDE.¶

Now that we have a good looking model, we can backtest it. Backtesting is a good check to see how the model survives real world conditions like slippage and commissions.

The backtests for this strategy is linked below in the forum post.

Part 5 - Analyze Our Backtest Using Pyfolio¶

Let's load our backtest result and run it through a tearsheet using pyfolio.

In [17]:
bt = get_backtest('5947e4e609c7d969f9c2a62a')
100% Time: 0:02:38|###########################################################|

Some key takeaways from the tearsheet:

  • Long and short exposure are equal.
  • Beta is 0.
  • Holdings per day is > 500.
  • Maximum position concentration is low (< 4%).
  • Trades daily.
  • The strategy does not perform as well in 2017. We should wait for out-of-sample data before we put much weight on our evaluation.
In [18]:
bt.create_full_tear_sheet()
Entire data start date: 2014-01-02
Entire data end date: 2017-06-02


Backtest Months: 41
Performance statistics Backtest
annual_return 0.10
cum_returns_final 0.37
annual_volatility 0.04
sharpe_ratio 2.50
calmar_ratio 2.51
stability_of_timeseries 0.93
max_drawdown -0.04
omega_ratio 1.53
sortino_ratio 3.96
skew 0.15
kurtosis 3.67
tail_ratio 1.16
common_sense_ratio 1.27
gross_leverage 1.00
information_ratio -0.01
alpha 0.10
beta -0.02
Worst drawdown periods net drawdown in % peak date valley date recovery date duration
0 3.88 2015-07-22 2015-11-16 2015-12-23 111
1 3.31 2016-10-10 2017-03-02 NaT NaN
2 2.17 2016-02-11 2016-02-19 2016-03-15 24
3 1.36 2016-04-11 2016-04-29 2016-06-21 52
4 1.33 2016-06-23 2016-06-27 2016-07-18 18

[-0.004 -0.01 ]
/usr/local/lib/python2.7/dist-packages/numpy/lib/function_base.py:3834: RuntimeWarning: Invalid value encountered in percentile
  RuntimeWarning)
Stress Events mean min max
Apr14 0.06% -0.39% 0.49%
Oct14 0.13% -0.55% 0.67%
Fall2015 0.03% -0.51% 0.36%
New Normal 0.04% -1.02% 1.57%
Top 10 long positions of all time max
ANF-15622 9.76%
TSLA-39840 8.87%
ADS-22747 8.33%
DVA-22110 8.14%
CNP-24064 7.38%
DGX-16348 7.33%
PCL-5813 7.13%
X-8329 6.66%
REGN-6413 6.52%
AMZN-16841 6.49%
Top 10 short positions of all time max
TSO-7612 -10.09%
IBM-3766 -9.89%
FBHS-41928 -9.01%
CCE-1332 -8.17%
EXPE-27543 -7.95%
VRTX-8045 -7.19%
MAS-4665 -6.85%
UPS-20940 -6.73%
APA-448 -6.69%
CPGX-49141 -6.59%
Top 10 positions of all time max
TSO-7612 10.09%
IBM-3766 9.89%
ANF-15622 9.76%
FBHS-41928 9.01%
TSLA-39840 8.87%
ADS-22747 8.33%
CCE-1332 8.17%
DVA-22110 8.14%
EXPE-27543 7.95%
CNP-24064 7.38%
All positions ever held max
TSO-7612 10.09%
IBM-3766 9.89%
ANF-15622 9.76%
FBHS-41928 9.01%
TSLA-39840 8.87%
ADS-22747 8.33%
CCE-1332 8.17%
DVA-22110 8.14%
EXPE-27543 7.95%
CNP-24064 7.38%
DGX-16348 7.33%
VRTX-8045 7.19%
PCL-5813 7.13%
MAS-4665 6.85%
UPS-20940 6.73%
APA-448 6.69%
X-8329 6.66%
CPGX-49141 6.59%
REGN-6413 6.52%
KSU-4315 6.49%
AMZN-16841 6.49%
S-2938 5.92%
SRE-24778 5.89%
STX-24518 5.56%
CSCO-1900 5.49%
EA-2602 5.47%
YUM-17787 5.41%
DNR-15789 5.37%
AEE-24783 5.34%
RCL-8863 5.32%
... ...
APD-460 1.62%
ES-5484 1.61%
MMM-4922 1.61%
AVY-663 1.60%
DTV-26111 1.60%
AIV-11598 1.60%
ALTR-328 1.59%
AWK-36098 1.59%
SCG-6701 1.58%
CMCS_A-1637 1.56%
HD-3496 1.56%
EIX-14372 1.56%
XOM-8347 1.54%
PFE-5923 1.51%
FRX-3014 1.50%
PNC-6068 1.47%
DNB-2237 1.46%
MRK-5029 1.43%
TMK-7488 1.40%
BBT-16850 1.37%
LM-4488 1.35%
CLR-33856 1.34%
AEP-161 1.31%
CMS-1665 1.31%
PBCT-5769 1.29%
LNT-18584 1.28%
Q-44692 1.26%
COV-34010 1.22%
MGM-4831 1.19%
AMTD-16586 1.15%

554 rows × 1 columns

  • Long and short exposure are equal.
  • Beta is 0.
  • Holdings per day is > 500.
  • Maximum position concentration is low (< 4%).
  • Trades daily.
  • The strategy does not perform as well in 2017. We should wait for out-of-sample data before we put much weight on our evaluation.
  • We can look to improve this strategy by incorporating other alpha signals and combining them.