Interactive Strategy Development

We'll develop our strategy logic interactively in a notebook, peeking at the DataFrames as we go, then transfer the code to a .py file for backtesting with Moonshot.

As a reminder, the rules of the QMOM strategy as outlined in the Alpha Architect white paper are:

  1. Universe selection
    1. Starting universe: all NYSE stocks
    2. Exclude financials, ADRs, REITs
    3. Liquidity screen: select top N percent of stocks by market cap (N=60)
  2. Apply momentum screen: calculate 12-month returns, excluding most recent month, and select N percent of stocks with best return (N=10)
  3. Filter by smoothness of momentum: of the momentum stocks, select the N percent with the smoothest momentum, as measured by the number of positive days in the last 12 months (N=50)
  4. Apply equal weights
  5. Rebalance portfolio before quarter-end to capture window-dressing seasonality effect

Query historical prices

Start by querying historical prices from your Sharadar history database. We specify our universe of NYSE stocks as well as the universes we wish to exclude.

For now we limit ourselves to a couple years of data to make it easier to work with. Later we'll run a backtest using a larger date range.

In [1]:
from quantrocket import get_prices

DB = "sharadar-us-stk-1d"
UNIVERSES = "nyse-stk"
EXCLUDE_UNIVERSES = ["nyse-financials", "nyse-reits", "nyse-adrs"]

prices = get_prices(DB, 
                    start_date="2014-01-01",
                    end_date="2016-01-01", 
                    universes=UNIVERSES,
                    exclude_universes=EXCLUDE_UNIVERSES, 
                    fields=["Close", "Volume"])

Step 1.C: Filter by dollar volume

The QMOM white paper calls for limiting the universe to the top 60% of stocks by market cap. We will use dollar volume as a proxy for market cap.

The code below will compute daily ranks by dollar volume and give us a boolean mask indicating which stocks have adequate dollar volume.

In [2]:
closes = prices.loc["Close"]
volumes = prices.loc["Volume"]

# calculate 90 day average dollar volume
avg_dollar_volumes = (closes * volumes).rolling(90).mean()

# rank biggest to smallest; pct=True gives percentile ranks between 0-1
dollar_volume_ranks = avg_dollar_volumes.rank(axis=1, ascending=False, pct=True)
        
have_adequate_dollar_volumes = dollar_volume_ranks <= (0.60)
have_adequate_dollar_volumes.tail()
Out[2]:
SidFIBBG0000018G2FIBBG000001J87FIBBG000001JC2FIBBG000001JD1FIBBG000001NT5FIBBG000001NV2FIBBG000001SF9FIBBG000002791FIBBG0000027B8FIBBG000002WJ5...QA000000001978QA000000001981QA000000001995QA000000014708QA000000014977QA000000017129QA000000018169QA000000020127QA000000021599QA000000021660
Date
2015-12-24FalseFalseFalseFalseFalseFalseFalseFalseFalseFalse...FalseFalseFalseFalseFalseFalseFalseFalseFalseTrue
2015-12-28FalseFalseFalseFalseFalseFalseFalseFalseFalseFalse...FalseFalseFalseFalseFalseFalseFalseFalseFalseTrue
2015-12-29FalseFalseFalseFalseFalseFalseFalseFalseFalseFalse...FalseFalseFalseFalseFalseFalseFalseFalseFalseTrue
2015-12-30FalseFalseFalseFalseFalseFalseFalseFalseFalseFalse...FalseFalseFalseFalseFalseFalseFalseFalseFalseTrue
2015-12-31FalseFalseFalseFalseFalseFalseFalseFalseFalseFalse...FalseFalseFalseFalseFalseFalseFalseFalseFalseTrue

5 rows × 1600 columns

We'll use this filter in the next step.

Step 2: Apply momentum screen

Next, we identify the 10% of stocks with the strongest 12-month momentum, excluding the most recent month. First calculate the returns:

In [3]:
TRADING_DAYS_PER_YEAR = 252
TRADING_DAYS_PER_MONTH = 22
year_ago_closes = closes.shift(TRADING_DAYS_PER_YEAR)
month_ago_closes = closes.shift(TRADING_DAYS_PER_MONTH)
returns = (month_ago_closes - year_ago_closes) / year_ago_closes.where(year_ago_closes != 0) # avoid DivisionByZero errors

We identify momentum stocks by ranking on returns, but we only apply the rankings to stocks with adequate dollar volume:

In [4]:
returns_ranks = returns.where(have_adequate_dollar_volumes).rank(axis=1, ascending=False, pct=True)
have_momentum = returns_ranks <= 0.10

Step 3: Filter by smoothness of momentum

The next step is to rank the momentum stocks by the smoothness of their momentum and select the top 50%. To calculate "smoothness," we count the number of days with a positive return over the last 12 months. The basic idea as explained in the white paper is that a stock which was mediocre for most of the year but made giant gains over a short period is not as appealing as a stock which rose more steadily over the course of the year.

First, get a rolling count of positive days in the last year:

In [5]:
are_positive_days = closes.pct_change() > 0
positive_days_last_twelve_months = are_positive_days.astype(int).rolling(TRADING_DAYS_PER_YEAR).sum()

Then, rank and filter to select the stocks with smoothest momentum:

In [6]:
positive_days_last_twelve_months_ranks = positive_days_last_twelve_months.where(have_momentum).rank(axis=1, ascending=False, pct=True)
have_smooth_momentum = positive_days_last_twelve_months_ranks <= 0.50

These stocks are our long signals:

In [7]:
long_signals = have_smooth_momentum.astype(int)

Step 4: Apply equal weights

The QMOM strategy trades an equal-weighted portfolio. By convention, for an unlevered strategy the daily weights should add up to 1 (=100% invested), so we divide each day's signals by the number of signals to get the individual position weights:

In [8]:
daily_signal_counts = long_signals.abs().sum(axis=1)
daily_signal_counts.tail()
Out[8]:
Date
2015-12-24    39
2015-12-28    42
2015-12-29    40
2015-12-30    43
2015-12-31    44
dtype: int64
In [9]:
weights = long_signals.div(daily_signal_counts, axis=0).fillna(0)
weights.where(weights!=0).stack().tail()
Out[9]:
Date        Sid           
2015-12-31  FIBBG0027Y18M0    0.022727
            FIBBG002832GV8    0.022727
            FIBBG002WMH2F2    0.022727
            FIBBG00449JPX5    0.022727
            FIBBG00KXRCDP0    0.022727
dtype: float64

Step 5: Rebalance before quarter-end

The Alpha Architect white paper outlines a technique to potentially enhance momentum returns by rebalancing the portfolio a month or so before quarter-end. The intention is to benefit from window dressing behavior by portfolio managers who bid up the strongest performing stocks in the last month of the quarter in order to include them in their quarterly statements.

To accomplish this with pandas, we can resample the DataFrame of daily weights to quarterly using the Q-NOV frequency. Q-NOV is a quarterly frequency with a fiscal year ending November 30. We can use pandas' date_range function to see some sample dates:

In [10]:
import pandas as pd
pd.date_range(start="2018-01-01", freq="Q-NOV", periods=4)
Out[10]:
DatetimeIndex(['2018-02-28', '2018-05-31', '2018-08-31', '2018-11-30'], dtype='datetime64[ns]', freq='Q-NOV')

Rebalancing on these dates will allow us to benefit from quarter-end window dressing. After resampling to Q-NOV, we take the last signal of the modified quarter, then reindex back to daily and fill forward:

In [11]:
# Resample daily to Q-NOV, taking the last day's signal
# For pandas offset aliases, see https://pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html#offset-aliases
weights = weights.resample("Q-NOV").last()

# Reindex back to daily and fill forward
weights = weights.reindex(closes.index, method="ffill")

Step 6: Positions and returns

The DataFrame of weights represents what we want to own, as calculated at the end of the day. Assuming we enter positions the next day, we simply shift the weights forward to simulate our positions:

In [12]:
positions = weights.shift()

To calculate the return (before costs), we multiply the security's percent change over the period by the size of the position.

Since positions represents when we enter the position, we must shift positions forward to get the "end" of the position, since that is when we collect the percent change, not when we first enter the position.

In [13]:
position_ends = positions.shift()
gross_returns = closes.pct_change() * position_ends

Next Up

Part 2: Moonshot Backtest