---
title: "2.2.2 Universe, signal, filter, entry, exit and sizing"
description: "A trend system on 24 futures built block by block, each block a function with tests, and what each one adds measured by ablation."
url: "https://pythonparatrading.com/en/python-trading-course/level-2/universe-signal-entry-exit-position-sizing/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-2/universe-signal-entry-exit-position-sizing.png"
---

# 2.2.2 · 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.

- Course: [Level 2: Systematic strategies and rigorous backtesting](https://pythonparatrading.com/en/python-trading-course/level-2/)
- Module: 2.2 · Anatomy of a trading system
- Duration: 70 min
- Prerequisites: [2.2.1 · Edge hypotheses: risk, behavior, structure and information](https://pythonparatrading.com/en/python-trading-course/level-2/trading-edge-hypothesis/), [1.4.6 · Continuous futures series: roll rules, difference adjustment and ratio adjustment](https://pythonparatrading.com/en/python-trading-course/level-1/continuous-futures-series-and-roll-adjustment/), [1.6.1 · Returns, volatility, rolling correlations and drawdown](https://pythonparatrading.com/en/python-trading-course/level-1/returns-volatility-correlation-and-drawdown/), [0.4.5 · Professional logging and testing with pytest](https://pythonparatrading.com/en/python-trading-course/level-0/python-logging-and-testing-with-pytest/)

## By the end of this lesson

- Break a system down into seven blocks (universe, signal, filter, entry, exit, sizing and risk management) with one responsibility each.
- Code each block as a pure function of past data and write tests that check its behavior and that it does not look into the future.
- Go from weights to whole contracts with multipliers, per-contract costs and roll costs, in a daily loop without look-ahead.
- Measure the contribution of each block with an ablation table and read it without mistaking noise for an improvement.

## 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
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 falling[^1]. 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.

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

The data are synthetic and the effect the system pursues is planted. This lesson's backtest checks that the code finds
what is there; it is not evidence that a trend system works in real markets.

</aside>

## 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).

```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")
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]]
```
```output
(6539, 24) 3380 rolls
mercado
IDE     7.25
IDB     7.25
AGD    39.75
MEB    52.25
dtype: float64
```

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

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

```python
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 * vl
```

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

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

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

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

```python
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)
```
```output
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_grande
```

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

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

```python
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)
```
```output
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: float64
```

Starting 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:

```python
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()
```
```output
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: float64
```

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

*Fig. — The system in one market: the 30-year bond (BOE) from the level 2 data, 2019–2025, adjusted price and the full system's contracts at each week's close.* Two panels. Top, the adjusted price of the 30-year bond rises from 99 in early 2019 to 163 in April 2021 and falls to 69 in 2025. Bottom, the contracts: a brief short in early 2019, long (blue) from August 2019 to February 2022, up to 308 contracts, and short (orange) from March 2022, highlighted, up to 852 contracts, with two brief exits in late 2024 and in 2025; the position grows with equity and as volatility falls.

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:

```python
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)
```
```output
                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.061
```

*Fig. — Ablation of the trend system: Sharpe and maximum drawdown when each block is removed, 2000–2025, with costs except in the “no costs” row.* Two horizontal bar panels with seven rows. Sharpe: full system 0.85; no filter 0.82; no hysteresis 0.87; no vol sizing 0.51; no brake 0.90; no costs 0.97; signal only 0.45. Maximum drawdown: full system −22.2%; no filter −21.0%; no hysteresis −24.5%; no vol sizing −23.4%; no brake −19.8%; no costs −20.0%; signal only −42.9%.

The 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).

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

The figures in the table come from synthetic data with planted momentum and from reference costs in the
specifications file. With real data, the costs of the less liquid markets, the gaps and the slippage in the rolls tend
to be larger, and the trend edge has had good and bad decades.

</aside>

## Exercises

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

```python
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)
```
```output
      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.911
```

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

2. Add a test that checks that the `backtest` loop 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.**

```python
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}")
```
```output
[ 0.17 -0.26 -0.09  0.04  0.07] mean -0.02
```

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

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

[^1]: Moskowitz, Ooi and Pedersen (2012), in the resources, document time series momentum in equity, currency,
commodity and bond futures.

## Self-assessment

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

   - 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
   - B. To make the backtest faster
   - C. Because the broker does not accept orders at the close
   - D. Because futures trade with a one-day delay

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

2. Entry requires a trend strength above 0.5 and the exit comes when it drops below 0. What does that difference (hysteresis) achieve?

   - A. More trades
   - B. That the system only trades long
   - C. That a signal oscillating around the threshold does not open and close the position every few days: less turnover and lower costs
   - D. Lower volatility

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

3. Why does sizing divide the target risk by the volatility of each market?

   - A. To buy more of the markets that rise the most
   - B. Because the clearing house requires it
   - C. To reduce commissions
   - 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

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

4. Removing a block raises the backtest's Sharpe from 0.85 to 0.87. What conclusion is reasonable?

   - A. Remove the block: the system improves
   - 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
   - C. That the block has a bug
   - D. That more blocks should be added

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

5. What does the universe block do with the index and bond futures if the account has USD 1 million instead of 20?

   - 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
   - B. Nothing: all markets are always traded
   - C. It trades them only short
   - D. It replaces them with commodities

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

## Resources

### Research

- [Volatility targeting](https://pythonparatrading.com/en/volatility-targeting-2/) · Research-0004 · 7 min
- [First system: moving average crossover](https://pythonparatrading.com/en/first-system-moving-average-crossover/) · Research-0018 · 7 min

### Documentation

- [pytest: documentation](https://docs.pytest.org/en/stable/) · docs.pytest.org
- [pandas: ewm (exponential means and standard deviations)](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.ewm.html) · pandas.pydata.org

### Books

- [Moskowitz, Ooi and Pedersen (2012): Time series momentum. Journal of Financial Economics 104(2), 228-250](https://doi.org/10.1016/j.jfineco.2011.11.003) · doi.org

### Data

- [Level 2 data: continuous futures (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/futuros2_continuos.parquet) · Download · futuros2_continuos.parquet
- [Level 2 data: futures specifications (CSV)](https://pythonparatrading.com/curso/datos/nivel-2/futuros2_especificaciones.csv) · Download · futuros2_especificaciones.csv

Previous lesson: [2.2.1 · Edge hypotheses: risk, behavior, structure and information](https://pythonparatrading.com/en/python-trading-course/level-2/trading-edge-hypothesis/)

Next lesson: [2.2.3 · Frequencies: intraday, swing and position trading, and the capacity of a strategy](https://pythonparatrading.com/en/python-trading-course/level-2/trading-frequency-and-strategy-capacity/)
