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:
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.
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"])
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.
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()
Sid | FIBBG0000018G2 | FIBBG000001J87 | FIBBG000001JC2 | FIBBG000001JD1 | FIBBG000001NT5 | FIBBG000001NV2 | FIBBG000001SF9 | FIBBG000002791 | FIBBG0000027B8 | FIBBG000002WJ5 | ... | QA000000001978 | QA000000001981 | QA000000001995 | QA000000014708 | QA000000014977 | QA000000017129 | QA000000018169 | QA000000020127 | QA000000021599 | QA000000021660 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Date | |||||||||||||||||||||
2015-12-24 | False | False | False | False | False | False | False | False | False | False | ... | False | False | False | False | False | False | False | False | False | True |
2015-12-28 | False | False | False | False | False | False | False | False | False | False | ... | False | False | False | False | False | False | False | False | False | True |
2015-12-29 | False | False | False | False | False | False | False | False | False | False | ... | False | False | False | False | False | False | False | False | False | True |
2015-12-30 | False | False | False | False | False | False | False | False | False | False | ... | False | False | False | False | False | False | False | False | False | True |
2015-12-31 | False | False | False | False | False | False | False | False | False | False | ... | False | False | False | False | False | False | False | False | False | True |
5 rows × 1600 columns
We'll use this filter in the next step.
Next, we identify the 10% of stocks with the strongest 12-month momentum, excluding the most recent month. First calculate the returns:
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:
returns_ranks = returns.where(have_adequate_dollar_volumes).rank(axis=1, ascending=False, pct=True)
have_momentum = returns_ranks <= 0.10
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:
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:
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:
long_signals = have_smooth_momentum.astype(int)
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:
daily_signal_counts = long_signals.abs().sum(axis=1)
daily_signal_counts.tail()
Date 2015-12-24 39 2015-12-28 42 2015-12-29 40 2015-12-30 43 2015-12-31 44 dtype: int64
weights = long_signals.div(daily_signal_counts, axis=0).fillna(0)
weights.where(weights!=0).stack().tail()
Date Sid 2015-12-31 FIBBG0027Y18M0 0.022727 FIBBG002832GV8 0.022727 FIBBG002WMH2F2 0.022727 FIBBG00449JPX5 0.022727 FIBBG00KXRCDP0 0.022727 dtype: float64
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:
import pandas as pd
pd.date_range(start="2018-01-01", freq="Q-NOV", periods=4)
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:
# 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")
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:
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.
position_ends = positions.shift()
gross_returns = closes.pct_change() * position_ends