Indicator Formulas, Cross-Checked
Every formula below was implemented independently in NumPy from its definition, then computed again with pandas_ta 0.4.71b0 on 750 real AAPL bars, and the two compared point by point. Agreement is the evidence that the formula on this page is the formula a library actually implements.
Finding 1 — a correct RSI still needs ~117 bars
Wilder's smoothing is recursive, so the seed value decays rather than disappearing after the window. Two correct implementations that seed differently took 117 bars to agree to within 0.01, and about 184 to match to machine precision. If your RSI is right and still disagrees with your platform, this is usually why — not the formula.
Finding 2 — pandas_ta's Bollinger bands use the sample standard deviation
The bands were computed both ways against the same library output. Only ddof = 1 matched, so pandas_ta's bands sit slightly wider than Bollinger's own population-form definition.
| population (Bollinger's own definition) | 4.24e+0 | does not match |
| sample (pandas .std default) | 2.54e-11 | matches |
Relative Strength Index
length = 14
gain_t = max(close_t - close_{t-1}, 0)
loss_t = max(close_{t-1} - close_t, 0)
avg_gain = Wilder(gain, 14), avg_loss = Wilder(loss, 14)
RS = avg_gain / avg_loss
RSI = 100 - 100 / (1 + RS)What trips people up
The averages are WILDER's smoothing, not a simple or standard exponential average. Wilder's is an EMA with alpha = 1/n, while a standard EMA uses 2/(n+1) -- for n = 14 that is 0.0714 against 0.1333, nearly double. An RSI built on a plain EMA looks right, moves right, and disagrees with every charting platform. This is the single most common RSI implementation bug. Second, because Wilder's smoothing is recursive the SEED persists far longer than the 14-bar window suggests -- see the measured warm-up below.
| Series | pandas_ta column | Bars to ±0.01 | Max diff after warm-up | Latest |
|---|---|---|---|---|
| RSI | RSI_14 | 117 | 7.48e-5 | 53.9971 |
Average True Range
length = 14
TR_t = max(high_t - low_t, |high_t - close_{t-1}|, |low_t - close_{t-1}|)
ATR = Wilder(TR, 14)What trips people up
True range uses the PREVIOUS close, which is what makes it capture overnight gaps -- a high-minus-low range cannot. The smoothing is Wilder's again, not a simple moving average of TR.
| Series | pandas_ta column | Bars to ±0.01 | Max diff after warm-up | Latest |
|---|---|---|---|---|
| ATR | ATRr_14 | exact from bar 1 | 6.22e-15 | 7.6528 |
Average Directional Index
length = 14
up = high_t - high_{t-1}, down = low_{t-1} - low_t
+DM = up if up > down and up > 0 else 0
-DM = down if down > up and down > 0 else 0
+DI = 100 * Wilder(+DM, 14) / ATR, -DI = 100 * Wilder(-DM, 14) / ATR
DX = 100 * |+DI - -DI| / (+DI + -DI)
ADX = Wilder(DX, 14)What trips people up
ADX is smoothed TWICE -- once into the DI values and again over DX -- so it needs roughly 2n bars before it means anything, and implementations differ in where they seed the second pass. Note also that only ONE of +DM and -DM can be non-zero on any bar; computing both from the raw moves without that exclusive rule is a frequent error.
| Series | pandas_ta column | Bars to ±0.01 | Max diff after warm-up | Latest |
|---|---|---|---|---|
| ADX | ADX_14 | 116 | 4.92e-5 | 19.9304 |
| DMP | DMP_14 | 96 | 4.93e-5 | 27.7636 |
| DMN | DMN_14 | 99 | 4.72e-5 | 25.4345 |
Moving Average Convergence Divergence
fast = 12, slow = 26, signal = 9
MACD = EMA(close, 12) - EMA(close, 26)
signal = EMA(MACD, 9)
histogram = MACD - signalWhat trips people up
These are standard EMAs with alpha = 2/(n+1), NOT Wilder's -- the opposite of RSI and ATR. The signal line is an EMA of the MACD line, not of price, and how its warm-up is seeded is where implementations diverge slightly in the first few dozen bars.
| Series | pandas_ta column | Bars to ±0.01 | Max diff after warm-up | Latest |
|---|---|---|---|---|
| MACD | MACD_12_26_9 | exact from bar 1 | 0 | -1.6424 |
| MACDs | MACDs_12_26_9 | exact from bar 1 | 0 | -0.9288 |
| MACDh | MACDh_12_26_9 | exact from bar 1 | 0 | -0.7135 |
Bollinger Bands
length = 5, stdev = 2 (pandas_ta defaults)
middle = SMA(close, n)
upper = middle + k * stdev(close, n)
lower = middle - k * stdev(close, n)What trips people up
Which standard deviation? Bollinger's own definition and most charting platforms use the POPULATION form (ddof = 0); pandas_ta computes the bands with pandas' .std(), which defaults to the SAMPLE form (ddof = 1). That was measured here, not assumed -- the bands were computed both ways against the same library output, and only ddof = 1 matched. Because the sample form has the smaller divisor it gives a larger deviation, so pandas_ta's bands are slightly WIDER than the classical definition, and the gap grows as the window shrinks. NumPy's np.std defaults the other way (ddof = 0), so hand-rolled NumPy code and pandas_ta disagree by default. Note also that pandas_ta 0.4.x defaults to length 5, not the conventional 20.
| Series | pandas_ta column | Bars to ±0.01 | Max diff after warm-up | Latest |
|---|---|---|---|---|
| BBL | BBL_5_2.0_2.0 | exact from bar 1 | 2.97e-11 | 298.8771 |
| BBM | BBM_5_2.0_2.0 | exact from bar 1 | 1.03e-11 | 308.7280 |
| BBU | BBU_5_2.0_2.0 | exact from bar 1 | 2.54e-11 | 318.5789 |
Stochastic Oscillator
k = 14, d = 3, smooth_k = 3
raw %K = 100 * (close - lowest_low(n)) / (highest_high(n) - lowest_low(n))
%K = SMA(raw %K, smooth_k)
%D = SMA(%K, d)What trips people up
What most references call %K is already SMOOTHED -- the unsmoothed version is 'raw %K' or 'fast %K'. Reading the formula literally and comparing to a platform's %K compares two different series. The extremes also use the period's high and low, not closes.
| Series | pandas_ta column | Bars to ±0.01 | Max diff after warm-up | Latest |
|---|---|---|---|---|
| STOCHk | STOCHk_14_3_3 | exact from bar 1 | 4.26e-14 | 43.4120 |
| STOCHd | STOCHd_14_3_3 | exact from bar 1 | 4.26e-14 | 25.1495 |
Wilder's smoothing, in code
Three of the six indicators here depend on it, and it is the piece most published formulas leave out. It is an exponential average with alpha = 1/n, seeded with a simple average of the first n values:
import numpy as np
def wilder(x: np.ndarray, n: int) -> np.ndarray:
"""Wilder's smoothing (RMA). Not the same as an EMA of the same length."""
out = np.full_like(x, np.nan, dtype=float)
out[n - 1] = np.nanmean(x[:n]) # seed: simple average of the first n
for i in range(n, len(x)):
out[i] = out[i - 1] + (x[i] - out[i - 1]) / n
return out
# Equivalent to an EMA with alpha = 1/n. A standard EMA uses 2/(n+1):
# n = 14 -> Wilder alpha = 0.0714, EMA alpha = 0.1333The seed choice is what causes the long warm-up. Different libraries seed differently — some with a simple average, some by running the recursion from the first observation — and because the filter is recursive those choices take hundreds of bars to wash out, not fourteen.
Method
Each formula is implemented independently here in plain NumPy from its definition, then computed again with pandas_ta on the same real bars, and the two are compared over every point where both are defined. Agreement is evidence the published formula is the one the library implements; disagreement is published as a finding rather than hidden.
Agreement is judged on the converged region, with the warm-up measured separately. That distinction is the point: a single max-difference over the whole series conflates "the formula is wrong" with "the first bars are seeded differently", and those are completely different problems for someone implementing this. Tolerance: relative difference below 1e-6 against the series' own scale.
Regenerate with python -m pipeline.indicator_verify. Every figure is tagged with the library version that produced it, so it goes visibly stale rather than quietly wrong.
Frequently asked questions
Why does my RSI not match TradingView or my broker?+
Two reasons, both measured on this page. First, the averages must use Wilder's smoothing (an EMA with alpha = 1/n), not a standard EMA (alpha = 2/(n+1)) or a simple average — for n = 14 that is 0.0714 against 0.1333, so a plain EMA gives a plausible but wrong RSI. Second, Wilder's smoothing is recursive, so the starting value persists: an independent implementation and pandas_ta took 117 bars to agree to within 0.01 on real data. If you feed 100 bars in, your last value is still visibly wrong regardless of a correct formula.
How much history does an RSI or ADX need?+
Far more than the period suggests. Measured against pandas_ta on real daily bars, RSI took about 117 bars and ADX about 116 bars before the two implementations agreed to within 0.01, and roughly 180 before they matched to machine precision. The common advice of "n + 1 bars" or "2n bars for ADX" produces values that are defined but not yet correct.
Which standard deviation do Bollinger Bands use?+
pandas_ta computes the bands with pandas' .std(), which defaults to ddof=1 -- the SAMPLE standard deviation. Bollinger's own definition and most charting platforms use the population form (ddof=0). Both were computed against the same library output; the match is measured, not assumed.
Which indicators use Wilder smoothing and which use a normal EMA?+
RSI, ATR and ADX use Wilder's (alpha = 1/n). MACD uses standard EMAs (alpha = 2/(n+1)). Bollinger and Stochastic use simple moving averages. Mixing these up is the most common source of "my numbers are close but not equal" — the shapes look right, so the bug survives a visual check.
How were these verified?+
Each formula is implemented independently here in plain NumPy from its definition, then computed again with pandas_ta on the same real bars, and the two are compared over every point where both are defined. Agreement is evidence the published formula is the one the library implements; disagreement is published as a finding rather than hidden. Measured on 750 real AAPL daily bars (2023-08-23 to 2026-08-19) against pandas_ta 0.4.71b0.
Related
Measured on 750 AAPL daily bars (2023-08-23 to 2026-08-19) against pandas_ta 0.4.71b0, generated 2026-08-20T16:22:25+00:00. Indicator values are arithmetic on historical prices and are not investment advice or trading signals.