Lesson 1.7.2 · 60 min

Your own implementation versus TA-Lib: validating results

Two correct implementations of the same RSI can differ by 20 points in their first sessions. This lesson compares the indicators from lesson 1.7.1 with TA-Lib, separates the differences that fade away (seeds and warm-up) from the ones that do not (conventions and bugs), and turns the comparison into tests.

Fig. — Absolute difference between the lesson 1.7.1 indicators and TA-Lib's on adjusted NOVA, on a log scale, over the first 330 sessions
02/04
Module
1.7 · Programmed technical analysis
Duration
60 min
Updated
Level 1 lessons
  1. 1.1 · How markets work

    1. 1.1.1 Market participants and trading venues
    2. 1.1.2 Sessions, auctions, holidays and calendars
    3. 1.1.3 Order book: bid, ask, spread and depth
    4. 1.1.4 Order types
    5. 1.1.5 Costs, settlement, leverage and margin
  2. 1.2 · Asset classes

    1. 1.2.1 Stocks: splits, dividends and corporate actions
    2. 1.2.2 ETFs: replication, tracking error, leveraged ETFs and UCITS
    3. 1.2.3 Futures: specifications, margin, roll and contango
    4. 1.2.4 Forex: pairs, pips, lots, rollover and CFDs
    5. 1.2.5 Options: calls, puts, intrinsic value and time value
    6. 1.2.6 Crypto: spot, perpetuals, funding and custody
    7. 1.2.7 Fixed income and interest rates; CFDs and leveraged products
  3. 1.3 · Market data sources

    1. 1.3.1 Types of market data: EOD, bars, ticks, order book and reference
    2. 1.3.2 Free market data sources
    3. 1.3.3 Retail and prosumer market data APIs
    4. 1.3.4 Professional market data providers
    5. 1.3.5 Crypto and on-chain data
    6. 1.3.6 Brokers as market data sources
    7. 1.3.7 Market data licensing and terms of use
    8. 1.3.8 How to choose a market data provider
  4. 1.4 · Financial data engineering

    1. 1.4.1 Downloading over REST and websockets: pagination, rate limits and retries
    2. 1.4.2 Data cleaning: missing values, bad ticks, gaps and time zones
    3. 1.4.3 Adjusting for splits and dividends: adjusted and unadjusted prices
    4. 1.4.4 Universes without survivorship bias: delistings, mergers and symbol changes
    5. 1.4.5 Building bars: time, tick, volume, dollar and imbalance
    6. 1.4.6 Continuous futures series: roll rules, difference adjustment and ratio adjustment
    7. 1.4.7 Storage: CSV, Parquet, partitioning, DuckDB, TimescaleDB and ArcticDB
    8. 1.4.8 Reproducible pipeline: download, validation, incremental updates and quality tests
  5. 1.5 · Fundamental and macro data

    1. 1.5.1 Financial statements, ratios, XBRL and SEC EDGAR
    2. 1.5.2 Macro data, economic calendars, estimates and transcripts
  6. 1.6 · Financial exploratory analysis

    1. 1.6.1 Returns, volatility, rolling correlations and drawdown
    2. 1.6.2 Stylized facts: fat tails, clustering, autocorrelation and leverage
    3. 1.6.3 Seasonality: day of the week, month, turn of the month and intraday sessions
  7. 1.7 · Programmed technical analysis

    1. 1.7.1 Technical indicators in Python
    2. 1.7.2 Your own implementation versus TA-Lib: validating results
    3. 1.7.3 Candlestick patterns and support and resistance
    4. 1.7.4 Multi-timeframe analysis without look-ahead

Why compare with a reference

Lesson 1.7.1 coded a dozen indicators in pandas. The formulas are right, but “they are right” is a claim that has to be checked: a wrong ddof, a span where an alpha belonged or a missing shift give believable, wrong numbers. The cheapest way to check is to compare them with a reference implementation, written by others and used by many people. In Python the reference is TA-Lib, the C library for technical analysis (more than 200 functions) with its Cython wrapper.

Comparing is not demanding equality. Two correct implementations can differ in how they start, and an incorrect one can match almost always. What tells one case from the other is how the difference evolves:

yesnoyesnoOwn indicatorDifferenceTA-LibTends to zero?SeedIs it constant?ConventionBug
Mermaid source
flowchart TB
  P[Own indicator] --> D[Difference]
  R[TA-Lib] --> D
  D --> Q{Tends to zero?}
  Q -- yes --> S[Seed]
  Q -- no --> C{Is it constant?}
  C -- yes --> V[Convention]
  C -- no --> E[Bug]

A difference that fades session after session is a different seed: recursive averages remember how they were initialized, and that memory is erased over time. A constant difference, in level or in proportion, is a different convention (population versus sample, initial value of a sum): neither is a bug, but you have to pick one and say so. A difference that neither converges nor is constant is a bug.

The implementations from lesson 1.7.1

Each lesson runs in a fresh process, so the first cell reloads adjusted NOVA and the second repeats the functions from lesson 1.7.1, unchanged:

IN [01]python
import numpy as np
import pandas as pd

bruto = pd.read_csv("nova_diario_sin_ajustar.csv",
                    parse_dates=["fecha"], index_col="fecha")
eventos = pd.read_csv("nova_eventos.csv", parse_dates=["fecha_ex"])

def factores(bruto, eventos):
    cierre_ant = bruto["cierre"].shift(1)
    f_precio = pd.Series(1.0, index=bruto.index)
    f_volumen = pd.Series(1.0, index=bruto.index)
    for e in eventos.itertuples():
        antes = bruto.index < e.fecha_ex
        if e.tipo == "split":
            f_precio[antes] /= e.ratio
            f_volumen[antes] *= e.ratio
        else:
            f_precio[antes] *= 1 - e.importe / cierre_ant[e.fecha_ex]
    return f_precio, f_volumen

f_precio, f_volumen = factores(bruto, eventos)
px = bruto[["apertura", "maximo", "minimo", "cierre"]].mul(
    f_precio, axis=0)
px.columns = ["o", "h", "l", "c"]
px["v"] = (bruto["volumen"] * f_volumen).astype("int64")
o, h, l, c, v = (px[k] for k in ["o", "h", "l", "c", "v"])
print(len(px), "sessions,", px.index[0].date(), "to",
      px.index[-1].date())
OUT [01]Adjusted NOVA
2011 sessions, 2018-01-02 to 2025-12-31
IN [02]python
def sma(x, n):
    return x.rolling(n).mean()

def ema(x, n):
    return x.ewm(span=n, adjust=False, min_periods=n).mean()

def wilder(x, n):
    return x.ewm(alpha=1 / n, adjust=False, min_periods=n).mean()

def rsi(x, n=14):
    d = x.diff()
    sube = wilder(d.clip(lower=0), n)
    baja = wilder((-d).clip(lower=0), n)
    return 100 - 100 / (1 + sube / baja)

def macd(x, rapida=12, lenta=26, senal=9):
    linea = ema(x, rapida) - ema(x, lenta)
    s = ema(linea, senal)
    return pd.DataFrame({"macd": linea, "senal": s,
                         "hist": linea - s})

def rango_verdadero(h, l, c):
    c_ant = c.shift(1)
    tramos = pd.concat([h - l, (h - c_ant).abs(),
                        (l - c_ant).abs()], axis=1)
    return tramos.max(axis=1, skipna=False)

def atr(h, l, c, n=14):
    return wilder(rango_verdadero(h, l, c), n)

def bollinger(x, n=20, k=2, ddof=0):
    media = sma(x, n)
    sd = x.rolling(n).std(ddof=ddof)
    return pd.DataFrame({"media": media, "sup": media + k * sd,
                         "inf": media - k * sd})

def estocastico(c, h, l, n=14, k=3, d=3):
    minimo, maximo = l.rolling(n).min(), h.rolling(n).max()
    k_rapido = 100 * (c - minimo) / (maximo - minimo)
    k_lento = k_rapido.rolling(k).mean()
    return pd.DataFrame({"k": k_lento, "d": k_lento.rolling(d).mean()})

def adx(h, l, c, n=14):
    sube, baja = h.diff(), -l.diff()
    dm_mas = sube.where((sube > baja) & (sube > 0), 0.0)
    dm_menos = baja.where((baja > sube) & (baja > 0), 0.0)
    a = atr(h, l, c, n)
    di_mas = 100 * wilder(dm_mas, n) / a
    di_menos = 100 * wilder(dm_menos, n) / a
    dx = 100 * (di_mas - di_menos).abs() / (di_mas + di_menos)
    return wilder(dx, n)

def obv(c, v):
    return (np.sign(c.diff()).fillna(0) * v).cumsum()

def kama(x, n=10, rapida=2, lenta=30):
    er = (x - x.shift(n)).abs() / x.diff().abs().rolling(n).sum()
    a_r, a_l = 2 / (rapida + 1), 2 / (lenta + 1)
    s = ((er * (a_r - a_l) + a_l) ** 2).to_numpy()
    p = x.to_numpy()
    out = np.full(len(p), np.nan)
    out[n] = p[n]
    for i in range(n + 1, len(p)):
        out[i] = out[i - 1] + s[i] * (p[i] - out[i - 1])
    return pd.Series(out, index=x.index)

TA-Lib from Python

Since version 0.6.5, the TA-Lib package on PyPI publishes wheels that include the C library, for Linux, macOS and Windows (those for 0.8.2, for CPython 3.11 to 3.15): uv add ta-lib is enough, with nothing to compile. The course uses 0.8.2 (the exact version is in curso-code/uv.lock). It is imported as talib:

IN [03]python
import talib

print("TA-Lib", talib.__version__)
grupos = talib.get_function_groups()
print(sum(len(f) for f in grupos.values()), "functions")
pd.Series({g: len(f) for g, f in grupos.items()}).sort_values()
OUT [03]Version and function groups
TA-Lib 0.8.2
201 functions
Cycle Indicators          5
Price Transform           6
Volatility Indicators     7
Statistic Functions      11
Volume Indicators        12
Math Operators           12
Math Transform           15
Overlap Studies          25
Momentum Indicators      47
Pattern Recognition      61
dtype: int64

The functions take NumPy arrays (or pandas Series, in which case they return Series with the same index) and the parameters as keyword arguments: talib.RSI(cierre, timeperiod=14). The first values, those of the lookback, come out empty. A requirement that surprises people: the arrays have to be float64. A Parquet file saved as float32 to save space fails:

IN [04]python
talib.SMA(c.to_numpy().astype("float32"), timeperiod=20)
OUT [04]TA-Lib requires float64
Exception: input array type is not double

The best-known pure-Python alternative, pandas-ta, is not in the course environment, and not by choice: its version 0.4.71b0 (September 2025) pins numba==0.61.2, and that numba version requires numpy<2.3 and only publishes wheels up to Python 3.13. With the course's numpy 2.5 and Python 3.14, those requirements cannot be met. In an environment with Python 3.13 and numpy 2.2, it would be used like this:

IN [05]con_pandas_ta.pypython
import pandas_ta as ta  # does not install with numpy >= 2.3 or Python 3.14

rsi_pta = ta.rsi(px["c"], length=14)            # Series RSI_14
bandas_pta = ta.bbands(px["c"], length=20, std=2)  # DataFrame
atr_pta = ta.atr(px["h"], px["l"], px["c"], length=14)

The checks in this lesson apply equally to pandas-ta, to Polars or to any other reference: what is compared is numbers, not libraries.

The first comparison

A comparison function summarizes four things: how many empty values each version has, the maximum difference, the difference in the last session and the first session from which the difference no longer exceeds 10−810^{-8}:

IN [06]python
C, H, L = c.to_numpy(), h.to_numpy(), l.to_numpy()

def comparar(propio, ref, tol=1e-8):
    a, b = np.asarray(propio, float), np.asarray(ref, float)
    d = np.abs(a - b)
    malas = np.flatnonzero(~(d <= tol) & ~np.isnan(b))
    return {"nan_propio": int(np.isnan(a).sum()),
            "nan_talib": int(np.isnan(b).sum()),
            "dif_max": np.nanmax(d), "dif_final": d[-1],
            "coincide_desde": malas[-1] + 1 if len(malas) else 0}

def resumen(casos):
    t = pd.DataFrame({k: comparar(*par) for k, par in casos.items()}).T
    enteros = ["nan_propio", "nan_talib", "coincide_desde"]
    return t.astype({k: int for k in enteros})

macd_ta, senal_ta, _ = talib.MACD(C)
casos = {
    "SMA 20": (sma(c, 20), talib.SMA(C, 20)),
    "Bollinger upper": (bollinger(c)["sup"], talib.BBANDS(C, 20)[0]),
    "Stochastic %K": (estocastico(c, h, l)["k"],
                      talib.STOCH(H, L, C, 14, 3, 0, 3, 0)[0]),
    "EMA 20": (ema(c, 20), talib.EMA(C, 20)),
    "RSI 14": (rsi(c), talib.RSI(C, 14)),
    "ATR 14": (atr(h, l, c), talib.ATR(H, L, C, 14)),
    "ADX 14": (adx(h, l, c), talib.ADX(H, L, C, 14)),
    "KAMA 10": (kama(c), talib.KAMA(C, 10)),
    "MACD": (macd(c)["macd"], macd_ta),
    "Bollinger ddof=1": (bollinger(c, ddof=1)["sup"],
                         talib.BBANDS(C, 20)[0]),
    "OBV": (obv(c, v), talib.OBV(C, v.to_numpy().astype(float))),
}
resumen(casos)
OUT [06]Own versus TA-Lib
                  nan_propio  nan_talib       dif_max     dif_final  coincide_desde
SMA 20                    19         19  8.526513e-14  0.000000e+00               0
Bollinger upper           19         19  4.206413e-12  1.094236e-12               0
Stochastic %K             15         17  1.421085e-13  4.973799e-14               0
EMA 20                    19         19  5.830524e-01  0.000000e+00             198
RSI 14                    14         14  1.990207e+01  7.105427e-15             303
ATR 14                    14         14  6.979309e-02  4.440892e-16             227
ADX 14                    27         27  4.008239e+00  1.953993e-14             324
KAMA 10                   10         10  7.225559e-01  0.000000e+00             214
MACD                      25         33  3.762379e-01  0.000000e+00             257
Bollinger ddof=1          19         19  4.732804e-01  7.238154e-02            2011
OBV                        0          0  1.123733e+07  1.123733e+07            2011

The table is read in blocks.

  • SMA, Bollinger and stochastic match from the first value except for floating-point noise (differences of the order of 10−1210^{-12} or smaller, lesson 0.3.1). For the stochastic, TA-Lib leaves two more empty values: it does not return the slow %K until it can also compute the %D. It is an alignment convention, not a calculation one.
  • EMA, RSI, ATR, ADX, KAMA and MACD start far apart (19.9 RSI points, 4.0 ADX points) and end up equal: a final difference of 10−1410^{-14}. They are recursive averages with different seeds. TA-Lib's MACD, moreover, has 33 empty values and ours 25.
  • Bollinger with ddof=1 and OBV never converge: the final difference is not zero. They are conventions.

Seeds: why recursive averages take a while to match

An EMA needs a previous value to compute the first one. ewm(adjust=False) uses the first data point of the series as the seed and applies the recursion from there; min_periods only hides the first results. TA-Lib seeds with the simple mean of the first nn data points and starts the recursion at data point nn. From then on, both versions apply the same formula, and the difference between them is multiplied by (1−α)(1-\alpha) every session:

EMAtA−EMAtB=(1−α) t−t0(EMAt0A−EMAt0B)\mathrm{EMA}^{A}_t - \mathrm{EMA}^{B}_t = (1-\alpha)^{\,t-t_0}\left(\mathrm{EMA}^{A}_{t_0} - \mathrm{EMA}^{B}_{t_0}\right)

With α=2/21\alpha = 2/21, the difference is divided by ten every 23 sessions. For the RSI and the ATR, with the Wilder average (α=1/14\alpha = 1/14), every 31. The figure at the top of the page shows it on a log scale: straight lines that drop an order of magnitude every 23 or every 31 sessions (the EMA's reaches 10−1410^{-14}, floating-point noise, before session 330), except Bollinger with ddof=1, which stays up.

If you want exact equality, replicating the seed is enough. The trick with pandas: a series with empty values up to position n−2n-2, the simple mean of the first nn data points at n−1n-1 and the original data after that. ewm skips the leading empty values and starts the recursion at the seed:

IN [07]python
def sembrar(x, n, inicio=0):
    """Simple mean of x[inicio:inicio+n] at inicio+n-1; NaN before."""
    s = x.astype(float).copy()
    s.iloc[: inicio + n - 1] = np.nan
    s.iloc[inicio + n - 1] = x.iloc[inicio:inicio + n].mean()
    return s

def ema_ta(x, n):
    return sembrar(x, n).ewm(span=n, adjust=False).mean()

def wilder_ta(x, n):
    # the first change (or true range) is at position 1
    return sembrar(x, n, inicio=1).ewm(alpha=1 / n, adjust=False).mean()

def rsi_ta(x, n=14):
    d = x.diff()
    sube = wilder_ta(d.clip(lower=0), n)
    baja = wilder_ta((-d).clip(lower=0), n)
    return 100 - 100 / (1 + sube / baja)

def atr_ta(h, l, c, n=14):
    return wilder_ta(rango_verdadero(h, l, c), n)

exactos = {
    "EMA 20": (ema_ta(c, 20), talib.EMA(C, 20)),
    "RSI 14": (rsi_ta(c), talib.RSI(C, 14)),
    "ATR 14": (atr_ta(h, l, c), talib.ATR(H, L, C, 14)),
}
resumen(exactos)
OUT [07]Replicating TA-Lib's seed
        nan_propio  nan_talib       dif_max     dif_final  coincide_desde
EMA 20          19         19  2.842171e-14  0.000000e+00               0
RSI 14          14         14  4.263256e-14  7.105427e-15               0
ATR 14          14         14  2.664535e-15  4.440892e-16               0

Now they match from the first value and with the same empty values. The MACD has one more subtlety: TA-Lib aligns the two EMAs. It does not seed the fast one (12) with the first 12 closes, but with the 12 that end where the slow one's (26) seed ends, and the signal line (9) with the first 9 MACD values. That is why its first value comes out at position 33 = 25 + 8:

Fig. — How TA-Lib seeds MACD (12, 26, 9): which closes go into each average's seed (bars) and where the recursion runs from (dark grey)
IN [08]python
def ema_desde(x, n, fin):
    """EMA seeded with the simple mean of x[fin-n+1 .. fin]."""
    s = x.astype(float).copy()
    s.iloc[:fin] = np.nan
    s.iloc[fin] = x.iloc[fin - n + 1: fin + 1].mean()
    return s.ewm(span=n, adjust=False).mean()

ingenuo = ema_ta(c, 12) - ema_ta(c, 26)
alineado = ema_desde(c, 12, 25) - ema_desde(c, 26, 25)
senal = ema_desde(alineado, 9, 33)
resumen({
    "Naive MACD": (ingenuo, macd_ta),
    "Aligned MACD": (alineado.where(senal.notna()), macd_ta),
    "Aligned signal": (senal, senal_ta),
})
OUT [08]MACD with the aligned seed
                nan_propio  nan_talib       dif_max     dif_final  coincide_desde
Naive MACD              25         33  8.057404e-02  0.000000e+00             129
Aligned MACD            33         33  4.973799e-14  0.000000e+00               0
Aligned signal          33         33  3.996803e-14  8.104628e-15               0

Seeding each EMA with its own first data points (the “naive” MACD) leaves a difference of 0.08 that fades away; with the aligned seed, equality is exact from the first value. Should you always copy TA-Lib's conventions? No. You should know them: if the system is computed with pandas and monitored with a platform that uses TA-Lib, the first weeks of any new series will not match, and it is worth knowing why before someone asks.

Conventions that do not fade

The Bollinger difference with ddof=1 does not tend to zero because it is not a seed: it is the sample standard deviation (divides by n−1n-1) versus the population one (divides by nn). The ratio between the two is constant, n/(n−1)\sqrt{n/(n-1)}. The OBV one is something else: TA-Lib starts the sum with the first session's volume and our version with zero.

IN [09]python
ancho_0 = bollinger(c)["sup"] - bollinger(c)["media"]
ancho_1 = bollinger(c, ddof=1)["sup"] - bollinger(c)["media"]
cociente = (ancho_1 / ancho_0).dropna()
print(f"Width ddof=1 / ddof=0: {cociente.min():.6f} to "
      f"{cociente.max():.6f}; square root of 20/19 = {np.sqrt(20 / 19):.6f}")

dif_obv = talib.OBV(C, v.to_numpy().astype(float)) - obv(c, v)
print(f"OBV TA-Lib - own: from {dif_obv.min():,.0f}"
      f" to {dif_obv.max():,.0f}")
print(f"Volume of the first session: {v.iloc[0]:,}")
OUT [09]Bollinger with ddof=1 and OBV: constant differences
Width ddof=1 / ddof=0: 1.025978 to 1.025978; square root of 20/19 = 1.025978
OBV TA-Lib - own: from 11,237,332 to 11,237,332
Volume of the first session: 11,237,332

Neither of the two is a bug. Bollinger defined his bands with the population deviation and TA-Lib follows it; pandas uses the sample one by default because it is the statistician's. The OBV has no meaningful level, so it does not matter where the sum starts: its slope is the same. What is a bug is not knowing which one you are using.

DifferenceExampleDoes it converge?What to do
Seed of a recursive averageEMA, RSI, ATR, ADX, KAMAYes, geometricallyDiscard the warm-up or replicate the seed
Output alignmentMACD (33 versus 25 empty), stochasticYes, or they already matchCompare only where both have a value
Calculation conventionddof=0 versus ddof=1No, fixed ratioChoose and document
Level conventionOBV with or without the first volumeNo, fixed offsetUse differences, not levels
Bugspan instead of alpha, a missing shiftNo, and not constant eitherFix it

The unstable period

The seed is not just a difference between libraries. With the same library, the RSI of a session depends on where the series starts, because the seed is computed with the first data available. This is what TA-Lib calls the unstable period. You can see it by computing NOVA's RSI on the full series and on the series starting 500 sessions later:

IN [10]python
completo = talib.RSI(C, 14)[500:]
tardio = talib.RSI(C[500:], 14)
dif = np.abs(tardio - completo)
for k in [14, 50, 100, 200]:
    print(f"{k:>3} sessions after the start: {dif[k]:.6f} points")
OUT [10]The RSI depends on where the series starts
 14 sessions after the start: 8.931394 points
 50 sessions after the start: 1.427800 points
100 sessions after the start: 0.013082 points
200 sessions after the start: 0.000045 points

Almost nine RSI points on the first value, one hundredth after 100 sessions. If one system starts with two months of history and another with ten years, for weeks they do not see the same RSI. TA-Lib lets you hide that stretch with set_unstable_period, which adds empty values at the start of the functions flagged as unstable:

IN [11]python
normal = talib.RSI(C, 14)
talib.set_unstable_period("RSI", 100)
con_inestable = talib.RSI(C, 14)
talib.set_unstable_period("RSI", 0)  # the setting is global: reset to 0

print("Empty:", np.isnan(normal).sum(), "->",
      np.isnan(con_inestable).sum())
print("Maximum difference where there is a value:",
      np.nanmax(np.abs(con_inestable - normal)))

from talib import abstract
inestables = [f for f in talib.get_functions()
              if "Function has an unstable period"
              in abstract.Function(f).info["function_flags"]]
print(len(inestables), "unstable functions:", ", ".join(inestables))
OUT [11]set_unstable_period hides, it does not recompute
Empty: 14 -> 114
Maximum difference where there is a value: 0.0
23 unstable functions: HT_DCPERIOD, HT_DCPHASE, HT_PHASOR, HT_SINE, HT_TRENDMODE, ADX, CMO, DX, MINUS_DI, MINUS_DM, PLUS_DI, PLUS_DM, RSI, EMA, HT_TRENDLINE, KAMA, MAMA, RMA, T3, HA, ATR, NATR, RVI

With an unstable period of 100, the RSI goes from 14 to 114 empty values and the ones it returns are identical: TA-Lib keeps computing from the first data point and only hides the results that depend on the seed. It does not make the RSI converge sooner; what it does is not show the values that have not converged yet. The list of unstable functions is the expected one: all those with an exponential or Wilder average inside (EMA, RSI, ATR, ADX and its components, KAMA, T3), the Hilbert transforms and a few new ones from version 0.8 (RMA, HA, RVI).

How much warm-up does each indicator need? It can be measured with the same comparison: how many sessions it takes the series that starts 500 sessions later to match the full one with a relative error below 0.01%:

IN [12]python
funciones = {
    "SMA 20": lambda C, H, L: talib.SMA(C, 20),
    "Bollinger 20": lambda C, H, L: talib.BBANDS(C, 20)[0],
    "EMA 20": lambda C, H, L: talib.EMA(C, 20),
    "KAMA 10": lambda C, H, L: talib.KAMA(C, 10),
    "ATR 14": lambda C, H, L: talib.ATR(H, L, C, 14),
    "RSI 14": lambda C, H, L: talib.RSI(C, 14),
    "MACD": lambda C, H, L: talib.MACD(C)[0],
    "ADX 14": lambda C, H, L: talib.ADX(H, L, C, 14),
}
filas = {}
for nombre, f in funciones.items():
    completo = f(C, H, L)[500:]
    tardio = f(C[500:], H[500:], L[500:])
    escala = np.nanmean(np.abs(completo))
    rel = np.abs(tardio - completo) / escala
    malas = np.flatnonzero(~(rel < 1e-4))
    filas[nombre] = {"lookback": int(np.isnan(tardio).sum()),
                     "coincide_desde": int(malas[-1] + 1)}
calentamiento = pd.DataFrame(filas).T
calentamiento["inestable"] = (calentamiento["coincide_desde"]
                              - calentamiento["lookback"])
calentamiento
OUT [12]Warm-up of each indicator
              lookback  coincide_desde  inestable
SMA 20              19              19          0
Bollinger 20        19              19          0
EMA 20              19              45         26
KAMA 10             10              50         40
ATR 14              14             120        106
RSI 14              14             120        106
MACD                33             110         77
ADX 14              27             142        115
Fig. — Warm-up of each TA-Lib indicator on NOVA: empty lookback values and extra sessions until the series starting 500 sessions later matches the full one

The SMA and Bollinger have no unstable period: with 20 data points, their value is already final. Recursive averages need extra sessions for the seed to stop showing at 0.01%: 26 for the 20-period EMA, 40 for KAMA, 77 for the MACD, 106 for the 14-period RSI and ATR, and 115 for the ADX, which chains two Wilder averages. The Wilder average forgets slowly (α=1/14\alpha = 1/14), hence the rule of thumb: load at least ten times the period of the slowest average before the first session that counts (150 sessions for a 14-period RSI) and check it with this cell for the specific combination of indicators.

From backtest to real time: the streaming API

A live system does not recompute the RSI of the whole history with each new bar. TA-Lib 0.8 comes with a streaming API: you open a handle with the history (one pass) and then each closed bar is added with update, in constant time. The documentation guarantees that each value is identical to the normal function's; it is checked like everything else:

IN [13]python
from talib import stream

s = stream.RSI(C[:-5], timeperiod=14)  # history without the last 5
en_vivo = [s.update(x) for x in C[-5:]]  # 5 closed bars, one at a time
print("Stream:  ", np.round(en_vivo, 4))
print("Function:", np.round(talib.RSI(C, 14)[-5:], 4))
print("peek with a bar in progress:", round(s.peek(C[-1] * 1.01), 4),
      "| stored value:", round(s.value, 4))
OUT [13]stream.RSI versus talib.RSI
Stream:   [58.7029 51.5    50.3727 50.0916 50.2113]
Function: [58.7029 51.5    50.3727 50.0916 50.2113]
peek with a bar in progress: 54.241 | stored value: 50.2113

update adds a closed bar and stores the state; peek computes the value a bar that is still forming would give, without storing anything. That is the distinction that matters in real time: a signal computed with peek on the forming bar uses a price that is not yet the close, and in a backtest with closed bars that signal does not exist (lesson 1.7.4).

Historystream.RSIupdate: closedpeek: in progress
Mermaid source
flowchart LR
  H[History] --> O[stream.RSI]
  O --> U[update: closed]
  O --> P[peek: in progress]

From the comparison to tests

What was checked by hand is lost as soon as someone touches a function. Tests pin it down. With pytest (lesson 0.4.5), a test is a function whose name starts with test_ and that uses assert; the cell defines four kinds of test for the indicators and runs them the way pytest would:

IN [14]python
CALENTAMIENTO = 300

def tras_calentamiento(x):
    return np.asarray(x, float)[CALENTAMIENTO:]

def test_igual_que_talib_tras_calentamiento():
    for propio, ref in [(ema(c, 20), talib.EMA(C, 20)),
                        (rsi(c), talib.RSI(C, 14)),
                        (atr(h, l, c), talib.ATR(H, L, C, 14))]:
        np.testing.assert_allclose(tras_calentamiento(propio),
                                   tras_calentamiento(ref),
                                   rtol=0, atol=1e-6)

def test_semilla_exacta_y_mismos_vacios():
    propio, ref = rsi_ta(c).to_numpy(), talib.RSI(C, 14)
    assert (np.isnan(propio) == np.isnan(ref)).all()
    np.testing.assert_allclose(propio, ref, rtol=0, atol=1e-10)

def test_rangos_validos():
    r = rsi(c).dropna()
    assert r.between(0, 100).all()
    assert (atr(h, l, c).dropna() > 0).all()
    b = bollinger(c).dropna()
    assert (b["sup"] >= b["media"]).all()

def test_sin_anticipacion():
    corte = "2022-12-30"
    for f in (rsi, lambda x: ema(x, 20), lambda x: sma(x, 20)):
        completo, truncado = f(c).loc[:corte], f(c.loc[:corte])
        np.testing.assert_allclose(completo, truncado, equal_nan=True)

tests = [test_igual_que_talib_tras_calentamiento,
         test_semilla_exacta_y_mismos_vacios,
         test_rangos_validos, test_sin_anticipacion]
for t in tests:
    t()
    print("PASSED", t.__name__)
OUT [14]Indicator tests
PASSED test_igual_que_talib_tras_calentamiento
PASSED test_semilla_exacta_y_mismos_vacios
PASSED test_rangos_validos
PASSED test_sin_anticipacion

Four kinds of test, four different properties: equality with the reference where there should be equality (with an absolute tolerance, not ==), exact equality and the same empty values when the convention has been replicated, ranges the indicator cannot violate, and the truncation test from lesson 1.7.1. In the project they live in a test file and are parametrized by indicator:

IN [15]tests/test_indicadores.pypython
import numpy as np
import pytest
import talib

from mi_sistema.indicadores import atr, ema, rsi
from mi_sistema.datos import nova_ajustada

PX = nova_ajustada()
C, H, L = (PX[k].to_numpy() for k in ["c", "h", "l"])

@pytest.mark.parametrize("propio, ref", [
    (lambda: ema(PX["c"], 20), lambda: talib.EMA(C, 20)),
    (lambda: rsi(PX["c"]), lambda: talib.RSI(C, 14)),
    (lambda: atr(PX["h"], PX["l"], PX["c"]),
     lambda: talib.ATR(H, L, C, 14)),
])
def test_igual_que_talib(propio, ref):
    np.testing.assert_allclose(propio().to_numpy()[300:], ref()[300:],
                               rtol=0, atol=1e-6)

And they are run with uv run pytest tests/test_indicadores.py. If tomorrow someone changes alpha=1/n to span=n in the Wilder average, the first test fails with the exact difference and position.

Your own, TA-Lib or both

Own implementation (pandas)TA-Lib
TransparencyTotal: the formula is in plain sightC code; conventions in the documentation and in the code
DependenciesOnly pandas and NumPyA binary wheel (includes the C library)
CoverageWhatever you codeMore than 200 functions, 61 candlestick patterns
Input typesWhatever pandas acceptsfloat64 required
Real timeRecompute or code the recursionStreaming API with update and peek
Custom or modified indicatorsImmediateNo: you have to step outside the library

In practice both are used: TA-Lib as the reference in the tests and for the standard indicators, and your own implementation when you need a variant (an ATR in percent, an RSI on another series, an indicator that does not exist) or when you want to understand every number. What is not advisable is mixing them without knowing it: a system that computes the signal with one and the stop with the other carries two conventions at once.

Exercises

  1. Your KAMA from lesson 1.7.1 differs from talib.KAMA by 0.72 at the start. Find TA-Lib's seed and write a version that matches exactly. Hint: look at how many empty values talib.KAMA leaves and try seeding with a close.
Solution
IN [16]python
def kama_ta(x, n=10, rapida=2, lenta=30):
    er = (x - x.shift(n)).abs() / x.diff().abs().rolling(n).sum()
    a_r, a_l = 2 / (rapida + 1), 2 / (lenta + 1)
    s = ((er * (a_r - a_l) + a_l) ** 2).to_numpy()
    p = x.to_numpy()
    out = np.full(len(p), np.nan)
    previo = p[n - 1]  # seed: the close before the first output
    for i in range(n, len(p)):
        previo = previo + s[i] * (p[i] - previo)
        out[i] = previo
    return pd.Series(out, index=x.index)

resumen({"Own KAMA": (kama(c), talib.KAMA(C, 10)),
         "KAMA TA-Lib seed": (kama_ta(c), talib.KAMA(C, 10))})
OUT [16]KAMA with TA-Lib's seed
                  nan_propio  nan_talib       dif_max  dif_final  coincide_desde
Own KAMA                  10         10  7.225559e-01        0.0             214
KAMA TA-Lib seed          10         10  7.105427e-15        0.0               0

TA-Lib leaves 10 empty values and its first value, at position 10, already applies the recursion: it starts from the close at position 9. The version from lesson 1.7.1 copied the close at position 10 without smoothing. Both are KAMA; only the second is TA-Lib's.

  1. Make your stochastic have the same empty values as talib.STOCH and write a test that checks it along with the equality of the values.
Solution
IN [17]python
def estocastico_ta(c, h, l, n=14, k=3, d=3):
    e = estocastico(c, h, l, n, k, d)
    return e.where(e["d"].notna())  # %K only where there is also a %D

def test_estocastico_como_talib():
    k_ta, d_ta = talib.STOCH(H, L, C, 14, 3, 0, 3, 0)
    e = estocastico_ta(c, h, l)
    for propio, ref in [(e["k"], k_ta), (e["d"], d_ta)]:
        propio = propio.to_numpy()
        assert (np.isnan(propio) == np.isnan(ref)).all()
        np.testing.assert_allclose(propio, ref, rtol=0, atol=1e-9,
                                   equal_nan=True)

test_estocastico_como_talib()
print("PASSED", "test_estocastico_como_talib")
OUT [17]Stochastic aligned with TA-Lib
PASSED test_estocastico_como_talib

TA-Lib returns both outputs aligned: neither until the last one exists (%D, which needs 14 + 2 + 2 sessions). It is enough to hide the %K where the %D is empty. An alignment convention is replicated in one line; what matters is that the test also checks the position of the empty values, not just the values.

  1. Your team puts into production a system that computes the RSI with pandas and monitors positions with a platform that uses TA-Lib. On the first day, with two weeks of history loaded, the two RSIs differ by 6 points. What is going on and what would you change?
Solution

It is the unstable period, multiplied by two different seeds: with ten sessions of history, neither the pandas RSI (seeded with the first change) nor TA-Lib's (seeded with the mean of the first 14) has forgotten its seed, and on top of that the history does not even reach the lookback. Three changes: load enough history (ten times the period of the slowest average, about 150 sessions for a 14-period RSI) before the first signal; use the same convention in both places (rsi_ta in the system or TA-Lib in both); and add a test that compares the two calculations after the warm-up and fails if they differ by more than a tolerance.

Each answer is folded under its question

Self-assessment

  1. Your 14-period RSI and TA-Lib's differ by 19.9 points on the first value and by 0.00000000000001 at the end of the series. What does it indicate?

    • AThat they use different seeds for the Wilder average; the difference fades geometrically and does not affect values far from the start
    • BThat one of the two is wrong
    • CThat TA-Lib rounds
    • DThat the RSI is not deterministic
    Show answer

    Answer A. That they use different seeds for the Wilder average; the difference fades geometrically and does not affect values far from the start

    The Wilder average is recursive: the first value carries the seed, and its weight is multiplied by 13/14 every session. TA-Lib seeds with the simple mean of the first 14 changes; ewm starts with the first change. After about 240 sessions the difference is below 10⁻⁶.

  2. Your Bollinger Bands and TA-Lib's always differ by the same proportion, 2.6% of the width, at the beginning and at the end. What is the most likely cause?

    • AA very long warm-up
    • BThe dividend adjustment
    • CThe standard deviation: sample (ddof=1, the pandas default) versus population (ddof=0, TA-Lib's)
    • DTA-Lib uses an EMA instead of an SMA
    Show answer

    Answer C. The standard deviation: sample (ddof=1, the pandas default) versus population (ddof=0, TA-Lib's)

    With 20 data points, √(20/19) = 1.0260. A difference that does not converge is not a seed: it is a different convention, or a bug. You have to find it and decide which one you want.

  3. What does talib.set_unstable_period('RSI', 100) do?

    • AComputes the RSI with 100 sessions instead of 14
    • BLeaves 100 more values empty at the start; the values it does return are the same as without the setting
    • CStarts the calculation 100 sessions later, which is why all the values change
    • DSmooths the RSI with a 100-period average
    Show answer

    Answer B. Leaves 100 more values empty at the start; the values it does return are the same as without the setting

    TA-Lib keeps computing from the first data point and only hides the first values, which depend on the seed. On NOVA it goes from 14 to 114 empty values and the maximum difference from the normal RSI is exactly 0.

  4. You compute NOVA's RSI with TA-Lib starting the series 500 sessions later. What do you expect?

    • AThe same values from the first one
    • BDifferent values forever
    • CAn error for lack of data
    • DDifferent values at first that converge to those of the full series: after 100 sessions the difference is one hundredth
    Show answer

    Answer D. Different values at first that converge to those of the full series: after 100 sessions the difference is one hundredth

    That is the unstable period: the value of a recursive average depends on where the history starts. On NOVA, 8.93 points of difference in the first RSI, 1.43 after 50 sessions, 0.013 after 100. With little history, two systems looking at “the same RSI” see different numbers.

  5. Why does talib.SMA fail with a float32 array?

    • ABecause TA-Lib functions work with double (float64) and do not convert the type for you
    • BBecause TA-Lib only accepts Python lists
    • CBecause float32 does not support NaN
    • DBecause the window is too long
    Show answer

    Answer A. Because TA-Lib functions work with double (float64) and do not convert the type for you

    The C library operates in double precision and the Python wrapper requires float64 arrays (or Series holding them). A Parquet file saved as float32 to save space has to be converted first with astype('float64').

Key points

  • Your own implementation is validated against a reference one by measuring four things: empty values, maximum difference, final difference and the session from which they match.
  • Recursive averages (EMA, Wilder, KAMA) depend on their seed: the differences fade geometrically. Replicating TA-Lib's seed, EMA, RSI, ATR and MACD match to 10⁻¹³.
  • The unstable period is not a TA-Lib bug: an RSI's value depends on where the series starts. set_unstable_period only hides the first values; it does not change them.
  • A difference that does not converge is a convention (ddof, initial OBV value, MACD alignment) or a bug. You have to find it, decide and document it.
  • The comparison becomes tests: equality after the warm-up, same position of the empty values, valid ranges and the truncation test.
  • pandas-ta 0.4.71b0 pins numba==0.61.2, which requires numpy<2.3 and has no wheels for Python 3.14: it does not install in the course environment. TA-Lib 0.8.2 does, with the C library included.

Resources

Data