Strategies

Donchian channel breakout

A study of a close breaking the highest or lowest rate of a prior window. The sample has no traded high or low. Educational only.

  • Breakout
  • Introductory
  • Daily
  • Python
  • C#
  • MQL5 illustrative

Educational study of a published rule. It is not a signal, a recommendation, or a forecast. The series is the ECB daily US dollar/euro reference rate from 2 January 2020 to 31 December 2024, one rate per TARGET business day, copied into open, high, low and close. Source: ECB statistics. The study charges a spread of 0.01 percent of the rate, a fraction of 0.0001, once, against the trader. Commission is zero. Swap and slippage are not modelled. The script does not print a return. Past performance does not predict future results.

Idea

A channel is the highest and lowest value over a window that ends before the current observation. The study goes long when the current rate breaks above the prior 20-observation high, and short when it breaks below the prior 20-observation low. A long is left when the rate breaks below the prior 10-observation low. A short is left on a break of the prior 10-observation high.

The current rate is not allowed into the channel it is trying to break. If it were, a series where high and close are the same number could never print a strict break of its own high. The code takes the max or min of values[i - window : i], which stops one observation before i.

Equality is not a break. The comparisons are strict.

What this is, and what it is not

John Bollinger’s own description of Donchian bands is the highest high and the lowest low of the immediately prior n days. Brock, Lakonishok and LeBaron (1992) test a trading-range break of the same family on the Dow Jones Industrial Average from 1897 to 1986. This page uses that shape. It does not reproduce either test, and it does not use the paper’s parameter grid.

Twenty and ten are round teaching lengths. They are a longer entry window and a shorter exit window. They were not chosen by searching this file. They are not presented here as anyone’s complete trading system. Published channel systems also specify where in the bar the break is filled, how size changes with volatility, and when a fresh break is skipped. None of that is in this study.

On a real OHLC bar the channel is built from highs and lows, and a break can happen during the bar. This file cannot support that. Each row is one ECB reference rate, so high, low and close are the same number and the channel is a channel of prior reference rates. The MQL5 sketch is the chart version, using CopyHigh and CopyLow on closed bars. It was not compiled, and it is not a bar-for-bar port of the ECB run.

Fills and costs

The break is judged when the observation is complete. The fill is the next observation, widened against the trader by a spread fraction of 0.0001. Commission is zero. Swap and slippage are not modelled. At a rate of 1.10 the fraction moves the fill by 0.00011, which is the arithmetic of the assumption, not a measured spread.

Python then hands the signal column to backtesting.py with trade_on_close false. The library count of closed round trips is not the study’s count of position changes, and the script does not read a return. C# uses the same channel and the same next-observation fill. The signal digests are required to match. A prefix check fails the run if a channel value at i depends on a later row.

If the exit break and the opposite entry break land on the same observation, the study exits and then takes the new break. That is one reversal, not two positions.

Weaknesses

A channel break buys strength and sells weakness, so the fill is often a worse price than the prices inside the window. That is the rule, not a defect to be coded away. The shorter exit can take the study out and the longer entry can put it back, which on a noisy series produces a run of reversals. The signal count on this file is a count of those changes. It is not a quality score.

The exit is not a fixed distance from the entry. A long can travel a long way down before the 10-observation low breaks. There is no second stop.

Because the sample has no range, a break here means “this reference rate is beyond the previous reference rates”. It does not mean a traded high was printed. Using the same lengths on a broker’s hourly bars is a different test: the channel is then a price range, and the spread is larger relative to the bar. This page does not make that test.

The usual ways to fool this rule are to include the current bar in its own channel, to fill at the same close that made the break, or to pick the window after looking at the sample. The slice excludes the current bar, the fill is next, and the windows are fixed in the source. None of that makes the rule a forecast.

On a demo account

Write down 20 and 10, strict breaks, closed bars only, and the exit channel as the only way out. Use a demo account and the smallest size it allows. On a chart, build the channel from completed bars, not from the bar that is still printing. The illustrative expert advisor copies highs and lows from shift 1 onward for that reason, and it returns without trading if the account is not a demo.

Log the assumed reference and the demo fill separately. The demo ticket has a spread, and a hold has a swap. This study has a single spread fraction and no swap. Do not shorten the window because the last break reversed. That is a search on a handful of fills. A demo fill does not predict a live fill, and past fills do not predict later ones.

Implementations

The decision uses only data up to that observation. The fill is the next observation. Python and C# are run against the ECB file and must produce the same signal digest. The MQL5 file is illustrative: it was not compiled or run in MetaTrader, and a chart has high and low prices that this sample does not. The code is released under the MIT licence. The ECB file is not.

Python

#!/usr/bin/env python3
# Copyright (c) 2026 fxtradingguide.com
# SPDX-License-Identifier: MIT

"""Donchian channel breakout study.

A close above the highest of the previous entry window goes long. A close
below the lowest of that window goes short. A long exits on a close below the
lowest of the previous, shorter, exit window, and the short is the mirror.
The current observation is not part of its own channel. The fill is the next
observation. Nothing here is a forecast or a recommendation.
"""

from __future__ import annotations

import sys
from pathlib import Path

import pandas as pd
from backtesting import Backtest

ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT / "strategies" / "shared" / "python"))

from fxstudy.data import assert_sample, load_observations
from fxstudy.library import SignalStrategy
from fxstudy.report import (
    MARGIN,
    SPREAD_FRACTION,
    STARTING_CASH,
    assert_causal,
    write_report,
)

STUDY = "donchian-breakout"
ENTRY = 20
EXIT = 10


def prior_extreme(values: list[float], window: int, highest: bool) -> list[float | None]:
    """Max or min of the previous ``window`` observations, excluding the current one."""
    if window <= 0:
        raise SystemExit("channel window must be positive")
    out: list[float | None] = [None] * len(values)
    for index in range(window, len(values)):
        sample = values[index - window : index]
        out[index] = max(sample) if highest else min(sample)
    return out


def signals(
    high: list[float],
    low: list[float],
    close: list[float],
    entry: int = ENTRY,
    exit_window: int = EXIT,
) -> list[int | None]:
    if entry <= 0 or exit_window <= 0:
        raise SystemExit("channel windows must be positive")
    entry_high = prior_extreme(high, entry, True)
    entry_low = prior_extreme(low, entry, False)
    exit_high = prior_extreme(high, exit_window, True)
    exit_low = prior_extreme(low, exit_window, False)
    position = 0
    desired: list[int | None] = []
    for index, price in enumerate(close):
        if entry_high[index] is None or exit_low[index] is None:
            desired.append(None)
            continue
        if position == 1 and price < exit_low[index]:
            position = 0
        elif position == -1 and price > exit_high[index]:
            position = 0
        if position <= 0 and price > entry_high[index]:
            position = 1
        elif position >= 0 and price < entry_low[index]:
            position = -1
        desired.append(position)
    return desired


def _assert_channel_excludes_itself() -> None:
    highest = prior_extreme([1, 3, 2, 5], 2, True)
    if highest != [None, None, 3, 3]:
        raise SystemExit(f"channel fixture failed: {highest}")


def library_trade_count(dates: list[str], rates: list[float], desired: list[int | None]) -> int:
    frame = pd.DataFrame(
        {
            "Open": rates,
            "High": rates,
            "Low": rates,
            "Close": rates,
            "Signal": [float("nan") if value is None else float(value) for value in desired],
        },
        index=pd.to_datetime(dates),
    )
    backtest = Backtest(
        frame,
        SignalStrategy,
        cash=STARTING_CASH,
        spread=SPREAD_FRACTION,
        commission=0.0,
        margin=MARGIN,
        trade_on_close=False,
        hedging=False,
        exclusive_orders=True,
    )
    return int(backtest.run()["# Trades"])


def main() -> None:
    _assert_channel_excludes_itself()
    observations = load_observations()
    assert_sample(observations)
    rates = [item.rate for item in observations]

    def compute(end: int) -> list[int | None]:
        sample = rates[:end]
        return signals(sample, sample, sample)

    assert_causal(len(rates), compute)
    desired = signals(rates, rates, rates)
    trades = library_trade_count([item.date for item in observations], rates, desired)
    write_report(STUDY, "python", observations, desired, str(trades))


if __name__ == "__main__":
    main()

This file on GitHub

C#

// Copyright (c) 2026 fxtradingguide.com
// SPDX-License-Identifier: MIT

using FxTradingGuide.Studies;

namespace FxTradingGuide.Strategies.DonchianBreakout;

/// <summary>
/// Close-confirmed channel. The current observation is not part of its own
/// channel. On this sample, high, low and close are the same reference rate.
/// </summary>
public static class DonchianBreakout
{
    public const int Entry = 20;
    public const int Exit = 10;

    public static double?[] PriorExtreme(IReadOnlyList<double> values, int window, bool highest)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(window);
        var result = new double?[values.Count];
        for (var index = window; index < values.Count; index++)
        {
            var extreme = values[index - window];
            for (var cursor = index - window + 1; cursor < index; cursor++)
            {
                var price = values[cursor];
                if (highest ? price > extreme : price < extreme)
                {
                    extreme = price;
                }
            }

            result[index] = extreme;
        }

        return result;
    }

    public static int?[] Signals(
        IReadOnlyList<double> high,
        IReadOnlyList<double> low,
        IReadOnlyList<double> close,
        int entry = Entry,
        int exitWindow = Exit)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(entry);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(exitWindow);
        if (high.Count != low.Count || low.Count != close.Count)
        {
            throw new ArgumentException("high, low and close must be the same length");
        }

        var entryHigh = PriorExtreme(high, entry, highest: true);
        var entryLow = PriorExtreme(low, entry, highest: false);
        var exitHigh = PriorExtreme(high, exitWindow, highest: true);
        var exitLow = PriorExtreme(low, exitWindow, highest: false);
        var position = 0;
        var desired = new int?[close.Count];
        for (var index = 0; index < close.Count; index++)
        {
            if (entryHigh[index] is null || exitLow[index] is null)
            {
                desired[index] = null;
                continue;
            }

            var price = close[index];
            if (position == 1 && price < exitLow[index])
            {
                position = 0;
            }
            else if (position == -1 && price > exitHigh[index])
            {
                position = 0;
            }

            if (position <= 0 && price > entryHigh[index])
            {
                position = 1;
            }
            else if (position >= 0 && price < entryLow[index])
            {
                position = -1;
            }

            desired[index] = position;
        }

        return desired;
    }

    public static void AssertFixture()
    {
        var highest = PriorExtreme(new[] { 1d, 3d, 2d, 5d }, 2, highest: true);
        if (highest[0] is not null || highest[1] is not null || highest[2] != 3d || highest[3] != 3d)
        {
            throw new InvalidOperationException("channel fixture failed");
        }
    }
}

public static class Program
{
    public static void Main()
    {
        DonchianBreakout.AssertFixture();
        var observations = SampleSeries.Load();
        var rates = observations.Select(item => item.Rate).ToArray();
        StudyReport.AssertCausal(rates.Length, end =>
        {
            var slice = new double[end];
            Array.Copy(rates, slice, end);
            return DonchianBreakout.Signals(slice, slice, slice);
        });
        StudyReport.Write("donchian-breakout", observations, DonchianBreakout.Signals(rates, rates, rates));
    }
}

This file on GitHub

MQL5 — Illustrative. This file was not compiled or run in MetaTrader. It uses the chart high and low. The ECB sample has neither.

//+------------------------------------------------------------------+
//| DonchianBreakout.mq5                                             |
//| Illustrative only. This file was not compiled or run.           |
//| Copyright (c) 2026 fxtradingguide.com                            |
//| SPDX-License-Identifier: MIT                                     |
//| https://fxtradingguide.com/strategies/donchian-breakout/         |
//+------------------------------------------------------------------+
#property copyright "Copyright (c) 2026 fxtradingguide.com"
#property link      "https://fxtradingguide.com/strategies/donchian-breakout/"
#property version   "1.00"
#property description "Educational channel study. Not a signal."
#property description "Uses the chart high and low. The ECB sample has neither."
#property description "Orders are refused unless the account is a demo account."

#include <Trade/Trade.mqh>

input int    EntryWindow = 20;   // prior closed bars for a break
input int    ExitWindow  = 10;   // prior closed bars for an exit
input int    Lookback    = 400;  // closed bars used to rebuild the position
input double Lots        = 0.01; // demo size, not a risk model

CTrade   trade;
datetime lastClosedBar = 0;

int OnInit()
{
   if(AccountInfoInteger(ACCOUNT_TRADE_MODE) != ACCOUNT_TRADE_MODE_DEMO)
   {
      Print("Refusing to run: this study sends orders only on a demo account.");
      return INIT_FAILED;
   }
   if(EntryWindow <= 0 || ExitWindow <= 0 || Lots <= 0.0)
      return INIT_PARAMETERS_INCORRECT;
   return INIT_SUCCEEDED;
}

void OnTick()
{
   datetime closed = iTime(_Symbol, PERIOD_CURRENT, 1);
   if(closed == 0 || closed == lastClosedBar)
      return;
   lastClosedBar = closed;
   int target = 0;
   if(!DesiredPosition(target))
      return;
   AlignDemoPosition(target);
}

bool DesiredPosition(int &target)
{
   int bars = Bars(_Symbol, PERIOD_CURRENT);
   int warm = MathMax(EntryWindow, ExitWindow);
   int count = MathMin(Lookback, bars - warm - 2);
   if(count < warm + 2)
      return false;

   double high[];
   double low[];
   double close[];
   ArraySetAsSeries(high, false);
   ArraySetAsSeries(low, false);
   ArraySetAsSeries(close, false);
   // Oldest copied bar at index 0. The copy starts at shift 1, so the forming
   // bar is not included. The channel at index i uses the bars before i.
   if(CopyHigh(_Symbol, PERIOD_CURRENT, 1, count, high) != count)
      return false;
   if(CopyLow(_Symbol, PERIOD_CURRENT, 1, count, low) != count)
      return false;
   if(CopyClose(_Symbol, PERIOD_CURRENT, 1, count, close) != count)
      return false;

   int position = 0;
   for(int i = warm; i < count; i++)
   {
      // Previous bars only. Index i itself is not inside its channel.
      double entryHigh = high[i - EntryWindow];
      double entryLow = low[i - EntryWindow];
      double exitHigh = high[i - ExitWindow];
      double exitLow = low[i - ExitWindow];
      for(int cursor = i - EntryWindow; cursor < i; cursor++)
      {
         if(high[cursor] > entryHigh)
            entryHigh = high[cursor];
         if(low[cursor] < entryLow)
            entryLow = low[cursor];
      }
      for(int cursor = i - ExitWindow; cursor < i; cursor++)
      {
         if(high[cursor] > exitHigh)
            exitHigh = high[cursor];
         if(low[cursor] < exitLow)
            exitLow = low[cursor];
      }

      if(position == 1 && close[i] < exitLow)
         position = 0;
      else if(position == -1 && close[i] > exitHigh)
         position = 0;
      if(position <= 0 && close[i] > entryHigh)
         position = 1;
      else if(position >= 0 && close[i] < entryLow)
         position = -1;
   }
   target = position;
   return true;
}

int OpenDirection()
{
   if(!PositionSelect(_Symbol))
      return 0;
   long type = PositionGetInteger(POSITION_TYPE);
   if(type == POSITION_TYPE_BUY)
      return 1;
   if(type == POSITION_TYPE_SELL)
      return -1;
   return 0;
}

void AlignDemoPosition(const int target)
{
   int current = OpenDirection();
   if(current == target)
      return;
   if(current != 0 && !trade.PositionClose(_Symbol))
   {
      Print("Demo close was not accepted: ", trade.ResultRetcodeDescription());
      return;
   }
   if(target == 1)
      trade.Buy(Lots);
   else if(target == -1)
      trade.Sell(Lots);
}

This file on GitHub

The order adapter and the shared report live next to the rule, so the page above is the decision and the links below are the fill.

Glossary

These are the directory’s definitions for words this study uses. They are not part of the rule, and they are not a recommendation.

Software catalogue

The Python file imports backtesting.py. MetaTrader 5 itself is in the platform directory below. The catalogue also lists the MetaTrader 5 Python package and the MQL5 Code Base. This study does not import that package, and the MQL5 file is not a Code Base entry.

Published broker costs

The spread in this study is one assumed fraction of the reference rate, applied on every fill. Swap is not modelled, and slippage is not measured. Firms publish their own spread, commission and financing figures, and those figures are not this assumption. The broker comparison sets the published figures side by side, with the source and the last-checked date. It does not score or rank firms, and it is not a recommendation.

Related guides

Quoting units such as pips, lots and leverage are explained in How FX trading works. What MetaTrader 4, MetaTrader 5, cTrader and TradingView are each for is in the platform guide. Neither page is a recommendation, and neither changes this study.

Sources and last review

Last reviewed 10 November 2026.

The code on this page is released under the MIT licence. The ECB series is not covered by that licence. Source: ECB statistics.