---
title: "7.6.2 Capstone C1: multi-asset futures in NautilusTrader"
description: "Project C1: futures with trend, carry and a spread, executed in NautilusTrader, validated with a trial log and DSR and taken to paper trading at IBKR."
url: "https://pythonparatrading.com/en/python-trading-course/level-7/capstone-multi-asset-futures-nautilustrader/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-7/capstone-multi-asset-futures-nautilustrader.png"
---

# 7.6.2 · Capstone C1: a multi-asset futures system, from forecast to paper account

> The assignment is a Carver-style diversified futures system, with trend, carry and a seasonal spread, that decides in a daily process and executes in NautilusTrader against an Interactive Brokers paper account. This guide sets the scope and the deliverables, builds a backbone that works end to end with the course futures, validates it with a trial log, walk-forward and Deflated Sharpe, and closes with the four-week paper trading plan and the rubric applied.

- Course: [Level 7: Production, MLOps, regulation, career and capstone](https://pythonparatrading.com/en/python-trading-course/level-7/)
- Module: 7.6 · Final capstone
- Duration: 70 min
- Prerequisites: [7.6.1 · Capstone: common requirements, the panel's rubric and preparing the defense](https://pythonparatrading.com/en/python-trading-course/level-7/capstone-requirements-rubric-defense/), [3.1.4 · Diversified futures systems: Carver's approach](https://pythonparatrading.com/en/python-trading-course/level-3/diversified-futures-systems-carver/), [3.2.6 · Seasonality of spreads: multi-year analysis, patterns and traps](https://pythonparatrading.com/en/python-trading-course/level-3/seasonality-of-futures-spreads/), [2.5.2 · In-sample and out-of-sample: anchored and rolling walk-forward](https://pythonparatrading.com/en/python-trading-course/level-2/in-sample-out-of-sample-walk-forward/), [2.5.4 · Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)](https://pythonparatrading.com/en/python-trading-course/level-2/deflated-sharpe-ratio-and-pbo/), [4.5.6 · NautilusTrader or LEAN live: from backtest to production with the same code](https://pythonparatrading.com/en/python-trading-course/level-4/nautilustrader-and-lean-live-trading/), [7.2.1 · Docker and docker-compose: reproducible images with uv](https://pythonparatrading.com/en/python-trading-course/level-7/docker-compose-reproducible-images-uv/)

## By the end of this lesson

- Define the C1 assignment: which system is built, what is out of scope and which verifiable deliverables the panel asks for.
- Build the system's backbone with the course data: trend and carry forecasts, a wheat-corn spread with an out-of-sample calendar, contracts with buffer and costs, and an output report.
- Check that NautilusTrader, fed with the same position targets, reproduces the result of the vectorized calculation to the cent.
- Log every variant tried, apply walk-forward and Deflated Sharpe and decide with a criterion set beforehand whether the system moves to paper trading.
- Plan the four weeks of paper trading at IBKR with limits, shutdown, monitoring and daily reconciliation.

## The assignment

A small asset manager wants to evaluate a systematic futures program before allocating capital to it. It hires you for
the prototype: a diversified system that combines **trend**, **carry** and at least one **spread**, that decides once
a day with closing data, that executes with NautilusTrader and that runs for four weeks against an Interactive Brokers
(IBKR) paper account without anyone touching anything except to handle alerts. The investment committee does not want
a pretty backtest: it wants to know whether the system does what it says, how much it costs to execute, what happens
when something fails and how many things were tried before reaching the final version.

In scope:

- A universe of 15 to 25 futures from at least four sectors (indices, rates, energy, metals, agriculturals,
  currencies), with real contracts and their roll calendar.
- Two families of forecasting rules (trend and carry) in the style of lesson 3.1.4 and a portfolio of spreads with a
  physical rationale (lessons 3.2.2 and 3.2.6), with their calendar estimated out of sample.
- Position sizing by target risk, whole contracts, buffer and costs per side and per roll.
- Anti-overfitting validation with a trial log, walk-forward and Deflated Sharpe (or CPCV).
- Execution with NautilusTrader: an event-driven backtest that reproduces the decision calculation and a live node
  against the paper account, with reconciliation at startup and every morning.
- Monitoring, limits and automatic shutdown.

Out of scope: intraday or high-frequency execution, options on futures, portfolio optimization with expected-return
estimates, trading with real money and paid data that cannot be documented. The client can be fictitious, but the
deliverable is that of a real assignment:

| Deliverable | Form | What the panel checks |
|---|---|---|
| Repository | template from 7.6.1: uv, Docker, `make reproduce` | that one command reproduces the report from scratch |
| Backtest report | JSON + generated tables | figures identical to those in the technical report |
| Trial log | JSONL, append-only | number of trials and their dispersion |
| Execution in NautilusTrader | backtest and live node | same class in both, measured difference |
| Paper trading journal | 4 weeks: orders, fills, reconciliations, alerts | that the system worked and was monitored |
| Technical report and defense | template from 7.6.1, 20 minutes | that the decisions are justified |

## Architecture

The system separates the **decision** from the **execution**. The decision is a batch process that runs once a day
after settlement: it reads prices and the contract calendar, computes forecasts, combines them, sizes positions and
writes a targets file (contracts per instrument). Execution is a NautilusTrader strategy that takes each contract to
its target and is the same class in the backtest and in the live node. It is pysystemtrade's separation (optimal
position on one side, order generation on the other) and it has a practical advantage: the decision can be audited
file by file and execution can be tested against it.

```mermaid
flowchart TB
  D[Data and rolls] --> P[Forecasts]
  P --> C[Combine and IDM]
  C --> R[Risk and buffer]
  R --> O[JSON targets]
  O --> E[Nautilus executor]
  E --> B[IBKR paper]
  B --> K[Reconciliation]
  K --> M[Monitor and alerts]
  M --> R
```

| Piece | Library | Where it was learned |
|---|---|---|
| Contract calendar and roll | pandas | 3.1.1, 3.1.3 |
| EWMAC and carry forecasts, FDM, IDM, buffer | numpy, pandas | 3.1.4 |
| Spreads with the same contracts and seasonal calendar | pandas, statsmodels | 3.2.2, 3.2.5, 3.2.6 |
| Walk-forward, CPCV, DSR, trial log | numpy, scipy | 2.2.4, 2.5.2, 2.5.3, 2.5.4 |
| Backtest engine and live node | NautilusTrader 1.231.0 | 2.4.5, 4.5.6 |
| Reconciliation and startup | NautilusTrader, SQLite | 4.5.5 |
| Containers, CI and secrets | Docker, uv, GitHub Actions | 7.2.1, 7.2.2, 7.2.4 |
| Metrics, alerts and shutdown | prometheus-client, Redis | 7.2.3, 7.3.3 |

The arrow that goes back from the monitor to risk is the important one: a monitor that only raises alerts is not
enough. The shutdown state (lesson 7.3.3) is read by the decision process, which stops opening new positions, and by
the executor, which stops sending orders except those that reduce risk.

## Data

You start with the level 3 synthetic futures: `futuros3_curvas.parquet` (settlement, volume and open interest of all
listed contracts of 23 markets, from 2010 to 2025), `futuros3_contratos.csv` (expiry, first notice and roll date of
each contract) and `futuros3_especificaciones.csv` (multiplier, tick, margins and commission). They imitate real
contracts (the `imita` column: E-mini S&P 500, 10-year note, WTI, gold, corn, wheat…) and their README documents the
known answer: planted trend and carry, and spread seasonality with its peak and its controls. The skeleton uses the 20
markets quoted in USD; the three in the fictitious currency QEV are added in the first exercise, with the conversion
from lesson 3.1.4. For a longer history (2000-2025, 24 markets with a continuous series), there are the level 2
`futuros2_*` files.

With real data, the student documents in the data README the source, license, cost and limitations of each source.
Three reasonable options for this project:

- **IBKR itself**: real-time data and historical bars through the API, which the NautilusTrader adapter requests at
  startup. On its pricing page, the real-time level 1 subscription for CME, CBOT, NYMEX and COMEX costs 1.55 USD a
  month each for a non-professional user (consulted on October 24, 2026); without a subscription, data for those
  markets arrive with a 10-minute delay. The IBKR paper account guide states that the real account's real-time data
  can be shared with it. The API history is good for the roll and the forecasts, not for a fifteen-year backtest.
- **Databento** for CME history with all contracts (lesson 3.2.5, with its symbology conventions): the pricing is
  checked on its website and recorded in the README with the date.
- **Free continuous series** from quote websites: only for exploration. They are usually the front contract without a
  documented roll calendar and with unknown adjustments; a backtest with them cannot be defended.

The data traps of this project are well known: continuous series with an adjustment nobody knows (lesson 1.4.6), rolls
on dates the real system would not have used (based on volume seen in hindsight), contracts that did not exist on the
simulated date, specification changes (multipliers, trading hours) and a universe chosen today from the markets that
have done best. The last one is the survivorship bias of futures: the universe is set with a liquidity criterion as of
the date, not with the result.

## Runnable skeleton

The backbone is seven cells: loading, forecasts, spread, contracts with costs, the same execution in NautilusTrader,
validation and report. Each one points to the lesson where the piece is explained; here it is only assembled.

```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):
    """Contract held, its price, the daily change of the same
    contract in points and the annual carry (lesson 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), "markets ·", len(fechas), "sessions ·",
      fechas[0].date(), "to", fechas[-1].date())
print(" ".join(MERCADOS))
print(huella)
```
```output
20 markets · 4024 sessions · 2010-01-04 to 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'}
```

The fingerprint (the first 12 characters of each file's SHA-256) goes into the final report: if someone regenerates the
data or changes a file, the report says so. It is the minimal version of the data versioning from lesson 7.3.1; in the
project repository, the data go with DVC or with a checksum manifest that the 7.6.1 auditor checks.

```python
ESTIMA = slice("2011", "2013")      # scalars and FDM: these years only
ajustado = dif.fillna(0).cumsum()
sigma_p = dif.ewm(span=35, min_periods=20).std()     # points per day
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}
```

The four EWMAC speeds and the carry are computed as in lesson 3.1.4, with the scalars estimated only on 2011-2013:
the evaluation starts in 2014 and no parameter has been estimated with the years in which it is measured. The
reference configuration (`BASE`) uses that lesson's weights. The other speeds are computed here for the validation's
trial log.

The spread is the piece most prone to self-deception, so its calendar is estimated **each year with the previous
years** (the out-of-sample version of lesson 3.2.6). Wheat and corn have the same delivery months and the same roll
rule in the data, so the two legs change contract on the same day and the spread's daily change has no jumps:

```python
def armonico(d):
    """Day of the peak of the annual drift of a daily change d:
    regression on the increments of cos and sin of the day of year."""
    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 from trough + 10 days to peak - 10; -1 from peak + 10 to
    trough - 10; 0 otherwise (lesson 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)                 # contract return
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}
```

The data README plants the wheat-corn peak on January 14 (day 14). With the four years from 2010 to 2013, the harmonic
places it at the end of September (day 270): in the first year, the spread trades with a calendar shifted by almost
four months. The estimate corrects itself year by year (day 361 in 2018) and from 2019 on it stays between January 3
and January 26. That is what should happen with an honest estimate: the first years pay for the learning, and the
backtest includes it instead of using the calendar seen with the whole sample.

Now everything is converted into contracts. Trend and carry follow the formula from lesson 3.1.4, with 80% of the
risk; the spread gets 20%, with the same notional on both legs. The two optimal positions are added before rounding,
and the buffer and costs are applied to the sum, which is what is traded:

```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 per year
media = (1 - W_SPREAD) * idm * w_i * CAPITAL * TAU / riesgo_ctr
nominal = W_SPREAD * CAPITAL * TAU / vol_sp          # USD per leg
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):
    """Whole contracts; trade only outside optimum ± buffer·average,
    up to the edge. Decided with t - 1 data (lesson 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 = {"trend and carry":
              backtest(contratos(prevision / 10 * media, media))[0],
          "wheat-corn spread": backtest(contratos(opt_sp, media))[0],
          "system": 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} · cost {100 * coste.mean() * 252:.2f} % "
      f"per year · {int((N.diff().abs() > 0).loc['2014':].sum().sum())}"
      " position changes")
```
```output
                   sharpe  vol_pct  rent_pct
trend and carry      0.70    13.54      9.43
wheat-corn spread    0.61     3.87      2.37
system               0.84    14.06     11.77
IDM 2.50 · cost 0.82 % per year · 11535 position changes
```

Net of costs, from 2014 to 2025, trend and carry give a Sharpe of 0.70 with 13.5% volatility; the spread alone, 0.61
with 3.9%; and the system, 0.84 with 14.1% (`rent_pct` is the annualized return). The figure at the top draws the
three cumulative results (sum of daily returns). The spread contributes little return but is almost uncorrelated with
the trend, and that is why it raises the Sharpe of the whole. In twelve years, the portfolio changes the position of
some market 11,535 times (about 960 a year across the 20). The total cost, 0.82% a year, is small relative to the
return with this capital; with 250,000 USD it would not be (exercise in lesson 3.1.4).

These figures are not an estimate of what the system would earn with money. The data are synthetic with planted trend
and carry: the system making money validates that the code recovers what was planted, as in lesson 3.1.4, and nothing
more. What can be defended with these numbers is the mechanics: the forecasts are on the right scale, the cost is
counted, the volatility is close to the target and no parameter was estimated with the years in which it is measured.

The other half is missing: that execution does the same. The NautilusTrader executor receives the targets and nothing
else. In the 2025 backtest, each contract is a `FuturesContract` with its expiry; each session brings one bar per
contract held and, on the roll day, another for the contract being rolled out of, whose target is zero:

```python
import warnings
from decimal import Decimal

from pandas.errors import Pandas4Warning

warnings.filterwarnings("ignore", message="Timestamp.utcnow",
                        category=Pandas4Warning)   # lesson 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):
    """Takes each contract to its target. Decides nothing."""

    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), "contracts ·", len(barras), "bars ·",
      len(fl), "filled orders")
print(f"gross 2025: engine {pyg_motor:,.2f} USD · "
      f"vectorized {pyg_vector:,.2f} USD · "
      f"difference {pyg_motor - pyg_vector:,.2f}")
```
```output
156 contracts · 5136 bars · 1185 filled orders
gross 2025: engine 615,674.44 USD · vectorized 615,674.44 USD · difference 0.00
```

A hundred and fifty-six different contracts in one year, 5,136 bars and 1,185 orders, and the gross result of the
event engine matches the vectorized calculation to the cent: 615,674.44 USD. The comparison is gross on purpose. The
engine has no commission model (each market's costs are different and the `PerContractFeeModel` in version 1.231.0
charges the same per contract across the whole venue) and the margins are set to zero; both are handled separately,
costs in the vectorized calculation and margins in the second exercise. The figures matching says three things the
panel will want to hear: the rolls are executed on the day the calendar says, no target gets lost along the way and
the engine's account and the research account are the same account. If one day they stop matching, the change is in
the executor or in the data, and you know it before sending an order to IBKR.

The execution price, on the other hand, is the settlement price, which nobody gets in reality: it is published after
the close. It is the main difference between this backtest and paper trading, and the one paper trading has to measure.

## Validation

Before looking at the result of the final configuration, you write down what is going to be tried and what result
would force you to stop. In this skeleton, the family of trials is the combination of four sets of trend speeds, three
carry weights and two buffers: 24 variants, all with the spread. Each one goes into an append-only log
(`registro_pruebas.jsonl`) with an identifier derived from its configuration, the period and its Sharpe; in the
project repository, the log also carries the data and code fingerprints (lesson 2.2.4) and the half-finished discarded
ideas, which count too.

```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), "trials; Sharpe from", reg["sharpe"].min(), "to",
      reg["sharpe"].max())

# walk-forward: each year, the best one using the previous years
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("chosen:", " ".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} · best in "
      f"hindsight {tramo.apply(sharpe).max():.2f} · mean of the "
      f"24 {tramo.apply(sharpe).mean():.2f}")


def psr(sr, umbral, t, asim, curt):          # lesson 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()   # daily
for n in (24, 100, 1000):
    u = sr0(n, sr_d.var())
    print(f"N = {n:4d}: threshold {u * np.sqrt(252):.2f} annual · "
          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 trials; Sharpe from 0.7206 to 1.031
chosen: 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 · best in hindsight 1.29 · mean of the 24 1.01
N =   24: threshold 0.19 annual · DSR 0.998
N =  100: threshold 0.24 annual · DSR 0.997
N = 1000: threshold 0.31 annual · DSR 0.994
```

Three readings. First: the 24 variants give Sharpes between 0.72 and 1.03, all positive and very similar; the fastest
ones with the least carry are the best, because the trend planted in the data responds to the sign of the last 252
days. (`las cuatro`, “all four”, is the configuration label for the four speeds together.) Second: the walk-forward,
which each year chooses the best one with the previous years, gives 1.02 over 2017-2025, the same as the mean of the
24 over that stretch (1.01) and quite a bit less than the best seen in hindsight (1.29). Choosing adds nothing out of
sample: the edge belongs to the family of rules, not to the configuration. The practical conclusion for the technical report is
to keep the reference configuration, which was not chosen by looking at these data, and to declare it. Third: the DSR
of the best one with N = 24 is 0.998. The variants are so correlated that their dispersion is small and the threshold
for the expected maximum is low (an annual Sharpe of 0.19). Even with N = 1,000, which is an exaggerated way of
counting, the threshold stays at 0.31.

Such a high DSR is not a virtue of the system: it is the consequence of data with a very strong planted trend. With
real data, a family like this usually has Sharpes of 0.3 to 0.7 and a DSR that depends heavily on how the trials are
counted (lesson 2.5.4): that is where the log makes the difference. If the project prefers CPCV (lesson 2.5.3), the
24-column `rend` matrix is exactly what it needs for the probability of backtest overfitting.

The stopping criterion is written into the project configuration before running this cell, not after:

| Check | Threshold to move to paper trading | Result |
|---|---|---|
| DSR of the chosen one with N = logged trials | ≥ 0.95 | 0.998 |
| Walk-forward Sharpe 2017-2025 | ≥ 0.5 | 1.02 |
| Annual cost relative to gross return | ≤ 25% | 6.5% |
| Engine versus vectorized, gross 2025 | ≤ 1 USD | 0.00 |

The third threshold protects against a system that makes money gross and gives it all away in commissions; the fourth,
against an execution that does not do what the decision says. If any of them fails, the project does not move to
paper trading in that state: the failure is documented, whatever is a bug is fixed and whatever is a result is
accepted. Trying variants again until it passes is exactly what the log exists to make 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
```

The report is the artifact that `make reproduce` regenerates and that the technical report cites. Its keys stay in Spanish, like
the course data (`sharpe_neto` is the net Sharpe, `coste_sobre_bruto` the cost as a fraction of gross, `n_pruebas` the
number of trials; the decision is `paper` or `parar`, stop). Its own fingerprint, the last line, serves what the
reproducibility rubric asks for: the panel clones the repository, runs the documented command and compares that
fingerprint with the one in the technical report. If they match, all the figures in the report are the same; if not, you have
to know why before going on. That is why the report carries no run dates, paths or anything that changes between two
runs (the generation date, if wanted, goes in a separate file).

## Risks and paper trading

The risks that really bring down a diversified futures system are not the backtest's:

| Risk | How it shows up | Defense in the system |
|---|---|---|
| Failed roll | the contract approaches first notice or expiry with a position | own roll calendar; alert 3 sessions before an unexecuted roll; contracts in the delivery period blocked |
| Margin | a losing streak or an exchange margin increase forces a reduction | limit on initial margin used (e.g. 30% of capital) checked before each order |
| Crisis correlations | the IDM was estimated in calm markets; in a crisis all markets move together | IDM capped at 2.5; limit on the portfolio's realized volatility; proportional reduction |
| Price limits and gaps | an agricultural market closes limit-locked or opens with a gap | limit orders with a band; do not chase the price; retry in the next session |
| Data | an erroneous settlement drives a forecast to the cap | check of jumps against volatility before deciding; the target changes by at most one average position per day |
| Specification error | multiplier or tick loaded incorrectly | reconciliation of notionals against the IBKR statement on the first day |
| Connection | nightly gateway restart; disconnections | adapter reconnection, reconciliation at startup, heartbeat |

Shutdown follows the graduated policy of lesson 7.3.3, with states that both the decision process and the executor
read:

```mermaid
stateDiagram-v2
  [*] --> RECONCILING
  RECONCILING --> TRADING
  RECONCILING --> BLOCKED
  TRADING --> REDUCING
  REDUCING --> TRADING
  REDUCING --> SHUTDOWN
  TRADING --> SHUTDOWN
  BLOCKED --> RECONCILING
  SHUTDOWN --> RECONCILING
```

`REDUCING` halves the targets: it is entered on a drawdown from the peak larger than twice the monthly target
volatility or on margin use above the limit. `SHUTDOWN` takes all targets to zero (only orders that reduce risk) and
requires a manual re-arm: it is entered on a drawdown larger than the worst in the backtest, on three consecutive days
without data for a sector or on an unexplained position difference. `BLOCKED` is the startup reconciliation state
(lesson 4.5.5): the system sends nothing while anything remains unexplained. The specific thresholds are set in the
configuration before paper trading and justified in the technical report; what is not done is changing them halfway through
the four weeks because an alert is annoying.

The live node is the same `Ejecutor` class, with two changes: the targets come from a file that the decision process
writes after settlement, and the orders are sent at a fixed time in the next session. The configuration follows the
documentation of the IBKR adapter in NautilusTrader 1.231.0: TWS in paper mode listens on port 7497 (IB Gateway, on
4002), individual contracts use the simplified symbology `{local symbol}.{exchange}` with one digit for the year
(`ESZ6.CME`), and the paper account example uses an identifier of the form `DU123456`. It is not run in the course: it
needs the package's `ib` extra, TWS or IB Gateway running and an account.

```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 + timer
from c1.config import ajustes              # pydantic-settings, 7.1.1

CONTRATOS = ["ESZ6.CME", "ZNZ6.CBOT", "CLF7.NYMEX", "GCZ6.COMEX",
             "ZCZ6.CBOT", "ZWZ6.CBOT"]   # regenerated at every 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…”, no secrets
        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,       # fixed, within the session
    estado_permiso="redis://127.0.0.1:6379/0",   # lesson 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()
```

Two details from the adapter documentation affect the plan: it mentions the gateway's nightly restart, and the adapter
treats the data-farm outages at those hours as transient; and it is advisable to reserve a band of identifiers
(`ibg_client_id`) for each client, because after a restart the previous identifier may still be in use. The live
executor's timer (`clock.set_timer` in the strategy) must not coincide with that window.

The plan for the four weeks (28 calendar days, about 20 CME sessions) has one rule: **the decision code is not changed
during paper trading**. A bug that forces a change restarts the counter. Each day goes into the journal:

- Before the session: reconciliation of positions, orders and cash in the paper account against the system's state
  (lessons 4.5.5 and 4.5.6); the previous day's decision report and its fingerprint.
- During: orders sent, fills, rejections, reconnections; metrics in Prometheus and alerts (lesson 7.2.3); the
  executor's heartbeat.
- After: slippage of each fill against the day's settlement, in ticks and in USD per contract; rolls done and pending;
  margin used; state of the shutdown policy.

At the end of the four weeks, the paper trading report compares the measured execution cost with the backtest's
assumption (half a tick plus commission): if the measured one is higher, the backtest is rerun with the measured cost
and the result goes into the technical report. The four-week Sharpe is not interpreted: with 20 sessions its standard error is
several units. What is assessed is that the system worked, that each difference with the broker was explained and
that the costs were measured.

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

A paper account does not reproduce the queue, the market impact or some restrictions of the real account, and its
execution price is a simulation by the broker. Paper trading serves to test the system, not its profitability. Moving
to real money afterwards is a separate decision that is not part of the capstone, and it requires reviewing the
applicable regulation (module 7.4, on the Spanish and EU framework).

</aside>

## Milestones and rubric

The schedule is that of 7.6.1 (ten weeks, 40 to 80 hours), applied to C1:

| Week | Verifiable deliverable |
|---|---|
| 1 | Repository from the template; data README with sources, licenses and fingerprints; 7.6.1 auditor green |
| 2 | Loading, contract calendar and roll; tests of the rolls against `futuros3_contratos.csv` |
| 3 | Forecasts, combination, contracts, buffer and costs; reproducible result by component |
| 4 | Trial log, walk-forward and DSR or CPCV; stopping criterion applied; JSON report |
| 5 | NautilusTrader executor with a difference of 0 against the vectorized version; live node connected to paper; Docker and monitoring |
| 6-9 | Four weeks of paper trading with a journal; technical report in parallel |
| 10 | Paper trading report, measured costs and recomputed backtest; rehearsal and defense |

The common rubric from 7.6.1, with the specific evidence it asks for in this project:

| Criterion (weight) | Evidence in C1 |
|---|---|
| Reproducibility (15%) | `make reproduce` regenerates `informe_c1.json` with the fingerprint in the technical report; uv.lock, Docker image, seeds, fingerprinted data |
| Data (15%) | source, license and cost of each source; documented roll calendar; universe set by liquidity as of the date; continuous-series traps handled |
| Validation (20%) | complete log (including what was discarded), walk-forward, DSR with the log's N or CPCV, stopping criterion written beforehand |
| Risk and execution (15%) | costs per side and per roll; whole contracts and buffer; margin and volatility limits; shutdown states tested with simulated failures |
| Paper trading (15%) | 28 days with a journal; daily reconciliation with no unexplained differences; slippage measured against settlement; rolls on time |
| Code (10%) | package with tests for rolls, contracts and the executor; ruff and pre-commit; validated configuration |
| Technical report and defense (10%) | decisions justified with figures from the report; limits of the study; answers to the typical questions |

Mistakes that fail this project on their own, in addition to the common ones: a backtest that computes the result on a
percentage-adjusted continuous series instead of contract by contract without saying so (lesson 3.1.4 explains why the
result changes), a spread calendar chosen with the whole sample and presented as out of sample, and paper trading in
which the decision code changed without restarting the counter.

## Exercises

1. Add the three markets in QEV (`RVE`, `RVH`, `TPE`) with the conversion from lesson 3.1.4 (`fx3_diario.parquet`)
   and recompute the system. Does the IDM change? And the net Sharpe?

**Solution.**

```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} · net Sharpe "
      f"{sharpe(neto):.2f} -> {sharpe(neto2):.2f}")
```
```output
IDM 2.50 -> 2.50 · net Sharpe 0.84 -> 0.92
```

With the 23 markets, the IDM stays at the 2.5 cap and the net Sharpe rises from 0.84 to 0.92: three more markets (two
indices and a bond in QEV) spread the risk across more sources. With a Sharpe whose standard error is close to 0.3
over twelve years, the improvement is small relative to the noise; the argument for including them is
diversification, not this figure. The conversion matters in two places, the risk of a contract (for the size) and the
result in USD; forgetting it in the first one sizes positions incorrectly and in the second one mixes currencies. In
the real project, in addition, the position in a foreign currency generates a balance in that currency in the account
that has to be converted or hedged, and NautilusTrader needs a multi-currency account (`base_currency=None`) to track
it.

2. Compute the initial margin the portfolio `N` would have required each day of 2025 with the per-contract margins in
   `futuros3_especificaciones.csv` and check whether a limit of 30% of capital would have blocked any order.

**Solution.**

```python
margen = (N.abs() * esp.loc[MERCADOS, "margen_inicial"]).sum(axis=1)
uso = (margen / CAPITAL).loc["2025"]
print(f"margin used in 2025: median {100 * uso.median():.1f} %, "
      f"max {100 * uso.max():.1f} % ({uso.idxmax().date()})")
print("sessions above 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
margin used in 2025: median 11.8 %, max 14.6 % (2025-02-10)
sessions above 30 %: 0
{'divisas': 0.0, 'metales': 1.2, 'indices': 2.0, 'agricolas': 3.8, 'energia': 3.8, 'tipos': 3.8}
```

In 2025 the portfolio uses a median of 11.8% of capital in initial margin, with a maximum of 14.6% on February 10: no
session goes above 30%, so the limit would not have blocked anything. On the day of the maximum, rates (`tipos`),
energy (`energia`) and agriculturals (`agricolas`) weigh the same (3.8% each) and currencies (`divisas`) nothing (there
is only one market, with no position that day). The limit not triggering in the backtest does not make it useless:
broker margins change with volatility and can rise sharply on a crisis day, and the system has to check them before
each order with the margins the broker publishes, not with those in the file.

3. A panel member asks: “Before these 24 variants you tried the gasoline crack spread and gold-silver, and you
   discarded them. Are they in the log? Does it change anything?” What do you answer?

**Solution.**

That they must be, because the log exists precisely for that: every idea tried and discarded is a trial, even if it
never made it to the final configuration. They change the number of trials (from 24 to 26 or more, depending on how
many variants of each spread were tried) and, above all, the dispersion of the Sharpes: a control spread like
gold-silver, with a Sharpe very different from those of the trend and carry family, increases the variance and with
it the threshold for the expected maximum. The DSR will drop somewhat; with these data it will stay above 0.95, but
what matters is that the declared number is the real one. Hiding the discarded trials is one of the mistakes that fail
a project on their own (7.6.1), regardless of the result.

## Self-assessment

1. In the skeleton, why does NautilusTrader receive position targets computed separately instead of recomputing the forecasts inside `on_bar`?

   - A. Because NautilusTrader does not support strategies with more than ten instruments
   - B. Because that way the backtest runs faster and nothing else matters
   - C. Because the daily decision of a system of this kind is a batch calculation over the whole portfolio (forecasts, IDM, buffer) that can be audited file by file, and execution only has to take each contract to its target; separating the two makes it possible to check that the engine reproduces the calculation and to use the same class in paper trading
   - D. Because Interactive Brokers requires receiving a targets file

   *Answer C: Because the daily decision of a system of this kind is a batch calculation over the whole portfolio (forecasts, IDM, buffer) that can be audited file by file, and execution only has to take each contract to its target; separating the two makes it possible to check that the engine reproduces the calculation and to use the same class in paper trading.* pysystemtrade likewise separates the calculation of optimal positions from order generation. In the skeleton, the event engine with the same targets gives the same gross result as the vectorized version (a difference of 0.00 USD in 2025), and that validates execution without rewriting the decision logic in two places.

2. The best of the 24 logged variants has a Sharpe of 1.03 between 2014 and 2025. Chosen each year with the previous years (walk-forward), the selection gives 1.02 between 2017 and 2025, and the mean of the 24 over that stretch is 1.01. What do you conclude?

   - A. That choosing between variants adds nothing out of sample: what wins is the family of rules, and the reference configuration, unoptimized, is as defensible as the best one
   - B. That you should keep the 1.03 variant, because it is the best
   - C. That the walk-forward is badly programmed
   - D. That the system is overfitted and must be abandoned

   *Answer A: That choosing between variants adds nothing out of sample: what wins is the family of rules, and the reference configuration, unoptimized, is as defensible as the best one.* The Sharpe of the out-of-sample selection matches the mean of the variants: the 1.29 of the best in hindsight over 2017-2025 is a choice made with data already seen. It is not harmful overfitting because all the variants make money; it is that optimizing adds nothing, and it is worth declaring it that way to the panel.

3. Which of these situations fails the project on its own, even if everything else is excellent?

   - A. That the paper trading Sharpe is negative over the four weeks
   - B. That the trial log has 24 rows and the technical report says that only the final configuration was tried
   - C. That the system trades 20 markets instead of 23
   - D. That paper execution has more slippage than the backtest

   *Answer B: That the trial log has 24 rows and the technical report says that only the final configuration was tried.* Hiding the number of trials is one of the mistakes that fail a project on their own (rubric in 7.6.1): the DSR and any conclusion depend on it. A bad result over four weeks of paper trading, on the other hand, says nothing about the strategy; what is assessed is that the system works, is monitored and is reconciled.

4. The backtest executes at each session's settlement price. What does paper trading have to measure with respect to that?

   - A. Nothing: if the code is the same, so is the price
   - B. Only whether orders are rejected
   - C. The four-week Sharpe, to compare it with the backtest's
   - D. The difference between the settlement price and the actual price of each fill (slippage, in ticks and in USD per contract), the rolls done on time and the position differences against the statement

   *Answer D: The difference between the settlement price and the actual price of each fill (slippage, in ticks and in USD per contract), the rolls done on time and the position differences against the statement.* Nobody can execute at the settlement price, which is published after the close. Four weeks are not enough to estimate a Sharpe, but they are enough to measure the real cost of executing, check the roll calendar and reconcile the account every day.

5. When the node connects to the paper account, reconciliation finds 3 corn contracts the system did not expect. What does a well-designed system do?

   - A. It closes them at market immediately
   - B. It does not trade that market (or any) until a person explains the difference; it records it and raises an alert
   - C. It adds them to its target and carries on
   - D. It ignores the difference, because there is no money at stake in paper trading

   *Answer B: It does not trade that market (or any) until a person explains the difference; it records it and raises an alert.* An unexplained difference between the system's state and the broker's blocks trading (lesson 4.5.5). There is no money at stake in paper trading, but what is assessed is precisely that the system behaves as it would with money.

## Key points

- C1 is a diversified futures system (trend, carry and a spread) with a daily batch decision and execution in NautilusTrader against an IBKR paper account; it does not include intraday optimization, options or real money.
- The backbone works with the level 3 futures: in 20 USD markets, from 2014 to 2025 and net of costs, trend and carry give a Sharpe of 0.70, the wheat-corn spread with an out-of-sample calendar 0.61 and the system 0.84, at a cost of 0.82% a year.
- NautilusTrader, with the same position targets, reproduces the 2025 gross result to the cent (615,674.44 USD): proof that execution does what the decision says.
- The 24 variants tried are logged before looking at the final results. The walk-forward selection does not improve on the mean of the variants and the DSR with 24 trials is 0.998: the stopping criterion, set beforehand, lets the system move to paper trading.
- The data are synthetic and the trend is planted: the skeleton validates the code, not the strategy in real markets.
- Four weeks of paper trading measure what the backtest cannot: slippage against settlement, rolls, margins, reconnections and daily reconciliation.

## Resources

### Research

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

### Documentation

- [NautilusTrader 1.231.0: Interactive Brokers integration (documentation)](https://github.com/nautechsystems/nautilus_trader/blob/v1.231.0/docs/integrations/ib.md) · github.com
- [Interactive Brokers: managing the paper trading account (Client Portal)](https://www.ibkrguides.com/clientportal/papertradingaccount.htm) · ibkrguides.com
- [Interactive Brokers: market data pricing](https://www.interactivebrokers.com/en/pricing/research-news-marketdata.php) · interactivebrokers.com
- [pysystemtrade: default configuration (forecast scaling, buffer, multipliers)](https://github.com/robcarver17/pysystemtrade/blob/a62269d27707806ba61f516791b71a9cd14cf268/sysdata/config/defaults.yaml) · github.com

### Books

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

### Data

- [Level 3 data: complete futures curves (Parquet)](https://pythonparatrading.com/curso/datos/nivel-3/futuros3_curvas.parquet) · Download · futuros3_curvas.parquet

Previous lesson: [7.6.1 · Capstone: common requirements, the panel's rubric and preparing the defense](https://pythonparatrading.com/en/python-trading-course/level-7/capstone-requirements-rubric-defense/)

Next lesson: [7.6.3 · Capstone C2: systematic options book, volatility surface and real-time Greeks](https://pythonparatrading.com/en/python-trading-course/level-7/capstone-options-book-volatility-greeks/)
