Momentum and Reversion Trading Signals Analysis

Adam
Call For Atlas
Published in
8 min readNov 1, 2023
MidJourney FinFluencer Impression 2023.11.1

In the world of quantitative trading, various strategies are employed to gain a competitive edge in financial markets. Most popular trading strategies involve momentum and trend — popular means they will not generate much alpha, unless you’re planning to sell these as youtube Financial courses.

Momentum and trend approach capitalizes on the idea that assets that have exhibited recent price strength will continue to do so in the near future, or the opposite in when there is weakness. There is a slight detail between trend and momentum worth noting:

  • Trend-following is directional absolute returns that looks at beta across the market.
  • Momentum is relative returns and market-neutral as it looks at a class or sector cross-section.

Reversion is the assumption that prices reverts back to a mean or means, over a period of time.

In this article, we will do an analysis of these strategies, and provide Python code implementations to study them yourself. We will build up to more advanced strategies and their analysis, see Pairs Trading.

We will keep to the latest post-pandemic market regime, therefore most stock prices will start in the 2021s. All strategies are long-only, and unleveraged.

Prepare Your Python

Have a jupyter environment ready, and pip install these libraries:

  • numpy
  • pandas
  • yfinance

Momentum & Trend Strategies

Let’s describe it with an example, say if an asset’s price has risen for the past week, it’s likely to continue upward. This approach capitalizes on the belief that the future mirrors the past, either in an upward or downward trend. As the WSB crowd used to say, stonks only go up.

While the strategy is easy to grasp and implement, it does have drawbacks:

  • Ignores market noise and special events, potentially smoothing out important fluctuations.
  • Transaction fees can accumulate due to frequent orders.
  • Everyone does it, there is little to no edge.

Let’s implement a few of these strategies.

Simple Moving Average (SMA) Crossover

The Moving Average Crossover strategy involves calculating two or more moving averages for an asset’s price: a short-term moving average (called a fast SMA) and a long-term moving average (slow SMA).

The code below implements this strategy:

def double_simple_moving_average_signals(ticker_ts_df, short_window=5, long_window=30):
"""
Generate trading signals based on a double simple moving average (SMA) strategy.
Parameters:
- aapl_ts_df (pandas.DataFrame): A DataFrame containing historical stock data.
- short_window (int): The window size for the short-term SMA.
- long_window (int): The window size for the long-term SMA.
Returns:
- signals (pandas.DataFrame): A DataFrame containing the trading signals.
"""
signals = pd.DataFrame(index=ticker_ts_df.index)
signals['signal'] = 0.0
signals['short_mavg'] = ticker_ts_df['Close'].rolling(window=short_window,
min_periods=1,
center=False).mean()
signals['long_mavg'] = ticker_ts_df['Close'].rolling(window=long_window,
min_periods=1,
center=False).mean()
# Generate signal when SMAs cross
signals['signal'] = np.where(
signals['short_mavg'] > signals['long_mavg'], 1, 0)
signals['orders'] = signals['signal'].diff()
signals.loc[signals['orders'] == 0, 'orders'] = None
return signals

This function takes a timeseries of any stock, and sets a short (fast SMA) and long (slow SMA) rolling windonw, and compares the 2 across the timeline. When the fast SMA is higher, a buy signal is generated (1), and when it’s lower, a sell signal is produced (-1).

We will create some utility functions to calculate our capital over the strategy’s timeline, and a graphing function to help us visualize the entries and exit signals against the stock’s timeseries.

def load_ticker_ts_df(ticker, start_date, end_date):
"""
Load and cache time series financial data from Yahoo Finance API.
Parameters:
- ticker (str): The stock ticker symbol (e.g., 'AAPL' for Apple Inc.).
- start_date (str): The start date in 'YYYY-MM-DD' format for data retrieval.
- end_date (str): The end date in 'YYYY-MM-DD' format for data retrieval.
Returns:
- df (pandas.DataFrame): A DataFrame containing the financial time series data."""
dir_path = './data'
cached_file_path = f'{dir_path}/{ticker}_{start_date}_{end_date}.pkl'
try:
if os.path.exists(cached_file_path):
df = pd.read_pickle(cached_file_path)
else:
df = yf.download(ticker, start=start_date, end=end_date)
if not os.path.exists(dir_path):
os.makedirs(dir_path)
df.to_pickle(cached_file_path)
except FileNotFoundError:
print(
f'Error downloading and caching or loading file with ticker: {ticker}')
return df

def calculate_profit(signals, prices):
"""
Calculate cumulative profit based on trading signals and stock prices.
Parameters:
- signals (pandas.DataFrame): A DataFrame containing trading signals (1 for buy, -1 for sell).
- prices (pandas.Series): A Series containing stock prices corresponding to the signal dates.
Returns:
- cum_profit (pandas.Series): A Series containing cumulative profit over time.
"""
profit = pd.DataFrame(index=prices.index)
profit['profit'] = 0.0
buys = signals[signals['orders'] == 1].index
sells = signals[signals['orders'] == -1].index
while sells[0] < buys[0]:
# These are long only strategies, we cannot start with sell
sells = sells[1:]
if len(buys) == 0 or len(sells) == 0:
# no actions taken
return profit
if len(sells) < len(buys):
# Assume we sell at the end
sells = sells.append(pd.Index(prices.tail(1).index))
buy_prices = prices.loc[buys]
sell_prices = prices.loc[sells]
profit.loc[sells, 'profit'] = sell_prices.values - buy_prices.values
profit['profit'] = profit['profit'].fillna(0)
# Make profit cumulative
profit['cum_profit'] = profit['profit'].cumsum()
return profit['cum_profit']

def plot_strategy(prices_df, signal_df, profit):
"""
Plot a trading strategy with buy and sell signals and cumulative profit.
Parameters:
- prices (pandas.Series): A Series containing stock prices.
- signals (pandas.DataFrame): A DataFrame with buy (1) and sell (-1) signals.
- profit (pandas.Series): A Series containing cumulative profit over time.
Returns:
- ax1 (matplotlib.axes.Axes): The top subplot displaying stock prices and signals.
- ax2 (matplotlib.axes.Axes): The bottom subplot displaying cumulative profit.
"""
fig, (ax1, ax2) = plt.subplots(2, 1, gridspec_kw={'height_ratios': (3, 1)},
figsize=(18, 12))
ax1.set_xlabel('Date')
ax1.set_ylabel('Price in $')
ax1.plot(prices_df.index, prices_df, color='g', lw=0.25)
# Plot the Buy and Sell signals
ax1.plot(signal_df.loc[signal_df.orders == 1.0].index,
prices_df[signal_df.orders == 1.0],
'^', markersize=12, color='blue', label='Buy')
ax1.plot(signal_df.loc[signal_df.orders == -1.0].index,
prices_df[signal_df.orders == -1.0],
'v', markersize=12, color='red', label='Sell')
ax2.plot(profit.index, profit, color='b')
ax2.set_ylabel('Cumulative Profit (%)')
ax2.set_xlabel('Date')
return ax1, ax2

Let’s run everything together:

aapl_ts_df = load_ticker_ts_df('AAPL',
start_date='2021-01-01',
end_date='2023-01-01')
signal_df = double_simple_moving_average_signals(aapl_ts_df, 5, 30)
profit_series = calculate_profit(signal_df, aapl_ts_df["Adj Close"])
ax1, ax2 = plot_strategy(aapl_ts_df["Adj Close"], signal_df, profit_series)

# Add short and long moving averages
ax1.plot(signal_df.index, signal_df['short_mavg'],
linestyle='--', label='Fast SMA')
ax1.plot(signal_df.index, signal_df['long_mavg'],
linestyle='--', label='Slow SMA')
ax1.legend(loc='upper left', fontsize=10)
plt.show()
MSA Crossover Plot

Throughout 2 years, this strategy gave us 30% return. Comparing it agianst the S&P 500’s latest return of 10% — that is quite a good strategy.

Naive Momentum

This strategy is based on the number of times a price increases or decreases. It assumes that when a price increases for a certain number of consecutive days, it’s a signal to buy, and when it decreases, it’s a signal to sell.

We willl reuse some of the utility functions from the SMA strategy above.

Here’s the code to implement a naive version of it:

def naive_momentum_signals(ticker_ts_df, nb_conseq_days=2):
"""
Generate naive momentum trading signals based on consecutive positive or negative price changes.
Parameters:
- ticker_ts_df (pandas.DataFrame): A DataFrame containing historical stock data.
- nb_conseq_days (int): The number of consecutive positive or negative days to trigger a signal.
Returns:
- signals (pandas.DataFrame): A DataFrame with 'orders' column containing buy (1) and sell (-1) signals.
"""
signals = pd.DataFrame(index=ticker_ts_df.index)
signals['orders'] = 0

price = ticker_ts_df['Adj Close']
price_diff = price.diff()
signal = 0
cons_day = 0
for i in range(1, len(ticker_ts_df)):
if price_diff[i] > 0:
cons_day = cons_day + 1 if price_diff[i] > 0 else 0
if cons_day == nb_conseq_days and signal != 1:
signals['orders'].iloc[i] = 1
signal = 1
elif price_diff[i] < 0:
cons_day = cons_day - 1 if price_diff[i] < 0 else 0
if cons_day == -nb_conseq_days and signal != -1:
signals['orders'].iloc[i] = -1
signal = -1
return signals

signal_df = naive_momentum_signals(aapl_ts_df)
profit_series = calculate_profit(signal_df, aapl_ts_df["Adj Close"])
ax1, _ = plot_strategy(aapl_ts_df["Adj Close"], signal_df, profit_series)
ax1.legend(loc='upper left', fontsize=10)
plt.show()
Trend Strategy plot

This strategy is a losing one, it didn’t give us any returns.

We would have done better inversing this model (just like you ought to do with most of WSB’s stock analysis). Hint to achieve this: signals[‘orders’] = signals[‘orders’] * -1

Granted, this strategy is usually based on the market in general rather than a single instrument, and is in a shorter timeframe.

Reversion Strategies

An example: Elon tweets that he will install blockchain in teslas, the market gets overzelous in its buying of Tesla stock.

The next day, everyone realizes that fundamentally nothing has changed, so the market loses interest and the price reverts back to an acceptable level.

Therefore, any instrument that divereges too fast from a benchmark in either direction, will eventually revert back to the benchmark in the longer timeframe.

Just like trends and momentum — this is a simple strategy, it smooths everything and is gernerally used by all market participants.

Mean Reversion

Here we assume that the price of a stock will stay in the vicinity of its mean.

Below is the signal code:

def mean_reversion_signals(ticker_ts_df, entry_threshold=1.0, exit_threshold=0.5):
"""
Generate mean reversion trading signals based on moving averages and thresholds.
Parameters:
- ticker_ts_df (pandas.DataFrame): A DataFrame containing historical stock data.
- entry_threshold (float): The entry threshold as a multiple of the standard deviation.
- exit_threshold (float): The exit threshold as a multiple of the standard deviation.
Returns:
- signals (pandas.DataFrame): A DataFrame with 'orders' column containing buy (1) and sell (-1) signals.
"""
signals = pd.DataFrame(index=ticker_ts_df.index)
signals['mean'] = ticker_ts_df['Adj Close'].rolling(
window=20).mean() # Adjust the window size as needed
signals['std'] = ticker_ts_df['Adj Close'].rolling(
window=20).std() # Adjust the window size as needed
signals['signal'] = np.where(ticker_ts_df['Adj Close'] > (
signals['mean'] + entry_threshold * signals['std']), 1, 0)
signals['signal'] = np.where(ticker_ts_df['Adj Close'] < (
signals['mean'] - exit_threshold * signals['std']), -1, 0)
signals['orders'] = signals['signal'].diff()
signals.loc[signals['orders'] == 0, 'orders'] = None

return signals

In this function, we find the standard deviation and the mean.

if the price diverges from its mean by a standard deviaion and a factor, it will generate a signal.

Let’s test it together with the functions we created:

signal_df = mean_reversion_signals(aapl_ts_df)
profit_series = calculate_profit(signal_df, aapl_ts_df["Adj Close"])
ax1, _ = plot_strategy(aapl_ts_df["Adj Close"], signal_df, profit_series)

ax1.plot(signal_df.index, signal_df['mean'], linestyle='--', label="Mean")
ax1.plot(signal_df.index, signal_df['mean'] +
signal_df['std'], linestyle='--', label="Ceiling STD")
ax1.plot(signal_df.index, signal_df['mean'] -
signal_df['std'], linestyle='--', label="Floor STD")
ax1.legend(loc='upper left', fontsize=10)
plt.show()
Mean Reversion Plot

10% on paper is not bad return, though in truth you would have done just as well using an S&P500 broad index.

In future articles, we will look at more advanced versions of reversion strategies, mainly: Pair-Trading and Statistical-Arbitrage. We will also look at strategies’ metrics, like the sharpe-ratio, which is why this strategy is weak, even though it returned the same as the S&P500.

Conclusion

We’ve studied simple stratgies of momentum and mean reversion trading strategies, and used python to make an analysis. Momentum strategies harness the power of trends to predict future price movements. On the other hand, mean reversion strategies are grounded in the idea that price or returns tend to revert to the mean after experiencing extreme movements.

In real portoflio trading, no single strategy guarantees success, unless you are shilling it on youtube as a finfluencer!

Your average youtube Finfluencer. MidJourney 2023.11.01

References

Github

Article here is also available on Github

Kaggle notebook available here

Media

All media used (in the form of code or images) are either solely owned by me, acquired through licensing, or part of the Public Domain and granted use through Creative Commons License.

CC Licensing and Use

This work is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License.

Made with ❤ by Adam

--

--

Adam
Call For Atlas

People, tech, and product focused senior technology leader with a penchant for AI in quant finance: https://www.linkedin.com/in/adam-darmanin/ #CallForAtlas