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.
- Module
- 2.4 · Backtesting: engines and paradigms
- Duration
- 70 min
- Prerequisites
- 0.5.1 · NumPy 2: arrays, broadcasting, vectorization, linear algebra and random generators, 0.5.3 · Selection, filtering, groupby, merge, pivot, resample and moving windows, 1.4.6 · Continuous futures series: roll rules, difference adjustment and ratio adjustment, 1.6.1 · Returns, volatility, rolling correlations and drawdown
- Author
- Jesús Cuesta
- Updated
Level 2 lessons
2.1 · Statistics and econometrics for trading
- 2.1.1 Hypothesis tests, p-values, bootstrap and regression with robust standard errors
- 2.1.2 Stationarity: ADF, KPSS, differencing and fractional differencing
- 2.1.3 ARMA and ARIMA models: autocorrelation (ACF) and partial autocorrelation (PACF)
- 2.1.4 Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library
- 2.1.5 Cointegration: Engle-Granger, Johansen and the half-life of mean reversion
- 2.1.6 Kalman filter for dynamic hedge ratios
- 2.1.7 Regime detection: hidden Markov models and structural breaks
- 2.1.8 The multiple testing problem: Bonferroni, FDR and the “factor zoo”
2.2 · Anatomy of a trading system
2.3 · Strategy families
- 2.3.1 Trend following and momentum
- 2.3.2 Mean reversion: RSI and Bollinger, intraday reversal and gaps
- 2.3.3 Pairs trading and statistical arbitrage: distance, cointegration and Kalman
- 2.3.4 Carry in currencies, futures and bonds; seasonality and calendar effects
- 2.3.5 Factors (value, quality, low volatility, size) and event strategies
- 2.3.6 Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov)
- 2.3.7 Crypto-specific strategies: momentum, funding arbitrage and basis
2.4 · Backtesting: engines and paradigms
- 2.4.1 Vectorized backtest from scratch: matrices, lag, costs and limits
- 2.4.2 Event-driven backtest from scratch: loop, portfolio, orders and fills
- 2.4.3 vectorbt: massive parameter sweeps and portfolio simulation
- 2.4.4 backtesting.py and backtrader: two teaching engines and their status
- 2.4.5 NautilusTrader: architecture, data, strategies and the same code in backtest and live
- 2.4.6 LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation
- 2.4.7 Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing
- 2.4.8 Fill modeling: limit orders, queue, partial fills, gaps and stops
2.5 · Biases and validation
- 2.5.1 Backtest biases: look-ahead, survivorship, selection and narrative
- 2.5.2 In-sample and out-of-sample: anchored and rolling walk-forward
- 2.5.3 Combinatorial purged cross-validation for strategies
- 2.5.4 Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)
- 2.5.5 Monte Carlo: trade resampling, block bootstrap and signal permutations
- 2.5.6 Parameter sensitivity: plateaus versus peaks, robustness across assets and periods
- 2.5.7 Synthetic data for stress testing: GBM, GARCH and bootstrapping
- 2.5.8 “Honest backtest” checklist before moving to paper trading
2.6 · Performance metrics
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 assets with weights (notional divided by capital) decided at the close of , the next day's return is
where is the asset's close-to-close return and the cost per unit of traded notional. The whole backtest is that formula applied to all dates at once. The index of the weight and the of the return are the important part: the weight is decided with what is known at the close of and earns what happens afterwards.
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..
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)(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.0039Three 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 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:
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}")with lag: Sharpe 0.61
without lag: Sharpe 2.69
5-day signal, with lag: 0.29
5-day signal, without lag: 17.33With 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 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),
with the signal, the portfolio target (here 10% a year), the number of markets and the volatility estimated with data up to (60-session EWMA, lesson 1.6.1). Dividing by would hit the target exactly if the markets were independent; they are not, and that will show in the result.
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)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.197On 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.
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]]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.59The 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.
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]) / volTwo simplifications in plain sight. Turnover is measured as , 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.
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")3 tests passedThe 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
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)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.08It 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:
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){'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.24More 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:
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}")daily turnover 107.1 cost 2.95 % net Sharpe 0.80
weekly turnover 70.9 cost 2.05 % net Sharpe 0.95Turnover 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.
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)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.76The 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:
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}")entries: 52
final capital: 2.055It 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:
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))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.45Three 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:
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()}")sessions: 1003; touch both levels: 187
target first (5 min): 88
stop first (5 min): 94
both in the same 5-min bar: 5In 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).
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} %")assuming the stop first: -77.5 %
with the 5-minute bars: 10.5 %
assuming the target first: 109.5 %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:
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} %")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 source
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.
Exercises
- Delay execution by one more day: decide with the close of and trade at the close of (
shift(2)). How much does the portfolio's gross Sharpe fall? What does that say about the strategy's sensitivity to execution?
Solution
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}")shift(1): gross Sharpe 1.00
shift(2): gross Sharpe 1.04
shift(5): gross Sharpe 0.96Delaying 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).
- Add to
backtest_vectorizadoamax_brutoparameter 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
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} %")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.
- 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
-
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
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.
In 2024, trading the wheat future costs 19.59 basis points of notional per side and the
IDBindex 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; anIDBcontract 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.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 centeredrolling). It is the most direct way to catch look-ahead bias without having to read every line.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.
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.