Lección 2.4.3 · 60 min
vectorbt: barridos masivos de parámetros y simulación de carteras
vectorbt convierte un barrido de miles de combinaciones de parámetros en unas pocas operaciones entre matrices. Esta lección explica cómo lo hace y su estado (licencia, edición PRO), programa el mismo barrido con NumPy sobre 24 futuros y 400 parejas de medias, y mide lo que casi nadie mide: cuánto vale fuera de muestra la mejor combinación.
- Módulo
- 2.4 · Backtesting: motores y paradigmas
- Duración
- 60 min
Lecciones del nivel 2
2.1 · Estadística y econometría para trading
- 2.1.1 Contrastes de hipótesis, p-valores, bootstrap y regresión con errores robustos
- 2.1.2 Estacionariedad: ADF, KPSS, diferenciación y diferenciación fraccional
- 2.1.3 Modelos ARMA y ARIMA: autocorrelación simple (ACF) y parcial (PACF)
- 2.1.4 Modelos de volatilidad: ARCH, GARCH, EGARCH y GJR con la librería arch
- 2.1.5 Cointegración: Engle-Granger, Johansen y vida media de la reversión
- 2.1.6 Filtro de Kalman para hedge ratios dinámicos
- 2.1.7 Detección de regímenes: modelos de Markov ocultos y cambios estructurales
- 2.1.8 El problema de las pruebas múltiples: Bonferroni, FDR y el «factor zoo»
2.2 · Anatomía de un sistema de trading
- 2.2.1 Hipótesis de ventaja: riesgo, comportamiento, estructura e información
- 2.2.2 Universo, señal, filtro, entrada, salida, sizing y gestión del riesgo
- 2.2.3 Frecuencias: intradía, swing y posicional, y la capacidad de una estrategia
- 2.2.4 Diario de investigación y registro de experimentos contra el data snooping
2.3 · Familias de estrategias
- 2.3.1 Seguimiento de tendencia y momentum
- 2.3.2 Reversión a la media: RSI y Bollinger, reversión intradía y huecos
- 2.3.3 Pairs trading y arbitraje estadístico: distancia, cointegración y Kalman
- 2.3.4 Carry en divisas, futuros y bonos; estacionalidad y efectos calendario
- 2.3.5 Factores (value, calidad, baja volatilidad, tamaño) y estrategias de eventos
- 2.3.6 Introducción al market making: inventario, spread y selección adversa (Avellaneda-Stoikov)
- 2.3.7 Estrategias específicas de cripto: momentum, arbitraje de funding y basis
2.4 · Backtesting: motores y paradigmas
- 2.4.1 Backtest vectorizado desde cero: matrices, desfase, costes y límites
- 2.4.2 Backtest por eventos desde cero: bucle, cartera, órdenes y fills
- 2.4.3 vectorbt: barridos masivos de parámetros y simulación de carteras
- 2.4.4 backtesting.py y backtrader: dos motores didácticos y su estado
- 2.4.5 NautilusTrader: arquitectura, datos, estrategias y el mismo código en backtest y en real
- 2.4.6 LEAN/QuantConnect y Zipline Reloaded; bt para asignación de activos
- 2.4.7 Modelado de costes: comisiones, horquilla, impacto, financiación y préstamo de títulos
- 2.4.8 Modelado de fills: órdenes limitadas, cola, fills parciales, huecos y stops
2.5 · Sesgos y validación
- 2.5.1 Sesgos de backtest: anticipación, supervivencia, selección y narrativa
- 2.5.2 In-sample y out-of-sample: walk-forward anclado y móvil
- 2.5.3 Validación cruzada combinatoria purgada para estrategias
- 2.5.4 Deflated Sharpe Ratio y Probability of Backtest Overfitting (PBO)
- 2.5.5 Monte Carlo: remuestreo de trades, bootstrap de bloques y permutaciones de señales
- 2.5.6 Sensibilidad de parámetros: mesetas frente a picos, robustez entre activos y periodos
- 2.5.7 Datos sintéticos para pruebas de estrés: GBM, GARCH y bootstrapping
- 2.5.8 Checklist de «backtest honesto» antes de pasar a paper trading
2.6 · Métricas de rendimiento
Qué es vectorbt y en qué estado está
vectorbt es una librería de Python para investigar estrategias a escala: indicadores, señales, simulación de carteras y
análisis de resultados sobre pandas y NumPy, con Numba (y un motor opcional en Rust) para la parte
pesada. Su especialidad es lo que en las lecciones 2.4.1 y 2.4.2 era caro: probar miles de combinaciones de
parámetros y de activos de una vez. A fecha de esta lección, su estado según sus fuentes primarias1Nota 1 · PyPI (pypi.org/project/vectorbt, metadatos de la versión 1.2.0), el repositorio de GitHub (README.md y
LICENSE.md) y la página «VectorBT vs PRO» de su documentación, consultados el 21 de octubre de 2026. No se
citan precios de la edición PRO: cambian y no son el objeto de la lección.:
| Aspecto | Dato |
|---|---|
| Versión en PyPI | 1.2.0, publicada el 19 de octubre de 2026; Python ≥ 3.11 y < 3.15 |
| Repositorio | polakowo/vectorbt en GitHub, con actividad en octubre de 2026 |
| Licencia | Apache 2.0 con Commons Clause (LICENSE.md) |
| Edición comercial | VectorBT PRO, de pago por suscripción; repositorio, documentación y comunidad privados |
| Motor | Funciones compiladas con Numba; motor opcional en Rust (vectorbt[rust]) |
La licencia pide leerla. Apache 2.0 es una licencia permisiva; la Commons Clause le quita un derecho: el de «vender» el software, definido como ofrecer a terceros, a cambio de una contraprestación, un producto o servicio cuyo valor derive total o sustancialmente de su funcionalidad, «incluidos alojamiento o consultoría y soporte» relacionados. Para un investigador o un trader que opera con su propio dinero no cambia nada. Para quien quiera montar un servicio de backtesting de pago, una plataforma o una consultoría construida sobre vectorbt, sí: necesita otra licencia. Por eso, y porque el titular del curso lo decidió así, en esta lección vectorbt no se ejecuta: su código aparece tal como lo documenta la librería, y la parte ejecutable reproduce sus ideas con NumPy.
La edición PRO. El README presenta vectorbt como la edición comunitaria y de código abierto de VectorBT PRO, que añade paralelización, más integraciones de datos, optimización de carteras, órdenes limitadas, apalancamiento y otras funciones. Su página de comparación describe el acceso como una suscripción: repositorio privado, documentación y Discord privados, y el derecho a seguir usando la versión instalada al terminar la suscripción. Las funciones de esta lección son de la edición abierta.
La idea: cada parámetro, una columna más
La lección 2.4.1 calculaba una cartera con una matriz fecha × mercado. vectorbt añade dimensiones: cada valor de
cada parámetro es una columna más, y la simulación trata todas las columnas a la vez. Un cruce de medias con 20
ventanas rápidas y 20 lentas sobre 24 mercados son columnas. Es broadcasting
(lección 0.5.1): NumPy combina un array de forma con otro de forma y obtiene
sin copiar los datos de partida.
Fuente Mermaid
flowchart TB P[Precios T] --> R[Medias T×20] P --> L[Medias T×20] R --> S[Señales T×20×20] L --> S S --> W[Posiciones] W --> N[Rend. netos] N --> M[Sharpe 20×20]
El código de vectorbt para un barrido así, tomado de su README y adaptado a los futuros del curso, es este (no se ejecuta en la lección):
import numpy as np
import pandas as pd
import vectorbt as vbt
fut = pd.read_parquet("futuros2_continuos.parquet")
px = fut.pivot(index="fecha", columns="mercado",
values="cierre_ajustado")
ventanas = np.arange(5, 401, 5)
rapida, lenta = vbt.MA.run_combs(px, window=ventanas, r=2,
short_names=["rapida", "lenta"])
entradas = rapida.ma_crossed_above(lenta)
salidas = rapida.ma_crossed_below(lenta)
pf = vbt.Portfolio.from_signals(
px, entradas, salidas,
short_entries=salidas, short_exits=entradas, # largo y corto
fees=0.0005, slippage=0.0005, freq="1D")
sharpe = pf.sharpe_ratio() # una cifra por columna
sharpe.groupby(["rapida_window", "lenta_window"]).mean()MA.run_combs calcula las medias de todas las ventanas y forma las combinaciones de dos (r=2), con la primera
ventana menor que la segunda; el resultado tiene un nivel de columnas por parámetro (rapida_window, lenta_window) y
otro por mercado. Portfolio.from_signals simula la cartera de cada columna: compra en las entradas, vende en las
salidas, cobra comisiones (fees, en fracción del valor de la orden) y deslizamiento (slippage, en fracción del
precio). Por defecto la orden se ejecuta al precio de cierre de la barra de la señal, la misma hipótesis que el
shift(1) de la lección 2.4.1.
El recorrido completo, de los datos a las métricas:
Fuente Mermaid
flowchart TB D[Datos: columnas] --> I[MA.run_combs] I --> S[Entradas, salidas] S --> P[from_signals] P --> N[Bucle Numba] N --> R[Órdenes y trades] R --> M[stats, heatmap]
Lo que no se ve: según su documentación, el módulo de cartera usa funciones compiladas con Numba para casi todos los
cálculos. La simulación sí recorre el tiempo, barra a barra, pero en código compilado y sobre todas las columnas.
Por eso from_signals admite reglas con memoria que un cálculo puramente matricial no puede hacer:
# Trailing stop del 4 % con máximos y mínimos de la barra
pf = vbt.Portfolio.from_signals(
close, entradas, salidas,
open=apertura, high=maximo, low=minimo,
sl_stop=0.04, sl_trail=True)
pf.stats() # resumen de una columna
sharpe.vbt.heatmap(x_level="lenta_window", y_level="rapida_window",
slider_level="mercado")sl_stop es el stop en fracción del precio de entrada y sl_trail=True lo convierte en trailing; open, high y
low solo se usan para decidir si un stop se ha tocado dentro de la barra. Es un motor por eventos en miniatura
(lección 2.4.2), con sus propias reglas para el orden de las cosas dentro de una barra: antes de confiar en él para una
estrategia intradía hay que leerlas.
El mismo barrido con NumPy
La parte ejecutable reproduce el barrido con lo que ya sabemos. Datos, volatilidad y costes son los de la lección 2.4.1: 24 futuros sintéticos con un momentum de 12 meses y un carry plantados, posiciones con objetivo de volatilidad y costes por contrato sobre la rotación y los rolls.
import numpy as np
import pandas as pd
fut = pd.read_parquet("futuros2_continuos.parquet")
spec = pd.read_csv("futuros2_especificaciones.csv",
index_col="mercado")
tabla = {c: fut.pivot(index="fecha", columns="mercado", values=c)
for c in ("cierre_ajustado", "cierre", "contrato")}
px, bruto, contrato = tabla.values()
r = px.pct_change(fill_method=None).fillna(0.0)
coste_usd = (spec["coste_tipico_ticks"] * spec["valor_tick"]
+ spec["comision_usd"])
coste_unit = (coste_usd / (bruto * spec["multiplicador"])).fillna(0.0)
rolls = (contrato != contrato.shift(1)) & contrato.shift(1).notna()
n = px.shape[1]
vol = r.ewm(span=60, min_periods=60).std() * np.sqrt(252)
k = (0.10 / np.sqrt(n) / vol).fillna(0.0) # peso por unidad de señal
print(px.shape, "| riesgo por mercado:", round(0.10 / np.sqrt(n), 4))(6539, 24) | riesgo por mercado: 0.0204La regla: estar largo si la media rápida está por encima de la lenta y corto si está por debajo. Las ventanas forman una rejilla geométrica de 20 valores cada una: rápidas de 5 a 120 sesiones y lentas de 20 a 400. Las parejas con la rápida igual o mayor que la lenta no tienen sentido y se descartan.
rapidas = np.unique(np.geomspace(5, 120, 20).round().astype(int))
lentas = np.unique(np.geomspace(20, 400, 20).round().astype(int))
valido = rapidas[:, None] < lentas[None, :] # broadcasting 20 x 20
print("rápidas:", rapidas)
print("lentas: ", lentas)
print(f"combinaciones: {valido.size}, válidas: {valido.sum()}")rápidas: [ 5 6 7 8 10 12 14 16 19 23 27 31 37 44 52 61 73 86
102 120]
lentas: [ 20 23 27 32 38 44 52 60 71 83 97 113 133 155 182 213 249 292
342 400]
combinaciones: 400, válidas: 325Las medias de todas las ventanas salen de una sola suma acumulada: la media de sesiones que termina en es
, con la suma de los precios hasta . Antes de usarla, se comprueba contra rolling:
def medias(p, ventanas):
"""Media móvil simple de p para cada ventana (una columna por
ventana), con una suma acumulada en lugar de un rolling."""
cs = np.concatenate([[0.0], np.cumsum(p)])
out = np.full((len(p), len(ventanas)), np.nan)
for j, w in enumerate(ventanas):
out[w - 1:, j] = (cs[w:] - cs[:-w]) / w
return out
ref = px["IDA"].rolling(97).mean().to_numpy()
error = np.nanmax(np.abs(medias(px["IDA"].to_numpy(), [97])[:, 0]
- ref))
print(f"error máximo frente a rolling: {error:.1e} puntos")error máximo frente a rolling: 1.5e-10 puntosEl barrido entero es una función. La línea importante es la de la señal: mf[:, :, None] tiene forma y
ms[:, None, :], ; su diferencia es el tensor con todas las parejas. El tamaño de cada
mercado, k, tiene forma y se amplía con [:, None, None]. El bucle es sobre los 24 mercados, no sobre las
400 parejas ni sobre las 6.539 sesiones.
def barrido(px, r, k, coste_unit, rolls, rapidas, lentas,
largo_corto=True):
"""Rendimiento neto diario de la cartera para cada pareja de
ventanas: array de forma (fechas, rápidas, lentas)."""
T, F, S = len(px), len(rapidas), len(lentas)
neto = np.zeros((T, F, S))
for m in px.columns:
p = px[m].to_numpy()
mf, ms = medias(p, rapidas), medias(p, lentas)
dif = mf[:, :, None] - ms[:, None, :] # T x F x S
senal = np.sign(dif) if largo_corto else (dif > 0) * 1.0
w = np.nan_to_num(senal) * k[m].to_numpy()[:, None, None]
pos = np.concatenate([np.zeros((1, F, S)), w[:-1]])
rot = (np.abs(w - pos)
+ 2 * np.abs(pos) * rolls[m].to_numpy()[:, None, None])
neto += (pos * r[m].to_numpy()[:, None, None]
- rot * coste_unit[m].to_numpy()[:, None, None])
return neto
neto = barrido(px, r, k, coste_unit, rolls, rapidas, lentas)
print(neto.shape, f"{neto.nbytes / 1e6:.0f} MB")(6539, 20, 20) 21 MBUn array de 6.539 × 20 × 20 números, 21 MB. vectorbt haría lo mismo con más columnas (también los 24 mercados por separado) y guardaría además las órdenes y las operaciones de cada una. Un barrido de 80 ventanas por 80 sobre 24 mercados ya serían 150.000 columnas: la memoria, más que el tiempo, es el límite de esta forma de trabajar.
Un barrido rápido que da una respuesta equivocada no sirve de nada, así que una celda del tensor se compara con una implementación directa, escrita como en la lección 2.4.1:
def una_pareja(f, s):
dif = px.rolling(f).mean() - px.rolling(s).mean()
w = np.sign(dif).fillna(0.0) * k
pos = w.shift(1, fill_value=0.0)
rot = (w - pos).abs() + 2 * pos.abs().where(rolls, 0.0)
return (pos * r).sum(axis=1) - (rot * coste_unit).sum(axis=1)
i, j = list(rapidas).index(31), list(lentas).index(97)
dif = np.abs(una_pareja(31, 97).to_numpy() - neto[:, i, j]).max()
print(f"pareja (31, 97): diferencia máxima {dif:.1e}")pareja (31, 97): diferencia máxima 2.8e-17El mapa de Sharpe: meseta y picos
fechas = px.index
def sharpe(x):
return np.sqrt(252) * x.mean(axis=0) / x.std(axis=0, ddof=1)
def mapa(neto, desde, hasta=None):
filas = (fechas >= desde) & (fechas < (hasta or "2100"))
sh = np.full(valido.shape, np.nan)
sh[valido] = sharpe(neto[filas][:, valido])
return sh
sh = mapa(neto, "2001")
i, j = np.unravel_index(np.nanargmax(sh), sh.shape)
print(f"mejor pareja: ({rapidas[i]}, {lentas[j]}), "
f"Sharpe {sh[i, j]:.2f}")
print(f"mediana de las {valido.sum()}: {np.nanmedian(sh):.2f}; "
f"por encima de 0,45: {(sh > 0.45).sum()}")
pd.DataFrame(sh, index=rapidas, columns=lentas).iloc[::3, ::3].round(2)mejor pareja: (27, 342), Sharpe 1.00
mediana de las 325: 0.55; por encima de 0,45: 202
20 32 52 83 133 213 342
5 0.06 0.23 0.28 0.37 0.55 0.68 0.92
8 0.09 0.24 0.31 0.42 0.55 0.68 0.90
14 0.09 0.20 0.30 0.41 0.54 0.67 0.95
23 NaN 0.27 0.34 0.41 0.68 0.70 0.97
37 NaN NaN 0.30 0.49 0.71 0.82 0.93
61 NaN NaN NaN 0.65 0.73 0.71 0.86
102 NaN NaN NaN NaN 0.46 0.46 0.72La figura de la cabecera es el mapa completo. El Sharpe neto crece de forma casi continua con la media lenta: en la tabla, con una lenta de 20 sesiones va de 0,06 a 0,09; con una de 342, de 0,72 a 0,97. La zona buena no es un punto: es una meseta amplia de medias lentas largas, y 202 de las 325 parejas superan 0,45 (la mediana es 0,55). Tiene sentido: el efecto plantado en estos futuros es el signo del rendimiento de las 252 sesiones anteriores, y cualquier cruce de medias lento lo aproxima. Las medias lentas cortas siguen tendencias de semanas, que en estos datos no tienen prima, rotan mucho y pagan más costes. La mejor celda, (27, 342) con 1,00, está en esa meseta, rodeada de vecinas casi igual de buenas (en la tabla, 0,97 con (23, 342) y 0,93 con (37, 342)), y eso es lo que la hace creíble; un máximo aislado, rodeado de celdas mediocres, sería lo contrario (lección 2.5.6).
Pero el mapa entero está calculado sobre toda la muestra. La pregunta honesta es la que se haría en tiempo real: si hubiera elegido la mejor pareja con los datos de 2001 a 2012, ¿qué habría obtenido de 2013 a 2025?
sh_in = mapa(neto, "2001", "2013")[valido]
sh_out = mapa(neto, "2013")[valido]
pares = np.argwhere(valido) # mismo orden que [valido]
b = sh_in.argmax()
f, s = rapidas[pares[b, 0]], lentas[pares[b, 1]]
print(f"mejor dentro de muestra: ({f}, {s}), Sharpe {sh_in[b]:.2f}")
print(f"la misma, fuera de muestra: {sh_out[b]:.2f}")
print(f"mediana fuera de muestra: {np.median(sh_out):.2f}")
rho = pd.Series(sh_in).rank().corr(pd.Series(sh_out).rank())
print(f"correlación de rangos dentro/fuera: {rho:.2f}")
quintil = pd.qcut(sh_in, 5, labels=["Q1", "Q2", "Q3", "Q4", "Q5"])
pd.Series(sh_out).groupby(quintil, observed=True).mean().round(2)mejor dentro de muestra: (27, 155), Sharpe 0.79
la misma, fuera de muestra: 0.70
mediana fuera de muestra: 0.63
correlación de rangos dentro/fuera: 0.72
Q1 0.20
Q2 0.50
Q3 0.58
Q4 0.85
Q5 0.92
dtype: float64Dos resultados a la vez, y los dos importan. El primero: el barrido ha encontrado algo real. El orden de las parejas se conserva de un periodo al otro (correlación de rangos de 0,72) y, agrupadas por quintil de su Sharpe dentro de muestra, las parejas rinden fuera de menos a más, de 0,20 en el peor quintil a 0,92 en el mejor. Es lo que se espera cuando el mapa tiene una estructura de verdad: las medias lentas recogen el momentum plantado y pagan menos costes en cualquier periodo.
El segundo: la mejor pareja de 2001-2012, (27, 155), no es la mejor de 2013-2025. Tenía un Sharpe de 0,79, 0,31 por encima de la mediana de su periodo (0,48, en la celda siguiente); fuera da 0,70, solo 0,07 por encima de la mediana (0,63) y por debajo de la media de su propio quintil. Toda la meseta rinde más en el segundo periodo, porque las tendencias de esos años fueron más rentables; lo que no se repite es la distancia del máximo a sus vecinas. Esa parte del Sharpe era ruido de 2001-2012, y es justo la que se paga al elegir el máximo. Quedarse con la zona buena (el quintil superior entero da 0,92 fuera) habría dado más que quedarse con la mejor celda.
Un barrido sobre datos sin efecto
La última comprobación es la más dura: repetir el barrido sobre datos en los que no hay nada que encontrar. Si se permutan al azar los rendimientos diarios de cada mercado, se conserva su distribución (volatilidad, colas) y se destruye cualquier tendencia. El mejor resultado de 325 combinaciones sobre esos datos mide cuánto «encuentra» la búsqueda por pura suerte.
rng = np.random.default_rng(2028)
r_azar = r.apply(lambda c: pd.Series(rng.permutation(c.to_numpy()),
c.index))
px_azar = (1 + r_azar).cumprod()
vol_azar = r_azar.ewm(span=60, min_periods=60).std() * np.sqrt(252)
k_azar = (0.10 / np.sqrt(n) / vol_azar).fillna(0.0)
neto_azar = barrido(px_azar, r_azar, k_azar, coste_unit, rolls,
rapidas, lentas)
for nombre, x in [("datos sintéticos", neto),
("permutados", neto_azar)]:
s_in = mapa(x, "2001", "2013")[valido]
print(f"{nombre:16s} 2001-2012: mediana {np.median(s_in):5.2f}, "
f"máximo {s_in.max():.2f}")datos sintéticos 2001-2012: mediana 0.48, máximo 0.79
permutados 2001-2012: mediana -0.46, máximo 0.15Sobre datos permutados, la mediana del Sharpe es de −0,46 (sin tendencias, lo único que queda son los costes) y el
máximo de 325 combinaciones llega a 0,15 en doce años sin ningún efecto. Es la referencia contra la que hay que medir
un barrido: el mejor resultado de los datos sintéticos, 0,79, está muy por encima, y su mediana (0,48) también supera
al máximo del azar; en la figura, casi todas las parejas quedan a su derecha. El efecto plantado es modesto (un Sharpe
de en torno a 0,15 por mercado), pero sumado en 24 mercados se separa con claridad del azar. Una estrategia real
cuyo mejor Sharpe en un barrido así no se separara del máximo de las permutaciones no habría demostrado nada.
Y este azar es benévolo: permutar los días también destruye el agrupamiento de la volatilidad y las rachas, y los
mercados reales dan al ruido más formas de parecer una tendencia. La lección 2.5.4 convierte esa intuición
en una corrección formal (el Deflated Sharpe Ratio, que vectorbt también calcula con deflated_sharpe_ratio()), y la
2.5.5 generaliza las permutaciones.
Cuándo usar vectorbt, y cuándo no
| Situación | vectorbt | Alternativa |
|---|---|---|
| Barrer miles de parámetros sobre muchos activos | Su terreno: columnas y Numba | NumPy propio, como en esta lección |
| Explorar señales e indicadores con gráficos | Muy cómodo (Plotly, heatmap, stats) | pandas y matplotlib |
| Stops y trailing stops sobre barras | Sí, con sus reglas de orden dentro de la barra | Motor por eventos propio (2.4.2) |
| Órdenes limitadas, apalancamiento, multiplicadores de futuros | Según su README, en la edición PRO | NautilusTrader (2.4.5) |
| Mismo código en backtest y en real | No es su objetivo | NautilusTrader, LEAN (2.4.5, 2.4.6) |
| Vender un servicio basado en él | No sin otra licencia (Commons Clause) | Librerías con licencia permisiva o propias |
La decisión práctica: vectorbt es una herramienta excelente para la fase de exploración, la misma que en la lección 2.4.1 hacía el backtest vectorizado propio, con más velocidad y más análisis ya hechos. La validación final de una estrategia que sobreviva al barrido pertenece a un motor por eventos con las reglas exactas, y la decisión de cuántas pruebas hacer, a ti.
Ejercicios
- Repite el barrido solo con posiciones largas (
largo_corto=False): largo si la rápida está por encima, fuera si no. ¿Cambia la mediana del mapa? ¿Y la posición de la mejor pareja?
Solución
neto_l = barrido(px, r, k, coste_unit, rolls, rapidas, lentas,
largo_corto=False)
sh_l = mapa(neto_l, "2001")
i, j = np.unravel_index(np.nanargmax(sh_l), sh_l.shape)
print(f"solo largos: mediana {np.nanmedian(sh_l):.2f}, mejor "
f"({rapidas[i]}, {lentas[j]}) con {sh_l[i, j]:.2f}")
print(f"largo y corto: mediana {np.nanmedian(sh):.2f}")solo largos: mediana 0.84, mejor (27, 342) con 1.11
largo y corto: mediana 0.55Solo con largos la mediana sube de 0,55 a 0,84 y la mejor pareja sigue siendo (27, 342), ahora con 1,11. El momentum plantado es simétrico, pero no es lo único que hay en estos datos: el README del nivel 2 añade a los futuros de índices la prima de la renta variable (la deriva del guion), y la parte corta del cruce la paga cada vez que está vendida; además, estar fuera en lugar de corto reduce la exposición, la rotación y los costes. Es un buen ejemplo de por qué un resultado de barrido se explica antes de usarlo: la mejora no viene de la regla de tendencia, sino de una prima que la versión larga recoge y la larga-corta neutraliza. En otro universo, la comparación saldría distinta.
- Cuenta cuántas parejas tendrían un estadístico mayor que 2 en 2001-2012 (con , el Sharpe anual y los años) en los datos sintéticos y en los permutados. ¿Qué dice el resultado de los permutados sobre un umbral fijo de ?
Solución
anos = 12
for nombre, x in [("sintéticos", neto), ("permutados", neto_azar)]:
t = mapa(x, "2001", "2013")[valido] * np.sqrt(anos)
print(f"{nombre:10s} t > 2: {(t > 2).sum():3d} de {t.size}")sintéticos t > 2: 99 de 325
permutados t > 2: 0 de 325En los sintéticos, 99 de 325 parejas superan porque el efecto existe; en los permutados, ninguna, en parte porque los costes hunden la mediana. Las 99 no son 99 descubrimientos: son el mismo momentum visto a través de ventanas muy parecidas, con rendimientos muy correlacionados entre sí. Y un umbral fijo no es una garantía: con pruebas independientes y sin costes, dejaría pasar por azar en torno a un 2 % de ellas (unas 7 de 325), y en datos reales el ruido tiene más formas de parecer una tendencia que en una permutación. El umbral correcto depende del número de pruebas y de lo correlacionadas que estén, y la cuenta tampoco dice cuál de las 99 elegir. Las lecciones 2.1.8 y 2.5.4 tratan el ajuste por pruebas múltiples.
- vectorbt ejecuta por defecto al cierre de la barra de la señal y
from_signalsno deja entrar de nuevo mientras ya hay posición. ¿Qué dos diferencias de resultado esperarías entre el códigoskipde vectorbt de esta lección y el barrido con NumPy, aun con los mismos datos y ventanas?
Solución
La primera es el dimensionado: el barrido con NumPy usa pesos con objetivo de volatilidad que cambian cada día, y el
ejemplo de vectorbt invierte todo el efectivo disponible en cada entrada (size por defecto infinito), sin
rebalancear entre señales; ni el riesgo ni la rotación son comparables. La segunda son los costes: allí, un porcentaje
fijo del valor negociado; aquí, un coste por contrato que depende del tamaño del contrato, más los rolls, que vectorbt
no ve en un precio ajustado. Para que coincidieran habría que pasarle pesos objetivo (Portfolio.from_orders con
size_type de tipo objetivo) y un coste equivalente: la misma reconciliación de la lección 2.4.2.
Footnotes
-
PyPI (
pypi.org/project/vectorbt, metadatos de la versión 1.2.0), el repositorio de GitHub (README.mdyLICENSE.md) y la página «VectorBT vs PRO» de su documentación, consultados el 21 de octubre de 2026. No se citan precios de la edición PRO: cambian y no son el objeto de la lección. ↩
Respuesta plegada en cada pregunta
Autoevaluación
¿Qué permite y qué no permite la Commons Clause que acompaña a la licencia Apache 2.0 de vectorbt?
- AProhíbe usarlo con fines comerciales, incluido operar con dinero propio
- BPermite usar, modificar y redistribuir el código, pero no vender un producto o servicio cuyo valor derive total o sustancialmente de vectorbt, incluidos alojamiento o consultoría sobre él
- CObliga a publicar el código de las estrategias
- DEs una licencia de dominio público
Ver respuesta
Respuesta B. Permite usar, modificar y redistribuir el código, pero no vender un producto o servicio cuyo valor derive total o sustancialmente de vectorbt, incluidos alojamiento o consultoría sobre él
El texto de la condición, en el
LICENSE.mddel repositorio, quita del permiso de Apache 2.0 el derecho a «vender» el software, y define vender como ofrecer a terceros, a cambio de una contraprestación, un producto o servicio cuyo valor derive sustancialmente de su funcionalidad. Usarlo para investigar o para operar no es venderlo; montar un servicio de backtesting de pago sobre él, sí.En el barrido de la lección, la combinación con mejor Sharpe de 2001 a 2012 (rápida 27, lenta 155) tiene 0,79 dentro de muestra, 0,31 por encima de la mediana (0,48). ¿Qué ocurre de 2013 a 2025?
- AMantiene su ventaja: el efecto plantado es real
- BDa 0,70, solo 0,07 por encima de la mediana fuera de muestra (0,63) y por debajo de la media de su propio quintil (0,92)
- CSu Sharpe se vuelve negativo
- DNo se puede saber sin vectorbt
Ver respuesta
Respuesta B. Da 0,70, solo 0,07 por encima de la mediana fuera de muestra (0,63) y por debajo de la media de su propio quintil (0,92)
El barrido encontró algo real: el orden de las parejas se conserva fuera de muestra (correlación de rangos de 0,72, quintiles crecientes). Pero la parte del Sharpe que separaba a la mejor de las demás era sobre todo ruido de ese periodo: su ventaja sobre la mediana pasa de 0,31 a 0,07. Elegir el máximo no compensa; elegir la zona buena, sí.
¿Por qué el mismo barrido sobre rendimientos permutados (sin ningún efecto) es una comprobación útil?
- APorque así el barrido tarda menos
- BPorque da la distribución del mejor resultado cuando no hay nada que encontrar: si el máximo de los datos reales no se separa de él, el barrido no ha encontrado una ventaja
- CPorque elimina los costes
- DPorque mejora el Sharpe
Ver respuesta
Respuesta B. Porque da la distribución del mejor resultado cuando no hay nada que encontrar: si el máximo de los datos reales no se separa de él, el barrido no ha encontrado una ventaja
Permutar los rendimientos de cada mercado destruye cualquier tendencia y conserva su distribución. El mejor de 325 resultados sobre esos datos mide cuánto puede «encontrar» la búsqueda por azar. Es la idea de las pruebas de permutación de la lección 2.5.5.
Portfolio.from_signalsadmitesl_stopconsl_trail=True. ¿Cómo puede un backtester «vectorizado» simular un trailing stop?- ANo puede: el parámetro no existe
- BPorque su simulación es un bucle sobre el tiempo compilado con Numba, que recorre cada columna barra a barra; lo vectorizado es el paso de muchas columnas a la vez
- CPorque precalcula todos los máximos con
rolling - DPorque usa la GPU
Ver respuesta
Respuesta B. Porque su simulación es un bucle sobre el tiempo compilado con Numba, que recorre cada columna barra a barra; lo vectorizado es el paso de muchas columnas a la vez
La documentación de vectorbt dice que el módulo de cartera usa funciones compiladas con Numba para casi todos los cálculos. El recorrido en el tiempo existe, pero en código compilado y sobre miles de columnas a la vez: por eso puede llevar estado (posición, máximo, stop) sin la lentitud de un bucle de Python.
Ideas clave
- vectorbt amplía cada parámetro a una columna más: 400 parejas de medias sobre 24 mercados son 9.600 columnas que se simulan juntas.
- Su licencia es Apache 2.0 con Commons Clause: se puede usar y modificar, pero no vender un producto o servicio basado en él. VectorBT PRO es una edición de pago por suscripción, con repositorio y documentación privados.
- El mismo barrido se programa con NumPy: medias con sumas acumuladas, señales con broadcasting
fecha × rápida × lentay costes sobre la rotación; 325 combinaciones válidas en segundos. - Con un efecto real, el orden del mapa se conserva fuera de muestra (correlación de rangos 0,72), pero el máximo se encoge: la mejor pareja de 2001-2012 pasa de 0,79 a 0,70, y su ventaja sobre la mediana, de 0,31 a 0,07.
- En los datos sintéticos, la zona buena del mapa es una meseta amplia de medias lentas, coherente con el momentum de 12 meses plantado; sobre datos permutados, el máximo de 325 combinaciones se queda en 0,15 dentro de muestra.
- Que muchas parejas pasen un umbral (99 de 325 con t > 2) no son muchos descubrimientos: es el mismo efecto visto a través de ventanas muy correlacionadas.