---
title: "7.6.2 Capstone C1: futuros multi-activo en NautilusTrader"
description: "Proyecto C1: futuros con tendencia, carry y un spread, ejecutados en NautilusTrader, validados con registro de pruebas y DSR y llevados a papel en IBKR."
url: "https://pythonparatrading.com/curso-python-trading/nivel-7/capstone-c1-futuros-multiactivo-nautilustrader/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-7/capstone-c1-futuros-multiactivo-nautilustrader.png"
---

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

- Curso: [Nivel 7: Producción, MLOps, regulación, carrera y capstone](https://pythonparatrading.com/curso-python-trading/nivel-7/)
- Módulo: 7.6 · Capstone final
- Duración: 70 min
- Requisitos: [7.6.1 · Capstone: requisitos comunes, rúbrica del tribunal y preparación de la defensa](https://pythonparatrading.com/curso-python-trading/nivel-7/capstone-requisitos-rubrica-y-defensa/), [3.1.4 · Sistemas de futuros diversificados: el enfoque de Carver](https://pythonparatrading.com/curso-python-trading/nivel-3/sistemas-de-futuros-diversificados-carver/), [3.2.6 · Estacionalidad de spreads: análisis multi-año, patrones y trampas](https://pythonparatrading.com/curso-python-trading/nivel-3/estacionalidad-de-spreads/), [2.5.2 · In-sample y out-of-sample: walk-forward anclado y móvil](https://pythonparatrading.com/curso-python-trading/nivel-2/in-sample-out-of-sample-y-walk-forward/), [2.5.4 · Deflated Sharpe Ratio y Probability of Backtest Overfitting (PBO)](https://pythonparatrading.com/curso-python-trading/nivel-2/deflated-sharpe-ratio-y-pbo/), [4.5.6 · NautilusTrader o LEAN en real: del backtest a producción con el mismo código](https://pythonparatrading.com/curso-python-trading/nivel-4/nautilustrader-y-lean-en-real/), [7.2.1 · Docker y docker-compose: imágenes reproducibles con uv](https://pythonparatrading.com/curso-python-trading/nivel-7/docker-y-docker-compose-con-uv/)

## Al terminar esta lección

- Delimitar el encargo C1: qué sistema se construye, qué no entra y qué entregables verificables pide el tribunal.
- Montar la columna vertebral del sistema con los datos del curso: previsiones de tendencia y carry, un spread trigo-maíz con calendario fuera de muestra, contratos con buffer y costes, y un informe de salida.
- Comprobar que NautilusTrader, alimentado con los mismos objetivos de posición, reproduce al céntimo el resultado del cálculo vectorizado.
- Registrar todas las variantes probadas, aplicar walk-forward y Deflated Sharpe y decidir con un criterio fijado antes si el sistema pasa a papel.
- Planificar las cuatro semanas de paper trading en IBKR con límites, desactivación, monitorización y conciliación diaria.

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

| Entregable | Forma | Lo que comprueba el tribunal |
|---|---|---|
| Repositorio | plantilla de 7.6.1: uv, Docker, `make reproduce` | que un comando reproduce el informe desde cero |
| Informe del backtest | JSON + tablas generadas | cifras iguales a las de la memoria |
| Registro de pruebas | JSONL, solo de añadir | número de pruebas y su dispersión |
| Ejecución en NautilusTrader | backtest y nodo en vivo | misma clase en los dos, diferencia medida |
| Diario de paper trading | 4 semanas: órdenes, ejecuciones, conciliaciones, alertas | que el sistema funcionó y se vigiló |
| Memoria técnica y defensa | plantilla de 7.6.1, 20 minutos | que 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.

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

| Pieza | Librería | Dónde se aprendió |
|---|---|---|
| Calendario de contratos y roll | pandas | 3.1.1, 3.1.3 |
| Previsiones EWMAC y carry, FDM, IDM, buffer | numpy, pandas | 3.1.4 |
| Spreads con los mismos contratos y calendario estacional | pandas, statsmodels | 3.2.2, 3.2.5, 3.2.6 |
| Walk-forward, CPCV, DSR, registro de pruebas | numpy, scipy | 2.2.4, 2.5.2, 2.5.3, 2.5.4 |
| Motor de backtest y nodo en vivo | NautilusTrader 1.231.0 | 2.4.5, 4.5.6 |
| Conciliación y arranque | NautilusTrader, SQLite | 4.5.5 |
| Contenedores, CI y secretos | Docker, uv, GitHub Actions | 7.2.1, 7.2.2, 7.2.4 |
| Métricas, alertas y desactivación | prometheus-client, Redis | 7.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.

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

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

```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()})
```
```output
{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:

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

```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}")
```
```output
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.

```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)
```
```output
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ón | Umbral para pasar a papel | Resultado |
|---|---|---|
| DSR de la elegida con N = pruebas registradas | ≥ 0,95 | 0,998 |
| Sharpe walk-forward 2017-2025 | ≥ 0,5 | 1,02 |
| Coste anual frente a la rentabilidad bruta | ≤ 25 % | 6,5 % |
| Motor frente a vectorizado, bruto 2025 | ≤ 1 USD | 0,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.

```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])
```
```output
{"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:

| Riesgo | Cómo aparece | Defensa en el sistema |
|---|---|---|
| Roll fallido | el contrato se acerca al primer aviso o al vencimiento con posición | calendario de roll propio; alerta a 3 sesiones del roll sin ejecutar; bloqueo de contratos en periodo de entrega |
| Margen | una racha de pérdidas o una subida de márgenes del mercado obliga a reducir | límite de margen inicial usado (p. ej. 30 % del capital) comprobado antes de cada orden |
| Correlaciones de crisis | el IDM se estimó en calma; en una crisis todos los mercados se mueven juntos | IDM con tope 2,5; límite de volatilidad realizada de la cartera; reducción proporcional |
| Límites de precio y huecos | un mercado agrícola cierra en límite o abre con hueco | órdenes limitadas con banda; no perseguir el precio; reintentar la sesión siguiente |
| Datos | una liquidación errónea dispara una previsión en el tope | comprobació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ón | multiplicador o tick mal cargado | conciliación de nominales contra el extracto de IBKR el primer día |
| Conexión | reinicio nocturno del gateway; desconexiones | reconexió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:

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

```python skip title="ejecutor_en_papel.py"
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.

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

Una cuenta de papel no reproduce la cola, el impacto ni algunas restricciones de la cuenta real, y su precio de
ejecución es una simulación del broker. El papel sirve para probar el sistema, no la rentabilidad. Pasar después a
dinero real es una decisión aparte que no forma parte del capstone, y que exige revisar la normativa aplicable (módulo
7.4).

</aside>

## Hitos y rúbrica

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

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

```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}")
```
```output
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.

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

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

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

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

   - A. Porque NautilusTrader no admite estrategias con más de diez instrumentos
   - B. Porque así el backtest va más deprisa y no importa nada más
   - 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
   - D. Porque Interactive Brokers exige recibir un fichero de objetivos

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

   - 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
   - B. Que hay que quedarse con la variante de 1,03, porque es la mejor
   - C. Que el walk-forward está mal programado
   - D. Que el sistema está sobreajustado y hay que abandonarlo

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

   - A. Que el Sharpe del paper trading sea negativo en las cuatro semanas
   - B. Que el registro de pruebas tenga 24 filas y la memoria diga que solo se probó la configuración final
   - C. Que el sistema opere 20 mercados en vez de 23
   - D. Que la ejecución en papel tenga más deslizamiento que el backtest

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

   - A. Nada: si el código es el mismo, el precio también
   - B. Solo si las órdenes se rechazan
   - C. El Sharpe de las cuatro semanas, para compararlo con el del backtest
   - 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

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

   - A. Los cierra a mercado inmediatamente
   - B. No opera ese mercado (o ninguno) hasta que una persona explique la diferencia; la anota y avisa
   - C. Los incorpora a su objetivo y sigue
   - D. Ignora la diferencia, porque en papel no hay dinero en juego

   *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

### Research

- [Volatility targeting](https://pythonparatrading.com/volatility-targeting/) · Research-0004 · 8 min

### Documentación

- [NautilusTrader 1.231.0: integración con Interactive Brokers (documentación)](https://github.com/nautechsystems/nautilus_trader/blob/v1.231.0/docs/integrations/ib.md) · github.com
- [Interactive Brokers: gestión de la cuenta de papel (Client Portal)](https://www.ibkrguides.com/clientportal/papertradingaccount.htm) · ibkrguides.com
- [Interactive Brokers: precios de los datos de mercado](https://www.interactivebrokers.com/en/pricing/research-news-marketdata.php) · interactivebrokers.com
- [pysystemtrade: configuración por defecto (escalado de previsiones, buffer, multiplicadores)](https://github.com/robcarver17/pysystemtrade/blob/a62269d27707806ba61f516791b71a9cd14cf268/sysdata/config/defaults.yaml) · github.com

### Libros

- [Carver, R. (2023): Advanced Futures Trading Strategies. Harriman House](https://www.harriman-house.com/authors/robert-carver/advanced-futures-trading-strategies/9780857199683) · harriman-house.com

### Datos

- [Datos del nivel 3: curvas completas de futuros (Parquet)](https://pythonparatrading.com/curso/datos/nivel-3/futuros3_curvas.parquet) · Descarga · futuros3_curvas.parquet

Lección anterior: [7.6.1 · Capstone: requisitos comunes, rúbrica del tribunal y preparación de la defensa](https://pythonparatrading.com/curso-python-trading/nivel-7/capstone-requisitos-rubrica-y-defensa/)

Lección siguiente: [7.6.3 · Capstone C2: libro de opciones sistemático, superficie de volatilidad y griegas en tiempo real](https://pythonparatrading.com/curso-python-trading/nivel-7/capstone-c2-libro-de-opciones-y-griegas/)
