Indicators in Code: Vectorised SMA, EMA, RSI, ATR and Bollinger Bands, Verified against a Charting Platform
Write vectorised pandas functions for the five most used indicators and learn why their values can differ from a charting platform and how to reconcile them.
What you will learn
- How
rollingandewmimplement SMA, EMA, Wilder smoothing, ATR and Bollinger Bands in a few lines each. - Which parameters (
adjust,alpha,ddof) silently change the numbers. - Why your RSI or EMA differs from the charting platform in the first bars and how to compare the two correctly.
- How to organise indicators as pure functions in
src/indicators.pyfor reuse and testing.
Explanation
The Technical Analysis in Depth course explained what these indicators mean; here the goal is correct, fast, testable code. Each function takes a Series and returns a Series on the same index.
SMA (simple moving average): def sma(s, n): return s.rolling(n).mean().
EMA (exponential moving average): def ema(s, n): return s.ewm(span=n, adjust=False).mean(). With span=n the smoothing constant is α = 2 ÷ (n + 1); for n = 20, α = 0.0952. adjust=False gives the recursive formula EMAt = α × Pt + (1 − α) × EMAt−1 that platforms use; the default adjust=True weights differently in the first few dozen bars. pandas seeds with the first price; most platforms seed with the SMA of the first n bars. Both seeds fade; after about 3n bars the series agree closely.
RSI (relative strength index), Wilder's version:
delta = s.diff()
gain = delta.clip(lower=0); loss = -delta.clip(upper=0)
avg_gain = gain.ewm(alpha=1/n, adjust=False).mean()
avg_loss = loss.ewm(alpha=1/n, adjust=False).mean()
rsi = 100 - 100 / (1 + avg_gain / avg_loss)
Wilder's smoothing is an EMA with α = 1 ÷ n, not 2 ÷ (n + 1); using span=14 here is the most common RSI bug. When avg_loss is zero, RSI correctly becomes 100.
ATR (average true range): the true range is the largest of high − low, |high − previous close| and |low − previous close|:
pc = close.shift(1)
tr = pd.concat([high - low, (high - pc).abs(), (low - pc).abs()], axis=1).max(axis=1)
atr = tr.ewm(alpha=1/n, adjust=False).mean()
Some platforms show an SMA-based ATR instead; check before comparing. Lesson 8 uses ATR for stops and sizing.
Bollinger Bands: middle = SMA(n), upper and lower = middle ± k × standard deviation over the same n bars, typically n = 20, k = 2. The trap is ddof: pandas rolling(n).std() divides by n − 1 (sample), while platforms commonly divide by n (population). Write std(ddof=0) to match. The sample value is larger, enough to turn a band "touch" into a "no touch".
Verifying against a platform. Use the same adjusted series as the platform, compute your indicator, and compare the last 100 values, because seed differences decay. Use np.allclose with a small tolerance. Persistent differences come from five usual sources: adjusted versus unadjusted data, seeding, span versus alpha, ddof, or a different input price. Fix or document the mismatch; never "tune" a parameter until the numbers agree.
Worked example
Hypothetical example: ten daily closes in euros: 20.00, 20.50, 21.00, 20.80, 21.40, 21.00, 21.60, 22.00, 21.80, 22.40.
SMA(5) on the last bar: (21.00 + 21.60 + 22.00 + 21.80 + 22.40) ÷ 5 = 108.80 ÷ 5 = €21.76.
Bollinger(5, 2) on the last bar. Deviations from 21.76: −0.76, −0.16, 0.24, 0.04, 0.64; squared sum 1.0720. Population variance = 1.0720 ÷ 5 = 0.2144, standard deviation 0.4630, upper = 21.76 + 2 × 0.4630 = €22.69, lower €20.83. Sample formula: 1.0720 ÷ 4 = 0.2680, standard deviation 0.5177, upper €22.80, lower €20.72.
EMA(5) with α = 2 ÷ 6 = 0.3333 and adjust=False: EMA1 = 20.00 (seed), EMA2 = 0.3333 × 20.50 + 0.6667 × 20.00 = 20.1667, EMA3 = 0.3333 × 21.00 + 0.6667 × 20.1667 = 20.4444, and so on to 21.8310 on the last bar.
True range for a bar with high €21.20, low €20.60 and previous close €20.80: max(0.60, |21.20 − 20.80| = 0.40, |20.60 − 20.80| = 0.20) = €0.60.
Common mistake
ewm(span=14) uses α = 2 ÷ 15 = 0.133, while Wilder's RSI and ATR need ewm(alpha=1/14), α = 0.0714. The span version reacts almost twice as fast and will not match any standard platform. Write the α you intend explicitly and record it in the function's docstring.Practice activity
On paper, using a 3-period Wilder RSI for illustration only (real use is 14): closes 50.00, 50.50, 51.00, 50.80. (a) List the gains and losses per bar. (b) pandas ewm(alpha=1/3, adjust=False) seeds with the first observation; compute avg_gain and avg_loss on the last bar and the RSI. (c) Explain in one sentence why a platform that seeds with the simple average of the first three changes would show a different value here, and what you would do before concluding that your code is wrong.
Show solution
(a) Changes: +0.50, +0.50, −0.20. Gains: 0.50, 0.50, 0.00. Losses: 0.00, 0.00, 0.20.
(b) α = 1/3. avg_gain: 0.50 (seed) → (1/3)(0.50) + (2/3)(0.50) = 0.50 → (1/3)(0) + (2/3)(0.50) = 0.3333. avg_loss: 0 → 0 → (1/3)(0.20) + (2/3)(0) = 0.0667. RS = 0.3333 ÷ 0.0667 = 5.0; RSI = 100 − 100 ÷ (1 + 5) = 83.33.
(c) Here the plain-average seed (gains 0.3333, losses 0.0667) happens to give the same 83.33, but in general the two seeds differ for the first few dozen bars, so compare the most recent 100 values of a long series before touching the code.
Limitations: a 3-period RSI is a teaching device; four bars cannot show the convergence behaviour that matters in practice.
Key takeaways
- SMA is
rolling(n).mean(); EMA isewm(span=n, adjust=False); Wilder smoothing for RSI and ATR isewm(alpha=1/n, adjust=False). - Bollinger Bands on most platforms use the population standard deviation: pass
ddof=0. - Seed differences fade; compare the last 100 bars with a tolerance, and look for the five usual causes of mismatch before changing code.
- Keep indicators as documented pure functions so they can be unit-tested and reused in every backtest.
Next step
Lesson 7 assembles the pieces into a vectorised backtest: signals, positions, the shift that prevents look-ahead bias, transaction costs and the equity curve.
Self-check
Answer the questions, then check your answers. You can retry as often as you like.
Automatic self-check for your own revision. Your answers are checked in your browser and are not seen or assessed by us. It is not an exam and not a qualification.
Your progress
“Complete” only means you marked the lesson as done. It is not a test of your understanding. Progress and notes are saved only in this browser on this device — not in an account.