---
title: "2.4.6 LEAN, Zipline Reloaded and bt"
description: "Zipline Reloaded with a custom bundle, Pipeline and cost models; LEAN's architecture and its CLI with Docker, and bt for asset allocation."
url: "https://pythonparatrading.com/en/python-trading-course/level-2/lean-zipline-reloaded-and-bt/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-2/lean-zipline-reloaded-and-bt.png"
---

# 2.4.6 · LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation

> Three engines with different philosophies: Zipline Reloaded, the Quantopian classic maintained by the community, with its factor Pipeline; LEAN, QuantConnect's C# engine with algorithms in Python; and bt, a small library for asset allocation portfolios. This lesson runs a complete long-short momentum strategy in Zipline on the level 2 data and shows the real code of the other two.

- Course: [Level 2: Systematic strategies and rigorous backtesting](https://pythonparatrading.com/en/python-trading-course/level-2/)
- Module: 2.4 · Backtesting: engines and paradigms
- Duration: 70 min
- Prerequisites: [1.4.3 · Adjusting for splits and dividends: adjusted and unadjusted prices](https://pythonparatrading.com/en/python-trading-course/level-1/adjusting-prices-for-splits-and-dividends/), [1.4.4 · Universes without survivorship bias: delistings, mergers and symbol changes](https://pythonparatrading.com/en/python-trading-course/level-1/survivorship-bias-and-point-in-time-data/), [0.4.1 · Object-oriented programming: classes, inheritance, composition and dataclasses](https://pythonparatrading.com/en/python-trading-course/level-0/python-classes-inheritance-dataclasses/)

## By the end of this lesson

- Explain the architecture of Zipline (bundle, DataPortal, Pipeline, algorithm, blotter and metrics) and how it differs from that of LEAN and of bt.
- Build an offline Zipline bundle with unadjusted prices, splits and dividends, and check that Zipline applies the adjustments at each date.
- Write a momentum factor with Pipeline and a long-short strategy with commission and slippage models, and read the results `DataFrame`.
- Prepare and launch a LEAN backtest with its CLI (Docker) and an asset allocation portfolio with bt, knowing what each line does.
- Choose an engine for the problem: factors on stocks, multi-asset with a path to live trading, or asset allocation.

## Three engines, three philosophies

Lessons 2.4.1 to 2.4.5 went from the simplest (a vectorized backtest) to the most complete (NautilusTrader). The
three engines in this lesson fill three other gaps:

| | Zipline Reloaded | LEAN (QuantConnect) | bt |
|---|---|---|---|
| What it is | Event-driven engine for stocks, heir to Quantopian | QuantConnect's multi-asset engine, for backtest and live | Portfolio backtesting library |
| Language | Python (with parts in Cython) | C#; algorithms in Python or C# | Python |
| Strong in | Factors on universes (Pipeline), local bundles | Integrated data and brokers, same code live | Asset allocation, rebalancing, strategy trees |
| Data | Bundles ingested by the user | Its own format; from QuantConnect or from providers | A `DataFrame` of prices |
| License | Apache-2.0 | Apache-2.0 | MIT |
| Version (PyPI) | `zipline-reloaded` 3.2.0, October 18, 2026 | `lean` CLI 1.0.230, October 15, 2026 | `bt` 1.4.1, October 10, 2026 |

Licenses and dates come from PyPI and GitHub as of the lesson date (links in the resources). All three are
maintained: Zipline Reloaded by Stefan Jansen and the community since Quantopian closed, LEAN by QuantConnect and bt
by its author (`pmorissette` on GitHub) and contributors.

This lesson runs in a separate environment: Zipline Reloaded 3.2.0 requires `pandas < 3`, and the rest of the course
uses pandas 3. The environment (`curso-code/entornos/zipline`, with pandas 2.3.3 and numpy 2.4.6) is created with
`uv sync --project curso-code/entornos/zipline`; LEAN and bt are not installed, and their code goes in blocks that are
not run.

## Inside Zipline

```mermaid
flowchart TB
  B[Bundle] --> DP[DataPortal]
  B --> PE[Pipeline]
  PE --> BT[Before the session]
  DP --> AL[Algorithm]
  BT --> AL
  AL --> BL[Blotter]
  BL --> SL[Slippage]
  BL --> CM[Commission]
  SL --> MT[Metrics]
  CM --> MT
  MT --> PF[perf DataFrame]
```

- **Bundle.** The data, ingested once into a local directory (`ZIPLINE_ROOT`): daily or minute bars in a columnar
  format, a SQLite database of assets (symbol, lifetime dates, exchange) and another of adjustments (splits, mergers,
  dividends).
- **DataPortal.** Serves the algorithm the prices of each moment, adjusted with what was known that day.
- **Pipeline.** Computes factors over the whole universe every day, before the open, with windows adjusted point in
  time. Its result reaches `before_trading_start`.
- **Algorithm.** Functions that Zipline calls: `initialize` (once), `before_trading_start` (every day),
  `handle_data` (every bar) and the ones scheduled with `schedule_function`.
- **Blotter.** Receives the orders and executes them on the **next** bar, with the slippage model (which can cap
  participation in volume and leave orders partially filled) and the commission model.
- **Metrics.** At the end, a `DataFrame` with one row per session: portfolio value, return, exposure, positions,
  orders and transactions.

## A custom bundle with the level 2 data

Zipline's built-in bundles download data from the internet. For the course, a custom bundle with an `ingest` function
writes the 120 stocks of the level 2 universe (synthetic, with companies that go public, go bankrupt or are acquired)
from the Parquet files: **unadjusted** prices, and separately the splits and the dividends, so that Zipline applies the
adjustments itself.

```python
import os
import tempfile
import warnings

import numpy as np
import pandas as pd

# zipline 3.2.0 with pandas 2.3 emits internal FutureWarnings (in
# asset_writer.py and benchmark_source.py): kept out of the output
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"]
            # there is only a close: open, high and low = close
            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-for-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), "stocks ·", n_splits, "splits ·",
      len(divs), "dividends")
```
```output
120 stocks · 9 splits · 7029 dividends
```

Three details that cost hours if you do not know them (they are in the README of the course environment):

- **`country_code="US"`** in the exchanges table. Without it, Pipeline finds no assets for its default domain, the
  US one.
- **The `ratio` of a split** is the factor by which earlier prices are multiplied: a 2-for-1 split is 0.5.
- **A single `register` per name** and the `;` at the end of the line: `register` returns the function, and without the
  semicolon the environment would print its memory address.

`auto_close_date` is the day on which Zipline closes the positions in a stock that stops trading, at the last known
price. For a bankruptcy, that price is between 0.30 and 1.50 USD (level 2 README): almost the −100% of the delisting
return, but not exactly.

The bundle stores the adjustments and Zipline applies them when needed. You can check it by reading the bundle:

```python
datos = load("universo2")
qxxc = datos.asset_finder.lookup_symbol("QXXC", as_of_date=None)
print(qxxc, "·", qxxc.start_date.date(), "to", 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, "· stops trading:", qt.end_date.date(),
      "· auto close:", qt.auto_close_date.date())
```
```output
Equity(102 [QXXC]) · 2006-01-03 to 2025-12-31
splits: [(datetime.date(2007, 8, 29), 0.5)]
Equity(79 [QXQT]) · stops trading: 2008-10-17 · auto close: 2008-10-18
```

## Pipeline: the momentum factor

Pipeline computes a factor for all stocks every day with a data window **adjusted to that date**: if `QXXC` had a
2-for-1 split on August 29, 2007, the window for a day in September 2007 has the earlier prices divided by two, and the
window for a day in July does not. That way there is no look-ahead in the adjustments.

The factor is the 12-1 momentum from lesson 2.3.1: the return over about twelve months skipping the last one. In the
level 2 universe it has a **planted** premium: according to the data README, the expected rank IC is ≈ +0.03, the
spread between quintiles ≈ 8% a year, and there are crashes in April-June 2009 and 2020, when the losers rebound.

```python
from zipline.pipeline import CustomFactor, Pipeline
from zipline.pipeline.data import USEquityPricing
from zipline.pipeline.factors import AverageDollarVolume


class Momentum121(CustomFactor):
    """Return from t-252 to t-21 sessions."""
    inputs = [USEquityPricing.close]
    window_length = 253

    def compute(self, today, assets, out, close):
        out[:] = close[-22] / close[0] - 1


def hacer_pipeline():
    # the 100 most traded by dollar volume (21 sessions)
    universo = AverageDollarVolume(window_length=21).top(100)
    mom = Momentum121(mask=universo)
    return Pipeline(
        columns={"mom": mom, "quintil": mom.quantiles(5)},
        screen=universo)
```

`close` arrives as a matrix of 253 rows (days) by as many columns as there are stocks: `compute` writes one number per
stock into `out`. The universe is set every day with the dollar volume of the last 21 sessions, so it includes the
stocks that will later go bankrupt and excludes the ones that have not yet gone public: the bundle has the whole
history, including that of the delisted stocks, and that is why there is no survivorship bias (lesson 1.4.4).

## The strategy, with and without costs

On the first trading day of each month, the strategy buys the winner quintile and sells short the loser quintile, with
50% of the capital in each leg and equal weights, and closes the stocks that leave the extremes. With daily data the
time in `time_rules` does not matter: the order is sent that day and executed at the close of the next session. Two
versions:

- **Without costs**: `NoCommission` and `NoSlippage`.
- **With costs**: `PerShare(cost=0.005, min_trade_cost=1.0)`, the per-share fee of `Alcor` from lesson 2.4.7, and
  `FixedBasisPointsSlippage(basis_points=5, volume_limit=0.1)`: 5 bp against you on each fill and, at most, 10% of the
  day's volume; whatever does not fit stays pending for the next day (a partial fill, lesson 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` is not called: without it, Zipline 3.2.0 does not compare against any index. (Careful:
`set_benchmark(False)` does not disable the benchmark; it takes the asset with `sid` 0, here `QXAC`, which stops
trading in 2007, and the backtest fails.) The two runs, from 2015 to 2025 so that the chapter takes no more than a
minute and a half:

```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` has one row per session. In February 2015 the portfolio holds about twenty positions per side, half a million
long and half a million short; the `orders`, `transactions` and `positions` columns store, day by day, lists of
dictionaries with each order, fill and position.

## Results

```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]
# the commission lives in the orders, cumulative: the last version
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), "transactions ·", len(ordenes), "orders ·",
      f"commissions {comision:,.0f} USD")
tabla.round(2)
```
```output
6408 transactions · 6406 orders · commissions 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. — Annual return of the long-short momentum in Zipline, without costs (grey) and with costs (blue), 2015-2025.* Paired annual bars, gross and net. Costs take between 0.4 and 0.6 points each year. Years range from −13.3% net in 2020 (highlighted, the year of the planted momentum crash) to +24.7% in 2021; 2019 gives +17.7% and 2025, −4.2%.

The header figure shows the two curves and this one, the result of each year. Three readings:

- **The planted premium shows up**, with the shape of momentum: good years (+17.7% in 2019, +24.7% in 2021), flat
  years and a sharp drop in 2020 (−13.3%). The spread between quintiles that the README states (≈ 8% a year) is split
  here into two legs of 50% of the capital, so half of it is expected per year, minus the crashes: 3.6% a year net, with
  a volatility of 6.1% and a Sharpe of 0.61.
- **Costs** take the difference between the two curves: 1.54 versus 1.47 million, about 73,000 USD. The commission,
  18,056 USD over 6,406 orders, is only a quarter of it; the rest is the 5 bp of slippage per side on each rebalance,
  which shows up in the price of each transaction and not in a separate column. Each year costs subtract between 0.4
  and 0.6 points.
- **Costs are missing.** Zipline does not charge for borrowing the stocks sold short nor pay interest on the cash from
  the sales, and it does not prevent short selling stocks with no availability. With the data in `costes2_prestamo.csv`,
  lesson 2.4.7 measures how much that changes the result.

The data is synthetic and the premium is planted on purpose: the backtest shows that the engine and the factor recover
it, not that momentum works in a real market.

## LEAN and QuantConnect

LEAN is the open-source engine (Apache-2.0) behind QuantConnect. It is written in C# and accepts algorithms in C# or
in Python; the same algorithm runs in backtest, paper and live with the brokers it integrates. There are two ways to use
it: in the QuantConnect cloud, with its data, or locally with the **LEAN CLI**.

```mermaid
flowchart TB
  CLI[lean CLI] --> DK[Docker container]
  DK --> EN[LEAN engine]
  DT[Local data] --> EN
  EN --> AM[Algorithm Manager]
  AM --> QA[QCAlgorithm]
  QA --> TH[Orders and portfolio]
  TH --> BR[Broker or simulator]
  EN --> RS[Results]
```

According to its documentation, the commands that run the engine locally (`lean backtest`, `lean optimize`,
`lean research`) do so inside the `quantconnect/lean` Docker image, which contains the engine and the QuantConnect
libraries; without Docker installed and running, it cannot be done. This course does not run it (Docker is not in its
environment), so the code has no output.

```bash
pip install --upgrade lean          # the CLI is a Python package
lean init                           # creates the working folder and the data folder
lean create-project "Momentum sectores" --language python
lean backtest "Momentum sectores"   # downloads the image the first time
```

The algorithm is a subclass of `QCAlgorithm`. This is the same 12-1 momentum on nine real US sector ETFs (in the
QuantConnect cloud its data is used; locally you need to have it in LEAN's format), with the lowercase Python API that
the current documentation uses:

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

The pieces map to Zipline's: `initialize` configures, `add_equity` registers the asset and its resolution,
`set_fee_model` and `set_slippage_model` set the asset's cost models (`InteractiveBrokersFeeModel` models that broker's
fee schedule), `schedule.on` schedules the rebalance and `history` provides the price window. `lean backtest` stores the
results in a dated folder inside `backtests/` in the project. Unlike Zipline, going live requires no rewrite:
`lean live deploy` launches the same algorithm against a broker, again in a Docker container with the
`quantconnect/lean` image. In exchange, the engine is a large system in another language, and debugging what happens
inside requires reading C#.

## bt for asset allocation

bt solves a narrower problem: portfolios that are rebalanced periodically with selection and weighting rules. A
strategy is a stack of **algos**, small functions that run in order at each date and stop as soon as one returns
`False` (for example, `RunMonthly` on a day that is not a rebalance day). The one below is a naive risk parity
(inverse-volatility weights) across five level 2 continuous futures, one per asset class:

```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` returns a results object with statistics (`display`), curves and the weights at each date. The pieces are
reusable and can be nested: a strategy can itself be an asset of another, which allows portfolios of strategies. What bt
lacks is what Zipline, LEAN or NautilusTrader have: orders, an order book, latency, a path to live trading. Nor does it
model the futures roll or their margins: here it is given adjusted continuous prices (lesson 1.4.6) as if they were
spot assets. For comparing allocation rules it is convenient and fast; for execution, it is not enough.

## Which one to choose

```mermaid
flowchart TB
  P{What is being tested} --> F[Factors, stocks]
  P --> A[Allocation]
  P --> R[Going live]
  F --> ZL[Zipline Reloaded]
  A --> BTB[bt or custom engine]
  R --> NL{Which language}
  NL -- Python and Rust --> NT[NautilusTrader]
  NL -- C# or Python --> LE[LEAN]
```

| Need | Reasonable engine | Why |
|---|---|---|
| Understand a backtest, prototype an idea | Custom vectorized engine (2.4.1) | Everything in view, seconds to run |
| Factors on a stock universe with your own data | Zipline Reloaded | Pipeline, point-in-time adjustments, local bundles |
| Allocation portfolios with rebalancing | bt | Reusable blocks, little code |
| Same code in backtest and live, multi-asset | NautilusTrader (2.4.5) or LEAN | Shared core between simulation and trading |
| Massive parameter sweeps | vectorbt (2.4.3) | Vectorization over parameter matrices |

No engine fixes a bad hypothesis or a bias in the data. What changes between them is which errors they make hard
(Zipline: adjustments and look-ahead; NautilusTrader and LEAN: the gap between simulation and live) and which
assumptions they leave to the user (in all three: securities borrowing, the queue and market impact).

## Exercises

1. With `perf` (the version with costs), compute the turnover: how many shares are bought and sold in total, the
   average commission per transaction and what share of the average capital turns over each year.

**Solution.**

```python
t = pd.DataFrame(trans)
efectivo = (t["amount"].abs() * t["price"]).sum()
años = len(perf) / 252
print(f"shares traded: {t['amount'].abs().sum():,.0f}")
print(f"average commission per order: {comision / len(ordenes):.2f} USD")
print(f"turnover: {efectivo / años / 1_000_000:.1f} times"
      " the initial capital per year")
```
```output
shares traded: 3,061,155
average commission per order: 2.82 USD
turnover: 8.0 times the initial capital per year
```

Each monthly rebalance changes a large part of the two quintiles, so the portfolio trades about eight times its
initial capital per year, adding up buys and sells (the turnover in lesson 2.4.7, measured differently, was about six
times). The average commission per order is 2.82 USD: with positions of about 25,000 USD, the 1 USD minimum of the fee
weighs little.

2. The level 2 README says that in April, May and June 2020 the momentum coefficient changes sign (the losers
   rebound). Check in `perf` what happened in those months in the version with costs.

**Solution.**

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

From March to July 2020 the long-short loses 11.7%. March (−5.1%) is the stress period in the data script; April,
May and June, the planted **momentum crash**, add up to −4.5% (April −1.9%, May +0.2%, June −2.9%): the losers of the
fall rebound more than the winners. The planted effect is visible, although mixed with the rest of the variance: a
strategy with an acceptable Sharpe can lose in a few months what it gained in a year, right after a crisis.

3. You want to take this same strategy to LEAN with real US stock data. List three things from the Zipline backtest
   that you would have to redo or check.

**Solution.**

The universe: here it comes from a custom bundle with all the stocks, including the delisted ones; in LEAN it is built
with its universe selection (for example, by dollar volume) and you have to confirm that the data includes the
companies that stopped trading, or the backtest will have survivorship bias. The costs: commissions and slippage are
set per asset with `set_fee_model` and `set_slippage_model`, and securities borrowing and interest on cash have to be
reviewed in its account model, because they change the result of a long-short (lesson 2.4.7). And the timing: in
Zipline the rebalance order is executed at the close of the next session with daily data; in LEAN it depends on the
data resolution and its execution models, so fill prices and the factor window can shift by one session, and it is
worth checking with a few trades.

## Self-assessment

1. In the lesson's bundle, Zipline receives the traded (unadjusted) prices plus one table of splits and another of dividends. What is the advantage over giving it the adjusted prices directly?

   - A. None: the result is identical
   - B. It takes less space
   - C. It allows minute data
   - D. Zipline adjusts the history as it was known at each date of the backtest, executes orders at that day's real prices and credits dividends in cash

   *Answer D: Zipline adjusts the history as it was known at each date of the backtest, executes orders at that day's real prices and credits dividends in cash.* With backward-adjusted prices, the 2015 prices depend on later splits and dividends (lesson 1.4.3), and the share count of each order is fictitious. With unadjusted prices and the adjustment tables, the Pipeline window is adjusted only with what had happened up to that day.

2. `Momentum121` uses `window_length = 253` and returns `close[-22] / close[0] - 1`. What does it measure?

   - A. The return over about twelve months skipping the last one, the 12-1 momentum
   - B. The return of the last month
   - C. Annual volatility
   - D. The return of the last year including the last month

   *Answer A: The return over about twelve months skipping the last one, the 12-1 momentum.* The window holds 253 closes up to the previous day; `close[0]` is the one from about twelve months ago and `close[-22]`, the one from about 21 days ago. Skipping the last month avoids the short-term reversal that contaminates momentum.

3. What does LEAN need to run a backtest locally with its CLI?

   - A. Only `pip install lean`
   - B. The CLI (`pip install lean`) and Docker installed and running, because the engine runs inside the `quantconnect/lean` image; plus data in LEAN's format
   - C. A paid QuantConnect account
   - D. Compiling LEAN from its C# source code

   *Answer B: The CLI (`pip install lean`) and Docker installed and running, because the engine runs inside the `quantconnect/lean` image; plus data in LEAN's format.* The CLI documentation says so explicitly: the commands that run the engine locally (`lean backtest`, `lean optimize`, `lean research`) run in a Docker container with LEAN and its dependencies. The data has to be bought, pulled from a provider or imported.

4. Which costs of a long-short portfolio does Zipline not model by default?

   - A. Commissions
   - B. Slippage
   - C. The borrowing of the securities sold short, the interest on the collateral and borrow availability
   - D. None

   *Answer C: The borrowing of the securities sold short, the interest on the collateral and borrow availability.* Zipline ships commission models (`PerShare`, `PerTrade`, `PerDollar`) and slippage models (`FixedBasisPointsSlippage`, `VolumeShareSlippage`…), but it does not charge borrow fees nor prevent short selling what cannot be borrowed. That has to be added separately, as in lesson 2.4.7.

5. What is bt best suited for?

   - A. High-frequency market making
   - B. Live trading with Interactive Brokers
   - C. Asset allocation portfolios with periodic rebalancing, combining blocks such as `RunMonthly`, `SelectAll`, `WeighInvVol` and `Rebalance`
   - D. Simulating limit order queues

   *Answer C: Asset allocation portfolios with periodic rebalancing, combining blocks such as `RunMonthly`, `SelectAll`, `WeighInvVol` and `Rebalance`.* bt organizes a strategy as a stack of small algorithms that run at each date (when to rebalance, what to select, how to weight) and supports strategy trees. It has no order book and no path to live trading.

## Key points

- Zipline Reloaded (Apache-2.0, 3.2.0) is an event-driven engine for stocks with daily or minute data, local bundles and Pipeline for factors; it requires pandas < 3.
- A bundle with unadjusted prices plus splits and dividends lets Zipline adjust the history point in time and credit the dividends.
- On the level 2 data, the long-short momentum strategy makes 54% gross and 47% net in 2015-2025 with a per-share commission and 5 bp of slippage; the April to June 2020 drop is the planted crash.
- LEAN (Apache-2.0) is a C# engine with algorithms in Python or C#, the same for backtest and live; the CLI runs the engine in Docker.
- bt (MIT, 1.4.1) builds asset allocation portfolios with reusable blocks; it does not simulate orders or an order book.

## Resources

### Research

- [Zipline in Python - Part 1: Quant Stack and Pipeline](https://pythonparatrading.com/en/zipline-backtesting-with-python-quant-stack/) · Research-0011 · 11 min

### Documentation

- [Zipline Reloaded: documentation (bundles, Pipeline, API)](https://zipline.ml4trading.io/) · zipline.ml4trading.io
- [Zipline Reloaded on GitHub (Stefan Jansen)](https://github.com/stefan-jansen/zipline-reloaded) · github.com
- [LEAN CLI: installation (Docker) and local backtests](https://www.lean.io/docs/v2/lean-cli/installation/installing-lean-cli) · lean.io
- [QuantConnect: the algorithm engine (QCAlgorithm)](https://www.quantconnect.com/docs/v2/writing-algorithms/key-concepts/algorithm-engine) · quantconnect.com
- [bt: documentation](https://pmorissette.github.io/bt/) · pmorissette.github.io

### Data

- [Level 2 data: unadjusted prices of the universe (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/universo2_precios.parquet) · Download · universo2_precios.parquet

Previous lesson: [2.4.5 · NautilusTrader: architecture, data, strategies and the same code in backtest and live](https://pythonparatrading.com/en/python-trading-course/level-2/nautilustrader-architecture-and-strategies/)

Next lesson: [2.4.7 · Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing](https://pythonparatrading.com/en/python-trading-course/level-2/backtest-transaction-cost-modeling/)
