The CCI has one part everyone gets wrong: the denominator uses mean absolute deviation, not standard deviation. Get that right and the rest is a typical price and a moving average. See the explainer for the concept.
What you’ll need
- Python 3.9+,
pandas/numpy high,low,closeseries.
From scratch
import numpy as np
def cci(high, low, close, period: int = 20, constant: float = 0.015):
tp = (high + low + close) / 3 # typical price
ma = tp.rolling(period).mean()
# Mean ABSOLUTE deviation of TP from its moving average (NOT std dev).
mad = tp.rolling(period).apply(
lambda x: np.abs(x - x.mean()).mean(), raw=True)
return (tp - ma) / (constant * mad)
tp.rolling(period).apply(lambda x: np.abs(x - x.mean()).mean()) is the mean
absolute deviation — the average distance of each windowed typical price from the
window’s mean. That’s what Lambert specified, and it’s the line that trips up most
implementations.
Both readings, in code
Because CCI supports opposite strategies, spell out which you mean:
c = cci(df["high"], df["low"], df["close"])
# Breakout reading (Lambert's lean): enter with a push above +100.
df["breakout_long"] = (c > 100) & (c.shift(1) <= 100)
# Reversal reading: fade the extreme back toward zero.
df["reversal_long"] = (c > -100) & (c.shift(1) <= -100)
Pick one to match your regime and backtest it in AlgoGen — they behave very differently.
Gotchas
- Mean absolute deviation, not standard deviation.
x.std()gives the wrong denominator and a CCI that matches no platform. Useabs(x - x.mean()).mean(). - The 0.015 constant. It calibrates ~70–80% of values into ±100; don’t drop it or “round” it.
- Typical price.
(H+L+C)/3, not close alone. - Unbounded. Don’t clip CCI to ±100; the excursions beyond are the signal.
Pair it with a regime filter
Since the reversal reading dies in trends and the breakout reading dies in ranges, gate CCI on an ADX-style regime check:
# Only fade +/-100 when NOT strongly trending; ride breakouts when trending.
a = adx(df["high"], df["low"], df["close"])["adx"] # from the ADX post
ranging = a < 20
df["fade_short"] = ranging & (c > 100) & (c.shift(1) <= 100)
df["ride_long"] = (~ranging) & (c > 100) & (c.shift(1) <= 100)
The same +100 cross means “fade” in a range and “ride” in a trend — the regime filter picks which. That’s the single most important thing to get right with CCI.
The lazy one-liner
import pandas_ta as ta
df["cci20"] = ta.cci(df["high"], df["low"], df["close"], length=20)
Same indicator elsewhere: MQL5, Pine Script, EasyLanguage, NinjaScript.
This post is educational, not financial advice. Indicators describe the past; they don’t predict the future. Backtest anything before you risk real money on it.
Sources and further reading
- Windowing operations (pandas documentation)
