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.
- Módulo
- 1.7 · Análisis técnico programado
- Duración
- 60 min
Lecciones del nivel 1
1.1 · Cómo funcionan los mercados
1.2 · Clases de activos
- 1.2.1 Acciones: splits, dividendos y eventos corporativos
- 1.2.2 ETFs: réplica, tracking error, apalancados y UCITS
- 1.2.3 Futuros: especificaciones, margen, roll y contango
- 1.2.4 Forex: pares, pips, lotes, rollover y CFD
- 1.2.5 Opciones: call, put, valor intrínseco y valor temporal
- 1.2.6 Cripto: spot, perpetuos, funding y custodia
- 1.2.7 Renta fija y tipos de interés; CFDs y productos apalancados
1.3 · Fuentes de datos de mercado
- 1.3.1 Tipos de datos de mercado: EOD, barras, ticks, libro y referencia
- 1.3.2 Fuentes de datos gratuitas
- 1.3.3 APIs de datos retail y prosumer
- 1.3.4 Proveedores de datos profesionales
- 1.3.5 Datos de cripto y on-chain
- 1.3.6 Brókeres como fuente de datos
- 1.3.7 Licencias y términos de uso de datos
- 1.3.8 Cómo elegir proveedor de datos
1.4 · Ingeniería de datos financieros
- 1.4.1 Descarga por REST y websockets: paginación, límites y reintentos
- 1.4.2 Limpieza de datos: faltantes, ticks erróneos, huecos y zonas horarias
- 1.4.3 Ajustes por splits y dividendos: precios ajustados y sin ajustar
- 1.4.4 Universos sin sesgo de supervivencia: deslistados, fusiones y cambios de símbolo
- 1.4.5 Construcción de barras: tiempo, ticks, volumen, dólar y desequilibrio
- 1.4.6 Series continuas de futuros: reglas de roll, ajuste por diferencia y ajuste por ratio
- 1.4.7 Almacenamiento: CSV, Parquet, particionado, DuckDB, TimescaleDB y ArcticDB
- 1.4.8 Pipeline reproducible: descarga, validación, actualización incremental y tests de calidad
1.5 · Datos fundamentales y macro
1.6 · Análisis exploratorio financiero
1.7 · Análisis técnico programado
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:
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:
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())2011 sesiones, 2018-01-02 a 2025-12-31def 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:
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()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: int64Las 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:
talib.SMA(c.to_numpy().astype("float32"), timeperiod=20)Exception: input array type is not doubleLa 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:
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 :
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) 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 2011La tabla se lee por bloques.
- SMA, Bollinger y estocástico coinciden desde el primer valor salvo ruido de coma flotante (diferencias del orden de 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 . 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=1y 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 primeros datos y empieza la recursión en el dato . A partir de ahí, las dos versiones
aplican la misma fórmula, y la diferencia entre ellas se multiplica por en cada sesión:
Con , la diferencia se divide por diez cada 23 sesiones. En el RSI y el ATR, con la media de Wilder
(), 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 , 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 , la media simple de los primeros datos en la y los datos originales después.
ewm se salta los vacíos del principio y arranca la recursión en la semilla:
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) 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 0Ahora 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:
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),
}) 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 0Sembrar 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 ) frente a la poblacional (divide por ). La proporción entre las dos es constante, .
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.
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]:,}")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,332Ninguna 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:
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") 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 puntosCasi 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:
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))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, RVICon 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 %:
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 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 115La 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 (), 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:
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))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.2113update 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).
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:
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__)PASSED test_igual_que_talib_tras_calentamiento
PASSED test_semilla_exacta_y_mismos_vacios
PASSED test_rangos_validos
PASSED test_sin_anticipacionCuatro 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:
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
- Tu KAMA de la lección 1.7.1 difiere de
talib.KAMAen 0,72 al principio. Encuentra la semilla de TA-Lib y escribe una versión que coincida exactamente. Pista: mira cuántos vacíos dejatalib.KAMAy prueba a sembrar con un cierre.
Solución
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))}) 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 0TA-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.
- Haz que tu estocástico tenga los mismos valores vacíos que
talib.STOCHy escribe un test que lo compruebe junto con la igualdad de valores.
Solución
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")PASSED test_estocastico_como_talibTA-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.
- 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
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;
ewmempieza con el primer cambio. Tras unas 240 sesiones la diferencia es menor que 10⁻⁶.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.
¿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.
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.
¿Por qué
talib.SMAfalla con un array defloat32?- 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 tiLa biblioteca de C opera en doble precisión y la envoltura de Python exige arrays
float64(o Series que los contengan). Un Parquet guardado enfloat32para ahorrar espacio hay que convertirlo antes conastype('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_periodsolo 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 exigenumpy<2.3y 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.