Lección 1.7.1 · 75 min

Indicadores técnicos en Python

Un indicador técnico es una transformación de precios y volúmenes pasados en un número. Esta lección programa los más usados con pandas, uno por familia, sobre la serie ajustada de NOVA y sobre una sesión operación a operación, y enseña a comprobar que ninguno usa información del futuro.

Fig. — NOVA (ajustada), primer semestre de 2025: cierre con bandas de Bollinger (20 sesiones, 2 desviaciones) y, debajo, el RSI de 14 sesiones
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

Qué es un indicador y qué no promete

Un indicador técnico es una función que toma precios y volúmenes pasados y devuelve un número por sesión: una media, un cociente, un rango. No predice nada por sí mismo. Resume una propiedad de la serie —hacia dónde va, con qué fuerza, cuánto se mueve, cuánto volumen lo acompaña— de una forma que se puede comparar entre días y entre activos. Que esa propiedad sirva para ganar dinero es otra pregunta, y se responde con un backtest honesto (módulos 2 y 3), no con un gráfico.

Programarlos uno mismo tiene dos ventajas. La primera, saber exactamente qué calcula cada uno: el «RSI de 14» de dos plataformas puede dar valores distintos, y la lección 1.7.2 enseña por qué. La segunda, controlar el tiempo: cada valor tiene que poder calcularse con lo que se sabía al cierre de su sesión, y la señal que produzca solo puede ejecutarse en la sesión siguiente.

OHLCV ajustadoIndicador en tSeñal en tOrden en t+1
Fuente Mermaid
flowchart LR
  D[OHLCV ajustado] --> I[Indicador en t]
  I --> S[Señal en t]
  S --> E[Orden en t+1]

Los indicadores clásicos se agrupan en cuatro familias. Combinar dos de la misma familia (RSI y estocástico, por ejemplo) añade poco: miden casi lo mismo.

FamiliaIndicadoresQué mideEntradaUso típico
TendenciaSMA, EMA, KAMA, MACD, ADXDirección y fuerza del movimientoCierre (ADX: máximo, mínimo, cierre)Filtros de régimen, cruces, seguimiento de tendencia
MomentoRSI, estocástico, histograma del MACDVelocidad reciente frente a la habitualCierre (estocástico: máximo y mínimo)Sobrecompra y sobreventa, divergencias
VolatilidadATR, Bollinger, Keltner, DonchianAmplitud del movimientoMáximo, mínimo, cierreTamaño de posición, stops, rupturas
VolumenOBV, VWAP, perfil de volumenCuánto se negocia y a qué precioPrecio y volumen (VWAP y perfil: operaciones)Confirmación, referencia de ejecución, zonas de precio

Los datos: NOVA ajustada

La serie diaria de NOVA (nova_diario_sin_ajustar.csv) tiene los precios que se negociaron, de 2018 a 2025. En ese periodo NOVA pagó 33 dividendos e hizo un split de 4 por 1 el 23 de agosto de 2021 (nova_eventos.csv). Para calcular indicadores hay que ajustar la serie hacia atrás, con el método multiplicativo de la lección 1.4.3: cada dividendo multiplica los precios anteriores por 1−D/Ct−11 - D / C_{t-1} y el split los divide por 4. El volumen solo se ajusta por el split (se multiplica por 4 antes de la fecha), porque un dividendo no cambia el número de acciones.

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):
    """Factor de precio (splits y dividendos) y de volumen (splits)."""
    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(f"{len(px)} sesiones; factor del 2018-01-02: "
      f"{f_precio.iloc[0]:.4f}")
px.round(2).loc["2021-08-19":"2021-08-24"]
OUT [01]NOVA ajustada por splits y dividendos
2011 sesiones; factor del 2018-01-02: 0.2196
                o      h      l      c         v
fecha
2021-08-19  54.72  57.51  54.40  55.05   9886444
2021-08-20  54.53  54.94  52.04  54.81   8813464
2021-08-23  55.07  55.19  53.08  54.40  13427377
2021-08-24  54.47  54.91  52.37  54.40  13974110

El factor del primer día, 0,2196, junta el split (÷ 4) y ocho años de dividendos, que lo reducen otro 12 %. Alrededor del split, la serie ajustada es continua: el 20 de agosto cerró a 240,00 USD sin ajustar y a 54,81 ajustado; el 23, primer día con acciones nuevas, abrió a 60,28 sin ajustar y 55,07 ajustado. Sin el ajuste, cualquier indicador de rango o de nivel se rompe:

IN [02]python
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)

tr = pd.DataFrame({
    "sin_ajustar": rango_verdadero(bruto["maximo"], bruto["minimo"],
                                   bruto["cierre"]),
    "ajustado": rango_verdadero(h, l, c),
})
tr.loc["2021-08-19":"2021-08-25"].round(2)
OUT [02]El split en el rango verdadero
            sin_ajustar  ajustado
fecha
2021-08-19        13.65      3.12
2021-08-20        13.20      3.01
2021-08-23       181.89      2.10
2021-08-24         2.78      2.54
2021-08-25         3.69      3.37

El 23 de agosto de 2021 NOVA se movió 2,30 USD entre su máximo y su mínimo (60,41 − 58,11), pero el rango verdadero sin ajustar es de 181,89: la distancia entre el cierre anterior, 240,00, y el mínimo del día. Un ATR de 14 sesiones sin ajustar vale 28,50 USD ese día y arrastra el salto durante semanas: un stop a «dos ATR» quedaría a 57 USD de un precio de 60. Con la serie ajustada, el rango verdadero de ese día es 2,10, como el de cualquier otro. Toda la lección usa px, la serie ajustada.

Medias: SMA, EMA y KAMA

La media simple (SMA) de nn sesiones pondera igual los últimos nn cierres:

SMAt=1n∑i=0n−1Ct−i\mathrm{SMA}_t = \frac{1}{n} \sum_{i=0}^{n-1} C_{t-i}

La media exponencial (EMA) es recursiva: mezcla el cierre de hoy con la EMA de ayer, con α=2/(n+1)\alpha = 2/(n+1). Su memoria es infinita, pero el peso de un cierre de hace kk sesiones decrece como (1−α)k(1-\alpha)^k:

EMAt=α Ct+(1−α) EMAt−1\mathrm{EMA}_t = \alpha\, C_t + (1 - \alpha)\, \mathrm{EMA}_{t-1}

La media adaptativa de Kaufman (KAMA) cambia su α\alpha cada día según el cociente de eficiencia ERtER_t: el desplazamiento neto de nn sesiones dividido por la suma de los movimientos de cada sesión. Con una tendencia limpia ERER vale 1 y la media se acerca a una EMA rápida (2 sesiones); con ruido lateral ERER tiende a 0 y la media casi no se mueve (EMA de 30):

ERt=∣Ct−Ct−n∣∑i=0n−1∣Ct−i−Ct−i−1∣st=(ERt (αr−αl)+αl)2KAMAt=KAMAt−1+st (Ct−KAMAt−1)ER_t = \frac{|C_t - C_{t-n}|}{\sum_{i=0}^{n-1} |C_{t-i} - C_{t-i-1}|} \qquad s_t = \left(ER_t\,(\alpha_r - \alpha_l) + \alpha_l\right)^2 \qquad \mathrm{KAMA}_t = \mathrm{KAMA}_{t-1} + s_t\,(C_t - \mathrm{KAMA}_{t-1})

En pandas, la SMA es rolling(n).mean() y la EMA, ewm(span=n, adjust=False). adjust=False es la recursión de arriba; con adjust=True (el valor por defecto) pandas normaliza los pesos de la ventana finita que ve, y los primeros valores cambian. min_periods=n deja vacíos los n−1n-1 primeros, en los que la media aún no tiene datos suficientes. KAMA no se puede vectorizar del todo: el coeficiente cambia cada día y la recursión necesita el valor anterior. El cociente de eficiencia sí se vectoriza, y el bucle que queda recorre arrays de NumPy, no filas de pandas.

IN [03]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 kama(x, n=10, rapida=2, lenta=30):
    cambio = (x - x.shift(n)).abs()
    ruido = x.diff().abs().rolling(n).sum()
    er = cambio / ruido
    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), er

k, er = kama(c)
medias = pd.DataFrame({"c": c, "sma20": sma(c, 20),
                       "ema20": ema(c, 20), "kama": k, "er": er})
medias.loc["2025-12-24":].round(2)
OUT [03]SMA, EMA y KAMA
                c  sma20  ema20   kama    er
fecha
2025-12-24  69.67  67.93  68.37  68.34  0.64
2025-12-26  68.43  67.84  68.37  68.34  0.08
2025-12-29  68.22  67.83  68.36  68.34  0.14
2025-12-30  68.17  67.82  68.34  68.33  0.27
2025-12-31  68.19  67.92  68.32  68.33  0.04

En las últimas sesiones de 2025 NOVA se mueve de lado y el cociente de eficiencia es bajo: KAMA apenas cambia (68,34, 68,34, 68,34, 68,33, 68,33) mientras el cierre baja de 69,67 a 68,19. La SMA y la EMA siguen al precio con más nervio.

¿Cuánto retraso tiene cada media? En una tendencia perfectamente lineal se puede medir exacto: la media queda un número fijo de sesiones por detrás del precio.

IN [04]python
rampa = pd.Series(np.arange(200.0))
for nombre, media in [("SMA 20", sma(rampa, 20)),
                      ("EMA 20", ema(rampa, 20)),
                      ("KAMA 10", kama(rampa)[0])]:
    print(f"{nombre}: {rampa.iloc[-1] - media.iloc[-1]:.2f} sesiones")
OUT [04]Retardo de cada media ante una rampa
SMA 20: 9.50 sesiones
EMA 20: 9.50 sesiones
KAMA 10: 1.25 sesiones

La SMA y la EMA de 20 sesiones tienen el mismo retardo, (n−1)/2=9,5(n-1)/2 = 9{,}5 sesiones: la EMA no es «más rápida» por ser exponencial, sino porque reacciona antes a un cambio brusco (pesa más el último dato). KAMA, con la tendencia limpia de la rampa (ER=1ER = 1), se queda a 1,25 sesiones: s=(2/3)2s = (2/3)^2 y el retardo de una EMA con ese coeficiente es (1−s)/s(1-s)/s. Pagar menos retardo en tendencia a cambio de quedarse quieta en el ruido es todo su diseño.

Momento: RSI, MACD y estocástico

El RSI de Wilder compara la media de las subidas con la media de las bajadas de las últimas sesiones. Wilder suaviza con su propia media, que es una EMA con α=1/n\alpha = 1/n (no 2/(n+1)2/(n+1)):

RSt=U‾tD‾tRSIt=100−1001+RStU‾t=U‾t−1+Ut−U‾t−1nRS_t = \frac{\overline{U}_t}{\overline{D}_t} \qquad \mathrm{RSI}_t = 100 - \frac{100}{1 + RS_t} \qquad \overline{U}_t = \overline{U}_{t-1} + \frac{U_t - \overline{U}_{t-1}}{n}

donde Ut=max⁡(Ct−Ct−1,0)U_t = \max(C_t - C_{t-1}, 0) y Dt=max⁡(Ct−1−Ct,0)D_t = \max(C_{t-1} - C_t, 0). El MACD de Appel es la diferencia entre dos EMA (12 y 26 sesiones), con una línea de señal (EMA de 9 del propio MACD) y un histograma (la diferencia entre las dos). El estocástico de Lane sitúa el cierre dentro del rango de las últimas 14 sesiones, de 0 (en el mínimo) a 100 (en el máximo), suavizado dos veces con medias de 3:

%Kt=100⋅Ct−min⁡(Lt−13..t)max⁡(Ht−13..t)−min⁡(Lt−13..t)\%K_t = 100 \cdot \frac{C_t - \min(L_{t-13..t})}{\max(H_{t-13..t}) - \min(L_{t-13..t})}
IN [05]python
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 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()})

momento = pd.concat([rsi(c).rename("rsi"), macd(c),
                     estocastico(c, h, l)], axis=1)
momento.tail(4).round(2)
OUT [05]RSI, MACD y estocástico
              rsi  macd  senal  hist      k      d
fecha
2025-12-26  51.50  0.49   0.37  0.12  83.25  86.86
2025-12-29  50.37  0.42   0.38  0.04  73.88  82.26
2025-12-30  50.09  0.35   0.37 -0.02  64.89  74.01
2025-12-31  50.21  0.30   0.36 -0.06  58.13  65.64

Al cerrar 2025, NOVA está en tierra de nadie: RSI en torno a 50, MACD positivo pero por debajo de su señal (histograma negativo desde el 30 de diciembre) y el estocástico bajando desde la zona alta. Tres indicadores de momento, tres lecturas compatibles: se mueven juntos porque miden casi lo mismo.

La figura de la cabecera pone el RSI debajo del precio en el primer semestre de 2025. NOVA sube de 53 a 70 USD hasta el 25 de febrero, cae hasta 44 el 10 de abril y se recupera a medias. El RSI no pasa de 70 en todo el semestre (el pico de febrero se queda por debajo), y pasa 11 sesiones por debajo de 30, con un mínimo de 22,5 el 8 de abril, dos sesiones antes del mínimo del precio. «Sobreventa» no quiere decir «va a subir»: el RSI estuvo por debajo de 30 desde el 26 de marzo mientras el precio seguía cayendo. Una regla que compra cuando el RSI cruza 30 hacia arriba es una hipótesis que hay que probar, no una ley.

IN [06]python
r = rsi(c)
print(f"RSI > 70: {(r > 70).mean():.1%} de las sesiones")
print(f"RSI < 30: {(r < 30).mean():.1%} de las sesiones")
OUT [06]Tiempo en los extremos del RSI
RSI > 70: 7.3% de las sesiones
RSI < 30: 8.0% de las sesiones

En toda la serie, el RSI pasa un 7,3 % de las sesiones por encima de 70 y un 8,0 % por debajo de 30: los extremos son raros por construcción, y una señal que solo se activa en ellos tendrá pocas operaciones (y pocas operaciones son poca evidencia, lección 0.1.2).

Volatilidad: ATR, Bollinger, Keltner y Donchian

El rango verdadero TRt=max⁡(Ht−Lt, ∣Ht−Ct−1∣, ∣Lt−Ct−1∣)TR_t = \max(H_t - L_t,\ |H_t - C_{t-1}|,\ |L_t - C_{t-1}|) amplía el rango de la sesión con el hueco desde el cierre anterior, y el ATR es su media de Wilder. Las bandas de Bollinger son la SMA de 20 más y menos dos desviaciones típicas de los 20 últimos cierres; el canal de Keltner (en su versión moderna) es una EMA de 20 más y menos dos ATR; el canal de Donchian es el máximo de los máximos y el mínimo de los mínimos de las últimas 20 sesiones. Los tres son «el precio más y menos una medida de su amplitud»; cambia cómo se mide.

IN [07]python
def atr(h, l, c, n=14):
    return wilder(rango_verdadero(h, l, c), n)

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

def keltner(h, l, c, n=20, n_atr=10, k=2):
    centro, a = ema(c, n), atr(h, l, c, n_atr)
    return pd.DataFrame({"centro": centro, "sup": centro + k * a,
                         "inf": centro - k * a})

def donchian(h, l, n=20):
    return pd.DataFrame({"sup": h.rolling(n).max(),
                         "inf": l.rolling(n).min()})

bb = bollinger(c)[["sup", "inf", "pct_b"]]
kc = keltner(h, l, c)[["sup", "inf"]]
dc = donchian(h, l)
vol = pd.concat([atr(h, l, c).rename("atr"), bb.add_prefix("bb_"),
                 kc.add_prefix("kc_"), dc.add_prefix("dc_")], axis=1)
vol.tail(3).round(2)
OUT [07]ATR, Bollinger, Keltner y Donchian
             atr  bb_sup  bb_inf  bb_pct_b  kc_sup  kc_inf  dc_sup  dc_inf
fecha
2025-12-29  1.39   70.71   64.96      0.57   71.08   65.64   70.27   64.54
2025-12-30  1.34   70.69   64.95      0.56   70.93   65.75   70.27   64.54
2025-12-31  1.30   70.70   65.13      0.55   70.82   65.83   70.27   64.54

La desviación típica de Bollinger usa ddof=0 (la de la población), que es la convención de Bollinger y de TA-Lib; la de pandas por defecto es ddof=1, y con 20 datos la banda saldría un 2,6 % más ancha (la lección 1.7.2 mide esa diferencia). pct_b sitúa el cierre dentro de la banda: 0 en la inferior, 1 en la superior; el 31 de diciembre de 2025 vale 0,55, casi en el centro.

El ATR está en dólares por acción y es la medida de volatilidad más usada para dimensionar posiciones y colocar stops (módulo 2). La figura muestra el canal de Keltner y, debajo, el ATR, en un tramo en el que la volatilidad de NOVA se dobla:

Fig. — NOVA (ajustada), noviembre de 2024 a abril de 2025: canal de Keltner alrededor del cierre y, debajo, el ATR de 14 sesiones en dólares

De noviembre de 2024 a abril de 2025 el ATR de 14 sesiones pasa de 1,61 a 3,11 USD, y en proporción del precio, del 2,8 % al 6,6 %. El canal de Keltner se ensancha con él. En febrero, una subida del 15 % en una sola sesión (el día 4) lleva el cierre por encima del canal; en marzo y abril, la caída lo lleva por debajo durante semanas: en esos seis meses NOVA cierra 8 veces por encima y 19 por debajo.

ADX: fuerza de la tendencia

El ADX de Wilder no dice hacia dónde va el precio, sino si va a algún sitio. Compara el movimiento direccional positivo (+DM+DM: lo que sube el máximo respecto a ayer, si es mayor que lo que baja el mínimo) con el negativo (−DM-DM), los divide por el ATR para obtener +DI+DI y −DI-DI, y suaviza la distancia normalizada entre los dos:

DXt=100⋅∣+DIt−−DIt∣+DIt+−DItADXt=Wilder14(DX)tDX_t = 100 \cdot \frac{|{+DI_t} - {-DI_t}|}{{+DI_t} + {-DI_t}} \qquad \mathrm{ADX}_t = \mathrm{Wilder}_{14}(DX)_t
IN [08]python
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 pd.DataFrame({"di_mas": di_mas, "di_menos": di_menos,
                         "adx": wilder(dx, n)})

fuerza = adx(h, l, c)
print(fuerza.tail(3).round(1))
print(f"Sesiones con ADX > 25: {(fuerza['adx'] > 25).mean():.1%}")
OUT [08]ADX, +DI y −DI
            di_mas  di_menos   adx
fecha
2025-12-29    22.8      22.7  12.0
2025-12-30    21.9      21.8  11.2
2025-12-31    22.0      20.8  10.6
Sesiones con ADX > 25: 47.3%

Al cerrar 2025 el ADX de NOVA está en 10,6, sin tendencia; +DI+DI y −DI-DI casi empatados. En toda la serie, el ADX supera 25 (el umbral habitual de «hay tendencia») en el 47,3 % de las sesiones: un filtro tan permisivo deja pasar casi la mitad de los días. El ADX se usa como filtro de régimen (operar rupturas solo cuando hay tendencia), no como señal.

Donchian y el desplazamiento que no puede faltar

Una regla de ruptura clásica (la de las tortugas de Dennis) compra cuando el precio supera el máximo de las últimas 20 sesiones. Programada sin cuidado no da ninguna señal:

IN [09]python
dc = donchian(h, l)
print("Sin desplazar:", (c > dc["sup"]).sum(), "cierres por encima")
print("Desplazado una sesión:",
      (c > dc["sup"].shift(1)).sum(), "por encima,",
      (c < dc["inf"].shift(1)).sum(), "por debajo")
OUT [09]Rupturas con y sin shift(1)
Sin desplazar: 0 cierres por encima
Desplazado una sesión: 148 por encima, 125 por debajo

El canal de hoy incluye el máximo de hoy, y el cierre nunca supera al máximo de su propia sesión: cero rupturas. La regla correcta compara el cierre de hoy con el canal de las 20 sesiones anteriores (shift(1)): 148 cierres por encima y 125 por debajo en ocho años. El error contrario es peor: comparar el cierre de ayer con un canal que ya incluye el máximo de hoy da rupturas «perfectas» que no se podían ver.

Fig. — NOVA (ajustada), segundo semestre de 2025: cierre y canal de Donchian de las 20 sesiones anteriores; en azul, cierres por encima del máximo previo; en naranja, por debajo del mínimo previo

En el segundo semestre de 2025, la subida de NOVA de 52,68 a 69,40 USD entre el 13 de agosto y el 3 de octubre produce una ruptura tras otra (15 cierres por encima del máximo previo); la primera, el 13 de agosto. Luego el precio se queda dentro del canal, con dos cierres por debajo del mínimo previo en octubre y dos nuevos máximos a mediados de noviembre. Un sistema de rupturas vive de pocos tramos así y sufre en los laterales.

Volumen: OBV, VWAP y perfil

El OBV (on-balance volume) de Granville acumula el volumen con el signo del cambio del cierre: suma el de los días que sube y resta el de los que baja. Su nivel no significa nada (depende del día en que empieza la suma); se mira su pendiente, o si diverge del precio:

IN [10]python
def obv(c, v):
    return (np.sign(c.diff()).fillna(0) * v).cumsum()

ob = obv(c, v)
print(f"OBV a 31-12-2025: {ob.iloc[-1] / 1e6:,.1f} M de acciones")
cambio = ob.iloc[-1] - ob.loc[:"2024"].iloc[-1]
print(f"Cambio en 2025: {cambio / 1e6:,.1f} M")
OUT [10]OBV
OBV a 31-12-2025: -375.2 M de acciones
Cambio en 2025: -0.8 M

El nivel, −375 millones de acciones, es un artefacto de dónde empieza la suma. Lo informativo es el cambio: en 2025 el OBV de NOVA apenas se mueve (−0,8 millones) mientras el precio ajustado sube de 56,28 a 68,19, un 21 %. Es lo que el análisis técnico llama una divergencia. Si significa algo se mide, como cualquier otra señal, con rentabilidades posteriores y muchos casos, no con uno.

El VWAP (volume-weighted average price) es el precio medio de una sesión ponderado por volumen:

VWAPt=∑i≤tpi qi∑i≤tqi\mathrm{VWAP}_t = \frac{\sum_{i \le t} p_i\, q_i}{\sum_{i \le t} q_i}

Es la referencia con la que se mide la ejecución institucional: un algoritmo que compra «al VWAP» intenta pagar ese precio medio. Se calcula sobre operaciones, no sobre barras, y la lección 1.1.1 ya avisaba: qué operaciones entran cambia el resultado. Con la sesión de NOVA del 2 de junio de 2025, todas las operaciones frente a solo la negociación continua (sin subastas ni bloques):

IN [11]python
op = pd.read_parquet("nova_operaciones.parquet")

def vwap(df):
    pq = (df["precio"] * df["tamano"]).cumsum()
    return pq / df["tamano"].cumsum()

op["vwap_todo"] = vwap(op)
continua = op[op["condicion"] == "regular"]
op["vwap_continua"] = vwap(continua).reindex(op.index).ffill()

antes_cierre = op.iloc[-2]
final = op.iloc[-1]
print(f"Antes de la subasta de cierre: {antes_cierre['vwap_todo']:.3f}"
      f" frente a {antes_cierre['vwap_continua']:.3f}")
print(f"Al final del día: {final['vwap_todo']:.3f}"
      f" frente a {final['vwap_continua']:.3f}")

barras = pd.read_parquet("nova_barras_5min.parquet")
dia = barras[barras["ts"].dt.date == pd.Timestamp("2025-06-02").date()]
tipico = (dia["maximo"] + dia["minimo"] + dia["cierre"]) / 3
aprox = (tipico * dia["volumen"]).sum() / dia["volumen"].sum()
print(f"Aproximación con barras de 5 min: {aprox:.3f}")
OUT [11]VWAP con y sin subastas y bloques
Antes de la subasta de cierre: 53.341 frente a 53.350
Al final del día: 53.319 frente a 53.350
Aproximación con barras de 5 min: 53.311
Fig. — NOVA, sesión del 2 de junio de 2025 (hora de Nueva York): VWAP acumulado con todas las operaciones y, debajo, su diferencia con el VWAP solo de negociación continua (sin subastas ni bloques)

Durante el día, las dos versiones van casi juntas: los bloques del centro oscuro mueven el VWAP con todas las operaciones unas décimas de céntimo cada vez (los escalones de la figura). Justo antes de la subasta de cierre la diferencia es de un céntimo. La subasta, 885.500 acciones a 53,12, muy por debajo del VWAP del día, la lleva a 3,1 céntimos. Para un algoritmo que ha comprado 200.000 acciones, son 6.200 USD de diferencia según con qué VWAP se le compare. La aproximación con barras de 5 minutos y el precio típico, (H+L+C)/3(H + L + C)/3, da 53,311: cerca, pero ni siquiera es ninguno de los dos.

El perfil de volumen reparte el volumen del día por niveles de precio en vez de por tiempo: cuántas acciones se negociaron en cada tramo de 5 céntimos. El tramo con más volumen es el POC (point of control) y el área de valor es el intervalo contiguo alrededor del POC que reúne el 70 % del volumen, construido añadiendo cada vez el tramo vecino con más volumen:

IN [12]python
def perfil(df, paso=0.05):
    tramo = (np.floor(df["precio"] / paso + 1e-9) * paso).round(2)
    return df.groupby(tramo)["tamano"].sum()

def area_valor(p, frac=0.70):
    i = lo = hi = int(np.argmax(p.to_numpy()))
    acumulado = p.iloc[i]
    while acumulado / p.sum() < frac:
        arriba = p.iloc[hi + 1] if hi + 1 < len(p) else -1
        abajo = p.iloc[lo - 1] if lo > 0 else -1
        if arriba >= abajo:
            hi, acumulado = hi + 1, acumulado + arriba
        else:
            lo, acumulado = lo - 1, acumulado + abajo
    return p.index[lo], p.index[hi] + 0.05, acumulado / p.sum()

p_cont = perfil(continua)
lo_va, hi_va, cubre = area_valor(p_cont)
print(f"Continua: POC {p_cont.idxmax():.2f}, área de valor "
      f"{lo_va:.2f}-{hi_va:.2f} ({cubre:.1%} del volumen)")
p_todo = perfil(op)
print(f"Todas: POC {p_todo.idxmax():.2f} con "
      f"{p_todo.max():,} acciones")
OUT [12]Perfil de volumen, POC y área de valor
Continua: POC 53.45, área de valor 53.15-53.90 (72.2% del volumen)
Todas: POC 53.10 con 967,099 acciones
Fig. — Perfil de volumen de NOVA el 2 de junio de 2025, negociación continua: acciones negociadas en cada tramo de 5 céntimos; en gris claro, el área de valor (70 % del volumen alrededor del POC)

En negociación continua, el POC de NOVA ese día es el tramo de 53,45 a 53,50, y el área de valor va de 53,15 a 53,90: el 72,2 % del volumen (el algoritmo se pasa del 70 % al añadir el último tramo). Con todas las operaciones, el POC salta a 53,10: la subasta de cierre, 885.500 acciones en un solo precio, pesa más que cualquier tramo de la negociación continua. Un perfil «con todo» señala como zona de interés un precio que nadie eligió en el libro: lo fijó el cruce de la subasta. El perfil de volumen se usa como mapa de soportes y resistencias; la lección 1.7.3 explica por qué hay que desconfiar de esos mapas.

La prueba de truncado: ningún indicador debe ver el futuro

Todos los indicadores de esta lección cumplen la regla del principio: su valor en tt solo usa datos hasta tt. La forma de comprobarlo, en lugar de suponerlo, es la prueba de truncado: calcular el indicador con la serie completa y con la serie cortada en una fecha, y comparar los valores hasta esa fecha. Si el indicador usa el futuro, los dos cálculos no coinciden.

sínoSerie completaIndicadorSerie hasta tIndicador hasta t¿Iguales hasta t?Sin anticipaciónUsa el futuro
Fuente Mermaid
flowchart TB
  A[Serie completa] --> B[Indicador]
  A --> C[Serie hasta t]
  C --> D[Indicador hasta t]
  B --> E{¿Iguales hasta t?}
  D --> E
  E -- sí --> F[Sin anticipación]
  E -- no --> G[Usa el futuro]

La celda aplica la prueba a cada indicador y a dos «indicadores» con anticipación que aparecen en código real: una media centrada (suaviza muy bien en un gráfico) y una normalización con la media y la desviación de toda la serie (el típico z-score calculado antes de partir los datos):

IN [13]python
def sin_anticipacion(f, datos, corte):
    completo = f(datos).loc[:corte]
    truncado = f(datos.loc[:corte])
    return np.allclose(completo, truncado, equal_nan=True)

pruebas = {
    "SMA 20": lambda d: sma(d["c"], 20),
    "EMA 20": lambda d: ema(d["c"], 20),
    "KAMA": lambda d: kama(d["c"])[0],
    "RSI 14": lambda d: rsi(d["c"]),
    "MACD": lambda d: macd(d["c"])["hist"],
    "ATR 14": lambda d: atr(d["h"], d["l"], d["c"]),
    "ADX 14": lambda d: adx(d["h"], d["l"], d["c"])["adx"],
    "Donchian": lambda d: donchian(d["h"], d["l"])["sup"],
    "Media centrada": lambda d: d["c"].rolling(21, center=True).mean(),
    "z-score global": lambda d: (d["c"] - d["c"].mean()) / d["c"].std(),
}
for nombre, f in pruebas.items():
    print(f"{nombre:15} {sin_anticipacion(f, px, '2023-06-30')}")
OUT [13]Prueba de truncado
SMA 20          True
EMA 20          True
KAMA            True
RSI 14          True
MACD            True
ATR 14          True
ADX 14          True
Donchian        True
Media centrada  False
z-score global  False

Los ocho indicadores pasan; los dos impostores, no. La prueba es barata y conviene dejarla en los tests del proyecto (lección 0.4.5) para cada indicador nuevo, con varias fechas de corte. Hay una trampa que no detecta: la propia serie ajustada. Los factores de ajuste se calculan con los dividendos posteriores, así que el precio ajustado de 2023 en un fichero de 2025 no es el que se veía en 2023:

IN [14]python
hasta = bruto.loc[:"2023-06-30"]
conocidos = eventos[eventos["fecha_ex"] <= "2023-06-30"]
f_2023, _ = factores(hasta, conocidos)
c_2023 = hasta["cierre"] * f_2023
cociente = c.loc[:"2023-06-30"] / c_2023
print(f"Precio ajustado hoy / ajustado en 2023: {cociente.min():.4f}"
      f" a {cociente.max():.4f}")
print("RSI igual:", np.allclose(rsi(c).loc[:"2023-06-30"], rsi(c_2023),
                              equal_nan=True))
print("SMA igual:", np.allclose(sma(c, 20).loc[:"2023-06-30"],
                              sma(c_2023, 20), equal_nan=True))
OUT [14]El ajuste hacia atrás también cambia con el futuro
Precio ajustado hoy / ajustado en 2023: 0.9328 a 0.9328
RSI igual: True
SMA igual: False

Los dividendos pagados después de junio de 2023 escalan todos los precios anteriores por un mismo factor, 0,9328. Un indicador que solo depende de cocientes entre precios (RSI, rentabilidades, %b) no cambia; uno que está en dólares (SMA, ATR, niveles de Donchian) sí. Para señales relativas no importa. Para una regla con un nivel absoluto («comprar si el precio pasa de 50 USD», un stop a un precio fijo, un soporte), sí: hay que usar los precios como eran en cada fecha, o reajustar en cada fecha del backtest (lecciones 1.4.3 y 1.4.4).

Errores típicos

  • Precios sin ajustar. El split de NOVA convierte un día normal en un rango verdadero de 181,89 USD. Ajustar antes de calcular.
  • Ventanas centradas o normalizaciones globales. center=True, mean() de toda la serie, MinMaxScaler ajustado con todos los datos: anticipación. La prueba de truncado los caza.
  • Canales y extremos sin shift(1). Comparar con un máximo que incluye la sesión actual da cero rupturas o, con el desplazamiento al revés, rupturas imposibles.
  • El periodo de calentamiento. Los primeros valores de una EMA o de un RSI dependen de cómo se inicializa la recursión. Con la serie de NOVA desde 2018 no importa en 2025, pero en un histórico corto sí (lección 1.7.2).
  • Convenciones que no se dicen. span frente a alpha, ddof=0 frente a ddof=1, Keltner con EMA y ATR o con SMA y rango medio: dos «RSI de 14» pueden no coincidir. Documentar la convención junto a la función.
  • Optimizar los parámetros. Probar 50 longitudes de media y quedarse con la mejor es un problema de pruebas múltiples (lección 1.7.3), no un descubrimiento.

Ejercicios

  1. El ancho de las bandas de Bollinger, (Bsup−Binf)/SMA20(B_{sup} - B_{inf}) / \mathrm{SMA}_{20}, mide la volatilidad relativa. Calcula su mínimo de 2025 en NOVA (el momento de máxima «compresión») y di qué pasó con el precio en las 20 sesiones siguientes.
Solución
IN [15]python
bb = bollinger(c)
ancho = ((bb["sup"] - bb["inf"]) / bb["media"]).loc["2025"]
fecha = ancho.idxmin()
despues = c.loc[fecha:].iloc[:21]
ini, fin = despues.iloc[0], despues.iloc[-1]
print(f"Ancho mínimo: {ancho.min():.1%} el {fecha:%Y-%m-%d}")
print(f"Cierre: {ini:.2f}; 20 sesiones después: {fin:.2f}"
      f" ({fin / ini - 1:+.1%})")
print(f"Rango: {despues.min():.2f}-{despues.max():.2f}")
OUT [15]Compresión de las bandas en 2025
Ancho mínimo: 3.2% el 2025-08-04
Cierre: 51.76; 20 sesiones después: 52.26 (+1.0%)
Rango: 50.36-57.00

La compresión mínima de 2025 es del 3,2 %, el 4 de agosto. En las 20 sesiones siguientes NOVA rompe hacia arriba (la primera ruptura de Donchian, el 13 de agosto), toca 57,00 y vuelve: 20 sesiones después cierra a 52,26, un 1,0 % por encima. La compresión no dice hacia dónde saldrá el precio, solo que la volatilidad reciente es baja comparada con su media. Una sola observación no prueba nada: para saber si la compresión anticipa movimientos grandes habría que medir, sobre todas las compresiones, la volatilidad de las sesiones siguientes frente a la de sesiones elegidas al azar.

  1. Calcula el VWAP de la sesión del 2 de junio de 2025 excluyendo solo la subasta de cierre (con la de apertura y los bloques). ¿Cuánto de la diferencia de 3,1 céntimos explica la subasta de cierre por sí sola?
Solución
IN [16]python
sin_cierre = op[op["condicion"] != "subasta_cierre"]
v_sin_cierre = vwap(sin_cierre).iloc[-1]
print(f"Todas: {op['vwap_todo'].iloc[-1]:.4f}")
print(f"Sin subasta de cierre: {v_sin_cierre:.4f}")
print(f"Solo continua: {op['vwap_continua'].iloc[-1]:.4f}")
OUT [16]VWAP sin la subasta de cierre
Todas: 53.3191
Sin subasta de cierre: 53.3412
Solo continua: 53.3503

Sin la subasta de cierre, el VWAP pasa de 53,3191 a 53,3412: la subasta explica 2,2 de los 3,1 céntimos. Los 0,9 restantes hasta la versión continua (53,3503) son de los bloques y de la subasta de apertura. Los tres VWAP son legítimos; lo que no es legítimo es comparar una ejecución con uno de ellos sin decir cuál.

  1. Un compañero calcula el RSI de NOVA con los precios sin ajustar y dice que no importa porque el RSI «es un cociente». ¿Tiene razón? Piensa en qué pasa la sesión del split.
Solución

Tiene razón a medias. El RSI no cambia si toda la serie se multiplica por un mismo número (por eso un ajuste por dividendos posterior no lo altera), pero el split no escala toda la serie: corta un salto de −180 USD en una sola sesión. Esa «bajada» entra en la media de Wilder de las bajadas con un peso de 1/14 y la domina durante semanas: el RSI sin ajustar pasa de 63,2 el 18 de agosto de 2021 a 21,8 el 24 y se queda entre 22 y 26 hasta finales de septiembre, un mes en el que la serie ajustada baja un 3 % y su RSI se mueve entre 38 y 66. Con la serie ajustada, ese día es una sesión normal. Los cocientes son inmunes a escalar toda la serie, no a un escalón en mitad de ella.

Respuesta plegada en cada pregunta

Autoevaluación

  1. Con un canal de Donchian de 20 sesiones calculado con rolling(20).max() sobre los máximos, ¿cuántas veces cierra NOVA por encima del canal en 2018-2025?

    • AUnas 150, una por cada ruptura
    • BNinguna: el canal incluye el máximo de la propia sesión, y el cierre nunca supera al máximo
    • CDepende del ajuste por dividendos
    • DTodas las sesiones de tendencia alcista
    Ver respuesta

    Respuesta B. Ninguna: el canal incluye el máximo de la propia sesión, y el cierre nunca supera al máximo

    El máximo de las 20 últimas sesiones incluye la de hoy, y el cierre de hoy es menor o igual que su máximo. La regla de ruptura compara con el canal de las sesiones anteriores: rolling(20).max().shift(1). Con ese desplazamiento salen 148 cierres por encima en NOVA.

  2. ¿Qué diferencia una EMA de 20 sesiones de una SMA de 20 sesiones?

    • ALa EMA usa solo las 20 últimas sesiones y la SMA toda la historia
    • BLa EMA pondera todas las sesiones pasadas con pesos que decrecen geométricamente; la SMA pondera igual las 20 últimas y nada las anteriores
    • CNinguna: son dos nombres de la misma media
    • DLa EMA usa precios futuros para reducir el retardo
    Ver respuesta

    Respuesta B. La EMA pondera todas las sesiones pasadas con pesos que decrecen geométricamente; la SMA pondera igual las 20 últimas y nada las anteriores

    La EMA es recursiva: cada valor mezcla el precio de hoy (peso α = 2/21) con la EMA de ayer. Su memoria es infinita pero se desvanece; con el mismo número de sesiones, su retardo ante una tendencia lineal es el mismo que el de la SMA, (n − 1)/2 sesiones, y reacciona antes a un cambio brusco.

  3. El RSI de Wilder suaviza las subidas y las bajadas con una media exponencial. ¿Qué α usa en pandas un RSI de 14 sesiones?

    • Aspan=14, es decir, α = 2/15
    • Balpha=1/14
    • Ccom=14
    • Dhalflife=14
    Ver respuesta

    Respuesta B. alpha=1/14

    Wilder suaviza con α = 1/n (lo que en pandas es alpha=1/14, equivalente a com=13). Con span=14 el RSI responde casi el doble de rápido y no coincide con el de ninguna plataforma.

  4. El VWAP de NOVA del 2 de junio de 2025 es 53,319 con todas las operaciones y 53,350 solo con negociación continua. ¿Qué explica casi toda la diferencia?

    • AUn error de redondeo
    • BLa subasta de cierre: 885.500 acciones a 53,12, por debajo del VWAP del día, que tiran de la media hacia abajo
    • CLas operaciones del internalizador
    • DLa subasta de apertura
    Ver respuesta

    Respuesta B. La subasta de cierre: 885.500 acciones a 53,12, por debajo del VWAP del día, que tiran de la media hacia abajo

    Justo antes de las 16:00 la diferencia es de un céntimo; la subasta de cierre, casi el 10 % del volumen del día en un único precio, la lleva a 3,1 céntimos. Quien compara su ejecución con «el VWAP» tiene que decir cuál.

  5. Una media móvil centrada (rolling(21, center=True)) suaviza mejor que una normal. ¿Por qué no sirve para una señal?

    • APorque es más lenta de calcular
    • BPorque el valor de cada día usa los diez días siguientes: falla la prueba de truncado
    • CPorque pandas no la admite en series con fechas
    • DPorque solo funciona con ventanas impares
    Ver respuesta

    Respuesta B. Porque el valor de cada día usa los diez días siguientes: falla la prueba de truncado

    Centrar la ventana mete en el valor de hoy diez precios que todavía no se conocen. En un gráfico queda bonita; en un backtest es anticipación pura. Calculada con la serie cortada en una fecha, sus últimos diez valores salen vacíos o distintos.

Ideas clave

  • Un indicador en la sesión t solo puede usar datos hasta t; la orden que provoca se ejecuta en t + 1. Esa regla, y no el indicador, es lo que separa un backtest útil de uno engañoso.
  • Casi todos los indicadores clásicos son medias móviles (simples, exponenciales o de Wilder) de precios, rangos o volúmenes: pandas los calcula con rolling y ewm sin bucles. KAMA, recursiva con un coeficiente que cambia cada día, necesita uno.
  • Las familias miden cosas distintas: tendencia (medias, MACD, ADX), momento (RSI, estocástico), volatilidad (ATR, Bollinger, Keltner, Donchian) y volumen (OBV, VWAP, perfil). Combinar dos de la misma familia apenas añade información.
  • Los indicadores se calculan sobre precios ajustados: sin ajustar, el split de NOVA de 2021 da un rango verdadero de 181,89 USD en una sesión que se movió 2,30.
  • La prueba de truncado detecta la anticipación: el indicador calculado con la serie cortada en una fecha debe coincidir con el calculado con toda la serie hasta esa fecha.
  • El VWAP y el perfil de volumen dependen de qué operaciones se incluyan: la subasta de cierre mueve el VWAP de NOVA 3,1 céntimos y lleva el POC de 53,45 a 53,10.

Recursos