Lección 2.2.2 · 70 min
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.
- 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, 1.4.6 · Series continuas de futuros: reglas de roll, ajuste por diferencia y ajuste por ratio, 1.6.1 · Rentabilidades, volatilidad, correlaciones móviles y drawdown, 0.4.5 · Logging profesional y testing con pytest
Lecciones del nivel 2
2.1 · Estadística y econometría para trading
- 2.1.1 Contrastes de hipótesis, p-valores, bootstrap y regresión con errores robustos
- 2.1.2 Estacionariedad: ADF, KPSS, diferenciación y diferenciación fraccional
- 2.1.3 Modelos ARMA y ARIMA: autocorrelación simple (ACF) y parcial (PACF)
- 2.1.4 Modelos de volatilidad: ARCH, GARCH, EGARCH y GJR con la librería arch
- 2.1.5 Cointegración: Engle-Granger, Johansen y vida media de la reversión
- 2.1.6 Filtro de Kalman para hedge ratios dinámicos
- 2.1.7 Detección de regímenes: modelos de Markov ocultos y cambios estructurales
- 2.1.8 El problema de las pruebas múltiples: Bonferroni, FDR y el «factor zoo»
2.2 · Anatomía de un sistema de trading
- 2.2.1 Hipótesis de ventaja: riesgo, comportamiento, estructura e información
- 2.2.2 Universo, señal, filtro, entrada, salida, sizing y gestión del riesgo
- 2.2.3 Frecuencias: intradía, swing y posicional, y la capacidad de una estrategia
- 2.2.4 Diario de investigación y registro de experimentos contra el data snooping
2.3 · Familias de estrategias
- 2.3.1 Seguimiento de tendencia y momentum
- 2.3.2 Reversión a la media: RSI y Bollinger, reversión intradía y huecos
- 2.3.3 Pairs trading y arbitraje estadístico: distancia, cointegración y Kalman
- 2.3.4 Carry en divisas, futuros y bonos; estacionalidad y efectos calendario
- 2.3.5 Factores (value, calidad, baja volatilidad, tamaño) y estrategias de eventos
- 2.3.6 Introducción al market making: inventario, spread y selección adversa (Avellaneda-Stoikov)
- 2.3.7 Estrategias específicas de cripto: momentum, arbitraje de funding y basis
2.4 · Backtesting: motores y paradigmas
- 2.4.1 Backtest vectorizado desde cero: matrices, desfase, costes y límites
- 2.4.2 Backtest por eventos desde cero: bucle, cartera, órdenes y fills
- 2.4.3 vectorbt: barridos masivos de parámetros y simulación de carteras
- 2.4.4 backtesting.py y backtrader: dos motores didácticos y su estado
- 2.4.5 NautilusTrader: arquitectura, datos, estrategias y el mismo código en backtest y en real
- 2.4.6 LEAN/QuantConnect y Zipline Reloaded; bt para asignación de activos
- 2.4.7 Modelado de costes: comisiones, horquilla, impacto, financiación y préstamo de títulos
- 2.4.8 Modelado de fills: órdenes limitadas, cola, fills parciales, huecos y stops
2.5 · Sesgos y validación
- 2.5.1 Sesgos de backtest: anticipación, supervivencia, selección y narrativa
- 2.5.2 In-sample y out-of-sample: walk-forward anclado y móvil
- 2.5.3 Validación cruzada combinatoria purgada para estrategias
- 2.5.4 Deflated Sharpe Ratio y Probability of Backtest Overfitting (PBO)
- 2.5.5 Monte Carlo: remuestreo de trades, bootstrap de bloques y permutaciones de señales
- 2.5.6 Sensibilidad de parámetros: mesetas frente a picos, robustez entre activos y periodos
- 2.5.7 Datos sintéticos para pruebas de estrés: GBM, GARCH y bootstrapping
- 2.5.8 Checklist de «backtest honesto» antes de pasar a paper trading
2.6 · Métricas de rendimiento
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:
Fuente 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 bajando1Nota 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.. 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.
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).
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]](6539, 24) 3380 rolls
mercado
IDE 7.25
IDB 7.25
AGD 39.75
MEB 52.25
dtype: float64Las 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.
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).
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 * vlLa 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.
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.
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 escalaEl 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.
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)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_grandeLos 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.
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).
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)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: float64Con 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:
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()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: float64El 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.
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:
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) 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.061La 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).
Ejercicios
- 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
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.911Con 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.
- Añade un test que compruebe que el bucle
backtestno 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
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}")[ 0.17 -0.26 -0.09 0.04 0.07] media -0.02Con 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.
- 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.
Footnotes
-
Moskowitz, Ooi y Pedersen (2012), en los recursos, documentan el momentum de series temporales en futuros de renta variable, divisas, materias primas y bonos. ↩
Respuesta plegada en cada pregunta
Autoevaluación
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?
- APara que el backtest sea más rápido
- BPorque 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
- CPorque el broker no acepta órdenes al cierre
- DPorque los futuros cotizan con un día de retraso
Ver respuesta
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.
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)?
- AMás operaciones
- BQue 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
- CQue el sistema solo opere a largo
- DQue la volatilidad sea menor
Ver respuesta
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.
¿Por qué el sizing divide el riesgo objetivo entre la volatilidad de cada mercado?
- APara comprar más de los mercados que más suben
- BPara 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
- CPorque lo exige la cámara de compensación
- DPara reducir las comisiones
Ver respuesta
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.
Quitar un bloque sube el Sharpe del backtest de 0,85 a 0,87. ¿Qué conclusión es razonable?
- AQuitar el bloque: el sistema mejora
- BNinguna 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
- CQue el bloque tiene un error
- DQue hay que añadir más bloques
Ver respuesta
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.
¿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?
- ANada: todos los mercados se operan siempre
- BLos 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
- CLos opera solo a corto
- DLos sustituye por materias primas
Ver respuesta
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.