How to Implement Momentum Factor Strategies Using Skip Month in Python
A skip-month momentum factor strategy ranks assets by their 12-month returns excluding the most recent month to avoid micro-structure noise and short-term reversal effects, then constructs a long-short portfolio by going long the top performers and shorting the bottom performers.
Momentum factor strategies exploit the tendency of winning assets to continue outperforming losing assets over intermediate time horizons. In the Awesome Systematic Trading repository, the skip-month implementation is demonstrated through QuantConnect-compatible Python algorithms that calculate formation-period returns while explicitly excluding the most recent month's data to mitigate micro-structure bias.
Why Skip the Most Recent Month?
Empirical research shows that the most recent month often contains short-term reversal effects and market micro-structure noise that can distort momentum signals. By skipping the latest month (sometimes called "skip-1-12" formation), you measure momentum from months 2 through 13, avoiding the short-term mean reversion that typically occurs in month 1. This approach is standard in academic finance and systematic trading implementations.
Core Implementation Architecture
The repository demonstrates this pattern in static/strategies/momentum-factor-effect-in-stocks.py, which implements a 12-month momentum factor with a one-month skip using a rolling window and scheduled selection logic.
Universe Selection
The algorithm first defines a manageable universe of liquid stocks. In the source file, this is configured to select the top 500 stocks by market capitalization:
self.coarse_count = 500
self.AddUniverse(self.CoarseSelectionFunction, self.FineSelectionFunction)
This universe selection runs daily, but the actual momentum calculation and rebalancing occur only on specific scheduled dates.
Rolling Window Setup
To capture price history while maintaining computational efficiency, the algorithm stores daily adjusted prices in a RollingWindow with a capacity of 252 trading days (12 months × 21 trading days):
self.period = 12 * 21
self.data[symbol] = RollingWindow[float](self.period)
This rolling window automatically discards the oldest price point when new data arrives, keeping exactly one year of history available for momentum calculations.
The Skip-Month Scheduling Mechanism
The critical component that enforces the skip-month logic is the monthly scheduling rule. The algorithm triggers selection on the first trading day of each month:
self.Schedule.On(self.DateRules.MonthStart(symbol), self.TimeRules.AfterMarketOpen(symbol), self.Selection)
Because the selection runs on MonthStart, the rolling window contains prices only through the end of the previous month. The data from the just-finished month has not yet been added to the window, effectively excluding the most recent month from the calculation without explicit filtering code.
Momentum Calculation
Within the FineSelectionFunction, the algorithm computes cumulative returns using the oldest and newest elements in the rolling window:
perf = {x.Symbol : self.data[x.Symbol][0] / self.data[x.Symbol][self.period-1] - 1 for x in fine}
Here, self.data[x.Symbol][0] represents the most recent price in the window (end of last month), while [self.period-1] represents the price 12 months prior. Since the window excludes the current month's data due to the MonthStart scheduling, this calculation naturally implements the 12-month skip-1 formation period.
Portfolio Construction and Execution
The algorithm ranks assets by their momentum scores and splits them into quantiles:
- Long leg: Top-performing stocks (highest momentum)
- Short leg: Bottom-performing stocks (lowest momentum)
Equal weights are assigned to each position. The execution logic in OnData checks for a selection flag, liquidates positions not in the new basket, and rebalances using SetHoldings:
def OnData(self, data):
if self.selection_flag:
self.selection_flag = False
# Liquidate positions not in new basket
for holding in self.Portfolio.Values:
if holding.Invested and holding.Symbol not in self.long + self.short:
self.Liquidate(holding.Symbol)
# Rebalance to new weights
for symbol in self.long + self.short:
self.SetHoldings(symbol, self.weight)
Standalone Python Implementation
You can implement the same skip-month logic outside of QuantConnect using pandas and yfinance. This example calculates 12-month momentum skipping the most recent month:
import yfinance as yf
import pandas as pd
def fetch_price(ticker, start, end):
"""Download daily adjusted close prices."""
return yf.download(ticker, start=start, end=end)['Adj Close']
def momentum_skip_month(prices, lookback_months=12, skip_months=1):
"""
Compute momentum as the cumulative return over the last `lookback_months`
but skip the most recent `skip_months`.
"""
# Convert calendar months to trading days (~21 per month)
window = (lookback_months + skip_months) * 21
if len(prices) < window:
raise ValueError("Not enough price data")
# Select the window that ends one month before the last observation
recent_end = -skip_months * 21 # exclude recent month
start_idx = recent_end - lookback_months * 21
# Cumulative return over the look-back period
return prices.iloc[recent_end] / prices.iloc[start_idx] - 1
# Example usage:
price_series = fetch_price('SPY', '2010-01-01', '2024-01-01')
mom = momentum_skip_month(price_series)
print(f"Momentum (12-month skip-1) = {mom:.2%}")
Alternative Skip-Month Implementations
The Awesome Systematic Trading repository contains several variations of this pattern:
static/strategies/consistent-momentum-strategy.py: Implements a six-month momentum strategy with an explicit one-month gap between formation and holding periods.static/strategies/time-series-momentum-effect.py: Demonstrates time-series momentum (long-only based on positive past returns) where the skip-month concept is implicit via monthly rebalancing rules.static/strategies/momentum-and-reversal-combined-with-volatility-effect-in-stocks.py: Combines momentum with reversal and volatility effects, including logic that explicitly skips recent months to avoid micro-structure bias.
Summary
- Schedule monthly: Use
MonthStartrules to ensure your calculation window naturally excludes the most recent month's data. - Use rolling windows: Maintain a 12×21 day (252 trading day) rolling window to capture formation-period prices without memory bloat.
- Calculate correctly: Compute returns from
window[0](most recent in window) towindow[period-1](12 months prior), which automatically skips the latest month. - Quantile rank: Split the universe into top and bottom deciles or quintiles for the long and short legs.
- Rebalance monthly: Execute trades immediately after selection to capture the momentum premium while minimizing unnecessary turnover.
Frequently Asked Questions
Why is the skip month important in momentum strategies?
The most recent month typically exhibits short-term reversal patterns and market micro-structure noise that can contaminate the momentum signal. By skipping this month, you measure the persistent trend over months 2-13 rather than capturing temporary price distortions that tend to reverse quickly.
How does the QuantConnect implementation automatically skip the latest month?
The algorithm schedules selection logic using DateRules.MonthStart, which fires on the first trading day of the month. Since the rolling window is populated via OnData (which runs daily), the window at the time of selection contains data only through the end of the previous month. The current month's data points have not yet been added, effectively implementing the skip without explicit date arithmetic.
What is the optimal lookback period for skip-month momentum?
The repository's primary implementation uses a 12-month formation period (252 trading days) with a 1-month skip, which aligns with academic research on the "12-1" momentum factor. However, consistent-momentum-strategy.py demonstrates that 6-month formation periods can also be effective, particularly when combined with volatility filters.
Can I implement skip-month momentum without QuantConnect?
Yes. The standalone Python example using pandas and yfinance demonstrates how to manually slice price arrays to exclude the most recent 21 trading days (approximately one month) when calculating returns. The key is ensuring your return calculation ends at current_index - 21 rather than the latest available price.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →