---
title: "2.4.1 Vectorized backtest from scratch and its limits"
description: "A vectorized backtester with pandas and NumPy: signal, lag, turnover costs, a futures portfolio and vol targeting; and where it goes wrong."
url: "https://pythonparatrading.com/en/python-trading-course/level-2/vectorized-backtest-from-scratch/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-2/vectorized-backtest-from-scratch.png"
---

# 2.4.1 · Vectorized backtest from scratch: matrices, lag, costs and limits

> A vectorized backtest turns a strategy into operations between matrices: signals, weights and returns, without a single loop. This lesson builds a complete one for a portfolio of 24 futures, with costs and a volatility target, protects it with tests and then takes it to the cases where it gives the wrong answer.

- 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: [0.5.1 · NumPy 2: arrays, broadcasting, vectorization, linear algebra and random generators](https://pythonparatrading.com/en/python-trading-course/level-0/numpy-arrays-and-vectorization/), [0.5.3 · Selection, filtering, groupby, merge, pivot, resample and moving windows](https://pythonparatrading.com/en/python-trading-course/level-0/pandas-groupby-merge-resample-rolling/), [1.4.6 · Continuous futures series: roll rules, difference adjustment and ratio adjustment](https://pythonparatrading.com/en/python-trading-course/level-1/continuous-futures-series-and-roll-adjustment/), [1.6.1 · Returns, volatility, rolling correlations and drawdown](https://pythonparatrading.com/en/python-trading-course/level-1/returns-volatility-correlation-and-drawdown/)

## By the end of this lesson

- Code a reusable vectorized backtester: lagged positions, portfolio return, costs on turnover and rolls.
- Size positions with a volatility target and measure the turnover and cost they generate.
- Protect the backtester with tests, including a perturbation test that detects look-ahead bias.
- Recognize path-dependent rules (trailing stops, re-entries, stops and targets in the same bar, whole contracts) and measure how wrong the vectorized calculation gets on them.

## Matrices instead of loops

A backtest answers one question: if I had followed this rule in the past, what would have happened to my money? There
are two ways to compute it. The **event-driven** one walks through time bar by bar, as the system would in live
trading: a price arrives, the strategy decides, an order is sent, it gets filled (lesson 2.4.2). The **vectorized** one
computes everything at once with operations between matrices: a table of signals, another of weights and another of
returns, aligned by date and asset.

For a portfolio of $N$ assets with weights $w_{i,t}$ (notional divided by capital) decided at the close of $t$, the
next day's return is

$$
R_{t+1} = \sum_{i=1}^{N} w_{i,t}\, r_{i,t+1} \;-\; \sum_{i=1}^{N} c_{i,t+1}\,\lvert w_{i,t+1} - w_{i,t} \rvert ,
$$

where $r_{i,t+1}$ is the asset's close-to-close return and $c_{i,t+1}$ the cost per unit of traded notional. The whole
backtest is that formula applied to all dates at once. The index $t$ of the weight and the $t+1$ of the return are the
important part: the weight is decided with what is known at the close of $t$ and earns what happens afterwards.

```mermaid
flowchart TB
  P[Prices] --> S[Signal at t]
  P --> V[Volatility at t]
  S --> W[Weights at t]
  V --> W
  W --> D[shift 1]
  D --> B[Gross return]
  W --> T[Turnover]
  T --> C[Costs]
  B --> N[Net return]
  C --> N
```

The advantages are those of NumPy (lesson 0.5.1): a portfolio of 24 markets over 26 years is computed in a fraction of
a second, the code fits on one screen and reads like the formula. The assumptions are also those of the formula: the
position depends only on past data, it is traded at the closing price, the cost is proportional to what is traded and
nothing that happens during the day changes the position. The last section of the lesson measures what happens when
that does not hold.

The data is the 24 synthetic futures of level 2: indices, bonds, energy, metals, agriculturals and currencies, with
ratio-adjusted continuous series (lesson 1.4.6), from 2000 to 2025. They are fictitious and carry two planted effects
with known parameters: a **carry** (the future returns half of the exogenous part of its curve's slope) and a **time
series momentum** (the expected return takes the sign of that of the previous 252 sessions, with a Sharpe of around
0.15 per market). What this lesson measures is the engine's ability to recover an effect we know is there; it says
nothing about whether that effect exists in real markets[^1].

```python
import numpy as np
import pandas as pd

fut = pd.read_parquet("futuros2_continuos.parquet")
spec = pd.read_csv("futuros2_especificaciones.csv",
                   index_col="mercado")
px = fut.pivot(index="fecha", columns="mercado",
               values="cierre_ajustado")
bruto = fut.pivot(index="fecha", columns="mercado",
                  values="cierre")
contrato = fut.pivot(index="fecha", columns="mercado",
                     values="contrato")
r = px.pct_change(fill_method=None)
print(px.shape, "|", px.index[0].date(), "to", px.index[-1].date())
print(spec.groupby("sector").size().to_dict())
r.iloc[1:4, :6].round(4)
```
```output
(6539, 24) | 2000-01-03 to 2025-12-31
{'agricolas': 4, 'bonos': 5, 'divisas': 3, 'energia': 3, 'metales': 4, 'renta_variable': 5}
mercado        AGA     AGB     AGC     AGD     BOA     BOB
fecha
2000-01-04  0.0174  0.0151  0.0250  0.0392  0.0005 -0.0038
2000-01-05 -0.0278 -0.0255 -0.0151 -0.0169  0.0005  0.0002
2000-01-06 -0.0091 -0.0067 -0.0175  0.0021 -0.0003 -0.0039
```

Three wide tables with one row per session and one column per market: the adjusted price (for returns), the traded
price of the contract held that day (for costs, which are paid on the real notional) and the contract name (to know
when there is a roll). Everything else comes from here.

## The signal and the lag

The rule in this lesson is the simplest of the trend family (lesson 2.3.1): be long if the future has risen over the
last 252 sessions and short if it has fallen. The signal is computed with the close of $t$ and can therefore only earn
what happens **afterwards**. In pandas that is a `shift(1)`. Forgetting it is the most common and most expensive
mistake in a vectorized backtest:

```python
def sharpe(x):
    x = x.dropna()
    return np.sqrt(252) * x.mean() / x.std()

senal = np.sign(px / px.shift(252) - 1)   # known at the close of t
bien = (senal.shift(1) * r).mean(axis=1)  # earned on day t+1
mal = (senal * r).mean(axis=1)            # earns the same day t
print(f"with lag: Sharpe {sharpe(bien.loc['2001':]):.2f}")
print(f"without lag: Sharpe {sharpe(mal.loc['2001':]):.2f}")

rapida = np.sign(px / px.shift(5) - 1)
print(f"5-day signal, with lag: "
      f"{sharpe((rapida.shift(1) * r).mean(axis=1)):.2f}")
print(f"5-day signal, without lag: "
      f"{sharpe((rapida * r).mean(axis=1)):.2f}")
```
```output
with lag: Sharpe 0.61
without lag: Sharpe 2.69
5-day signal, with lag: 0.29
5-day signal, without lag: 17.33
```

With the lag, the equal-weighted portfolio of signals has a Sharpe of 0.61: the planted momentum (plus what the trends
of the scripted crises add) shows up. Without the lag, 2.69. The error is silent: the code does not fail, the curve
rises more and nothing warns you. With a 5-day signal it is grotesque, 17.33, because the last day weighs a lot in a
sum of five. With the 252-day one the error looks like “just” a good result, and that is why it is more dangerous.

<aside class="callout" data-kind="riesgo">

Executing at the same close used to compute the signal is also an optimistic assumption: in practice the order has to
be sent before the close (an on-close order, lesson 1.1.4) with a price that is not yet final, or executed in the next
session. Exercise 1 measures what it costs to delay execution by one more day.

</aside>

## Volatility-targeted weights and costs

A signal of $\pm 1$ is not a position. A future on a 2-year bond moves 2% a year; one on natural gas, 50%. With the
same notional, gas would dominate the portfolio's risk. The usual solution is to size each position so that it
contributes the same risk: the **volatility target** (*vol targeting*),

$$
w_{i,t} = s_{i,t}\,\frac{\sigma^{*}}{\sqrt{N}\;\hat\sigma_{i,t}},
$$

with $s_{i,t}$ the signal, $\sigma^{*}$ the portfolio target (here 10% a year), $N$ the number of markets and
$\hat\sigma_{i,t}$ the volatility estimated with data up to $t$ (60-session EWMA, lesson 1.6.1). Dividing by
$\sqrt{N}$ would hit the target exactly if the markets were independent; they are not, and that will show in the
result.

```python
vol = r.ewm(span=60, min_periods=60).std() * np.sqrt(252)
n = px.shape[1]
objetivo = 0.10 / np.sqrt(n)     # risk per market
pesos = senal * objetivo / vol   # notional / capital
dia = pd.Timestamp("2024-06-03")
tabla = pd.DataFrame({"senal": senal.loc[dia],
                      "vol": vol.loc[dia],
                      "peso": pesos.loc[dia]})
print(f"gross leverage that day: "
      f"{pesos.loc[dia].abs().sum():.2f}")
tabla.loc[["IDA", "BOA", "BOE", "ENC", "MEA", "DIB"]].round(3)
```
```output
gross leverage that day: 5.04
         senal    vol   peso
mercado
IDA        1.0  0.086  0.238
BOA       -1.0  0.016 -1.256
BOE       -1.0  0.098 -0.208
ENC        1.0  0.504  0.040
MEA       -1.0  0.146 -0.140
DIB        1.0  0.103  0.197
```

On June 3, 2024, the 2-year bond (`BOA`, 1.6% volatility) needs a notional of 126% of capital to contribute the same as
4.0% in natural gas (`ENC`, 50.4%). The sum of the absolute values of the weights, the **gross leverage**, is 5.04 times
capital. In futures that is normal: the notional is not paid, only a margin is posted (lesson 1.2.3). But it means
that a sizing error gets multiplied.

Costs are paid per contract: the typical slippage from the specifications (in ticks, times their dollar value) plus
the commission. To bring them to the same unit as the weights they are divided by the notional of one contract, with
the price that is actually traded (`cierre`, not the adjusted one). In addition, each roll forces selling the expiring
contract and buying the next one: a turnover of twice the position, even if the signal does not change.

```python
coste_usd = (spec["coste_tipico_ticks"] * spec["valor_tick"]
             + spec["comision_usd"])          # per contract and side
nocional = bruto * spec["multiplicador"]      # USD per contract
coste_unit = coste_usd / nocional             # per USD traded
rolls = (contrato != contrato.shift(1)) & contrato.shift(1).notna()
print("rolls per year:",
      (rolls.loc["2001":].sum() / 25).round(1)[["IDA", "ENA", "AGA"]]
      .to_dict())
costes = pd.DataFrame({                       # 2024 medians
    "nocional": nocional.loc["2024"].median().round(-2).astype(int),
    "coste_usd": coste_usd,
    "pb": (1e4 * coste_unit.loc["2024"].median()).round(2)})
costes.sort_values("pb").iloc[[0, 1, 2, 3, 4, 5, -3, -2, -1]]
```
```output
rolls per year: {'IDA': 4.0, 'ENA': 12.0, 'AGA': 5.0}
         nocional  coste_usd     pb
mercado
IDB        419700      7.250   0.17
IDE        287900      7.250   0.25
IDA        345900     14.750   0.43
IDD        210100     12.250   0.58
IDC        390500     27.250   0.70
BOA        193300     17.875   0.92
MEB         39600     52.250  13.19
AGA          9400     14.750  15.69
AGB         13900     27.250  19.59
```

The cost per unit of notional, in basis points per side (2024 median), ranges from 0.17 for the `IDB` index to 19.59
for wheat (`AGB`). It is not that wheat is hard to trade: the cost is paid per contract, in fixed dollars, and
contracts have very different sizes. In the synthetic data (2024 medians), a wheat contract is worth 13,900 USD and one
tick plus commission is 27.25 USD; an `IDB` contract is worth 419,700 USD and pays 7.25. The indices, with the largest
contracts, sit at the bottom of the table; silver (`MEB`) and the agriculturals, at the top.

## The backtester and its tests

With the three pieces (weights, returns, unit cost) the backtester is a ten-line function. The second function
packages the strategy so it can be run again with other prices, which is what one of the tests needs.

```python
def backtest_vectorizado(pesos, rend, coste_unit, rolls=None):
    """Daily return of a portfolio of target weights.

    pesos: date x asset, notional/capital decided at the close.
    rend: close-to-close return of each asset.
    coste_unit: cost per unit of traded notional.
    rolls: True on the day a new contract appears.
    """
    pesos = pesos.fillna(0.0)
    pos = pesos.shift(1, fill_value=0.0)   # what is held at t
    bruto = (pos * rend.fillna(0.0)).sum(axis=1)
    rot = (pesos - pos).abs()              # what is traded at t
    if rolls is not None:                  # sell old, buy new
        rot = rot + 2 * pos.abs().where(rolls, 0.0)
    coste = (rot * coste_unit.fillna(0.0)).sum(axis=1)
    return pd.DataFrame({"bruto": bruto, "coste": coste,
                         "neto": bruto - coste,
                         "rotacion": rot.sum(axis=1)})


def estrategia(px, ventana=252, span=60, vol_obj=0.10):
    r = px.pct_change(fill_method=None)
    senal = np.sign(px / px.shift(ventana) - 1)
    vol = r.ewm(span=span, min_periods=span).std() * np.sqrt(252)
    return senal * vol_obj / np.sqrt(px.shape[1]) / vol
```

Two simplifications in plain sight. Turnover is measured as $\lvert w_t - w_{t-1} \rvert$, without accounting for the
fact that between two closes the actual weight drifts with the price (if a market rises 2%, its weight relative to
capital is no longer yesterday's); lesson 2.4.2 measures that difference against an engine that trades contracts. And
the roll cost is booked on the day the new contract appears, at that day's price.

A backtester without tests is an opinion. The first two check small cases with the answer computed by hand (lesson
0.4.5). The third is the most useful of all, a **perturbation test**: if prices are changed from a given date on, no
result before that date can change. If it does, something in the code is looking at the future, and you do not need
to know what to know that it is wrong.

```python
def test_desfase():
    idx = pd.date_range("2024-01-01", periods=6, freq="B")
    rend = pd.DataFrame({"A": [0, .01, -.02, .03, -.01, .02]}, idx)
    adivino = np.sign(rend)        # knows today's return
    res = backtest_vectorizado(adivino, rend, rend * 0)
    # earns tomorrow's return with today's signal, not today's
    assert np.isclose(res["bruto"].sum(),
                      (np.sign(rend).shift(1) * rend).sum().iloc[0])
    assert res["bruto"].sum() < rend.abs().sum().iloc[0]

def test_costes():
    idx = pd.date_range("2024-01-01", periods=4, freq="B")
    w = pd.DataFrame({"A": [0.0, 1.0, 1.0, 0.0]}, idx)
    c = pd.DataFrame({"A": [0.001] * 4}, idx)
    res = backtest_vectorizado(w, w * 0, c)
    assert np.isclose(res["coste"].sum(), 2 * 0.001)  # entry and exit

def test_sin_anticipacion():
    corte = px.index[-500]
    alterado = px.copy()
    alterado.loc[corte:] *= 1.3     # change the future from the cutoff
    a = backtest_vectorizado(estrategia(px), r, coste_unit)
    b = backtest_vectorizado(estrategia(alterado),
                             alterado.pct_change(fill_method=None),
                             coste_unit)
    antes = a.index < corte
    assert np.allclose(a.loc[antes], b.loc[antes])

for t in (test_desfase, test_costes, test_sin_anticipacion):
    t()
print("3 tests passed")
```
```output
3 tests passed
```

The perturbation test covers the whole pipeline, not just the last function: the signal and the volatility too. Try
it by breaking something on purpose, for example with `rolling(20, center=True)` instead of `ewm` or by normalizing
volatility by its full-sample mean: the `assert` fires. In a real project these tests live in a `pytest` file and run
every time the engine's code changes.

## The 24-futures portfolio

```python
res = backtest_vectorizado(estrategia(px), r, coste_unit, rolls)
res = res.loc["2001":]
def resumen(d):
    eq = (1 + d).cumprod()
    return pd.Series({
        "rent_anual_%": 100 * (eq.iloc[-1] ** (252 / len(d)) - 1),
        "vol_%": 100 * d.std() * np.sqrt(252),
        "sharpe": sharpe(d),
        "max_dd_%": 100 * (eq / eq.cummax() - 1).min()})
tabla = pd.DataFrame({"bruto": resumen(res["bruto"]),
                      "neto": resumen(res["neto"])})
print(f"average cost: {100 * 252 * res['coste'].mean():.2f} % per year")
print(f"turnover: {252 * res['rotacion'].mean():.0f} times per year")
tabla.round(2)
```
```output
average cost: 2.95 % per year
turnover: 107 times per year
              bruto   neto
rent_anual_%  14.58  11.25
vol_%         14.70  14.72
sharpe         1.00   0.80
max_dd_%     -23.49 -28.08
```

It is the header figure. Gross, 14.58% a year with a Sharpe of 1.00; after costs, 11.25% and 0.80. Three readings:

- **Volatility is 14.7%, not the 10% requested.** The markets are not independent: in the scripted crises many trend in
  the same direction at once and the signals line up. An exact portfolio target would need the covariance matrix, not
  just the volatilities (lesson 0.7.3).
- **The gross Sharpe, 1.00, is higher than that of a single market.** The planted momentum gives about 0.15 per
  market; diversification across 24 weakly correlated markets multiplies it, and the scripted trends add to it. That
  is why these systems are traded in broad portfolios.
- **Costs eat a fifth of the return.** 2.95 points a year, with a turnover of 107 times capital.

Where does so much cost come from? The function makes it possible to separate daily rebalancing from rolls:

```python
w = estrategia(px).fillna(0.0)
pos = w.shift(1, fill_value=0.0)
cu = coste_unit.fillna(0.0)
def anual(x):
    return 100 * 252 * x.loc["2001":].mean()
reparto = pd.DataFrame({
    "rebalanceo": anual((w - pos).abs() * cu),
    "roll": anual(2 * pos.abs().where(rolls, 0.0) * cu)})
reparto["total"] = reparto.sum(axis=1)
print({k: round(v, 2) for k, v in reparto.sum().items()})
reparto.sort_values("total", ascending=False).head(4).round(2)
```
```output
{'rebalanceo': 1.7, 'roll': 1.25, 'total': 2.95}
         rebalanceo  roll  total
mercado
MEB            0.18  0.10   0.29
AGC            0.13  0.13   0.25
BOA            0.16  0.08   0.24
AGA            0.14  0.10   0.24
```

More than half the cost is rebalancing (1.70 points): the weight changes every day with the estimated volatility even
if the signal does not change, and the backtest chases it to the cent. The rest, 1.25 points, is rolls, unavoidable
while the position is held. The cost is spread out: the four most expensive markets add up to a little over one point.
Three have small contracts (silver, soybeans and corn); the fourth, the 2-year bond, has one of the lowest unit costs,
but its notional exceeds 100% of capital. Rebalancing only on Fridays, keeping the weights for the week, is a one-line
change:

```python
w = estrategia(px)
viernes = w.index.dayofweek == 4
w_sem = w.where(pd.Series(viernes, w.index), np.nan).ffill()
sem = backtest_vectorizado(w_sem, r, coste_unit, rolls).loc["2001":]
for nombre, d in [("daily", res), ("weekly", sem)]:
    print(f"{nombre:8s} turnover {252 * d['rotacion'].mean():5.1f}"
          f"  cost {100 * 252 * d['coste'].mean():.2f} %"
          f"  net Sharpe {sharpe(d['neto']):.2f}")
```
```output
daily    turnover 107.1  cost 2.95 %  net Sharpe 0.80
weekly   turnover  70.9  cost 2.05 %  net Sharpe 0.95
```

Turnover drops from 107 to 71 and cost from 2.95 to 2.05 points; the net Sharpe rises from 0.80 to 0.95. The
alternative real systems use is better: leave the position alone while it stays within a **band** around the target
(for example, ±10% of the weight) and trade only when it leaves it. And here the first limit appears: whether to trade
today depends on the position held, which depends on whether there was a trade yesterday. That is no longer an
operation between matrices.

<aside class="callout" data-kind="aviso">

Do not choose the rebalancing day, the window or the volatility target by looking at these results: every test that
keeps the best version uses up the backtest's validity (lessons 2.2.4 and 2.5.4). Here they are compared only to show
the effect of turnover on costs.

</aside>

## Where it goes wrong: rules with memory

A rule is **path-dependent** when today's decision depends on earlier decisions and not only on prices. The most
common examples: a trailing stop (the level depends on the high since entry, and the entry depends on when the
previous exit happened), not re-entering until a new signal after a stop, no-trade bands, daily loss limits and any
sizing that depends on accumulated capital. With matrices they can be **approximated**; the problem is that the
approximation does not warn you when it stops being one.

Seeing it in numbers requires bars with high and low. The level 2 futures only have closes, so the test uses `KAPA`, a
synthetic ETF with 5-minute bars from 2022 to 2025, aggregated into daily bars. KAPA's planted effect is in the first
half hour (a small tendency to close the opening gap); the rule in this section does not look for it.

```python
b5 = pd.read_parquet("indice2_5min.parquet")
b5["sesion"] = (b5["ts"].dt.tz_convert("America/New_York")
                .dt.tz_localize(None).dt.normalize())
d = b5.groupby("sesion").agg(apertura=("apertura", "first"),
                             maximo=("maximo", "max"),
                             minimo=("minimo", "min"),
                             cierre=("cierre", "last"))
print(len(d), "sessions")
d.head(3)
```
```output
1003 sessions
            apertura  maximo  minimo  cierre
sesion
2022-01-03    251.30  253.42  251.30  252.38
2022-01-04    252.64  253.65  250.76  251.52
2022-01-05    252.08  255.91  251.78  254.76
```

The rule: buy at the close when KAPA crosses above its 20-session average; sell when it falls 4% from the highest
close reached **since entry**; after an exit, wait for the next upward crossover. This is how almost everyone would
write it in pandas:

```python
STOP = 0.04
c = d["cierre"]
encima = c > c.rolling(20).mean()
cruce = encima & ~encima.shift(1, fill_value=False)
pico = c.rolling(20).max()                 # 20-day peak, not
roto = c < pico * (1 - STOP)               # the peak since entry
pos_vec = (encima & ~roto).astype(float).shift(1, fill_value=0)
rc = c.pct_change().fillna(0)
eq_vec = (1 + pos_vec * rc).cumprod()
print(f"entries: {int((pos_vec.diff() == 1).sum())}")
print(f"final capital: {eq_vec.iloc[-1]:.3f}")
```
```output
entries: 52
final capital: 2.055
```

It has three differences from the written rule, and all three come from lack of memory: the peak is that of the last
20 sessions and not that of the trade; the position reappears as soon as the condition stops holding, without waiting
for a crossover; and the exit is executed at the next close, not when the price touches the level. The version with
memory needs a loop that keeps the state (position, peak) from one bar to the next:

```python
def trailing_bucle(d, cruce, stop=STOP, intrabarra=True):
    """Enters at the close on each upward crossover if flat; exits
    with a stop at stop% below the highest close since entry."""
    o, h, l, cl = (d[k].to_numpy() for k in
                   ("apertura", "maximo", "minimo", "cierre"))
    efectivo, unidades, pico = 1.0, 0.0, 0.0
    capital, operaciones = np.empty(len(d)), []
    for i in range(len(d)):
        if unidades:
            nivel = pico * (1 - stop)
            if intrabarra and l[i] <= nivel:     # live stop order
                precio = min(o[i], nivel)        # gap: the open
            elif not intrabarra and cl[i] <= nivel:
                precio = cl[i]                   # at the close only
            else:
                precio = None
                pico = max(pico, cl[i])
            if precio is not None:
                efectivo, unidades = unidades * precio, 0.0
                operaciones.append((d.index[i], precio,
                                    o[i] < nivel))
        if not unidades and cruce.iloc[i]:
            unidades, efectivo, pico = efectivo / cl[i], 0.0, cl[i]
        capital[i] = efectivo + unidades * cl[i]
    return pd.Series(capital, d.index), operaciones

eq_cierre, ops_cierre = trailing_bucle(d, cruce, intrabarra=False)
eq_bucle, ops = trailing_bucle(d, cruce)
for nombre, eq, o in [("loop, stop at close", eq_cierre, ops_cierre),
                      ("loop, intraday stop", eq_bucle, ops)]:
    print(f"{nombre:22s} exits {len(o):2d}  capital "
          f"{eq.iloc[-1]:.3f}")
print("exits with a gap below the stop:",
      sum(h for *_, h in ops))
print("last exit:", ops[-1][0].date(), round(ops[-1][1], 2))
```
```output
loop, stop at close    exits 23  capital 1.973
loop, intraday stop    exits 28  capital 1.535
exits with a gap below the stop: 3
last exit: 2025-09-12 422.45
```

*Fig. — KAPA, 2022–2025: the same 4% trailing stop coded in a (naive) vectorised way and with a loop that tracks the position, and the equity gap between them.* Top, two weekly equity curves starting at 1: the vectorised one ends at 2.05 and the loop (highlighted) at 1.53. Bottom, the gap in percentage points of equity, almost always positive: it hovers near zero until 2024 and jumps in the second half of 2025, to 54 points.

Three answers to the same question. The vectorized calculation multiplies capital by 2.05 with 52 entries. The loop
that only looks at closes, by 1.97 with 23 exits. The loop with a real stop order, which triggers as soon as the bar's
low touches the level and is filled at that level or at the open if the price opens below it (3 of the 28 exits), by
1.53. The figure shows where they diverge: for two and a half years the curves almost coincide, and in the second half
of 2025 the vectorized one runs up to 54 points of capital above. After the last exit, on September 12, 2025, KAPA
rises from 422 to 534 without crossing its average from below again until December 9: the real rule stays out for
almost the whole rebound and the vectorized one, which does not remember having exited, re-enters in September.

That the vectorized version wins is not what matters: with another stretch of prices it would lose. What matters is
that **it does not answer the question it was asked**, and that the difference depends on the specific price path, so
it cannot be corrected with a factor.

## Inside the bar, whole contracts and limit orders

**Stop and target in the same bar.** With daily bars the day's high and low are known, but not which came first. A
strategy that buys at the open with a stop at −0.5% and a target at +0.5% (and, if it touches neither, sells at the
close) depends entirely on that order. KAPA's 5-minute bars resolve it:

```python
K = 0.005
filas = []
for sesion, g in b5.groupby("sesion"):
    ap = g["apertura"].iloc[0]
    sube = g["maximo"].to_numpy() >= ap * (1 + K)
    baja = g["minimo"].to_numpy() <= ap * (1 - K)
    i_s = sube.argmax() if sube.any() else 10**6
    i_b = baja.argmax() if baja.any() else 10**6
    filas.append((sube.any(), baja.any(), i_s, i_b,
                  g["cierre"].iloc[-1] / ap - 1))
br = pd.DataFrame(filas, columns=["sube", "baja", "i_s", "i_b", "rc"])
ambas = br["sube"] & br["baja"]
print(f"sessions: {len(br)}; touch both levels: {ambas.sum()}")
primero_obj = (ambas & (br.i_s < br.i_b)).sum()
print(f"  target first (5 min): {primero_obj}")
print(f"  stop first (5 min): {(ambas & (br.i_b < br.i_s)).sum()}")
print(f"  both in the same 5-min bar: "
      f"{(ambas & (br.i_s == br.i_b)).sum()}")
```
```output
sessions: 1003; touch both levels: 187
  target first (5 min): 88
  stop first (5 min): 94
  both in the same 5-min bar: 5
```

In 187 of 1,003 sessions the daily bar touches both levels. With the 5-minute bars, in 88 the target comes first, in
94 the stop, and in 5 both fall within the same 5-minute bar, where there is ambiguity again (they are counted as
stops, to be prudent).

```python
def resultado(r_ambas):
    out = np.where(br["sube"] & ~br["baja"], K,
          np.where(br["baja"] & ~br["sube"], -K,
          np.where(~br["sube"] & ~br["baja"], br["rc"], r_ambas)))
    return 100 * out.sum()
verdad = np.where(br.i_s < br.i_b, K, -K)  # same bar: the stop
print(f"assuming the stop first: {resultado(-K):6.1f} %")
print(f"with the 5-minute bars: {resultado(verdad):6.1f} %")
print(f"assuming the target first: {resultado(K):6.1f} %")
```
```output
assuming the stop first:  -77.5 %
with the 5-minute bars:   10.5 %
assuming the target first:  109.5 %
```

*Fig. — Buying KAPA at the open with a stop at −0.5% and a target at +0.5%, 2022–2025: the result depending on what is assumed to happen first when the daily bar touches both levels.* Three horizontal bars with the sum of daily returns of the same strategy: −77.5% assuming the stop is hit first, +109.5% assuming the target, and +10.5% (highlighted) with the actual order from the 5-minute bars. 187 of the 1,003 sessions touch both levels.

Adding up the daily returns, the strategy gains 109.5% if the target is assumed first, loses 77.5% if the stop is
assumed, and gains 10.5% with the actual order. The difference between the two assumptions, 187 points, is one point
per ambiguous session (the 0.5% gained plus the 0.5% lost). No fixed rule on daily bars gives the answer: it takes
higher-resolution data or an engine that uses it. And the 10.5% does not include the bid-ask spread or the slippage of
the stop orders either, which in a strategy that trades every day decide everything (lessons 2.4.7 and 2.4.8).

**Capital and whole contracts.** Weights are fractions; contracts are not. With a given capital, the real position is
the whole number of contracts closest to the target, and a market whose contract is worth more than the risk assigned
to it stays at zero:

```python
w = estrategia(px).loc["2001":]
noc = nocional.loc["2001":]
for capital in (1e6, 1e7, 1e8):
    n_ctr = (w * capital / noc).round()
    w_real = n_ctr * noc / capital
    ceros = ((n_ctr == 0) & (w.abs() > 0))
    d_real = (w_real.shift(1) * r.loc["2001":]).sum(axis=1)
    d_obj = (w.shift(1) * r.loc["2001":]).sum(axis=1)
    te = 100 * (d_real - d_obj).std() * np.sqrt(252)
    a_cero = 100 * ceros.mean().mean()
    idc = 100 * ceros["IDC"].mean()
    print(f"capital {capital:>11,.0f} USD: at zero {a_cero:4.1f} % "
          f"(IDC {idc:3.0f} %), tracking error {te:.2f} %")
```
```output
capital   1,000,000 USD: at zero  4.2 % (IDC  34 %), tracking error 3.14 %
capital  10,000,000 USD: at zero  0.0 % (IDC   0 %), tracking error 0.27 %
capital 100,000,000 USD: at zero  0.0 % (IDC   0 %), tracking error 0.03 %
```

With one million dollars, 4.2% of the positions round to zero contracts and the real portfolio departs from the
weights portfolio with a tracking error of 3.14% a year, a fifth of its volatility. The ones left out most are the
indices, the largest contracts: the `IDC` contract is worth 390,500 USD (2024 median) and its risk weight does not
reach one contract with that capital, so it is at zero 34% of the days. With ten million no position is left at zero
and the error drops to 0.27%; with a hundred, to 0.03%. The cell's calculation is also an approximation, because it
uses a fixed capital; in an honest backtest the number of contracts depends on each day's capital, which depends on
everything before it: the path again.

**Limit orders.** A vectorized backtest usually treats a buy limit order as filled if the bar's low touches the limit
price. It is optimistic twice over: touching the price does not guarantee a fill (there is a queue of orders at the
same price ahead) and the orders that do get filled are, more often, the ones the market cuts through forcefully just
before falling further (adverse selection). Lesson 2.4.8 models the queue and partial fills.

When is the vectorized approach enough? The practical question is whether tomorrow's position can be computed from
prices alone:

```mermaid
flowchart TB
  Q{Prices only?}
  Q -- yes --> I{Intraday order?}
  Q -- no --> E[Event-driven engine]
  I -- no --> V[Vectorized]
  I -- yes --> E
```

| Situation | Vectorized | Loop or events |
|---|---|---|
| Close signal, periodic rebalancing, proportional costs | Exact and fast | Just as exact, slower |
| Sweeps over thousands of parameters (lesson 2.4.3) | The natural tool | Too slow |
| Trailing stops, re-entries, no-trade bands | Approximation without warning | Required |
| Stops, targets and limits inside the bar | Depends on an arbitrary assumption | Required, with higher-resolution data |
| Whole contracts, margin, available capital | Approximation with fixed capital | Required |
| Going live with the same code | No | Yes (lesson 2.4.5) |

Common practice combines the two: vectorized to explore and discard ideas quickly, and an event-driven engine to
validate the one that survives with the exact rules. If both give the same result when the assumptions coincide, the
exploration was reliable; lesson 2.4.2 does that reconciliation with this same portfolio.

[^1]: The empirical evidence for time series momentum in real futures is, for example, in Moskowitz, Ooi
    and Pedersen (2012), linked in the resources. The parameters of the synthetic data are in the level 2 README.

## Exercises

1. Delay execution by one more day: decide with the close of $t$ and trade at the close of $t+1$ (`shift(2)`). How
   much does the portfolio's gross Sharpe fall? What does that say about the strategy's sensitivity to execution?

**Solution.**

```python
w = estrategia(px)
for k in (1, 2, 5):
    d = (w.shift(k) * r).sum(axis=1).loc["2001":]
    print(f"shift({k}): gross Sharpe {sharpe(d):.2f}")
```
```output
shift(1): gross Sharpe 1.00
shift(2): gross Sharpe 1.04
shift(5): gross Sharpe 0.96
```

Delaying one day costs nothing measurable (from 1.00 to 1.04, a difference within the noise) and five days, little
(0.96): the 252-session signal changes slowly and a few days of delay barely move it. That is good news for a slow
strategy, and a test worth always running: if the result collapses with one day of
delay, the strategy depends on an execution precision it will not have in live trading (or on a hidden look-ahead
bias).

2. Add to `backtest_vectorizado` a `max_bruto` parameter that scales down a day's weights proportionally when gross
   leverage exceeds that limit. Write a test for it and measure the effect of a limit of 4 times capital. Is it a
   path-dependent rule?

**Solution.**

```python
def recortar(pesos, max_bruto):
    bruto = pesos.abs().sum(axis=1)
    factor = (max_bruto / bruto).clip(upper=1.0).fillna(1.0)
    return pesos.mul(factor, axis=0)

def test_recorte():
    idx = pd.date_range("2024-01-01", periods=2, freq="B")
    w = pd.DataFrame({"A": [3.0, 1.0], "B": [-3.0, 1.0]}, idx)
    out = recortar(w, 4)
    assert np.allclose(out.abs().sum(axis=1), [4.0, 2.0])
test_recorte()

w4 = recortar(estrategia(px), 4)
lim = backtest_vectorizado(w4, r, coste_unit, rolls).loc["2001":]
recortados = (estrategia(px).abs().sum(axis=1) > 4).loc["2001":]
print(f"days scaled down: {recortados.mean():.0%}")
print(f"net Sharpe: no limit {sharpe(res['neto']):.2f}, "
      f"with limit {sharpe(lim['neto']):.2f}")
print(f"vol: {100 * lim['neto'].std() * np.sqrt(252):.1f} %")
```
```output
days scaled down: 77%
net Sharpe: no limit 0.80, with limit 0.75
vol: 13.2 %
```

With a limit of 4 times, 77% of the days are scaled down (without a limit, gross leverage exceeds 4 times in three out
of every four sessions), volatility drops to 13.2% and the net Sharpe, from 0.80 to 0.75: the limit removes exposure
precisely when more signals are aligned, and in this data those days paid off. It is not path-dependent: each day's
scaling only uses that day's weights, which only use past prices. That is why
it fits in an operation between matrices. It would be path-dependent if the limit were on available margin, because
margin depends on accumulated capital.

3. Classify these rules by whether they can be computed exactly with a vectorized backtest or need a loop: (a) 50- and
   200-session moving averages; (b) exit after 10 sessions in the position; (c) do not trade on the day of a macro
   release; (d) cut size in half after a 5% loss from the account's high; (e) re-enter only after a new crossover.

**Solution.**

(a) and (c) are vectorizable: the position comes only from prices and from a calendar known in advance. (b) looks
vectorizable with a `groupby` per trade, but the entry date depends on when the previous trade ended; if signals never
overlap it is exact, and if they do, it is not. (d) depends on the strategy's own accumulated capital: it needs a
loop. (e) is the KAPA trailing stop case: you have to remember whether there was an exit and since when.

## Self-assessment

1. In the futures portfolio, a 5-day signal gives a Sharpe of 0.29 with `shift(1)` and 17.33 without it. What happens without the lag?

   - A. The 5-day signal is better than it seemed
   - B. Costs are counted twice
   - C. The position on day t earns the return of t, which is part of the signal itself: it is information that did not exist when deciding
   - D. The Sharpe is annualized incorrectly

   *Answer C: The position on day t earns the return of t, which is part of the signal itself: it is information that did not exist when deciding.* The signal is computed with the close of t, which includes the return of t. If that same signal earns the return of t, the strategy bets already knowing the result. The shorter the window, the more the last day weighs in the signal and the more the result is inflated: with the 252-day window the error takes the Sharpe from 0.61 to 2.69; with the 5-day one, to 17.33.

2. In 2024, trading the wheat future costs 19.59 basis points of notional per side and the `IDB` index future, 0.17. Why more than a hundred times more?

   - A. Because wheat is traded more often than the index
   - B. Because wheat has more rolls
   - C. Because its volatility is higher
   - D. Because the cost per contract is fixed in dollars and the wheat contract is worth much less: the same cost weighs more per dollar of notional

   *Answer D: Because the cost per contract is fixed in dollars and the wheat contract is worth much less: the same cost weighs more per dollar of notional.* The unit cost is `cost per contract / (price × multiplier)`. In the synthetic data (2024 medians), a wheat contract is worth 13,900 USD and one tick plus commission is 27.25 USD; an `IDB` contract is worth 419,700 USD and pays 7.25. That is why small contracts (silver, corn, wheat) sit at the top of the cost table and the indices at the bottom.

3. What does the backtester's perturbation test check?

   - A. That changing prices from a given date on does not change any result before that date
   - B. That the backtest is fast
   - C. That the Sharpe is positive
   - D. That there are no missing values

   *Answer A: That changing prices from a given date on does not change any result before that date.* If a past result changes when the future is modified, the code uses later information somewhere (a missing `shift`, a normalization with the full sample, a centered `rolling`). It is the most direct way to catch look-ahead bias without having to read every line.

4. In KAPA, the vectorized trailing stop ends with capital of 2.05 and the loop with 1.53. What is the main cause of the difference in the second half of 2025?

   - A. Commissions
   - B. The vectorized version re-enters as soon as the price recovers, while the real rule waits for a new upward crossover, which does not come during the whole rebound
   - C. A rounding error
   - D. The loop uses 5-minute data

   *Answer B: The vectorized version re-enters as soon as the price recovers, while the real rule waits for a new upward crossover, which does not come during the whole rebound.* After the stop on September 12, 2025, KAPA rises from 422 to 534 without crossing its 20-session average from below again until December 9, so the real rule stays out for almost the whole rebound. The vectorized calculation has no memory of the exit: as soon as the close is back within 4% of the 20-day high, the position reappears.

5. A strategy buys at the open with a stop and a target at ±0.5%. With daily bars, 187 of 1,003 sessions touch both levels. Which result is defensible?

   - A. Neither of the two without higher-resolution data: the actual order, with 5-minute bars, gives +10.5%, between the −77.5% and +109.5% of the two assumptions
   - B. Assuming the target comes first, because the market is bullish
   - C. Assuming the stop comes first, because it is prudent
   - D. Either, because the difference is small

   *Answer A: Neither of the two without higher-resolution data: the actual order, with 5-minute bars, gives +10.5%, between the −77.5% and +109.5% of the two assumptions.* With daily bars it is unknown what happened first. The difference between the two assumptions is 187 percentage points, the whole result and more. Assuming the stop first is prudent, but here it turns an almost flat strategy into a ruinous one: the answer comes from intraday data or from an engine that uses it.

## Key points

- A vectorized backtest computes the portfolio as `weights.shift(1) × returns − costs`: fast, readable and exact as long as the position depends only on past data.
- The lag is not a detail: without it, the same momentum signal goes from a Sharpe of 0.61 to 2.69, and a 5-day one, to 17.33.
- Costs come from turnover (rolls included) times the cost per unit of notional; with daily rebalancing and vol targeting, the futures portfolio turns over 107 times a year and loses 2.95 points of return.
- A perturbation test (change the future and check the past does not change) detects look-ahead bias across the whole pipeline.
- Rules with memory (trailing stops, re-entries, no-trade bands, capital and whole contracts) and what happens inside a bar do not fit in an operation between matrices: they need a loop or an event-driven engine (lesson 2.4.2).
- The data is synthetic with planted momentum and carry: the results teach how to build and validate the engine, they are not evidence that the strategy works in real markets.

## Resources

### Research

- [Your first system in Python: moving-average crossover on SPY](https://pythonparatrading.com/en/first-system-moving-average-crossover/) · Research-0018 · 7 min

### Documentation

- [pandas: DataFrame.shift](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.shift.html) · pandas.pydata.org
- [pandas: DataFrame.ewm (exponential moving averages and standard deviations)](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.ewm.html) · pandas.pydata.org

### Books

- [Moskowitz, Ooi and Pedersen (2012): “Time series momentum”, Journal of Financial Economics 104(2), 228-250](https://doi.org/10.1016/j.jfineco.2011.11.003) · doi.org
- [Robert Carver: Advanced Futures Trading Strategies (author's page)](https://www.systematicmoney.org/advanced-futures) · systematicmoney.org

### Data

- [Level 2 data: synthetic continuous futures (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/futuros2_continuos.parquet) · Download · futuros2_continuos.parquet
- [Level 2 data: KAPA 5-minute bars (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/indice2_5min.parquet) · Download · indice2_5min.parquet

Previous lesson: [2.3.7 · Crypto-specific strategies: momentum, funding arbitrage and basis](https://pythonparatrading.com/en/python-trading-course/level-2/crypto-momentum-funding-basis-strategies/)

Next lesson: [2.4.2 · Event-driven backtest from scratch: loop, portfolio, orders and fills](https://pythonparatrading.com/en/python-trading-course/level-2/event-driven-backtest-from-scratch/)
