---
title: "1.7.2 Validating your indicators against TA-Lib"
description: "Compare your indicators with TA-Lib: maximum differences, seeds, warm-up period, unstable period, conventions and tests with asserts."
url: "https://pythonparatrading.com/en/python-trading-course/level-1/validating-indicators-against-ta-lib/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-1/validating-indicators-against-ta-lib.png"
---

# 1.7.2 · 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.

- Course: [Level 1: Financial data, markets and analysis](https://pythonparatrading.com/en/python-trading-course/level-1/)
- Module: 1.7 · Programmed technical analysis
- Duration: 60 min
- Prerequisites: [1.7.1 · Technical indicators in Python](https://pythonparatrading.com/en/python-trading-course/level-1/technical-indicators-in-python/), [0.4.5 · Professional logging and testing with pytest](https://pythonparatrading.com/en/python-trading-course/level-0/python-logging-and-testing-with-pytest/)

## By the end of this lesson

- Use TA-Lib from Python (functions, input types, groups, streaming API) and know why pandas-ta does not install in the course environment.
- Measure the difference between your own implementation and the reference one: empty values, maximum difference, final difference and convergence session.
- Explain the warm-up period and the unstable period of recursive averages and replicate TA-Lib's seed exactly for EMA, RSI, ATR and MACD.
- Tell a difference that converges (initialization) from one that does not (`ddof`, initial OBV value) and pin both down in tests with asserts.

## 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**:

```mermaid
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:

```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())
```
```output
2011 sessions, 2018-01-02 to 2025-12-31
```

```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`:

```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()
```
```output
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:

```python raises
talib.SMA(c.to_numpy().astype("float32"), timeperiod=20)
```
```output
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:

```python skip title="con_pandas_ta.py"
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^{-8}$:

```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)
```
```output
                  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^{-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^{-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 $n$ data points and starts the recursion at data point $n$. From then on, both versions
apply the same formula, and the difference between them is multiplied by $(1-\alpha)$ every session:

$$
\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 $\alpha = 2/21$, the difference is divided by ten every 23 sessions. For the RSI and the ATR, with the Wilder
average ($\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^{-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-2$, the simple mean of the first $n$ data points at $n-1$ and the original data after that. `ewm` skips the
leading empty values and starts the recursion at the seed:

```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)
```
```output
        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).* Four rows over positions 0 to 40 of a series of closes. EMA 26: seed with the mean of closes 0 to 25. TA-Lib's EMA 12, highlighted: seed with closes 14 to 25, aligned with the EMA 26 seed. Naive EMA 12: seed with closes 0 to 11. Signal 9: seed with the MACD values at positions 25 to 33. A vertical line marks position 33, the first value TA-Lib returns.

```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),
})
```
```output
                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-1$) versus the population one (divides by $n$). The ratio between the two is constant,
$\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.

```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]:,}")
```
```output
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.

| Difference | Example | Does it converge? | What to do |
|---|---|---|---|
| Seed of a recursive average | EMA, RSI, ATR, ADX, KAMA | Yes, geometrically | Discard the warm-up or replicate the seed |
| Output alignment | MACD (33 versus 25 empty), stochastic | Yes, or they already match | Compare only where both have a value |
| Calculation convention | `ddof=0` versus `ddof=1` | No, fixed ratio | Choose and document |
| Level convention | OBV with or without the first volume | No, fixed offset | Use differences, not levels |
| Bug | `span` instead of `alpha`, a missing `shift` | No, and not constant either | Fix 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:

```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")
```
```output
 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:

```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))
```
```output
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).

<aside class="callout" data-kind="aviso">

`set_unstable_period` changes a setting for **the whole process**, not for one call: it affects every function computed
afterward, in any module. In TA-Lib 0.8, changing it while a calculation is running or a *stream* is open raises
`RuntimeError`. Set it once at startup and leave it alone.

</aside>

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%:

```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
```
```output
              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.* Horizontal bars with the sessions each TA-Lib indicator needs on NOVA: dark grey, the empty lookback values; light grey, the extra sessions until it matches the full series with an error below 0.01%. SMA 20 and Bollinger 20: 19 and nothing more. EMA 20: 19 + 26. KAMA 10: 10 + 40. MACD: 33 + 77. ATR 14 and RSI 14: 14 + 106. ADX 14, highlighted, the slowest: 27 + 115, 142 sessions.

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
($\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:

```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))
```
```output
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).

```mermaid
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:

```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__)
```
```output
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:

```python skip title="tests/test_indicadores.py"
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 |
|---|---|---|
| Transparency | Total: the formula is in plain sight | C code; conventions in the documentation and in the code |
| Dependencies | Only pandas and NumPy | A binary wheel (includes the C library) |
| Coverage | Whatever you code | More than 200 functions, 61 candlestick patterns |
| Input types | Whatever pandas accepts | `float64` required |
| Real time | Recompute or code the recursion | Streaming API with `update` and `peek` |
| Custom or modified indicators | Immediate | No: 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.**

```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))})
```
```output
                  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.

2. 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.**

```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")
```
```output
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.

3. 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.

## 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?

   - A. That they use different seeds for the Wilder average; the difference fades geometrically and does not affect values far from the start
   - B. That one of the two is wrong
   - C. That TA-Lib rounds
   - D. That the RSI is not deterministic

   *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?

   - A. A very long warm-up
   - B. The dividend adjustment
   - C. The standard deviation: sample (`ddof=1`, the pandas default) versus population (`ddof=0`, TA-Lib's)
   - D. TA-Lib uses an EMA instead of an SMA

   *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?

   - A. Computes the RSI with 100 sessions instead of 14
   - B. Leaves 100 more values empty at the start; the values it does return are the same as without the setting
   - C. Starts the calculation 100 sessions later, which is why all the values change
   - D. Smooths the RSI with a 100-period average

   *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?

   - A. The same values from the first one
   - B. Different values forever
   - C. An error for lack of data
   - D. Different values at first that converge to those of the full series: after 100 sessions the difference is one hundredth

   *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?

   - A. Because TA-Lib functions work with `double` (float64) and do not convert the type for you
   - B. Because TA-Lib only accepts Python lists
   - C. Because float32 does not support NaN
   - D. Because the window is too long

   *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

### Documentation

- [TA-Lib for Python: documentation (function, abstract and streaming APIs)](https://ta-lib.github.io/ta-lib-python/) · ta-lib.github.io
- [TA-Lib for Python: functions, lookback and unstable period](https://ta-lib.github.io/ta-lib-python/func.html) · ta-lib.github.io
- [TA-Lib 0.8.2 on PyPI (wheels with the C library included)](https://pypi.org/project/TA-Lib/) · pypi.org
- [pandas-ta on PyPI (dependencies of version 0.4.71b0)](https://pypi.org/project/pandas-ta/) · pypi.org

### Books

- [Brian Okken, Python Testing with pytest, 2nd ed. (Pragmatic Bookshelf, 2022)](https://pragprog.com/titles/bopytest2/python-testing-with-pytest-second-edition/) · pragprog.com

### Data

- [Level 1 data: NOVA daily, unadjusted, 2018-2025 (CSV)](https://pythonparatrading.com/curso/datos/nivel-1/nova_diario_sin_ajustar.csv) · Download · nova_diario_sin_ajustar.csv

Previous lesson: [1.7.1 · Technical indicators in Python](https://pythonparatrading.com/en/python-trading-course/level-1/technical-indicators-in-python/)

Next lesson: [1.7.3 · Candlestick patterns and support and resistance](https://pythonparatrading.com/en/python-trading-course/level-1/candlestick-patterns-support-and-resistance/)
