Lección 2.4.6 · 70 min
LEAN/QuantConnect y Zipline Reloaded; bt para asignación de activos
Tres motores con filosofías distintas: Zipline Reloaded, el clásico de Quantopian mantenido por la comunidad, con su Pipeline de factores; LEAN, el motor de QuantConnect en C# con algoritmos en Python; y bt, una librería pequeña para carteras de asignación de activos. Esta lección ejecuta un momentum long-short completo en Zipline con los datos del nivel 2 y muestra el código real de los otros dos.
- Módulo
- 2.4 · Backtesting: motores y paradigmas
- Duración
- 70 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
Tres motores, tres filosofías
Las lecciones 2.4.1 a 2.4.5 fueron de lo más simple (un backtest vectorizado) a lo más completo (NautilusTrader). Los tres motores de esta lección ocupan otros tres huecos:
| Zipline Reloaded | LEAN (QuantConnect) | bt | |
|---|---|---|---|
| Qué es | Motor por eventos para acciones, heredero de Quantopian | Motor multiactivo de QuantConnect, de backtest y real | Librería de backtests de carteras |
| Lenguaje | Python (con partes en Cython) | C#; algoritmos en Python o C# | Python |
| Fuerte en | Factores sobre universos (Pipeline), bundles locales | Datos y brokers integrados, mismo código en real | Asignación de activos, rebalanceos, árboles de estrategias |
| Datos | Bundles que ingiere el usuario | Formato propio; de QuantConnect o de proveedores | Un DataFrame de precios |
| Licencia | Apache-2.0 | Apache-2.0 | MIT |
| Versión (PyPI) | zipline-reloaded 3.2.0, 18 de octubre de 2026 | CLI lean 1.0.230, 15 de octubre de 2026 | bt 1.4.1, 10 de octubre de 2026 |
Las licencias y las fechas salen de PyPI y de GitHub en la fecha de la lección (enlaces en los recursos). Los tres
están mantenidos: Zipline Reloaded por Stefan Jansen y la comunidad desde que Quantopian cerró, LEAN por QuantConnect y
bt por su autor (pmorissette en GitHub) y colaboradores.
Esta lección se ejecuta en un entorno aparte: Zipline Reloaded 3.2.0 exige pandas < 3, y el resto del curso usa
pandas 3. El entorno (curso-code/entornos/zipline, con pandas 2.3.3 y numpy 2.4.6) se crea con
uv sync --project curso-code/entornos/zipline; LEAN y bt no se instalan, y su código va en bloques sin ejecutar.
Zipline por dentro
Fuente Mermaid
flowchart TB B[Bundle] --> DP[DataPortal] B --> PE[Pipeline] PE --> BT[Antes de la sesión] DP --> AL[Algoritmo] BT --> AL AL --> BL[Blotter] BL --> SL[Deslizamiento] BL --> CM[Comisión] SL --> MT[Métricas] CM --> MT MT --> PF[DataFrame perf]
- Bundle. Los datos, ingeridos una vez a un directorio local (
ZIPLINE_ROOT): barras diarias o de minuto en formato columnar, una base de datos SQLite de activos (símbolo, fechas de vida, mercado) y otra de ajustes (splits, fusiones, dividendos). - DataPortal. Sirve al algoritmo los precios de cada momento, ajustados con lo que se sabía ese día.
- Pipeline. Calcula factores sobre todo el universo cada día, antes de la apertura, con ventanas ajustadas punto a
punto. Su resultado llega a
before_trading_start. - Algoritmo. Funciones que Zipline llama:
initialize(una vez),before_trading_start(cada día),handle_data(cada barra) y las que se programan conschedule_function. - Blotter. Recibe las órdenes y las ejecuta en la barra siguiente, con el modelo de deslizamiento (que puede limitar la participación en el volumen y dejar órdenes a medias) y el de comisión.
- Métricas. Al final, un
DataFramecon una fila por sesión: valor de la cartera, rentabilidad, exposición, posiciones, órdenes y transacciones.
Un bundle propio con los datos del nivel 2
Los bundles de serie de Zipline descargan datos de internet. Para el curso, un bundle propio con una función ingest
que escribe las 120 acciones del universo del nivel 2 (sintético, con empresas que salen a bolsa, quiebran o son
compradas) a partir de los Parquet: precios sin ajustar, y aparte los splits y los dividendos, para que Zipline
aplique los ajustes él mismo.
import os
import tempfile
import warnings
import numpy as np
import pandas as pd
# zipline 3.2.0 con pandas 2.3 emite FutureWarning internos (en
# asset_writer.py y benchmark_source.py): fuera de la salida
warnings.filterwarnings("ignore", category=FutureWarning,
module="zipline")
os.environ.setdefault("ZIPLINE_ROOT",
tempfile.mkdtemp(prefix="zipline-"))
from zipline.data.bundles import ingest, load, register
precios = pd.read_parquet("universo2_precios.parquet")
eventos = pd.read_csv("universo2_eventos.csv", parse_dates=["fecha"])
divs = pd.read_csv("universo2_dividendos.csv",
parse_dates=["fecha_anuncio", "fecha_ex",
"fecha_pago"])
simbolos = sorted(precios["ticker"].unique())
sid = {s: i for i, s in enumerate(simbolos)}
vida = precios.groupby("ticker")["fecha"].agg(["min", "max"])
def ingest_universo(environ, asset_db_writer, minute_bar_writer,
daily_bar_writer, adjustment_writer, calendar,
start_session, end_session, cache, show_progress,
output_dir):
def barras():
for s, g in precios.groupby("ticker"):
c = g.set_index("fecha")["cierre"]
# solo hay cierre: apertura, máximo y mínimo = cierre
yield sid[s], pd.DataFrame({
"open": c, "high": c, "low": c, "close": c,
"volume": g.set_index("fecha")["volumen"]
.astype(float)})
daily_bar_writer.write(barras(), show_progress=False)
asset_db_writer.write(
equities=pd.DataFrame({
"symbol": simbolos, "exchange": "CURSO",
"start_date": vida.loc[simbolos, "min"].values,
"end_date": vida.loc[simbolos, "max"].values,
"auto_close_date": (vida.loc[simbolos, "max"]
+ pd.Timedelta(days=1)).values}),
exchanges=pd.DataFrame({"exchange": ["CURSO"],
"canonical_name": ["CURSO"],
"country_code": ["US"]}))
sp = eventos[eventos["evento"] == "split"]
adjustment_writer.write(
splits=pd.DataFrame({
"sid": sp["ticker"].map(sid),
"effective_date": sp["fecha"],
"ratio": 1 / sp["ratio"]}), # 2 por 1 -> 0,5
dividends=pd.DataFrame({
"sid": divs["ticker"].map(sid),
"ex_date": divs["fecha_ex"],
"declared_date": divs["fecha_anuncio"],
"record_date": divs["fecha_ex"],
"pay_date": divs["fecha_pago"],
"amount": divs["importe"]}))
sesiones = pd.DatetimeIndex(sorted(precios["fecha"].unique()))
register("universo2", ingest_universo, calendar_name="XNYS",
start_session=sesiones[0], end_session=sesiones[-1]);
ingest("universo2", show_progress=False)
n_splits = (eventos["evento"] == "split").sum()
print(len(simbolos), "acciones ·", n_splits, "splits ·",
len(divs), "dividendos")120 acciones · 9 splits · 7029 dividendosTres detalles que cuestan horas si no se saben (están en el README del entorno del curso):
country_code="US"en la tabla de mercados. Sin él, Pipeline no encuentra activos para su dominio por defecto, el de EE. UU.- El
ratiode un split es el factor por el que se multiplican los precios anteriores: un split 2 por 1 es 0,5. - Un solo
registerpor nombre y el;al final de la línea:registerdevuelve la función, y sin el punto y coma el entorno imprimiría su dirección de memoria.
auto_close_date es el día en que Zipline cierra las posiciones de una acción que deja de cotizar, al último precio
conocido. Para una quiebra, ese precio está entre 0,30 y 1,50 USD (README del nivel 2): casi el −100 % de la
rentabilidad de deslistado, pero no exactamente.
El bundle guarda los ajustes y Zipline los aplica cuando hacen falta. Se puede comprobar leyendo el bundle:
datos = load("universo2")
qxxc = datos.asset_finder.lookup_symbol("QXXC", as_of_date=None)
print(qxxc, "·", qxxc.start_date.date(), "a", qxxc.end_date.date())
splits = datos.adjustment_reader.get_adjustments_for_sid(
"splits", qxxc.sid)
print("splits:", [(pd.Timestamp(f).date(), r) for f, r in splits])
qt = datos.asset_finder.lookup_symbol("QXQT", as_of_date=None)
print(qt, "· deja de cotizar:", qt.end_date.date(),
"· cierre automático:", qt.auto_close_date.date())Equity(102 [QXXC]) · 2006-01-03 a 2025-12-31
splits: [(datetime.date(2007, 8, 29), 0.5)]
Equity(79 [QXQT]) · deja de cotizar: 2008-10-17 · cierre automático: 2008-10-18Pipeline: el factor de momentum
Pipeline calcula un factor para todas las acciones cada día con una ventana de datos ajustada a esa fecha: si
QXXC hizo un split 2 por 1 el 29 de agosto de 2007, la ventana de un día de septiembre de 2007 tiene los precios
anteriores divididos por dos, y la de un día de julio, no. Así no hay anticipación de ajustes.
El factor es el momentum 12-1 de la lección 2.3.1: la rentabilidad de unos doce meses saltándose el último. En el universo del nivel 2 tiene una prima plantada: según el README de los datos, el IC de rangos esperado es ≈ +0,03, la diferencia entre quintiles ≈ 8 % anual y hay crashes en abril-junio de 2009 y de 2020, cuando los perdedores rebotan.
from zipline.pipeline import CustomFactor, Pipeline
from zipline.pipeline.data import USEquityPricing
from zipline.pipeline.factors import AverageDollarVolume
class Momentum121(CustomFactor):
"""Rentabilidad de t-252 a t-21 sesiones."""
inputs = [USEquityPricing.close]
window_length = 253
def compute(self, today, assets, out, close):
out[:] = close[-22] / close[0] - 1
def hacer_pipeline():
# las 100 más negociadas en efectivo (21 sesiones)
universo = AverageDollarVolume(window_length=21).top(100)
mom = Momentum121(mask=universo)
return Pipeline(
columns={"mom": mom, "quintil": mom.quantiles(5)},
screen=universo)close llega como una matriz de 253 filas (días) por tantas columnas como acciones: compute escribe en out una cifra
por acción. El universo se fija cada día con el volumen en efectivo de las últimas 21 sesiones, así que incluye las
acciones que más tarde quebrarán y no incluye las que todavía no han salido a bolsa: el bundle tiene toda la historia,
también la de los deslistados, y por eso no hay sesgo de supervivencia (lección 1.4.4).
La estrategia, con y sin costes
Cada primer día de mes, la estrategia compra el quintil ganador y vende en corto el perdedor, con el 50 % del capital
en cada pata y pesos iguales, y cierra las acciones que salen de los extremos. Con datos diarios la hora de
time_rules no importa: la orden se envía ese día y se ejecuta al cierre de la sesión siguiente. Dos versiones:
- Sin costes:
NoCommissionyNoSlippage. - Con costes:
PerShare(cost=0.005, min_trade_cost=1.0), la tarifa por acción deAlcorde la lección 2.4.7, yFixedBasisPointsSlippage(basis_points=5, volume_limit=0.1): 5 pb en contra en cada ejecución y, como mucho, el 10 % del volumen del día; lo que no cabe queda pendiente para el día siguiente (un fill parcial, lección 2.4.8).
from zipline import run_algorithm
from zipline.api import (attach_pipeline, date_rules,
order_target_percent, pipeline_output, record,
schedule_function, set_commission,
set_slippage, time_rules)
from zipline.finance import commission, slippage
def hacer_algoritmo(costes):
def initialize(context):
attach_pipeline(hacer_pipeline(), "factores")
schedule_function(rebalanceo, date_rules.month_start(),
time_rules.market_open(minutes=30))
if costes:
set_commission(commission.PerShare(
cost=0.005, min_trade_cost=1.0))
set_slippage(slippage.FixedBasisPointsSlippage(
basis_points=5, volume_limit=0.1))
else:
set_commission(commission.NoCommission())
set_slippage(slippage.NoSlippage())
def before_trading_start(context, data):
context.f = pipeline_output("factores")
def rebalanceo(context, data):
f = context.f
largos = f.index[f["quintil"] == 4]
cortos = f.index[f["quintil"] == 0]
for a in context.portfolio.positions:
if (a not in largos and a not in cortos
and data.can_trade(a)):
order_target_percent(a, 0)
for a in largos:
if data.can_trade(a):
order_target_percent(a, 0.5 / len(largos))
for a in cortos:
if data.can_trade(a):
order_target_percent(a, -0.5 / len(cortos))
record(largos=len(largos), cortos=len(cortos))
return dict(initialize=initialize,
before_trading_start=before_trading_start)set_benchmark no se llama: sin él, Zipline 3.2.0 no compara con ningún índice. (Ojo: set_benchmark(False) no
desactiva el benchmark, sino que toma el activo con sid 0, aquí QXAC, que deja de cotizar en 2007, y el backtest
falla.) Las dos ejecuciones, de 2015 a 2025 para que el capítulo no tarde más de un minuto y medio:
resultados = {}
for nombre, costes in [("bruto", False), ("neto", True)]:
resultados[nombre] = run_algorithm(
start=pd.Timestamp("2015-01-02"),
end=pd.Timestamp("2025-12-31"),
capital_base=1_000_000, data_frequency="daily",
bundle="universo2", **hacer_algoritmo(costes))
perf = resultados["neto"]
print(perf.shape)
perf[["portfolio_value", "long_exposure", "short_exposure",
"largos", "cortos"]].iloc[[30, -1]].round(0)(2766, 39)
portfolio_value long_exposure short_exposure largos cortos
2015-02-17 21:00:00+00:00 999410.0 502812.0 -504200.0 20 20
2025-12-31 21:00:00+00:00 1471150.0 774988.0 -753217.0 20 20perf tiene una fila por sesión. En febrero de 2015 la cartera tiene una veintena de posiciones por lado, medio
millón comprado y medio millón vendido; las columnas orders, transactions y positions guardan, día a día, listas
de diccionarios con cada orden, ejecución y posición.
Resultados
def resumen(p):
r = p["returns"]
años = len(r) / 252
total = p["portfolio_value"].iloc[-1] / 1_000_000 - 1
curva = p["portfolio_value"]
return {
"total_pct": 100 * total,
"anual_pct": 100 * ((1 + total) ** (1 / años) - 1),
"vol_pct": 100 * r.std() * np.sqrt(252),
"sharpe": r.mean() / r.std() * np.sqrt(252),
"max_dd_pct": 100 * (curva / curva.cummax() - 1).min(),
}
tabla = pd.DataFrame({k: resumen(v) for k, v in resultados.items()})
trans = [t for dia in perf["transactions"] for t in dia]
# la comisión va en las órdenes, acumulada: la última versión
ordenes = {o["id"]: o for dia in perf["orders"] for o in dia}
comision = sum(o["commission"] or 0 for o in ordenes.values())
print(len(trans), "transacciones ·", len(ordenes), "órdenes ·",
f"comisiones {comision:,.0f} USD")
tabla.round(2)6408 transacciones · 6406 órdenes · comisiones 18,056 USD
bruto neto
total_pct 54.40 47.12
anual_pct 4.04 3.58
vol_pct 6.11 6.11
sharpe 0.68 0.61
max_dd_pct -19.08 -19.45La figura de la cabecera muestra las dos curvas y esta, el resultado de cada año. Tres lecturas:
- La prima plantada aparece, con la forma de un momentum: años buenos (+17,7 % en 2019, +24,7 % en 2021), años planos y una caída fuerte en 2020 (−13,3 %). La diferencia entre quintiles que declara el README (≈ 8 % anual) se reparte aquí en dos patas del 50 % del capital, así que se espera la mitad por año, menos los crashes: un 3,6 % anual neto, con una volatilidad del 6,1 % y un Sharpe de 0,61.
- Los costes se llevan la diferencia entre las dos curvas: 1,54 frente a 1,47 millones, unos 73.000 USD. La comisión, 18.056 USD en 6.406 órdenes, es solo una cuarta parte; el resto son los 5 pb de deslizamiento por lado en cada rebalanceo, que se ven en el precio de cada transacción y no en una columna aparte. Cada año los costes restan entre 0,4 y 0,6 puntos.
- Faltan costes. Zipline no cobra el préstamo de las acciones vendidas en corto ni remunera el efectivo de las
ventas, y no impide vender en corto las acciones sin disponibilidad. Con los datos de
costes2_prestamo.csv, la lección 2.4.7 mide cuánto cambia eso el resultado.
Los datos son sintéticos y la prima está puesta a propósito: el backtest demuestra que el motor y el factor la recuperan, no que el momentum funcione en un mercado real.
LEAN y QuantConnect
LEAN es el motor de código abierto (Apache-2.0) detrás de QuantConnect. Está escrito en C# y acepta algoritmos en C# o en Python; el mismo algoritmo corre en backtest, en papel y en real con los brokers que integra. Hay dos formas de usarlo: en la nube de QuantConnect, con sus datos, o en local con la LEAN CLI.
Fuente Mermaid
flowchart TB CLI[lean CLI] --> DK[Contenedor Docker] DK --> EN[Motor LEAN] DT[Datos locales] --> EN EN --> AM[Algorithm Manager] AM --> QA[QCAlgorithm] QA --> TH[Órdenes y cartera] TH --> BR[Broker o simulador] EN --> RS[Resultados]
Según su documentación, los comandos que ejecutan el motor en local (lean backtest, lean optimize, lean research)
lo hacen dentro de la imagen Docker quantconnect/lean, que contiene el motor y las librerías de QuantConnect; sin
Docker instalado y en marcha, no se puede. Este curso no lo ejecuta (Docker no está en su entorno), así que el código
va sin salida.
pip install --upgrade lean # la CLI es un paquete de Python
lean init # crea la carpeta de trabajo y la de datos
lean create-project "Momentum sectores" --language python
lean backtest "Momentum sectores" # descarga la imagen la primera vezEl algoritmo es una subclase de QCAlgorithm. Este es el mismo momentum 12-1 sobre nueve ETF sectoriales reales de
EE. UU. (en la nube de QuantConnect se usan sus datos; en local hay que tenerlos en el formato de LEAN), con la API de
Python en minúsculas que usa la documentación actual:
from AlgorithmImports import *
class MomentumSectores(QCAlgorithm):
def initialize(self) -> None:
self.set_start_date(2015, 1, 1)
self.set_end_date(2025, 12, 31)
self.set_cash(1_000_000)
tickers = ["XLB", "XLE", "XLF", "XLI", "XLK",
"XLP", "XLU", "XLV", "XLY"]
self._simbolos = []
for t in tickers:
sec = self.add_equity(t, Resolution.DAILY)
sec.set_fee_model(InteractiveBrokersFeeModel())
sec.set_slippage_model(VolumeShareSlippageModel())
self._simbolos.append(sec.symbol)
self.schedule.on(
self.date_rules.month_start("XLK"),
self.time_rules.after_market_open("XLK", 30),
self._rebalanceo)
def _rebalanceo(self) -> None:
hist = self.history(self._simbolos, 253, Resolution.DAILY)
cierres = hist.close.unstack(0).dropna(axis=1)
if len(cierres) < 253:
return
mom = cierres.iloc[-22] / cierres.iloc[0] - 1
ganadores = mom.nlargest(3).index
for s in self._simbolos:
peso = 1 / 3 if s in ganadores else 0
self.set_holdings(s, peso)Las piezas equivalen a las de Zipline: initialize configura, add_equity da de alta el activo y su resolución,
set_fee_model y set_slippage_model fijan los modelos de coste del activo (InteractiveBrokersFeeModel modela la
tarifa de ese broker), schedule.on programa el rebalanceo y history da la ventana de precios. lean backtest guarda
los resultados en una carpeta con la fecha dentro de backtests/ en el proyecto. A diferencia de Zipline, el paso a
real no exige reescribir nada: lean live deploy lanza el mismo algoritmo contra un broker, de nuevo en un contenedor
Docker con la imagen quantconnect/lean. A cambio, el motor es un sistema grande en otro lenguaje, y depurar lo
que pasa dentro exige leer C#.
bt para asignación de activos
bt resuelve un problema más estrecho: carteras que se rebalancean cada cierto tiempo con reglas de selección y de
ponderación. Una estrategia es una pila de algos, funciones pequeñas que se ejecutan en orden en cada fecha y se
cortan en cuanto una devuelve False (por ejemplo, RunMonthly en un día que no es de rebalanceo). La de abajo es una
paridad de riesgo ingenua (pesos inversos a la volatilidad) entre cinco futuros continuos del nivel 2, uno por clase de
activo:
import bt
import pandas as pd
fut = pd.read_parquet("futuros2_continuos.parquet")
precios = (fut.pivot(index="fecha", columns="mercado",
values="cierre_ajustado")
[["IDA", "BOC", "ENA", "MEA", "DIA"]].dropna())
paridad = bt.Strategy("paridad_riesgo", [
bt.algos.RunMonthly(),
bt.algos.SelectAll(),
bt.algos.WeighInvVol(lookback=pd.DateOffset(months=3)),
bt.algos.Rebalance(),
])
equiponderada = bt.Strategy("equiponderada", [
bt.algos.RunMonthly(),
bt.algos.SelectAll(),
bt.algos.WeighEqually(),
bt.algos.Rebalance(),
])
coste = lambda cantidad, precio: 0.0005 * abs(cantidad * precio)
res = bt.run(bt.Backtest(paridad, precios, commissions=coste),
bt.Backtest(equiponderada, precios, commissions=coste))
res.display()bt.run devuelve un objeto de resultados con estadísticas (display), curvas y pesos de cada fecha. Las piezas son
reutilizables y se pueden anidar: una estrategia puede ser a su vez un activo de otra, lo que permite carteras de
estrategias. Lo que bt no tiene es lo que tienen Zipline, LEAN o NautilusTrader: órdenes, libro, latencia, paso a
real. Tampoco modela el roll de los futuros ni sus márgenes: aquí se le dan precios continuos ajustados (lección 1.4.6)
como si fueran activos de contado. Para comparar reglas de asignación es cómodo y rápido; para ejecutar, no basta.
Cuál elegir
Fuente Mermaid
flowchart TB
P{Qué se prueba} --> F[Factores, acciones]
P --> A[Asignación]
P --> R[Paso a real]
F --> ZL[Zipline Reloaded]
A --> BTB[bt o motor propio]
R --> NL{Qué lenguaje}
NL -- Python y Rust --> NT[NautilusTrader]
NL -- C# o Python --> LE[LEAN]| Necesidad | Motor razonable | Por qué |
|---|---|---|
| Entender un backtest, prototipar una idea | Motor propio vectorizado (2.4.1) | Todo a la vista, segundos de ejecución |
| Factores sobre un universo de acciones con datos propios | Zipline Reloaded | Pipeline, ajustes punto a punto, bundles locales |
| Carteras de asignación con rebalanceos | bt | Bloques reutilizables, poco código |
| Mismo código en backtest y en real, multiactivo | NautilusTrader (2.4.5) o LEAN | Núcleo compartido entre simulación y operativa |
| Barridos masivos de parámetros | vectorbt (2.4.3) | Vectorización sobre matrices de parámetros |
Ningún motor arregla una hipótesis mala ni un sesgo de los datos. Lo que cambia entre ellos es qué errores hacen difíciles (Zipline: los ajustes y la anticipación; NautilusTrader y LEAN: la diferencia entre simulación y real) y qué supuestos dejan a cargo del usuario (en los tres: el préstamo de títulos, la cola y el impacto).
Ejercicios
- Con
perf(la versión con costes), calcula la rotación: cuántas acciones se compran y venden en total, la comisión media por transacción y qué parte del capital medio rota cada año.
Solución
t = pd.DataFrame(trans)
efectivo = (t["amount"].abs() * t["price"]).sum()
años = len(perf) / 252
print(f"acciones negociadas: {t['amount'].abs().sum():,.0f}")
print(f"comisión media por orden: {comision / len(ordenes):.2f} USD")
print(f"rotación: {efectivo / años / 1_000_000:.1f} veces"
" el capital inicial al año")acciones negociadas: 3,061,155
comisión media por orden: 2.82 USD
rotación: 8.0 veces el capital inicial al añoCada rebalanceo mensual cambia una parte grande de los dos quintiles, así que la cartera negocia unas ocho veces su capital inicial al año, sumando compras y ventas (la rotación de la lección 2.4.7, medida de otra forma, era de unas seis veces). La comisión media por orden es de 2,82 USD: con posiciones de unos 25.000 USD, el mínimo de 1 USD de la tarifa pesa poco.
- El README del nivel 2 dice que en abril, mayo y junio de 2020 el coeficiente del momentum cambia de signo (los
perdedores rebotan). Comprueba en
perfqué pasó esos meses en la versión con costes.
Solución
mensual = (1 + perf["returns"]).resample("ME").prod() - 1
(100 * mensual.loc["2020-02":"2020-08"]).round(2)2020-02-29 00:00:00+00:00 1.61
2020-03-31 00:00:00+00:00 -5.08
2020-04-30 00:00:00+00:00 -1.85
2020-05-31 00:00:00+00:00 0.21
2020-06-30 00:00:00+00:00 -2.89
2020-07-31 00:00:00+00:00 -2.58
2020-08-31 00:00:00+00:00 2.20
Freq: ME, Name: returns, dtype: float64De marzo a julio de 2020 el long-short pierde un 11,7 %. Marzo (−5,1 %) es el tramo de estrés del guion de los datos; abril, mayo y junio, el crash del momentum plantado, suman −4,5 % (abril −1,9 %, mayo +0,2 %, junio −2,9 %): los perdedores de la caída rebotan más que los ganadores. El efecto plantado se nota, aunque mezclado con el resto de la varianza: una estrategia con un Sharpe aceptable puede perder en unos meses lo ganado en un año, justo después de una crisis.
- Quieres llevar esta misma estrategia a LEAN con datos reales de acciones de EE. UU. Enumera tres cosas del backtest de Zipline que tendrías que rehacer o comprobar.
Solución
El universo: aquí sale de un bundle propio con todas las acciones, también las deslistadas; en LEAN se construye con
su selección de universo (por ejemplo, por volumen en efectivo) y hay que confirmar que los datos incluyen las
empresas que dejaron de cotizar, o el backtest tendrá sesgo de supervivencia. Los costes: las comisiones y el
deslizamiento se fijan por activo con set_fee_model y set_slippage_model, y el préstamo de títulos y la
remuneración del efectivo hay que revisarlos en su modelo de cuenta, porque cambian el resultado de un long-short
(lección 2.4.7). Y los tiempos: en Zipline la orden del rebalanceo se ejecuta con el cierre de la sesión siguiente en datos
diarios; en LEAN depende de la resolución de los datos y de sus modelos de ejecución, así que los precios de ejecución
y la ventana del factor pueden desplazarse una sesión, y conviene comprobarlo con unas pocas operaciones.
Respuesta plegada en cada pregunta
Autoevaluación
En el bundle de la lección, Zipline recibe los precios negociados (sin ajustar) más una tabla de splits y otra de dividendos. ¿Qué ventaja tiene frente a darle directamente los precios ajustados?
- ANinguna: el resultado es idéntico
- BZipline ajusta la historia como se conocía en cada fecha del backtest, ejecuta las órdenes a los precios reales de ese día y abona los dividendos en efectivo
- COcupa menos espacio
- DPermite usar datos de minuto
Ver respuesta
Respuesta B. Zipline ajusta la historia como se conocía en cada fecha del backtest, ejecuta las órdenes a los precios reales de ese día y abona los dividendos en efectivo
Con precios ajustados hacia atrás, los precios de 2015 dependen de splits y dividendos posteriores (lección 1.4.3), y el número de acciones de cada orden es ficticio. Con precios sin ajustar y las tablas de ajustes, la ventana de Pipeline se ajusta solo con lo ocurrido hasta ese día.
Momentum121usawindow_length = 253y devuelveclose[-22] / close[0] - 1. ¿Qué mide?- ALa rentabilidad del último mes
- BLa rentabilidad de unos doce meses saltándose el último, el momentum 12-1
- CLa volatilidad anual
- DLa rentabilidad del último año incluido el último mes
Ver respuesta
Respuesta B. La rentabilidad de unos doce meses saltándose el último, el momentum 12-1
La ventana tiene 253 cierres hasta el día anterior;
close[0]es el de hace unos doce meses yclose[-22], el de hace unos 21 días. Saltarse el último mes evita la reversión a corto plazo que contamina el momentum.¿Qué necesita LEAN para ejecutar un backtest en local con su CLI?
- ASolo
pip install lean - BLa CLI (
pip install lean) y Docker instalado y en marcha, porque el motor se ejecuta dentro de la imagenquantconnect/lean; además, datos en el formato de LEAN - CUna cuenta de pago en QuantConnect
- DCompilar LEAN desde el código en C#
Ver respuesta
Respuesta B. La CLI (
pip install lean) y Docker instalado y en marcha, porque el motor se ejecuta dentro de la imagenquantconnect/lean; además, datos en el formato de LEANLa documentación de la CLI lo dice expresamente: los comandos que ejecutan el motor en local (
lean backtest,lean optimize,lean research) corren en un contenedor Docker con LEAN y sus dependencias. Los datos hay que comprarlos, traerlos de un proveedor o importarlos.- ASolo
¿Qué costes de una cartera long-short no modela Zipline por defecto?
- ALas comisiones
- BEl deslizamiento
- CEl préstamo de los títulos vendidos en corto, la remuneración del colateral y la disponibilidad de préstamo
- DNinguno
Ver respuesta
Respuesta C. El préstamo de los títulos vendidos en corto, la remuneración del colateral y la disponibilidad de préstamo
Zipline trae modelos de comisión (
PerShare,PerTrade,PerDollar) y de deslizamiento (FixedBasisPointsSlippage,VolumeShareSlippage…), pero no cobra tasas de préstamo ni impide vender en corto lo que no se puede tomar prestado. Hay que añadirlo aparte, como en la lección 2.4.7.¿Para qué es más adecuado bt?
- APara market making de alta frecuencia
- BPara carteras de asignación de activos con rebalanceos periódicos, combinando bloques como
RunMonthly,SelectAll,WeighInvVolyRebalance - CPara operar en real con Interactive Brokers
- DPara simular colas de órdenes limitadas
Ver respuesta
Respuesta B. Para carteras de asignación de activos con rebalanceos periódicos, combinando bloques como
RunMonthly,SelectAll,WeighInvVolyRebalancebt organiza una estrategia como una pila de algoritmos pequeños que se ejecutan en cada fecha (cuándo rebalancear, qué seleccionar, cómo ponderar) y admite árboles de estrategias. No tiene libro de órdenes ni paso a real.
Ideas clave
- Zipline Reloaded (Apache-2.0, 3.2.0) es un motor por eventos para acciones con datos diarios o de minuto, bundles locales y Pipeline para factores; exige pandas < 3.
- Un bundle con precios sin ajustar más splits y dividendos deja a Zipline ajustar la historia punto a punto y abonar los dividendos.
- En los datos del nivel 2, el momentum long-short gana un 54 % bruto y un 47 % neto en 2015-2025 con comisión por acción y 5 pb de deslizamiento; la caída de abril a junio de 2020 es el crash plantado.
- LEAN (Apache-2.0) es un motor en C# con algoritmos en Python o C#, el mismo para backtest y real; la CLI ejecuta el motor en Docker.
- bt (MIT, 1.4.1) monta carteras de asignación de activos con bloques reutilizables; no simula órdenes ni libro.