Lesson 7.6.2 · 70 min

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.

Fig. — Cumulative net result (sum of daily returns, net of costs) of the C1 skeleton from 2014 to 2025, by part: trend and carry, wheat-corn spread and full system (level 3 synthetic data)
Level 7 lessons
  1. 7.1 · Software engineering for trading

    1. 7.1.1 Project structure, configuration and quality: from notebook to repository
    2. 7.1.2 Performance: measure, profile and compile only what is needed
    3. 7.1.3 Patterns: events, message queues and hexagonal architecture
  2. 7.2 · Infrastructure and deployment

    1. 7.2.1 Docker and docker-compose: reproducible images with uv
    2. 7.2.2 VPS, cloud and latency; CI/CD with GitHub Actions
    3. 7.2.3 Orchestration and monitoring: task graphs, structured logs, metrics, alerts and heartbeats
    4. 7.2.4 Security and continuity: 2FA, IP allowlists, backups and failure procedures
  3. 7.3 · MLOps for trading models

    1. 7.3.1 Tracking experiments, models and data: MLflow, DVC and a run reproduced end to end
    2. 7.3.2 Scheduled retraining, automatic validation and champion/challenger
    3. 7.3.3 Live model monitoring and automatic shutdown
    4. 7.3.4 Model serving: ONNX, CPU or GPU inference and the cost of LLMs in production
  4. 7.4 · Regulation, ethics and taxation

    1. 7.4.1 MiFID II: clients, suitability and algorithmic trading
    2. 7.4.2 ESMA and CFDs: leverage, close-out and negative balance
    3. 7.4.3 PRIIPs, the KID and US ETFs
    4. 7.4.4 MiCA after the transitional period: checking a provider in the registers
    5. 7.4.5 Market abuse: spoofing, layering and the bot that imitates them by accident
    6. 7.4.6 Trading taxes in Spain
    7. 7.4.7 Ethics: personal data, artificial intelligence and selling signals
  5. 7.5 · Quant career

    1. 7.5.1 Quant roles: six jobs around the same system
    2. 7.5.2 Where to work: what each firm does and what the registers say
    3. 7.5.3 Public portfolio and interviews: show judgment, not returns
    4. 7.5.4 Certifications, training and prop firms: what each one certifies and what a challenge measures
  6. 7.6 · Final capstone

    1. 7.6.1 Capstone: common requirements, the panel's rubric and preparing the defense
    2. 7.6.2 Capstone C1: a multi-asset futures system, from forecast to paper account
    3. 7.6.3 Capstone C2: systematic options book, volatility surface and real-time Greeks
    4. 7.6.4 Capstone C3: stock and ETF portfolio with ML, HRP and Spanish taxation
    5. 7.6.5 Capstone C4: a research platform with LLMs and alternative data
    6. 7.6.6 Capstone C5: crypto market making or arbitrage with counterparty control

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:

DeliverableFormWhat the panel checks
Repositorytemplate from 7.6.1: uv, Docker, make reproducethat one command reproduces the report from scratch
Backtest reportJSON + generated tablesfigures identical to those in the technical report
Trial logJSONL, append-onlynumber of trials and their dispersion
Execution in NautilusTraderbacktest and live nodesame class in both, measured difference
Paper trading journal4 weeks: orders, fills, reconciliations, alertsthat the system worked and was monitored
Technical report and defensetemplate from 7.6.1, 20 minutesthat 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.

Data and rollsForecastsCombine and IDMRisk and bufferJSON targetsNautilus executorIBKR paperReconciliationMonitor and alerts
Mermaid source
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
PieceLibraryWhere it was learned
Contract calendar and rollpandas3.1.1, 3.1.3
EWMAC and carry forecasts, FDM, IDM, buffernumpy, pandas3.1.4
Spreads with the same contracts and seasonal calendarpandas, statsmodels3.2.2, 3.2.5, 3.2.6
Walk-forward, CPCV, DSR, trial lognumpy, scipy2.2.4, 2.5.2, 2.5.3, 2.5.4
Backtest engine and live nodeNautilusTrader 1.231.02.4.5, 4.5.6
Reconciliation and startupNautilusTrader, SQLite4.5.5
Containers, CI and secretsDocker, uv, GitHub Actions7.2.1, 7.2.2, 7.2.4
Metrics, alerts and shutdownprometheus-client, Redis7.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.

IN [01]python
import hashlib
import json

import numpy as np
import pandas as pd

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


def serie(mercado):
    """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)
OUT [01]Loading: 20 USD futures and data fingerprint
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.

IN [02]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())
OUT [02]Forecasts: scaled trend and carry, FDM
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:

IN [03]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()})
OUT [03]Wheat-corn spread: calendar estimated with the previous years
{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:

IN [04]python
CAPITAL, TAU, W_SPREAD = 5_000_000, 0.20, 0.20
normal = (dif / sigma_p.shift(1)).loc[ESTIMA]
w_i = pd.Series(1 / len(MERCADOS), index=MERCADOS)
idm = min(2.5, 1 / np.sqrt(w_i @ normal.corr() @ w_i))
riesgo_ctr = sigma_p * np.sqrt(252) * mult          # USD 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")
OUT [04]Contracts, buffer, costs and net result by component
                   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:

IN [05]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}")
OUT [05]The targets executor in NautilusTrader, 2025
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.

IN [06]python
import itertools

from scipy.stats import norm

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

CheckThreshold to move to paper tradingResult
DSR of the chosen one with N = logged trials≥ 0.950.998
Walk-forward Sharpe 2017-2025≥ 0.51.02
Annual cost relative to gross return≤ 25%6.5%
Engine versus vectorized, gross 2025≤ 1 USD0.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.

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

RiskHow it shows upDefense in the system
Failed rollthe contract approaches first notice or expiry with a positionown roll calendar; alert 3 sessions before an unexecuted roll; contracts in the delivery period blocked
Margina losing streak or an exchange margin increase forces a reductionlimit on initial margin used (e.g. 30% of capital) checked before each order
Crisis correlationsthe IDM was estimated in calm markets; in a crisis all markets move togetherIDM capped at 2.5; limit on the portfolio's realized volatility; proportional reduction
Price limits and gapsan agricultural market closes limit-locked or opens with a gaplimit orders with a band; do not chase the price; retry in the next session
Dataan erroneous settlement drives a forecast to the capcheck of jumps against volatility before deciding; the target changes by at most one average position per day
Specification errormultiplier or tick loaded incorrectlyreconciliation of notionals against the IBKR statement on the first day
Connectionnightly gateway restart; disconnectionsadapter 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:

RECONCILINGTRADINGBLOCKEDREDUCINGSHUTDOWN
Mermaid source
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.

IN [08]ejecutor_en_papel.pypython
from datetime import timedelta

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

from c1.ejecucion import EjecutorEnVivo   # Ejecutor + 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.

Milestones and rubric

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

WeekVerifiable deliverable
1Repository from the template; data README with sources, licenses and fingerprints; 7.6.1 auditor green
2Loading, contract calendar and roll; tests of the rolls against futuros3_contratos.csv
3Forecasts, combination, contracts, buffer and costs; reproducible result by component
4Trial log, walk-forward and DSR or CPCV; stopping criterion applied; JSON report
5NautilusTrader executor with a difference of 0 against the vectorized version; live node connected to paper; Docker and monitoring
6-9Four weeks of paper trading with a journal; technical report in parallel
10Paper 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
IN [09]python
fx = pd.read_parquet("fx3_diario.parquet")
usd_qev = (fx[fx["divisa"] == "QEV"].set_index("fecha")
           ["usd_por_unidad"].reindex(fechas).ffill())
TODOS = list(esp.index[esp["mercado_base"].isna()])
S.update({m: serie(m) for m in TODOS if m not in S})
P2 = pd.DataFrame({m: S[m]["precio"] for m in TODOS}).reindex(fechas)
D2 = pd.DataFrame({m: S[m]["dif"] for m in TODOS}).reindex(fechas)
C2 = pd.DataFrame({m: S[m]["carry"] for m in TODOS}).reindex(fechas)
cambio2 = pd.DataFrame({m: usd_qev if esp.at[m, "divisa"] == "QEV"
                        else 1.0 for m in TODOS}, index=fechas)
sig2 = D2.ewm(span=35, min_periods=20).std()
aj2 = D2.fillna(0).cumsum()


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


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

  1. 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
IN [10]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())
OUT [10]Initial margin used in 2025
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.

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

Each answer is folded under its question

Self-assessment

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

    • ABecause NautilusTrader does not support strategies with more than ten instruments
    • BBecause that way the backtest runs faster and nothing else matters
    • CBecause 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
    • DBecause Interactive Brokers requires receiving a targets file
    Show answer

    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?

    • AThat 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
    • BThat you should keep the 1.03 variant, because it is the best
    • CThat the walk-forward is badly programmed
    • DThat the system is overfitted and must be abandoned
    Show answer

    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?

    • AThat the paper trading Sharpe is negative over the four weeks
    • BThat the trial log has 24 rows and the technical report says that only the final configuration was tried
    • CThat the system trades 20 markets instead of 23
    • DThat paper execution has more slippage than the backtest
    Show answer

    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?

    • ANothing: if the code is the same, so is the price
    • BOnly whether orders are rejected
    • CThe four-week Sharpe, to compare it with the backtest's
    • DThe 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
    Show answer

    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?

    • AIt closes them at market immediately
    • BIt does not trade that market (or any) until a person explains the difference; it records it and raises an alert
    • CIt adds them to its target and carries on
    • DIt ignores the difference, because there is no money at stake in paper trading
    Show answer

    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

Data