---
title: "2.2.2 Universo, señal, filtro, entrada, salida y sizing"
description: "Un sistema de tendencia en 24 futuros construido bloque a bloque, cada bloque una función con tests, y lo que aporta cada uno medido por ablación."
url: "https://pythonparatrading.com/curso-python-trading/nivel-2/universo-senal-filtro-entrada-salida-y-sizing/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-2/universo-senal-filtro-entrada-salida-y-sizing.png"
---

# 2.2.2 · Universo, señal, filtro, entrada, salida, sizing y gestión del riesgo

> Un sistema de trading no es una señal: es una cadena de decisiones, cada una con su función, sus parámetros y sus errores posibles. Esta lección construye un sistema de tendencia completo sobre los 24 futuros del nivel, bloque a bloque y con tests, y después quita los bloques uno a uno para ver qué aporta cada uno.

- Curso: [Nivel 2: Estrategias sistemáticas y backtesting riguroso](https://pythonparatrading.com/curso-python-trading/nivel-2/)
- Módulo: 2.2 · Anatomía de un sistema de trading
- Duración: 70 min
- Requisitos: [2.2.1 · Hipótesis de ventaja: riesgo, comportamiento, estructura e información](https://pythonparatrading.com/curso-python-trading/nivel-2/hipotesis-de-ventaja-edge/), [1.4.6 · Series continuas de futuros: reglas de roll, ajuste por diferencia y ajuste por ratio](https://pythonparatrading.com/curso-python-trading/nivel-1/series-continuas-de-futuros/), [1.6.1 · Rentabilidades, volatilidad, correlaciones móviles y drawdown](https://pythonparatrading.com/curso-python-trading/nivel-1/volatilidad-correlaciones-y-drawdown/), [0.4.5 · Logging profesional y testing con pytest](https://pythonparatrading.com/curso-python-trading/nivel-0/logging-y-testing-con-pytest/)

## Al terminar esta lección

- Descomponer un sistema en siete bloques (universo, señal, filtro, entrada, salida, sizing y gestión del riesgo) con una responsabilidad cada uno.
- Programar cada bloque como una función pura de los datos pasados y escribir tests que comprueben su comportamiento y que no miran al futuro.
- Pasar de pesos a contratos enteros con multiplicadores, costes por contrato y costes de roll, en un bucle diario sin anticipación.
- Medir la aportación de cada bloque con una tabla de ablación y leerla sin confundir una mejora con ruido.

## Siete bloques y una responsabilidad cada uno

Cuando se habla de «una estrategia» se suele hablar de su señal: «compra cuando el precio supera su media de 200
sesiones». Pero una señal no dice cuánto comprar, en qué mercados, cuándo no hacer caso, cuándo salir ni qué hacer si
la cuenta pierde un 20 %. Todas esas decisiones existen en cualquier sistema; si no se escriben, las toma alguien a
mano, o el código por omisión. La descomposición habitual tiene siete bloques:

```mermaid
flowchart TB
  U[Universo] --> S[Señal]
  S --> F[Filtro]
  F --> E[Entrada y salida]
  E --> Z[Sizing]
  Z --> G[Gestión riesgo]
  G --> O[Órdenes y costes]
  O -. capital .-> G
```

| Bloque | Pregunta que contesta | En el sistema de esta lección |
|---|---|---|
| Universo | ¿Qué se puede operar hoy? | Futuros con un año de historia y un contrato que no pese más del 5 % del capital |
| Señal | ¿Hacia dónde apunta la ventaja? | Fuerza de tendencia: rentabilidad de 252 sesiones dividida por su volatilidad |
| Filtro | ¿Cuándo no hacer caso a la señal? | Fuera si la volatilidad de 20 sesiones dobla la de un año |
| Entrada y salida | ¿Cuándo abrir y cuándo cerrar? | Entrar con fuerza > 0,5; salir cuando vuelve a cruzar 0 |
| Sizing | ¿Cuánto? | Mismo riesgo por mercado: 10 % anual de objetivo de cartera |
| Gestión del riesgo | ¿Qué pasa si va mal? | Exposición a la mitad con un drawdown del 15 % desde el máximo del último año; entera al volver al 7,5 % |
| Órdenes y costes | ¿Qué se negocia de verdad? | Contratos enteros al cierre siguiente, con una banda de no negociación del 10 %; horquilla, comisión y roll |

La flecha de vuelta es la única dependencia del camino: la gestión del riesgo mira el capital, y el capital depende
de lo que se negoció antes. Todo lo demás se calcula con precios pasados y se puede vectorizar.

La ventaja que persigue el sistema es el **momentum de series temporales**: los mercados que han subido en el último
año tienden a seguir subiendo, y los que han bajado, a seguir bajando[^1]. En los datos del nivel está plantada: el
rendimiento esperado de cada futuro lleva un término de `0,15·σ·signo(rendimiento de 252 sesiones)`, un Sharpe de
≈ 0,15 por mercado, más una parte del carry y, en los índices y los bonos, la deriva del guion (la prima de la renta
variable y la caída de los bonos en 2022). El README del nivel 2 (sección c) da la referencia: una cartera de los 24
mercados con pesos inversos a la volatilidad y el signo de las 252 sesiones, sin costes, tiene un Sharpe de 0,98.

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

Los datos son sintéticos y el efecto que persigue el sistema está plantado. El backtest de esta lección comprueba que
el código encuentra lo que hay; no es evidencia de que un sistema de tendencia funcione en los mercados reales.

</aside>

## Los datos: precios, contratos y especificaciones

El sistema necesita tres cosas de cada mercado: la serie continua ajustada (para las rentabilidades y las señales),
el precio del contrato que se tiene (para el nocional y el número de contratos) y su especificación (multiplicador,
tick y coste).

```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()

# coste por contrato y lado: deslizamiento típico más comisión
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
```

Las rentabilidades salen de la serie ajustada por ratio, que conserva las rentabilidades porcentuales de los contratos
(lección 1.4.6); el número de contratos, del precio del contrato vigente. Un `roll` es una sesión en que cambia el
contrato: el sistema cierra el viejo y abre el nuevo, y paga los dos lados.

## Los bloques, uno a uno

Cada bloque es una función que recibe tablas de fechas por mercados y devuelve otra. Ninguna mira más allá de la fila
que calcula.

```python
CAPITAL = 20e6

def universo(cierre, spec, capital=CAPITAL, max_nocional=0.05,
             min_historia=260):
    """True donde el mercado se puede operar esa sesión."""
    nocional = cierre * spec["multiplicador"]
    historia = cierre.notna().cumsum() >= min_historia
    return historia & (nocional <= max_nocional * capital)
```

El universo no es la lista de mercados que existen, sino la de los que **esta cuenta** puede operar. Con 20 millones
de USD, un contrato que vale más de un millón no se puede ajustar sin saltos grandes de riesgo: una posición de 0,4
contratos se redondea a 0 o a 1. En estos datos ningún contrato llega a tanto, así que con 20 millones el límite no
deja fuera a nadie; con una cuenta más pequeña, el mismo bloque cambia el sistema entero (se ve más abajo).

```python
def senal(ajustado, ventana=252, span_vol=60):
    """Fuerza de tendencia: rentabilidad logarítmica de `ventana`
    sesiones dividida por la volatilidad esperada en ese plazo."""
    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 salvo cuando la volatilidad reciente dobla la de un año."""
    vc = r.rolling(corta).std()
    vl = r.rolling(larga, min_periods=120).std()
    return vc < umbral * vl
```

La señal es una especie de estadístico t de la tendencia: con rentabilidades sin deriva y volatilidad estable valdría
en torno a 0 con desviación típica 1, así que un 0,5 ya es una tendencia moderada. El filtro expresa una hipótesis
aparte: cuando la volatilidad se dispara, la tendencia pasada dice menos del futuro y los costes y saltos pesan más.
Si eso es cierto o no en estos datos lo dirá la ablación.

```python
def estados(fuerza, permitido, entrada=0.5, salida=0.0):
    """Máquina de estados por mercado: +1 largo, -1 corto, 0 fuera.
    Entra si |fuerza| > entrada; sale si cruza `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)
```

La entrada y la salida usan umbrales distintos: es la **histéresis**. Con un único umbral, una señal que oscila
alrededor de él abriría y cerraría la posición cada pocos días. Donde el filtro o el universo no permiten operar, la
fuerza es `NaN` y la posición se cierra.

```python
def pesos(estado, r, en_universo, vol_objetivo=0.10, span=60,
          tope=4.0):
    """Peso nocional por mercado: el mismo riesgo para cada uno."""
    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):
    """Escala de la exposición según el drawdown de la cartera."""
    if escala == 1.0 and drawdown < umbral:
        return factor
    if escala < 1.0 and drawdown > vuelta:
        return 1.0
    return escala
```

El sizing reparte el riesgo, no el dinero. El presupuesto de cada mercado es el objetivo de la cartera dividido por la
raíz del número de mercados: si las posiciones fueran independientes, la volatilidad total sería la del objetivo.
No lo son (la renta variable se mueve junta, y más en las crisis: lección 1.6.1), así que la volatilidad realizada
será otra; el backtest la mide. El `tope` impide que un mercado de volatilidad casi nula reciba un peso desmedido.

El freno es la gestión del riesgo de la cartera: con un drawdown del 15 % la exposición baja a la mitad, y vuelve
entera cuando el drawdown se recupera hasta el 7,5 %. Son dos umbrales por la misma razón que en la entrada y la
salida. El drawdown se mide en el bucle (más abajo) respecto al máximo del capital en el último año, no al máximo
histórico: con el máximo histórico, una cartera a media exposición tarda tanto en recuperarlo que el freno se queda
puesto la mayor parte del tiempo.

## Tests de cada bloque

Cada bloque tiene tests que fijan lo que debe hacer. En un repositorio irían en `tests/` y se ejecutarían con `pytest`
(lección 0.4.5); aquí se llaman desde la propia celda. El más importante es el de anticipación: si se cambian los
precios a partir de una fecha, nada de lo calculado antes de esa fecha puede cambiar.

```python
def test_senal_no_mira_al_futuro():
    corte = ajustado.index[3000]
    otro = ajustado.copy()
    otro.loc[corte:] *= 1.5          # el futuro cambia
    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
```

Los tests de anticipación comparan hasta la sesión anterior al corte: la propia sesión del corte cambia, como debe,
porque su rentabilidad ya incluye el precio nuevo. Un indicador con `center=True`, un `bfill` o una normalización con
la media de toda la muestra harían fallar estos tests.

## Del peso al contrato: el bucle diario

Los bloques dan, para cada sesión, el peso nocional que el sistema querría tener. El bucle lo convierte en contratos
enteros y lleva la cuenta del capital. Las reglas de tiempo son las importantes:

- Los pesos calculados con el cierre de la sesión t − 1 se negocian **al cierre de t**: la señal se calcula después
  del cierre y la orden se envía para la sesión siguiente.
- El número de contratos es `peso × capital / (precio × multiplicador)`, redondeado.
- Solo se ajusta una posición si el cambio supera el 10 % de la objetivo, o si hay que cerrarla: es la **banda de no
  negociación**, que evita pagar costes por reajustes diminutos del sizing cada día.
- Cada cambio de contratos paga su coste por contrato y lado; cada roll, dos lados por la posición entera.
- El resultado de una sesión es `contratos × multiplicador × precio anterior × rentabilidad`: el resultado en USD de
  la posición que se tenía al empezarla.

```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()     # decidido en 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:])     # máximo del último año
        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)
        # banda de no negociación: solo se ajusta si el cambio
        # supera el 10 % del objetivo (o si hay que cerrar)
        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)` hace que un mercado sin precio ese día tenga nocional infinito y cero contratos
(`noc` lo deja en 0 para las sumas). La columna `giro` suma el nocional negociado (para la rotación) y `bruto`, el nocional de todas las posiciones (para el
apalancamiento).

```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"capital final: {bt['capital'].iloc[-1] / 1e6:.1f} M USD")
print(f"freno activo: {(bt['escala'] < 1).mean():.1%} de sesiones")
print(f"mercados en el universo al final: {en_u.iloc[-1].sum()}")
u_1m = universo(cierre, spec, capital=1e6).iloc[-1]
print(f"  con 1 M USD de capital: {u_1m.sum()}; fuera:")
print("   ", " ".join(sorted(u_1m.index[~u_1m])))
fuera = (~filtro_vol(r) & en_u).sum().sum() / en_u.sum().sum()
print(f"filtro de volatilidad cerrado: {fuera:.1%} de los días")
metricas(bt).round(3)
```
```output
capital final: 277.0 M USD
freno activo: 9.2% de sesiones
mercados en el universo al final: 24
  con 1 M USD de capital: 9; fuera:
    AGC BOA BOB BOC BOD BOE DIA DIB DIC IDA IDB IDC IDD IDE MED
filtro de volatilidad cerrado: 0.5% de los días
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
```

Con 20 millones de USD de partida, el sistema termina con 277,0: un 10,7 % anual con un 12,9 % de volatilidad, Sharpe
de 0,85 y un drawdown máximo del 22,2 %. Paga un 1,4 % del capital al año en costes, con una rotación de 15,2 veces
el capital y un apalancamiento bruto medio de 3,4 (los bonos, de volatilidad baja, necesitan mucho nocional para
aportar su parte del riesgo). Al final están los 24 mercados en el universo.

Con 1 millón de USD, en cambio, solo quedarían 9: la regla del 5 % deja fuera los cinco índices, los cinco bonos, las
tres divisas, la soja (AGC) y el platino (MED). Son justo los mercados de volatilidad baja o de contrato grande, los
que el sizing necesita para repartir el riesgo; la cuenta pequeña tendría otro sistema, solo de materias primas.
El universo depende de la cuenta, no solo del mercado.

La volatilidad realizada (12,9 %) supera el objetivo del 10 %: el sizing supone posiciones independientes y no lo son.
El freno está activo en el 9,2 % de las sesiones y el filtro de volatilidad solo se cierra en el 0,5 % de los días de
mercado: con un umbral de dos veces la volatilidad de un año, casi nunca salta.

## Qué hace el sistema: posiciones y capital

La figura de la cabecera compara el capital del sistema completo con el de la señal sola (signo de la tendencia,
mismo nocional por mercado, sin filtro ni freno) y enseña el apalancamiento bruto debajo. El primer año no hay
posiciones: la señal necesita 252 sesiones de historia. Dónde se gana y dónde no:

```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("años con resultado positivo, por 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
años con resultado positivo, por sector:
sector
metales           0.42
divisas           0.58
renta_variable    0.62
energia           0.62
agricolas         0.69
bonos             0.69
dtype: float64
```

El resultado bruto (sin costes) no se reparte por igual: los bonos aportan 122,4 millones, los agrícolas 75,0 y la
renta variable 70,9, mientras que la energía (−4,2) y los metales (−2,6) pierden en el total de 25 años, aunque el
término de momentum está plantado en todos los mercados. Con un Sharpe de ≈ 0,15 por mercado, el error típico de un
sector de tres o cuatro mercados en 25 años es mayor que su ventaja: que un sector pierda no prueba que ahí no haya
tendencia. Ningún sector gana todos los años: los metales, solo el 42 %; los agrícolas y los bonos, el 69 %. Un
sistema de tendencia vive de unos pocos años buenos en cada mercado, y la diversificación es lo que suaviza la curva
total.

*Fig. — El sistema en un mercado: bono a 30 años (BOE) de los datos del nivel 2, 2019–2025, precio ajustado y contratos del sistema completo al cierre de cada semana.* Dos paneles. Arriba, el precio ajustado del bono a 30 años sube de 99 a principios de 2019 a 163 en abril de 2021 y cae hasta 69 en 2025. Abajo, los contratos: un corto breve a principios de 2019, largos (azul) desde agosto de 2019 hasta febrero de 2022, con hasta 308 contratos, y cortos (naranja) desde marzo de 2022, destacado, hasta 852 contratos, con dos salidas breves a finales de 2024 y en 2025; la posición crece con el capital y cuando baja la volatilidad.

En el bono a 30 años se ve cómo encajan los bloques: el sistema está largo desde agosto de 2019 mientras el precio
sube, sale en febrero de 2022, cuando la caída plantada en los bonos de ese año ya ha borrado la tendencia, y entra
corto en marzo. Mantiene el corto mientras el precio baja, con dos salidas breves a finales de 2024 y en 2025, cuando
la fuerza de la tendencia vuelve a cruzar 0. Las posiciones crecen con el capital y cuando la volatilidad del mercado
baja: el sizing pide más contratos para el mismo riesgo.

## Ablación: qué aporta cada bloque

Una **ablación** quita un bloque cada vez, deja todo lo demás igual y compara. No dice si el sistema es bueno, sino
cuánto cambia cada métrica por cada bloque:

```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:  # mismo nocional en cada mercado, escalado a ~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 = {
    "completo": {},
    "sin filtro": {"con_filtro": False},
    "sin histéresis": {"histeresis": False},
    "sin sizing por vol": {"sizing_vol": False},
    "sin freno": {"con_freno": False},
    "sin costes": {"costes": False},
    "solo señal": {"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
completo            0.107  0.129   0.852  -0.222    15.210       0.014    3.387
sin filtro          0.103  0.130   0.817  -0.210    15.257       0.014    3.407
sin histéresis      0.114  0.134   0.872  -0.245    47.134       0.023    3.865
sin sizing por vol  0.052  0.113   0.505  -0.234     5.695       0.012    1.591
sin freno           0.117  0.134   0.897  -0.198    15.104       0.014    3.544
sin costes          0.123  0.129   0.966  -0.200    15.092       0.000    3.396
solo señal          0.052  0.133   0.449  -0.429    22.673       0.022    2.061
```

*Fig. — Ablación del sistema de tendencia: Sharpe y drawdown máximo al quitar cada bloque, 2000–2025, con costes salvo en la fila «sin costes».* Dos paneles de barras horizontales con siete filas. Sharpe: completo 0,85; sin filtro 0,82; sin histéresis 0,87; sin sizing vol 0,51; sin freno 0,90; sin costes 0,97; solo señal 0,45. Drawdown máximo: completo −22,2 %; sin filtro −21,0 %; sin histéresis −24,5 %; sin sizing vol −23,4 %; sin freno −19,8 %; sin costes −20,0 %; solo señal −42,9 %.

La tabla se lee fila a fila contra la primera:

- **Sizing por volatilidad: el bloque que más aporta.** Sin él, el Sharpe baja de 0,85 a 0,51 y la rentabilidad, del
  10,7 % al 5,2 % anual. Con el mismo nocional en cada mercado, los volátiles (gas, crudo, plata) dominan el riesgo y
  los bonos, los que más aportan en este sistema, casi no cuentan; la cartera deja de estar diversificada. La rotación
  baja (5,7 frente a 15,2) porque el nocional fijo no se reajusta con la volatilidad.
- **Histéresis: menos rotación y menos costes, no más Sharpe.** Sin ella la rotación se triplica (47,1 frente a 15,2)
  y los costes pasan del 1,4 % al 2,3 % anual, pero el Sharpe sube de 0,85 a 0,87 y el drawdown empeora (24,5 %). La
  ventaja plantada es `σ·signo(tendencia)`: paga también cuando la tendencia es débil, y un umbral de entrada de 0,5
  deja fuera esas posiciones. Lo que ahorra en costes lo pierde en señal; el bloque se justifica por la rotación (y
  por la capacidad, lección 2.2.3), no por el Sharpe.
- **Freno: aquí no cumple su función.** Sin él, el Sharpe sube de 0,85 a 0,90 y el drawdown máximo baja del 22,2 % al
  19,8 %. El freno solo actúa en el 9,2 % de las sesiones, y cuando actúa reduce la exposición después de perder: si
  la tendencia vuelve antes de que se suelte, el sistema recupera a media exposición y el drawdown se alarga en vez
  de acortarse. Un freno por drawdown no garantiza un drawdown menor; es una regla que depende del camino, y en esta
  muestra el camino no le favorece.
- **Filtro de volatilidad: nada que se distinga del ruido.** Sharpe de 0,85 con él y de 0,82 sin él, drawdown del
  22,2 % y del 21,0 %. Se cierra en el 0,5 % de los días: en estos datos no aporta. Un bloque que no hace nada no es
  inocuo: es un parámetro más que alguien tendrá la tentación de ajustar, y una prueba más en la cuenta (lección
  2.2.4). Lo honesto es quitarlo o justificarlo con una hipótesis que se pueda contrastar fuera de esta muestra.
- **Costes: 0,11 de Sharpe.** Sin costes el sistema rinde un 12,3 % anual con Sharpe de 0,97, casi el 0,98 de
  referencia del README. La diferencia con el completo es lo que se llevan horquilla, comisión y rolls.
- **Solo señal: la idea sin sistema.** Con la señal desnuda (signo de la tendencia, nocional igual, sin filtro, sin
  freno ni histéresis), el Sharpe es de 0,45 y el drawdown, del 42,9 %. La misma ventaja plantada, explotada sin el
  sizing, da un producto que casi nadie mantendría tras perder cerca de la mitad.

Dos cautelas al leer una ablación. La primera: el error típico de un Sharpe con 25 años de datos ronda 0,2; las
variantes comparten casi todas las operaciones y sus diferencias tienen menos ruido que eso, pero diferencias de 0,01
a 0,05 (filtro, histéresis, freno) no bastan para decidir por el Sharpe. Esos bloques se juzgan por lo que hacen con
el drawdown y los costes, que es su función, y aquí solo la histéresis la cumple. La segunda: cada variante es una prueba. Si se prueban veinte
umbrales del freno y se queda el mejor, el Sharpe de la tabla ya no es una estimación honesta (lecciones 2.2.4 y
2.5.6).

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

Las cifras de la tabla salen de datos sintéticos con un momentum plantado y de costes de referencia del fichero de
especificaciones. Con datos reales, los costes de los mercados menos líquidos, los huecos y el deslizamiento en los
rolls suelen ser mayores, y la ventaja de tendencia ha tenido décadas buenas y malas.

</aside>

## Ejercicios

1. Cambia la ventana de la señal de 252 a 63 sesiones (un trimestre) en el sistema completo. ¿Qué pasa con la rotación,
   los costes y el Sharpe? Relaciónalo con la ventaja plantada.

**Solución.**

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

Con una ventana de un trimestre, la señal se aleja de la ventaja plantada (el término de momentum de los datos usa el
signo de la rentabilidad de 252 sesiones) y cambia de opinión más a menudo. La rotación pasa de 15,2 a 38,3 veces el
capital, los costes del 1,4 % al 1,9 % anual, el Sharpe baja de 0,85 a 0,39 y el drawdown máximo sube del 22,2 % al
38,7 %. En datos reales no se conoce la ventana
«verdadera», y elegirla por el Sharpe de un backtest es justo la clase de decisión que la lección 2.2.4 obliga a
registrar.

2. Añade un test que compruebe que el bucle `backtest` no gana dinero con pesos aleatorios en ausencia de costes más
   allá de lo que da el azar. Pista: con pesos aleatorios independientes de todo, el Sharpe de muchas repeticiones debe
   centrarse en 0.

**Solución.**

```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"media {np.mean(sh):.2f}")
```
```output
[ 0.17 -0.26 -0.09  0.04  0.07] media -0.02
```

Con pesos de signo aleatorio y el mismo tamaño que los del sistema, cinco repeticiones dan Sharpe entre −0,26 y
+0,17, con media de −0,02: alrededor de cero, dentro del error típico de unos 0,2 de cada Sharpe con 25 años. Este
test detecta errores del motor que crean resultado sin señal: un coste con el signo cambiado, un precio desalineado
que favorezca a cualquier posición, un capital que se reinvierte mal. No detecta la anticipación de la señal (con
pesos aleatorios no hay señal que anticipar); eso lo comprueban los tests de la celda «Tests». Como test automático,
se fijaría la semilla y se exigiría, por ejemplo, `abs(np.mean(sh)) < 0.3`.

3. Con una cuenta de 1 millón de USD el universo pierde los índices, los bonos y las divisas. ¿Qué bloque
   modificarías para recuperarlos, y qué coste tendría hacerlo?

**Solución.**

El universo. Esos mercados quedan fuera porque un contrato vale más del 5 % del capital, así que hay dos caminos: más
capital (el umbral es relativo) o contratos más pequeños (algunos mercados reales tienen minicontratos, con más coste
por unidad de nocional). Subir el umbral sin más no sirve: la posición objetivo de un bono, que necesita mucho nocional
por su volatilidad baja, seguiría siendo una fracción de contrato, y el redondeo convertiría una apuesta pequeña en una
enorme o en ninguna. El coste de forzarlo es un riesgo muy concentrado en unos pocos contratos; el de no hacerlo, un
sistema de solo 9 mercados, todos de materias primas, sin la diversificación que dan los bonos.

[^1]: Moskowitz, Ooi y Pedersen (2012), en los recursos, documentan el momentum de series temporales en futuros de
renta variable, divisas, materias primas y bonos.

## Autoevaluación

1. En el sistema de la lección, ¿por qué los pesos que se negocian al cierre de la sesión t se calculan con datos hasta el cierre de t − 1?

   - A. Para que el backtest sea más rápido
   - B. Porque la señal, el filtro y la volatilidad de t solo se conocen cuando ya ha cerrado t; operar a ese mismo cierre con ellos sería usar un precio para decidir la orden que lo forma
   - C. Porque el broker no acepta órdenes al cierre
   - D. Porque los futuros cotizan con un día de retraso

   *Respuesta B: Porque la señal, el filtro y la volatilidad de t solo se conocen cuando ya ha cerrado t; operar a ese mismo cierre con ellos sería usar un precio para decidir la orden que lo forma.* Es la regla de la lección 0.1.1 aplicada a un sistema completo: cada decisión usa información anterior al momento en que se ejecuta. En la práctica, la señal se calcula tras el cierre y la orden se envía para la sesión siguiente.

2. La entrada exige una fuerza de tendencia mayor que 0,5 y la salida llega cuando baja de 0. ¿Qué consigue esa diferencia (histéresis)?

   - A. Más operaciones
   - B. Que una señal que oscila alrededor del umbral no abra y cierre la posición cada pocos días: menos rotación y menos costes
   - C. Que el sistema solo opere a largo
   - D. Que la volatilidad sea menor

   *Respuesta B: Que una señal que oscila alrededor del umbral no abra y cierre la posición cada pocos días: menos rotación y menos costes.* Con un único umbral, cada cruce es una operación. Con dos, la posición se mantiene mientras la señal no se aleje del todo. En la tabla de ablación, quitar la histéresis sube la rotación del sistema.

3. ¿Por qué el sizing divide el riesgo objetivo entre la volatilidad de cada mercado?

   - A. Para comprar más de los mercados que más suben
   - B. Para que un bono a 2 años con un 2 % de volatilidad y un gas natural con un 50 % aporten un riesgo parecido; con pesos iguales en nocional, el gas dominaría la cartera
   - C. Porque lo exige la cámara de compensación
   - D. Para reducir las comisiones

   *Respuesta B: Para que un bono a 2 años con un 2 % de volatilidad y un gas natural con un 50 % aporten un riesgo parecido; con pesos iguales en nocional, el gas dominaría la cartera.* Con nocional igual, la volatilidad de la cartera la ponen los mercados más volátiles. Igualar el riesgo reparte la apuesta entre los 24 mercados y estabiliza la volatilidad total en el tiempo.

4. Quitar un bloque sube el Sharpe del backtest de 0,85 a 0,87. ¿Qué conclusión es razonable?

   - A. Quitar el bloque: el sistema mejora
   - B. Ninguna todavía: con 25 años de datos el error típico del Sharpe ronda 0,2, así que una diferencia de 0,02 entre dos variantes muy correladas es compatible con el ruido; hay que mirar también drawdown, rotación y por qué debería ayudar el bloque
   - C. Que el bloque tiene un error
   - D. Que hay que añadir más bloques

   *Respuesta B: Ninguna todavía: con 25 años de datos el error típico del Sharpe ronda 0,2, así que una diferencia de 0,02 entre dos variantes muy correladas es compatible con el ruido; hay que mirar también drawdown, rotación y por qué debería ayudar el bloque.* Las variantes de una ablación comparten casi todas las operaciones, así que su diferencia tiene menos ruido que dos Sharpe independientes, pero sigue habiendo ruido, y cada variante probada es una prueba más (lección 2.2.4). Un bloque se justifica por su función (controlar riesgo, costes o capacidad), no solo por el Sharpe.

5. ¿Qué hace el bloque de universo con los futuros de índices y de bonos si la cuenta tiene 1 millón de USD en lugar de 20?

   - A. Nada: todos los mercados se operan siempre
   - B. Los deja fuera: un solo contrato vale más del 5 % del capital (50.000 USD), así que el sistema no podría ajustar su posición sin saltos enormes de riesgo
   - C. Los opera solo a corto
   - D. Los sustituye por materias primas

   *Respuesta B: Los deja fuera: un solo contrato vale más del 5 % del capital (50.000 USD), así que el sistema no podría ajustar su posición sin saltos enormes de riesgo.* El universo no es solo qué mercados existen, sino cuáles puede operar esta cuenta. Con 20 millones entran los 24 mercados; con 1 millón, solo 9: quedan fuera los cinco índices, los cinco bonos, las tres divisas, la soja y el platino, cuyos contratos valen más de 50.000 USD.

## Ideas clave

- Un sistema completo es una cadena: universo, señal, filtro, entrada y salida, sizing y gestión del riesgo, más la conversión a órdenes con sus costes. Cada bloque tiene una sola responsabilidad.
- Cada bloque es una función de datos pasados con tests: que no cambia si se altera el futuro, que la máquina de estados respeta los umbrales y que el sizing da el riesgo pedido.
- Los pesos de una sesión se negocian al cierre de la siguiente, en contratos enteros, pagando horquilla, comisión y los dos lados de cada roll.
- La tabla de ablación mide qué aporta cada bloque: aquí el sizing por volatilidad es el que más aporta (Sharpe de 0,51 a 0,85) y la histéresis divide por tres la rotación; el filtro y el freno no mejoran nada en esta muestra (sin freno, Sharpe de 0,90 y drawdown del 19,8 % frente al 22,2 %).
- Los datos son sintéticos y el momentum de series temporales está plantado: el sistema lo recupera, lo que valida el código, no la estrategia en mercados reales.

## Recursos

### Research

- [Volatility targeting](https://pythonparatrading.com/volatility-targeting/) · Research-0004 · 8 min
- [Primer sistema: cruce de medias](https://pythonparatrading.com/primer-sistema-cruce-de-medias/) · Research-0018 · 7 min

### Documentación

- [pytest: documentación](https://docs.pytest.org/en/stable/) · docs.pytest.org
- [pandas: ewm (medias y desviaciones exponenciales)](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.ewm.html) · pandas.pydata.org

### Libros

- [Moskowitz, Ooi y 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

### Datos

- [Datos del nivel 2: futuros continuos (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/futuros2_continuos.parquet) · Descarga · futuros2_continuos.parquet
- [Datos del nivel 2: especificaciones de los futuros (CSV)](https://pythonparatrading.com/curso/datos/nivel-2/futuros2_especificaciones.csv) · Descarga · futuros2_especificaciones.csv

Lección anterior: [2.2.1 · Hipótesis de ventaja: riesgo, comportamiento, estructura e información](https://pythonparatrading.com/curso-python-trading/nivel-2/hipotesis-de-ventaja-edge/)

Lección siguiente: [2.2.3 · Frecuencias: intradía, swing y posicional, y la capacidad de una estrategia](https://pythonparatrading.com/curso-python-trading/nivel-2/frecuencias-y-capacidad-de-una-estrategia/)
