Lección 2.4.1 · 70 min
Backtest vectorizado desde cero: matrices, desfase, costes y límites
Un backtest vectorizado convierte una estrategia en operaciones entre matrices: señales, pesos y rendimientos, sin un solo bucle. Esta lección construye uno completo para una cartera de 24 futuros, con costes y objetivo de volatilidad, lo protege con tests y después lo lleva a los casos en los que da una respuesta equivocada.
- Módulo
- 2.4 · Backtesting: motores y paradigmas
- Duración
- 70 min
- Requisitos
- 0.5.1 · NumPy 2: arrays, broadcasting, vectorización, álgebra lineal y generadores aleatorios, 0.5.3 · Selección, filtrado, groupby, merge, pivot, resample y ventanas móviles, 1.4.6 · Series continuas de futuros: reglas de roll, ajuste por diferencia y ajuste por ratio, 1.6.1 · Rentabilidades, volatilidad, correlaciones móviles y drawdown
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
Matrices en lugar de bucles
Un backtest responde a una pregunta: si hubiera seguido esta regla en el pasado, ¿qué habría pasado con mi dinero? Hay dos formas de calcularlo. La orientada a eventos recorre el tiempo barra a barra, como lo haría el sistema en real: llega un precio, la estrategia decide, se envía una orden, se ejecuta (lección 2.4.2). La vectorizada calcula todo de golpe con operaciones entre matrices: una tabla de señales, otra de pesos y otra de rendimientos, alineadas por fecha y activo.
Para una cartera de activos con pesos (nocional dividido por capital) decididos al cierre de , el rendimiento del día siguiente es
donde es el rendimiento de cierre a cierre del activo y el coste por unidad de nocional negociado. Todo el backtest es esa fórmula aplicada a todas las fechas a la vez. El índice del peso y el del rendimiento son la parte importante: el peso se decide con lo que se sabe al cierre de y cobra lo que pasa después.
Fuente Mermaid
flowchart TB P[Precios] --> S[Señal en t] P --> V[Volatilidad en t] S --> W[Pesos en t] V --> W W --> D[shift 1] D --> B[Rend. bruto] W --> T[Rotación] T --> C[Costes] B --> N[Rend. neto] C --> N
Las ventajas son las de NumPy (lección 0.5.1): una cartera de 24 mercados y 26 años se calcula en una fracción de segundo, el código cabe en una pantalla y se lee como la fórmula. Las hipótesis también son las de la fórmula: la posición depende solo de datos pasados, se negocia al precio de cierre, el coste es proporcional a lo negociado y nada de lo que ocurra dentro del día cambia la posición. La última sección de la lección mide qué pasa cuando eso no se cumple.
Los datos son los 24 futuros sintéticos del nivel 2: índices, bonos, energía, metales, agrícolas y divisas, con series continuas ajustadas por ratio (lección 1.4.6), de 2000 a 2025. Son ficticios y llevan dos efectos plantados con parámetros conocidos: un carry (el futuro rinde la mitad de la parte exógena de la pendiente de su curva) y un momentum de series temporales (el rendimiento esperado lleva el signo del de las 252 sesiones anteriores, con un Sharpe de en torno a 0,15 por mercado). Lo que mida esta lección es la capacidad del motor para recuperar un efecto que sabemos que está; no dice nada de si ese efecto existe en los mercados reales1Nota 1 · La evidencia empírica del momentum de series temporales en futuros reales está, por ejemplo, en Moskowitz, Ooi y Pedersen (2012), enlazado en los recursos. Los parámetros de los datos sintéticos están en el README del nivel 2..
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")
px = fut.pivot(index="fecha", columns="mercado",
values="cierre_ajustado")
bruto = fut.pivot(index="fecha", columns="mercado",
values="cierre")
contrato = fut.pivot(index="fecha", columns="mercado",
values="contrato")
r = px.pct_change(fill_method=None)
print(px.shape, "|", px.index[0].date(), "a", px.index[-1].date())
print(spec.groupby("sector").size().to_dict())
r.iloc[1:4, :6].round(4)(6539, 24) | 2000-01-03 a 2025-12-31
{'agricolas': 4, 'bonos': 5, 'divisas': 3, 'energia': 3, 'metales': 4, 'renta_variable': 5}
mercado AGA AGB AGC AGD BOA BOB
fecha
2000-01-04 0.0174 0.0151 0.0250 0.0392 0.0005 -0.0038
2000-01-05 -0.0278 -0.0255 -0.0151 -0.0169 0.0005 0.0002
2000-01-06 -0.0091 -0.0067 -0.0175 0.0021 -0.0003 -0.0039Tres tablas anchas con una fila por sesión y una columna por mercado: el precio ajustado (para los rendimientos), el precio negociado del contrato que se tiene ese día (para los costes, que se pagan sobre el nocional real) y el nombre del contrato (para saber cuándo hay roll). Todo lo demás sale de aquí.
La señal y el desfase
La regla de esta lección es la más sencilla de la familia de tendencia (lección 2.3.1): estar largo si el futuro ha
subido en las últimas 252 sesiones y corto si ha bajado. La señal se calcula con el cierre de y, por tanto, solo
puede cobrar lo que pasa después. En pandas eso es un shift(1). Olvidarlo es el error más común y el más caro de
un backtest vectorizado:
def sharpe(x):
x = x.dropna()
return np.sqrt(252) * x.mean() / x.std()
senal = np.sign(px / px.shift(252) - 1) # conocida al cierre de t
bien = (senal.shift(1) * r).mean(axis=1) # se cobra el día t+1
mal = (senal * r).mean(axis=1) # cobra el mismo día t
print(f"con desfase: Sharpe {sharpe(bien.loc['2001':]):.2f}")
print(f"sin desfase: Sharpe {sharpe(mal.loc['2001':]):.2f}")
rapida = np.sign(px / px.shift(5) - 1)
print(f"señal de 5 días, con desfase: "
f"{sharpe((rapida.shift(1) * r).mean(axis=1)):.2f}")
print(f"señal de 5 días, sin desfase: "
f"{sharpe((rapida * r).mean(axis=1)):.2f}")con desfase: Sharpe 0.61
sin desfase: Sharpe 2.69
señal de 5 días, con desfase: 0.29
señal de 5 días, sin desfase: 17.33Con el desfase, la cartera equiponderada de señales tiene un Sharpe de 0,61: el momentum plantado (más el que añaden las tendencias de las crisis del guion) aparece. Sin el desfase, 2,69. El error es silencioso: el código no falla, la curva sube más y nada avisa. Con una señal de 5 días es grotesco, 17,33, porque el último día pesa mucho en una suma de cinco. Con la de 252 el error parece «solo» un buen resultado, y por eso es más peligroso.
Pesos con objetivo de volatilidad y costes
Una señal de no es una posición. Un futuro sobre un bono a 2 años se mueve un 2 % al año; uno de gas natural, un 50 %. Con el mismo nocional, el gas dominaría el riesgo de la cartera. La solución habitual es dimensionar cada posición para que aporte el mismo riesgo: el objetivo de volatilidad (vol targeting),
con la señal, el objetivo de la cartera (aquí un 10 % anual), el número de mercados y la volatilidad estimada con datos hasta (EWMA de 60 sesiones, lección 1.6.1). Dividir por daría exactamente el objetivo si los mercados fueran independientes; no lo son, y eso se verá en el resultado.
vol = r.ewm(span=60, min_periods=60).std() * np.sqrt(252)
n = px.shape[1]
objetivo = 0.10 / np.sqrt(n) # riesgo por mercado
pesos = senal * objetivo / vol # nocional / capital
dia = pd.Timestamp("2024-06-03")
tabla = pd.DataFrame({"senal": senal.loc[dia],
"vol": vol.loc[dia],
"peso": pesos.loc[dia]})
print(f"apalancamiento bruto ese día: "
f"{pesos.loc[dia].abs().sum():.2f}")
tabla.loc[["IDA", "BOA", "BOE", "ENC", "MEA", "DIB"]].round(3)apalancamiento bruto ese día: 5.04
senal vol peso
mercado
IDA 1.0 0.086 0.238
BOA -1.0 0.016 -1.256
BOE -1.0 0.098 -0.208
ENC 1.0 0.504 0.040
MEA -1.0 0.146 -0.140
DIB 1.0 0.103 0.197El 3 de junio de 2024, el bono a 2 años (BOA, volatilidad del 1,6 %) necesita un nocional del 126 % del capital para
aportar lo mismo que un 4,0 % en gas natural (ENC, 50,4 %). La suma de los valores absolutos de los pesos, el
apalancamiento bruto, es de 5,04 veces el capital. En futuros es normal: el nocional no se paga, solo se deposita
un margen (lección 1.2.3). Pero significa que un error de dimensionado se multiplica.
Los costes se pagan por contrato: el deslizamiento típico de las especificaciones (en ticks, por su valor en dólares)
más la comisión. Para llevarlos a la misma unidad que los pesos se dividen por el nocional de un contrato, con el
precio que se negocia de verdad (cierre, no el ajustado). Además, cada roll obliga a vender el contrato que vence y
comprar el siguiente: una rotación de dos veces la posición, aunque la señal no cambie.
coste_usd = (spec["coste_tipico_ticks"] * spec["valor_tick"]
+ spec["comision_usd"]) # por contrato y lado
nocional = bruto * spec["multiplicador"] # USD por contrato
coste_unit = coste_usd / nocional # por USD negociado
rolls = (contrato != contrato.shift(1)) & contrato.shift(1).notna()
print("rolls por año:",
(rolls.loc["2001":].sum() / 25).round(1)[["IDA", "ENA", "AGA"]]
.to_dict())
costes = pd.DataFrame({ # medianas de 2024
"nocional": nocional.loc["2024"].median().round(-2).astype(int),
"coste_usd": coste_usd,
"pb": (1e4 * coste_unit.loc["2024"].median()).round(2)})
costes.sort_values("pb").iloc[[0, 1, 2, 3, 4, 5, -3, -2, -1]]rolls por año: {'IDA': 4.0, 'ENA': 12.0, 'AGA': 5.0}
nocional coste_usd pb
mercado
IDB 419700 7.250 0.17
IDE 287900 7.250 0.25
IDA 345900 14.750 0.43
IDD 210100 12.250 0.58
IDC 390500 27.250 0.70
BOA 193300 17.875 0.92
MEB 39600 52.250 13.19
AGA 9400 14.750 15.69
AGB 13900 27.250 19.59El coste por unidad de nocional, en puntos básicos por lado (mediana de 2024), va de 0,17 en el índice IDB a 19,59 en
el trigo (AGB). No es que el trigo sea difícil de negociar: el coste se paga por contrato, en dólares fijos, y los
contratos tienen tamaños muy distintos. En los datos sintéticos (medianas de 2024), un contrato de trigo vale 13.900
USD y un tick más la comisión son 27,25 USD; uno de IDB vale 419.700 USD y paga 7,25. Los índices, con los contratos
más grandes, quedan abajo de la tabla; la plata (MEB) y los agrícolas, arriba.
El backtester y sus tests
Con las tres piezas (pesos, rendimientos, coste unitario) el backtester es una función de diez líneas. La segunda función empaqueta la estrategia para poder volver a ejecutarla con otros precios, que es lo que necesita uno de los tests.
def backtest_vectorizado(pesos, rend, coste_unit, rolls=None):
"""Rendimiento diario de una cartera de pesos objetivo.
pesos: fecha x activo, nocional/capital decidido al cierre.
rend: rendimiento de cierre a cierre de cada activo.
coste_unit: coste por unidad de nocional negociado.
rolls: True el día en que aparece un contrato nuevo.
"""
pesos = pesos.fillna(0.0)
pos = pesos.shift(1, fill_value=0.0) # lo que se tiene en t
bruto = (pos * rend.fillna(0.0)).sum(axis=1)
rot = (pesos - pos).abs() # lo que se negocia en t
if rolls is not None: # vender viejo, comprar nuevo
rot = rot + 2 * pos.abs().where(rolls, 0.0)
coste = (rot * coste_unit.fillna(0.0)).sum(axis=1)
return pd.DataFrame({"bruto": bruto, "coste": coste,
"neto": bruto - coste,
"rotacion": rot.sum(axis=1)})
def estrategia(px, ventana=252, span=60, vol_obj=0.10):
r = px.pct_change(fill_method=None)
senal = np.sign(px / px.shift(ventana) - 1)
vol = r.ewm(span=span, min_periods=span).std() * np.sqrt(252)
return senal * vol_obj / np.sqrt(px.shape[1]) / volDos simplificaciones a la vista. La rotación se mide como , sin tener en cuenta que entre dos cierres el peso real se desplaza con el precio (si un mercado sube un 2 %, su peso sobre el capital ya no es el de ayer); la lección 2.4.2 mide esa diferencia contra un motor que negocia contratos. Y el coste del roll se apunta el día en que aparece el contrato nuevo, con el precio de ese día.
Un backtester sin tests es una opinión. Los dos primeros comprueban casos pequeños con la respuesta calculada a mano (lección 0.4.5). El tercero es el más útil de todos, un test de perturbación: si se cambian los precios a partir de una fecha, ningún resultado anterior a esa fecha puede cambiar. Si cambia, algo del código mira al futuro, y no hace falta saber qué para saber que está mal.
def test_desfase():
idx = pd.date_range("2024-01-01", periods=6, freq="B")
rend = pd.DataFrame({"A": [0, .01, -.02, .03, -.01, .02]}, idx)
adivino = np.sign(rend) # conoce el rendimiento de hoy
res = backtest_vectorizado(adivino, rend, rend * 0)
# cobra el rendimiento de mañana con la señal de hoy, no el de hoy
assert np.isclose(res["bruto"].sum(),
(np.sign(rend).shift(1) * rend).sum().iloc[0])
assert res["bruto"].sum() < rend.abs().sum().iloc[0]
def test_costes():
idx = pd.date_range("2024-01-01", periods=4, freq="B")
w = pd.DataFrame({"A": [0.0, 1.0, 1.0, 0.0]}, idx)
c = pd.DataFrame({"A": [0.001] * 4}, idx)
res = backtest_vectorizado(w, w * 0, c)
assert np.isclose(res["coste"].sum(), 2 * 0.001) # entrar y salir
def test_sin_anticipacion():
corte = px.index[-500]
alterado = px.copy()
alterado.loc[corte:] *= 1.3 # cambia el futuro desde el corte
a = backtest_vectorizado(estrategia(px), r, coste_unit)
b = backtest_vectorizado(estrategia(alterado),
alterado.pct_change(fill_method=None),
coste_unit)
antes = a.index < corte
assert np.allclose(a.loc[antes], b.loc[antes])
for t in (test_desfase, test_costes, test_sin_anticipacion):
t()
print("3 tests superados")3 tests superadosEl test de perturbación cubre todo el pipeline, no solo la última función: también la señal y la volatilidad. Pruébalo
estropeando algo a propósito, por ejemplo con rolling(20, center=True) en lugar de ewm o normalizando la
volatilidad por su media de toda la muestra: el assert salta. En un proyecto real estos tests viven en un fichero de
pytest y se ejecutan cada vez que cambia el código del motor.
La cartera de 24 futuros
res = backtest_vectorizado(estrategia(px), r, coste_unit, rolls)
res = res.loc["2001":]
def resumen(d):
eq = (1 + d).cumprod()
return pd.Series({
"rent_anual_%": 100 * (eq.iloc[-1] ** (252 / len(d)) - 1),
"vol_%": 100 * d.std() * np.sqrt(252),
"sharpe": sharpe(d),
"max_dd_%": 100 * (eq / eq.cummax() - 1).min()})
tabla = pd.DataFrame({"bruto": resumen(res["bruto"]),
"neto": resumen(res["neto"])})
print(f"coste medio: {100 * 252 * res['coste'].mean():.2f} % al año")
print(f"rotación: {252 * res['rotacion'].mean():.0f} veces al año")
tabla.round(2)coste medio: 2.95 % al año
rotación: 107 veces al año
bruto neto
rent_anual_% 14.58 11.25
vol_% 14.70 14.72
sharpe 1.00 0.80
max_dd_% -23.49 -28.08Es la figura de la cabecera. En bruto, un 14,58 % anual con un Sharpe de 1,00; después de costes, un 11,25 % y 0,80. Tres lecturas:
- La volatilidad es del 14,7 %, no del 10 % pedido. Los mercados no son independientes: en las crisis del guion muchos tienden en la misma dirección a la vez y las señales se alinean. Un objetivo de cartera exacto necesitaría la matriz de covarianzas, no solo las volatilidades (lección 0.7.3).
- El Sharpe bruto, 1,00, es mayor que el de un mercado suelto. El momentum plantado da unos 0,15 por mercado; la diversificación entre 24 mercados poco correlacionados multiplica, y las tendencias del guion suman. Es la razón por la que estos sistemas se operan en carteras amplias.
- Los costes se comen una quinta parte del rendimiento. 2,95 puntos al año, con una rotación de 107 veces el capital.
¿De dónde sale tanto coste? La función permite separar el rebalanceo diario de los rolls:
w = estrategia(px).fillna(0.0)
pos = w.shift(1, fill_value=0.0)
cu = coste_unit.fillna(0.0)
def anual(x):
return 100 * 252 * x.loc["2001":].mean()
reparto = pd.DataFrame({
"rebalanceo": anual((w - pos).abs() * cu),
"roll": anual(2 * pos.abs().where(rolls, 0.0) * cu)})
reparto["total"] = reparto.sum(axis=1)
print({k: round(v, 2) for k, v in reparto.sum().items()})
reparto.sort_values("total", ascending=False).head(4).round(2){'rebalanceo': 1.7, 'roll': 1.25, 'total': 2.95}
rebalanceo roll total
mercado
MEB 0.18 0.10 0.29
AGC 0.13 0.13 0.25
BOA 0.16 0.08 0.24
AGA 0.14 0.10 0.24Más de la mitad del coste es rebalanceo (1,70 puntos): el peso cambia cada día con la volatilidad estimada aunque la señal no cambie, y el backtest lo persigue al céntimo. El resto, 1,25 puntos, son rolls, inevitables mientras se mantenga la posición. El coste está repartido: los cuatro mercados que más cuestan suman algo más de un punto. Tres tienen contratos pequeños (plata, soja y maíz); el cuarto, el bono a 2 años, tiene un coste unitario de los más bajos, pero su nocional pasa del 100 % del capital. Rebalancear solo los viernes, manteniendo los pesos de la semana, es un cambio de una línea:
w = estrategia(px)
viernes = w.index.dayofweek == 4
w_sem = w.where(pd.Series(viernes, w.index), np.nan).ffill()
sem = backtest_vectorizado(w_sem, r, coste_unit, rolls).loc["2001":]
for nombre, d in [("diario", res), ("semanal", sem)]:
print(f"{nombre:8s} rotación {252 * d['rotacion'].mean():5.1f}"
f" coste {100 * 252 * d['coste'].mean():.2f} %"
f" Sharpe neto {sharpe(d['neto']):.2f}")diario rotación 107.1 coste 2.95 % Sharpe neto 0.80
semanal rotación 70.9 coste 2.05 % Sharpe neto 0.95La rotación baja de 107 a 71 y el coste, de 2,95 a 2,05 puntos; el Sharpe neto sube de 0,80 a 0,95. La alternativa que usan los sistemas reales es mejor: no tocar la posición mientras esté dentro de una banda alrededor del objetivo (por ejemplo, ±10 % del peso) y negociar solo cuando se sale de ella. Y aquí aparece el primer límite: si se negocia o no hoy depende de la posición que se tiene, que depende de si se negoció ayer. Eso ya no es una operación entre matrices.
Dónde se equivoca: reglas con memoria
Una regla depende del camino cuando la decisión de hoy depende de decisiones anteriores y no solo de los precios. Los ejemplos más comunes: un trailing stop (el nivel depende del máximo desde que se entró, y la entrada depende de cuándo se salió), no volver a entrar hasta una nueva señal tras un stop, bandas de no negociación, límites de pérdida diaria y cualquier dimensionado que dependa del capital acumulado. Con matrices se pueden aproximar; el problema es que la aproximación no avisa de cuándo deja de serlo.
Para verlo con números hacen falta barras con máximo y mínimo. Los futuros del nivel 2 solo tienen cierre, así que la
prueba usa KAPA, un ETF sintético con barras de 5 minutos de 2022 a 2025, agregadas a barras diarias. El efecto
plantado de KAPA está en la primera media hora (una pequeña tendencia a cerrar el hueco de apertura); la regla de
esta sección no lo busca.
b5 = pd.read_parquet("indice2_5min.parquet")
b5["sesion"] = (b5["ts"].dt.tz_convert("America/New_York")
.dt.tz_localize(None).dt.normalize())
d = b5.groupby("sesion").agg(apertura=("apertura", "first"),
maximo=("maximo", "max"),
minimo=("minimo", "min"),
cierre=("cierre", "last"))
print(len(d), "sesiones")
d.head(3)1003 sesiones
apertura maximo minimo cierre
sesion
2022-01-03 251.30 253.42 251.30 252.38
2022-01-04 252.64 253.65 250.76 251.52
2022-01-05 252.08 255.91 251.78 254.76La regla: comprar al cierre cuando KAPA cruza al alza su media de 20 sesiones; vender cuando cae un 4 % desde el máximo cierre alcanzado desde la entrada; tras una salida, esperar al siguiente cruce al alza. Así la escribiría casi todo el mundo en pandas:
STOP = 0.04
c = d["cierre"]
encima = c > c.rolling(20).mean()
cruce = encima & ~encima.shift(1, fill_value=False)
pico = c.rolling(20).max() # pico de 20 días, no
roto = c < pico * (1 - STOP) # el pico desde la entrada
pos_vec = (encima & ~roto).astype(float).shift(1, fill_value=0)
rc = c.pct_change().fillna(0)
eq_vec = (1 + pos_vec * rc).cumprod()
print(f"entradas: {int((pos_vec.diff() == 1).sum())}")
print(f"capital final: {eq_vec.iloc[-1]:.3f}")entradas: 52
capital final: 2.055Tiene tres diferencias con la regla escrita, y las tres son por falta de memoria: el pico es el de las últimas 20 sesiones y no el de la operación; la posición reaparece en cuanto la condición deja de cumplirse, sin esperar a un cruce; y la salida se ejecuta al cierre siguiente, no cuando el precio toca el nivel. La versión con memoria necesita un bucle que guarde el estado (posición, pico) de una barra a la siguiente:
def trailing_bucle(d, cruce, stop=STOP, intrabarra=True):
"""Entra al cierre en cada cruce al alza si está fuera; sale con
un stop a stop% del máximo cierre desde la entrada."""
o, h, l, cl = (d[k].to_numpy() for k in
("apertura", "maximo", "minimo", "cierre"))
efectivo, unidades, pico = 1.0, 0.0, 0.0
capital, operaciones = np.empty(len(d)), []
for i in range(len(d)):
if unidades:
nivel = pico * (1 - stop)
if intrabarra and l[i] <= nivel: # orden stop viva
precio = min(o[i], nivel) # hueco: la apertura
elif not intrabarra and cl[i] <= nivel:
precio = cl[i] # solo al cierre
else:
precio = None
pico = max(pico, cl[i])
if precio is not None:
efectivo, unidades = unidades * precio, 0.0
operaciones.append((d.index[i], precio,
o[i] < nivel))
if not unidades and cruce.iloc[i]:
unidades, efectivo, pico = efectivo / cl[i], 0.0, cl[i]
capital[i] = efectivo + unidades * cl[i]
return pd.Series(capital, d.index), operaciones
eq_cierre, ops_cierre = trailing_bucle(d, cruce, intrabarra=False)
eq_bucle, ops = trailing_bucle(d, cruce)
for nombre, eq, o in [("bucle, stop al cierre", eq_cierre, ops_cierre),
("bucle, stop intradía", eq_bucle, ops)]:
print(f"{nombre:22s} salidas {len(o):2d} capital "
f"{eq.iloc[-1]:.3f}")
print("salidas con hueco por debajo del stop:",
sum(h for *_, h in ops))
print("última salida:", ops[-1][0].date(), round(ops[-1][1], 2))bucle, stop al cierre salidas 23 capital 1.973
bucle, stop intradía salidas 28 capital 1.535
salidas con hueco por debajo del stop: 3
última salida: 2025-09-12 422.45Tres respuestas a la misma pregunta. El cálculo vectorizado multiplica el capital por 2,05 con 52 entradas. El bucle que solo mira cierres, por 1,97 con 23 salidas. El bucle con una orden stop de verdad, que salta en cuanto el mínimo de la barra toca el nivel y se ejecuta a ese nivel o a la apertura si el precio abre por debajo (3 de las 28 salidas), por 1,53. La figura enseña dónde se separan: durante dos años y medio las curvas casi coinciden, y en la segunda mitad de 2025 el vectorizado se escapa hasta 54 puntos de capital por encima. Tras la última salida, el 12 de septiembre de 2025, KAPA sube de 422 a 534 sin volver a cruzar su media desde abajo hasta el 9 de diciembre: la regla real se queda fuera casi todo el repunte y la vectorizada, que no recuerda haber salido, vuelve a entrar en septiembre.
Que el vectorizado gane no es lo importante: con otro tramo de precios perdería. Lo importante es que no responde a la pregunta que se le hizo, y que la diferencia depende del camino concreto de los precios, así que no se puede corregir con un factor.
Dentro de la barra, contratos enteros y órdenes limitadas
Stop y objetivo en la misma barra. Con barras diarias se sabe el máximo y el mínimo del día, pero no cuál llegó antes. Una estrategia que compra en la apertura con un stop a −0,5 % y un objetivo a +0,5 % (y si no toca ninguno, vende al cierre) depende por completo de ese orden. Las barras de 5 minutos de KAPA lo resuelven:
K = 0.005
filas = []
for sesion, g in b5.groupby("sesion"):
ap = g["apertura"].iloc[0]
sube = g["maximo"].to_numpy() >= ap * (1 + K)
baja = g["minimo"].to_numpy() <= ap * (1 - K)
i_s = sube.argmax() if sube.any() else 10**6
i_b = baja.argmax() if baja.any() else 10**6
filas.append((sube.any(), baja.any(), i_s, i_b,
g["cierre"].iloc[-1] / ap - 1))
br = pd.DataFrame(filas, columns=["sube", "baja", "i_s", "i_b", "rc"])
ambas = br["sube"] & br["baja"]
print(f"sesiones: {len(br)}; tocan los dos niveles: {ambas.sum()}")
primero_obj = (ambas & (br.i_s < br.i_b)).sum()
print(f" primero el objetivo (5 min): {primero_obj}")
print(f" primero el stop (5 min): {(ambas & (br.i_b < br.i_s)).sum()}")
print(f" los dos en la misma barra de 5 min: "
f"{(ambas & (br.i_s == br.i_b)).sum()}")sesiones: 1003; tocan los dos niveles: 187
primero el objetivo (5 min): 88
primero el stop (5 min): 94
los dos en la misma barra de 5 min: 5En 187 de 1.003 sesiones la barra diaria toca los dos niveles. Con las barras de 5 minutos, en 88 llega antes el objetivo, en 94 el stop, y en 5 los dos caen dentro de la misma barra de 5 minutos, donde vuelve a haber ambigüedad (se cuentan como stop, por prudencia).
def resultado(r_ambas):
out = np.where(br["sube"] & ~br["baja"], K,
np.where(br["baja"] & ~br["sube"], -K,
np.where(~br["sube"] & ~br["baja"], br["rc"], r_ambas)))
return 100 * out.sum()
verdad = np.where(br.i_s < br.i_b, K, -K) # misma barra: el stop
print(f"suponiendo el stop primero: {resultado(-K):6.1f} %")
print(f"con las barras de 5 minutos: {resultado(verdad):6.1f} %")
print(f"suponiendo el objetivo primero: {resultado(K):6.1f} %")suponiendo el stop primero: -77.5 %
con las barras de 5 minutos: 10.5 %
suponiendo el objetivo primero: 109.5 %Sumando los rendimientos diarios, la estrategia gana un 109,5 % si se supone el objetivo primero, pierde un 77,5 % si se supone el stop y gana un 10,5 % con el orden real. La diferencia entre los dos supuestos, 187 puntos, es un punto por cada sesión ambigua (el 0,5 % que se gana más el 0,5 % que se pierde). Ninguna regla fija sobre barras diarias da la respuesta: hacen falta datos de más resolución o un motor que los use. Y el 10,5 % tampoco incluye la horquilla ni el deslizamiento de las órdenes stop, que en una estrategia que opera cada día lo deciden todo (lecciones 2.4.7 y 2.4.8).
Capital y contratos enteros. Los pesos son fracciones; los contratos, no. Con un capital dado, la posición real es el número entero de contratos más cercano al objetivo, y un mercado cuyo contrato vale más que el riesgo que se le asigna se queda a cero:
w = estrategia(px).loc["2001":]
noc = nocional.loc["2001":]
for capital in (1e6, 1e7, 1e8):
n_ctr = (w * capital / noc).round()
w_real = n_ctr * noc / capital
ceros = ((n_ctr == 0) & (w.abs() > 0))
d_real = (w_real.shift(1) * r.loc["2001":]).sum(axis=1)
d_obj = (w.shift(1) * r.loc["2001":]).sum(axis=1)
te = 100 * (d_real - d_obj).std() * np.sqrt(252)
a_cero = 100 * ceros.mean().mean()
idc = 100 * ceros["IDC"].mean()
print(f"capital {capital:>11,.0f} USD: a cero {a_cero:4.1f} % "
f"(IDC {idc:3.0f} %), error de seguimiento {te:.2f} %")capital 1,000,000 USD: a cero 4.2 % (IDC 34 %), error de seguimiento 3.14 %
capital 10,000,000 USD: a cero 0.0 % (IDC 0 %), error de seguimiento 0.27 %
capital 100,000,000 USD: a cero 0.0 % (IDC 0 %), error de seguimiento 0.03 %Con un millón de dólares, un 4,2 % de las posiciones redondea a cero contratos y la cartera real se separa de la de
pesos con un error de seguimiento del 3,14 % anual, una quinta parte de su volatilidad. Los que más se quedan fuera son
los índices, los contratos más grandes: el de IDC vale 390.500 USD (mediana de 2024) y su peso de riesgo no llega a un contrato
con ese capital, así que está a cero un 34 % de los días. Con diez millones ya no queda ninguna posición a cero y el
error baja al 0,27 %; con cien, al 0,03 %. El cálculo de la celda es además una aproximación, porque usa un capital fijo;
en un backtest honesto el número de contratos depende del capital de cada día, que depende de todo lo anterior: otra
vez el camino.
Órdenes limitadas. Un backtest vectorizado suele dar por ejecutada una compra limitada si el mínimo de la barra toca el precio límite. Es optimista dos veces: tocar el precio no garantiza la ejecución (delante hay una cola de órdenes al mismo precio) y las órdenes que se ejecutan son, con más frecuencia, las que el mercado atraviesa con fuerza justo antes de seguir cayendo (selección adversa). La lección 2.4.8 modela la cola y los fills parciales.
¿Cuándo basta con el vectorizado? La pregunta práctica es si la posición de mañana se puede calcular solo con precios:
Fuente Mermaid
flowchart TB
Q{¿Solo precios?}
Q -- sí --> I{¿Orden intradía?}
Q -- no --> E[Motor por eventos]
I -- no --> V[Vectorizado]
I -- sí --> E| Situación | Vectorizado | Bucle o eventos |
|---|---|---|
| Señal de cierre, rebalanceo periódico, costes proporcionales | Exacto y rápido | Igual de exacto, más lento |
| Barridos de miles de parámetros (lección 2.4.3) | La herramienta natural | Demasiado lento |
| Trailing stops, reentradas, bandas de no negociación | Aproximación sin aviso | Necesario |
| Stops, objetivos y límites dentro de la barra | Depende de un supuesto arbitrario | Necesario, con datos de más resolución |
| Contratos enteros, margen, capital disponible | Aproximación con capital fijo | Necesario |
| Paso a real con el mismo código | No | Sí (lección 2.4.5) |
La práctica habitual combina los dos: vectorizado para explorar y descartar ideas rápido, y un motor por eventos para validar la que sobrevive con las reglas exactas. Si los dos dan lo mismo cuando las hipótesis coinciden, la exploración era fiable; la lección 2.4.2 hace esa reconciliación con esta misma cartera.
Ejercicios
- Retrasa la ejecución un día más: decide con el cierre de y negocia al cierre de (
shift(2)). ¿Cuánto cae el Sharpe bruto de la cartera? ¿Qué dice eso de la sensibilidad de la estrategia a la ejecución?
Solución
w = estrategia(px)
for k in (1, 2, 5):
d = (w.shift(k) * r).sum(axis=1).loc["2001":]
print(f"shift({k}): Sharpe bruto {sharpe(d):.2f}")shift(1): Sharpe bruto 1.00
shift(2): Sharpe bruto 1.04
shift(5): Sharpe bruto 0.96Retrasar un día no cuesta nada medible (de 1,00 a 1,04, una diferencia dentro del ruido) y cinco días, poco (0,96): la señal de 252 sesiones cambia despacio y unos días de retraso apenas la mueven. Es una buena noticia para una estrategia lenta, y una prueba que conviene hacer siempre: si el resultado se hunde con un día de retraso, la estrategia depende de una precisión de ejecución que no tendrá en real (o de un sesgo de anticipación escondido).
- Añade a
backtest_vectorizadoun parámetromax_brutoque recorte los pesos de un día, en proporción, cuando el apalancamiento bruto supere ese límite. Escribe un test para él y mide el efecto de un límite de 4 veces el capital. ¿Es una regla que dependa del camino?
Solución
def recortar(pesos, max_bruto):
bruto = pesos.abs().sum(axis=1)
factor = (max_bruto / bruto).clip(upper=1.0).fillna(1.0)
return pesos.mul(factor, axis=0)
def test_recorte():
idx = pd.date_range("2024-01-01", periods=2, freq="B")
w = pd.DataFrame({"A": [3.0, 1.0], "B": [-3.0, 1.0]}, idx)
out = recortar(w, 4)
assert np.allclose(out.abs().sum(axis=1), [4.0, 2.0])
test_recorte()
w4 = recortar(estrategia(px), 4)
lim = backtest_vectorizado(w4, r, coste_unit, rolls).loc["2001":]
recortados = (estrategia(px).abs().sum(axis=1) > 4).loc["2001":]
print(f"días recortados: {recortados.mean():.0%}")
print(f"Sharpe neto: sin límite {sharpe(res['neto']):.2f}, "
f"con límite {sharpe(lim['neto']):.2f}")
print(f"vol: {100 * lim['neto'].std() * np.sqrt(252):.1f} %")días recortados: 77%
Sharpe neto: sin límite 0.80, con límite 0.75
vol: 13.2 %Con un límite de 4 veces se recorta el 77 % de los días (sin límite, el apalancamiento bruto pasa de 4 veces en tres de cada cuatro sesiones), la volatilidad baja al 13,2 % y el Sharpe neto, de 0,80 a 0,75: el límite quita exposición justo cuando hay más señales alineadas, y en estos datos esos días compensaban. No depende del camino: el recorte de cada día solo usa los pesos de ese día, que solo usan precios pasados. Por eso cabe en una operación entre matrices. Dependería del camino si el límite fuera sobre el margen disponible, porque el margen depende del capital acumulado.
- Clasifica estas reglas según se puedan calcular exactamente con un backtest vectorizado o necesiten un bucle: (a) media móvil de 50 y 200 sesiones; (b) salir tras 10 sesiones en posición; (c) no operar el día de un dato macro; (d) reducir el tamaño a la mitad tras una pérdida del 5 % desde el máximo de la cuenta; (e) volver a entrar solo después de un nuevo cruce.
Solución
(a) y (c) son vectorizables: la posición sale solo de precios y de un calendario conocido de antemano. (b) parece
vectorizable con un groupby por operación, pero la fecha de entrada depende de cuándo terminó la operación anterior;
si nunca se solapan señales es exacto, y si se solapan, no. (d) depende del capital acumulado de la propia estrategia:
necesita un bucle. (e) es el caso del trailing stop de KAPA: hay que recordar si se salió y desde cuándo.
Footnotes
-
La evidencia empírica del momentum de series temporales en futuros reales está, por ejemplo, en Moskowitz, Ooi y Pedersen (2012), enlazado en los recursos. Los parámetros de los datos sintéticos están en el README del nivel 2. ↩
Respuesta plegada en cada pregunta
Autoevaluación
En la cartera de futuros, una señal de 5 días da un Sharpe de 0,29 con
shift(1)y de 17,33 sin él. ¿Qué ocurre sin el desfase?- AQue la señal de 5 días es mejor de lo que parecía
- BQue la posición del día t cobra el rendimiento de t, que forma parte de la propia señal: es información que no existía al decidir
- CQue los costes se cuentan dos veces
- DQue el Sharpe se anualiza mal
Ver respuesta
Respuesta B. Que la posición del día t cobra el rendimiento de t, que forma parte de la propia señal: es información que no existía al decidir
La señal se calcula con el cierre de t, que incluye el rendimiento de t. Si esa misma señal cobra el rendimiento de t, la estrategia apuesta sabiendo ya el resultado. Cuanto más corta es la ventana, más pesa el último día en la señal y más se infla el resultado: con la de 252 días el error lleva el Sharpe de 0,61 a 2,69; con la de 5, a 17,33.
En 2024, negociar el futuro de trigo cuesta 19,59 puntos básicos del nocional por lado y el del índice
IDB, 0,17. ¿Por qué más de cien veces más?- APorque el trigo se negocia más veces que el índice
- BPorque el coste por contrato es fijo en dólares y el contrato de trigo vale mucho menos: el mismo coste pesa más por cada dólar de nocional
- CPorque el trigo tiene más rolls
- DPorque su volatilidad es más alta
Ver respuesta
Respuesta B. Porque el coste por contrato es fijo en dólares y el contrato de trigo vale mucho menos: el mismo coste pesa más por cada dólar de nocional
El coste unitario es
coste por contrato / (precio × multiplicador). En los datos sintéticos (medianas de 2024), un contrato de trigo vale 13.900 USD y un tick más la comisión son 27,25 USD; uno deIDBvale 419.700 USD y paga 7,25. Por eso los contratos pequeños (plata, maíz, trigo) ocupan lo alto de la tabla de costes y los índices, lo bajo.¿Qué comprueba el test de perturbación del backtester?
- AQue el backtest es rápido
- BQue cambiar los precios a partir de una fecha no cambia ningún resultado anterior a esa fecha
- CQue el Sharpe es positivo
- DQue no hay valores ausentes
Ver respuesta
Respuesta B. Que cambiar los precios a partir de una fecha no cambia ningún resultado anterior a esa fecha
Si un resultado del pasado cambia al modificar el futuro, el código usa información posterior en algún sitio (un
shiftque falta, una normalización con toda la muestra, unrollingcentrado). Es la forma más directa de cazar el sesgo de anticipación sin tener que leer cada línea.En KAPA, el trailing stop vectorizado acaba con un capital de 2,05 y el bucle con 1,53. ¿Cuál es la causa principal de la diferencia en la segunda mitad de 2025?
- ALas comisiones
- BEl vectorizado vuelve a entrar en cuanto el precio se recupera, mientras que la regla real espera a un nuevo cruce al alza, que no llega en todo el repunte
- CUn error de redondeo
- DEl bucle usa datos de 5 minutos
Ver respuesta
Respuesta B. El vectorizado vuelve a entrar en cuanto el precio se recupera, mientras que la regla real espera a un nuevo cruce al alza, que no llega en todo el repunte
Tras el stop del 12 de septiembre de 2025, KAPA sube de 422 a 534 sin volver a cruzar su media de 20 sesiones desde abajo hasta el 9 de diciembre, así que la regla real se queda fuera casi todo el repunte. El cálculo vectorizado no tiene memoria de la salida: en cuanto el cierre vuelve a estar a menos de un 4 % del máximo de 20 días, la posición reaparece.
Una estrategia compra en la apertura con stop y objetivo a ±0,5 %. Con barras diarias, 187 de 1.003 sesiones tocan los dos niveles. ¿Qué resultado es defendible?
- AEl de suponer que el objetivo llega primero, porque el mercado es alcista
- BEl de suponer que el stop llega primero, porque es prudente
- CNinguno de los dos sin datos de más resolución: el orden real, con barras de 5 minutos, da +10,5 %, entre el −77,5 % y el +109,5 % de los dos supuestos
- DCualquiera, porque la diferencia es pequeña
Ver respuesta
Respuesta C. Ninguno de los dos sin datos de más resolución: el orden real, con barras de 5 minutos, da +10,5 %, entre el −77,5 % y el +109,5 % de los dos supuestos
Con barras diarias no se sabe qué ocurrió antes. La diferencia entre los dos supuestos es de 187 puntos porcentuales, todo el resultado y más. Suponer el stop primero es prudente, pero aquí convierte una estrategia casi neutra en una ruinosa: la respuesta sale de datos intradía o de un motor que los use.
Ideas clave
- Un backtest vectorizado calcula la cartera como
pesos.shift(1) × rendimientos − costes: rápido, legible y exacto mientras la posición dependa solo de datos pasados. - El desfase no es un detalle: sin él, la misma señal de momentum pasa de un Sharpe de 0,61 a 2,69, y una de 5 días, a 17,33.
- Los costes salen de la rotación (incluidos los rolls) por el coste por unidad de nocional; con rebalanceo diario y vol targeting, la cartera de futuros rota 107 veces al año y pierde 2,95 puntos de rentabilidad.
- Un test de perturbación (cambiar el futuro y comprobar que el pasado no cambia) detecta el sesgo de anticipación en todo el pipeline.
- Las reglas con memoria (trailing stops, reentradas, bandas de no negociación, capital y contratos enteros) y lo que ocurre dentro de una barra no caben en una operación entre matrices: ahí hace falta un bucle o un motor por eventos (lección 2.4.2).
- Los datos son sintéticos con un momentum y un carry plantados: los resultados enseñan a construir y validar el motor, no son evidencia de que la estrategia funcione en mercados reales.