Donchian Channels

Tracks highest high and lowest low over a period to identify breakout opportunities.

About the Donchian Channels

Donchian Channels

Donchian Channels are a technical indicator developed by Richard Donchian to identify potential breakout trading opportunities. The indicator consists of three lines: an upper band representing the highest high over a specific period, a lower band representing the lowest low over the same period, and a middle line representing the average of the upper and lower bands.

What It Measures

Donchian Channels measure price volatility and potential breakout points by tracking the highest and lowest prices over a specified period (typically 20 days). The width of the channel indicates the volatility of the market:

  • Wide channels suggest high volatility and potential for large price movements
  • Narrow channels suggest low volatility and potential for a breakout
  • Upper band represents the maximum price reached in the lookback period
  • Lower band represents the minimum price reached in the lookback period
  • Middle line represents the midpoint between the upper and lower bands

When to Use

Donchian Channels are particularly useful in the following scenarios:

  1. Breakout Trading: When price breaks above the upper band, it may signal a bullish breakout. When price breaks below the lower band, it may signal a bearish breakout.

  2. Trend Following: The position of price relative to the bands can indicate trend strength. Prices consistently near the upper band suggest an uptrend, while prices near the lower band suggest a downtrend.

  3. Volatility Analysis: The width of the channel provides insights into market volatility, helping traders adjust position sizes and risk management strategies.

  4. Support and Resistance: The upper and lower bands can act as dynamic support and resistance levels.

Interpretation

Trading Signals

  • Buy Signal: Price breaks above the upper band, indicating a potential bullish breakout or strong upward momentum
  • Sell Signal: Price breaks below the lower band, indicating a potential bearish breakout or strong downward momentum
  • Consolidation: When the channel narrows significantly, it often precedes a significant price movement in either direction

Channel Position

  • Price at Upper Band: Strong bullish momentum; consider taking profits on long positions or preparing for potential reversal
  • Price at Lower Band: Strong bearish momentum; consider taking profits on short positions or looking for long entry
  • Price at Middle Line: Neutral zone; price may move toward either the upper or lower band

Best Practices

  1. Combine with other indicators (like RSI or MACD) to confirm signals
  2. Use longer periods (e.g., 20-30 days) for more stable signals in trending markets
  3. Use shorter periods (e.g., 10-15 days) for more responsive signals in volatile markets
  4. Consider volume confirmation when price breaks through the bands
  5. Be aware of false breakouts, especially in ranging markets

Parameters

The main parameter for Donchian Channels is the lookback period:

  • Period: The number of bars (days, hours, etc.) to look back for the highest high and lowest low
    • Default: 20 periods (commonly used by traders)
    • Optimized: 15 periods (based on recent S&P 500 data analysis)
    • Range: Typically between 10-30 periods depending on trading timeframe and strategy

Example Usage

use centaur_technical_indicators::candle_indicators::bulk::donchian_channels;

pub fn main() {
    // fetch the data in your preferred way
    // let high = vec![...];   // high prices
    // let low = vec![...];    // low prices

    let donchian_channels = donchian_channels(&high, &low, 20);
    println!("{:?}", donchian_channels);
}
import centaur_technical_indicators as cti

# fetch the data in your preferred way
# high = [...]   # high prices
# low = [...]    # low prices

donchian_channels = cti.candle_indicators.bulk.donchian_channels(high, low, period=20)
print(donchian_channels)
// WASM import
import init, { candle_bulk_donchianChannels } from 'https://cdn.jsdelivr.net/npm/centaur-technical-indicators@latest/dist/web/centaur-technical-indicators.js';

await init();

// fetch the data in your preferred way
// const high = [...];   // high prices
// const low = [...];    // low prices

const donchian_channels = candle_bulk_donchianChannels(high, low, 20);
console.log(donchian_channels);

Optimization

Even if the defaults have stood the test of time, tuning them to your specific asset and timeframe gives you signals that fit your market. Below shows you how to achieve this.

Optimization Code

use centaur_technical_indicators::chart_trends::{peaks, valleys};

fn proximity_rating(fuzzed_location: &usize, price_location: &usize) -> f64 {
    1.0 / (*fuzzed_location as f64 - *price_location as f64).abs()
}


pub fn main() {

    // fetch the data in your preferred way
    let indicator_loop = Instant::now();

        // get buy and sell points, in an ideal world we would buy at the lowest point in the dip and sell at the highest point in the peak
        // In the course of a 20-day period (1 month of trading days), we want to find the highest peak and lowest valley within 5 days of each other
        let sell_points = peaks(&close, 20, 5).into_iter().map(|(_, i)| i).collect::<Vec<usize>>();
        let buy_points = valleys(&close, 20, 5).into_iter().map(|(_, i)| i).collect::<Vec<usize>>();

        // Define the ranges for optimization
        let max_period = 126;
        let min_period = 2;

        let fuzz_parameter = 5; // Allowable distance from buy/sell points

        // Store the best parameters found
        let mut best_rating = 0.0;
        let mut best_period = 0;
        let mut best_indicators = vec![];

        for period in min_period..=max_period {

                                let indicators = donchian_channels(&high, &low, period);
                                let mut rating = vec![];
                                let mut matched_sell = vec![];
                                let mut matched_buy = vec![];
                                for i in 0..indicators.len() {
                                    let price_location = i + period + 1; // Adjust for indicator lag
                                    if i >= price_location { break; }
                                    if price_location >= close.len() { break; }
                                    let oversold = indicators[i].0;
                                    let overbought = indicators[i].2; // Placeholder for overbought threshold
                                    if close[price_location] > overbought {
                                        if sell_points.contains(&price_location) {
                                            // If sell point == rsi, rate positively
                                            rating.push(1.0);
                                            matched_sell.push(price_location);
                                        } else if buy_points.contains(&price_location) {
                                            // If buy point == rsi, rate negatively
                                            rating.push(-1.0);
                                        } else {
                                            let mut found_sell = false;
                                            for fuzzed_location in (price_location - fuzz_parameter)..=(price_location + fuzz_parameter) {
                                                // It's ok if we count multiple times for fuzzed locations as we reduce the rating
                                                // based off of distance from the actual sell point which will impact the final rating
                                                if sell_points.contains(&fuzzed_location) {
                                                    rating.push(proximity_rating(&fuzzed_location, &price_location));
                                                    matched_sell.push(fuzzed_location);
                                                    found_sell = true;
                                                }
                                                if buy_points.contains(&fuzzed_location) {
                                                    // Note the `-` here to penalize for selling instead of buying
                                                    if !matched_sell.contains(&fuzzed_location) {
                                                        rating.push(-proximity_rating(&fuzzed_location, &price_location));
                                                    }
                                                }
                                            }
                                            if !found_sell {
                                                rating.push(0.0);
                                            }
                                        }
                                    } else if close[price_location] < oversold {
                                        if buy_points.contains(&price_location) {
                                            // If buy point == rsi, rate positively
                                            rating.push(1.0);
                                            matched_buy.push(price_location);
                                        } else if sell_points.contains(&price_location) {
                                            rating.push(-1.0);
                                        } else {
                                            let mut found_buy = false;
                                            for fuzzed_location in (price_location - fuzz_parameter)..=(price_location + fuzz_parameter) {
                                                // It's ok if we count multiple times for fuzzed locations as we reduce the rating
                                                // based off of distance from the actual sell point which will impact the final rating
                                                if buy_points.contains(&fuzzed_location) {
                                                    rating.push(proximity_rating(&fuzzed_location, &price_location));
                                                    matched_buy.push(fuzzed_location);
                                                    found_buy = true;
                                                }
                                                if sell_points.contains(&fuzzed_location) {
                                                    // Note the `-` here to penalize for buying instead of selling
                                                    if !matched_buy.contains(&fuzzed_location) {
                                                        rating.push(-proximity_rating(&fuzzed_location, &price_location));
                                                    }
                                                }
                                            }
                                            if !found_buy {
                                                rating.push(0.0);
                                            }
                                        }
                                    }
                                }
                                // Look for any missed buy/sell points and penalize
                                for missed_sell in sell_points.iter() {
                                    if !matched_sell.contains(missed_sell) {
                                        rating.push(-1.0);
                                    }
                                }
                                for missed_buy in buy_points.iter() {
                                    if !matched_buy.contains(missed_buy) {
                                        rating.push(-1.0);
                                    }
                                }
                                let total_rating: f64 = rating.iter().sum::<f64>() / (rating.len() as f64);
                                if total_rating > best_rating {
                                    best_rating = total_rating;
                                    best_period = period;
                                    best_indicators = indicators.clone();

            }
        }

        println!(
            "Indicators optimization loop took {} ms to run",
            indicator_loop.elapsed().as_millis()
        );

        println!("
Best Indicator parameters found:");
        println!("period = {}", best_period);
        println!("Rating: {}", best_rating);
        println!("Best Indicator values: {:?}", best_indicators);

}

Optimization Output

Example output from running the optimization code above on a year of S&P data.

Best Indicator parameters found:
period = 15
Rating: 0.2584229390681003
Best Indicator values: [(5104.35, 5184.6, 5264.85), (5104.35, 5184.6, 5264.85), (5131.59, 5198.22, 5264.85), ...]

Trading Simulation

In order to determine whether the optimized parameters beat the defaults, a trading simulation was run, below are the results.

Optimized Trading Simulation

Initial Investment
$1000.00
Final Capital
$1033.62
Total P&L
$33.62
Open Position
  • SideLONG
  • Shares0.0356
  • Entry$5849.72
  • Value$200.72

Default Trading Simulation

Initial Investment
$1000.00
Final Capital
$997.80
Total P&L
$-2.20
Open Position
  • SideLONG
  • Shares0.0344
  • Entry$5849.72
  • Value$193.77

Analysis

The optimized Donchian Channels (15-period) generated more frequent trading signals compared to the default 20-period configuration. A trading simulation was conducted with both parameter sets, starting with $1000 initial capital and investing 20% per trade. Long positions were opened when price dropped below the lower channel band and closed when price exceeded the upper band, while short positions were opened when price broke above the upper band and covered when price fell below the lower band.

The results demonstrate the effectiveness of the optimized parameters. The 15-period Donchian Channels strategy achieved a profit of $33.62 with a $200.72 open long position, significantly outperforming the default 20-period strategy which resulted in a loss of $2.20 (with a $193.77 open long position). The tighter 15-period channels provided more responsive signals that better captured short-term price movements in this dataset.

Trading Simulation Code

For those who want to run their own simulation to compare results.


fn simulate_trading(best_indicator: &[(f64, f64, f64)], best_period: usize, close: &[f64]) {
    println!("
--- Trading Simulation ---");

    let initial_capital = 1000.0;
    let mut capital = initial_capital;
    let investment_pct = 0.20;

    struct Position {
        entry_price: f64,
        shares: f64,
    }

    let mut open_long: Option<Position> = None;
    let mut open_short: Option<Position> = None;

    // Print table header
    println!("{:<5} | {:<19} | {:<10} | {:<10} | {:<12} | {:<15} | {:<10}",
             "Day", "Event", "Indicator", "Price", "Shares", "Capital", "P/L");
    println!("{}", "-".repeat(95));

    for i in 0..best_indicator.len() {
        let price_index = i + best_period + 1;
        if price_index >= close.len() { break; }

        let indicator_overbought = best_indicator[i].2;
        let indicator_oversold = best_indicator[i].0;
        let current_price = close[price_index];
        let day = price_index;

        // --- Handle Long Position ---
        if let Some(long_pos) = open_long.take() {
            if current_price > indicator_overbought as f64 {
                let sale_value = long_pos.shares * current_price;
                let profit = sale_value - (long_pos.shares * long_pos.entry_price);
                capital += sale_value;
                println!("{:<5} | {:<19} | {:<10.2} | ${:<9.2} | {:<12.4} | ${:<14.2} | ${:<9.2}",
                         day, "Sell (Close Long)", indicator_overbought, current_price, long_pos.shares, capital, profit);
            } else {
                open_long = Some(long_pos); // Put it back if not selling
            }
        } else if current_price < indicator_oversold as f64 && open_short.is_none() { // Don't buy if short is open
            let investment = capital * investment_pct;
            let shares_bought = investment / current_price;
            open_long = Some(Position { entry_price: current_price, shares: shares_bought });
            capital -= investment;
            println!("{:<5} | {:<19} | {:<10.2} | ${:<9.2} | {:<12.4} | ${:<14.2} | {}",
                     day, "Buy (Open Long)", indicator_oversold, current_price, shares_bought, capital, "-");
        }

        // --- Handle Short Position ---
        if let Some(short_pos) = open_short.take() {
            if current_price < indicator_oversold as f64 {
                let cost_to_cover = short_pos.shares * current_price;
                let profit = (short_pos.shares * short_pos.entry_price) - cost_to_cover;
                capital += profit; // Add profit to capital
                println!("{:<5} | {:<19} | {:<10.2} | ${:<9.2} | {:<12.4} | ${:<14.2} | ${:<9.2}",
                         day, "Cover (Close Short)", indicator_oversold, current_price, short_pos.shares, capital, profit);
            } else {
                open_short = Some(short_pos); // Put it back if not covering
            }
        } else if current_price > indicator_overbought as f64 && open_long.is_none() { // Don't short if long is open
            let short_value = capital * investment_pct;
            let shares_shorted = short_value / current_price;
            open_short = Some(Position { entry_price: current_price, shares: shares_shorted });
            // Capital doesn't change when opening a short, it's held as collateral
            println!("{:<5} | {:<19} | {:<10.2} | ${:<9.2} | {:<12.4} | ${:<14.2} | {}",
                     day, "Short (Open Short)", indicator_overbought, current_price, shares_shorted, capital, "-");
        }
    }

    println!("
--- Final Results ---");
    if let Some(pos) = open_long {
        println!("Simulation ended with an OPEN LONG position:");
        println!("  - Shares: {:.4}", pos.shares);
        println!("  - Entry Price: ${:.2}", pos.entry_price);
        let last_price = close.last().unwrap_or(&0.0);
        let current_value = pos.shares * last_price;
        capital += current_value;
        println!("  - Position value at last price (${:.2}): ${:.2}", last_price, current_value);
    }
    if let Some(pos) = open_short {
        println!("Simulation ended with an OPEN SHORT position:");
        println!("  - Shares: {:.4}", pos.shares);
        println!("  - Entry Price: ${:.2}", pos.entry_price);
        let last_price = close.last().unwrap_or(&0.0);
        let cost_to_cover = pos.shares * last_price;
        let pnl = (pos.shares * pos.entry_price) - cost_to_cover;
        capital += pnl;
        println!("  - Unrealized P/L at last price (${:.2}): ${:.2}", last_price, pnl);
    }

    let final_pnl = capital - initial_capital;
    println!("
Initial Capital: ${:.2}", initial_capital);
    println!("Final Capital:   ${:.2}", capital);
    println!("Total P/L:       ${:.2}", final_pnl);

}

fn main() {
    // Fetch data and perform optimization as shown in the optimization code above
    simulate_trading(&best_indicators, best_period, &close);

    println!("
Default Indicator values for comparison:");
    let default_dc = donchian_channels(&high, &low, 20);
    println!("{:?}", default_dc);
    simulate_trading(&default_dc, 20, &close);
}