Lección 1.7.2 · 60 min

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.

Fig. — Diferencia absoluta entre los indicadores propios de la lección 1.7.1 y los de TA-Lib sobre NOVA ajustada, en escala logarítmica, en las primeras 330 sesiones
02/04
Módulo
1.7 · Análisis técnico programado
Duración
60 min
Lecciones del nivel 1
  1. 1.1 · Cómo funcionan los mercados

    1. 1.1.1 Participantes y centros de negociación
    2. 1.1.2 Sesiones, subastas, festivos y calendarios
    3. 1.1.3 Libro de órdenes: bid, ask, spread y profundidad
    4. 1.1.4 Tipos de órdenes
    5. 1.1.5 Costes, liquidación, apalancamiento y margen
  2. 1.2 · Clases de activos

    1. 1.2.1 Acciones: splits, dividendos y eventos corporativos
    2. 1.2.2 ETFs: réplica, tracking error, apalancados y UCITS
    3. 1.2.3 Futuros: especificaciones, margen, roll y contango
    4. 1.2.4 Forex: pares, pips, lotes, rollover y CFD
    5. 1.2.5 Opciones: call, put, valor intrínseco y valor temporal
    6. 1.2.6 Cripto: spot, perpetuos, funding y custodia
    7. 1.2.7 Renta fija y tipos de interés; CFDs y productos apalancados
  3. 1.3 · Fuentes de datos de mercado

    1. 1.3.1 Tipos de datos de mercado: EOD, barras, ticks, libro y referencia
    2. 1.3.2 Fuentes de datos gratuitas
    3. 1.3.3 APIs de datos retail y prosumer
    4. 1.3.4 Proveedores de datos profesionales
    5. 1.3.5 Datos de cripto y on-chain
    6. 1.3.6 Brókeres como fuente de datos
    7. 1.3.7 Licencias y términos de uso de datos
    8. 1.3.8 Cómo elegir proveedor de datos
  4. 1.4 · Ingeniería de datos financieros

    1. 1.4.1 Descarga por REST y websockets: paginación, límites y reintentos
    2. 1.4.2 Limpieza de datos: faltantes, ticks erróneos, huecos y zonas horarias
    3. 1.4.3 Ajustes por splits y dividendos: precios ajustados y sin ajustar
    4. 1.4.4 Universos sin sesgo de supervivencia: deslistados, fusiones y cambios de símbolo
    5. 1.4.5 Construcción de barras: tiempo, ticks, volumen, dólar y desequilibrio
    6. 1.4.6 Series continuas de futuros: reglas de roll, ajuste por diferencia y ajuste por ratio
    7. 1.4.7 Almacenamiento: CSV, Parquet, particionado, DuckDB, TimescaleDB y ArcticDB
    8. 1.4.8 Pipeline reproducible: descarga, validación, actualización incremental y tests de calidad
  5. 1.5 · Datos fundamentales y macro

    1. 1.5.1 Estados financieros, ratios, XBRL y SEC EDGAR
    2. 1.5.2 Datos macro, calendarios económicos, estimaciones y transcripciones
  6. 1.6 · Análisis exploratorio financiero

    1. 1.6.1 Rentabilidades, volatilidad, correlaciones móviles y drawdown
    2. 1.6.2 Hechos estilizados: colas gruesas, agrupamiento, autocorrelación y apalancamiento
    3. 1.6.3 Estacionalidad: día de la semana, mes, cambio de mes y sesiones intradía
  7. 1.7 · Análisis técnico programado

    1. 1.7.1 Indicadores técnicos en Python
    2. 1.7.2 Implementación propia frente a TA-Lib: validar resultados
    3. 1.7.3 Patrones de velas y soportes y resistencias
    4. 1.7.4 Multi-timeframe sin anticipación

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:

sínosínoIndicador propioDiferenciaTA-Lib¿Tiende a cero?Semilla¿Es constante?ConvenciónError
Fuente 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:

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), "sesiones,", px.index[0].date(), "a",
      px.index[-1].date())
OUT [01]NOVA ajustada
2011 sesiones, 2018-01-02 a 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 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:

IN [03]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()
OUT [03]Versión y grupos de funciones
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:

IN [04]python
talib.SMA(c.to_numpy().astype("float32"), timeperiod=20)
OUT [04]TA-Lib exige float64
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:

IN [05]con_pandas_ta.pypython
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−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 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)
OUT [06]Propios frente a TA-Lib
                  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−1210^{-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−1410^{-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 nn primeros datos y empieza la recursión en el dato nn. A partir de ahí, las dos versiones aplican la misma fórmula, y la diferencia entre ellas se multiplica por (1−α)(1-\alpha) en cada sesión:

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)

Con α=2/21\alpha = 2/21, la diferencia se divide por diez cada 23 sesiones. En el RSI y el ATR, con la media de Wilder (α=1/14\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−1410^{-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−2n-2, la media simple de los nn primeros datos en la n−1n-1 y los datos originales después. ewm se salta los vacíos del principio y arranca la recursión en la semilla:

IN [07]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)
OUT [07]Replicar la semilla de TA-Lib
        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)
IN [08]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),
})
OUT [08]MACD con la semilla alineada
                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−1n-1) frente a la poblacional (divide por nn). La proporción entre las dos es constante, n/(n−1)\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.

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"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]:,}")
OUT [09]Bollinger con ddof=1 y OBV: diferencias constantes
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.

DiferenciaEjemplo¿Converge?Qué hacer
Semilla de una media recursivaEMA, RSI, ATR, ADX, KAMASí, geométricamenteDescartar el calentamiento o replicar la semilla
Alineación de salidasMACD (33 frente a 25 vacíos), estocásticoSí, o ya coincidenComparar solo donde las dos tienen valor
Convención de cálculoddof=0 frente a ddof=1No, proporción fijaElegir y documentar
Convención de nivelOBV con o sin el primer volumenNo, desplazamiento fijoUsar diferencias, no niveles
Errorspan en vez de alpha, shift de menosNo, ni es constanteCorregir

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:

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} sesiones después del inicio: {dif[k]:.6f} puntos")
OUT [10]El RSI depende de dónde empieza la serie
 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:

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)  # 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))
OUT [11]set_unstable_period oculta, no recalcula
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).

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

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]Calentamiento de cada indicador
              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

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 (α=1/14\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:

IN [13]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))
OUT [13]stream.RSI frente a talib.RSI
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).

Historiastream.RSIupdate: cerradapeek: en curso
Fuente 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:

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]Tests de los indicadores
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:

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)

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
TransparenciaTotal: la fórmula está a la vistaCódigo de C; convenciones en la documentación y en el código
DependenciasSolo pandas y NumPyUna rueda binaria (incluye la biblioteca de C)
CoberturaLo que programesMás de 200 funciones, 61 patrones de velas
Tipos de entradaLo que acepte pandasfloat64 obligatorio
Tiempo realRecalcular o programar la recursiónAPI de streaming con update y peek
Indicadores propios o modificadosInmediatoNo: 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
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]  # 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))})
OUT [16]KAMA con la semilla de TA-Lib
                     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.

  1. 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
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 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")
OUT [17]Estocástico alineado con TA-Lib
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.

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

Respuesta plegada en cada pregunta

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?

    • AQue uno de los dos está mal
    • BQue usan semillas distintas para la media de Wilder; la diferencia se desvanece geométricamente y no afecta a los valores lejos del inicio
    • CQue TA-Lib redondea
    • DQue el RSI no es determinista
    Ver respuesta

    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?

    • AUn calentamiento muy largo
    • BLa desviación típica: muestral (ddof=1, la de pandas por defecto) frente a poblacional (ddof=0, la de TA-Lib)
    • CEl ajuste por dividendos
    • DTA-Lib usa una EMA en vez de una SMA
    Ver respuesta

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

    • ACalcula el RSI con 100 sesiones en vez de 14
    • BDeja vacíos 100 valores más al principio; los valores que sí devuelve son los mismos que sin el ajuste
    • CEmpieza el cálculo 100 sesiones más tarde, y por eso cambian todos los valores
    • DSuaviza el RSI con una media de 100
    Ver respuesta

    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?

    • ALos mismos valores desde el primero
    • BValores distintos al principio que convergen a los de la serie completa: tras 100 sesiones la diferencia es de una centésima
    • CValores distintos para siempre
    • DUn error por falta de datos
    Ver respuesta

    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?

    • APorque TA-Lib solo acepta listas de Python
    • BPorque las funciones de TA-Lib trabajan con double (float64) y no convierten el tipo por ti
    • CPorque float32 no admite NaN
    • DPorque la ventana es demasiado larga
    Ver respuesta

    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

Datos