In the 1970's, Richard Donchian began the trend following trend by introducing a simple breakout trading system that would make him millions over the following decades.

This system was predicated on an indicator that came to bear his name the Donchian Channel.

We're going to show you how to calculate and trade the Donchian Channel with three example strategies so you can incorporate it into your own algorithmic trading system.

How to Calculate the Donchian Channel

The Donchian Channel consists of an upper and lower bound which are calculated by taking the highest high and lowest low over the previous N periods.

In pseudo-code we have:

  • Upper Donchian:
D_u[t] = max(highs[t-N:t])
  • Lower Donchian
D_l[t] = min(lows[t-N:t])

Sometimes traders use the middle value, which is just the average of the upper and lower bounds.

  • Middle Donchian:
D_m[t] = (D_u[t] + D_l[t]) / 2

It's quick and easy to calculate in Python too:

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

def calcDonchianChannels(data: pd.DataFrame, period: int):
  data["upperDon"] = data["High"].rolling(period).max()
  data["lowerDon"] = data["Low"].rolling(period).min()
  data["midDon"] = (data["upperDon"] + data["lowerDon"]) / 2
  return data

Let's grab some data from Yahoo! Finance and apply our new function to see what this looks like.

ticker = "XOM"
yfObj = yf.Ticker(ticker)
data = yfObj.history(start="2020-01-01", end="2022-08-01").drop(
    ["Volume", "Stock Splits"], axis=1)
data = calcDonchianChannels(data, 20)
data.tail()
donchian-channel-table-1.png

A picture is worth a thousand words, or so the say, so let's get a plot of this channel.

colors = plt.rcParams["axes.prop_cycle"].by_key()["color"]

plt.figure(figsize=(12, 8))
plt.plot(data["Close"], label="Close")
plt.plot(data["upperDon"], label="Upper", c=colors[1])
plt.plot(data["lowerDon"], label="Lower", c=colors[4])
plt.plot(data["midDon"], label="Mid", c=colors[2], linestyle=":")
plt.fill_between(data.index, data["upperDon"], data["lowerDon"], alpha=0.3,
                 color=colors[1])

plt.xlabel("Date")
plt.ylabel("Price ($)")
plt.title(f"Donchian Channels for {ticker}")
plt.xticks(rotation=45)
plt.legend()
plt.show()
donchian-example-plot-1.png

We can clearly see the channel provides some solid boundaries for the price. While the middle value crosses back and forth above and below the price.

Let's turn and look at each of our trading strategies starting with the middle value cross-over we just highlighted.

Example Strategy 1: Donchian Middle Value Cross-Over

This strategy will enter a position when the price closes above the middle value and will go short when the price drops below. The idea is to get those trends in the data when the close gets closer to the highs and vice versa.

Here's how we'd do that in Python.

def midDonCrossOver(data: pd.DataFrame, period: int=20, shorts: bool=True):
  data = calcDonchianChannels(data, period)

  data["position"] = np.nan
  data["position"] = np.where(data["Close"]>data["midDon"], 1, 
                              data["position"])
  if shorts:
    data["position"] = np.where(data["Close"]<data["midDon"], -1, 
                                data["position"])
  else:
    data["position"] = np.where(data["Close"]<data["midDon"], 0, 
                                data["position"])
  data["position"] = data["position"].ffill().fillna(0)

  return calcReturns(data)

This strategy only takes a few lines of code to execute. We pass our raw data, let it know how many days we want for our lookback period, and whether or not we want to go short and let it go!

You'll see in the last line, we use calcReturns, this is a simple helper function that we use regularly to speed up some of the stats and compare strategies.

# Here are a few helper functions to give us returns and summary stats
def calcReturns(df):
  df['returns'] = df['Close'] / df['Close'].shift(1)
  df['log_returns'] = np.log(df['returns'])
  df['strat_returns'] = df['position'].shift(1) * df['returns']
  df['strat_log_returns'] = df['position'].shift(1) * \
      df['log_returns']
  df['cum_returns'] = np.exp(df['log_returns'].cumsum()) - 1
  df['strat_cum_returns'] = np.exp(
      df['strat_log_returns'].cumsum()) - 1
  df['peak'] = df['cum_returns'].cummax()
  df['strat_peak'] = df['strat_cum_returns'].cummax()
  return df

def getStratStats(log_returns: pd.Series,
  risk_free_rate: float = 0.02):
  stats = {}  # Total Returns
  stats['tot_returns'] = np.exp(log_returns.sum()) - 1  
  
  # Mean Annual Returns
  stats['annual_returns'] = np.exp(log_returns.mean() * 252) - 1  
  
  # Annual Volatility
  stats['annual_volatility'] = log_returns.std() * np.sqrt(252)
  
  # Sortino Ratio
  annualized_downside = log_returns.loc[log_returns<0].std() * \
    np.sqrt(252)
  stats['sortino_ratio'] = (stats['annual_returns'] - \
    risk_free_rate) / annualized_downside  
  
  # Sharpe Ratio
  stats['sharpe_ratio'] = (stats['annual_returns'] - \
    risk_free_rate) / stats['annual_volatility']  
  
  # Max Drawdown
  cum_returns = log_returns.cumsum() - 1
  peak = cum_returns.cummax()
  drawdown = peak - cum_returns
  max_idx = drawdown.argmax()
  stats['max_drawdown'] = 1 - np.exp(cum_returns[max_idx]) \
    / np.exp(peak[max_idx])
  
  # Max Drawdown Duration
  strat_dd = drawdown[drawdown==0]
  strat_dd_diff = strat_dd.index[1:] - strat_dd.index[:-1]
  strat_dd_days = strat_dd_diff.map(lambda x: x.days).values
  strat_dd_days = np.hstack([strat_dd_days,
    (drawdown.index[-1] - strat_dd.index[-1]).days])
  stats['max_drawdown_duration'] = strat_dd_days.max()
  return {k: np.round(v, 4) if type(v) == np.float_ else v
          for k, v in stats.items()}

Now we're all set. Let's test this on our data and take a look at the results!

midDon = midDonCrossOver(data.copy(), 20, shorts=False)

plt.figure(figsize=(12, 4))
plt.plot(midDon["strat_cum_returns"] * 100, label="Mid Don X-Over")
plt.plot(midDon["cum_returns"] * 100, label="Buy and Hold")
plt.title("Cumulative Returns for Mid Donchian Cross-Over Strategy")
plt.xlabel("Date")
plt.ylabel("Returns (%)")
plt.xticks(rotation=45)
plt.legend()

plt.show()

stats = pd.DataFrame(getStratStats(midDon["log_returns"]), 
                     index=["Buy and Hold"])
stats = pd.concat([stats,
                   pd.DataFrame(getStratStats(midDon["strat_log_returns"]),
                               index=["MidDon X-Over"])])
stats
donchian-channel-backtest-strategy-1.png
Strategy Total Returns (%) Annual Returns (%) Annual Volatility (%) Sortino Ratio Sharpe Ratio Max Drawdown (%) Max Drawdown Duration (days)
Buy and Hold 60.1% 20.1% 41.8% 0.617 0.433 55.0% 526
Mid Donchian X-Over 102.6% 31.6% 28.6% 1.18 1.04 28.4% 305

And just like that, we have a simple strategy that beats the pants off the buy and hold strategy by over 40% (adding shorts is even better).

This is a small sample size with only 2.5 years of data, so take it with a grain of salt, but the initial results are certainly intriguing!

Let's turn to our next Donchian-based strategy which is a trend following classic: Donchian Breakouts.

Example Strategy 2: Donchian Channel Breakout

This basic strategy has a long history of success being used by trend following traders since Donchian himself invented it!

There are a few flavors, but the basic idea is to go long when the price breaks through the upper channel and short or exit the trade if it breaks below the lower channel. Because we're looking at high prices, we'll define a breakout as a close that's greater than yesterday's Donchian bound.

Here's a quick and dirty backtest of this strategy.

def donChannelBreakout(data, period=20, shorts=True):
  data = calcDonchianChannels(data, period)
  
  data["position"] = np.nan
  data["position"] = np.where(data["Close"]>data["upperDon"].shift(1), 1, 
                              data["position"])
  if shorts:
    data["position"] = np.where(
      data["Close"]<data["lowerDon"].shift(1), -1, data["position"])
  else:
    data["position"] = np.where(
      data["Close"]<data["lowerDon"].shift(1), 0, data["position"])
      
  data["position"] = data["position"].ffill().fillna(0)
  
  return calcReturns(data)

And, we can test it on our data just like we did above.

breakout = donChannelBreakout(data.copy(), 20, shorts=False)

plt.figure(figsize=(12, 4))
plt.plot(breakout["strat_cum_returns"] * 100, label="Donchian Breakout")
plt.plot(breakout["cum_returns"] * 100, label="Buy and Hold")
plt.title("Cumulative Returns for Donchian Breakout Strategy")
plt.xlabel("Date")
plt.ylabel("Returns (%)")
plt.xticks(rotation=45)
plt.legend()

plt.show()

stats = pd.concat([stats,
                   pd.DataFrame(getStratStats(breakout["strat_log_returns"]),
                               index=["Donchian Breakout"])])
stats
donchian-channel-backtest-strategy-2.png
Strategy Total Returns (%) Annual Returns (%) Annual Volatility (%) Sortino Ratio Sharpe Ratio Max Drawdown (%) Max Drawdown Duration (days)
Buy and Hold 60.1% 20.1% 41.8% 0.617 0.433 55.0% 526
Mid Donchian X-Over 102.6% 31.6% 28.6% 1.18 1.04 28.4% 305
Donchian Breakout 158.1% 44.6% 29.8% 1.65 1.43 22.2% 214

This model further separates itself from the Buy and Hold approach with a 1.5x return over the same time period!

The stats are almost unanimously better for this method than the cross-over model as well, showing that there's still a lot of value in these classic trend following strategies.

Finally, we'll try one other model. This one will use the Donchian Channel as a mean reverting indicator.

Example Strategy 3: Donchian Reversal

While the Donchian Channel was invented to be a trend following indicator and capture the momentum of a move, it's possible it may also work as a mean reversion indicator.

Instead of going short on a breakout to new lows, we'll take these low prices as an opportunity to go long and vice versa.

Here's the Python code for this method:

def donReversal(data, period=20, shorts=True):
  data = calcDonchianChannels(data, period)
  
  data["position"] = np.nan
  data["position"] = np.where(data["Close"]<data["lowerDon"].shift(1), 
    1, data["position"])
  
  short_val = -1 if shorts else 0
  data["position"] = np.where(data["Close"]>data["lowerDon"].shift(1), 
    short_val, data["position"])
      
  data["position"] = data["position"].ffill().fillna(0)
  
  return calcReturns(data)

And to test it:

donRev = donReversal(data.copy(), 20, shorts=True)

plt.figure(figsize=(12, 4))
plt.plot(donRev["strat_cum_returns"] * 100, label="Donchian Reversal")
plt.plot(donRev["cum_returns"] * 100, label="Buy and Hold")
plt.title("Cumulative Returns for Donchian Reversal Strategy")
plt.xlabel("Date")
plt.ylabel("Returns (%)")
plt.xticks(rotation=45)
plt.legend()

plt.show()

stats = pd.concat([stats,
                   pd.DataFrame(getStratStats(donRev["strat_log_returns"]),
                               index=["Donchian Reversal (20-Day)"])])
stats
donchian-channel-backtest-strategy-3.png
Strategy Total Returns (%) Annual Returns (%) Annual Volatility (%) Sortino Ratio Sharpe Ratio Max Drawdown (%) Max Drawdown Duration (days)
Buy and Hold 60.1% 20.1% 41.8% 0.617 0.433 55.0% 526
Mid Donchian X-Over 102.6% 31.6% 28.6% 1.18 1.04 28.4% 305
Donchian Breakout 158.1% 44.6% 29.8% 1.65 1.43 22.2% 214
Donchian Reversal (20-day) -36.5% -16.2% 41.6% -0.623 -0.438 75.5% 637

Ok, probably no surprise that this does much worse than the breakout. Not because the indicator is "bad" per se, but because we're trading the exact opposite of a strategy that has a great backtest, so clearly it will perform poorly.

Let's try again, but we'll shorten the period to 5-days to see if that gets us better results.

period = 5
donRev = donReversal(data.copy(), period, shorts=True)

plt.figure(figsize=(12, 4))
plt.plot(donRev["strat_cum_returns"] * 100, label="Donchian Reversal")
plt.plot(donRev["cum_returns"] * 100, label="Buy and Hold")
plt.title("Cumulative Returns for Donchian Reversal Strategy")
plt.xlabel("Date")
plt.ylabel("Returns (%)")
plt.xticks(rotation=45)
plt.legend()

plt.show()

stats = pd.concat([stats,
                   pd.DataFrame(getStratStats(donRev["strat_log_returns"]),
                               index=[f"Donchian Reversal ({period}-Day)"])])
stats
donchian-channel-backtest-strategy-4.png
Strategy Total Returns (%) Annual Returns (%) Annual Volatility (%) Sortino Ratio Sharpe Ratio Max Drawdown (%) Max Drawdown Duration (days)
Buy and Hold 60.1% 20.1% 41.8% 0.617 0.433 55.0% 526
Mid Donchian X-Over 102.6% 31.6% 28.6% 1.18 1.04 28.4% 305
Donchian Breakout 158.1% 44.6% 29.8% 1.65 1.43 22.2% 214
Donchian Reversal (20-day) -36.5% -16.2% 41.6% -0.623 -0.438 75.5% 637
Donchian Reversal (5-day) -27.8% -11.9% 41.8% -0.488 -0.333 75.0% 637

A 5-day reversal does do better, but we still lose money, let alone greatly underperform the baseline. The model does spectacularly well in 2020, but it doesn't hold up over the long run. Perhaps it could work overlain with a Hurst exponent filter or some other indicator that helps traders discern whether a market is trending or mean reverting.

Trading with Donchian Channels

Although the idea behing the Donchian Channel is relatively simple and straightforward, it is a powerful trading indicator and can work wonders for your system.

Of course, you shouldn't just jump in with a simple, vectorized backtest like this. A more complete strategy would include risk management principles, additional diversification, and be tested with a more robust, event-driven backtest system.

If you want to learn how to do that, check out this article here to get step-by-step instructions on building your Donchian-based trading system.