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:
- Date
- Upper: EMA + (ATR * mult).
- Basis: The Exponential Moving Average (EMA).
- Lower: EMA - (ATR * mult).
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]];
}
}