Lección 7.6.2 · 70 min

Capstone C1: un sistema de futuros multi-activo, de la previsión a la cuenta de papel

El encargo es un sistema de futuros diversificado al estilo de Carver, con tendencia, carry y un spread estacional, que decide en un proceso diario y ejecuta en NautilusTrader contra una cuenta de papel de Interactive Brokers. Esta guía fija el alcance y los entregables, monta una columna vertebral que funciona de punta a punta con los futuros del curso, la valida con un registro de pruebas, walk-forward y Deflated Sharpe, y cierra con el plan de cuatro semanas de papel y la rúbrica aplicada.

Fig. — Resultado neto acumulado (suma de rendimientos diarios, neto de costes) del esqueleto de C1 de 2014 a 2025, por partes: tendencia y carry, spread trigo-maíz y sistema completo (datos sintéticos del nivel 3)
Lecciones del nivel 7
  1. 7.1 · Ingeniería de software para trading

    1. 7.1.1 Estructura de proyecto, configuración y calidad: del notebook al repositorio
    2. 7.1.2 Rendimiento: medir, perfilar y compilar solo lo que hace falta
    3. 7.1.3 Patrones: eventos, colas de mensajes y arquitectura hexagonal
  2. 7.2 · Infraestructura y despliegue

    1. 7.2.1 Docker y docker-compose: imágenes reproducibles con uv
    2. 7.2.2 VPS, nube y latencia; CI/CD con GitHub Actions
    3. 7.2.3 Orquestación y monitorización: grafos de tareas, logs estructurados, métricas, alertas y latidos
    4. 7.2.4 Seguridad y continuidad: 2FA, IP, copias y procedimientos ante fallos
  3. 7.3 · MLOps para modelos de trading

    1. 7.3.1 Registro de experimentos, modelos y datos: MLflow, DVC y una ejecución reproducida de principio a fin
    2. 7.3.2 Reentrenamiento programado, validación automática y champion/challenger
    3. 7.3.3 Monitorización del modelo en vivo y desactivación automática
    4. 7.3.4 Servir modelos: ONNX, inferencia en CPU o GPU y el coste de los LLM en producción
  4. 7.4 · Regulación, ética y fiscalidad

    1. 7.4.1 MiFID II: clientes, idoneidad y trading algorítmico
    2. 7.4.2 ESMA y CFD: apalancamiento, cierre y saldo negativo
    3. 7.4.3 PRIIPs, KID y ETFs estadounidenses
    4. 7.4.4 MiCA después del transitorio: comprobar un proveedor en los registros
    5. 7.4.5 Abuso de mercado: spoofing, layering y el bot que los imita sin querer
    6. 7.4.6 Fiscalidad del trading en España
    7. 7.4.7 Ética: datos personales, inteligencia artificial y la venta de señales
  5. 7.5 · Carrera profesional quant

    1. 7.5.1 Roles del quant: seis oficios alrededor de un mismo sistema
    2. 7.5.2 Dónde trabajar: qué hace cada empresa y qué dicen los registros
    3. 7.5.3 Portfolio público y entrevistas: enseñar criterio, no rentabilidades
    4. 7.5.4 Certificaciones, formación y prop firms: qué certifica cada cosa y qué mide un examen
  6. 7.6 · Capstone final

    1. 7.6.1 Capstone: requisitos comunes, rúbrica del tribunal y preparación de la defensa
    2. 7.6.2 Capstone C1: un sistema de futuros multi-activo, de la previsión a la cuenta de papel
    3. 7.6.3 Capstone C2: libro de opciones sistemático, superficie de volatilidad y griegas en tiempo real
    4. 7.6.4 Capstone C3: cartera de acciones y ETFs con ML, HRP y fiscalidad española
    5. 7.6.5 Capstone C4: una plataforma de investigación con LLMs y datos alternativos
    6. 7.6.6 Capstone C5: market making o arbitraje en cripto con control de contraparte

El encargo

Una gestora pequeña quiere evaluar un programa sistemático de futuros antes de dedicarle capital. Te encarga el prototipo: un sistema diversificado que combine tendencia, carry y al menos un spread, que decida una vez al día con datos de cierre, que ejecute con NautilusTrader y que funcione durante cuatro semanas contra una cuenta de papel de Interactive Brokers (IBKR) sin que nadie toque nada salvo para atender alertas. El comité de inversión no quiere un backtest bonito: quiere saber si el sistema hace lo que dice, cuánto le cuesta ejecutarlo, qué pasa cuando algo falla y cuántas cosas se probaron antes de llegar a la versión final.

Entra en el encargo:

  • Un universo de 15 a 25 futuros de al menos cuatro sectores (índices, tipos, energía, metales, agrícolas, divisas), con contratos reales y su calendario de roll.
  • Dos familias de reglas de previsión (tendencia y carry) al estilo de la lección 3.1.4 y una cartera de spreads con justificación física (lecciones 3.2.2 y 3.2.6), con su calendario estimado fuera de muestra.
  • Dimensionamiento por riesgo objetivo, contratos enteros, buffer y costes por lado y por roll.
  • Validación anti-sobreajuste con registro de pruebas, walk-forward y Deflated Sharpe (o CPCV).
  • Ejecución con NautilusTrader: backtest de eventos que reproduzca el cálculo de la decisión y nodo en vivo contra la cuenta de papel, con conciliación al arrancar y cada mañana.
  • Monitorización, límites y desactivación automática.

No entra: ejecución intradía o de alta frecuencia, opciones sobre futuros, optimización de la cartera con estimaciones de rentabilidad esperada, operar con dinero real ni datos de pago que no se puedan documentar. El cliente puede ser ficticio, pero el entregable es el de un encargo real:

EntregableFormaLo que comprueba el tribunal
Repositorioplantilla de 7.6.1: uv, Docker, make reproduceque un comando reproduce el informe desde cero
Informe del backtestJSON + tablas generadascifras iguales a las de la memoria
Registro de pruebasJSONL, solo de añadirnúmero de pruebas y su dispersión
Ejecución en NautilusTraderbacktest y nodo en vivomisma clase en los dos, diferencia medida
Diario de paper trading4 semanas: órdenes, ejecuciones, conciliaciones, alertasque el sistema funcionó y se vigiló
Memoria técnica y defensaplantilla de 7.6.1, 20 minutosque las decisiones están justificadas

Arquitectura

El sistema separa la decisión de la ejecución. La decisión es un proceso por lotes que corre una vez al día tras la liquidación: lee precios y calendario de contratos, calcula previsiones, combina, dimensiona y escribe un fichero de objetivos (contratos por instrumento). La ejecución es una estrategia de NautilusTrader que lleva cada contrato a su objetivo y que es la misma clase en el backtest y en el nodo en vivo. Es la separación de pysystemtrade (posición óptima por un lado, generación de órdenes por otro) y tiene una ventaja práctica: la decisión se puede auditar fichero a fichero y la ejecución se puede probar contra ella.

Datos y rollsPrevisionesCombinar y IDMRiesgo y bufferObjetivos JSONEjecutor NautilusIBKR papelConciliaciónMonitor y alertas
Fuente Mermaid
flowchart TB
  D[Datos y rolls] --> P[Previsiones]
  P --> C[Combinar y IDM]
  C --> R[Riesgo y buffer]
  R --> O[Objetivos JSON]
  O --> E[Ejecutor Nautilus]
  E --> B[IBKR papel]
  B --> K[Conciliación]
  K --> M[Monitor y alertas]
  M --> R
PiezaLibreríaDónde se aprendió
Calendario de contratos y rollpandas3.1.1, 3.1.3
Previsiones EWMAC y carry, FDM, IDM, buffernumpy, pandas3.1.4
Spreads con los mismos contratos y calendario estacionalpandas, statsmodels3.2.2, 3.2.5, 3.2.6
Walk-forward, CPCV, DSR, registro de pruebasnumpy, scipy2.2.4, 2.5.2, 2.5.3, 2.5.4
Motor de backtest y nodo en vivoNautilusTrader 1.231.02.4.5, 4.5.6
Conciliación y arranqueNautilusTrader, SQLite4.5.5
Contenedores, CI y secretosDocker, uv, GitHub Actions7.2.1, 7.2.2, 7.2.4
Métricas, alertas y desactivaciónprometheus-client, Redis7.2.3, 7.3.3

La flecha que vuelve del monitor al riesgo es la importante: un monitor que solo avisa no basta. El estado de desactivación (lección 7.3.3) lo lee el proceso de decisión, que deja de abrir posiciones nuevas, y el ejecutor, que deja de enviar órdenes salvo las que reducen riesgo.

Datos

Se arranca con los futuros sintéticos del nivel 3: futuros3_curvas.parquet (liquidación, volumen e interés abierto de todos los contratos listados de 23 mercados, de 2010 a 2025), futuros3_contratos.csv (vencimiento, primer aviso y fecha de roll de cada contrato) y futuros3_especificaciones.csv (multiplicador, tick, márgenes y comisión). Imitan contratos reales (la columna imita: E-mini S&P 500, nota a 10 años, WTI, oro, maíz, trigo…) y su README documenta la respuesta conocida: tendencia y carry plantados, estacionalidad de los spreads con su máximo y sus controles. El esqueleto usa los 20 mercados que cotizan en USD; los tres en la divisa ficticia QEV se añaden en el primer ejercicio, con la conversión de la lección 3.1.4. Para una historia más larga (2000-2025, 24 mercados con serie continua), están los futuros2_* del nivel 2.

Con datos reales, el alumno documenta en el README de datos el origen, la licencia, el coste y las limitaciones de cada fuente. Tres opciones razonables para este proyecto:

  • El propio IBKR: datos en tiempo real y barras históricas por la API, que el adaptador de NautilusTrader pide al arrancar. En su página de precios, la suscripción de nivel 1 en tiempo real de CME, CBOT, NYMEX y COMEX cuesta 1,55 USD al mes cada una para un usuario no profesional (consultado el 24 de octubre de 2026); sin suscripción, los datos de esos mercados llegan con 10 minutos de retraso. La guía de la cuenta de papel de IBKR indica que se pueden compartir con ella los datos en tiempo real de la cuenta real. El histórico de la API sirve para el roll y las previsiones, no para un backtest de quince años.
  • Databento para el histórico de CME con todos los contratos (lección 3.2.5, con sus convenciones de símbolos): la tarifa se consulta en su web y se apunta en el README con la fecha.
  • Series continuas gratuitas de webs de cotizaciones: solo para explorar. Suelen ser del contrato más cercano sin calendario de roll documentado y con ajustes desconocidos; un backtest con ellas no se puede defender.

Las trampas de datos de este proyecto son conocidas: series continuas con un ajuste que no se sabe cuál es (lección 1.4.6), rolls en fechas que el sistema real no habría usado (por volumen visto a posteriori), contratos que no existían en la fecha simulada, cambios de especificación (multiplicadores, horarios) y un universo elegido hoy entre los mercados que mejor han ido. El último es el sesgo de supervivencia de los futuros: el universo se fija con un criterio de liquidez de la fecha, no con el resultado.

Esqueleto ejecutable

La columna vertebral son siete celdas: carga, previsiones, spread, contratos con costes, la misma ejecución en NautilusTrader, validación e informe. Cada una remite a la lección donde se explica la pieza; aquí solo se monta.

IN [01]python
import hashlib
import json

import numpy as np
import pandas as pd

curvas = pd.read_parquet("futuros3_curvas.parquet")
ctr = pd.read_csv("futuros3_contratos.csv",
                  parse_dates=["vencimiento", "fecha_roll"])
esp = pd.read_csv("futuros3_especificaciones.csv",
                  index_col="mercado")
MERCADOS = list(esp.index[esp["mercado_base"].isna()
                          & (esp["divisa"] == "USD")])


def serie(mercado):
    """Contrato que se tiene, su precio, el cambio diario del mismo
    contrato en puntos y el carry anual (lección 3.1.4)."""
    c = curvas[curvas["mercado"] == mercado]
    P = c.pivot(index="fecha", columns="contrato",
                values="liquidacion")
    cm = ctr[ctr["mercado"] == mercado].reset_index(drop=True)
    pos = np.searchsorted(cm["fecha_roll"].to_numpy(),
                          P.index.to_numpy(), side="right")
    tiene = cm["contrato"].to_numpy()[pos]
    sig = cm["contrato"].to_numpy()[np.minimum(pos + 1, len(cm) - 1)]
    i, j = P.columns.get_indexer(tiene), P.columns.get_indexer(sig)
    f, Pv = np.arange(len(P)), P.to_numpy()
    ayer = np.r_[i[0], i[:-1]]
    dif = np.r_[np.nan, Pv[f[1:], ayer[1:]] - Pv[f[1:] - 1, ayer[1:]]]
    v = cm.set_index("contrato")["vencimiento"]
    dt = (v.reindex(sig).to_numpy()
          - v.reindex(tiene).to_numpy()).astype("timedelta64[D]")
    F2 = np.where(j >= 0, Pv[f, np.maximum(j, 0)], np.nan)
    carry = np.log(Pv[f, i] / F2) / (dt.astype(float) / 365)
    return pd.DataFrame({"contrato": tiene, "precio": Pv[f, i],
                         "dif": dif, "carry": carry}, index=P.index)


S = {m: serie(m) for m in MERCADOS}
fechas = S["RVA"].index
tabla = lambda col: pd.DataFrame(
    {m: S[m][col] for m in MERCADOS}).reindex(fechas)
precio, dif = tabla("precio"), tabla("dif")
contrato, carry = tabla("contrato"), tabla("carry")
mult = esp.loc[MERCADOS, "multiplicador"]
huella = {a: hashlib.sha256(open(a, "rb").read()).hexdigest()[:12]
          for a in ("futuros3_curvas.parquet",
                    "futuros3_contratos.csv",
                    "futuros3_especificaciones.csv")}
print(len(MERCADOS), "mercados ·", len(fechas), "sesiones ·",
      fechas[0].date(), "a", fechas[-1].date())
print(" ".join(MERCADOS))
print(huella)
OUT [01]Carga: 20 futuros en USD y huella de los datos
20 mercados · 4024 sesiones · 2010-01-04 a 2025-12-31
RVA RVB RVC TPA TPB TPC TPD PEC PEG PEH GNA ELB MTO MTP GRM GRT GRS GRH GRA DQE
{'futuros3_curvas.parquet': '127923561770', 'futuros3_contratos.csv': 'e558b9981fe5', 'futuros3_especificaciones.csv': 'e49e496e6361'}

La huella (los 12 primeros caracteres del SHA-256 de cada fichero) va al informe final: si alguien regenera los datos o cambia un fichero, el informe lo dice. Es la versión mínima del versionado de datos de la lección 7.3.1; en el repositorio del proyecto, los datos van con DVC o con un manifiesto de sumas que el auditor de 7.6.1 comprueba.

IN [02]python
ESTIMA = slice("2011", "2013")      # escalares y FDM: solo estos años
ajustado = dif.fillna(0).cumsum()
sigma_p = dif.ewm(span=35, min_periods=20).std()     # puntos por día
vol_anual = (dif / precio.shift(1)).ewm(
    span=35, min_periods=20).std() * np.sqrt(252)


def escalada(bruta):
    k = 10 / bruta.loc[ESTIMA].abs().stack().mean()
    return (bruta * k).clip(-20, 20)


def ewmac(r):
    return escalada((ajustado.ewm(span=r, min_periods=r).mean()
                     - ajustado.ewm(span=4 * r, min_periods=4 * r)
                     .mean()) / sigma_p)


REGLAS = {f"ewmac{r}": ewmac(r) for r in (8, 16, 32, 64)}
REGLAS["carry"] = escalada(
    carry.ewm(span=30, min_periods=10).mean() / vol_anual)


def combinar(pesos):
    w = pd.Series(pesos)
    junt = pd.DataFrame({k: REGLAS[k].loc[ESTIMA].stack()
                         for k in w.index}).dropna()
    fdm = min(2.5, 1 / np.sqrt(w @ junt.corr() @ w))
    return (sum(REGLAS[k] * w[k] for k in w.index) * fdm
            ).clip(-20, 20), fdm


BASE = {"ewmac16": 0.25, "ewmac64": 0.25, "carry": 0.5}
prevision, fdm = combinar(BASE)
print(f"FDM {fdm:.3f}")
print(prevision.loc["2025-12-31"].round(1).sort_values()
      .iloc[[0, 1, -2, -1]].to_dict())
OUT [02]Previsiones: tendencia y carry escaladas, FDM
FDM 1.249
{'GRH': -11.3, 'GRM': -11.2, 'PEH': 14.0, 'ELB': 16.6}

Las cuatro velocidades de EWMAC y el carry se calculan como en la lección 3.1.4, con los escalares estimados solo con 2011-2013: la evaluación empieza en 2014 y ningún parámetro se ha estimado con los años en que se mide. La configuración de referencia (BASE) usa los pesos de esa lección. Las otras velocidades se calculan aquí para el registro de pruebas de la validación.

El spread es la pieza que más se presta al autoengaño, así que su calendario se estima cada año con los años anteriores (la versión fuera de muestra de la lección 3.2.6). Trigo y maíz tienen los mismos meses de entrega y la misma regla de roll en los datos, así que las dos patas cambian de contrato el mismo día y el cambio diario del spread no tiene saltos:

IN [03]python
def armonico(d):
    """Día del máximo de la deriva anual de un cambio diario d:
    regresión sobre los incrementos de cos y sin del día del año."""
    a = 2 * np.pi * d.index.dayofyear.to_numpy() / 365.25
    b = 2 * np.pi * (d.index.dayofyear.to_numpy() - 1) / 365.25
    X = np.c_[np.cos(a) - np.cos(b), np.sin(a) - np.sin(b)]
    ok = d.notna().to_numpy()
    c, s = np.linalg.lstsq(X[ok], d.to_numpy()[ok], rcond=None)[0]
    return np.degrees(np.arctan2(s, c)) % 360 / 360 * 365.25


def calendario(indice, pico):
    """+1 de valle + 10 días a máximo - 10; -1 de máximo + 10 a
    valle - 10; 0 el resto (lección 3.2.6)."""
    doy = indice.dayofyear.to_numpy()
    valle = (pico - 182.6) % 365.25
    sube = ((doy - (valle + 10)) % 365.25) < 162.6
    baja = ((doy - (pico + 10)) % 365.25) < 162.6
    return np.where(sube, 1.0, np.where(baja, -1.0, 0.0))


r = dif / precio.shift(1)                 # rendimiento del contrato
d_log = np.log1p(r["GRT"]) - np.log1p(r["GRM"])
lado = pd.Series(0.0, index=fechas)
picos = {}
for a in range(2014, 2026):
    picos[a] = armonico(d_log[d_log.index.year < a])
    lado[fechas.year == a] = calendario(fechas[fechas.year == a],
                                        picos[a])
vol_sp = (r["GRT"] - r["GRM"]).ewm(span=60, min_periods=40
                                   ).std() * np.sqrt(252)
print({a: round(p) for a, p in picos.items()})
OUT [03]Spread trigo-maíz: calendario estimado con los años anteriores
{2014: 270, 2015: 290, 2016: 298, 2017: 332, 2018: 361, 2019: 26, 2020: 18, 2021: 12, 2022: 3, 2023: 12, 2024: 10, 2025: 9}

El README de los datos planta el máximo del trigo-maíz el 14 de enero (día 14). Con los cuatro años de 2010 a 2013, el armónico lo sitúa a finales de septiembre (día 270): el primer año, el spread opera con el calendario desplazado casi cuatro meses. La estimación se va corrigiendo año a año (día 361 en 2018) y desde 2019 queda entre el 3 y el 26 de enero. Es lo que tiene que pasar con una estimación honesta: los primeros años pagan el aprendizaje, y el backtest lo incluye en vez de usar el calendario que se ve con toda la muestra.

Ahora todo se convierte en contratos. Tendencia y carry siguen la fórmula de la lección 3.1.4, con el 80 % del riesgo; el spread recibe el 20 %, con el mismo nominal en las dos patas. Las dos posiciones óptimas se suman antes de redondear, y el buffer y los costes se aplican a la suma, que es lo que se negocia:

IN [04]python
CAPITAL, TAU, W_SPREAD = 5_000_000, 0.20, 0.20
normal = (dif / sigma_p.shift(1)).loc[ESTIMA]
w_i = pd.Series(1 / len(MERCADOS), index=MERCADOS)
idm = min(2.5, 1 / np.sqrt(w_i @ normal.corr() @ w_i))
riesgo_ctr = sigma_p * np.sqrt(252) * mult          # USD al año
media = (1 - W_SPREAD) * idm * w_i * CAPITAL * TAU / riesgo_ctr
nominal = W_SPREAD * CAPITAL * TAU / vol_sp          # USD por pata
opt_sp = pd.DataFrame(0.0, index=fechas, columns=MERCADOS)
opt_sp["GRT"] = lado * nominal / (precio["GRT"] * mult["GRT"])
opt_sp["GRM"] = -lado * nominal / (precio["GRM"] * mult["GRM"])
opt_sp = opt_sp.fillna(0)
coste_lado = (0.5 * esp.loc[MERCADOS, "valor_tick"]
              + esp.loc[MERCADOS, "comision"])        # USD
roll = contrato.ne(contrato.shift(1))


def contratos(optimo, media, buffer=0.10):
    """Enteros; se opera solo fuera de óptimo ± buffer·media y hasta
    el borde. Decidido con datos de t - 1 (lección 3.1.4)."""
    P = optimo.shift(1).to_numpy()
    B = buffer * media.shift(1).to_numpy()
    N, actual = np.zeros_like(P), np.zeros(P.shape[1])
    for t in range(len(P)):
        nuevo = np.clip(actual, np.round(P[t] - B[t]),
                        np.round(P[t] + B[t]))
        actual = np.where(np.isnan(P[t] + B[t]), 0.0, nuevo)
        N[t] = actual
    return pd.DataFrame(N, index=optimo.index, columns=optimo.columns)


def backtest(N):
    pyg = (N.shift(1) * dif * mult).sum(axis=1)
    c = (N.diff().abs() * coste_lado
         + 2 * N.shift(1).abs() * roll * coste_lado).sum(axis=1)
    return (((pyg - c) / CAPITAL).loc["2014":],
            (c / CAPITAL).loc["2014":])


def sharpe(x):
    return x.mean() / x.std() * np.sqrt(252)


N = contratos(prevision / 10 * media + opt_sp, media)
neto, coste = backtest(N)
partes = {"tendencia y carry":
              backtest(contratos(prevision / 10 * media, media))[0],
          "spread trigo-maíz": backtest(contratos(opt_sp, media))[0],
          "sistema": neto}
print(pd.DataFrame({k: {"sharpe": sharpe(v),
                        "vol_pct": 100 * v.std() * np.sqrt(252),
                        "rent_pct": 100 * v.mean() * 252}
                    for k, v in partes.items()}).T.round(2))
print(f"IDM {idm:.2f} · coste {100 * coste.mean() * 252:.2f} % "
      f"al año · {int((N.diff().abs() > 0).loc['2014':].sum().sum())}"
      " cambios de posición")
OUT [04]Contratos, buffer, costes y resultado neto por partes
                   sharpe  vol_pct  rent_pct
tendencia y carry    0.70    13.54      9.43
spread trigo-maíz    0.61     3.87      2.37
sistema              0.84    14.06     11.77
IDM 2.50 · coste 0.82 % al año · 11535 cambios de posición

Neto de costes, de 2014 a 2025, tendencia y carry dan un Sharpe de 0,70 con una volatilidad del 13,5 %; el spread solo, 0,61 con un 3,9 %; y el sistema, 0,84 con un 14,1 %. La figura de la cabecera dibuja los tres resultados acumulados (suma de rendimientos diarios). El spread aporta poco rendimiento pero está casi descorrelado de la tendencia, y por eso sube el Sharpe del conjunto. En doce años, la cartera cambia 11.535 veces la posición de algún mercado (unas 960 al año entre los 20). El coste total, el 0,82 % anual, es pequeño frente a la rentabilidad con este capital; con 250.000 USD no lo sería (ejercicio de la lección 3.1.4).

Estas cifras no son una estimación de lo que el sistema ganaría con dinero. Los datos son sintéticos con tendencia y carry plantados: que el sistema gane valida que el código recupera lo que se plantó, como en la lección 3.1.4, y nada más. Lo que sí se puede defender con estos números es la mecánica: las previsiones están en la escala correcta, el coste está contado, la volatilidad está cerca de la pedida y ningún parámetro se estimó con los años en que se mide.

Falta la otra mitad: que la ejecución haga lo mismo. El ejecutor de NautilusTrader recibe los objetivos y nada más. En el backtest de 2025, cada contrato es un FuturesContract con su vencimiento; cada sesión llega una barra por contrato que se tiene y, el día del roll, otra del contrato que se deja, cuyo objetivo es cero:

IN [05]python
import warnings
from decimal import Decimal

from pandas.errors import Pandas4Warning

warnings.filterwarnings("ignore", message="Timestamp.utcnow",
                        category=Pandas4Warning)   # lección 2.4.5

from nautilus_trader.backtest.engine import BacktestEngine
from nautilus_trader.config import (BacktestEngineConfig,
                                    LoggingConfig, StrategyConfig)
from nautilus_trader.model.currencies import USD
from nautilus_trader.model.data import Bar, BarType
from nautilus_trader.model.enums import (AccountType, AssetClass,
                                         OmsType, OrderSide)
from nautilus_trader.model.identifiers import (InstrumentId, Symbol,
                                               Venue)
from nautilus_trader.model.instruments import FuturesContract
from nautilus_trader.model.objects import Money, Price, Quantity
from nautilus_trader.trading.strategy import Strategy

VENUE = Venue("SIM")
ANO = fechas[fechas.year == 2025]
px = curvas.set_index(["fecha", "contrato"])["liquidacion"]
info = ctr.set_index("contrato")
ns = lambda x: pd.Timestamp(x, tz="UTC").value


def decimales(tick):
    s = np.format_float_positional(tick)
    return len(s.split(".")[1]) if "." in s else 0


def futuro(c, m):
    d = decimales(esp.at[m, "tick"])
    return FuturesContract(
        InstrumentId(Symbol(c), VENUE), Symbol(c),
        AssetClass.COMMODITY, USD, d, Price(esp.at[m, "tick"], d),
        Quantity.from_int(int(mult[m])), Quantity.from_int(1), m,
        ns(info.at[c, "fecha_alta"]), ns(info.at[c, "vencimiento"]),
        0, 0, margin_init=Decimal(0), margin_maint=Decimal(0))


inst, barras, objetivo = {}, [], {}
for t in ANO:
    cierre = ns(t) + 21 * 3_600 * 10**9      # 21:00 UTC
    for m in MERCADOS:
        hoy, ayer = contrato.at[t, m], contrato.shift(1).at[t, m]
        for c in sorted({hoy, ayer}):
            inst.setdefault(c, futuro(c, m))
            p = Price(px[(t, c)], inst[c].price_precision)
            barras.append(Bar(
                BarType.from_str(f"{c}.SIM-1-DAY-LAST-EXTERNAL"),
                p, p, p, p, Quantity.from_int(1_000), cierre, cierre))
            objetivo[(cierre, c)] = N.at[t, m] if c == hoy else 0.0


class Ejecutor(Strategy):
    """Lleva cada contrato a su objetivo. No decide nada."""

    def __init__(self, objetivo):
        super().__init__(StrategyConfig(strategy_id="EJECUTOR-001"))
        self.objetivo = objetivo

    def on_start(self):
        for i in inst.values():
            self.subscribe_bars(
                BarType.from_str(f"{i.id}-1-DAY-LAST-EXTERNAL"))

    def on_bar(self, bar):
        iid = bar.bar_type.instrument_id
        q = self.objetivo[(bar.ts_event, iid.symbol.value)]
        d = int(q - float(self.portfolio.net_position(iid)))
        if d:
            self.submit_order(self.order_factory.market(
                iid, OrderSide.BUY if d > 0 else OrderSide.SELL,
                Quantity.from_int(abs(d))))


motor = BacktestEngine(BacktestEngineConfig(
    logging=LoggingConfig(log_level="ERROR")))
motor.add_venue(venue=VENUE, oms_type=OmsType.NETTING,
                account_type=AccountType.MARGIN, base_currency=USD,
                starting_balances=[Money(CAPITAL, USD)])
for i in inst.values():
    motor.add_instrument(i)
motor.add_data(barras)
motor.add_strategy(Ejecutor(objetivo))
motor.run()

fl = motor.trader.generate_order_fills_report()
sym = fl["instrument_id"].str.split(".").str[0]
q = (fl["filled_qty"].astype(float)
     * np.where(fl["side"] == "BUY", 1, -1))
m_fl = mult.reindex(info.loc[sym, "mercado"]).to_numpy()
caja = -(q * fl["avg_px"].astype(float) * m_fl).sum()
fin = ANO[-1]
marca = (N.loc[fin] * precio.loc[fin] * mult).sum()
pyg_motor = caja + marca
pyg_vector = (N.shift(1) * dif * mult).loc[ANO[1:]].sum().sum()
print(len(inst), "contratos ·", len(barras), "barras ·",
      len(fl), "órdenes ejecutadas")
print(f"bruto 2025: motor {pyg_motor:,.2f} USD · "
      f"vectorizado {pyg_vector:,.2f} USD · "
      f"diferencia {pyg_motor - pyg_vector:,.2f}")
OUT [05]El ejecutor de objetivos en NautilusTrader, 2025
156 contratos · 5136 barras · 1185 órdenes ejecutadas
bruto 2025: motor 615,674.44 USD · vectorizado 615,674.44 USD · diferencia 0.00

Ciento cincuenta y seis contratos distintos en un año, 5.136 barras y 1.185 órdenes, y el resultado bruto del motor de eventos coincide al céntimo con el del cálculo vectorizado: 615.674,44 USD. La comparación es en bruto a propósito. El motor no lleva modelo de comisiones (los costes de cada mercado son distintos y el PerContractFeeModel de la versión 1.231.0 cobra lo mismo por contrato en todo el venue) y los márgenes están a cero; los dos se tratan aparte, los costes en el cálculo vectorizado y los márgenes en el segundo ejercicio. Que las cifras coincidan dice tres cosas que el tribunal querrá oír: los rolls se ejecutan el día que dice el calendario, ningún objetivo se pierde por el camino y la cuenta del motor y la del research son la misma cuenta. Si un día dejan de coincidir, el cambio está en el ejecutor o en los datos, y se sabe antes de enviar una orden a IBKR.

El precio de ejecución, en cambio, es el de la liquidación, que en real nadie consigue: se publica después del cierre. Es la diferencia principal entre este backtest y el papel, y la que el paper trading tiene que medir.

Validación

Antes de mirar el resultado de la configuración final se escribe qué se va a probar y qué resultado obligaría a parar. En este esqueleto, la familia de pruebas es la combinación de cuatro conjuntos de velocidades de tendencia, tres pesos del carry y dos buffers: 24 variantes, todas con el spread. Cada una va a un registro de solo añadir (registro_pruebas.jsonl) con un identificador que sale de su configuración, el periodo y su Sharpe; en el repositorio del proyecto, el registro lleva además la huella de los datos y del código (lección 2.2.4) y las ideas descartadas a medias, que también cuentan.

IN [06]python
import itertools

from scipy.stats import norm

TENDENCIA = {"8+32": ("ewmac8", "ewmac32"),
             "16+64": ("ewmac16", "ewmac64"),
             "32+64": ("ewmac32", "ewmac64"),
             "las cuatro": ("ewmac8", "ewmac16", "ewmac32",
                            "ewmac64")}
registro, rend = [], {}
for (nt, reglas), wc, buf in itertools.product(
        TENDENCIA.items(), (0.3, 0.5, 0.7), (0.0, 0.10)):
    pesos = {k: (1 - wc) / len(reglas) for k in reglas}
    pesos["carry"] = wc
    p_i, _ = combinar(pesos)
    r_i = backtest(contratos(p_i / 10 * media + opt_sp, media,
                             buf))[0]
    cfg = {"tendencia": nt, "peso_carry": wc, "buffer": buf}
    pid = hashlib.sha256(json.dumps(cfg, sort_keys=True)
                         .encode()).hexdigest()[:8]
    rend[pid] = r_i
    registro.append({"id": pid, **cfg, "desde": "2014-01-02",
                     "sesiones": len(r_i),
                     "sharpe": round(sharpe(r_i), 4)})
with open("registro_pruebas.jsonl", "w") as f:
    for fila in registro:
        f.write(json.dumps(fila, ensure_ascii=False) + "\n")
reg = pd.DataFrame(registro).set_index("id")
rend = pd.DataFrame(rend)
print(len(reg), "pruebas; Sharpe de", reg["sharpe"].min(), "a",
      reg["sharpe"].max())

# walk-forward: cada año, la mejor con los años anteriores
oos, elegidas = [], []
for a in range(2017, 2026):
    antes = rend[rend.index.year < a]
    mejor = (antes.mean() / antes.std()).idxmax()
    elegidas.append(mejor)
    oos.append(rend.loc[rend.index.year == a, mejor])
oos = pd.concat(oos)
tramo = rend.loc["2017":]
print("elegidas:", " ".join(f"{a % 100:02d}:{reg.at[e, 'tendencia']}"
      f"/{reg.at[e, 'peso_carry']}" for a, e
      in zip(range(2017, 2026), elegidas)))
print(f"2017-2025 · walk-forward {sharpe(oos):.2f} · mejor a "
      f"posteriori {tramo.apply(sharpe).max():.2f} · media de las "
      f"24 {tramo.apply(sharpe).mean():.2f}")


def psr(sr, umbral, t, asim, curt):          # lección 2.5.4
    den = np.sqrt(1 - asim * sr + (curt - 1) / 4 * sr**2)
    return norm.cdf((sr - umbral) * np.sqrt(t - 1) / den)


def sr0(n, var_sr):
    g = np.euler_gamma
    return np.sqrt(var_sr) * ((1 - g) * norm.ppf(1 - 1 / n)
                              + g * norm.ppf(1 - 1 / (n * np.e)))


mejor = reg["sharpe"].idxmax()
x, sr_d = rend[mejor], rend.mean() / rend.std()   # diarios
for n in (24, 100, 1000):
    u = sr0(n, sr_d.var())
    print(f"N = {n:4d}: umbral {u * np.sqrt(252):.2f} anual · "
          f"DSR {psr(sr_d[mejor], u, len(x), x.skew(),
                     x.kurt() + 3):.3f}")
dsr = psr(sr_d[mejor], sr0(len(reg), sr_d.var()), len(x),
          x.skew(), x.kurt() + 3)
OUT [06]Registro de 24 pruebas, walk-forward y Deflated Sharpe
24 pruebas; Sharpe de 0.7206 a 1.031
elegidas: 17:32+64/0.3 18:32+64/0.3 19:32+64/0.3 20:32+64/0.3 21:8+32/0.5 22:8+32/0.3 23:8+32/0.3 24:8+32/0.3 25:8+32/0.3
2017-2025 · walk-forward 1.02 · mejor a posteriori 1.29 · media de las 24 1.01
N =   24: umbral 0.19 anual · DSR 0.998
N =  100: umbral 0.24 anual · DSR 0.997
N = 1000: umbral 0.31 anual · DSR 0.994

Tres lecturas. Primera: las 24 variantes dan Sharpes entre 0,72 y 1,03, todas positivas y muy parecidas; las más rápidas y con menos carry son las mejores, porque la tendencia plantada en los datos responde al signo de los últimos 252 días. Segunda: el walk-forward, que cada año elige la mejor con los años anteriores, da 1,02 en 2017-2025, lo mismo que la media de las 24 en ese tramo (1,01) y bastante menos que la mejor vista a posteriori (1,29). Elegir no aporta nada fuera de muestra: la ventaja es de la familia de reglas, no de la configuración. La conclusión práctica para la memoria es quedarse con la configuración de referencia, que no se eligió mirando estos datos, y declararlo. Tercera: el DSR de la mejor con N = 24 es 0,998. Las variantes están tan correlacionadas que su dispersión es pequeña y el umbral del máximo esperado, bajo (un Sharpe de 0,19 anual). Incluso con N = 1.000, que es una forma exagerada de contar, el umbral se queda en 0,31.

Un DSR tan alto no es una virtud del sistema: es la consecuencia de unos datos con una tendencia plantada muy fuerte. Con datos reales, una familia así suele tener Sharpes de 0,3 a 0,7 y un DSR que depende mucho de cómo se cuenten las pruebas (lección 2.5.4): ahí es donde el registro marca la diferencia. Si el proyecto prefiere CPCV (lección 2.5.3), la matriz rend de 24 columnas es exactamente lo que necesita para la probabilidad de sobreajuste.

El criterio de parada se escribe en la configuración del proyecto antes de ejecutar esta celda, no después:

ComprobaciónUmbral para pasar a papelResultado
DSR de la elegida con N = pruebas registradas≥ 0,950,998
Sharpe walk-forward 2017-2025≥ 0,51,02
Coste anual frente a la rentabilidad bruta≤ 25 %6,5 %
Motor frente a vectorizado, bruto 2025≤ 1 USD0,00

El tercer umbral protege de un sistema que gana en bruto y se lo deja en comisiones; el cuarto, de una ejecución que no hace lo que dice la decisión. Si cualquiera falla, el proyecto no pasa a papel en ese estado: se documenta el fallo, se corrige lo que sea un error y lo que sea un resultado se acepta. Volver a probar variantes hasta que pase es exactamente lo que el registro existe para hacer visible.

IN [07]python
CRITERIOS = {"dsr_min": 0.95, "sharpe_wf_min": 0.5,
             "coste_max_frac": 0.25, "motor_max_usd": 1.0}
bruto = neto + coste
frac = coste.mean() / bruto.mean()
pasa = {"dsr": bool(dsr >= CRITERIOS["dsr_min"]),
        "walk_forward": bool(sharpe(oos) >= CRITERIOS["sharpe_wf_min"]),
        "coste": bool(frac <= CRITERIOS["coste_max_frac"]),
        "motor": bool(abs(pyg_motor - pyg_vector)
                      <= CRITERIOS["motor_max_usd"])}
informe = {
    "datos": huella, "mercados": len(MERCADOS),
    "configuracion": {**BASE, "w_spread": W_SPREAD, "tau": TAU,
                      "capital": CAPITAL, "buffer": 0.10},
    "periodo": ["2014-01-02", "2025-12-31"],
    "sharpe_neto": round(sharpe(neto), 3),
    "coste_sobre_bruto": round(float(frac), 3),
    "n_pruebas": len(reg), "dsr_mejor": round(float(dsr), 4),
    "sharpe_walk_forward": round(sharpe(oos), 3),
    "criterios": CRITERIOS, "pasa": pasa,
    "decision": "paper" if all(pasa.values()) else "parar"}
with open("informe_c1.json", "w") as f:
    json.dump(informe, f, ensure_ascii=False, indent=1)
print(json.dumps({k: informe[k] for k in
                  ("sharpe_neto", "coste_sobre_bruto", "n_pruebas",
                   "dsr_mejor", "sharpe_walk_forward", "decision")},
                 ensure_ascii=False))
print(hashlib.sha256(open("informe_c1.json", "rb").read())
      .hexdigest()[:12])
OUT [07]Informe de salida: huella, configuración, pruebas y decisión
{"sharpe_neto": 0.837, "coste_sobre_bruto": 0.065, "n_pruebas": 24, "dsr_mejor": 0.9983, "sharpe_walk_forward": 1.025, "decision": "paper"}
b465af1864ec

El informe es el artefacto que reproduce make reproduce y que la memoria cita. Su propia huella, la última línea, sirve para lo que pide la rúbrica de reproducibilidad: el tribunal clona el repositorio, ejecuta el comando documentado y compara esa huella con la de la memoria. Si coinciden, todas las cifras del informe son las mismas; si no, hay que saber por qué antes de seguir. Por eso el informe no lleva fechas de ejecución, rutas ni nada que cambie entre dos ejecuciones (la fecha de generación, si se quiere, va en un fichero aparte).

Riesgos y paper trading

Los riesgos que de verdad tumban un sistema de futuros diversificado no son los del backtest:

RiesgoCómo apareceDefensa en el sistema
Roll fallidoel contrato se acerca al primer aviso o al vencimiento con posicióncalendario de roll propio; alerta a 3 sesiones del roll sin ejecutar; bloqueo de contratos en periodo de entrega
Margenuna racha de pérdidas o una subida de márgenes del mercado obliga a reducirlímite de margen inicial usado (p. ej. 30 % del capital) comprobado antes de cada orden
Correlaciones de crisisel IDM se estimó en calma; en una crisis todos los mercados se mueven juntosIDM con tope 2,5; límite de volatilidad realizada de la cartera; reducción proporcional
Límites de precio y huecosun mercado agrícola cierra en límite o abre con huecoórdenes limitadas con banda; no perseguir el precio; reintentar la sesión siguiente
Datosuna liquidación errónea dispara una previsión en el topecomprobación de saltos frente a la volatilidad antes de decidir; el objetivo cambia como mucho una posición media al día
Error de especificaciónmultiplicador o tick mal cargadoconciliación de nominales contra el extracto de IBKR el primer día
Conexiónreinicio nocturno del gateway; desconexionesreconexión del adaptador, conciliación al arrancar, latido

La desactivación sigue la política graduada de la lección 7.3.3, con estados que lee tanto el proceso de decisión como el ejecutor:

CONCILIANDOOPERANDOBLOQUEADOREDUCIENDOAPAGADO
Fuente Mermaid
stateDiagram-v2
  [*] --> CONCILIANDO
  CONCILIANDO --> OPERANDO
  CONCILIANDO --> BLOQUEADO
  OPERANDO --> REDUCIENDO
  REDUCIENDO --> OPERANDO
  REDUCIENDO --> APAGADO
  OPERANDO --> APAGADO
  BLOQUEADO --> CONCILIANDO
  APAGADO --> CONCILIANDO

REDUCIENDO divide los objetivos entre dos: se entra por una caída desde máximos mayor que dos veces la volatilidad objetivo mensual o por un uso de margen por encima del límite. APAGADO lleva todos los objetivos a cero (solo órdenes que reducen) y exige rearme manual: se entra por una caída mayor que la peor del backtest, por tres días seguidos sin datos de un sector o por una diferencia de posición sin explicar. BLOQUEADO es el de la conciliación al arrancar (lección 4.5.5): el sistema no envía nada mientras quede algo sin explicar. Los umbrales concretos se fijan en la configuración antes del papel y se justifican en la memoria; lo que no se hace es cambiarlos a mitad de las cuatro semanas porque una alerta molesta.

El nodo en vivo es la misma clase Ejecutor, con dos cambios: los objetivos llegan de un fichero que escribe el proceso de decisión tras la liquidación, y las órdenes se envían a una hora fija de la sesión siguiente. La configuración sigue la documentación del adaptador de IBKR de NautilusTrader 1.231.0: TWS en papel escucha en el puerto 7497 (IB Gateway, en el 4002), los contratos individuales usan la simbología simplificada {símbolo local}.{exchange} con un dígito para el año (ESZ6.CME), y el ejemplo de la cuenta de papel usa un identificador del tipo DU123456. No se ejecuta en el curso: necesita el extra ib del paquete, TWS o IB Gateway abiertos y una cuenta.

IN [08]ejecutor_en_papel.pypython
from datetime import timedelta

from nautilus_trader.adapters.interactive_brokers.common import IB
from nautilus_trader.adapters.interactive_brokers.config import (
    InteractiveBrokersDataClientConfig,
    InteractiveBrokersExecClientConfig,
    InteractiveBrokersInstrumentProviderConfig,
    SymbologyMethod,
)
from nautilus_trader.adapters.interactive_brokers.factories import (
    InteractiveBrokersLiveDataClientFactory,
    InteractiveBrokersLiveExecClientFactory,
)
from nautilus_trader.config import (
    LoggingConfig, RoutingConfig, TradingNodeConfig)
from nautilus_trader.live.node import TradingNode
from nautilus_trader.model.identifiers import InstrumentId

from c1.ejecucion import EjecutorEnVivo   # Ejecutor + temporizador
from c1.config import ajustes              # pydantic-settings, 7.1.1

CONTRATOS = ["ESZ6.CME", "ZNZ6.CBOT", "CLF7.NYMEX", "GCZ6.COMEX",
             "ZCZ6.CBOT", "ZWZ6.CBOT"]   # se regenera en cada roll
proveedor = InteractiveBrokersInstrumentProviderConfig(
    symbology_method=SymbologyMethod.IB_SIMPLIFIED,
    load_ids=frozenset(InstrumentId.from_str(c) for c in CONTRATOS),
)
config = TradingNodeConfig(
    trader_id="C1-PAPEL-001",
    logging=LoggingConfig(log_level="INFO", log_directory="logs"),
    data_clients={IB: InteractiveBrokersDataClientConfig(
        ibg_host="127.0.0.1", ibg_port=7497, ibg_client_id=1,
        instrument_provider=proveedor)},
    exec_clients={IB: InteractiveBrokersExecClientConfig(
        ibg_host="127.0.0.1", ibg_port=7497, ibg_client_id=6,
        account_id=ajustes.ib_cuenta_papel,      # «DU…», sin secretos
        instrument_provider=proveedor,
        routing=RoutingConfig(default=True))},
)
node = TradingNode(config=config)
node.trader.add_strategy(EjecutorEnVivo(
    fichero_objetivos="estado/objetivos.json",
    hora_envio=ajustes.hora_envio_utc,       # fija, en la sesión
    estado_permiso="redis://127.0.0.1:6379/0",   # lección 7.3.3
    intervalo=timedelta(days=1)))
node.add_data_client_factory(
    IB, InteractiveBrokersLiveDataClientFactory)
node.add_exec_client_factory(
    IB, InteractiveBrokersLiveExecClientFactory)
node.build()
try:
    node.run()
finally:
    node.dispose()

Dos detalles de la documentación del adaptador que afectan al plan: menciona el reinicio nocturno del gateway, y el adaptador trata como transitorias las caídas de las granjas de datos de esas horas; y conviene reservar a cada cliente una banda de identificadores (ibg_client_id), porque tras un reinicio el identificador anterior puede seguir ocupado. El temporizador del ejecutor en vivo (clock.set_timer en la estrategia) no debe coincidir con esa ventana.

El plan de las cuatro semanas (28 días naturales, unas 20 sesiones de CME) tiene una regla: no se cambia el código de la decisión durante el papel. Un error que obligue a cambiarlo reinicia el contador. Cada día queda en el diario:

  • Antes de la sesión: conciliación de posiciones, órdenes y efectivo de la cuenta de papel contra el estado del sistema (lecciones 4.5.5 y 4.5.6); el informe de la decisión del día anterior y su huella.
  • Durante: órdenes enviadas, ejecuciones, rechazos, reconexiones; métricas en Prometheus y alertas (lección 7.2.3); el latido del ejecutor.
  • Después: deslizamiento de cada ejecución frente a la liquidación del día, en ticks y en USD por contrato; rolls hechos y pendientes; margen usado; estado de la política de desactivación.

Al final de las cuatro semanas, el informe de papel compara el coste de ejecución medido con el supuesto del backtest (medio tick más comisión): si el medido es mayor, el backtest se vuelve a ejecutar con el coste medido y el resultado entra en la memoria. El Sharpe de cuatro semanas no se interpreta: con 20 sesiones su error típico es de varias unidades. Lo que se evalúa es que el sistema funcionó, que cada diferencia con el broker se explicó y que los costes se midieron.

Hitos y rúbrica

El calendario es el de 7.6.1 (diez semanas, de 40 a 80 horas), aplicado a C1:

SemanaEntregable verificable
1Repositorio desde la plantilla; README de datos con fuentes, licencias y huellas; auditor de 7.6.1 en verde
2Carga, calendario de contratos y roll; tests de los rolls contra futuros3_contratos.csv
3Previsiones, combinación, contratos, buffer y costes; resultado por partes reproducible
4Registro de pruebas, walk-forward y DSR o CPCV; criterio de parada aplicado; informe JSON
5Ejecutor en NautilusTrader con diferencia 0 frente al vectorizado; nodo en vivo conectado a papel; Docker y monitorización
6-9Paper trading de 4 semanas con diario; memoria técnica en paralelo
10Informe de papel, costes medidos y backtest recalculado; ensayo y defensa

La rúbrica común de 7.6.1, con la evidencia concreta que pide en este proyecto:

Criterio (peso)Evidencia en C1
Reproducibilidad (15 %)make reproduce regenera informe_c1.json con la huella de la memoria; uv.lock, imagen Docker, semillas, datos con huella
Datos (15 %)origen, licencia y coste de cada fuente; calendario de roll documentado; universo fijado por liquidez de la fecha; trampas de series continuas tratadas
Validación (20 %)registro completo (también lo descartado), walk-forward, DSR con el N del registro o CPCV, criterio de parada escrito antes
Riesgo y ejecución (15 %)costes por lado y por roll; contratos enteros y buffer; límites de margen y de volatilidad; estados de desactivación probados con fallos simulados
Paper trading (15 %)28 días con diario; conciliación diaria sin diferencias sin explicar; deslizamiento medido frente a la liquidación; rolls a tiempo
Código (10 %)paquete con tests de rolls, de contratos y del ejecutor; ruff y pre-commit; configuración validada
Memoria y defensa (10 %)decisiones justificadas con cifras del informe; límites del estudio; respuestas a las preguntas típicas

Errores que suspenden por sí solos en este proyecto, además de los comunes: un backtest que calcula el resultado sobre una serie continua ajustada en porcentaje en vez de contrato a contrato sin decirlo (la lección 3.1.4 explica por qué cambia el resultado), un calendario de spread elegido con toda la muestra y presentado como fuera de muestra, y un paper trading en el que el código de la decisión cambió sin reiniciar el contador.

Ejercicios

  1. Añade los tres mercados en QEV (RVE, RVH, TPE) con la conversión de la lección 3.1.4 (fx3_diario.parquet) y vuelve a calcular el sistema. ¿Cambia el IDM? ¿Y el Sharpe neto?
Solución
IN [09]python
fx = pd.read_parquet("fx3_diario.parquet")
usd_qev = (fx[fx["divisa"] == "QEV"].set_index("fecha")
           ["usd_por_unidad"].reindex(fechas).ffill())
TODOS = list(esp.index[esp["mercado_base"].isna()])
S.update({m: serie(m) for m in TODOS if m not in S})
P2 = pd.DataFrame({m: S[m]["precio"] for m in TODOS}).reindex(fechas)
D2 = pd.DataFrame({m: S[m]["dif"] for m in TODOS}).reindex(fechas)
C2 = pd.DataFrame({m: S[m]["carry"] for m in TODOS}).reindex(fechas)
cambio2 = pd.DataFrame({m: usd_qev if esp.at[m, "divisa"] == "QEV"
                        else 1.0 for m in TODOS}, index=fechas)
sig2 = D2.ewm(span=35, min_periods=20).std()
aj2 = D2.fillna(0).cumsum()


def esc2(b):
    return (b * 10 / b.loc[ESTIMA].abs().stack().mean()).clip(-20, 20)


ew = {r: esc2((aj2.ewm(span=r, min_periods=r).mean()
               - aj2.ewm(span=4 * r, min_periods=4 * r).mean())
              / sig2) for r in (16, 64)}
va2 = (D2 / P2.shift(1)).ewm(span=35, min_periods=20).std() * 252**.5
ca2 = esc2(C2.ewm(span=30, min_periods=10).mean() / va2)
prev2 = (0.25 * ew[16] + 0.25 * ew[64] + 0.5 * ca2) * fdm
prev2 = prev2.clip(-20, 20)
w2 = pd.Series(1 / len(TODOS), index=TODOS)
idm2 = min(2.5, 1 / np.sqrt(w2 @ (D2 / sig2.shift(1)).loc[ESTIMA]
                            .corr() @ w2))
m2 = esp.loc[TODOS, "multiplicador"]
media2 = ((1 - W_SPREAD) * idm2 * w2 * CAPITAL * TAU
          / (sig2 * 252**.5 * m2 * cambio2))
sp2 = opt_sp.reindex(columns=TODOS, fill_value=0.0)
N2 = contratos(prev2 / 10 * media2 + sp2, media2)
cl2 = ((0.5 * esp.loc[TODOS, "valor_tick"]
        + esp.loc[TODOS, "comision"]) * cambio2)
roll2 = pd.DataFrame({m: S[m]["contrato"] for m in TODOS}
                     ).reindex(fechas)
roll2 = roll2.ne(roll2.shift(1))
pyg2 = (N2.shift(1) * D2 * m2 * cambio2).sum(axis=1)
c2 = (N2.diff().abs() * cl2
      + 2 * N2.shift(1).abs() * roll2 * cl2).sum(axis=1)
neto2 = ((pyg2 - c2) / CAPITAL).loc["2014":]
print(f"IDM {idm:.2f} -> {idm2:.2f} · Sharpe neto "
      f"{sharpe(neto):.2f} -> {sharpe(neto2):.2f}")
OUT [09]Los 23 mercados, con la divisa convertida
IDM 2.50 -> 2.50 · Sharpe neto 0.84 -> 0.92

Con los 23 mercados, el IDM sigue en el tope de 2,5 y el Sharpe neto sube de 0,84 a 0,92: tres mercados más (dos índices y un bono en QEV) reparten el riesgo entre más fuentes. Con un Sharpe de error típico cercano a 0,3 en doce años, la mejora es pequeña frente al ruido; el argumento para incluirlos es la diversificación, no esta cifra. La conversión importa en dos sitios, el riesgo de un contrato (para el tamaño) y el resultado en USD; olvidarla en el primero dimensiona mal y en el segundo mezcla monedas. En el proyecto real, además, la posición en divisa genera un saldo en esa divisa en la cuenta que hay que convertir o cubrir, y NautilusTrader necesita una cuenta multidivisa (base_currency=None) para llevarlo.

  1. Calcula el margen inicial que habría exigido la cartera N cada día de 2025 con los márgenes por contrato de futuros3_especificaciones.csv y comprueba si un límite del 30 % del capital habría bloqueado alguna orden.
Solución
IN [10]python
margen = (N.abs() * esp.loc[MERCADOS, "margen_inicial"]).sum(axis=1)
uso = (margen / CAPITAL).loc["2025"]
print(f"margen usado en 2025: mediana {100 * uso.median():.1f} %, "
      f"máximo {100 * uso.max():.1f} % ({uso.idxmax().date()})")
print("sesiones por encima del 30 %:", int((uso > 0.30).sum()))
por_sector = (N.abs() * esp.loc[MERCADOS, "margen_inicial"]).loc[
    uso.idxmax()].groupby(esp.loc[MERCADOS, "sector"]).sum()
print((por_sector / CAPITAL * 100).round(1).sort_values().to_dict())
OUT [10]Margen inicial usado en 2025
margen usado en 2025: mediana 11.8 %, máximo 14.6 % (2025-02-10)
sesiones por encima del 30 %: 0
{'divisas': 0.0, 'metales': 1.2, 'indices': 2.0, 'agricolas': 3.8, 'energia': 3.8, 'tipos': 3.8}

En 2025 la cartera usa una mediana del 11,8 % del capital en margen inicial, con un máximo del 14,6 % el 10 de febrero: ninguna sesión pasa del 30 %, así que el límite no habría bloqueado nada. El día del máximo, tipos, energía y agrícolas pesan lo mismo (3,8 % cada uno) y las divisas nada (solo hay un mercado, sin posición ese día). Que el límite no se active en el backtest no lo hace inútil: los márgenes del broker cambian con la volatilidad y pueden subir mucho en un día de crisis, y el sistema tiene que comprobarlo antes de cada orden con los márgenes que publica el broker, no con los del fichero.

  1. Un miembro del tribunal pregunta: «Antes de estas 24 variantes probaste el spread crack de la gasolina y el oro-plata, y los descartaste. ¿Están en el registro? ¿Cambia algo?». ¿Qué respondes?
Solución

Que deben estar, porque el registro existe precisamente para eso: cada idea probada y descartada es una prueba, aunque no llegara a la configuración final. Cambian el número de pruebas (de 24 a 26 o más, según cuántas variantes de cada spread se probaran) y, sobre todo, la dispersión de los Sharpes: un spread de control como el oro-plata, con un Sharpe muy distinto de los de la familia de tendencia y carry, aumenta la varianza y con ella el umbral del máximo esperado. El DSR bajará algo; con estos datos seguirá por encima de 0,95, pero lo relevante es que el número declarado sea el real. Ocultar las pruebas descartadas es uno de los errores que suspenden por sí solos (7.6.1), con independencia del resultado.

Respuesta plegada en cada pregunta

Autoevaluación

  1. En el esqueleto, ¿por qué NautilusTrader recibe objetivos de posición calculados aparte en vez de recalcular las previsiones dentro de on_bar?

    • APorque NautilusTrader no admite estrategias con más de diez instrumentos
    • BPorque así el backtest va más deprisa y no importa nada más
    • CPorque la decisión diaria de un sistema de este tipo es un cálculo por lotes sobre toda la cartera (previsiones, IDM, buffer) que se puede auditar fichero a fichero, y la ejecución solo tiene que llevar cada contrato a su objetivo; separar las dos cosas permite comprobar que el motor reproduce el cálculo y usar la misma clase en papel
    • DPorque Interactive Brokers exige recibir un fichero de objetivos
    Ver respuesta

    Respuesta C. Porque la decisión diaria de un sistema de este tipo es un cálculo por lotes sobre toda la cartera (previsiones, IDM, buffer) que se puede auditar fichero a fichero, y la ejecución solo tiene que llevar cada contrato a su objetivo; separar las dos cosas permite comprobar que el motor reproduce el cálculo y usar la misma clase en papel

    pysystemtrade separa igual el cálculo de posiciones óptimas de la generación de órdenes. En el esqueleto, el motor de eventos con los mismos objetivos da el mismo resultado bruto que el vectorizado (diferencia 0,00 USD en 2025), y eso valida la ejecución sin reescribir la lógica de la decisión en dos sitios.

  2. La mejor de las 24 variantes registradas tiene un Sharpe de 1,03 entre 2014 y 2025. Elegida cada año con los años anteriores (walk-forward), la selección da 1,02 entre 2017 y 2025, y la media de las 24 en ese tramo es 1,01. ¿Qué concluyes?

    • AQue elegir entre variantes no aporta nada fuera de muestra: lo que gana es la familia de reglas, y la configuración de referencia, sin optimizar, es tan defendible como la mejor
    • BQue hay que quedarse con la variante de 1,03, porque es la mejor
    • CQue el walk-forward está mal programado
    • DQue el sistema está sobreajustado y hay que abandonarlo
    Ver respuesta

    Respuesta A. Que elegir entre variantes no aporta nada fuera de muestra: lo que gana es la familia de reglas, y la configuración de referencia, sin optimizar, es tan defendible como la mejor

    El Sharpe de la selección fuera de muestra coincide con la media de las variantes: el 1,29 de la mejor a posteriori en 2017-2025 es elección con los datos ya vistos. No es sobreajuste dañino porque todas las variantes ganan; es que optimizar no añade nada y conviene declararlo así ante el tribunal.

  3. ¿Cuál de estas situaciones suspende el proyecto por sí sola, aunque el resto sea excelente?

    • AQue el Sharpe del paper trading sea negativo en las cuatro semanas
    • BQue el registro de pruebas tenga 24 filas y la memoria diga que solo se probó la configuración final
    • CQue el sistema opere 20 mercados en vez de 23
    • DQue la ejecución en papel tenga más deslizamiento que el backtest
    Ver respuesta

    Respuesta B. Que el registro de pruebas tenga 24 filas y la memoria diga que solo se probó la configuración final

    Ocultar el número de pruebas es uno de los errores que suspenden (rúbrica de 7.6.1): el DSR y cualquier conclusión dependen de él. Un mal resultado en cuatro semanas de papel, en cambio, no dice nada de la estrategia; lo que se evalúa es que el sistema funcione, se vigile y se concilie.

  4. El backtest ejecuta al precio de liquidación de cada sesión. ¿Qué tiene que medir el paper trading respecto a eso?

    • ANada: si el código es el mismo, el precio también
    • BSolo si las órdenes se rechazan
    • CEl Sharpe de las cuatro semanas, para compararlo con el del backtest
    • DLa diferencia entre el precio de liquidación y el precio real de cada ejecución (deslizamiento, en ticks y en USD por contrato), los rolls hechos a tiempo y las diferencias de posición contra el extracto
    Ver respuesta

    Respuesta D. La diferencia entre el precio de liquidación y el precio real de cada ejecución (deslizamiento, en ticks y en USD por contrato), los rolls hechos a tiempo y las diferencias de posición contra el extracto

    Nadie puede ejecutar al precio de liquidación, que se publica después del cierre. Cuatro semanas no bastan para estimar un Sharpe, pero sí para medir el coste real de ejecutar, comprobar el calendario de rolls y conciliar la cuenta todos los días.

  5. Al conectar el nodo a la cuenta de papel, la conciliación encuentra 3 contratos de maíz que el sistema no esperaba. ¿Qué hace un sistema bien diseñado?

    • ALos cierra a mercado inmediatamente
    • BNo opera ese mercado (o ninguno) hasta que una persona explique la diferencia; la anota y avisa
    • CLos incorpora a su objetivo y sigue
    • DIgnora la diferencia, porque en papel no hay dinero en juego
    Ver respuesta

    Respuesta B. No opera ese mercado (o ninguno) hasta que una persona explique la diferencia; la anota y avisa

    Una diferencia sin explicar entre el estado del sistema y el del broker bloquea la operativa (lección 4.5.5). En papel no hay dinero en juego, pero lo que se evalúa es precisamente que el sistema se comporte como lo haría con dinero.

Ideas clave

  • C1 es un sistema de futuros diversificado (tendencia, carry y un spread) con decisión diaria por lotes y ejecución en NautilusTrader contra una cuenta de papel de IBKR; no incluye optimización intradía, opciones ni dinero real.
  • La columna vertebral funciona con los futuros del nivel 3: en 20 mercados en USD, de 2014 a 2025 y neto de costes, tendencia y carry dan un Sharpe de 0,70, el spread trigo-maíz con calendario fuera de muestra 0,61 y el sistema 0,84, con un coste del 0,82 % anual.
  • NautilusTrader, con los mismos objetivos de posición, reproduce el resultado bruto de 2025 al céntimo (615.674,44 USD): la prueba de que la ejecución hace lo que dice la decisión.
  • Las 24 variantes probadas se registran antes de mirar los resultados finales. La selección por walk-forward no mejora la media de las variantes y el DSR con 24 pruebas es 0,998: el criterio de parada, fijado antes, deja pasar el sistema a papel.
  • Los datos son sintéticos y la tendencia está plantada: el esqueleto valida el código, no la estrategia en mercados reales.
  • El paper trading de cuatro semanas mide lo que el backtest no puede: deslizamiento frente a la liquidación, rolls, márgenes, reconexiones y conciliación diaria.

Recursos

Datos