---
title: "1.7.2 Validar tus indicadores frente a TA-Lib"
description: "Compara tus indicadores con TA-Lib: diferencias máximas, semillas, periodo de calentamiento, periodo inestable, convenciones y tests con asserts."
url: "https://pythonparatrading.com/curso-python-trading/nivel-1/validar-indicadores-frente-a-ta-lib/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-1/validar-indicadores-frente-a-ta-lib.png"
---

# 1.7.2 · Implementación propia frente a TA-Lib: validar resultados

> Dos implementaciones correctas del mismo RSI pueden diferir en 20 puntos durante sus primeras sesiones. Esta lección compara los indicadores de la lección 1.7.1 con TA-Lib, separa las diferencias que se desvanecen (semillas y calentamiento) de las que no (convenciones y errores) y convierte la comparación en tests.

- Curso: [Nivel 1: Datos financieros, mercados y análisis](https://pythonparatrading.com/curso-python-trading/nivel-1/)
- Módulo: 1.7 · Análisis técnico programado
- Duración: 60 min
- Requisitos: [1.7.1 · Indicadores técnicos en Python](https://pythonparatrading.com/curso-python-trading/nivel-1/indicadores-tecnicos-en-python/), [0.4.5 · Logging profesional y testing con pytest](https://pythonparatrading.com/curso-python-trading/nivel-0/logging-y-testing-con-pytest/)

## Al terminar esta lección

- Usar TA-Lib desde Python (función, tipos de entrada, grupos, API de streaming) y saber por qué pandas-ta no se instala en el entorno del curso.
- Medir la diferencia entre una implementación propia y la de referencia: valores vacíos, diferencia máxima, diferencia final y sesión de convergencia.
- Explicar el periodo de calentamiento y el periodo inestable de las medias recursivas y replicar exactamente la semilla de TA-Lib en EMA, RSI, ATR y MACD.
- Distinguir una diferencia que converge (inicialización) de una que no (`ddof`, valor inicial del OBV) y fijar las dos cosas en tests con asserts.

## Por qué comparar con una referencia

La lección 1.7.1 programó una docena de indicadores con pandas. Las fórmulas están bien, pero «están bien» es una
afirmación que hay que comprobar: un `ddof` equivocado, un `span` donde iba un `alpha` o un `shift` de menos dan
números creíbles y erróneos. La forma más barata de comprobarlo es compararlos con una implementación de referencia,
escrita por otros y usada por mucha gente. En Python la referencia es **TA-Lib**, la biblioteca de C de análisis
técnico (más de 200 funciones) con su envoltura de Cython.

Comparar no es exigir igualdad. Dos implementaciones correctas pueden diferir por cómo arrancan, y una incorrecta
puede coincidir casi siempre. Lo que distingue un caso del otro es **cómo evoluciona la diferencia**:

```mermaid
flowchart TB
  P[Indicador propio] --> D[Diferencia]
  R[TA-Lib] --> D
  D --> Q{¿Tiende a cero?}
  Q -- sí --> S[Semilla]
  Q -- no --> C{¿Es constante?}
  C -- sí --> V[Convención]
  C -- no --> E[Error]
```

Una diferencia que se desvanece sesión a sesión es una **semilla** distinta: las medias recursivas recuerdan cómo se
inicializaron, y ese recuerdo se borra con el tiempo. Una diferencia constante, en nivel o en proporción, es una
**convención** distinta (población frente a muestra, valor inicial de una suma): ninguna es un error, pero hay que
elegir una y decirlo. Una diferencia que ni converge ni es constante es un **error**.

## Las implementaciones de la lección 1.7.1

Cada lección se ejecuta en un proceso nuevo, así que la primera celda vuelve a cargar NOVA ajustada y la segunda
repite las funciones de la lección 1.7.1, sin cambios:

```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), "sesiones,", px.index[0].date(), "a",
      px.index[-1].date())
```
```output
2011 sesiones, 2018-01-02 a 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 desde Python

Desde la versión 0.6.5, el paquete `TA-Lib` de PyPI publica ruedas que **incluyen la biblioteca de C**, para Linux,
macOS y Windows (las de la 0.8.2, para CPython 3.11 a 3.15): `uv add ta-lib` basta, sin compilar nada. El curso usa la 0.8.2 (la
versión exacta está en `curso-code/uv.lock`). Se importa como `talib`:

```python
import talib

print("TA-Lib", talib.__version__)
grupos = talib.get_function_groups()
print(sum(len(f) for f in grupos.values()), "funciones")
pd.Series({g: len(f) for g, f in grupos.items()}).sort_values()
```
```output
TA-Lib 0.8.2
201 funciones
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
```

Las funciones reciben arrays de NumPy (o Series de pandas, y devuelven entonces Series con el mismo índice) y los
parámetros como argumentos con nombre: `talib.RSI(cierre, timeperiod=14)`. Los primeros valores, los del
*lookback*, salen vacíos. Una exigencia que sorprende: los arrays tienen que ser de `float64`. Un Parquet guardado en
`float32` para ahorrar espacio falla:

```python raises
talib.SMA(c.to_numpy().astype("float32"), timeperiod=20)
```
```output
Exception: input array type is not double
```

La alternativa en Python puro más conocida, **pandas-ta**, no está en el entorno del curso, y no por elección: su
versión 0.4.71b0 (septiembre de 2025) fija `numba==0.61.2`, y esa versión de numba exige `numpy<2.3` y solo publica
ruedas hasta Python 3.13. Con el numpy 2.5 y el Python 3.14 del curso, esas exigencias no se pueden cumplir. En un
entorno con Python 3.13 y numpy 2.2, su uso sería este:

```python skip title="con_pandas_ta.py"
import pandas_ta as ta  # no instala con numpy >= 2.3 ni 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)
```

Las comprobaciones de esta lección valen igual para pandas-ta, para Polars o para cualquier otra referencia: lo que
se compara son números, no bibliotecas.

## La primera comparación

Una función de comparación resume cuatro cosas: cuántos valores vacíos tiene cada versión, la diferencia máxima, la
diferencia en la última sesión y la primera sesión a partir de la cual la diferencia ya no supera $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 sup.": (bollinger(c)["sup"], talib.BBANDS(C, 20)[0]),
    "Estocástico %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 sup.            19         19  4.206413e-12  1.094236e-12               0
Estocástico %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
```

La tabla se lee por bloques.

- **SMA, Bollinger y estocástico** coinciden desde el primer valor salvo ruido de coma flotante (diferencias del
  orden de $10^{-12}$ o menores, lección 0.3.1). En el estocástico, TA-Lib deja dos valores vacíos más: no devuelve el %K lento
  hasta que puede calcular también el %D. Es una convención de alineación, no de cálculo.
- **EMA, RSI, ATR, ADX, KAMA y MACD** empiezan lejos (19,9 puntos de RSI, 4,0 de ADX) y terminan iguales: diferencia
  final de $10^{-14}$. Son medias recursivas con semillas distintas. El MACD de TA-Lib, además, tiene 33 valores
  vacíos y el propio 25.
- **Bollinger con `ddof=1` y OBV** no convergen nunca: la diferencia final no es cero. Son convenciones.

## Semillas: por qué las medias recursivas tardan en coincidir

Una EMA necesita un valor anterior para calcular el primero. `ewm(adjust=False)` usa el primer dato de la serie como
semilla y aplica la recursión desde ahí; `min_periods` solo oculta los primeros resultados. TA-Lib siembra con la
**media simple** de los $n$ primeros datos y empieza la recursión en el dato $n$. A partir de ahí, las dos versiones
aplican la misma fórmula, y la diferencia entre ellas se multiplica por $(1-\alpha)$ en cada sesión:

$$
\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)
$$

Con $\alpha = 2/21$, la diferencia se divide por diez cada 23 sesiones. En el RSI y el ATR, con la media de Wilder
($\alpha = 1/14$), cada 31. La figura de la cabecera lo muestra en escala logarítmica: rectas que bajan un orden de
magnitud cada 23 o cada 31 sesiones (la de la EMA llega a $10^{-14}$, el ruido de coma flotante, antes de la sesión
330), salvo la de Bollinger con `ddof=1`, que se queda arriba.

Si se quiere la igualdad exacta, basta con replicar la semilla. El truco con pandas: una serie con valores vacíos
hasta la posición $n-2$, la media simple de los $n$ primeros datos en la $n-1$ y los datos originales después.
`ewm` se salta los vacíos del principio y arranca la recursión en la semilla:

```python
def sembrar(x, n, inicio=0):
    """Media simple de x[inicio:inicio+n] en inicio+n-1; NaN antes."""
    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):
    # el primer cambio (o rango verdadero) está en la posición 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
```

Ahora coinciden desde el primer valor y con los mismos vacíos. El MACD tiene una sutileza más: TA-Lib **alinea** las
dos EMA. No siembra la rápida (12) con los 12 primeros cierres, sino con los 12 que terminan donde termina la semilla
de la lenta (26), y la línea de señal (9) con los 9 primeros valores del MACD. Por eso su primer valor sale en la
posición 33 = 25 + 8:

*Fig. — Cómo siembra TA-Lib el MACD (12, 26, 9): qué cierres entran en la semilla de cada media (barras) y desde dónde corre la recursión (gris oscuro).* Cuatro filas sobre las posiciones 0 a 40 de una serie de cierres. EMA 26: semilla con la media de los cierres 0 a 25. EMA 12 de TA-Lib, destacada: semilla con los cierres 14 a 25, alineada con la de la EMA 26. EMA 12 ingenua: semilla con los cierres 0 a 11. Señal de 9: semilla con los MACD de las posiciones 25 a 33. Una línea vertical marca la posición 33, el primer valor que devuelve TA-Lib.

```python
def ema_desde(x, n, fin):
    """EMA sembrada con la media simple de 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({
    "MACD ingenuo": (ingenuo, macd_ta),
    "MACD alineado": (alineado.where(senal.notna()), macd_ta),
    "Señal alineada": (senal, senal_ta),
})
```
```output
                nan_propio  nan_talib       dif_max     dif_final  coincide_desde
MACD ingenuo            25         33  8.057404e-02  0.000000e+00             129
MACD alineado           33         33  4.973799e-14  0.000000e+00               0
Señal alineada          33         33  3.996803e-14  8.104628e-15               0
```

Sembrar cada EMA con sus propios primeros datos (el MACD «ingenuo») deja una diferencia de 0,08 que se desvanece; con
la semilla alineada, la igualdad es exacta desde el primer valor. ¿Hay que copiar siempre las convenciones de TA-Lib?
No. Hay que **conocerlas**: si el sistema se calcula con pandas y se vigila con una plataforma que usa TA-Lib, las
primeras semanas de cualquier serie nueva no coincidirán, y conviene saber por qué antes de que alguien lo pregunte.

## Convenciones que no se desvanecen

La diferencia de Bollinger con `ddof=1` no tiende a cero porque no es una semilla: es la desviación típica muestral
(divide por $n-1$) frente a la poblacional (divide por $n$). La proporción entre las dos es constante, $\sqrt{n/(n-1)}$.
La del OBV es otra cosa: TA-Lib empieza la suma con el volumen de la primera sesión y la versión propia con cero.

```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"Ancho ddof=1 / ddof=0: {cociente.min():.6f} a "
      f"{cociente.max():.6f}; raíz de 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 - propio: de {dif_obv.min():,.0f}"
      f" a {dif_obv.max():,.0f}")
print(f"Volumen de la primera sesión: {v.iloc[0]:,}")
```
```output
Ancho ddof=1 / ddof=0: 1.025978 a 1.025978; raíz de 20/19 = 1.025978
OBV TA-Lib - propio: de 11,237,332 a 11,237,332
Volumen de la primera sesión: 11,237,332
```

Ninguna de las dos es un error. Bollinger definió sus bandas con la desviación poblacional y TA-Lib la sigue; pandas
usa la muestral por defecto porque es la de la estadística. El OBV no tiene un nivel con significado, así que da
igual dónde empiece la suma: su pendiente es la misma. Lo que sí es un error es no saber cuál se está usando.

| Diferencia | Ejemplo | ¿Converge? | Qué hacer |
|---|---|---|---|
| Semilla de una media recursiva | EMA, RSI, ATR, ADX, KAMA | Sí, geométricamente | Descartar el calentamiento o replicar la semilla |
| Alineación de salidas | MACD (33 frente a 25 vacíos), estocástico | Sí, o ya coinciden | Comparar solo donde las dos tienen valor |
| Convención de cálculo | `ddof=0` frente a `ddof=1` | No, proporción fija | Elegir y documentar |
| Convención de nivel | OBV con o sin el primer volumen | No, desplazamiento fijo | Usar diferencias, no niveles |
| Error | `span` en vez de `alpha`, `shift` de menos | No, ni es constante | Corregir |

## El periodo inestable

La semilla no es solo una diferencia entre bibliotecas. Con **la misma** biblioteca, el RSI de una sesión depende de
dónde empieza la serie, porque la semilla se calcula con los primeros datos disponibles. Es lo que TA-Lib llama el
**periodo inestable**. Se ve calculando el RSI de NOVA con la serie completa y con la serie empezando 500 sesiones
más tarde:

```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} sesiones después del inicio: {dif[k]:.6f} puntos")
```
```output
 14 sesiones después del inicio: 8.931394 puntos
 50 sesiones después del inicio: 1.427800 puntos
100 sesiones después del inicio: 0.013082 puntos
200 sesiones después del inicio: 0.000045 puntos
```

Casi nueve puntos de RSI en el primer valor, una centésima tras 100 sesiones. Si un sistema arranca con dos meses de
historia y otro con diez años, durante semanas no ven el mismo RSI. TA-Lib permite ocultar ese tramo con
`set_unstable_period`, que añade valores vacíos al principio de las funciones marcadas como inestables:

```python
normal = talib.RSI(C, 14)
talib.set_unstable_period("RSI", 100)
con_inestable = talib.RSI(C, 14)
talib.set_unstable_period("RSI", 0)  # el ajuste es global: volver a 0

print("Vacíos:", np.isnan(normal).sum(), "->",
      np.isnan(con_inestable).sum())
print("Diferencia máxima donde hay valor:",
      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), "funciones inestables:", ", ".join(inestables))
```
```output
Vacíos: 14 -> 114
Diferencia máxima donde hay valor: 0.0
23 funciones inestables: 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
```

Con un periodo inestable de 100, el RSI pasa de 14 a 114 valores vacíos y los que devuelve son **idénticos**: TA-Lib
sigue calculando desde el primer dato y solo oculta los resultados que dependen de la semilla. No hace que el RSI
converja antes; lo que hace es no enseñar los valores que aún no han convergido. La lista de funciones inestables es
la esperable: todas las que tienen una media exponencial o de Wilder dentro (EMA, RSI, ATR, ADX y sus componentes,
KAMA, T3), las transformadas de Hilbert y alguna nueva de la versión 0.8 (RMA, HA, RVI).

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

`set_unstable_period` cambia un ajuste **del proceso entero**, no de una llamada: afecta a todas las funciones que se
calculen después, en cualquier módulo. En TA-Lib 0.8, cambiarlo mientras hay un cálculo en curso o un *stream*
abierto lanza `RuntimeError`. Fíjalo una vez al arrancar y no lo toques.

</aside>

¿Cuánto calentamiento necesita cada indicador? Se puede medir con la misma comparación: cuántas sesiones tarda la
serie que empieza 500 sesiones más tarde en coincidir con la completa con un error relativo menor que 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. — Calentamiento de cada indicador de TA-Lib en NOVA: valores vacíos del lookback y sesiones adicionales hasta que la serie que empieza 500 sesiones más tarde coincide con la completa.* Barras horizontales con las sesiones que necesita cada indicador de TA-Lib en NOVA: en gris oscuro, los valores vacíos del lookback; en gris claro, las sesiones adicionales hasta coincidir con la serie completa con un error menor que el 0,01 %. SMA 20 y Bollinger 20: 19 y nada más. EMA 20: 19 + 26. KAMA 10: 10 + 40. MACD: 33 + 77. ATR 14 y RSI 14: 14 + 106. ADX 14, destacado, el que más tarda: 27 + 115, 142 sesiones.

La SMA y Bollinger no tienen periodo inestable: con 20 datos, su valor ya es el definitivo. Las medias recursivas
necesitan sesiones de más para que la semilla deje de notarse al 0,01 %: 26 la EMA de 20, 40 la KAMA, 77 el MACD,
106 el RSI y el ATR de 14 y 115 el ADX, que encadena dos medias de Wilder. La media de Wilder olvida despacio
($\alpha = 1/14$), y de ahí la regla práctica: cargar al menos **diez veces** el periodo de la media más lenta antes
de la primera sesión que cuente (150 sesiones para un RSI de 14) y comprobarlo con esta celda para la combinación
concreta de indicadores.

## Del backtest al tiempo real: la API de streaming

En un sistema en vivo no se recalcula el RSI de toda la historia con cada barra nueva. TA-Lib 0.8 trae una API de
streaming: se abre un *handle* con la historia (una pasada) y después cada barra cerrada se añade con `update`, en
tiempo constante. La documentación garantiza que cada valor es idéntico al de la función normal; se comprueba igual
que todo lo demás:

```python
from talib import stream

s = stream.RSI(C[:-5], timeperiod=14)  # historia sin las 5 últimas
en_vivo = [s.update(x) for x in C[-5:]]  # 5 barras cerradas, una a una
print("Stream:  ", np.round(en_vivo, 4))
print("Función: ", np.round(talib.RSI(C, 14)[-5:], 4))
print("peek con una barra a medias:", round(s.peek(C[-1] * 1.01), 4),
      "| valor guardado:", round(s.value, 4))
```
```output
Stream:   [58.7029 51.5    50.3727 50.0916 50.2113]
Función:  [58.7029 51.5    50.3727 50.0916 50.2113]
peek con una barra a medias: 54.241 | valor guardado: 50.2113
```

`update` añade una barra **cerrada** y guarda el estado; `peek` calcula el valor que daría una barra que aún se está
formando sin guardar nada. Es la distinción que importa en tiempo real: una señal calculada con `peek` sobre la barra
en formación usa un precio que todavía no es el cierre, y en un backtest con barras cerradas esa señal no existe
(lección 1.7.4).

```mermaid
flowchart LR
  H[Historia] --> O[stream.RSI]
  O --> U[update: cerrada]
  O --> P[peek: en curso]
```

## De la comparación a los tests

Lo comprobado a mano se pierde en cuanto alguien toca una función. Los tests lo fijan. Con pytest (lección 0.4.5), un
test es una función que empieza por `test_` y usa `assert`; la celda define cuatro tipos de test para los indicadores
y los ejecuta como lo haría pytest:

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

Cuatro tipos de test, cuatro propiedades distintas: igualdad con la referencia donde debe haberla (con tolerancia
absoluta, no `==`), igualdad exacta y mismos vacíos cuando se ha replicado la convención, rangos que el indicador no
puede violar y la prueba de truncado de la lección 1.7.1. En el proyecto viven en un archivo de tests y se
parametrizan por indicador:

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

Y se ejecutan con `uv run pytest tests/test_indicadores.py`. Si mañana alguien cambia `alpha=1/n` por `span=n` en la
media de Wilder, el primer test falla con la diferencia exacta y la posición.

## Propia, TA-Lib o las dos

| | Implementación propia (pandas) | TA-Lib |
|---|---|---|
| Transparencia | Total: la fórmula está a la vista | Código de C; convenciones en la documentación y en el código |
| Dependencias | Solo pandas y NumPy | Una rueda binaria (incluye la biblioteca de C) |
| Cobertura | Lo que programes | Más de 200 funciones, 61 patrones de velas |
| Tipos de entrada | Lo que acepte pandas | `float64` obligatorio |
| Tiempo real | Recalcular o programar la recursión | API de streaming con `update` y `peek` |
| Indicadores propios o modificados | Inmediato | No: hay que salir de la biblioteca |

En la práctica se usan las dos: TA-Lib como referencia en los tests y para los indicadores estándar, y una
implementación propia cuando hace falta una variante (un ATR en porcentaje, un RSI sobre otra serie, un indicador que
no existe) o cuando se quiere entender cada número. Lo que no conviene es mezclar sin saberlo: un sistema que calcula
la señal con una y el stop con otra arrastra dos convenciones a la vez.

## Ejercicios

1. Tu KAMA de la lección 1.7.1 difiere de `talib.KAMA` en 0,72 al principio. Encuentra la semilla de TA-Lib y
   escribe una versión que coincida exactamente. Pista: mira cuántos vacíos deja `talib.KAMA` y prueba a sembrar con
   un cierre.

**Solución.**

```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]  # semilla: el cierre anterior a la primera salida
    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({"KAMA propia": (kama(c), talib.KAMA(C, 10)),
         "KAMA semilla TA-Lib": (kama_ta(c), talib.KAMA(C, 10))})
```
```output
                     nan_propio  nan_talib       dif_max  dif_final  coincide_desde
KAMA propia                  10         10  7.225559e-01        0.0             214
KAMA semilla TA-Lib          10         10  7.105427e-15        0.0               0
```

TA-Lib deja 10 vacíos y su primer valor, en la posición 10, ya aplica la recursión: parte del cierre de la posición
9. La versión de la lección 1.7.1 copiaba el cierre de la posición 10 sin suavizar. Las dos son KAMA; solo la segunda
es la de TA-Lib.

2. Haz que tu estocástico tenga los mismos valores vacíos que `talib.STOCH` y escribe un test que lo compruebe junto
   con la igualdad de valores.

**Solución.**

```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 solo donde también hay %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 devuelve las dos salidas alineadas: ninguna hasta que existe la última (%D, que necesita 14 + 2 + 2 sesiones).
Basta con ocultar el %K donde el %D está vacío. Una convención de alineación se replica en una línea; lo importante
es que el test compruebe también la posición de los vacíos, no solo los valores.

3. Tu equipo pasa a producción un sistema que calcula el RSI con pandas y vigila las posiciones con una plataforma
   que usa TA-Lib. El primer día, con dos semanas de historia cargadas, los dos RSI difieren en 6 puntos. ¿Qué
   pasa y qué cambiarías?

**Solución.**

Es el periodo inestable, multiplicado por dos semillas distintas: con diez sesiones de historia, ni el RSI de pandas
(sembrado con el primer cambio) ni el de TA-Lib (sembrado con la media de los 14 primeros) han olvidado su semilla, y
además la historia no llega ni al *lookback*. Tres cambios: cargar historia suficiente (diez veces el periodo de la
media más lenta, unas 150 sesiones para un RSI de 14) antes de la primera señal; usar la misma convención en
los dos sitios (`rsi_ta` en el sistema o TA-Lib en los dos); y añadir un test que compare los dos cálculos tras el
calentamiento y falle si difieren más de una tolerancia.

## Autoevaluación

1. Tu RSI de 14 y el de TA-Lib difieren en 19,9 puntos en el primer valor y en 0,00000000000001 al final de la serie. ¿Qué indica?

   - A. Que uno de los dos está mal
   - B. Que usan semillas distintas para la media de Wilder; la diferencia se desvanece geométricamente y no afecta a los valores lejos del inicio
   - C. Que TA-Lib redondea
   - D. Que el RSI no es determinista

   *Respuesta B: Que usan semillas distintas para la media de Wilder; la diferencia se desvanece geométricamente y no afecta a los valores lejos del inicio.* La media de Wilder es recursiva: el primer valor arrastra la semilla, y su peso se multiplica por 13/14 en cada sesión. TA-Lib siembra con la media simple de los 14 primeros cambios; `ewm` empieza con el primer cambio. Tras unas 240 sesiones la diferencia es menor que 10⁻⁶.

2. Tus bandas de Bollinger y las de TA-Lib difieren siempre en la misma proporción, un 2,6 % del ancho, al principio y al final. ¿Cuál es la causa más probable?

   - A. Un calentamiento muy largo
   - B. La desviación típica: muestral (`ddof=1`, la de pandas por defecto) frente a poblacional (`ddof=0`, la de TA-Lib)
   - C. El ajuste por dividendos
   - D. TA-Lib usa una EMA en vez de una SMA

   *Respuesta B: La desviación típica: muestral (`ddof=1`, la de pandas por defecto) frente a poblacional (`ddof=0`, la de TA-Lib).* Con 20 datos, √(20/19) = 1,0260. Una diferencia que no converge no es una semilla: es una convención distinta, o un error. Hay que encontrarla y decidir cuál se quiere.

3. ¿Qué hace `talib.set_unstable_period('RSI', 100)`?

   - A. Calcula el RSI con 100 sesiones en vez de 14
   - B. Deja vacíos 100 valores más al principio; los valores que sí devuelve son los mismos que sin el ajuste
   - C. Empieza el cálculo 100 sesiones más tarde, y por eso cambian todos los valores
   - D. Suaviza el RSI con una media de 100

   *Respuesta B: Deja vacíos 100 valores más al principio; los valores que sí devuelve son los mismos que sin el ajuste.* TA-Lib sigue calculando desde el primer dato y solo oculta los primeros valores, que dependen de la semilla. En NOVA pasa de 14 a 114 valores vacíos y la diferencia máxima con el RSI normal es exactamente 0.

4. Calculas el RSI de NOVA con TA-Lib empezando la serie 500 sesiones más tarde. ¿Qué esperas?

   - A. Los mismos valores desde el primero
   - B. Valores distintos al principio que convergen a los de la serie completa: tras 100 sesiones la diferencia es de una centésima
   - C. Valores distintos para siempre
   - D. Un error por falta de datos

   *Respuesta B: Valores distintos al principio que convergen a los de la serie completa: tras 100 sesiones la diferencia es de una centésima.* Ese es el periodo inestable: el valor de una media recursiva depende de dónde empieza la historia. En NOVA, 8,93 puntos de diferencia en el primer RSI, 1,43 a las 50 sesiones, 0,013 a las 100. Con poca historia, dos sistemas que miran «el mismo RSI» ven números distintos.

5. ¿Por qué `talib.SMA` falla con un array de `float32`?

   - A. Porque TA-Lib solo acepta listas de Python
   - B. Porque las funciones de TA-Lib trabajan con `double` (float64) y no convierten el tipo por ti
   - C. Porque float32 no admite NaN
   - D. Porque la ventana es demasiado larga

   *Respuesta B: Porque las funciones de TA-Lib trabajan con `double` (float64) y no convierten el tipo por ti.* La biblioteca de C opera en doble precisión y la envoltura de Python exige arrays `float64` (o Series que los contengan). Un Parquet guardado en `float32` para ahorrar espacio hay que convertirlo antes con `astype('float64')`.

## Ideas clave

- Una implementación propia se valida contra una de referencia midiendo cuatro cosas: valores vacíos, diferencia máxima, diferencia final y sesión a partir de la cual coinciden.
- Las medias recursivas (EMA, Wilder, KAMA) dependen de su semilla: las diferencias se desvanecen geométricamente. Replicando la semilla de TA-Lib, EMA, RSI, ATR y MACD coinciden hasta 10⁻¹³.
- El periodo inestable no es un error de TA-Lib: el valor de un RSI depende de dónde empieza la serie. `set_unstable_period` solo oculta los primeros valores; no los cambia.
- Una diferencia que no converge es una convención (`ddof`, valor inicial del OBV, alineación del MACD) o un error. Hay que encontrarla, decidir y documentarla.
- La comparación se convierte en tests: igualdad tras el calentamiento, misma posición de los valores vacíos, rangos válidos y prueba de truncado.
- pandas-ta 0.4.71b0 fija `numba==0.61.2`, que exige `numpy<2.3` y no tiene ruedas para Python 3.14: no se instala en el entorno del curso. TA-Lib 0.8.2 sí, con la biblioteca de C incluida.

## Recursos

### Documentación

- [TA-Lib para Python: documentación (API de funciones, abstracta y de streaming)](https://ta-lib.github.io/ta-lib-python/) · ta-lib.github.io
- [TA-Lib para Python: funciones, lookback y periodo inestable](https://ta-lib.github.io/ta-lib-python/func.html) · ta-lib.github.io
- [TA-Lib 0.8.2 en PyPI (ruedas con la biblioteca de C incluida)](https://pypi.org/project/TA-Lib/) · pypi.org
- [pandas-ta en PyPI (dependencias de la versión 0.4.71b0)](https://pypi.org/project/pandas-ta/) · pypi.org

### Libros

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

### Datos

- [Datos del nivel 1: NOVA diario sin ajustar, 2018-2025 (CSV)](https://pythonparatrading.com/curso/datos/nivel-1/nova_diario_sin_ajustar.csv) · Descarga · nova_diario_sin_ajustar.csv

Lección anterior: [1.7.1 · Indicadores técnicos en Python](https://pythonparatrading.com/curso-python-trading/nivel-1/indicadores-tecnicos-en-python/)

Lección siguiente: [1.7.3 · Patrones de velas y soportes y resistencias](https://pythonparatrading.com/curso-python-trading/nivel-1/patrones-de-velas-y-soportes/)
