Lesson 2.2.2 · 70 min
Universe, signal, filter, entry, exit, sizing and risk management
A trading system is not a signal: it is a chain of decisions, each with its function, its parameters and its possible errors. This lesson builds a complete trend system on the level's 24 futures, block by block and with tests, and then removes the blocks one at a time to see what each one adds.
- Module
- 2.2 · Anatomy of a trading system
- Duration
- 70 min
- Prerequisites
- 2.2.1 · Edge hypotheses: risk, behavior, structure and information, 1.4.6 · Continuous futures series: roll rules, difference adjustment and ratio adjustment, 1.6.1 · Returns, volatility, rolling correlations and drawdown, 0.4.5 · Professional logging and testing with pytest
- 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
Seven blocks and one responsibility each
When people talk about “a strategy” they usually mean its signal: “buy when the price goes above its 200-session average”. But a signal does not say how much to buy, in which markets, when to ignore it, when to exit or what to do if the account loses 20%. All those decisions exist in any system; if they are not written down, someone makes them by hand, or the code makes them by default. The usual breakdown has seven blocks:
Mermaid source
flowchart TB U[Universe] --> S[Signal] S --> F[Filter] F --> E[Entry and exit] E --> Z[Sizing] Z --> G[Risk management] G --> O[Orders and costs] O -. capital .-> G
| Block | Question it answers | In this lesson's system |
|---|---|---|
| Universe | What can be traded today? | Futures with one year of history and a contract worth no more than 5% of the capital |
| Signal | Which way does the edge point? | Trend strength: 252-session return divided by its volatility |
| Filter | When should the signal be ignored? | Out if 20-session volatility doubles one-year volatility |
| Entry and exit | When to open and when to close? | Enter with strength > 0.5; exit when it crosses 0 again |
| Sizing | How much? | Same risk per market: 10% annual portfolio target |
| Risk management | What happens if it goes wrong? | Exposure halved at a 15% drawdown from the last year's high; full again on recovering to 7.5% |
| Orders and costs | What is actually traded? | Whole contracts at the next close, with a 10% no-trade band; bid-ask spread, commission and roll |
The return arrow is the only path dependency: risk management looks at the capital, and the capital depends on what was traded before. Everything else is computed with past prices and can be vectorized.
The edge the system pursues is time series momentum: markets that have risen over the last year tend to keep
rising, and those that have fallen, to keep falling1Note 1 · Moskowitz, Ooi and Pedersen (2012), in the resources, document time series momentum in equity, currency,
commodity and bond futures.. It is planted in the level's data: the expected return of
each future carries a term of 0.15·σ·sign(252-session return), a Sharpe of ≈ 0.15 per market, plus part of the
carry and, in the indexes and bonds, the drift of the script (the equity premium and the 2022 bond decline). The
level 2 README (section c) gives the benchmark: a portfolio of the 24 markets with inverse-volatility weights and the
sign of the 252 sessions, without costs, has a Sharpe of 0.98.
The data: prices, contracts and specifications
The system needs three things from each market: the adjusted continuous series (for returns and signals), the price of the contract held (for the notional and the number of contracts) and its specification (multiplier, tick and cost).
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")
ajustado = fut.pivot(index="fecha", columns="mercado",
values="cierre_ajustado")
cierre = fut.pivot(index="fecha", columns="mercado", values="cierre")
contrato = fut.pivot(index="fecha", columns="mercado",
values="contrato")
spec = spec.loc[ajustado.columns]
r = ajustado.pct_change()
roll = contrato.ne(contrato.shift()) & contrato.shift().notna()
# cost per contract and side: typical slippage plus commission
coste_contrato = (spec["coste_tipico_ticks"] * spec["valor_tick"]
+ spec["comision_usd"])
print(ajustado.shape, f"{roll.to_numpy().sum()} rolls")
coste_contrato.sort_values().iloc[[0, 1, -2, -1]](6539, 24) 3380 rolls
mercado
IDE 7.25
IDB 7.25
AGD 39.75
MEB 52.25
dtype: float64Returns come from the ratio-adjusted series, which preserves the contracts' percentage returns (lesson 1.4.6); the
number of contracts, from the price of the current contract. A roll is a session in which the contract changes: the
system closes the old one and opens the new one, and pays both sides.
The blocks, one by one
Each block is a function that takes tables of dates by markets and returns another one. None looks beyond the row it computes.
CAPITAL = 20e6
def universo(cierre, spec, capital=CAPITAL, max_nocional=0.05,
min_historia=260):
"""True where the market can be traded in that session."""
nocional = cierre * spec["multiplicador"]
historia = cierre.notna().cumsum() >= min_historia
return historia & (nocional <= max_nocional * capital)The universe is not the list of markets that exist, but that of those this account can trade. With USD 20 million, a contract worth more than a million cannot be adjusted without large jumps in risk: a position of 0.4 contracts is rounded to 0 or 1. In these data no contract gets that far, so with 20 million the limit leaves nobody out; with a smaller account, the same block changes the whole system (as shown below).
def senal(ajustado, ventana=252, span_vol=60):
"""Trend strength: log return over `ventana` sessions
divided by the expected volatility over that horizon."""
lr = np.log(ajustado).diff()
tendencia = np.log(ajustado / ajustado.shift(ventana))
vol = lr.ewm(span=span_vol, min_periods=span_vol).std()
return tendencia / (vol * np.sqrt(ventana))
def filtro_vol(r, corta=20, larga=250, umbral=2.0):
"""True except when recent volatility doubles one-year volatility."""
vc = r.rolling(corta).std()
vl = r.rolling(larga, min_periods=120).std()
return vc < umbral * vlThe signal is a kind of t statistic of the trend: with returns without drift and stable volatility it would be around 0 with a standard deviation of 1, so 0.5 is already a moderate trend. The filter expresses a separate hypothesis: when volatility spikes, the past trend says less about the future and costs and jumps weigh more. Whether that is true in these data or not, the ablation will tell.
def estados(fuerza, permitido, entrada=0.5, salida=0.0):
"""State machine per market: +1 long, -1 short, 0 flat.
Enters if |strength| > entrada; exits if it crosses `salida`."""
x = fuerza.where(permitido).to_numpy()
out = np.zeros(x.shape)
pos = np.zeros(x.shape[1])
for t in range(len(x)):
f = x[t]
pos = np.where(np.isnan(f), 0, pos)
pos = np.where((pos == 1) & (f < salida), 0, pos)
pos = np.where((pos == -1) & (f > -salida), 0, pos)
pos = np.where((pos == 0) & (f > entrada), 1, pos)
pos = np.where((pos == 0) & (f < -entrada), -1, pos)
out[t] = pos
return pd.DataFrame(out, index=fuerza.index,
columns=fuerza.columns)Entry and exit use different thresholds: that is hysteresis. With a single threshold, a signal oscillating around
it would open and close the position every few days. Where the filter or the universe do not allow trading, the
strength is NaN and the position is closed.
def pesos(estado, r, en_universo, vol_objetivo=0.10, span=60,
tope=4.0):
"""Notional weight per market: the same risk for each one."""
vol = r.ewm(span=span, min_periods=span).std() * np.sqrt(252)
n = en_universo.sum(axis=1).clip(lower=1)
presupuesto = vol_objetivo / np.sqrt(n)
w = estado.mul(presupuesto, axis=0) / vol
return w.clip(-tope, tope).fillna(0)
def freno(drawdown, escala, umbral=-0.15, vuelta=-0.075,
factor=0.5):
"""Exposure scale according to the portfolio's drawdown."""
if escala == 1.0 and drawdown < umbral:
return factor
if escala < 1.0 and drawdown > vuelta:
return 1.0
return escalaSizing spreads risk, not money. Each market's budget is the portfolio target divided by the square root of the number
of markets: if the positions were independent, total volatility would be the target's. They are not (equities move
together, and more so in crises: lesson 1.6.1), so realized volatility will be different; the backtest measures it.
The tope (cap) prevents a market with almost zero volatility from receiving an outsized weight.
The brake is the portfolio's risk management: at a 15% drawdown the exposure is halved, and it comes back in full when the drawdown recovers to 7.5%. There are two thresholds for the same reason as in the entry and exit. The drawdown is measured in the loop (below) against the capital's high over the last year, not the all-time high: with the all-time high, a portfolio at half exposure takes so long to recover it that the brake stays on most of the time.
Tests for each block
Each block has tests that pin down what it must do. In a repository they would go in tests/ and run with pytest
(lesson 0.4.5); here they are called from the cell itself. The most important one is the look-ahead test: if prices
are changed from a date onward, nothing computed before that date can change.
def test_senal_no_mira_al_futuro():
corte = ajustado.index[3000]
otro = ajustado.copy()
otro.loc[corte:] *= 1.5 # the future changes
a = senal(ajustado).loc[:corte].iloc[:-1]
b = senal(otro).loc[:corte].iloc[:-1]
pd.testing.assert_frame_equal(a, b)
def test_filtro_no_mira_al_futuro():
corte = r.index[3000]
otro = r.copy()
otro.loc[corte:] *= 3
a = filtro_vol(r).loc[:corte].iloc[:-1]
b = filtro_vol(otro).loc[:corte].iloc[:-1]
pd.testing.assert_frame_equal(a, b)
def test_estados_histeresis():
f = pd.DataFrame({"X": [0, 0.6, 0.3, -0.1, -0.6, -0.2, 0.1]})
ok = pd.DataFrame({"X": [True] * 7})
got = estados(f, ok)["X"].tolist()
assert got == [0, 1, 1, 0, -1, -1, 0], got
def test_estados_cierra_sin_permiso():
f = pd.DataFrame({"X": [0.6, 0.6, 0.6]})
ok = pd.DataFrame({"X": [True, False, True]})
assert estados(f, ok)["X"].tolist() == [1, 0, 1]
def test_pesos_dan_el_riesgo_pedido():
rng = np.random.default_rng(1)
rr = pd.DataFrame(rng.normal(0, 0.01, (2000, 4)),
columns=list("ABCD"))
est = pd.DataFrame(1.0, index=rr.index, columns=rr.columns)
w = pesos(est, rr, est.astype(bool))
vol_pos = (w.shift(1) * rr).std() * np.sqrt(252)
assert np.allclose(vol_pos, 0.10 / 2, rtol=0.1), vol_pos
def test_freno():
esc = 1.0
seq = []
for dd in [-0.05, -0.16, -0.12, -0.07, -0.20]:
esc = freno(dd, esc)
seq.append(esc)
assert seq == [1.0, 0.5, 0.5, 1.0, 0.5], seq
def test_universo_contrato_grande():
c = pd.DataFrame({"A": [100.0] * 300, "B": [100.0] * 300})
s = pd.DataFrame({"multiplicador": [10, 100_000]},
index=["A", "B"])
u = universo(c, s, capital=1e6)
assert u["A"].iloc[-1] and not u["B"].iloc[-1]
pruebas = {k: v for k, v in dict(globals()).items()
if k.startswith("test_")}
for nombre, prueba in pruebas.items():
prueba()
print("ok", nombre)ok test_senal_no_mira_al_futuro
ok test_filtro_no_mira_al_futuro
ok test_estados_histeresis
ok test_estados_cierra_sin_permiso
ok test_pesos_dan_el_riesgo_pedido
ok test_freno
ok test_universo_contrato_grandeThe look-ahead tests compare up to the session before the cut: the cut session itself changes, as it should, because
its return already includes the new price. An indicator with center=True, a bfill or a normalization with the
mean of the whole sample would make these tests fail.
From weight to contract: the daily loop
The blocks give, for each session, the notional weight the system would like to hold. The loop turns it into whole contracts and keeps track of the capital. The timing rules are the important ones:
- The weights computed with the close of session t − 1 are traded at the close of t: the signal is computed after the close and the order is sent for the next session.
- The number of contracts is
weight × capital / (price × multiplier), rounded. - A position is only adjusted if the change exceeds 10% of the target, or if it has to be closed: that is the no-trade band, which avoids paying costs for tiny daily sizing readjustments.
- Each change in contracts pays its cost per contract and side; each roll, two sides for the whole position.
- A session's result is
contracts × multiplier × previous price × return: the USD result of the position held at its start.
def backtest(w, capital=CAPITAL, costes=True, con_freno=True,
banda=0.10, memoria=252, registro=None):
mult = spec["multiplicador"].to_numpy()
cc = coste_contrato.to_numpy() * costes
W = w.shift(1).fillna(0).to_numpy() # decided at t-1
R = r.fillna(0).to_numpy()
P = cierre.ffill().to_numpy()
ROLL = roll.to_numpy()
n = np.zeros(W.shape[1])
eq, escala = capital, 1.0
historia = [capital]
filas = []
for t in range(1, len(W)):
pnl = np.nansum(n * mult * P[t - 1] * R[t])
c_roll = np.sum(np.abs(n) * ROLL[t] * 2 * cc)
eq += pnl - c_roll
pico = max(historia[-memoria:]) # last year's high
if con_freno:
escala = freno(eq / pico - 1, escala)
nocional = mult * np.nan_to_num(P[t], nan=np.inf)
objetivo = np.round(escala * W[t] * eq / nocional)
# no-trade band: only adjust if the change exceeds
# 10% of the target (or if the position must be closed)
mover = (np.abs(objetivo - n) > banda * np.abs(objetivo)) | (
objetivo == 0)
nuevo = np.where(mover, objetivo, n)
noc = np.where(np.isfinite(nocional), nocional, 0)
c_op = np.sum(np.abs(nuevo - n) * cc)
giro = np.sum(np.abs(nuevo - n) * noc)
eq -= c_op
historia.append(eq)
n = nuevo
if registro is not None:
registro.append(n)
filas.append((eq, c_roll + c_op, giro,
np.sum(np.abs(n) * noc),
np.count_nonzero(n), escala))
return pd.DataFrame(filas, index=w.index[1:], columns=[
"capital", "costes", "giro", "bruto", "posiciones",
"escala"])np.nan_to_num(P[t], nan=np.inf) gives a market with no price that day an infinite notional and zero contracts
(noc sets it to 0 for the sums). The giro column adds up the notional traded (for turnover) and bruto, the
notional of all positions (for leverage).
en_u = universo(cierre, spec).shift(1, fill_value=False)
fuerza = senal(ajustado)
permitido = filtro_vol(r) & en_u
estado = estados(fuerza, permitido)
w = pesos(estado, r, en_u)
registro = []
bt = backtest(w, registro=registro)
def metricas(bt, capital=CAPITAL):
eq = bt["capital"]
rd = eq.pct_change().dropna()
anos = len(rd) / 252
dd = eq / eq.cummax() - 1
return pd.Series({
"cagr": (eq.iloc[-1] / capital) ** (1 / anos) - 1,
"vol": rd.std() * np.sqrt(252),
"sharpe": rd.mean() / rd.std() * np.sqrt(252),
"max_dd": dd.min(),
"rotacion": bt["giro"].sum() / eq.mean() / anos,
"costes_pct": bt["costes"].sum() / eq.mean() / anos,
"apalanc": (bt["bruto"] / eq).mean(),
})
print(f"final capital: {bt['capital'].iloc[-1] / 1e6:.1f} M USD")
print(f"brake active: {(bt['escala'] < 1).mean():.1%} of sessions")
print(f"markets in the universe at the end: {en_u.iloc[-1].sum()}")
u_1m = universo(cierre, spec, capital=1e6).iloc[-1]
print(f" with 1 M USD of capital: {u_1m.sum()}; out:")
print(" ", " ".join(sorted(u_1m.index[~u_1m])))
fuera = (~filtro_vol(r) & en_u).sum().sum() / en_u.sum().sum()
print(f"volatility filter closed: {fuera:.1%} of the days")
metricas(bt).round(3)final capital: 277.0 M USD
brake active: 9.2% of sessions
markets in the universe at the end: 24
with 1 M USD of capital: 9; out:
AGC BOA BOB BOC BOD BOE DIA DIB DIC IDA IDB IDC IDD IDE MED
volatility filter closed: 0.5% of the days
cagr 0.107
vol 0.129
sharpe 0.852
max_dd -0.222
rotacion 15.210
costes_pct 0.014
apalanc 3.387
dtype: float64Starting from USD 20 million, the system ends with 277.0: 10.7% a year with 12.9% volatility, a Sharpe of 0.85 and a maximum drawdown of 22.2%. It pays 1.4% of the capital a year in costs, with a turnover of 15.2 times the capital and an average gross leverage of 3.4 (bonds, with low volatility, need a lot of notional to contribute their share of the risk). At the end all 24 markets are in the universe.
With USD 1 million, on the other hand, only 9 would remain: the 5% rule leaves out the five indexes, the five bonds, the three currencies, soybeans (AGC) and platinum (MED). They are exactly the low-volatility or large-contract markets, the ones sizing needs to spread the risk; the small account would have a different system, commodities only. The universe depends on the account, not only on the market.
Realized volatility (12.9%) exceeds the 10% target: sizing assumes independent positions and they are not. The brake is active in 9.2% of the sessions and the volatility filter only closes on 0.5% of the market days: with a threshold of twice the one-year volatility, it almost never triggers.
What the system does: positions and capital
The figure at the top compares the capital of the complete system with that of the signal alone (sign of the trend, same notional per market, no filter or brake) and shows the gross leverage below. In the first year there are no positions: the signal needs 252 sessions of history. Where money is made and where it is not:
contratos = pd.DataFrame(registro, index=w.index[1:],
columns=w.columns)
pnl = (contratos.shift(1) * spec["multiplicador"]
* cierre.ffill().shift(1) * r).loc[contratos.index]
por_sector = pnl.T.groupby(spec["sector"]).sum().T
anual = por_sector.resample("YE").sum() / 1e6
print((por_sector.sum() / 1e6).round(1).sort_values())
print("years with a positive result, by sector:")
(anual > 0).mean().round(2).sort_values()sector
energia -4.2
metales -2.6
divisas 25.3
renta_variable 70.9
agricolas 75.0
bonos 122.4
dtype: float64
years with a positive result, by sector:
sector
metales 0.42
divisas 0.58
renta_variable 0.62
energia 0.62
agricolas 0.69
bonos 0.69
dtype: float64The gross result (without costs) is not spread evenly: bonds contribute 122.4 million, agriculturals 75.0 and equities 70.9, while energy (−4.2) and metals (−2.6) lose over the full 25 years, even though the momentum term is planted in all markets. With a Sharpe of ≈ 0.15 per market, the standard error of a sector of three or four markets over 25 years is larger than its edge: a sector losing does not prove there is no trend there. No sector wins every year: metals, only 42%; agriculturals and bonds, 69%. A trend system lives off a few good years in each market, and diversification is what smooths the total curve.
The 30-year bond shows how the blocks fit together: the system is long from August 2019 while the price rises, exits in February 2022, when the decline planted in that year's bonds has already erased the trend, and goes short in March. It holds the short while the price falls, with two brief exits at the end of 2024 and in 2025, when the trend strength crosses 0 again. Positions grow with the capital and when the market's volatility drops: sizing asks for more contracts for the same risk.
Ablation: what each block adds
An ablation removes one block at a time, leaves everything else the same and compares. It does not say whether the system is good, but how much each metric changes because of each block:
def sistema(con_filtro=True, histeresis=True, sizing_vol=True,
con_freno=True, costes=True):
perm = (filtro_vol(r) if con_filtro else r.notna()) & en_u
est = estados(fuerza, perm, entrada=0.5 if histeresis else 0.0)
if sizing_vol:
ww = pesos(est, r, en_u)
else: # same notional in each market, scaled to ~10%
n = en_u.sum(axis=1).clip(lower=1)
vol_med = (r.std() * np.sqrt(252)).median()
ww = est.mul(0.10 / np.sqrt(n) / vol_med, axis=0)
return backtest(ww, costes=costes, con_freno=con_freno)
variantes = {
"full": {},
"no filter": {"con_filtro": False},
"no hysteresis": {"histeresis": False},
"no vol sizing": {"sizing_vol": False},
"no brake": {"con_freno": False},
"no costs": {"costes": False},
"signal only": {"con_filtro": False, "histeresis": False,
"sizing_vol": False, "con_freno": False},
}
resultados = {k: sistema(**v) for k, v in variantes.items()}
ablacion = pd.DataFrame({k: metricas(v)
for k, v in resultados.items()}).T
ablacion.round(3) cagr vol sharpe max_dd rotacion costes_pct apalanc
full 0.107 0.129 0.852 -0.222 15.210 0.014 3.387
no filter 0.103 0.130 0.817 -0.210 15.257 0.014 3.407
no hysteresis 0.114 0.134 0.872 -0.245 47.134 0.023 3.865
no vol sizing 0.052 0.113 0.505 -0.234 5.695 0.012 1.591
no brake 0.117 0.134 0.897 -0.198 15.104 0.014 3.544
no costs 0.123 0.129 0.966 -0.200 15.092 0.000 3.396
signal only 0.052 0.133 0.449 -0.429 22.673 0.022 2.061The table is read row by row against the first one:
- Volatility sizing: the block that adds the most. Without it, the Sharpe drops from 0.85 to 0.51 and the return, from 10.7% to 5.2% a year. With the same notional in each market, the volatile ones (gas, crude, silver) dominate the risk and bonds, the largest contributors in this system, barely count; the portfolio stops being diversified. Turnover drops (5.7 against 15.2) because the fixed notional is not readjusted with volatility.
- Hysteresis: less turnover and lower costs, not a higher Sharpe. Without it turnover triples (47.1 against 15.2)
and costs go from 1.4% to 2.3% a year, but the Sharpe rises from 0.85 to 0.87 and the drawdown worsens (24.5%). The
planted edge is
σ·sign(trend): it also pays when the trend is weak, and an entry threshold of 0.5 leaves those positions out. What it saves in costs it loses in signal; the block is justified by turnover (and by capacity, lesson 2.2.3), not by the Sharpe. - Brake: it does not do its job here. Without it, the Sharpe rises from 0.85 to 0.90 and the maximum drawdown drops from 22.2% to 19.8%. The brake only acts in 9.2% of the sessions, and when it acts it reduces exposure after losing: if the trend comes back before it is released, the system recovers at half exposure and the drawdown gets longer instead of shorter. A drawdown brake does not guarantee a smaller drawdown; it is a path-dependent rule, and in this sample the path does not favor it.
- Volatility filter: nothing that stands out from the noise. A Sharpe of 0.85 with it and 0.82 without it, a drawdown of 22.2% and 21.0%. It closes on 0.5% of the days: in these data it adds nothing. A block that does nothing is not harmless: it is one more parameter someone will be tempted to tune, and one more test in the count (lesson 2.2.4). The honest thing is to remove it or justify it with a hypothesis that can be tested outside this sample.
- Costs: 0.11 of Sharpe. Without costs the system returns 12.3% a year with a Sharpe of 0.97, almost the README's benchmark of 0.98. The difference with the complete system is what the bid-ask spread, commission and rolls take.
- Signal only: the idea without a system. With the bare signal (sign of the trend, equal notional, no filter, no brake or hysteresis), the Sharpe is 0.45 and the drawdown, 42.9%. The same planted edge, exploited without sizing, gives a product almost nobody would keep after losing close to half.
Two caveats when reading an ablation. The first: the standard error of a Sharpe with 25 years of data is around 0.2; the variants share almost all their trades and their differences have less noise than that, but differences of 0.01 to 0.05 (filter, hysteresis, brake) are not enough to decide by Sharpe. Those blocks are judged by what they do with drawdown and costs, which is their function, and here only hysteresis fulfills it. The second: each variant is a test. If twenty brake thresholds are tested and the best is kept, the Sharpe in the table is no longer an honest estimate (lessons 2.2.4 and 2.5.6).
Exercises
- Change the signal window from 252 to 63 sessions (one quarter) in the complete system. What happens to turnover, costs and the Sharpe? Relate it to the planted edge.
Solution
f63 = senal(ajustado, ventana=63)
bt63 = backtest(pesos(estados(f63, permitido), r, en_u))
pd.DataFrame({"252": metricas(bt), "63": metricas(bt63)}).T.round(3) cagr vol sharpe max_dd rotacion costes_pct apalanc
252 0.107 0.129 0.852 -0.222 15.210 0.014 3.387
63 0.040 0.120 0.387 -0.387 38.301 0.019 2.911With a one-quarter window, the signal moves away from the planted edge (the momentum term in the data uses the sign of the 252-session return) and changes its mind more often. Turnover goes from 15.2 to 38.3 times the capital, costs from 1.4% to 1.9% a year, the Sharpe drops from 0.85 to 0.39 and the maximum drawdown rises from 22.2% to 38.7%. In real data the “true” window is not known, and choosing it by a backtest's Sharpe is exactly the kind of decision lesson 2.2.4 requires recording.
- Add a test that checks that the
backtestloop does not make money with random weights in the absence of costs beyond what chance gives. Hint: with random weights independent of everything, the Sharpe of many repetitions should be centered on 0.
Solution
rng = np.random.default_rng(7)
sh = []
for _ in range(5):
signo = np.sign(rng.normal(size=w.shape))
wa = pd.DataFrame(signo, index=w.index,
columns=w.columns).where(w != 0, 0)
sh.append(metricas(backtest(wa * w.abs(), costes=False))["sharpe"])
print(np.round(sh, 2), f"mean {np.mean(sh):.2f}")[ 0.17 -0.26 -0.09 0.04 0.07] mean -0.02With random-sign weights of the same size as the system's, five repetitions give Sharpe ratios between −0.26 and
+0.17, with a mean of −0.02: around zero, within the standard error of about 0.2 for each Sharpe with 25 years. This
test catches engine bugs that create a result without a signal: a cost with the wrong sign, a misaligned price that
favors any position, capital reinvested incorrectly. It does not catch look-ahead in the signal (with random weights
there is no signal to anticipate); that is checked by the tests in the “Tests” cell. As an automated test, the seed
would be fixed and, for example, abs(np.mean(sh)) < 0.3 would be required.
- With a USD 1 million account the universe loses the indexes, the bonds and the currencies. Which block would you modify to get them back, and what would doing so cost?
Solution
The universe. Those markets are left out because a contract is worth more than 5% of the capital, so there are two paths: more capital (the threshold is relative) or smaller contracts (some real markets have mini contracts, at a higher cost per unit of notional). Simply raising the threshold does not work: the target position in a bond, which needs a lot of notional because of its low volatility, would still be a fraction of a contract, and rounding would turn a small bet into a huge one or into none. The cost of forcing it is a very concentrated risk in a few contracts; that of not doing it, a system of only 9 markets, all commodities, without the diversification bonds provide.
Footnotes
-
Moskowitz, Ooi and Pedersen (2012), in the resources, document time series momentum in equity, currency, commodity and bond futures. ↩
Each answer is folded under its question
Self-assessment
In the lesson's system, why are the weights traded at the close of session t computed with data up to the close of t − 1?
- ABecause the signal, the filter and the volatility of t are only known once t has closed; trading at that same close with them would mean using a price to decide the order that forms it
- BTo make the backtest faster
- CBecause the broker does not accept orders at the close
- DBecause futures trade with a one-day delay
Show answer
Answer A. Because the signal, the filter and the volatility of t are only known once t has closed; trading at that same close with them would mean using a price to decide the order that forms it
It is the rule of lesson 0.1.1 applied to a complete system: each decision uses information from before the moment it is executed. In practice, the signal is computed after the close and the order is sent for the next session.
Entry requires a trend strength above 0.5 and the exit comes when it drops below 0. What does that difference (hysteresis) achieve?
- AMore trades
- BThat the system only trades long
- CThat a signal oscillating around the threshold does not open and close the position every few days: less turnover and lower costs
- DLower volatility
Show answer
Answer C. That a signal oscillating around the threshold does not open and close the position every few days: less turnover and lower costs
With a single threshold, each crossing is a trade. With two, the position is held as long as the signal does not move away completely. In the ablation table, removing hysteresis raises the system's turnover.
Why does sizing divide the target risk by the volatility of each market?
- ATo buy more of the markets that rise the most
- BBecause the clearing house requires it
- CTo reduce commissions
- DSo that a 2-year bond with 2% volatility and natural gas with 50% contribute a similar risk; with equal notional weights, gas would dominate the portfolio
Show answer
Answer D. So that a 2-year bond with 2% volatility and natural gas with 50% contribute a similar risk; with equal notional weights, gas would dominate the portfolio
With equal notional, the portfolio's volatility is set by the most volatile markets. Equalizing risk spreads the bet across the 24 markets and stabilizes total volatility over time.
Removing a block raises the backtest's Sharpe from 0.85 to 0.87. What conclusion is reasonable?
- ARemove the block: the system improves
- BNone yet: with 25 years of data the standard error of the Sharpe is around 0.2, so a difference of 0.02 between two highly correlated variants is consistent with noise; drawdown, turnover and why the block should help also have to be looked at
- CThat the block has a bug
- DThat more blocks should be added
Show answer
Answer B. None yet: with 25 years of data the standard error of the Sharpe is around 0.2, so a difference of 0.02 between two highly correlated variants is consistent with noise; drawdown, turnover and why the block should help also have to be looked at
The variants in an ablation share almost all their trades, so their difference has less noise than two independent Sharpe ratios, but there is still noise, and each variant tested is one more test (lesson 2.2.4). A block is justified by its function (controlling risk, costs or capacity), not by the Sharpe alone.
What does the universe block do with the index and bond futures if the account has USD 1 million instead of 20?
- AIt leaves them out: a single contract is worth more than 5% of the capital (USD 50,000), so the system could not adjust its position without huge jumps in risk
- BNothing: all markets are always traded
- CIt trades them only short
- DIt replaces them with commodities
Show answer
Answer A. It leaves them out: a single contract is worth more than 5% of the capital (USD 50,000), so the system could not adjust its position without huge jumps in risk
The universe is not only which markets exist, but which ones this account can trade. With 20 million all 24 markets are in; with 1 million, only 9: the five indexes, the five bonds, the three currencies, soybeans and platinum are left out, since their contracts are worth more than USD 50,000.
Key points
- A complete system is a chain: universe, signal, filter, entry and exit, sizing and risk management, plus the conversion to orders with their costs. Each block has a single responsibility.
- Each block is a function of past data with tests: that it does not change if the future is altered, that the state machine respects the thresholds and that sizing gives the risk requested.
- A session's weights are traded at the close of the next one, in whole contracts, paying the bid-ask spread, the commission and both sides of each roll.
- The ablation table measures what each block adds: here volatility sizing adds the most (Sharpe from 0.51 to 0.85) and hysteresis cuts turnover by a factor of three; the filter and the brake improve nothing in this sample (without the brake, a Sharpe of 0.90 and a drawdown of 19.8% against 22.2%).
- The data are synthetic and time series momentum is planted: the system recovers it, which validates the code, not the strategy in real markets.