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.
- Module
- 3.2 · Futures spreads
- Duration
- 70 min
- Prerequisites
- 1.3.4 · Professional market data providers, 1.4.1 · Downloading over REST and websockets: pagination, rate limits and retries, 2.4.7 · Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing, 3.2.1 · Calendar spreads, butterflies and condors
- Author
- Jesús Cuesta
- Updated
Level 3 lessons
3.1 · Futures in depth
3.2 · Futures spreads
- 3.2.1 Calendar spreads, butterflies and condors
- 3.2.2 Intercommodity spreads: crack, spark and crush
- 3.2.3 Rate and index spreads: 2s10s, NOB, NQ/ES and Europe versus the U.S.
- 3.2.4 Hedge ratios: by notional, by DV01 and by volatility
- 3.2.5 Spread data and backtesting: listed, synthetic and legged
- 3.2.6 Seasonality of spreads: multi-year analysis, patterns and traps
3.3 · Advanced forex
3.4 · Option theory and pricing
- 3.4.1 Payoffs, put-call parity and arbitrage bounds
- 3.4.2 Binomial trees, Black-Scholes-Merton, Black-76 and Monte Carlo
- 3.4.3 Greeks: delta, gamma, vega, theta, rho, vanna, volga and charm
- 3.4.4 Implied volatility: Newton-Raphson, Brent, py_vollib and QuantLib
- 3.4.5 Smile, skew and term structure; volatility surface with SVI
- 3.4.6 Realized vs implied volatility; volatility risk premium; VIX and VSTOXX
- 3.4.7 Advanced models: Heston, SABR and local volatility
3.5 · Option structures in Python
- 3.5.1 Directional and vertical structures: call, put, covered call, collar and spreads
- 3.5.2 Volatility and range structures: straddles, strangles, condors and butterflies
- 3.5.3 Calendars, diagonals, ratios, backspreads, risk reversals and jade lizard
- 3.5.4 A custom tool: payoff, aggregate Greeks and scenarios
- 3.5.5 Management: adjustments, rolls, early closes, early assignment and pin risk
3.6 · Systematic volatility and options strategies
3.7 · Crypto derivatives and specific strategies
- 3.7.1 Perpetuals: funding mechanics, mark price and liquidations
- 3.7.2 Funding arbitrage, basis with futures and cross-exchange arbitrage
- 3.7.3 Crypto options, on-chain data and DeFi
- 3.7.4 ccxt for trading on multiple exchanges
- 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.
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 --> DThe 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:
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.
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}")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.80On 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.
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)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.90Across 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.
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) 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.0Half 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 the multiplier:
with and the contracts of the pair at (near and far), even if is the roll day.
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) 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.0Carrying 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:
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) 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.08In 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.
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"))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 896527The 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.
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) 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.0With 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).
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)minutos
1 6.2
5 10.3
15 16.5
30 23.0
60 32.0
Name: desv_usd, dtype: float64Waiting 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:
| Form | Who crosses | Legging risk | Price |
|---|---|---|---|
| Exchange spread | The exchange's engine, in the spread's book | No: a single trade | One, the spread's |
| Guaranteed combination | The exchange or the broker, all legs at once | No | That of the whole |
| Non-guaranteed combination | The broker, leg by leg | Yes | That 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.
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:
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:
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 ; 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.
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:"))Simulator running: TrueThe 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.
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"]]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 165The 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.
get_range(BASE, "db-clave-equivocada", dataset="GLBX.MDP3",
schema="bbo-1m", symbols="PECZ25",
start="2025-11-04", end="2025-11-05")urllib.error.HTTPError: HTTP Error 401: UnauthorizedA 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).
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()4140 rows; match the Parquet: TrueA 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-1mthe 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
- 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
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) media desv
0 10.9 5.7
5 15.1 96.4
15 23.8 166.3
60 66.8 353.0Starting 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.
- 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
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) 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.00In 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.
- Extend the simulator to paginate: with a
limitparameter, it should return at most that number of rows, and the client should request the next chunk starting from the lastts_recvreceived. What problem appears if several rows share the samets_recvright 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
-
Databento, “Statistics” (
statisticsschema:stat_type3 = 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
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.
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.
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.
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.
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_classS, symbols likeCLZ5-CLF6), with best prices per minute (bbo-1m) and settlements (statistics,stat_type3);metadata.get_costtells 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
Documentation
- Databento: timeseries.get_range (historical API, parameters and HTTP example)
- Databento: symbology (parent, raw_symbol, continuous)
- Databento: instrument definitions (instrument_class and spread legs)
- Databento: special conventions for futures (spreads, settlements in statistics)
- CME Group Client Systems Wiki: Implied Orders
- Interactive Brokers, TWS API: combos and spreads (BAG, ComboLeg, non-guaranteed combinations)