---
title: "2.4.6 LEAN, Zipline Reloaded y bt"
description: "Zipline Reloaded con un bundle propio, Pipeline y modelos de costes; la arquitectura de LEAN y su CLI con Docker, y bt para asignación de activos."
url: "https://pythonparatrading.com/curso-python-trading/nivel-2/lean-zipline-reloaded-y-bt/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-2/lean-zipline-reloaded-y-bt.png"
---

# 2.4.6 · 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.

- Curso: [Nivel 2: Estrategias sistemáticas y backtesting riguroso](https://pythonparatrading.com/curso-python-trading/nivel-2/)
- Módulo: 2.4 · Backtesting: motores y paradigmas
- Duración: 70 min
- Requisitos: [1.4.3 · Ajustes por splits y dividendos: precios ajustados y sin ajustar](https://pythonparatrading.com/curso-python-trading/nivel-1/ajustes-por-splits-y-dividendos/), [1.4.4 · Universos sin sesgo de supervivencia: deslistados, fusiones y cambios de símbolo](https://pythonparatrading.com/curso-python-trading/nivel-1/sesgo-de-supervivencia-y-point-in-time/), [0.4.1 · Programación orientada a objetos: clases, herencia, composición y dataclasses](https://pythonparatrading.com/curso-python-trading/nivel-0/clases-herencia-y-dataclasses/)

## Al terminar esta lección

- Explicar la arquitectura de Zipline (bundle, DataPortal, Pipeline, algoritmo, blotter y métricas) y en qué se diferencia de la de LEAN y de la de bt.
- Crear un bundle de Zipline sin red con precios sin ajustar, splits y dividendos, y comprobar que Zipline aplica los ajustes en cada fecha.
- Escribir un factor de momentum con Pipeline y una estrategia long-short con modelos de comisión y de deslizamiento, y leer el `DataFrame` de resultados.
- Preparar y lanzar un backtest de LEAN con su CLI (Docker) y una cartera de asignación de activos con bt, sabiendo qué hace cada línea.
- Elegir motor según el problema: factores sobre acciones, multiactivo con paso a real o asignación de activos.

## 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

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

```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")
```
```output
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:

```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())
```
```output
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.

```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).

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

```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)
```
```output
(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

```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)
```
```output
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.* Barras por año en parejas, bruto y neto. Los costes restan entre 0,4 y 0,6 puntos cada año. Los años van de −13,3 % neto en 2020 (destacado, el año del crash del momentum plantado) a +24,7 % en 2021; 2019 da +17,7 % y 2025, −4,2 %.

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

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

```bash
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:

```python skip title="main.py"
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:

```python skip title="paridad_riesgo_bt.py"
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

```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

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

```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")
```
```output
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.

2. 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.**

```python
mensual = (1 + perf["returns"]).resample("ME").prod() - 1
(100 * mensual.loc["2020-02":"2020-08"]).round(2)
```
```output
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.

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

## 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?

   - A. Ninguna: el resultado es idéntico
   - 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
   - C. Ocupa menos espacio
   - D. Permite usar datos de minuto

   *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?

   - A. La rentabilidad del último mes
   - B. La rentabilidad de unos doce meses saltándose el último, el momentum 12-1
   - C. La volatilidad anual
   - D. La rentabilidad del último año incluido el último mes

   *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?

   - A. Solo `pip install lean`
   - 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
   - C. Una cuenta de pago en QuantConnect
   - D. Compilar LEAN desde el código en C#

   *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?

   - A. Las comisiones
   - B. El deslizamiento
   - C. El préstamo de los títulos vendidos en corto, la remuneración del colateral y la disponibilidad de préstamo
   - D. Ninguno

   *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?

   - A. Para market making de alta frecuencia
   - B. Para carteras de asignación de activos con rebalanceos periódicos, combinando bloques como `RunMonthly`, `SelectAll`, `WeighInvVol` y `Rebalance`
   - C. Para operar en real con Interactive Brokers
   - D. Para simular colas de órdenes limitadas

   *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

### Research

- [Zipline en Python - Parte 1: Quant Stack y Pipeline.](https://pythonparatrading.com/zipline-backtesting-con-python-quant-stack/) · Research-0011 · 9 min

### Documentación

- [Zipline Reloaded: documentación (bundles, Pipeline, API)](https://zipline.ml4trading.io/) · zipline.ml4trading.io
- [Zipline Reloaded en GitHub (Stefan Jansen)](https://github.com/stefan-jansen/zipline-reloaded) · github.com
- [LEAN CLI: instalación (Docker) y backtests locales](https://www.lean.io/docs/v2/lean-cli/installation/installing-lean-cli) · lean.io
- [QuantConnect: el motor de algoritmos (QCAlgorithm)](https://www.quantconnect.com/docs/v2/writing-algorithms/key-concepts/algorithm-engine) · quantconnect.com
- [bt: documentación](https://pmorissette.github.io/bt/) · pmorissette.github.io

### Datos

- [Datos del nivel 2: precios sin ajustar del universo (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/universo2_precios.parquet) · Descarga · universo2_precios.parquet

Lección anterior: [2.4.5 · NautilusTrader: arquitectura, datos, estrategias y el mismo código en backtest y en real](https://pythonparatrading.com/curso-python-trading/nivel-2/nautilustrader-arquitectura-y-estrategias/)

Lección siguiente: [2.4.7 · Modelado de costes: comisiones, horquilla, impacto, financiación y préstamo de títulos](https://pythonparatrading.com/curso-python-trading/nivel-2/modelado-de-costes/)
