Keltner Channels


Keltner Channels are volatility-based envelopes set above and below an Exponential Moving Average (EMA). The width of the channels is determined by the Average True Range (ATR). They are used to identify trend direction, reversals, and breakouts.

KELTNER

=KELTNER(data, length, mult)

Example Usage

=KELTNER(A2:F500, 20, 2)

Parameters

Parameter Type Description Status
data
Range
The input range of columns containing the Date, Open, High, Low, Close, and Volume data.
Required
length
Number
The period for the EMA and ATR calculations. Default is 20.
Optional
mult
Number
The multiplier for the ATR to determine the channel width. Default is 2.
Optional

Returns

A multi-column array containing:

  1. Date
  2. Upper: EMA + (ATR * mult).
  3. Basis: The Exponential Moving Average (EMA).
  4. Lower: EMA - (ATR * mult).
Keltner Channels Formula Result in Google Sheets

Source Code

Copy the following code into your Apps Script editor (Extensions > Apps Script) to use the KELTNER-CHANNELS function in your spreadsheet.

keltner.js
/**
 * Calculates Keltner Channels.
 * Middle Line = EMA(Close, 20)
 * Upper Channel = Middle Line + (2 * ATR(10))
 * Lower Channel = Middle Line - (2 * ATR(10))
 *
 * @param {array} data - The input range. Must include at least 5 columns: Date, Open, High, Low, Close.
 * @param {number} [length=20] - EMA period for the middle line (default 20).
 * @param {number} [mult=2] - ATR multiplier (default 2).
 * @param {number} [atrLength=10] - ATR period (default 10).
 * @returns {array} A multi-column array with headers "Date", "Upper", "Basis", "Lower".
 * @customfunction
 */
function KELTNER_CHANNELS(data, length = 20, mult = 2, atrLength = 10) {
    checkPremium();

    // Argument validation
    if (arguments.length < 1 || arguments.length > 4) {
        throw new Error(`Wrong number of arguments.`);
    }

    const processedData = getData(data);
    const columnCount = processedData[0].length;
    if (columnCount < 5) {
        throw new Error(`Invalid data structure. Expected at least 5 columns (Date, O, H, L, C).`);
    }

    const dataRows = processedData.slice(1);
    const results = [["Date", `Upper (${length}, ${mult})`, `Basis (${length})`, `Lower (${length}, ${mult})`]];

    // We need two independent calculations:
    // 1. EMA of Close (period 'length')
    // 2. ATR (period 'atrLength') -> Requires TR -> RMA

    // EMA State
    let ema = 0;
    const k = 2 / (length + 1);
    const emaBuffer = [];
    let emaSum = 0;

    // ATR State (RMA of TR)
    let atr = 0;
    let prevRMA = 0;
    let trSum = 0;
    const trBuffer = [];
    let prevClose = 0;
    let atrReady = false;

    for (let i = 0; i < dataRows.length; i++) {
        const row = dataRows[i];
        const date = row[0];
        const high = row[2];
        const low = row[3];
        const close = row[4];

        // --- EMA Calculation ---
        let currentEma = null;
        if (emaBuffer.length < length) {
            emaBuffer.push(close);
            emaSum += close;
            if (emaBuffer.length === length) {
                ema = emaSum / length;
                currentEma = ema;
            }
        } else {
            ema = (close * k) + (ema * (1 - k));
            currentEma = ema;
        }

        // --- ATR Calculation ---
        let tr = 0;
        if (i === 0) {
            tr = high - low;
        } else {
            tr = Math.max(high - low, Math.abs(high - prevClose), Math.abs(low - prevClose));
        }
        prevClose = close;

        let currentAtr = null;
        if (!atrReady) {
            trBuffer.push(tr);
            trSum += tr;
            if (trBuffer.length === atrLength) {
                prevRMA = trSum / atrLength; // First RMA is SMA
                currentAtr = prevRMA;
                atrReady = true;
            }
        } else {
            currentAtr = ((prevRMA * (atrLength - 1)) + tr) / atrLength;
            prevRMA = currentAtr;
        }

        // --- Final Calculation ---
        if (currentEma !== null && currentAtr !== null) {
            const upper = currentEma + (mult * currentAtr);
            const lower = currentEma - (mult * currentAtr);
            results.push([date, upper, currentEma, lower]);
        } else {
            results.push([date, "", "", ""]);
        }
    }

    // Trim Output
    // Robust alignment: Check that Upper, Basis, and Lower are ALL present.
    // Although the calculation logic groups them, this explicit check guards against future edge cases.
    let firstValidIndex = -1;
    for (let i = 1; i < results.length; i++) {
        const row = results[i];
        if (row[1] !== "" && row[1] !== null &&
            row[2] !== "" && row[2] !== null &&
            row[3] !== "" && row[3] !== null) {
            firstValidIndex = i;
            break;
        }
    }

    if (firstValidIndex !== -1) {
        return [results[0], ...results.slice(firstValidIndex)];
    } else {
        return [results[0]];
    }
}