Lección 5.2.1 · 70 min
Features de precio, volumen y microestructura
Casi todo modelo de trading empieza por transformar precios y volúmenes en números comparables entre activos y entre fechas. Esta lección construye las features clásicas sobre un panel de 400 acciones con la verdad publicada, mide cuánto anticipa cada una y pasa a la microestructura: el desequilibrio de órdenes y el VPIN de un futuro en barras de un minuto, calculados con el lado agresor verdadero y con los clasificadores que hay que usar cuando no se tiene.
- Módulo
- 5.2 · Ingeniería de características
- Duración
- 70 min
- Requisitos
- 1.4.5 · Construcción de barras: tiempo, ticks, volumen, dólar y desequilibrio, 1.7.1 · Indicadores técnicos en Python, 2.3.1 · Seguimiento de tendencia y momentum, 4.3.2 · Order flow imbalance, señales de microestructura y latencia, 5.1.1 · Por qué el machine learning financiero es distinto
Lecciones del nivel 5
5.1 · Fundamentos de ML aplicados a finanzas
5.2 · Ingeniería de características
5.3 · Etiquetado y muestreo
- 5.3.1 Etiquetado de horizonte fijo y sus problemas
- 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
- 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
- 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
- 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
5.4 · Validación para series financieras
5.5 · Modelos clásicos
- 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
- 5.5.2 Árboles de decisión, Random Forest y bagging
- 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
- 5.5.4 Optuna y el riesgo de sobreajustar la validación
- 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
- 5.5.6 Learning to rank para la selección de acciones
5.6 · Deep learning con Keras 3
5.7 · Deep learning con PyTorch
- 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
- 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
- 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
- 5.7.4 Redes de grafos para relaciones entre activos
- 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
5.8 · Modelos fundacionales de series temporales
- 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
- 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
- 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
- 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
- 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
5.9 · Aprendizaje por refuerzo
5.10 · Interpretabilidad, robustez y riesgo de modelo
Una feature es una función del pasado
En el machine learning financiero, una feature (variable explicativa, predictor) es un número que se calcula para cada activo y cada fecha con información disponible en esa fecha, y que se espera relacionado con lo que pasará después: el rendimiento, su signo, su magnitud. La definición tiene dos partes y las dos importan. «Con información disponible» excluye cualquier dato posterior, aunque sea de un minuto (lección 0.1.1). «Relacionado con lo que pasará» es una hipótesis que hay que medir, y casi siempre la respuesta es «un poco».
Esta lección trabaja con las familias que salen solo de precios y volúmenes. Los fundamentales y los datos alternativos son la lección 5.2.2; la normalización transversal sin fugas, la 5.2.4.
| Familia | Ejemplos | Qué intenta capturar | Trampa típica |
|---|---|---|---|
| Rentabilidades | 1, 5, 21, 63 sesiones; momentum 12-1 | Reversión a corto plazo, tendencia a medio | Horizontes solapados que repiten la misma información |
| Volatilidad | Desviación de 21 y 63 sesiones | Riesgo, régimen | Confundir anticipar la magnitud con anticipar la dirección |
| Volumen | Volumen relativo, rotación | Atención, liquidez, confirmación | Unidades absolutas (acciones) no comparables |
| Indicadores | RSI, distancia al máximo, medias | Lo mismo que las rentabilidades, en otra escala | Unidades de precio; redundancia entre ellos |
| Microestructura | Desequilibrio de órdenes, VPIN | Flujo informado, toxicidad | Clasificar el lado del agresor sin tenerlo |
El recorrido de los datos hasta el modelo es siempre el mismo, y las fugas se cuelan en las flechas de la izquierda:
Fuente Mermaid
flowchart LR P[Precios y volumen] --> F[Features en t] F --> N[Normalización] N --> M[Modelo] E[Etiqueta t a t+h] --> M
El panel y las features recalculadas
Los datos son el panel sintético del nivel 5: 400 acciones ficticias, 3.772 sesiones de la NYSE de 2011 a 2025 y 22 features semanales publicadas con su definición (README de los datos). El generador conoce el rendimiento esperado verdadero de cada acción y semana, así que cada medida de esta lección se puede comparar con la respuesta. Son datos sintéticos: los efectos están plantados y ninguna cifra es evidencia de lo que pasa en un mercado real.
Antes de usar una feature publicada por otro (un proveedor, un compañero, el propio generador), conviene recalcularla desde los precios. Si sale distinta, o la definición no es la que se cree, o hay un problema de alineación:
import numpy as np
import pandas as pd
px = pd.read_parquet("panel5_precios.parquet")
C = px.pivot(index="fecha", columns="ticker", values="cierre")
V = px.pivot(index="fecha", columns="ticker", values="volumen")
R = C.pct_change()
fe = pd.read_parquet("panel5_features.parquet")
fechas = pd.DatetimeIndex(fe["fecha"].unique())
print(C.shape, len(fechas), "semanas")
mias = {
"ret_1s": C / C.shift(5) - 1,
"mom_12_1": C.shift(21) / C.shift(252) - 1,
"vol_21d": R.rolling(21).std() * np.sqrt(252),
"volumen_rel": np.log(V.rolling(5).mean()
/ V.rolling(63).mean()),
}
for nombre, X in mias.items():
pub = fe.pivot(index="fecha", columns="ticker", values=nombre)
dif = (X.loc[fechas] - pub).abs().max().max()
print(f"{nombre:12s} diferencia máxima {dif:.4f}")(3772, 400) 730 semanas
ret_1s diferencia máxima 0.0001
mom_12_1 diferencia máxima 0.0001
vol_21d diferencia máxima 0.0001
volumen_rel diferencia máxima 0.0007Las cuatro coinciden hasta la cuarta cifra decimal, que es la precisión con la que se publican; el volumen relativo
difiere algo más porque el volumen del archivo está redondeado a tres cifras significativas. Dos detalles hacen que
el cálculo sea point-in-time. Todas las ventanas (rolling, shift) terminan en la fila de la fecha, es decir, en el
cierre de ese día: la feature se conoce al cierre y solo se puede operar con ella en la sesión siguiente. Y el momentum
mom_12_1 mide de a : salta el último mes a propósito, por la razón que se ve en la siguiente sección.
Rentabilidades a varios horizontes
La medida que se usa en todo el módulo es el IC de rangos (information coefficient): cada semana, la correlación de Spearman entre la feature y el rendimiento de la semana siguiente en las 400 acciones; después, su media en las 729 semanas y un estadístico que la divide por su error típico,
Usar rangos protege de los valores extremos y mide lo que importa para ordenar acciones. Las etiquetas semanales no se solapan, así que tratar las semanas como observaciones distintas es razonable (con etiquetas de 21 sesiones no lo sería: lección 5.3.4). El oráculo del README, que conoce el rendimiento esperado verdadero, llega a 0,029: ese es el techo de cualquier feature o modelo en estos datos (lección 5.1.1).
et = pd.read_parquet("panel5_etiquetas.parquet")
Y = et.pivot(index="fecha", columns="ticker",
values="ret_sig_semana").loc[fechas]
def ic(X, y=Y):
"""IC de rangos semanal: media, t y número de semanas."""
X = X.reindex(index=fechas, columns=y.columns)
v = X.rank(axis=1).corrwith(y.rank(axis=1), axis=1)
v = v.dropna()
t = v.mean() / v.std() * np.sqrt(len(v))
return {"ic": round(v.mean(), 4), "t": round(t, 1),
"semanas": len(v)}
tabla = {f"{h} sesiones": ic(C / C.shift(h) - 1)
for h in [1, 2, 5, 10, 21, 63, 126, 252]}
tabla["mom_12_1"] = ic(C.shift(21) / C.shift(252) - 1)
pd.DataFrame(tabla).T.astype({"semanas": int}) ic t semanas
1 sesiones -0.0040 -1.0 729
2 sesiones -0.0095 -2.4 729
5 sesiones -0.0159 -4.3 729
10 sesiones -0.0109 -2.9 729
21 sesiones -0.0077 -2.1 729
63 sesiones -0.0050 -1.6 729
126 sesiones -0.0000 -0.0 729
252 sesiones 0.0053 1.7 729
mom_12_1 0.0077 2.4 729La figura de la cabecera dibuja la tabla. El generador planta una reversión a 5 sesiones (ret_1s) y un momentum de
12 meses que salta el último (mom_12_1); los demás horizontes no tienen efecto propio. Aun así:
- Los vecinos heredan. El rendimiento de 2 sesiones es una parte del de 5, y el de 10 lo contiene entero: los dos salen negativos y significativos (t = −2,4 y −2,9) sin efecto propio. El de 1 sesión, con menos información de la semana, se queda en t = −1,0.
- Los efectos se tapan entre sí. A 126 sesiones, la reversión reciente y el momentum antiguo se compensan y el IC
es cero. A 252, el momentum asoma (0,0053, t = 1,7), pero contaminado por el último mes; quitándolo,
mom_12_1sube a 0,0077 (t = 2,4). Esa es la razón del «12-1» clásico de Jegadeesh y Titman (1993)1Nota 1 · N. Jegadeesh y S. Titman, «Returns to Buying Winners and Selling Losers: Implications for Stock Market Efficiency», Journal of Finance 48(1), 1993, pp. 65-91, doi:10.1111/j.1540-6261.1993.tb04702.x., que separaron el momentum de la reversión a un mes documentada por Jegadeesh (1990)2Nota 2 · N. Jegadeesh, «Evidence of Predictable Behavior of Security Returns», Journal of Finance 45(3), 1990, pp. 881-898, doi:10.1111/j.1540-6261.1990.tb05110.x.. - Un IC significativo no es una señal operable. El mejor, −0,016, es algo más de la mitad del techo del oráculo, y la cartera larga-corta que se construye con señales así no cubre los costes de su rotación semanal (README de los datos; lección 5.1.3).
Con 22 features y 8 horizontes probados, además, un de 2 no impresiona: es una búsqueda múltiple (lección 2.1.8). Aquí se puede contrastar con la verdad; en datos reales, no.
La segunda variante habitual divide el rendimiento por la volatilidad de la acción, para que una subida del 5 % en una acción tranquila pese más que en una volátil:
vol63 = R.rolling(63).std()
for h in [5, 21, 252]:
bruto = C / C.shift(h) - 1
ajustado = bruto / (vol63 * np.sqrt(h))
print(f"{h:3d} sesiones: bruto {ic(bruto)['ic']:+.4f}, "
f"/vol {ic(ajustado)['ic']:+.4f} "
f"(t = {ic(ajustado)['t']:+.1f})") 5 sesiones: bruto -0.0159, /vol -0.0154 (t = -4.7)
21 sesiones: bruto -0.0077, /vol -0.0064 (t = -2.0)
252 sesiones: bruto +0.0053, /vol +0.0066 (t = +2.2)Dividir por la volatilidad casi no cambia el IC a 5 sesiones (−0,0159 frente a −0,0154, con un algo mayor) y mejora algo el de 252 (de 0,0053 a 0,0066). Con rangos dentro de cada semana, el efecto de la normalización es pequeño porque la escala ya da igual; lo que cambia es el orden entre acciones de distinta volatilidad. En un modelo que use los valores (una regresión, una red), la normalización importa mucho más: sin ella, las acciones volátiles dominan la pérdida. La lección 5.2.4 trata cómo normalizar sin usar información futura.
Volatilidad: anticipa la magnitud, no la dirección
La volatilidad realizada es la feature más fácil de predecir y la que más se confunde. Es muy persistente (agrupamiento de la volatilidad, lección 1.6.2), así que la de las últimas semanas anticipa bien la de la siguiente. Lo que no anticipa es el signo:
for w in [5, 21, 63]:
v = R.rolling(w).std()
con_abs = ic(v, Y.abs())
con_signo = ic(v)
print(f"vol {w:2d} sesiones: IC con |r| {con_abs['ic']:.3f} "
f"(t = {con_abs['t']:.0f}); con r {con_signo['ic']:+.4f} "
f"(t = {con_signo['t']:+.1f})")vol 5 sesiones: IC con |r| 0.171 (t = 56); con r +0.0024 (t = +0.5)
vol 21 sesiones: IC con |r| 0.220 (t = 69); con r +0.0020 (t = +0.4)
vol 63 sesiones: IC con |r| 0.227 (t = 76); con r +0.0032 (t = +0.6)Las tres ventanas ordenan muy bien a las acciones por la magnitud del movimiento de la semana siguiente: un IC de 0,22 con 63 sesiones, unas 30 veces el techo del oráculo para la dirección, con de 76. Con el rendimiento con signo, el IC es cero. Una feature de riesgo así sirve para normalizar otras (la sección anterior), para condicionarlas (una interacción) y para dimensionar posiciones (lección 4.2.2), no como predictor de dirección.
El generador planta un efecto de baja volatilidad pequeño y negativo en vol_63d, y no se ve: las acciones volátiles
tienen más beta y, como el mercado sube de media, la beta compensa la prima (el README lo recupera quitando la beta
verdadera). Es un buen recordatorio de que el IC univariante mezcla el efecto de una feature con el de todo lo que se
le parece.
Volumen e interacciones
El volumen en acciones no es comparable entre empresas (una de 2 USD negocia muchas más acciones que una de 800).
La forma habitual es el volumen relativo: el de los últimos días frente al de un periodo más largo de la misma
acción, en logaritmos. En el panel, volumen_rel = log(volumen medio de 5 sesiones / de 63 sesiones) no tiene efecto
propio, pero el generador lo usa en una interacción: la reversión de la semana es más fuerte cuando el movimiento
se hizo con poco volumen, . Es la idea económica
de que un movimiento sin volumen suele ser ruido de liquidez, y uno con volumen, información. Se ve partiendo la
muestra por terciles:
r1s = C / C.shift(5) - 1
vrel = np.log(V.rolling(5).mean() / V.rolling(63).mean())
q = vrel.loc[fechas].rank(axis=1, pct=True)
tramos = {"bajo": (0, 1 / 3), "medio": (1 / 3, 2 / 3),
"alto": (2 / 3, 1)}
serie = {}
for nombre, (lo, hi) in tramos.items():
X = r1s.loc[fechas].where((q > lo) & (q <= hi))
serie[nombre] = X.rank(axis=1).corrwith(Y.rank(axis=1), axis=1)
r = ic(X)
print(f"volumen relativo {nombre:5s}: IC {r['ic']:+.4f} "
f"(t = {r['t']:+.1f})")
dif = (serie["bajo"] - serie["alto"]).dropna()
t_dif = dif.mean() / dif.std() * np.sqrt(len(dif))
print(f"bajo - alto: {dif.mean():+.4f} (t = {t_dif:+.1f})")
centrado = lambda X: X.rank(axis=1, pct=True) - 0.5
inter = centrado(r1s.loc[fechas]) * (1 - centrado(vrel.loc[fechas]))
r = ic(inter)
print(f"interacción: IC {r['ic']:+.4f} (t = {r['t']:+.1f})")volumen relativo bajo : IC -0.0240 (t = -5.1)
volumen relativo medio: IC -0.0162 (t = -3.3)
volumen relativo alto : IC -0.0080 (t = -1.9)
bajo - alto: -0.0160 (t = -3.4)
interacción: IC -0.0170 (t = -4.6)La reversión con volumen bajo es tres veces la de volumen alto (−0,024 frente a −0,008), y la diferencia, medida semana a semana sobre las mismas fechas, es de −0,016 con = −3,4: no es ruido. La feature de interacción construida a mano, con los rangos centrados, da un IC de −0,017, algo mejor que el rendimiento de 5 sesiones solo.
Un árbol de decisión o un boosting (lecciones 5.5.2 y 5.5.3) encuentran interacciones así sin construirlas; un modelo lineal no, salvo que se le den. Construirlas a mano tiene la ventaja de que se pueden razonar y contrastar una a una, y el inconveniente de que cada interacción probada es otra prueba más en la búsqueda múltiple.
Indicadores normalizados
Los indicadores técnicos (lección 1.7.1) se construyen casi siempre en unidades de precio: la distancia a una media móvil en dólares, un MACD, una banda de Bollinger. En un panel, esas unidades hacen que el indicador mida sobre todo cuánto vale la acción, no lo que se quería medir. Y muchos indicadores son, en el fondo, el mismo número con otra escala:
sma50 = C.rolling(50).mean()
en_precio = (C - sma50).loc[fechas]
en_tanto = (C / sma50 - 1).loc[fechas]
en_vol = (en_tanto / (vol63.loc[fechas] * np.sqrt(50)))
nivel = C.loc[fechas].rank(axis=1)
for nombre, X in [("C - SMA50 (USD)", en_precio),
("C / SMA50 - 1", en_tanto),
("(C / SMA50 - 1) / vol", en_vol)]:
rho = X.abs().rank(axis=1).corrwith(nivel, axis=1).mean()
print(f"{nombre:22s} corr. de |X| con el precio: {rho:+.2f}")
rsi = fe.pivot(index="fecha", columns="ticker", values="rsi_14")
r14 = (C / C.shift(14) - 1).loc[fechas]
rho = rsi.rank(axis=1).corrwith(r14.rank(axis=1), axis=1).mean()
print(f"RSI 14 frente a rendimiento de 14 sesiones: {rho:.2f}")C - SMA50 (USD) corr. de |X| con el precio: +0.72
C / SMA50 - 1 corr. de |X| con el precio: +0.01
(C / SMA50 - 1) / vol corr. de |X| con el precio: +0.00
RSI 14 frente a rendimiento de 14 sesiones: 0.95La distancia a la media de 50 sesiones en dólares tiene una correlación de rangos de 0,72 entre su valor absoluto y el precio: en una acción de 800 USD, una distancia «grande» es simplemente la de una acción cara. Dividida por la media, la relación desaparece (0,01); dividida además por la volatilidad esperada en 50 sesiones, se convierte en un número de desviaciones típicas, comparable entre acciones y entre fechas. Las tres reglas: dividir por el precio, por la volatilidad y, para el volumen, por el propio historial de la acción.
La última línea es la redundancia. El RSI de 14 sesiones y el rendimiento de 14 sesiones tienen una correlación de
rangos media de 0,95: son casi la misma feature. Por eso rsi_14 sale con IC negativo (−0,008, t = −2,6 en el README)
sin tener efecto propio: hereda la reversión. Meter en un modelo veinte indicadores que son transformaciones del mismo
rendimiento no añade información y sí ruido y colinealidad (la lección 5.2.5 mide la importancia por grupos de features
correladas).
Microestructura: el desequilibrio de órdenes
Las features de precio y volumen diarias resumen lo que ya pasó en el precio. La microestructura mira quién empujó: si el volumen de cada intervalo lo iniciaron compradores o vendedores agresivos (los que cruzan la horquilla, lección 1.1.1). El desequilibrio de órdenes de un intervalo es
Es la versión con operaciones del order flow imbalance del libro de la lección 4.3.2. Su problema práctico es que el lado agresor no siempre está en los datos: la cinta consolidada de acciones de EE. UU. no lo trae, y por eso existen los clasificadores. La regla del tick asigna cada operación al lado del último cambio de precio, y el algoritmo de Lee y Ready (1991)3Nota 3 · C. M. C. Lee y M. J. Ready, «Inferring Trade Direction from Intraday Data», Journal of Finance 46(2), 1991, pp. 733-746, doi:10.1111/j.1540-6261.1991.tb02683.x. la combina con la cotización vigente. Con barras, sin operaciones individuales, la clasificación por volumen (BVC, bulk volume classification) de Easley, López de Prado y O'Hara4Nota 4 · D. Easley, M. López de Prado y M. O'Hara, «Flow Toxicity and Liquidity in a High-frequency World», Review of Financial Studies 25(5), 2012, pp. 1457-1493, doi:10.1093/rfs/hhs053 (VPIN y clasificación por volumen); de los mismos autores, «Discerning information from trade data», Journal of Financial Economics 120(2), 2016, pp. 269-285, doi:10.1016/j.jfineco.2016.01.018. reparte el volumen de cada barra según el cambio de precio tipificado: .
Los datos son un año de barras de un minuto de FNX, un futuro ficticio sobre un índice (247 sesiones de 390 minutos), con el volumen comprador verdadero de cada minuto. El generador alterna dos regímenes ocultos: tendencia, con flujo informado que empuja en una dirección durante una hora de media, y reversión a la media de 30 minutos. La fracción compradora sigue al precio en los dos regímenes y, solo en tendencia, lleva además un sesgo persistente hacia la dirección del episodio (0,5 + 0,12·tanh(r/σ) + 0,03·D + ruido). Ese sesgo es la huella del flujo informado, y no está en el precio.
from scipy.stats import norm
b = pd.read_parquet("hf5_barras_1min.parquet")
b["sesion"] = b["ts"].dt.date
g = b.groupby("sesion")
b["r"] = np.log(b["cierre"]).groupby(b["sesion"]).diff()
# lado verdadero (el agresor de cada operación)
b["vc"] = b["volumen_comprador"]
# regla del tick: todo el minuto al lado del cambio de precio
signo = np.sign(b["cierre"].groupby(b["sesion"]).diff())
b["vc_tick"] = b["volumen"] * (signo.fillna(0) + 1) / 2
# BVC: fracción compradora = Φ(r / σ), σ de los minutos previos
sigma = g["r"].transform(
lambda s: s.ewm(span=100, min_periods=10).std().shift(1))
frac = pd.Series(norm.cdf(b["r"] / sigma), index=b.index)
b["vc_bvc"] = b["volumen"] * frac.fillna(0.5)
d = {m: 2 * b[c] / b["volumen"] - 1
for m, c in [("verdad", "vc"), ("tick", "vc_tick"),
("bvc", "vc_bvc")]}
d = pd.DataFrame(d)[b["r"].notna() & sigma.notna()]
print(len(b), "minutos;", b["sesion"].nunique(), "sesiones")
d.corr().round(3)96330 minutos; 247 sesiones
verdad tick bvc
verdad 1.000 0.591 0.658
tick 0.591 1.000 0.861
bvc 0.658 0.861 1.000Minuto a minuto, los dos clasificadores se parecen mucho entre sí (0,86) y bastante menos a la verdad (0,59 la regla del tick y 0,66 BVC). Es lo esperable: los dos deducen el lado del movimiento del precio, así que reconstruyen la parte del flujo que sigue al precio y pierden la otra. La pregunta para un modelo es si lo que pierden importa. El régimen de tendencia es lo que interesa detectar, y el desequilibrio de 30 minutos es la feature natural:
from sklearn.metrics import roc_auc_score
verdad = pd.read_parquet("hf5_verdad.parquet")
b["regimen"] = verdad["regimen"].to_numpy() # misma fila, mismo ts
def roll30(col):
return g[col].transform(lambda s: s.rolling(30).sum())
vol30 = roll30("volumen")
for m, c in [("verdad", "vc"), ("tick", "vc_tick"),
("bvc", "vc_bvc")]:
b[f"di_{m}"] = 2 * roll30(c) / vol30 - 1
b["r30"] = g["r"].transform(lambda s: s.rolling(30).sum())
ok = b[["di_verdad", "r30"]].notna().all(axis=1)
tend = b.loc[ok, "regimen"] == "tendencia"
print(f"minutos en tendencia: {tend.mean():.1%}")
for c in ["r30", "di_verdad", "di_tick", "di_bvc"]:
auc = roc_auc_score(tend, b.loc[ok, c].abs())
print(f"AUC de |{c}| para el régimen: {auc:.3f}")minutos en tendencia: 35.1%
AUC de |r30| para el régimen: 0.603
AUC de |di_verdad| para el régimen: 0.682
AUC de |di_tick| para el régimen: 0.556
AUC de |di_bvc| para el régimen: 0.564El AUC mide cuánto separa el valor absoluto de cada feature los minutos de tendencia de los de reversión (0,5 es azar, 1 es perfecto). El rendimiento de los últimos 30 minutos ya separa algo (0,603), porque en tendencia el precio se mueve más en una dirección. El desequilibrio con el lado verdadero separa más (0,682): añade el sesgo persistente que el precio no tiene. Los dos clasificados quedan por debajo del propio rendimiento (0,556 y 0,564): son una versión ruidosa del cambio de precio, y además el ruido de clasificar se acumula en 30 minutos.
¿Anticipa los 30 minutos siguientes?
Distinguir el régimen no es aún anticipar el precio. La prueba honesta es la de las lecciones 5.4: una observación cada 30 minutos (las ventanas de 30 minutos no se solapan), un modelo ajustado en la primera mitad de las sesiones y evaluado en la segunda, y la comparación con la baseline más simple, el rendimiento de los 30 minutos anteriores (momentum intradía). El objetivo es el rendimiento de los 30 minutos siguientes dividido por la volatilidad esperada:
import statsmodels.api as sm
b["minuto"] = g.cumcount()
b["sigma"] = g["r"].transform(
lambda s: s.ewm(span=100, min_periods=10).std())
b["fut30"] = g["r"].transform(
lambda s: s[::-1].rolling(30).sum()[::-1].shift(-1))
b["y"] = b["fut30"] / (b["sigma"] * np.sqrt(30))
b["r30z"] = b["r30"] / (b["sigma"] * np.sqrt(30))
cols = ["y", "r30z", "di_verdad", "di_tick", "di_bvc"]
m = b[(b["minuto"] % 30 == 0)
& b[cols].notna().all(axis=1)].copy()
sesiones = np.sort(m["sesion"].unique())
ent = m["sesion"] < sesiones[len(sesiones) // 2]
print(f"{ent.sum()} observaciones de entrenamiento, "
f"{(~ent).sum()} de prueba; error típico ≈ "
f"{1 / np.sqrt((~ent).sum()):.3f}")
for x in [["r30z"], ["di_tick"], ["di_bvc"], ["di_verdad"],
["r30z", "di_verdad"]]:
X = sm.add_constant(m[x])
fit = sm.OLS(m.loc[ent, "y"], X[ent]).fit()
pred = fit.predict(X[~ent])
c = np.corrcoef(pred, m.loc[~ent, "y"])[0, 1]
print(f"{' + '.join(x):18s} corr. fuera de muestra {c:+.3f}")1353 observaciones de entrenamiento, 1364 de prueba; error típico ≈ 0.027
r30z corr. fuera de muestra +0.111
di_tick corr. fuera de muestra +0.049
di_bvc corr. fuera de muestra +0.061
di_verdad corr. fuera de muestra +0.182
r30z + di_verdad corr. fuera de muestra +0.182Con 1.364 observaciones de prueba, el error típico de cada correlación es de unos 0,027. El rendimiento pasado da 0,111: hay momentum intradía, porque una parte del tiempo es tendencia. Los dos desequilibrios clasificados quedan por debajo (0,049 y 0,061): no añaden nada al precio del que salen. El desequilibrio verdadero llega a 0,182, y combinado con el rendimiento pasado no mejora: lo contiene. La figura de la sección anterior resume las dos medidas.
VPIN: el desequilibrio en tiempo de volumen
El VPIN (volume-synchronized probability of informed trading) de Easley, López de Prado y O'Hara (2012)4 mide el mismo desequilibrio en tiempo de volumen: parte la negociación en cubos de un volumen fijo y promedia el desequilibrio absoluto de los últimos cubos,
Medir en cubos de volumen hace que el indicador avance deprisa cuando hay mucha actividad y despacio cuando hay poca, como las barras de volumen de la lección 1.4.5. Sus autores lo presentaron como una medida de la toxicidad del flujo para los creadores de mercado y como indicador de volatilidad a corto plazo.
Fuente Mermaid
flowchart LR B[Barras de 1 min] --> C[Cubos de volumen] C --> D[Desequilibrio] D --> M[Media de 10 cubos]
El tamaño del cubo se fija con datos anteriores (aquí, 1/50 del volumen diario medio de las 20 primeras sesiones) y no se recalcula con la muestra entera, que sería mirar el futuro. Un minuto que cruza el borde de un cubo se reparte entre los dos en proporción a su volumen:
def vpin(vol, vc, cubo, ventana=10):
"""VPIN por cubo: media móvil de |2·V_compra − V| / V."""
cum_v = np.cumsum(vol)
bordes = np.arange(cubo, cum_v[-1], cubo)
# volumen comprador acumulado en cada borde (reparto lineal
# del minuto que cruza el borde entre los dos cubos)
cum_c = np.interp(bordes, cum_v, np.cumsum(vc))
comp = np.diff(np.r_[0.0, cum_c])
desequilibrio = np.abs(2 * comp - cubo) / cubo
vp = pd.Series(desequilibrio).rolling(ventana).mean()
cierre = np.searchsorted(cum_v, bordes) # minuto de cierre
return vp.to_numpy(), cierre
vol = b["volumen"].to_numpy(float)
# cubo fijado con las 20 primeras sesiones: 1/50 del volumen diario
cubo = b.groupby("sesion")["volumen"].sum().iloc[:20].mean() / 50
print(f"cubo de {cubo:,.0f} contratos")
res = {}
for m_, c in [("verdad", "vc"), ("tick", "vc_tick"),
("bvc", "vc_bvc")]:
vp, cierre = vpin(vol, b[c].to_numpy(float), cubo)
res[m_] = vp
res = pd.DataFrame(res)
res["regimen"] = b["regimen"].to_numpy()[cierre]
res = res.dropna()
print(len(res), "cubos; VPIN medio con el lado verdadero",
round(res["verdad"].mean(), 3))
print(res.groupby("regimen")[["verdad", "tick", "bvc"]]
.mean().round(3))
tend = res["regimen"] == "tendencia"
for m_ in ["verdad", "tick", "bvc"]:
print(f"AUC del VPIN ({m_}): "
f"{roc_auc_score(tend, res[m_]):.3f}")cubo de 23,644 contratos
12687 cubos; VPIN medio con el lado verdadero 0.084
verdad tick bvc
regimen
reversion 0.079 0.311 0.197
tendencia 0.091 0.337 0.210
AUC del VPIN (verdad): 0.639
AUC del VPIN (tick): 0.575
AUC del VPIN (bvc): 0.565Con el lado verdadero, el VPIN medio es de 0,084: 0,079 en reversión y 0,091 en tendencia, con un AUC de 0,639 para separar los regímenes, algo menos que el desequilibrio de 30 minutos (el README de los datos, con cubos de 1/50 del volumen diario nominal del generador, 22.000 contratos, da 0,094 y 0,080: el nivel depende del tamaño del cubo). Con los clasificadores pasan dos cosas. El nivel se dispara: 0,32 con la regla del tick y 0,20 con BVC, porque cada error de clasificación pasa volumen de un lado a otro y, en valor absoluto, casi nunca se compensa. Y la separación cae a 0,575 y 0,565. Un umbral de VPIN fijado con un método de clasificación no vale con otro, y un nivel publicado no se puede comparar con uno calculado con otros cubos.
La figura muestra una sesión elegida como ilustración (la diferencia media entre regímenes es mucho menor que la de este día). En el primer episodio de tendencia, una caída de casi 80 puntos en una hora y media, el VPIN sube hasta 0,15; en la reversión de la tarde se queda entre 0,04 y 0,07.
Las conclusiones del artículo original sobre el VPIN se discutieron mucho. Andersen y Bondarenko5Nota 5 · T. G. Andersen y O. Bondarenko, «VPIN and the flash crash», Journal of Financial Markets 17, 2014, pp. 1-46, doi:10.1016/j.finmar.2013.05.005, y «Reflecting on the VPIN dispute», Journal of Financial Markets 17, 2014, pp. 53-64, doi:10.1016/j.finmar.2013.08.002 (resumen consultado el 23/10/2026). mostraron con datos de futuros sobre el S&P 500 que el VPIN está correlado por construcción con el volumen y con la volatilidad, que al controlar por ellos no encuentran poder predictivo adicional para la volatilidad futura y que sus propiedades dependen mucho de la clasificación de las operaciones. Para un modelo, la lección práctica es la de toda la sección: una feature de microestructura vale lo que vale el dato del lado agresor que la alimenta, y se compara siempre con lo que ya dicen el precio, el volumen y la volatilidad.
Errores típicos
- Calcular la feature con el cierre del día y operar a ese mismo cierre. La feature se conoce al cierre; la orden va a la sesión siguiente (o a la subasta de cierre, si se calcula unos minutos antes con precios de ese momento).
- Horizontes que se repiten. Rendimientos de 1, 2, 5 y 10 sesiones comparten casi toda la información; con ventanas anidadas, el modelo reparte un mismo efecto entre varias columnas y la importancia de cada una engaña.
- Unidades de precio en un panel. Una distancia en dólares, un MACD sin normalizar o un volumen en acciones ordenan por tamaño o por precio de la acción, no por lo que se quería medir.
- Confundir riesgo con dirección. La volatilidad anticipa la magnitud del movimiento; si el modelo predice rendimientos con signo, su IC alto con el valor absoluto no dice nada.
- Clasificar el lado agresor y creer que se tiene el flujo. La regla del tick y BVC reconstruyen lo que ya está en el precio. Si la señal depende del flujo, hace falta el dato del lado agresor.
- Ventanas que cruzan sesiones. Un desequilibrio de 30 minutos a las 9:35 que incluya el final de la sesión anterior
mezcla dos días; en FNX los
rollingvan agrupados por sesión (el VPIN, en cambio, sí sigue de un día al siguiente, como en su definición original). - Evaluar con ventanas solapadas. Una observación por minuto con un objetivo de 30 minutos infla el número de observaciones unas 30 veces y hace que todo parezca significativo.
Ejercicios
dist_max_52syrsi_14no tienen efecto propio en el panel, pero su IC univariante no es cero. Quita, semana a semana, la parte de cada una que explica el rendimiento de 5 sesiones (regresión de rangos) y calcula el IC de lo que queda. ¿Qué features son redundantes?
Solución
def residuo_rangos(X, Z):
# rango de X menos su proyección lineal sobre el rango de Z
rx, rz = X.rank(axis=1), Z.rank(axis=1)
rx = rx.sub(rx.mean(axis=1), axis=0)
rz = rz.sub(rz.mean(axis=1), axis=0)
beta = (rx * rz).sum(axis=1) / (rz * rz).sum(axis=1)
return rx - rz.mul(beta, axis=0)
r1 = fe.pivot(index="fecha", columns="ticker", values="ret_1s")
for nombre in ["rsi_14", "dist_max_52s"]:
X = fe.pivot(index="fecha", columns="ticker", values=nombre)
antes, despues = ic(X), ic(residuo_rangos(X, r1))
print(f"{nombre:13s} IC {antes['ic']:+.4f} (t = {antes['t']:+.1f})"
f" -> sin ret_1s {despues['ic']:+.4f} "
f"(t = {despues['t']:+.1f})")rsi_14 IC -0.0083 (t = -2.6) -> sin ret_1s -0.0005 (t = -0.2)
dist_max_52s IC -0.0031 (t = -1.0) -> sin ret_1s +0.0019 (t = +0.7)El RSI pierde todo su IC al quitarle la reversión de 5 sesiones (de −0,0083 a −0,0005): era la misma feature con otra escala. La distancia al máximo de 52 semanas no era significativa antes (t = −1,0) y tampoco lo es después (t = 0,7): el pequeño cambio de signo es ruido. Las dos son redundantes con las rentabilidades. En datos reales no se tiene la verdad para confirmarlo, pero el procedimiento es el mismo: medir el IC incremental sobre las features que ya están en el modelo, no el univariante.
- Repite el VPIN con el lado verdadero con cubos el doble de grandes (1/25 del volumen diario) y con una ventana de 50 cubos. ¿Cómo cambian el nivel medio y el AUC del régimen? ¿Qué parámetros elegirías y cómo, sin mirar la muestra de prueba?
Solución
vc = b["vc"].to_numpy(float)
for factor, ventana in [(1, 10), (2, 10), (1, 50), (2, 50)]:
vp, cierre = vpin(vol, vc, cubo * factor, ventana)
t = pd.DataFrame({"vp": vp,
"reg": b["regimen"].to_numpy()[cierre]})
t = t.dropna()
auc = roc_auc_score(t["reg"] == "tendencia", t["vp"])
print(f"cubo x{factor}, ventana {ventana:2d}: media "
f"{t['vp'].mean():.3f}, AUC {auc:.3f}")cubo x1, ventana 10: media 0.084, AUC 0.639
cubo x2, ventana 10: media 0.064, AUC 0.613
cubo x1, ventana 50: media 0.084, AUC 0.556
cubo x2, ventana 50: media 0.064, AUC 0.543Con cubos el doble de grandes, cada cubo agrega más volumen y el desequilibrio absoluto medio baja (de 0,084 a 0,064): el nivel del VPIN depende del cubo, no solo del mercado. Con una ventana de 50 cubos (unas sesiones enteras) el indicador se suaviza tanto que deja de seguir episodios de una hora y el AUC cae a 0,556 y 0,543. Los parámetros se eligen con el horizonte del fenómeno que se quiere detectar (aquí, episodios de una hora: ventanas de unas horas como mucho) y con validación en la primera mitad de los datos, nunca con el AUC de la segunda. Cada combinación probada cuenta en la búsqueda múltiple.
- Tu proveedor de datos de un futuro te da barras de un minuto con OHLCV, sin el lado agresor. Un colega propone construir el desequilibrio con BVC y entrenar un modelo con él. ¿Qué le dirías, y qué dato pedirías?
Solución
Que BVC solo reparte el volumen según el cambio de precio de la barra: en FNX, el desequilibrio así construido separa el régimen peor que el propio rendimiento de 30 minutos (AUC 0,564 frente a 0,603) y anticipa menos el precio (0,061 frente a 0,111). Antes de añadirlo hay que compararlo con las features de precio de las que sale; si no las mejora fuera de muestra, sobra. Si la hipótesis es que el flujo contiene información que el precio aún no tiene, hace falta el dato de cada operación con su lado agresor (muchos mercados de futuros lo difunden en su feed de operaciones) o, como mínimo, las operaciones con la cotización vigente para aplicar Lee y Ready. Y, en los dos casos, comprobar la marca de tiempo de cada operación frente al cierre de la barra.
Footnotes
-
N. Jegadeesh y S. Titman, «Returns to Buying Winners and Selling Losers: Implications for Stock Market Efficiency», Journal of Finance 48(1), 1993, pp. 65-91, doi:10.1111/j.1540-6261.1993.tb04702.x. ↩
-
N. Jegadeesh, «Evidence of Predictable Behavior of Security Returns», Journal of Finance 45(3), 1990, pp. 881-898, doi:10.1111/j.1540-6261.1990.tb05110.x. ↩
-
C. M. C. Lee y M. J. Ready, «Inferring Trade Direction from Intraday Data», Journal of Finance 46(2), 1991, pp. 733-746, doi:10.1111/j.1540-6261.1991.tb02683.x. ↩
-
D. Easley, M. López de Prado y M. O'Hara, «Flow Toxicity and Liquidity in a High-frequency World», Review of Financial Studies 25(5), 2012, pp. 1457-1493, doi:10.1093/rfs/hhs053 (VPIN y clasificación por volumen); de los mismos autores, «Discerning information from trade data», Journal of Financial Economics 120(2), 2016, pp. 269-285, doi:10.1016/j.jfineco.2016.01.018. ↩ ↩2
-
T. G. Andersen y O. Bondarenko, «VPIN and the flash crash», Journal of Financial Markets 17, 2014, pp. 1-46, doi:10.1016/j.finmar.2013.05.005, y «Reflecting on the VPIN dispute», Journal of Financial Markets 17, 2014, pp. 53-64, doi:10.1016/j.finmar.2013.08.002 (resumen consultado el 23/10/2026). ↩
Respuesta plegada en cada pregunta
Autoevaluación
En el panel, el rendimiento de 5 sesiones tiene un IC de −0,016 y el de 10 sesiones, de −0,011, aunque solo el de 5 tiene efecto plantado. ¿Por qué el de 10 también sale negativo y significativo?
- APorque el rendimiento de 10 sesiones contiene el de las 5 últimas: hereda su efecto aunque no tenga uno propio
- BPorque el generador planta también una reversión a 10 sesiones
- CPorque el IC de rangos está sesgado hacia valores negativos
- DPorque las etiquetas se solapan
Ver respuesta
Respuesta A. Porque el rendimiento de 10 sesiones contiene el de las 5 últimas: hereda su efecto aunque no tenga uno propio
Un rendimiento de 10 sesiones es el de las 5 últimas más el de las 5 anteriores. La parte reciente arrastra la reversión plantada; la antigua solo añade ruido, y por eso el IC baja pero no desaparece. Un IC univariante no distingue el efecto propio del heredado: hace falta un modelo con las dos features a la vez o conocer la verdad, como aquí.
La volatilidad de 63 sesiones tiene un IC de 0,23 con el valor absoluto del rendimiento de la semana siguiente y de 0,003 con el rendimiento con signo. ¿Qué uso tiene en un modelo de dirección?
- ANinguno: si no predice la dirección hay que quitarla
- BEs la mejor feature de dirección del panel
- CEscalar o condicionar otras features (una reversión pesa distinto en una acción tranquila que en una volátil) y dimensionar posiciones; por sí sola no ordena por rendimiento esperado
- DSustituir a la etiqueta
Ver respuesta
Respuesta C. Escalar o condicionar otras features (una reversión pesa distinto en una acción tranquila que en una volátil) y dimensionar posiciones; por sí sola no ordena por rendimiento esperado
La volatilidad es muy persistente y anticipa la magnitud de los movimientos, no su signo. Sirve para normalizar (rendimiento dividido por volatilidad), para interacciones y para el tamaño de las posiciones. En el panel, además, tiene un efecto plantado negativo pequeño que la beta tapa con el rendimiento bruto (README de los datos).
Con las barras de un minuto de FNX, el desequilibrio de 30 minutos calculado con el volumen comprador verdadero distingue el régimen de tendencia con un AUC de 0,68; reconstruido con la regla del tick, con 0,56, menos que el propio rendimiento de 30 minutos (0,60). ¿Por qué tanta diferencia?
- APorque la regla del tick tiene un error de programación
- BPorque la regla del tick deduce el lado del movimiento del precio, así que solo recupera la parte del flujo que ya está en el precio; la parte que no se ve en él (el sesgo persistente del flujo informado) se pierde
- CPorque el AUC no se puede comparar entre features
- DPorque el volumen comprador verdadero tiene fuga
Ver respuesta
Respuesta B. Porque la regla del tick deduce el lado del movimiento del precio, así que solo recupera la parte del flujo que ya está en el precio; la parte que no se ve en él (el sesgo persistente del flujo informado) se pierde
En los datos, la fracción compradora es 0,5 + 0,12·tanh(r/σ) + 0,03·D en tendencia + ruido. Un clasificador que mira el precio solo puede reconstruir el primer término. El término 0,03·D, la huella del flujo informado, solo está en el lado agresor de cada operación.
El VPIN de FNX calculado con BVC sale en torno a 0,20 y con el lado verdadero en torno a 0,08. ¿Qué se concluye?
- AQue el mercado es más tóxico de lo que parece
- BQue BVC es más preciso
- CQue el VPIN no depende del tamaño de los cubos
- DQue el nivel del VPIN depende del método de clasificación: el ruido de clasificar se suma como desequilibrio aparente, y un umbral fijado con un método no vale con otro
Ver respuesta
Respuesta D. Que el nivel del VPIN depende del método de clasificación: el ruido de clasificar se suma como desequilibrio aparente, y un umbral fijado con un método no vale con otro
Cada cubo de volumen mide |V_compra − V_venta|/V. Un error de clasificación desplaza volumen de un lado a otro y casi nunca se compensa en valor absoluto: el VPIN medido sube. Por eso los niveles publicados de VPIN no se pueden comparar si cambia la clasificación o el tamaño del cubo.
Para evaluar si el desequilibrio de 30 minutos anticipa el rendimiento de los 30 minutos siguientes, ¿por qué se toma una observación cada 30 minutos y no una por minuto?
- APorque con una por minuto las ventanas se solapan: 30 observaciones seguidas comparten casi todo el futuro y el error típico calculado como si fueran independientes sería unas cinco veces demasiado pequeño
- BPara que el cálculo sea más rápido
- CPorque las barras de un minuto no tienen volumen comprador
- DPorque la correlación no admite más de 3.000 puntos
Ver respuesta
Respuesta A. Porque con una por minuto las ventanas se solapan: 30 observaciones seguidas comparten casi todo el futuro y el error típico calculado como si fueran independientes sería unas cinco veces demasiado pequeño
Con ventanas solapadas, la correlación media se puede estimar, pero el número efectivo de observaciones es unas 30 veces menor. Muestrear sin solapamiento da un error típico honesto de 1/√n (lecciones 5.3.4 y 5.4.1).
Ideas clave
- Una feature es una función del pasado conocida al cierre de su fecha. Recalcularla desde los precios y compararla con la publicada es la forma más barata de encontrar errores de definición y de alineación.
- En el panel, la reversión plantada a 5 sesiones da el IC más fuerte entre los rendimientos (−0,016, t = −4,3); los horizontes vecinos heredan parte de su efecto. El momentum de 12 meses salta el último mes para no mezclarse con la reversión.
- La volatilidad anticipa la magnitud (IC de 0,23 con el rendimiento absoluto) y no la dirección: sirve para normalizar, condicionar y dimensionar.
- Las interacciones importan: la reversión es tres veces más fuerte con volumen relativo bajo (IC −0,024) que con volumen alto (−0,008).
- Los indicadores en unidades de precio no son comparables entre acciones: se normalizan por el precio y por la volatilidad. Muchos indicadores clásicos son casi la misma feature (el RSI de 14 sesiones y el rendimiento de 14 sesiones tienen una correlación de rangos de 0,95).
- El desequilibrio de órdenes con el lado agresor verdadero lleva información que el precio no tiene: en FNX distingue el régimen (AUC 0,68) y anticipa los 30 minutos siguientes (correlación de 0,18 fuera de muestra, frente a 0,11 del rendimiento pasado). Clasificado desde las barras, pierde casi todo eso; el VPIN, además, cambia de nivel con el método.
Recursos
Documentación
- Easley, López de Prado y O'Hara (2012): Flow Toxicity and Liquidity in a High-frequency World, Review of Financial Studies 25(5)
- Andersen y Bondarenko (2014): VPIN and the flash crash, Journal of Financial Markets 17
- Easley, López de Prado y O'Hara (2016): Discerning information from trade data, Journal of Financial Economics 120(2)
- Lee y Ready (1991): Inferring Trade Direction from Intraday Data, Journal of Finance 46(2)