Lesson 2.4.1 · 70 min

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.

Fig. — Portfolio of 24 synthetic futures with 12-month momentum and volatility targeting, 2001–2025: gross and net-of-cost equity from the vectorised backtest
Level 2 lessons
  1. 2.1 · Statistics and econometrics for trading

    1. 2.1.1 Hypothesis tests, p-values, bootstrap and regression with robust standard errors
    2. 2.1.2 Stationarity: ADF, KPSS, differencing and fractional differencing
    3. 2.1.3 ARMA and ARIMA models: autocorrelation (ACF) and partial autocorrelation (PACF)
    4. 2.1.4 Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library
    5. 2.1.5 Cointegration: Engle-Granger, Johansen and the half-life of mean reversion
    6. 2.1.6 Kalman filter for dynamic hedge ratios
    7. 2.1.7 Regime detection: hidden Markov models and structural breaks
    8. 2.1.8 The multiple testing problem: Bonferroni, FDR and the “factor zoo”
  2. 2.2 · Anatomy of a trading system

    1. 2.2.1 Edge hypotheses: risk, behavior, structure and information
    2. 2.2.2 Universe, signal, filter, entry, exit, sizing and risk management
    3. 2.2.3 Frequencies: intraday, swing and position trading, and the capacity of a strategy
    4. 2.2.4 Research journal and experiment tracking against data snooping
  3. 2.3 · Strategy families

    1. 2.3.1 Trend following and momentum
    2. 2.3.2 Mean reversion: RSI and Bollinger, intraday reversal and gaps
    3. 2.3.3 Pairs trading and statistical arbitrage: distance, cointegration and Kalman
    4. 2.3.4 Carry in currencies, futures and bonds; seasonality and calendar effects
    5. 2.3.5 Factors (value, quality, low volatility, size) and event strategies
    6. 2.3.6 Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov)
    7. 2.3.7 Crypto-specific strategies: momentum, funding arbitrage and basis
  4. 2.4 · Backtesting: engines and paradigms

    1. 2.4.1 Vectorized backtest from scratch: matrices, lag, costs and limits
    2. 2.4.2 Event-driven backtest from scratch: loop, portfolio, orders and fills
    3. 2.4.3 vectorbt: massive parameter sweeps and portfolio simulation
    4. 2.4.4 backtesting.py and backtrader: two teaching engines and their status
    5. 2.4.5 NautilusTrader: architecture, data, strategies and the same code in backtest and live
    6. 2.4.6 LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation
    7. 2.4.7 Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing
    8. 2.4.8 Fill modeling: limit orders, queue, partial fills, gaps and stops
  5. 2.5 · Biases and validation

    1. 2.5.1 Backtest biases: look-ahead, survivorship, selection and narrative
    2. 2.5.2 In-sample and out-of-sample: anchored and rolling walk-forward
    3. 2.5.3 Combinatorial purged cross-validation for strategies
    4. 2.5.4 Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)
    5. 2.5.5 Monte Carlo: trade resampling, block bootstrap and signal permutations
    6. 2.5.6 Parameter sensitivity: plateaus versus peaks, robustness across assets and periods
    7. 2.5.7 Synthetic data for stress testing: GBM, GARCH and bootstrapping
    8. 2.5.8 “Honest backtest” checklist before moving to paper trading
  6. 2.6 · Performance metrics

    1. 2.6.1 CAGR, Sharpe, Sortino, Calmar, Omega, drawdown and Ulcer Index
    2. 2.6.2 Win rate, payoff, expectancy, alpha, beta, information ratio, turnover and capacity
    3. 2.6.3 Reports with QuantStats or pyfolio-reloaded and custom tearsheets

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 NN assets with weights wi,tw_{i,t} (notional divided by capital) decided at the close of tt, the next day's return is

Rt+1=∑i=1Nwi,t ri,t+1  −  ∑i=1Nci,t+1 ∣wi,t+1−wi,t∣,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 ri,t+1r_{i,t+1} is the asset's close-to-close return and ci,t+1c_{i,t+1} the cost per unit of traded notional. The whole backtest is that formula applied to all dates at once. The index tt of the weight and the t+1t+1 of the return are the important part: the weight is decided with what is known at the close of tt and earns what happens afterwards.

PricesSignal at tVolatility at tWeights at tshift 1Gross returnTurnoverCostsNet return
Mermaid source
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 markets1Note 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..

IN [01]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)
OUT [01]The 24 futures
(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 tt 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:

IN [02]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}")
OUT [02]Signal, lag and look-ahead
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.

Volatility-targeted weights and costs

A signal of ±1\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),

wi,t=si,t σ∗N  σ^i,t,w_{i,t} = s_{i,t}\,\frac{\sigma^{*}}{\sqrt{N}\;\hat\sigma_{i,t}},

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

IN [03]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)
OUT [03]Volatility-targeted weights
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.

IN [04]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]]
OUT [04]Cost per unit of notional
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.

IN [05]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 ∣wt−wt−1∣\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.

IN [06]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")
OUT [06]Three tests
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

IN [07]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)
OUT [07]The 24-futures portfolio
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:

IN [08]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)
OUT [08]Where does the cost go?
{'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:

IN [09]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}")
OUT [09]Rebalance every Friday
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.

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.

IN [10]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)
OUT [10]KAPA daily bars from the 5-minute bars
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:

IN [11]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}")
OUT [11]Vectorized trailing stop (naive)
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:

IN [12]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))
OUT [12]The same rule with a loop
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

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:

IN [13]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()}")
OUT [13]Stop and target in the same bar
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).

IN [14]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} %")
OUT [14]Three answers for the same strategy
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

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:

IN [15]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} %")
OUT [15]Whole contracts
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:

yesnonoyesPrices only?Intraday order?Event-driven engineVectorized
Mermaid source
flowchart TB
  Q{Prices only?}
  Q -- yes --> I{Intraday order?}
  Q -- no --> E[Event-driven engine]
  I -- no --> V[Vectorized]
  I -- yes --> E
SituationVectorizedLoop or events
Close signal, periodic rebalancing, proportional costsExact and fastJust as exact, slower
Sweeps over thousands of parameters (lesson 2.4.3)The natural toolToo slow
Trailing stops, re-entries, no-trade bandsApproximation without warningRequired
Stops, targets and limits inside the barDepends on an arbitrary assumptionRequired, with higher-resolution data
Whole contracts, margin, available capitalApproximation with fixed capitalRequired
Going live with the same codeNoYes (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.

Exercises

  1. Delay execution by one more day: decide with the close of tt and trade at the close of t+1t+1 (shift(2)). How much does the portfolio's gross Sharpe fall? What does that say about the strategy's sensitivity to execution?
Solution
IN [16]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}")
OUT [16]Execute at the next close
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).

  1. 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
IN [17]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} %")
OUT [17]Gross leverage limit
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.

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

Footnotes

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

Each answer is folded under its question

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?

    • AThe 5-day signal is better than it seemed
    • BCosts are counted twice
    • CThe 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
    • DThe Sharpe is annualized incorrectly
    Show answer

    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?

    • ABecause wheat is traded more often than the index
    • BBecause wheat has more rolls
    • CBecause its volatility is higher
    • DBecause 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
    Show answer

    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?

    • AThat changing prices from a given date on does not change any result before that date
    • BThat the backtest is fast
    • CThat the Sharpe is positive
    • DThat there are no missing values
    Show answer

    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?

    • ACommissions
    • BThe 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
    • CA rounding error
    • DThe loop uses 5-minute data
    Show answer

    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?

    • ANeither 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
    • BAssuming the target comes first, because the market is bullish
    • CAssuming the stop comes first, because it is prudent
    • DEither, because the difference is small
    Show answer

    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

Data