Classic trend following indicators rely on moving averages, standard deviations, and other statistical measures to identify trends and get in on a trade early.

A standard breakout or cross-over doesn’t tell you how strong the trend is.

Should we treat all moving average cross-overs the same way? Or is there a way to measure the strength of the trend and enter accordingly?

Some systems use different concepts to allocate scarce capital to stronger trends. So rather than jumping in immediately on a breakout, these systems seek to reduce their losses by waiting for a stronger trend to emerge first.

An easy way to achieve that is with the Moving Average Distance (MAD) indicator.

This is simply a ratio of a short and long moving average. If the value moves beyond a pre-defined threshold, then we go long or short. If it drops within the neutral range, we exit the position.

Let's get into how this works.

MAD Breakdown

The MAD is just a ratio of two moving averages. As shown in this paper the authors like 21 and 200-day moving averages.

Here's the formula:

$$MAD_t = \frac{\frac{1}{N}∑_{t-n}^N P_t}{\frac{1}{M}∑_{t-m}^M P_t}$$


where N is our fast moving average period and M is our slow moving average (N < M).

It's straightforward to implement this in Python too:

import pandas as pd

def calcMAD(price: pd.Series, fast_ma: int=21, slow_ma: int=200) -> pd.Series:
  return price.rolling(fast_ma).mean() / price.rolling(slow_ma).mean()

Generating MAD Trading Signals

With our calcMAD function ready to go, we can apply it to a price series to generate signals.

First, let's import some other packages and grab some data.

import numpy as np
import yfinance as yf
import matplotlib.pyplot as plt

ticker = "META"
data = yf.Ticker(ticker).history(start="2015-01-01", end="2022-12-31")
# Drop unnecessary columns
data.drop(["Open", "Low", "High", "Volume",
  "Stock Splits", "Dividends"], axis=1, inplace=True)
# Calculate MAD
data["MAD"] = calcMAD(data["Close"])

Now that we calculated the MAD for our price series, we need some comparison value to get our signals.

When MAD > 1, the short-term moving average is above the long-term moving average. Setting a buy signal whenever MAD > 1 would be identical to a moving average cross-over. That's fine, but maybe we want to wait until the results are a bit stronger, say MAD > 1.05 before going into a position.

We can do that with the following code:

def MADSignals(mad: pd.Series, long_level: float=1.05,
               short_level: float=0.95, shorts=True) -> pd.Series:
  pos = np.zeros(len(mad))
  pos[:] = np.nan
  pos = np.where(mad>long_level, 1, pos)
  pos = np.where(mad<long_level, 0, pos)
  if shorts:
    pos = np.where(mad<short_level, -1, pos)
    pos = np.where((mad>short_level) &
                   (mad<long_level), 0, pos)
  position = pd.Series(pos, index=mad.index).ffill().fillna(0)
  return position

Finally, we can run this on our data and take a look at the results.

We'll call the output strat1 and we'll use a few helper functions (calcReturns and getStratStats) that have been introduced in other posts to calculate the returns from the strategy and get the statistics (just like the functions sound).

strat1 = data.copy()
strat1.dropna(inplace=True)
strat1["position"] = MADSignals(strat1["MAD"], long_level=1.05)
strat1 = calcReturns(strat1)

# Plot results
colors = plt.rcParams["axes.prop_cycle"].by_key()["color"]
fig, ax = plt.subplots(2, figsize=(12, 8), sharex=True)
ax[0].plot(strat1["strat_cum_returns"] * 100, label="Strategy")
ax[0].plot(strat1["cum_returns"] * 100, label="Buy and Hold")
ax[0].legend()
ax[0].set_ylabel("Returns (%)")
ax[0].set_title(f"MAD Strategy vs Buy and Hold on {ticker}")

ax[1].plot(strat1["MAD"])
ax[1].axhline(1.05, label="Long Threshold", linestyle="--",
              c=colors[1])
ax[1].axhline(0.95, label="Short Threshold", linestyle="--",
              c=colors[3])
ax[1].legend()
ax[1].set_ylabel("MAD")
ax[1].set_xlabel("Date")
plt.tight_layout()
plt.show()

stats = {"Buy and Hold": getStratStats(strat1["log_returns"])}
stats["MAD"] = getStratStats(strat1["strat_log_returns"])
pd.DataFrame.from_dict(stats, orient="index")
mad-meta-backtest1.png
Total Returns Annual Returns Annual Volatitlity Sortino Ratio Sharpe Ratio Max Drawdown Max Drawdown Duration
Buy and Hold 0.2011 0.0258 0.3915 0.0166 0.0149 0.7674 533
MAD 2.1590 0.1736 0.3636 0.5211 0.4224 0.5189 1157

This simple model beats a buy and hold strategy on Meta (formerly Facebook) with a 216% gain vs 20% over the same time period. Mostly, the gains are driven by going short on the stock over the past 15 months, but you can tweak the parameters to get better results as you see fit.

For what it's worth, the authors of the original paper say the indicator works well with a 5-35 day short-term moving average and a 200-250 day long-term moving average.

Variable Long/Short Threshold

The paper I pulled this indicator from didn't use just a fixed level to go long or short. They used the standard deviation of the returns in their universe.

We can do that here too.

Let's take the same data and add a 21-day standard deviation to our model to get the trailing, monthly volatility.

strat2 = data.copy()
strat2["sigma"] = strat2["Close"].pct_change().rolling(21).std()
strat2.dropna(inplace=True)

From here, we'll tweak our signal function slightly to compare to recent volatility levels +/-1 for our long/short decisions.

def MADSigmaSignals(mad: pd.Series, sigma: pd.Series, 
  shorts=True) -> pd.Series:
  pos = np.zeros(len(mad))
  pos[:] = np.nan
  pos = np.where(mad>1+sigma, 1, pos)
  if shorts:
    pos = np.where(mad<1-sigma, -1, pos)
    pos = np.where((mad>1-sigma) &
                   (mad<1-sigma), 0, pos)
  position = pd.Series(pos, index=mad.index).ffill().fillna(0)
  return position

From here, we're ready to test the updated strategy and see how it performs.

strat2["position"] = MADSigmaSignals(strat2["MAD"], strat2["sigma"])
strat2 = calcReturns(strat2)

fig, ax = plt.subplots(2, figsize=(12, 8), sharex=True)
ax[0].plot(strat2["strat_cum_returns"] * 100, label="Strategy")
ax[0].plot(strat2["cum_returns"] * 100, label="Buy and Hold")
ax[0].legend()
ax[0].set_ylabel("Returns (%)")
ax[0].set_title(f"MAD Strategy vs Buy and Hold on {ticker}")

ax[1].plot(strat2["MAD"])
ax[1].plot(1+strat2["sigma"], label="Long Threshold", linestyle="--",
            c=colors[1])
ax[1].plot(1-strat2["sigma"], label="Short Threshold", linestyle="--",
              c=colors[3])
ax[1].legend()
ax[1].set_ylabel("MAD")
ax[1].set_xlabel("Date")
plt.tight_layout()
plt.show()

stats["MAD+Sigma"] = getStratStats(strat2["strat_log_returns"])
pd.DataFrame.from_dict(stats, orient="index")
mad-meta-backtest2.png
Total Returns Annual Returns Annual Volatitlity Sortino Ratio Sharpe Ratio Max Drawdown Max Drawdown Duration
Buy and Hold 0.2011 0.0258 0.3915 0.0166 0.0149 0.7674 533
MAD 2.1590 0.1736 0.3636 0.5211 0.4224 0.5189 1157
MAD + Sigma 1.6101 0.1428 0.3914 0.4201 0.3138 0.5885 1495

Adding our volatility threshold to the MAD model outperformed our baseline model, however, it underperformed our fixed threshold in every category.

In both cases, 2020 gave the model a really rough time with it’s rapid market crash and bounce that was nearly as fast in the other direction. Even though both models were long for most of 2020, they were starting from a decimated capital base and compounding took quite a while to catch up.

Applying the MAD to your Trading Universe

I wouldn't read too much into this either way. It's a simple backtest on a single stock with a host of simplifying assumptions.

We're going to revisit this indicator as we dig into a more sophisticated, cross-sectional momentum strategy and show you step-by-step how it can be applied.

Be sure to subscribe to get notified of new posts so you don't miss it!