Lesson 3.2.5 · 70 min

Spread data and backtesting: listed, synthetic and legged

A futures spread can be traded as an instrument of its own, with its own book, or assembled leg by leg. The two leave different footprints in the data and in the result. This lesson measures the difference between the spread's book and the one built from its legs, writes a backtest that carries each leg with its contracts and its costs, quantifies the risk of executing one leg without the other and shows how to request the real data from Databento with a local simulator of its API.

Fig. — Legging risk in the fictional crude December-January calendar: standard deviation of the cost of buying the spread leg by leg (near now, far k minutes later) against the change in the spread's own ask over the same time
Level 3 lessons
  1. 3.1 · Futures in depth

    1. 3.1.1 Main futures contracts
    2. 3.1.2 Futures curve: basis, cost of carry, contango and roll yield
    3. 3.1.3 Roll management, margins, SPAN and implied leverage
    4. 3.1.4 Diversified futures systems: Carver's approach
  2. 3.2 · Futures spreads

    1. 3.2.1 Calendar spreads, butterflies and condors
    2. 3.2.2 Intercommodity spreads: crack, spark and crush
    3. 3.2.3 Rate and index spreads: 2s10s, NOB, NQ/ES and Europe versus the U.S.
    4. 3.2.4 Hedge ratios: by notional, by DV01 and by volatility
    5. 3.2.5 Spread data and backtesting: listed, synthetic and legged
    6. 3.2.6 Seasonality of spreads: multi-year analysis, patterns and traps
  3. 3.3 · Advanced forex

    1. 3.3.1 FX microstructure: OTC, ECN, last look and sessions
    2. 3.3.2 Carry and crash risk, momentum and value in currencies; baskets
    3. 3.3.3 Currency risk in multi-currency portfolios; execution via OANDA, IBKR or MT5
  4. 3.4 · Option theory and pricing

    1. 3.4.1 Payoffs, put-call parity and arbitrage bounds
    2. 3.4.2 Binomial trees, Black-Scholes-Merton, Black-76 and Monte Carlo
    3. 3.4.3 Greeks: delta, gamma, vega, theta, rho, vanna, volga and charm
    4. 3.4.4 Implied volatility: Newton-Raphson, Brent, py_vollib and QuantLib
    5. 3.4.5 Smile, skew and term structure; volatility surface with SVI
    6. 3.4.6 Realized vs implied volatility; volatility risk premium; VIX and VSTOXX
    7. 3.4.7 Advanced models: Heston, SABR and local volatility
  5. 3.5 · Option structures in Python

    1. 3.5.1 Directional and vertical structures: call, put, covered call, collar and spreads
    2. 3.5.2 Volatility and range structures: straddles, strangles, condors and butterflies
    3. 3.5.3 Calendars, diagonals, ratios, backspreads, risk reversals and jade lizard
    4. 3.5.4 A custom tool: payoff, aggregate Greeks and scenarios
    5. 3.5.5 Management: adjustments, rolls, early closes, early assignment and pin risk
  6. 3.6 · Systematic volatility and options strategies

    1. 3.6.1 Systematic premium selling: put-write, strangles and their tail risk
    2. 3.6.2 Delta hedging, gamma scalping and dispersion
    3. 3.6.3 VIX and VSTOXX strategies: volatility futures and ETPs
    4. 3.6.4 Options backtesting: data, fills and event adjustments
  7. 3.7 · Crypto derivatives and specific strategies

    1. 3.7.1 Perpetuals: funding mechanics, mark price and liquidations
    2. 3.7.2 Funding arbitrage, basis with futures and cross-exchange arbitrage
    3. 3.7.3 Crypto options, on-chain data and DeFi
    4. 3.7.4 ccxt for trading on multiple exchanges
    5. 3.7.5 MiCA: the EU framework for crypto-assets and their service providers (CASPs)

Listed or synthetic

A calendar spread (buying the near expiry and selling the next one, lesson 3.2.1) can be executed in two ways. The first, as a listed spread: futures exchanges list the most used spreads as instruments of their own, with their order book, their price (near minus far) and their volume; one order buys the whole spread, both legs at once, at a single price. The second, as a synthetic spread: buying one leg in its book and selling the other in its own, two trades.

listedsyntheticsyntheticSpread orderHow?Spread bookNear legFar legOne executionTwo executions
Mermaid source
flowchart TB
  O[Spread order] --> C{How?}
  C -- listed --> L[Spread book]
  C -- synthetic --> P1[Near leg]
  C -- synthetic --> P2[Far leg]
  L --> E[One execution]
  P1 --> D[Two executions]
  P2 --> D

The difference shows in the prices. With the best bid and ask of each leg, the synthetic spread is bought by paying the near leg's ask and receiving the far leg's bid, and sold the other way around:

bidsynth=bidnear−askfar,asksynth=asknear−bidfar\text{bid}_{\text{synth}} = \text{bid}_{\text{near}} - \text{ask}_{\text{far}}, \qquad \text{ask}_{\text{synth}} = \text{ask}_{\text{near}} - \text{bid}_{\text{far}}

Its width is the sum of the widths of the two legs. The spread's book, on the other hand, is quoted by those who want the whole spread, and its width need not be the sum of anything.

spreads3_cotizados.parquet has, for each session from 2010 to 2025, the close of the calendar spread's book (contract held minus the next one) for six markets of the level, and the best bid and ask of each leg. It is synthetic data with a known answer: the spread's book quotes at 1 tick 75% of days and at 2 the rest; the near leg, at 1 or 2 ticks, and the far leg, at 1, 2 or 3. In RVA (index) and TPC (bond) the spread only has rows in the 10 sessions before the roll, which is when it trades in those markets.

IN [01]python
import numpy as np
import pandas as pd

cot = pd.read_parquet("spreads3_cotizados.parquet")
esp = pd.read_csv("futuros3_especificaciones.csv",
                  index_col="mercado")
tick = cot["mercado"].map(esp["tick"])
cot["ancho_libro"] = (cot["ask"] - cot["bid"]) / tick
sint_bid = cot["bid_cerca"] - cot["ask_lejos"]
sint_ask = cot["ask_cerca"] - cot["bid_lejos"]
cot["ancho_sint"] = (sint_ask - sint_bid) / tick
ultimo = cot[cot["mercado"] == "PEC"].iloc[-1]
print(ultimo[["fecha", "spread", "liquidacion", "bid", "ask"]]
      .to_string())
print(f"Synthetic: bid {sint_bid[ultimo.name]:.2f}, "
      f"ask {sint_ask[ultimo.name]:.2f}")
OUT [01]The spread's book and that of its legs
fecha          2025-12-31 00:00:00
spread               PECG26-PECH26
liquidacion                   0.78
bid                           0.77
ask                           0.78
Synthetic: bid 0.77, ask 0.80

On the last day of the data, the February-March spread of the fictitious crude oil quotes 0.77-0.78 in its book: one tick (0.01 USD per barrel, 10 USD per 1,000-barrel contract). The synthetic at the same close, built from the legs, quotes 0.77-0.80: three ticks. The spread's settlement (0.78) is the difference of the legs' settlements and, in every row of the file, falls inside the spread's book.

IN [02]python
mas_ancho = cot["ancho_sint"] > cot["ancho_libro"]
anchos = pd.DataFrame({
    "filas": cot.groupby("mercado").size(),
    "libro": cot.groupby("mercado")["ancho_libro"].mean(),
    "sintetico": cot.groupby("mercado")["ancho_sint"].mean(),
    "sint_mas_ancho": mas_ancho.groupby(cot["mercado"]).mean(),
})
print(f"Mean: book {cot['ancho_libro'].mean():.2f} ticks, "
      f"synthetic {cot['ancho_sint'].mean():.2f}")
anchos.round(2)
OUT [02]Widths in ticks by market
Mean: book 1.25 ticks, synthetic 2.78
         filas  libro  sintetico  sint_mas_ancho
mercado
GNA       4024   1.25       2.81            0.94
GRM       4024   1.25       2.79            0.89
MTO       4024   1.24       2.76            0.96
PEC       4024   1.25       2.77            0.93
RVA        650   1.22       2.83            0.91
TPC        650   1.25       2.78            0.90

Across the six markets, the spread's book has an average width of 1.25 ticks and the synthetic, 2.78: more than double, as the known answer says. The synthetic is wider in between 89% and 96% of sessions and ties in the rest; it is never narrower. In a real market the relationship depends on the liquidity of each book, but the mechanism of the exchange's implied orders (below) pushes in the same direction: the legs' prices are carried over to the spread's book.

IN [03]python
mk = esp.loc[["RVA", "TPC", "PEC", "GNA", "MTO", "GRM"]]
media = cot.groupby("mercado")[["ancho_libro", "ancho_sint"]].mean()
coste = pd.DataFrame({
    "medio_libro": media["ancho_libro"] / 2 * mk["valor_tick"],
    "medio_sint": media["ancho_sint"] / 2 * mk["valor_tick"],
    "comision_2_patas": 2 * mk["comision"],
    "margen_spread": mk["margen_spread_calendario"],
    "margen_2_simples": 2 * mk["margen_inicial"],
})
coste.round(1)
OUT [03]Cost of crossing and margin, in USD per spread
         medio_libro  medio_sint  comision_2_patas  margen_spread  margen_2_simples
mercado
GNA              6.2        14.0               4.5         3300.0           17000.0
GRM              7.8        17.4               4.5          150.0            1680.0
MTO              6.2        13.8               4.5          290.0           19200.0
PEC              6.3        13.9               4.5          670.0           11200.0
RVA              7.7        17.7               4.2          610.0           40400.0
TPC              9.8        21.7               3.0          140.0            5400.0

Half the width is what it costs to cross once: through the book, from 6.2 to 9.8 USD per spread; leg by leg, from 13.8 to 21.7, a bit more than double. On top of that come the commissions of the two legs, which are paid the same either way. Margin adds another difference, this time in favor of any spread: the clearing house recognizes that the two legs offset each other and asks for 670 USD for a crude oil calendar versus 11,200 for two outright positions, 6%; in the index, 610 versus 40,400 (lesson 3.1.3).

The backtest carries the legs

The tempting way to backtest a spread is to take its price series (the liquidacion of the spread's book, or the difference of the first two positions of the curve) and treat it as one more asset. It fails for the same reason as an unadjusted continuous futures series (lesson 1.4.6): at each roll the series moves from one pair of contracts to the next, and that day's difference is not what anyone earned. Lesson 3.2.1 shows the jump; here its effect is measured in a full backtest.

The correct backtest is by legs: the position is specific contracts, and each day's P&L is the price change of the contracts held at the previous close. For a long spread (near − far), with mm the multiplier:

P&Lt=m[(Ft c−Ft−1 c)−(Ft l−Ft−1 l)]\mathrm{P\&L}_t = m\left[\left(F^{\,c}_{t} - F^{\,c}_{t-1}\right) - \left(F^{\,l}_{t} - F^{\,l}_{t-1}\right)\right]

with cc and ll the contracts of the pair at t−1t - 1 (near and far), even if tt is the roll day.

IN [04]python
cur = pd.read_parquet("futuros3_curvas.parquet")
px = cur.set_index(["fecha", "contrato"])["liquidacion"]

def backtest_calendario(mercado):
    """Long 1 spread (near - far) every day, rolled when the pair
    changes. P&L by legs (same contracts as yesterday) and naive
    P&L (difference of the spread's settlement series)."""
    s = cot[cot["mercado"] == mercado].set_index("fecha")
    m = esp.at[mercado, "multiplicador"]
    f, f0 = s.index[1:], s.index[:-1]
    cerca = s["pata_cerca"].iloc[:-1]       # yesterday's pair
    lejos = s["pata_lejos"].iloc[:-1]

    def cambio(patas):
        hoy = px.reindex(list(zip(f, patas))).to_numpy()
        antes = px.reindex(list(zip(f0, patas))).to_numpy()
        return hoy - antes

    out = pd.DataFrame(index=f)
    out["patas"] = (cambio(cerca) - cambio(lejos)) * m
    out["ingenuo"] = s["liquidacion"].diff().iloc[1:] * m
    out["roll"] = (s["spread"] != s["spread"].shift(1)).iloc[1:]
    return out

bt = {k: backtest_calendario(k) for k in ["PEC", "GNA", "MTO", "GRM"]}
resumen = {}
for k, v in bt.items():
    salto = (v["ingenuo"] - v["patas"])[v["roll"]]
    resumen[k] = {"patas": v["patas"].sum(),
                  "ingenuo": v["ingenuo"].sum(),
                  "saltos_roll": salto.sum(),
                  "rolls": v["roll"].sum()}
pd.DataFrame(resumen).T.round(0)
OUT [04]A long, rolled calendar: by legs or with the series
       patas  ingenuo  saltos_roll  rolls
PEC  11850.0    790.0     -11060.0  192.0
GNA   6910.0   1960.0      -4950.0  192.0
MTO  -1230.0  -1450.0       -220.0   96.0
GRM   5400.0   -262.0      -5662.0   80.0
Fig. — Long calendar in the fictional crude (near minus next, 1 spread, rolled monthly), 2010-2025, before costs: cumulative P&L carrying the legs against the difference of the spread's settlement series

Carrying the legs, the long crude oil calendar earns 11,850 USD in 16 years without costs; with the spread series, 790. The difference, −11,060 USD, comes entirely from the 192 roll days: it is the sum of the jumps between the old pair and the new one, which trade at different levels because they sit on different segments of the curve. In gas, 6,910 versus 1,960; in corn, 5,400 versus −262: even the sign changes. Only in gold, with an almost straight curve (its slope is the financing cost), is the error small (−220 USD). The figure shows how the two curves drift apart: the series backtest is not wrong on any particular day, it is a little wrong at every roll, always in the same direction.

Roll costs

A calendar held over time makes two spread trades at each roll: closing the old pair and opening the new one. Crossing half the width on each, the cost is one full width plus four commissions (two legs per spread), through the spread's book or, if assembled leg by leg, with the width of the two legs:

IN [05]python
def neto(mercado):
    s = cot[cot["mercado"] == mercado].set_index("fecha").iloc[1:]
    v = bt[mercado]
    vt, t = esp.at[mercado, "valor_tick"], esp.at[mercado, "tick"]
    com = esp.at[mercado, "comision"]
    libro = (s["ask"] - s["bid"]) / t * vt           # USD
    patas = ((s["ask_cerca"] - s["bid_cerca"])
             + (s["ask_lejos"] - s["bid_lejos"])) / t * vt
    c_libro = (libro + 4 * com).where(v["roll"], 0)
    c_patas = (patas + 4 * com).where(v["roll"], 0)
    anos = len(v) / 252
    sr = lambda x: x.mean() / v["patas"].std() * np.sqrt(252)
    return {"bruto_anual": v["patas"].sum() / anos,
            "coste_libro_anual": c_libro.sum() / anos,
            "coste_patas_anual": c_patas.sum() / anos,
            "sharpe_bruto": sr(v["patas"]),
            "sharpe_libro": sr(v["patas"] - c_libro),
            "sharpe_patas": sr(v["patas"] - c_patas)}

pd.DataFrame({k: neto(k) for k in bt}).T.round(2)
OUT [05]Roll costs: through the book or leg by leg
     bruto_anual  coste_libro_anual  coste_patas_anual  sharpe_bruto  sharpe_libro  sharpe_patas
PEC       742.28             261.08             428.96          0.21          0.14          0.09
GNA       432.84             262.96             432.09          0.06          0.02          0.00
MTO       -77.05             130.54             216.98         -0.35         -0.95         -1.35
GRM       338.26             122.62             222.84          0.24          0.15          0.08

In crude oil, the long calendar earns 742 USD a year gross. Rolling through the book costs 261 USD a year (twelve rolls of one full width plus four commissions): 35% of the gross, and the Sharpe goes from 0.21 to 0.14. Leg by leg it costs 429 USD a year, 58%, and the Sharpe drops to 0.09. In gas, the net result disappears. In gold, which loses gross, costs multiply the loss: its spread moves little (about 220 USD of annual volatility) and 131 USD a year of costs take its Sharpe from −0.35 to −0.95. Corn, with five expiries a year, rolls less and pays less: 123 USD. Lesson 2.4.7 covers cost modeling in general; what is specific to a spread here is that the cost depends on how it is executed.

Legging risk

Assembling the spread leg by leg has a cost beyond the bid-ask spread: between the first execution and the second, the position is not a spread but an outright future. If the price moves in that interval, the second leg is executed at another level. This is legging risk, and its size depends on how much one leg moves compared with how much the spread moves.

spreads3_intradia_1min.parquet has five sessions from November 2025 of the fictitious crude oil PEC, minute by minute: the best bid and ask of the December leg (PECZ25), of the January leg (PECF26) and of the book of the spread between the two (PECZ25-PECF26). Each session runs from 23:00 UTC the previous day to 22:00 UTC.

IN [06]python
im = pd.read_parquet("spreads3_intradia_1min.parquet")
im["medio"] = (im["bid"] + im["ask"]) / 2
med = im.pivot(index="ts", columns="instrumento", values="medio")
d1 = med.diff()
print(f"Correlation of the legs' 1-minute changes: "
      f"{d1['PECZ25'].corr(d1['PECF26']):.3f}")
d5 = med.diff(5).std()
print(f"Std. dev. of the 5-minute change: near leg "
      f"{d5['PECZ25']:.3f} USD, spread {d5['PECZ25-PECF26']:.3f} USD")
im.groupby("instrumento").agg(minimo=("bid", "min"),
                              maximo=("ask", "max"),
                              volumen=("volumen", "sum"))
OUT [06]Five sessions of the two legs and the spread, minute by minute
Correlation of the legs' 1-minute changes: 0.987
Std. dev. of the 5-minute change: near leg 0.097 USD, spread 0.011 USD
               minimo  maximo  volumen
instrumento
PECF26          67.09   73.99  1992508
PECZ25          68.74   76.54  1509831
PECZ25-PECF26    1.53    2.59   896527
Fig. — The two legs of the fictional crude December-January calendar and the spread book, mid price every 30 minutes, 3 to 7 November 2025

The two legs move almost together (a correlation of 0.987 between one-minute changes) and by a lot: the near one, with a standard deviation of 0.097 USD in 5 minutes, 97 USD per 1,000-barrel contract. The spread between the two, 0.011 USD: nine times less. The figure shows it over the five sessions: the legs rise from about 70 to about 76 USD, with drops of more than a dollar within a session; the spread goes from 1.56 to 2.58 without jumps.

IN [07]python
w = im.pivot(index="ts", columns="instrumento", values=["bid", "ask"])
sesion = (w.index + pd.Timedelta(hours=1)).date   # 23:00 to 22:00 UTC
SP, CERCA, LEJOS = "PECZ25-PECF26", "PECZ25", "PECF26"

def legging(k):
    """Buy the spread leg by leg: near at the ask at t, far at the bid
    at t + k (same session). Cost versus the spread book's ask at t,
    in USD per spread (1,000 barrels)."""
    lejos_bid = w[("bid", LEJOS)].groupby(sesion).shift(-k)
    pagado = w[("ask", CERCA)] - lejos_bid
    return ((pagado - w[("ask", SP)]) * 1000).dropna()

filas = {}
for k in [0, 1, 2, 5, 10, 15, 30, 60]:
    c = legging(k)
    filas[k] = {"media": c.mean(), "desv": c.std(),
                "p1": c.quantile(0.01), "p99": c.quantile(0.99)}
tab_leg = pd.DataFrame(filas).T.rename_axis("minutos")
tab_leg.round(1)
OUT [07]Legging: one leg now and the other k minutes later
         media   desv      p1    p99
minutos
0         10.9    5.7    -0.0   20.0
1         10.2   43.9  -100.0  120.0
2          9.5   61.3  -150.0  160.0
5          7.3   96.3  -250.0  240.0
10         3.5  135.2  -370.0  320.0
15        -0.4  165.7  -440.0  380.0
30       -12.7  238.8  -675.1  530.0
60       -37.9  350.5 -1080.0  820.0

With k = 0, both legs in the same minute, the synthetic costs 10.9 USD more than the ask of the spread's book (a bit more than a tick), with little dispersion (5.7). As soon as you wait, the dispersion grows like the square root of time, that of a single leg: 96.3 USD with 5 minutes, 165.7 with 15 and 350.5 with 60. In the worst 1% of cases, waiting 15 minutes costs 380 USD per spread and waiting one hour, 820: more than a year's gross result of the calendar. The mean turns negative with long waits (−37.9 with 60 minutes) because in these five sessions crude oil rises within the session and the far leg is sold at a higher price later: it is the five-day trend, not an expected cost (exercise 1 checks it the other way around).

IN [08]python
espera = {k: (w[("ask", SP)].groupby(sesion).shift(-k)
              - w[("ask", SP)]).mul(1000).std()
          for k in [1, 5, 15, 30, 60]}
pd.Series(espera, name="desv_usd").rename_axis("minutos").round(1)
OUT [08]Waiting with the whole spread
minutos
1      6.2
5     10.3
15    16.5
30    23.0
60    32.0
Name: desv_usd, dtype: float64

Waiting with the whole spread also carries risk, but the spread's: a deviation of 10.3 USD in 5 minutes and 32.0 in 60, between nine and eleven times less than leg by leg. The header figure puts the two curves together. Hence the practical rule: if the spread is listed, execute it through its book; if it has to be assembled leg by leg, first the less liquid leg (the one that is harder to fill) and the other immediately, with an order that crosses. And in the backtest, the legging cost (the bid-ask spread of the two legs plus a margin for the delay) is charged at every entry and at every roll.

Combination and implied orders

There are three ways to take a spread order to the market, with different risks:

FormWho crossesLegging riskPrice
Exchange spreadThe exchange's engine, in the spread's bookNo: a single tradeOne, the spread's
Guaranteed combinationThe exchange or the broker, all legs at onceNoThat of the whole
Non-guaranteed combinationThe broker, leg by legYesThat of each leg

On CME Globex, the spread's book and the legs' books are not isolated. The exchange computes implied orders: an order in the spread's book derived from orders in the legs (implied IN) or an order in one leg derived from orders in the spread and in the other leg (implied OUT). The example from its documentation: a sell of 10 September contracts at 9500 and a buy of 5 December at 9450 create an implied sell of 5 September-December spreads at 50.

Sell Sep. 9500Implied sell 50Buy Dec. 9450Sep-Dec book
Mermaid source
flowchart LR
  A[Sell Sep. 9500] --> I[Implied sell 50]
  B[Buy Dec. 9450] --> I
  I --> S[Sep-Dec book]

The same page sets some rules: at least two orders in related markets are needed, implied buys do not cross with implied sells, in futures they have no time priority and they do not exist when the market is not trading (the pre-open, for example). For whoever runs the backtest, the consequence is that the spread's book of a liquid contract is usually not worse than the synthetic: if it were, the exchange itself would improve it with implied orders.

At a broker, a combination is defined by its legs. With the Interactive Brokers API (here with ib_async, the maintained continuation of ib_insync: version 2.1.0, BSD license, on PyPI), a combination is a BAG contract with one ComboLeg per leg, each with its conId, its ratio and its side. The API documentation distinguishes the intercommodity spreads listed by the exchange, which IBKR routes directly and are always guaranteed, from futures combinations via SMART, which are sent as non-guaranteed with the NonGuaranteed tag: the broker does not ensure that all legs are executed at the same time. The block needs TWS or IB Gateway and is not run here:

IN [09]python
from ib_async import IB, Bag, ComboLeg, Future, LimitOrder, TagValue

ib = IB()
ib.connect("127.0.0.1", 7497, clientId=7)   # TWS on a paper account

cerca = Future("CL", "202512", "NYMEX")
lejos = Future("CL", "202601", "NYMEX")
ib.qualifyContracts(cerca, lejos)           # fills in the conId

spread = Bag(symbol="CL", exchange="SMART", currency="USD",
             comboLegs=[
                 ComboLeg(conId=cerca.conId, ratio=1, action="BUY",
                          exchange="NYMEX"),
                 ComboLeg(conId=lejos.conId, ratio=1, action="SELL",
                          exchange="NYMEX")])

# Limit price of the spread (near - far), not of each leg.
orden = LimitOrder("BUY", 1, 0.55)
orden.smartComboRoutingParams = [TagValue("NonGuaranteed", "1")]
operacion = ib.placeOrder(spread, orden)
ib.sleep(5)
print(operacion.orderStatus.status, operacion.fills)

If the order fills only partially or one leg is executed before the other, operacion.fills shows it execution by execution. A system that trades this way also needs a rule for the case of a hanging leg: how long to wait, when to cross the other at market, when to unwind the first.

The real data: CME with Databento

Databento distributes CME Globex data (the GLBX.MDP3 dataset, introduced in lesson 1.3.4) with the exchange's spreads as instruments of their own. According to its symbology documentation, the parent symbol ES.FUT includes “all futures and futures spreads” of the E-mini, and in the definitions the instrument_class field distinguishes a future (F) from a futures spread (S); calendar spread symbols have the form ESU3-ESZ3. Best prices per minute are in the bbo-1m schema (with ts_recv at the end of each minute) and official settlements, in statistics, with stat_type 31Note 1 · Databento, “Statistics” (statistics schema: stat_type 3 = settlement price, 6 = cleared volume, 9 = open interest): https://databento.com/docs/schemas-and-data-formats/statistics. With the official client (databento 0.88.0, Apache-2.0 license, on PyPI) and a key in the DATABENTO_API_KEY environment variable, downloading one week of the December-January crude oil calendar would be:

IN [10]python
import databento as db

cliente = db.Historical()          # reads DATABENTO_API_KEY

# 1. Which instruments exist: futures and spreads of the whole CL family
defs = cliente.timeseries.get_range(
    dataset="GLBX.MDP3", schema="definition", symbols=["CL.FUT"],
    stype_in="parent", start="2025-11-03", end="2025-11-04").to_df()
spreads = defs[defs["instrument_class"] == "S"]
calendarios = spreads[spreads["raw_symbol"].str.fullmatch(
    r"CL[FGHJKMNQUVXZ]\d-CL[FGHJKMNQUVXZ]\d")]

# 2. What it would cost before requesting it (USD)
simbolos = ["CLZ5", "CLF6", "CLZ5-CLF6"]
coste = cliente.metadata.get_cost(
    dataset="GLBX.MDP3", schema="bbo-1m", symbols=simbolos,
    start="2025-11-03", end="2025-11-08")

# 3. Best bid and ask of the legs and the spread, minute by minute
bbo = cliente.timeseries.get_range(
    dataset="GLBX.MDP3", schema="bbo-1m", symbols=simbolos,
    start="2025-11-03", end="2025-11-08").to_df()

# 4. Official settlements
st = cliente.timeseries.get_range(
    dataset="GLBX.MDP3", schema="statistics", symbols=simbolos,
    start="2025-11-03", end="2025-11-08").to_df()
liquidaciones = st[st["stat_type"] == 3]

Underneath the client there is an HTTP API: POST https://hist.databento.com/v0/timeseries.get_range, with the key as the basic authentication username and form parameters (dataset, symbols, schema, start, end, encoding, pretty_px, pretty_ts, map_symbols, limit). Without pretty_px, prices arrive as integers in units of 10−910^{-9}; without pretty_ts, timestamps arrive in nanoseconds. Its limits page allows per IP address up to 100 simultaneous connections and 100 time series requests per second, and responds with 429 and a Retry-After header when exceeded. All of that can be tested without network or key with a local simulator that mimics the API: a server in a thread, on a free port chosen by the system, that serves the five sessions of the fictitious crude oil in the bbo-1m schema format (with fewer columns than the real one) and rejects the first request with a 429.

IN [11]python
import base64
import io
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

CLAVE = "db-" + "x" * 29            # 32 characters, like a real one
IDS = {"PECZ25": 101, "PECF26": 102, "PECZ25-PECF26": 9001}
peticiones = []

def a_csv(d, mapear):
    """Rows of `im` in the bbo-1m format (simplified)."""
    fin_minuto = d["ts"] + pd.Timedelta(minutes=1)
    out = pd.DataFrame({
        "ts_recv": fin_minuto.dt.as_unit("ns").astype("int64"),
        "rtype": 196,
        "instrument_id": d["instrumento"].map(IDS),
        "bid_px_00": (d["bid"] * 1e9).round().astype("int64"),
        "ask_px_00": (d["ask"] * 1e9).round().astype("int64"),
        "bid_sz_00": d["tam_bid"], "ask_sz_00": d["tam_ask"]})
    if mapear:
        out["symbol"] = d["instrumento"].to_numpy()
    return out.to_csv(index=False)

class Simulador(BaseHTTPRequestHandler):
    def log_message(self, *args):      # no logging to stderr
        pass

    def responder(self, codigo, cuerpo, cabeceras=()):
        self.send_response(codigo)
        for k, v in cabeceras:
            self.send_header(k, v)
        self.end_headers()
        self.wfile.write(cuerpo.encode())

    def do_POST(self):
        peticiones.append(self.path)
        clave = base64.b64encode(f"{CLAVE}:".encode()).decode()
        if self.headers.get("Authorization") != f"Basic {clave}":
            return self.responder(401, '{"detail":"Not authenticated"}')
        if len(peticiones) == 1:      # the first one hits the limit
            return self.responder(429, '{"detail":"Too Many Requests"}',
                                  [("Retry-After", "1")])
        largo = int(self.headers["Content-Length"])
        p = urllib.parse.parse_qs(self.rfile.read(largo).decode())
        if (self.path != "/v0/timeseries.get_range"
                or p["schema"] != ["bbo-1m"]
                or p.get("encoding", ["csv"]) != ["csv"]):
            return self.responder(422, '{"detail":"not simulated"}')
        ini = pd.Timestamp(p["start"][0], tz="UTC")
        fin = pd.Timestamp(p["end"][0], tz="UTC")
        d = im[im["instrumento"].isin(p["symbols"][0].split(","))
               & (im["ts"] >= ini) & (im["ts"] < fin)]
        mapear = p.get("map_symbols", ["false"]) == ["true"]
        self.responder(200, a_csv(d, mapear),
                       [("Content-Type", "text/csv")])

servidor = ThreadingHTTPServer(("127.0.0.1", 0), Simulador)
threading.Thread(target=servidor.serve_forever, daemon=True).start()
BASE = f"http://127.0.0.1:{servidor.server_port}"
print("Simulator running:", BASE.startswith("http://127.0.0.1:"))
OUT [11]A local simulator of timeseries.get_range
Simulator running: True

The client is the one from lesson 1.4.1 with what is specific to this API: basic authentication with the key as the username and an empty password, form parameters and, on a 429, waiting for whatever Retry-After says before retrying.

IN [12]python
def get_range(base, clave, intentos=3, **params):
    """POST /v0/timeseries.get_range with encoding=csv. Returns a
    DataFrame with prices in USD and UTC timestamps."""
    datos = urllib.parse.urlencode({"encoding": "csv", **params})
    auth = base64.b64encode(f"{clave}:".encode()).decode()
    for i in range(intentos):
        req = urllib.request.Request(
            f"{base}/v0/timeseries.get_range", data=datos.encode(),
            headers={"Authorization": f"Basic {auth}"})
        try:
            with urllib.request.urlopen(req, timeout=10) as r:
                df = pd.read_csv(io.StringIO(r.read().decode()))
            break
        except urllib.error.HTTPError as e:
            if e.code != 429 or i == intentos - 1:
                raise
            espera = float(e.headers.get("Retry-After", "1"))
            print(f"HTTP 429: waiting {espera:.0f} s and retrying")
            time.sleep(espera)
    df["ts_recv"] = pd.to_datetime(df["ts_recv"], unit="ns", utc=True)
    for c in ["bid_px_00", "ask_px_00"]:
        df[c] = df[c] / 1e9
    return df

bbo = get_range(BASE, CLAVE, dataset="GLBX.MDP3", schema="bbo-1m",
                symbols="PECZ25,PECF26,PECZ25-PECF26",
                start="2025-11-04T13:00", end="2025-11-04T13:05",
                map_symbols="true")
print(f"{len(peticiones)} requests, {len(bbo)} rows")
bbo.loc[bbo["symbol"] == "PECZ25-PECF26",
        ["ts_recv", "bid_px_00", "ask_px_00", "bid_sz_00", "ask_sz_00"]]
OUT [12]Client with authentication, limits and retries
HTTP 429: waiting 1 s and retrying
2 requests, 15 rows
                     ts_recv  bid_px_00  ask_px_00  bid_sz_00  ask_sz_00
2  2025-11-04 13:01:00+00:00       1.69       1.70         42         90
5  2025-11-04 13:02:00+00:00       1.70       1.71         24         33
8  2025-11-04 13:03:00+00:00       1.70       1.71         33        123
11 2025-11-04 13:04:00+00:00       1.70       1.71         51         63
14 2025-11-04 13:05:00+00:00       1.70       1.71        111        165

The first request gets a 429; the client waits the second that Retry-After asks for and the second request brings the 15 rows: five minutes of three instruments. The timestamps are the end of each minute, as in bbo-1m: the 13:01 row is the minute that starts at 13:00 in the course's Parquet.

IN [13]python
get_range(BASE, "db-clave-equivocada", dataset="GLBX.MDP3",
          schema="bbo-1m", symbols="PECZ25",
          start="2025-11-04", end="2025-11-05")
OUT [13]A wrong key
urllib.error.HTTPError: HTTP Error 401: Unauthorized

A 401 is not retried: it is a configuration error, not a load error. The client only retries on a 429; on a real 5xx it would be wise to retry too, with exponential backoff (lesson 1.4.1).

IN [14]python
dia = get_range(BASE, CLAVE, dataset="GLBX.MDP3", schema="bbo-1m",
                symbols="PECZ25,PECF26,PECZ25-PECF26",
                start="2025-11-03T23:00", end="2025-11-04T22:00",
                map_symbols="true")
ref = im[(im["ts"] >= "2025-11-03T23:00Z")
         & (im["ts"] < "2025-11-04T22:00Z")]
iguales = (np.allclose(dia["bid_px_00"], ref["bid"])
           and np.allclose(dia["ask_px_00"], ref["ask"])
           and (dia["ts_recv"] - pd.Timedelta(minutes=1)
                == ref["ts"].dt.as_unit("ns").to_numpy()).all())
print(f"{len(dia)} rows; match the Parquet: {iguales}")
servidor.shutdown()
OUT [14]The same rows as the file
4140 rows; match the Parquet: True

A full session (from 23:00 to 22:00 UTC, 1,380 minutes per instrument) arrives with 4,140 rows and matches the file, shifted by one minute. With the real URL and a key, the same client requests the real data; the simulator is useful for testing authentication, limits, errors and pagination without spending usage (Databento charges by volume downloaded, lesson 1.3.4) and without depending on the network in tests (lesson 0.4.5).

Common mistakes

  • The spread series as if it were an asset. Its difference on the roll day mixes two pairs. The P&L is computed with yesterday's contracts; the series is useful for the signal, not for the result.
  • Executing in the backtest at the spread's settlement. The settlement in the data always falls inside the spread's book, but it is not a price you can trade at in size: the minimum realistic cost is half the book's width.
  • Spread costs with the synthetic's width, or the other way around. If the system trades through the book, the cost is the book's; if it trades leg by leg, that of the two legs plus legging. Confusing them changes the net result by a factor of two in the crude oil calendar.
  • Ignoring the days without a book. In indices and bonds, the calendar spread only really trades around the roll (in the data, the 10 previous sessions). A backtest that trades it all quarter assumes liquidity that does not exist.
  • Vendor timestamps. In Databento's bbo-1m the timestamp is the end of the minute; in the course's Parquet, the start. Misaligning a minute between the signal and the price is look-ahead bias (lesson 1.7.4).
  • Margin. The margin of a calendar spread is a fraction of that of two outright positions (670 USD versus 11,200 in the fictitious crude oil): a return “on margin” looks huge and hides that the possible loss is not reduced in the same proportion (lesson 3.1.3).

Exercises

  1. Repeat the legging study the other way around: first sell the far leg at the bid and buy the near one at the ask k minutes later. Does the deviation change? And the mean? Explain why the mean of the legging in the table turns negative with long waits in these five sessions.
Solution
IN [15]python
def legging_inverso(k):
    cerca_ask = w[("ask", CERCA)].groupby(sesion).shift(-k)
    pagado = cerca_ask - w[("bid", LEJOS)]
    return ((pagado - w[("ask", SP)]) * 1000).dropna()

pd.DataFrame({k: {"media": legging_inverso(k).mean(),
                  "desv": legging_inverso(k).std()}
              for k in [0, 5, 15, 60]}).T.round(1)
OUT [15]Legging starting with the far leg
    media   desv
0    10.9    5.7
5    15.1   96.4
15   23.8  166.3
60   66.8  353.0

Starting with the far leg, the deviation is the same (96.4 USD with 5 minutes, 353.0 with 60): the risk of a single leg does not depend on which one is executed first. The mean changes sign: +15.1 USD with 5 minutes and +66.8 with 60, versus 7.3 and −37.9. In these five sessions crude oil rises within the session (the December contract goes from about 70 to about 76 USD, as the figure shows): whoever buys the near leg later pays more for it, and whoever sells the far leg later sells it for more. With five sessions, that mean is the trend of those days; the deviation, five times larger, is what you have to budget for.

  1. The Asian sessions (from 23:00 to 08:00 UTC) have wider bid-ask spreads in the data. Measure the average width in ticks of the spread's book and of the synthetic by time window (Asia, London from 08:00 to 13:00, New York from 13:00) and the cost of buying the spread each way in USD.
Solution
IN [16]python
h = w.index.hour
franja = np.select([(h >= 23) | (h < 8), h < 13],
                   ["asia", "londres"], "nueva_york")
libro = (w[("ask", SP)] - w[("bid", SP)]) / 0.01
sint = ((w[("ask", CERCA)] - w[("bid", CERCA)])
        + (w[("ask", LEJOS)] - w[("bid", LEJOS)])) / 0.01
t = pd.DataFrame({"libro": libro, "sintetico": sint})
t = t.groupby(franja).mean()
t["coste_libro_usd"] = t["libro"] / 2 * 10
t["coste_sint_usd"] = t["sintetico"] / 2 * 10
t.round(2)
OUT [16]Widths by time window
            libro  sintetico  coste_libro_usd  coste_sint_usd
asia         1.43       2.89             7.16           14.44
londres      1.00       2.00             5.00           10.00
nueva_york   1.00       2.00             5.00           10.00

In Asia, the spread's book has an average width of 1.43 ticks and the synthetic, 2.89; in London and New York, exactly 1.00 and 2.00. Buying through the book costs 7.16 USD in Asia and 5.00 afterwards; leg by leg, 14.44 and 10.00. In the data, the time of day only widens the market in Asia (half of the minutes with one more tick in each book). A system that executes at the European or American close pays the minimum; one that reacts to overnight news pays 43% more through the book.

  1. Extend the simulator to paginate: with a limit parameter, it should return at most that number of rows, and the client should request the next chunk starting from the last ts_recv received. What problem appears if several rows share the same ts_recv right at the cut?
Solution

The limit parameter of the real API limits the number of records, but does not return a cursor: the client has to request again from the last timestamp received. With start = last ts_recv (inclusive), the rows at that instant are duplicated; with start = last ts_recv + 1 ns, the rows that did not fit in the previous chunk and shared that timestamp are lost (in bbo-1m, the three series of the same minute have the same ts_recv). The robust solution is to request from the last timestamp inclusive and drop duplicates by (ts_recv, instrument_id), or else not to paginate by time but by closed time blocks (one day per request), which is what Databento's batch download does (batch.submit_job). In the simulator it is enough to add d = d.head(int(p["limit"][0])) if limit is present, and in the client a loop that advances start until a response arrives with fewer rows than limit.

Footnotes

  1. Databento, “Statistics” (statistics schema: stat_type 3 = settlement price, 6 = cleared volume, 9 = open interest): https://databento.com/docs/schemas-and-data-formats/statistics ↩

Each answer is folded under its question

Self-assessment

  1. In the data, the calendar spread's book quotes with an average width of 1.25 ticks and the spread built from the legs, 2.78. Why is the synthetic wider?

    • ABecause the data has errors
    • BBecause buying the synthetic means buying the near leg at the ask and selling the far leg at the bid: you pay both widths, and the sum of two bid-ask spreads is larger than the bid-ask spread of an instrument traded as a whole
    • CBecause the listed spread cannot be traded
    • DBecause the synthetic includes the commission
    Show answer

    Answer B. Because buying the synthetic means buying the near leg at the ask and selling the far leg at the bid: you pay both widths, and the sum of two bid-ask spreads is larger than the bid-ask spread of an instrument traded as a whole

    The synthetic's bid is bid_near − ask_far and its ask, ask_near − bid_far: its width is the sum of the widths of the two legs. The spread's book is quoted by those who want the whole spread, and with the exchange's implied orders it is never worse than the synthetic.

  2. A backtest of a long, rolled crude oil calendar gives 790 USD over 16 years with the differences of the spread's settlement series, and 11,850 USD carrying the legs. Which one is right and why?

    • AThe series one, because it is the spread's price
    • BBoth, depending on the day
    • CThe legs one: the spread series changes pair at every roll and its difference on that day mixes two different instruments; those jumps add up to −11,060 USD that nobody paid
    • DNeither: you have to use the ratio-adjusted series
    Show answer

    Answer C. The legs one: the spread series changes pair at every roll and its difference on that day mixes two different instruments; those jumps add up to −11,060 USD that nobody paid

    A day's P&L is the change of the pair held yesterday. At the roll, the series moves from the old pair to the new one and its difference is neither a gain nor a loss: it is the change of instrument. It is the same error as an unadjusted continuous futures series (lesson 1.4.6), multiplied by 192 rolls.

  3. You buy the near leg at the ask and wait 15 minutes to sell the far one. What does the legging table from the data say?

    • AThat nothing changes: the spread barely moves
    • BThat you always win because you wait for a better price
    • CThat it costs exactly one tick
    • DThat the average cost is almost zero, but its standard deviation is 166 USD per spread and the 1st percentile, −440 USD: the risk is that of a single leg, not that of the spread
    Show answer

    Answer D. That the average cost is almost zero, but its standard deviation is 166 USD per spread and the 1st percentile, −440 USD: the risk is that of a single leg, not that of the spread

    While there is only one leg, the position is a 1,000-barrel crude oil future, not a spread. Its price moves 0.097 USD in 5 minutes (97 USD per contract) versus 0.011 for the spread. Waiting with the whole spread costs a tenth of that risk.

  4. What is an “implied IN” order on CME Globex?

    • AAn order in the spread's book that the exchange derives from orders in the legs' books
    • BAn order that the broker splits in two
    • CA hidden order from a market maker
    • DAn order that only exists in the auction
    Show answer

    Answer A. An order in the spread's book that the exchange derives from orders in the legs' books

    According to CME's documentation, a sell of 10 September at 9500 and a buy of 5 December at 9450 create an implied sell of 5 at 50 in the September-December spread. Conversely, from orders in the spread and in one leg the exchange derives orders in the other leg (implied OUT). That is why the spread's book is usually not worse than the synthetic.

  5. A futures combination sent via SMART as non-guaranteed to IBKR: what risk does it carry?

    • ANone: the broker executes it as a single instrument
    • BThat the legs are executed separately and not at the same time: legging risk, with the result of a single leg if the other one does not fill
    • CThat of paying double commissions
    • DThat the exchange always rejects it
    Show answer

    Answer B. That the legs are executed separately and not at the same time: legging risk, with the result of a single leg if the other one does not fill

    IBKR's API documentation distinguishes exchange-listed spreads (intercommodity, routed directly and always guaranteed) from SMART-routed combinations, which are flagged as non-guaranteed. Non-guaranteed means the broker does not ensure that all legs are executed together.

Key points

  • The listed spread is an instrument with its own book; the synthetic is assembled from the legs and pays both of their bid-ask spreads. In the data, 1.25 ticks versus 2.78 of average width: in 93% of crude oil sessions, the synthetic is wider.
  • A spread backtest carries the legs: each day's P&L is the change of the pair held yesterday. The difference of the spread series mixes pairs at every roll; in the crude oil calendar, 790 USD versus 11,850 over 16 years.
  • Each roll of a calendar is closing one spread and opening another. Through the book it costs 261 USD a year in the crude oil calendar, 35% of the gross result; leg by leg, 429 USD, 58%.
  • Legging risk is the risk of a single leg: with a 5-minute wait, a 96 USD deviation per crude oil spread; with 60, 350. Waiting with the whole spread, 10 and 32.
  • Databento serves CME spreads as instruments of their own (instrument_class S, symbols like CLZ5-CLF6), with best prices per minute (bbo-1m) and settlements (statistics, stat_type 3); metadata.get_cost tells you what it costs before downloading.
  • Exchange combination order: a single trade, no legging. Broker non-guaranteed combination: leg by leg. CME's implied orders connect the books of the legs and of the spread.

Resources

Data