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.

Fig. — Momentum 12-1 long-short en Zipline Reloaded con el universo del nivel 2, 2015-2025: sin costes y con comisión por acción y 5 pb de deslizamiento
Lecciones del nivel 2
  1. 2.1 · Estadística y econometría para trading

    1. 2.1.1 Contrastes de hipótesis, p-valores, bootstrap y regresión con errores robustos
    2. 2.1.2 Estacionariedad: ADF, KPSS, diferenciación y diferenciación fraccional
    3. 2.1.3 Modelos ARMA y ARIMA: autocorrelación simple (ACF) y parcial (PACF)
    4. 2.1.4 Modelos de volatilidad: ARCH, GARCH, EGARCH y GJR con la librería arch
    5. 2.1.5 Cointegración: Engle-Granger, Johansen y vida media de la reversión
    6. 2.1.6 Filtro de Kalman para hedge ratios dinámicos
    7. 2.1.7 Detección de regímenes: modelos de Markov ocultos y cambios estructurales
    8. 2.1.8 El problema de las pruebas múltiples: Bonferroni, FDR y el «factor zoo»
  2. 2.2 · Anatomía de un sistema de trading

    1. 2.2.1 Hipótesis de ventaja: riesgo, comportamiento, estructura e información
    2. 2.2.2 Universo, señal, filtro, entrada, salida, sizing y gestión del riesgo
    3. 2.2.3 Frecuencias: intradía, swing y posicional, y la capacidad de una estrategia
    4. 2.2.4 Diario de investigación y registro de experimentos contra el data snooping
  3. 2.3 · Familias de estrategias

    1. 2.3.1 Seguimiento de tendencia y momentum
    2. 2.3.2 Reversión a la media: RSI y Bollinger, reversión intradía y huecos
    3. 2.3.3 Pairs trading y arbitraje estadístico: distancia, cointegración y Kalman
    4. 2.3.4 Carry en divisas, futuros y bonos; estacionalidad y efectos calendario
    5. 2.3.5 Factores (value, calidad, baja volatilidad, tamaño) y estrategias de eventos
    6. 2.3.6 Introducción al market making: inventario, spread y selección adversa (Avellaneda-Stoikov)
    7. 2.3.7 Estrategias específicas de cripto: momentum, arbitraje de funding y basis
  4. 2.4 · Backtesting: motores y paradigmas

    1. 2.4.1 Backtest vectorizado desde cero: matrices, desfase, costes y límites
    2. 2.4.2 Backtest por eventos desde cero: bucle, cartera, órdenes y fills
    3. 2.4.3 vectorbt: barridos masivos de parámetros y simulación de carteras
    4. 2.4.4 backtesting.py y backtrader: dos motores didácticos y su estado
    5. 2.4.5 NautilusTrader: arquitectura, datos, estrategias y el mismo código en backtest y en real
    6. 2.4.6 LEAN/QuantConnect y Zipline Reloaded; bt para asignación de activos
    7. 2.4.7 Modelado de costes: comisiones, horquilla, impacto, financiación y préstamo de títulos
    8. 2.4.8 Modelado de fills: órdenes limitadas, cola, fills parciales, huecos y stops
  5. 2.5 · Sesgos y validación

    1. 2.5.1 Sesgos de backtest: anticipación, supervivencia, selección y narrativa
    2. 2.5.2 In-sample y out-of-sample: walk-forward anclado y móvil
    3. 2.5.3 Validación cruzada combinatoria purgada para estrategias
    4. 2.5.4 Deflated Sharpe Ratio y Probability of Backtest Overfitting (PBO)
    5. 2.5.5 Monte Carlo: remuestreo de trades, bootstrap de bloques y permutaciones de señales
    6. 2.5.6 Sensibilidad de parámetros: mesetas frente a picos, robustez entre activos y periodos
    7. 2.5.7 Datos sintéticos para pruebas de estrés: GBM, GARCH y bootstrapping
    8. 2.5.8 Checklist de «backtest honesto» antes de pasar a paper trading
  6. 2.6 · Métricas de rendimiento

    1. 2.6.1 CAGR, Sharpe, Sortino, Calmar, Omega, drawdown y Ulcer Index
    2. 2.6.2 Win rate, payoff, expectativa, alpha, beta, information ratio, turnover y capacidad
    3. 2.6.3 Informes con QuantStats o pyfolio-reloaded y tearsheets propios

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 ReloadedLEAN (QuantConnect)bt
Qué esMotor por eventos para acciones, heredero de QuantopianMotor multiactivo de QuantConnect, de backtest y realLibrería de backtests de carteras
LenguajePython (con partes en Cython)C#; algoritmos en Python o C#Python
Fuerte enFactores sobre universos (Pipeline), bundles localesDatos y brokers integrados, mismo código en realAsignación de activos, rebalanceos, árboles de estrategias
DatosBundles que ingiere el usuarioFormato propio; de QuantConnect o de proveedoresUn DataFrame de precios
LicenciaApache-2.0Apache-2.0MIT
Versión (PyPI)zipline-reloaded 3.2.0, 18 de octubre de 2026CLI lean 1.0.230, 15 de octubre de 2026bt 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

BundleDataPortalPipelineAntes de la sesiónAlgoritmoBlotterDeslizamientoComisiónMétricasDataFrame perf
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 con schedule_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 DataFrame con 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.

IN [01]python
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")
OUT [01]Ingesta del universo con splits y dividendos
120 acciones · 9 splits · 7029 dividendos

Tres 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 ratio de un split es el factor por el que se multiplican los precios anteriores: un split 2 por 1 es 0,5.
  • Un solo register por nombre y el ; al final de la línea: register devuelve 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:

IN [02]python
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())
OUT [02]Lo que guarda el bundle
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-18

Pipeline: 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.

IN [03]python
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: NoCommission y NoSlippage.
  • Con costes: PerShare(cost=0.005, min_trade_cost=1.0), la tarifa por acción de Alcor de la lección 2.4.7, y FixedBasisPointsSlippage(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).
IN [04]python
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:

IN [05]python
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)
OUT [05]Dos backtests: sin costes y con costes
(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      20

perf 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

IN [06]python
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)
OUT [06]Rentabilidad, riesgo y costes
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.45
Fig. — Rentabilidad de cada año del momentum long-short en Zipline, sin costes (gris) y con costes (azul), 2015-2025

La 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.

lean CLIContenedor DockerMotor LEANDatos localesAlgorithm ManagerQCAlgorithmÓrdenes y carteraBroker o simuladorResultados
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 vez

El 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:

IN [07]main.pypython
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:

IN [08]paridad_riesgo_bt.pypython
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

Python y RustC# o PythonQué se pruebaFactores, accionesAsignaciónPaso a realZipline Reloadedbt o motor propioQué lenguajeNautilusTraderLEAN
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]
NecesidadMotor razonablePor qué
Entender un backtest, prototipar una ideaMotor propio vectorizado (2.4.1)Todo a la vista, segundos de ejecución
Factores sobre un universo de acciones con datos propiosZipline ReloadedPipeline, ajustes punto a punto, bundles locales
Carteras de asignación con rebalanceosbtBloques reutilizables, poco código
Mismo código en backtest y en real, multiactivoNautilusTrader (2.4.5) o LEANNúcleo compartido entre simulación y operativa
Barridos masivos de parámetrosvectorbt (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

  1. 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
IN [09]python
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")
OUT [09]Rotación de la estrategia en Zipline
acciones negociadas: 3,061,155
comisión media por orden: 2.82 USD
rotación: 8.0 veces el capital inicial al año

Cada 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.

  1. 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 perf qué pasó esos meses en la versión con costes.
Solución
IN [10]python
mensual = (1 + perf["returns"]).resample("ME").prod() - 1
(100 * mensual.loc["2020-02":"2020-08"]).round(2)
OUT [10]El crash del momentum de 2020
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: float64

De 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.

  1. 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

  1. 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.

  2. Momentum121 usa window_length = 253 y devuelve close[-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 y close[-22], el de hace unos 21 días. Saltarse el último mes evita la reversión a corto plazo que contamina el momentum.

  3. ¿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 imagen quantconnect/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 imagen quantconnect/lean; además, datos en el formato de LEAN

    La 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.

  4. ¿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.

  5. ¿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, WeighInvVol y Rebalance
    • 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, WeighInvVol y Rebalance

    bt 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.

Recursos

Datos