Keltner Channel

Keltner Channel

Keltner Channels create a volatility-based envelope around price using a moving average centerline with bands set at a multiple of the Average True Range (ATR).

What It Measures

Keltner Channels track price volatility and potential breakout points through three components: a middle line (moving average of closing price), an upper band (middle line plus ATR × multiplier), and a lower band (middle line minus ATR × multiplier).

When to Use

Use Keltner Channels for trend identification (price position relative to middle line), breakout trading (price breaking outside bands), mean reversion strategies in ranging markets, and volatility assessment for position sizing.

Interpretation

Price touching or breaking below the lower band suggests oversold conditions and potential buy signals, while price at the upper band indicates overbought conditions and potential sell signals. Price walking along a band suggests strong trending conditions. Narrow bands (channel squeeze) often precede significant price movements.

Default Usage

use rust_ti::candle_indicators::bulk::keltner_channel;
use rust_ti::ConstantModelType;

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

    let keltner_channel = keltner_channel(&high, &low, &close, ConstantModelType::SimpleMovingAverage, ConstantModelType::SimpleMovingAverage, 2.0, 10);
    println!("{:?}", keltner_channel);
}
import pytechnicalindicators as pti

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

keltner_channel = pti.candle_indicators.bulk.keltner_channel(high, low, close, model="SimpleMovingAverage", atr_model="SimpleMovingAverage", multiplier=2.0, period=10)
print(keltner_channel)
// WASM import
import init, { candle_bulk_keltnerChannel } from 'https://cdn.jsdelivr.net/npm/ti-engine@latest/dist/web/ti_engine.js';

await init();

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

const keltnerChannelSeries = candle_bulk_keltnerChannel(high, low, close, ConstantModelType["SimpleMovingAverage"], ConstantModelType["SimpleMovingAverage"], 2.0, 10);
console.log(keltnerChannelSeries);

Optimization

The best way to determine what the best parameters for your indicator are is to build a simple optimization loop that tests all possible parameter combinations between a defined min and max value, and rate the output.

Below is an example of how to do this in Rust.

use rust_ti::chart_trends::{peaks, valleys};
use rust_ti::candle_indicators::bulk::keltner_channel;
use rust_ti::ConstantModelType;
use std::time::Instant;

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();
    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 min_multiplier = 0;
    let max_multiplier = 100;

    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_multiplier = 0.0;
    let mut best_model = ConstantModelType::SimpleMovingAverage;
    let mut best_atr_model = ConstantModelType::SimpleMovingAverage;
    let mut best_indicators = vec![];

    let total_count = (max_period - min_period) * (max_multiplier - min_multiplier) * 5 * 5;
    let mut iteration_count = 0;
    println!("
Running optimization loop with {} total iterations...", total_count);

    for &ma_type in &[ConstantModelType::SimpleMovingAverage, ConstantModelType::ExponentialMovingAverage, ConstantModelType::SmoothedMovingAverage, ConstantModelType::SimpleMovingMedian, ConstantModelType::SimpleMovingMode] {
        for &atr_ma_type in &[ConstantModelType::SimpleMovingAverage, ConstantModelType::ExponentialMovingAverage, ConstantModelType::SmoothedMovingAverage, ConstantModelType::SimpleMovingMedian, ConstantModelType::SimpleMovingMode] {
            for multiplier in min_multiplier..=max_multiplier {
                let multiplier = multiplier as f64 / 10.0;
                for period in min_period..=max_period {
                    iteration_count += 1;
                    if iteration_count % (total_count / 20) == 0 {
                        let next_log_percent = (iteration_count * 100) / total_count;
                        println!("Optimization is {}% complete...", next_log_percent);
                    }
                    let indicators = keltner_channel(&high, &low, &close, ma_type, atr_ma_type, multiplier, 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;
                        if close[price_location] > overbought {
                            if sell_points.contains(&price_location) {
                                // If sell point == indicator, rate positively
                                rating.push(1.0);
                                matched_sell.push(price_location);
                            } else if buy_points.contains(&price_location) {
                                // If buy point == indicator, rate negatively
                                rating.push(-1.0);
                            } else {
                                let mut found_sell = false;
                                for fuzzed_location in (price_location - fuzz_parameter)..=(price_location + fuzz_parameter) {
                                    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) {
                                        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 == indicator, 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) {
                                    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) {
                                        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_model = ma_type;
                        best_multiplier = multiplier;
                        best_atr_model = atr_ma_type;
                        best_indicators = indicators.clone();
                    }
                }
            }
        }
    }

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

    println!("
Best Indicator parameters found:");
    println!("best_period = {}", best_period);
    println!("model = {:?}", best_model);
    println!("atr_model = {:?}", best_atr_model);
    println!("multiplier = {}", best_multiplier);
    println!("Rating: {}", best_rating);
    println!("Best Indicator values: {:?}", best_indicators);

}

Optimization Output

Below is an example output from the optimization code above run on a year of S&P data.

period = 18
model = SimpleMovingMode
atr_model = ExponentialMovingAverage
multiplier = 2.5
Rating: 0.3859929078014186
Best Indicator values: [(5108.283988578862, 5218.0, 5327.716011421138), (5109.945653951631, 5218.0, 5326.054346048369), (5088.775794145894, 5206.333333333333, 5323.890872520772), ...]

Interactive Chart

To better illustrate how the indicator performs with different parameters, an interactive chart is provided below comparing default parameters (blue) with optimized parameters (green).

Analysis

The optimized Keltner Channel parameters achieved a total profit of $13.07 compared to the default parameters which resulted in a loss of -$15.63. The optimized configuration's 18-period with SimpleMovingMode centerline and ExponentialMovingAverage for ATR calculation, combined with a 2.5 multiplier, created wider channels that filtered out false signals and provided more reliable trading signals.

Optimized trading simulation

Initial Investment
$1000.00
Final Capital
$1013.07
Total P&L
$13.07
Open Position
  • SideLONG
  • Shares0.03488783234495463
  • Entry$5849.72
  • Value$196.73

Default trading simulation

Initial Investment
$1000.00
Final Capital
$984.37
Total P&L
$-15.63
Open Position
  • SideLONG
  • Shares0.03341377317563841
  • Entry$5955.25
  • Value$188.42

Trading simulation code

For those you want to run their own simulation to compare results

use rust_ti::candle_indicators::bulk::keltner_channel;
use rust_ti::ConstantModelType;

fn chart_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; }

        // This is an oversimplification, in reality we would use specific components of the channel
        let indicator_oversold = best_indicator[i].0;
        let indicator_overbought = best_indicator[i].2;
        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);
        println!("{{ position = "LONG", shares = {}, entry_price = "${:.2}", position_value_at_last_price = "${:.2}" }}", pos.shares, pos.entry_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);
        println!("{{ position = "SHORT", shares = {}, entry_price = "${:.2}", position_value_at_last_price = "${:.2}" }}", pos.shares, pos.entry_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
    chart_simulate_trading(&best_indicators, best_period, &close);
    
    println!("
Default Indicator values for comparison:");
    let default_kc = keltner_channel(&high, &low, &close, ConstantModelType::SimpleMovingAverage, ConstantModelType::SimpleMovingAverage, 2.0, 10);
    println!("{:?}", default_kc);
    chart_simulate_trading(&default_kc, 10, &close);
}