Monthly Up-Minus-Down Stock Momentum with a One-Month Skip
Summary
This strategy forms a long-short equity portfolio by ranking stocks on trailing returns, buying the strongest group and shorting the weakest. The ranking uses a twelve-month lookback while skipping the most recent month, a common way to reduce the influence of short-term reversal effects. Signals are refreshed monthly, and the portfolio assigns equal weights to active positions.
The implementation delays positions by one period and calculates gross returns using changes in opening prices, with positions shifted to reflect entry timing. A per-share commission class is included for the demo configuration. The code explains the mechanics but provides no backtest results, slippage model, or discussion of universe selection beyond its demo settings. Its equal-weight long-short construction can still carry market, sector, liquidity, and shorting risks, and the example should not be read as evidence of profitability.
Key ideas
- Stocks are ranked by trailing returns over a twelve-month window that omits the latest month.
- The highest-ranked group is bought and the lowest-ranked group is shorted.
- Signals and portfolio rebalancing occur monthly, with equal weights assigned to active positions.
- Positions enter after the signal, and returns are modeled from open-to-open price changes.
- The sample implementation includes per-share commissions but gives no performance or slippage results.
Tags
Full text
# umd.py
```py
# Copyright 2020-2024 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 UpMinusDown(Moonshot):
"""
Strategy that buys recent winners and sells recent losers.
Specifically:
- rank stocks by their performance over the past MOMENTUM_WINDOW days
- ignore very recent performance by excluding the last RANKING_PERIOD_GAP
days from the ranking window (as commonly recommended for UMD)
- buy the TOP_N_PCT percent of highest performing stocks and short the TOP_N_PCT
percent of lowest performing stocks
- rebalance the portfolio according to REBALANCE_INTERVAL
"""
CODE = "umd"
MOMENTUM_WINDOW = 252 # rank by twelve-month returns
RANKING_PERIOD_GAP = 22 # but exclude most recent 1 month performance
TOP_N_PCT = 50 # Buy/sell the top/bottom 50%
REBALANCE_INTERVAL = "M" # M = monthly; see https://pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html#offset-aliases
def prices_to_signals(self, prices: pd.DataFrame):
"""
This method receives a DataFrame of prices and should return a
DataFrame of integer signals, where 1=long, -1=short, and 0=cash.
"""
closes = prices.loc["Close"]
# Calculate the returns
returns = closes.shift(self.RANKING_PERIOD_GAP)/closes.shift(self.MOMENTUM_WINDOW) - 1
# Rank the best and worst
top_ranks = returns.rank(axis=1, ascending=False, pct=True)
bottom_ranks = returns.rank(axis=1, ascending=True, pct=True)
top_n_pct = self.TOP_N_PCT / 100
# Get long and short signals and convert to 1, 0, -1
longs = (top_ranks <= top_n_pct)
shorts = (bottom_ranks <= top_n_pct)
longs = longs.astype(int)
shorts = -shorts.astype(int)
# Combine long and short signals
signals = longs.where(longs == 1, shorts)
# Resample using the rebalancing interval.
# Keep only the last signal of the month, then fill it forward
signals = signals.resample(self.REBALANCE_INTERVAL).last()
signals = signals.reindex(closes.index, method="ffill")
return signals
def signals_to_target_weights(self, signals: pd.DataFrame, prices: pd.DataFrame):
"""
This method receives a DataFrame of integer signals (-1, 0, 1) and
should return a DataFrame indicating how much capital to allocate to
the signals, expressed as a percentage of the total capital allocated
to the strategy (for example, -0.25, 0, 0.1 to indicate 25% short,
cash, 10% long).
"""
weights = self.allocate_equal_weights(signals)
return weights
def target_weights_to_positions(self, weights: pd.DataFrame, prices: pd.DataFrame):
"""
This method receives a DataFrame of allocations and should return a
DataFrame of positions. This allows for modeling the delay between
when the signal occurs and when the position is entered, and can also
be used to model non-fills.
"""
# Enter the position in the period/day after the signal
return weights.shift()
def positions_to_gross_returns(self, positions: pd.DataFrame, prices: pd.DataFrame):
"""
This method receives a DataFrame of positions and a DataFrame of
prices, and should return a DataFrame of percentage returns before
commissions and slippage.
"""
# We'll enter on the open, so our return is today's open to
# tomorrow's open
opens = prices.loc["Open"]
# The return is the security's percent change over the period,
# multiplied by the position.
gross_returns = opens.pct_change() * positions.shift()
return gross_returns
class USStockCommission(PerShareCommission):
BROKER_COMMISSION_PER_SHARE = 0.005
class UpMinusDownDemo(UpMinusDown):
CODE = "umd-demo"
DB = "usstock-free-1d"
UNIVERSES = "usstock-free"
TOP_N_PCT = 50
COMMISSION_CLASS = USStockCommission
```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.