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.
- Módulo
- 1.7 · Análisis técnico programado
- Duración
- 75 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
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.
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.
| Familia | Indicadores | Qué mide | Entrada | Uso típico |
|---|---|---|---|---|
| Tendencia | SMA, EMA, KAMA, MACD, ADX | Dirección y fuerza del movimiento | Cierre (ADX: máximo, mínimo, cierre) | Filtros de régimen, cruces, seguimiento de tendencia |
| Momento | RSI, estocástico, histograma del MACD | Velocidad reciente frente a la habitual | Cierre (estocástico: máximo y mínimo) | Sobrecompra y sobreventa, divergencias |
| Volatilidad | ATR, Bollinger, Keltner, Donchian | Amplitud del movimiento | Máximo, mínimo, cierre | Tamaño de posición, stops, rupturas |
| Volumen | OBV, VWAP, perfil de volumen | Cuánto se negocia y a qué precio | Precio 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 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.
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"]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 13974110El 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:
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) 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.37El 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 sesiones pondera igual los últimos cierres:
La media exponencial (EMA) es recursiva: mezcla el cierre de hoy con la EMA de ayer, con . Su memoria es infinita, pero el peso de un cierre de hace sesiones decrece como :
La media adaptativa de Kaufman (KAMA) cambia su cada día según el cociente de eficiencia : el desplazamiento neto de sesiones dividido por la suma de los movimientos de cada sesión. Con una tendencia limpia vale 1 y la media se acerca a una EMA rápida (2 sesiones); con ruido lateral tiende a 0 y la media casi no se mueve (EMA de 30):
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 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.
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) 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.04En 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.
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")SMA 20: 9.50 sesiones
EMA 20: 9.50 sesiones
KAMA 10: 1.25 sesionesLa SMA y la EMA de 20 sesiones tienen el mismo retardo, 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 (), se queda a 1,25 sesiones: y el retardo de una EMA con ese coeficiente es . 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 (no ):
donde y . 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:
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) 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.64Al 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.
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")RSI > 70: 7.3% de las sesiones
RSI < 30: 8.0% de las sesionesEn 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 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.
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) 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.54La 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:
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 (: lo que sube el máximo respecto a ayer, si es mayor que lo que baja el mínimo) con el negativo (), los divide por el ATR para obtener y , y suaviza la distancia normalizada entre los dos:
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%}") 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; y 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:
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")Sin desplazar: 0 cierres por encima
Desplazado una sesión: 148 por encima, 125 por debajoEl 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.
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:
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")OBV a 31-12-2025: -375.2 M de acciones
Cambio en 2025: -0.8 MEl 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:
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):
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}")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.311Durante 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, , 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:
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")Continua: POC 53.45, área de valor 53.15-53.90 (72.2% del volumen)
Todas: POC 53.10 con 967,099 accionesEn 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 solo usa datos hasta . 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.
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):
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')}")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 FalseLos 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:
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))Precio ajustado hoy / ajustado en 2023: 0.9328 a 0.9328
RSI igual: True
SMA igual: FalseLos 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,MinMaxScalerajustado 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.
spanfrente aalpha,ddof=0frente addof=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
- El ancho de las bandas de Bollinger, , 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
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}")Ancho mínimo: 3.2% el 2025-08-04
Cierre: 51.76; 20 sesiones después: 52.26 (+1.0%)
Rango: 50.36-57.00La 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.
- 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
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}")Todas: 53.3191
Sin subasta de cierre: 53.3412
Solo continua: 53.3503Sin 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.
- 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
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.¿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.
El RSI de Wilder suaviza las subidas y las bajadas con una media exponencial. ¿Qué α usa en pandas un RSI de 14 sesiones?
- A
span=14, es decir, α = 2/15 - B
alpha=1/14 - C
com=14 - D
halflife=14
Ver respuesta
Respuesta B.
alpha=1/14Wilder suaviza con α = 1/n (lo que en pandas es
alpha=1/14, equivalente acom=13). Conspan=14el RSI responde casi el doble de rápido y no coincide con el de ninguna plataforma.- A
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.
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
rollingyewmsin 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.