US Equity Momentum Selection with Smoothness and Quarterly Rebalancing
Summary
This strategy selects a portfolio of NYSE stocks through liquidity, momentum, and return consistency screens. It excludes financial firms, ADRs, and REITs, then ranks eligible stocks by average dollar volume over 90 sessions. From that liquid subset, it selects the strongest performers over roughly 12 months while skipping the latest month, and keeps the half with the most positive days during the lookback. Selected stocks receive equal weights.
The portfolio is reset on a fiscal quarter schedule tied to November, February, May, and August, with positions entered after the signal. The document describes the method and its implementation, but supplies no performance results, benchmark comparison, or evidence that the proposed window dressing timing improves returns. Its rules also depend on historical price and volume data and require realistic assessment of trading costs, turnover, and survivorship or universe effects before drawing conclusions.
Key ideas
- The strategy begins with NYSE stocks and removes financial firms, ADRs, and REITs.
- It filters for liquidity using average dollar volume before ranking momentum.
- Momentum is measured over 12 months with the most recent month excluded.
- A positive-day count favors stocks whose gains were more consistent during the lookback.
- The portfolio equal-weights selections and refreshes them on a fiscal quarter schedule.
Tags
Full text
# QuantitativeMomentum
# QuantitativeMomentum
Momentum strategy modeled on Alpha Architect's QMOM ETF.
Strategy rules:
1. Universe selection
a. Starting universe: all NYSE stocks
b. Exclude financials, ADRs, REITs
c. Liquidity screen: select top N percent of stocks by dollar
volume (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
## Source (Apache-2.0)
```python
# Copyright QuantRocket LLC - All Rights Reserved
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import pandas as pd
from moonshot import Moonshot
from moonshot.commission import PerShareCommission
class USStockCommission(PerShareCommission):
BROKER_COMMISSION_PER_SHARE = 0.005
class QuantitativeMomentum(Moonshot):
"""
Momentum strategy modeled on Alpha Architect's QMOM ETF.
Strategy rules:
1. Universe selection
a. Starting universe: all NYSE stocks
b. Exclude financials, ADRs, REITs
c. Liquidity screen: select top N percent of stocks by dollar
volume (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
"""
CODE = "qmom"
DB = "sharadar-us-stk-1d"
DB_FIELDS = ["Close", "Volume"]
DOLLAR_VOLUME_TOP_N_PCT = 60
DOLLAR_VOLUME_WINDOW = 90
UNIVERSES = "nyse-stk"
EXCLUDE_UNIVERSES = ["nyse-financials", "nyse-adrs", "nyse-reits"]
MOMENTUM_WINDOW = 252
MOMENTUM_EXCLUDE_MOST_RECENT_WINDOW = 22
TOP_N_PCT = 10
SMOOTHEST_TOP_N_PCT = 50
REBALANCE_INTERVAL = "Q-NOV" # = end of quarter, fiscal year ends in Nov (= Nov 30, Feb 28, May 31, Aug 31); https://pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html#anchored-offsets
COMMISSION_CLASS = USStockCommission
def prices_to_signals(self, prices: pd.DataFrame):
# Step 1.c: get a mask of stocks with adequate dollar volume
closes = prices.loc["Close"]
volumes = prices.loc["Volume"]
avg_dollar_volumes = (closes * volumes).rolling(self.DOLLAR_VOLUME_WINDOW).mean()
dollar_volume_ranks = avg_dollar_volumes.rank(axis=1, ascending=False, pct=True)
have_adequate_dollar_volumes = dollar_volume_ranks <= (self.DOLLAR_VOLUME_TOP_N_PCT/100)
# Step 2: apply momentum screen
year_ago_closes = closes.shift(self.MOMENTUM_WINDOW)
month_ago_closes = closes.shift(self.MOMENTUM_EXCLUDE_MOST_RECENT_WINDOW)
returns = (month_ago_closes - year_ago_closes) / year_ago_closes.where(year_ago_closes != 0) # avoid DivisionByZero errors
# Rank only among stocks with adequate dollar volume
returns_ranks = returns.where(have_adequate_dollar_volumes).rank(axis=1, ascending=False, pct=True)
have_momentum = returns_ranks <= (self.TOP_N_PCT / 100)
# Step 3: Filter by smoothness of momentum
are_positive_days = closes.pct_change() > 0
positive_days_last_twelve_months = are_positive_days.astype(int).rolling(self.MOMENTUM_WINDOW).sum()
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 <= (self.SMOOTHEST_TOP_N_PCT/100)
signals = have_smooth_momentum.astype(int)
return signals
def signals_to_target_weights(self, signals: pd.DataFrame, prices: pd.DataFrame):
# Step 4: equal weights
daily_signal_counts = signals.abs().sum(axis=1)
weights = signals.div(daily_signal_counts, axis=0).fillna(0)
# Step 5: Rebalance portfolio before quarter-end to capture window-dressing seasonality effect
# Resample daily to REBALANCE_INTERVAL, 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(self.REBALANCE_INTERVAL).last()
# Reindex back to daily and fill forward
weights = weights.reindex(prices.loc["Close"].index, method="ffill")
return weights
def target_weights_to_positions(self, weights: pd.DataFrame, prices: pd.DataFrame):
# Enter the position the day after the signal
return weights.shift()
def positions_to_gross_returns(self, positions: pd.DataFrame, prices: pd.DataFrame):
closes = prices.loc["Close"]
position_ends = positions.shift()
# The return is the security's percent change over the period,
# multiplied by the position.
gross_returns = closes.pct_change() * position_ends
return gross_returns
def order_stubs_to_orders(self, orders: pd.DataFrame, prices: pd.DataFrame):
orders["Exchange"] = "SMART"
orders["OrderType"] = "MOC"
orders["Tif"] = "DAY"
return orders
```Shown in full with attribution under the source's licence. Licence: Apache-2.0
This summary was written by Stratmill's research agent from the original; it is not a copy of the source.