Lesson 3.2.2 · 65 min

Intercommodity spreads: crack, spark and crush

A refinery buys crude and sells gasoline; a power plant buys gas and sells electricity; a crusher buys soybeans and sells meal and oil. Their margins are spreads between two or three different futures, with different units and a physical link that ties them together. This lesson builds them with the same delivery month in every leg, measures their risk and checks whether their reversion is the one planted in the data.

Fig. — The two 1:1 cracks of the synthetic crude, same delivery month in both legs, 2010–2025
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)

Processing margins and relative value

A calendar spread (lesson 3.2.1) pits two months of the same product against each other. An intercommodity spread pits different products against each other. The most traded ones are the margins of an industry: what whoever turns one product into another earns. A refinery buys crude and sells gasoline and diesel (the crack); a combined cycle power plant buys gas and sells electricity (the spark); a crusher buys soybeans and sells meal and oil (the crush).

CrudeRefineryGasolineDieselSoybeansCrusherMealOilGasPower plantElectricity
Mermaid source
flowchart LR
  C[Crude] --> R[Refinery]
  R --> G[Gasoline]
  R --> D[Diesel]
  S[Soybeans] --> M[Crusher]
  M --> H[Meal]
  M --> A[Oil]
  N[Gas] --> E[Power plant]
  E --> L[Electricity]

The physical link is what makes these spreads interesting. If the refining margin rises a lot, refineries process more crude and sell more gasoline, and the margin falls; if it drops below the cost of operating, they shut down units and the margin recovers. The spread cannot drift without limit away from what it costs to transform the product, at least over horizons of months. Other intercommodity spreads do not have that anchor: the gold/silver ratio or wheat versus corn are relative value bets between products that partly substitute for each other or that have moved together in the past.

yesnoTwo marketsPhysical link?Processing marginRelative valueReversion possibleNo anchor
Mermaid source
flowchart TB
  P[Two markets] --> V{Physical link?}
  V -- yes --> T[Processing margin]
  V -- no --> R[Relative value]
  T --> RV[Reversion possible]
  R --> SA[No anchor]

The level 3 data have the three processing margins and two relative value spreads, with the known answer in spreads3_verdad.csv: which component of the model moves each spread, how fast it reverts and which ones are controls. They are synthetic data: the markets imitate WTI, RBOB gasoline, New York Harbor diesel, Henry Hub gas, PJM peak electricity, the soybean complex, wheat, corn, gold and silver, but the prices are not market prices.

Units and contract ratios

Before subtracting two prices you have to put them in the same unit. Each contract is quoted in its own:

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

cur = 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 = ["PEC", "PEG", "PEH", "GNA", "ELB", "GRS", "GRH",
            "GRA", "GRT", "GRM", "MTO", "MTP"]
esp.loc[mercados, ["imita", "multiplicador", "unidad_cotizacion",
                   "meses"]]
OUT [01]Contract sizes and units
                                imita  multiplicador   unidad_cotizacion         meses
mercado
PEC                WTI Crude Oil (CL)         1000.0          USD/barril  FGHJKMNQUVXZ
PEG                RBOB Gasoline (RB)        42000.0           USD/galón  FGHJKMNQUVXZ
PEH               NY Harbor ULSD (HO)        42000.0           USD/galón  FGHJKMNQUVXZ
GNA        Henry Hub Natural Gas (NG)        10000.0           USD/MMBtu  FGHJKMNQUVXZ
ELB      PJM Western Hub Peak mensual          800.0             USD/MWh  FGHJKMNQUVXZ
GRS                     Soybeans (ZS)           50.0     centavos/bushel       FHKNQUX
GRH                 Soybean Meal (ZM)          100.0  USD/tonelada corta      FHKNQUVZ
GRA                  Soybean Oil (ZL)          600.0      centavos/libra      FHKNQUVZ
GRT            Chicago SRW Wheat (ZW)           50.0     centavos/bushel         HKNUZ
GRM                         Corn (ZC)           50.0     centavos/bushel         HKNUZ
MTO                         Gold (GC)          100.0            USD/onza        GJMQVZ
MTP                       Silver (SI)         5000.0            USD/onza         HKNUZ

The conversions that define each spread:

SpreadFormulaUnitConversion
1:1 gasoline crack42⋅PEG−PEC42 \cdot \text{PEG} - \text{PEC}USD/barrel42 gallons per barrel
1:1 diesel crack42⋅PEH−PEC42 \cdot \text{PEH} - \text{PEC}USD/barrel42 gallons per barrel
3:2:1 crack(2⋅42⋅PEG+42⋅PEH−3⋅PEC)/3(2 \cdot 42 \cdot \text{PEG} + 42 \cdot \text{PEH} - 3 \cdot \text{PEC})/3USD/barreltwo of gasoline for one of diesel
SparkELB−7⋅GNA\text{ELB} - 7 \cdot \text{GNA}USD/MWhheat rate of 7 MMBtu per MWh
Crush0.022⋅GRH+0.11⋅GRA−GRS/1000.022 \cdot \text{GRH} + 0.11 \cdot \text{GRA} - \text{GRS}/100USD/bushel0.022 t of meal and 11 lb of oil per bushel
Gold/silverMTO/MTP\text{MTO} / \text{MTP}ratioounces of silver per ounce of gold
Wheat/cornGRT/GRM\text{GRT} / \text{GRM}ratiosame bushels

In the crack, the gasoline or diesel contract has 42,000 gallons, which are 1,000 barrels: the same volume as the crude one. One product contract against one crude contract is the 1:1 crack, and the exchange treats it that way in its examples. The 3:2:1 (three of crude, two of gasoline, one of diesel) imitates the yield of a typical U.S. refinery, which produces roughly twice as much gasoline as distillates.

In the spark, the heat rate is the efficiency of the power plant: the MMBtu of gas it burns to produce one MWh. The EIA publishes the spark spread with a reference heat rate of 7,000 Btu/kWh (7 MMBtu/MWh), that of a modern, efficient combined cycle plant; and the spark does not count the plant's other costs (gas transport, maintenance, emissions), so a positive spark is not a profit.

In the crush, the CME guide starts from the physical yields: a 60-pound bushel gives about 44 pounds of meal and 11 of oil (plus hulls and shrink). With 44/2,000 = 0.022 short tons of meal per bushel and the contract sizes, the proportion in contracts is:

IN [02]python
bushels = 10 * 5000                    # 10 soybean contracts
harina_t = 44 / 2000 * bushels         # short tons
aceite_lb = 11 * bushels               # pounds
print(f"Meal: {harina_t:,.0f} t = {harina_t / 100:.2f} contracts")
print(f"Oil: {aceite_lb:,.0f} lb = "
      f"{aceite_lb / 60000:.2f} contracts")
OUT [02]The board crush: from yields to contracts
Meal: 1,100 t = 11.00 contracts
Oil: 550,000 lb = 9.17 contracts
Fig. — Anatomy of the soybean crush: the value of what comes out of a bushel against what it costs (July 2025 contracts, 4 June session)

The figure does the calculation with the July 2025 contracts on June 4: the meal from one bushel is worth 7.48 USD, the oil 5.52 and the soybeans cost 10.89, so the gross crush margin is 2.12 USD per bushel.

Ten soybean contracts give exactly eleven of meal and 9.17 of oil. CME facilitates the board crush as 30 contracts: 10 of soybeans, 11 of meal and 9 of oil. The oil ends up 2% short: with small whole numbers, the ratio is never exact. The second exercise does the same search for the spark spread.

The same delivery month in every leg

A July crack is July gasoline against July crude. Mixing months adds to the position a calendar spread nobody asked for. The legs also have different calendars: soybeans list seven months a year, meal and oil eight, and only six are common (January, March, May, July, August and September). For each session, the code looks for the first delivery month listed in every leg whose roll date has not arrived in any of them, and computes the daily change with the previous day's contracts, as in lesson 3.2.1:

IN [03]python
cm = cur.merge(ctr[["contrato", "mes_entrega"]], on="contrato")
px = cm.pivot_table(index="fecha", columns=["mercado", "mes_entrega"],
                    values="liquidacion")

def mes_comun(mercados, meses=None):
    """Delivery month held at the same time in every leg: the
    first one listed in all of them whose first roll date has not
    arrived."""
    c = ctr[ctr["mercado"].isin(mercados)]
    r = c.groupby("mes_entrega").agg(
        n=("mercado", "nunique"), roll=("fecha_roll", "min"))
    r = r[r["n"] == len(mercados)]
    if meses:
        r = r[[int(m[5:]) in meses for m in r.index]]
    roll = r["roll"][::-1].cummin()[::-1]
    pos = np.searchsorted(roll.to_numpy(), px.index.to_numpy(),
                          side="right")
    pos = np.minimum(pos, len(r) - 1)
    return pd.Series(r.index.to_numpy()[pos], index=px.index)

def patas_mes(mercados, meses=None):
    """Today's price of each leg in today's common month and today's
    change of each leg in yesterday's common month (no jumps)."""
    mes = mes_comun(mercados, meses)
    dif = px.diff()
    def toma(tabla, m, meses_):
        idx = pd.MultiIndex.from_arrays([mes.index, meses_.to_numpy()])
        return tabla[m].stack().reindex(idx).to_numpy()
    nivel = pd.DataFrame({m: toma(px, m, mes) for m in mercados},
                         index=mes.index)
    cambio = pd.DataFrame({m: toma(dif, m, mes.shift(1))
                           for m in mercados}, index=mes.index)
    return nivel, cambio, mes

niv, dif, mes = patas_mes(["PEG", "PEC"])
mes.loc["2025-06-02":"2025-06-06"]
OUT [03]The same delivery month in every leg
fecha
2025-06-02    2025-07
2025-06-03    2025-07
2025-06-04    2025-07
2025-06-05    2025-07
2025-06-06    2025-07
dtype: str

On June 4, 2025, the crack being held is the July one in both legs. With that function the six spreads are built in their units:

IN [04]python
SPREADS = {
    "crack_gasolina": (["PEG", "PEC"], None,
                       lambda p: 42 * p.PEG - p.PEC),
    "crack_gasoleo": (["PEH", "PEC"], None,
                      lambda p: 42 * p.PEH - p.PEC),
    "spark": (["ELB", "GNA"], None, lambda p: p.ELB - 7 * p.GNA),
    "crush": (["GRS", "GRH", "GRA"], [1, 3, 5, 7, 8, 9],
              lambda p: 0.022 * p.GRH + 0.11 * p.GRA - p.GRS / 100),
    "trigo_maiz": (["GRT", "GRM"], [3, 5, 7, 9, 12],
                   lambda p: p.GRT / p.GRM),
    "aceite_harina": (["GRA", "GRH"], [1, 3, 5, 7, 8, 9, 10, 12],
                      lambda p: p.GRA / p.GRH),
}
UNIDAD = {"crack_gasolina": "USD/barrel", "crack_gasoleo": "USD/barrel",
          "spark": "USD/MWh", "crush": "USD/bushel",
          "trigo_maiz": "ratio", "aceite_harina": "ratio"}
niveles = {}
for nombre, (ms, meses, f) in SPREADS.items():
    niveles[nombre] = f(patas_mes(ms, meses)[0])
tabla = pd.DataFrame(niveles).describe().T
tabla["unidad"] = pd.Series(UNIDAD)
tabla[["unidad", "mean", "min", "max"]].round(3)
OUT [04]Six spreads in their units
                    unidad    mean    min     max
crack_gasolina  USD/barrel  21.318 -8.886  65.059
crack_gasoleo   USD/barrel  31.515  9.177  78.124
spark              USD/MWh  15.711 -2.250  74.091
crush           USD/bushel   1.034 -0.289   3.611
trigo_maiz           ratio   1.355  1.019   1.779
aceite_harina        ratio   0.132  0.087   0.251

The average crack of the synthetic gasoline is 21.3 USD per barrel and it even turns negative (−8.89); the diesel one, higher and more stable, never falls below 9.18. The spark also turns negative (−2.25 USD/MWh): there are days when burning gas to produce peak electricity does not even cover the fuel. The average crush is 1.03 USD per bushel. The figure at the top shows the two cracks: the gasoline one oscillates much more, with a very marked annual cycle. Before interpreting it, it is worth looking at the average level by delivery month:

IN [05]python
mes_gas = patas_mes(["PEG", "PEC"])[2]
nivel_mes = niveles["crack_gasolina"].groupby(mes_gas.str[5:]).mean()
nivel_mes.round(1).to_frame("usd_barril").T
OUT [05]The gasoline crack by delivery month
              01    02    03    04    05    06    07    08    09    10   11    12
usd_barril  13.6  14.9  23.1  31.1  33.8  32.8  29.5  26.7  19.2  11.0  9.3  11.1

The gasoline crack for May delivery is worth on average 33.8 USD; the November one, 9.3. Summer gasoline is a different product (more expensive to produce) and less winter gasoline is sold, so the gasoline curve has a fixed seasonal shape. The cycle at the top is, above all, the switch from one delivery month to the next.

Risk: it depends on the correlation of the legs

A two-leg spread with volatilities σa\sigma_a and σb\sigma_b and correlation ρ\rho, with equal weights in logarithms, has a volatility of

σspread=σa2+σb2−2ρ σaσb.\sigma_{\text{spread}} = \sqrt{\sigma_a^2 + \sigma_b^2 - 2 \rho \, \sigma_a \sigma_b}.

The more correlated the legs are, the less risk remains. Measured with the same contracts from one day to the next:

IN [06]python
LOG = {
    "crack_gasolina": lambda p: np.log(42 * p.PEG / p.PEC),
    "crack_gasoleo": lambda p: np.log(42 * p.PEH / p.PEC),
    "spark": lambda p: np.log(p.ELB / (7 * p.GNA)),
    "crush": lambda p: np.log((0.022 * p.GRH + 0.11 * p.GRA)
                              / (p.GRS / 100)),
    "trigo_maiz": lambda p: np.log(p.GRT / p.GRM),
    "aceite_harina": lambda p: np.log(p.GRA / p.GRH),
}
filas = {}
for nombre, (ms, meses, _) in SPREADS.items():
    nivel, cambio, mes = patas_mes(ms, meses)
    antes = nivel.shift(1)            # yesterday, yesterday's contract
    r = np.log((antes + cambio) / antes).dropna()     # same contract
    s = LOG[nombre](antes + cambio) - LOG[nombre](antes)
    a, b = ms[0], ms[-1]
    filas[nombre] = {"pata_a": a, "vol_a": r[a].std() * 252 ** 0.5,
                     "pata_b": b, "vol_b": r[b].std() * 252 ** 0.5,
                     "correlacion": r[a].corr(r[b]),
                     "vol_spread": s.std() * 252 ** 0.5}
riesgo = pd.DataFrame(filas).T
num = ["vol_a", "vol_b", "correlacion", "vol_spread"]
riesgo[num] = riesgo[num].astype(float).round(2)
riesgo
OUT [06]Risk of the legs and of the spread, in logarithms
               pata_a  vol_a pata_b  vol_b  correlacion  vol_spread
crack_gasolina    PEG   0.39    PEC   0.36         0.91        0.16
crack_gasoleo     PEH   0.38    PEC   0.36         0.93        0.14
spark             ELB   0.48    GNA   0.42         0.81        0.29
crush             GRS   0.19    GRA   0.27         0.71        0.10
trigo_maiz        GRT   0.29    GRM   0.24         0.83        0.17
aceite_harina     GRA   0.28    GRH   0.25         0.49        0.27

The legs of the crack move with a correlation of 0.91 (gasoline) and 0.93 (diesel): the spread keeps 14-16% annual volatility versus 36-39% for each leg. The spark, at 0.81, keeps 29%; oil versus meal, at 0.49, 27%: almost as much as each leg. In the crush the table shows soybeans and oil (0.71), but the spread includes all three legs, and its volatility is the lowest of all, 10%. An intercommodity spread has more risk than a calendar spread of the same market (lesson 3.2.1), because two different products are less alike than two months of the same one.

That lower risk is also reflected in the margin, although less than in calendars. CME explains that, thanks to the offset between the legs, a crack can require up to 30% less capital than an outright position in gasoline or diesel, and in its example with micros the crack requires 480 USD versus a diesel leg that, on its own, requires 599: 19% less. A crude calendar in the data requires 12% of the outright margin, not 70-80%.

Reversion: planted versus measured

The physical link suggests that processing margins revert. In the data they do by construction: each spread's own component is an Ornstein-Uhlenbeck process with speed κP\kappa_P, and its half-life in sessions is ln⁡2/κP⋅252\ln 2 / \kappa_P \cdot 252. The useful question is whether a standard method recovers it. The method is the one from lesson 2.1.5: an AR(1) of the level of the log-spread, from which its mean by delivery month is first removed (the seasonal shape of the curve from the previous section), and an ADF unit root test:

Δxt=a+b xt−1+εt,half-life=ln⁡2−b.\Delta x_t = a + b \, x_{t-1} + \varepsilon_t, \qquad \text{half-life} = \frac{\ln 2}{-b}.
IN [07]python
from statsmodels.tsa.stattools import adfuller

ver = pd.read_csv("spreads3_verdad.csv", index_col="spread")

def adf_p(x):
    """ADF p-value; result_object=False keeps the usual tuple
    and avoids the statsmodels 0.15 FutureWarning."""
    return adfuller(x, result_object=False)[1]

def vida_media(nivel, clave):
    """AR(1) of the level minus its mean by key (delivery month)."""
    x = (nivel - nivel.groupby(clave).transform("mean")).dropna()
    b = np.polyfit(x.shift(1).dropna(), x.diff().dropna(), 1)[0]
    return np.log(2) / -b if b < 0 else np.inf

res = {}
for nombre, (ms, meses, _) in SPREADS.items():
    nivel, _, mes = patas_mes(ms, meses)
    s = LOG[nombre](nivel)
    clave = mes.str[5:]
    x = (s - s.groupby(clave).transform("mean")).dropna()
    res[nombre] = {"plantada": ver.loc[nombre, "vida_media_sesiones"],
                   "medida": vida_media(s, clave),
                   "p_adf": adf_p(x),
                   "control": ver.loc[nombre, "control"]}
pd.DataFrame(res).T.astype({"plantada": float, "medida": float,
                            "p_adf": float}).round(
    {"plantada": 1, "medida": 1, "p_adf": 4})
OUT [07]Planted and measured half-life
                plantada  medida   p_adf control
crack_gasolina      43.7    36.6  0.0000   False
crack_gasoleo       58.2    40.5  0.0000    True
spark               29.1    17.5  0.0000   False
crush               69.9    54.6  0.0000   False
trigo_maiz         174.7    71.4  0.0003   False
aceite_harina      218.3   157.6  0.0522    True
Fig. — Mean-reversion half-life planted in the data against the one measured with an AR(1), six spreads and the gold/silver control

The ADF rejects the unit root in five of the six spreads and the AR(1) recovers the order of magnitude of the reversion, but with a systematic bias: every measured half-life is shorter than the planted one. In the spark, 17.5 sessions versus 29.1; in the gasoline crack, 36.6 versus 43.7; in wheat versus corn, 71.4 versus 174.7, less than half. It is the bias of the autoregressive estimator in finite samples that lesson 2.1.5 measured with simulations, to which is added that each spread mixes its own component with leftovers of the legs' common factors.

Oil versus meal is the hard case: it reverts (planted half-life of 218 sessions, almost a year), but so slowly that the ADF stays at p = 0.052 with 16 years of data. A test at 5% would deem it non-stationary.

A ratio without an anchor: gold versus silver

The gold/silver ratio is the classic example of relative value: how many ounces of silver one ounce of gold buys. In the data it is, by construction, a random walk with episodes of drift: there is nothing that brings it back to a mean.

IN [08]python
def simple(mercado):
    c = cur[cur["mercado"] == mercado].merge(ctr, on=["mercado",
                                                     "contrato"])
    c = c[c["fecha"] < c["fecha_roll"]].sort_values(
        ["fecha", "vencimiento"])
    return c.groupby("fecha")["liquidacion"].first()

ratio = simple("MTO") / simple("MTP")
lr = np.log(ratio)
uno = pd.Series("", index=lr.index)
print(f"Gold/silver ratio: from {ratio.min():.1f} to {ratio.max():.1f}")
print(f"Whole sample: half-life {vida_media(lr, uno):.0f} "
      f"sessions, ADF p {adf_p(lr):.2f}")
for ini in range(2010, 2023, 3):
    t = lr.loc[str(ini):str(ini + 2)]
    print(f"{ini}-{ini + 2}: half-life "
          f"{vida_media(t, uno.loc[t.index]):4.0f}, "
          f"ADF p {adf_p(t):.2f}")
OUT [08]The gold/silver control: a random walk
Gold/silver ratio: from 39.5 to 108.2
Whole sample: half-life 698 sessions, ADF p 0.58
2010-2012: half-life  183, ADF p 0.37
2013-2015: half-life   61, ADF p 0.11
2016-2018: half-life  153, ADF p 0.84
2019-2021: half-life  136, ADF p 0.66
2022-2024: half-life  111, ADF p 0.51

The ratio ranges from 39.5 to 108.2 in 16 years. The AR(1) of the whole sample gives a half-life of 698 sessions (almost three years), which sounds like “slow reversion”; in three-year windows it gives between 61 and 183 sessions, which sounds like tradable reversion. But the ADF does not reject the unit root either in the whole sample (p = 0.58) or in any window (from 0.11 to 0.84). The regression of the change on the level almost always gives a negative coefficient in a finite sample, even if the process does not revert: the half-life comes out finite because the estimator is biased, not because there is reversion. It is the highlighted point in the previous figure.

What this changes in a system

  • Units, first. Each leg in its quotation unit, converted before subtracting; a unit error gives a different spread.
  • The same month in every leg. Otherwise, the spread carries a calendar inside it. With different delivery calendars, only the common months count.
  • Whole ratios. The formula gives fractions; contracts do not. 10:11:9 in the crush, 25:14 in the spark: with few contracts, rounding leaves exposure to one leg. Hedges by notional, DV01 or volatility are the subject of lesson 3.2.4.
  • Tested reversion. The measured half-life is shorter than the real one, and a ratio without an anchor also has a measured half-life. Reversion is tested and explained.
  • Seasonal shape is not drift. The level by delivery month is priced in; the drift, if it exists, is studied separately (lesson 3.2.6). The leg-by-leg backtest, with its costs and the risk of executing one leg without the other, is the one in lesson 3.2.5.

Exercises

  1. Build the daily P&L in USD of the 3:2:1 crack (two gasoline contracts and one diesel contract against three of crude) and check that it is the sum of two gasoline cracks and one diesel crack. Compare its risk in 2025 with the sum of the risks of the three cracks separately.
Solution
IN [09]python
nivel, cambio, mes = patas_mes(["PEG", "PEH", "PEC"])
usd = 1000 * (2 * 42 * cambio.PEG + 42 * cambio.PEH
              - 3 * cambio.PEC)                 # 2 PEG + 1 PEH − 3 PEC
gas = 1000 * (42 * cambio.PEG - cambio.PEC)     # 1:1 crack
die = 1000 * (42 * cambio.PEH - cambio.PEC)
print("Equal to 2 gasoline cracks + 1 diesel crack:",
      np.allclose(usd.dropna(), (2 * gas + die).dropna()))
r25 = pd.DataFrame({"usd": usd, "gas": gas, "die": die}).loc["2025"]
print(f"Daily risk of the 3:2:1: {r25['usd'].std():,.0f} USD")
print(f"Sum of the separate risks: "
      f"{2 * r25['gas'].std() + r25['die'].std():,.0f} USD")
print(f"Correlation of the two cracks: {gas.corr(die):.2f}")
OUT [09]The 3:2:1 crack
Equal to 2 gasoline cracks + 1 diesel crack: True
Daily risk of the 3:2:1: 1,982 USD
Sum of the separate risks: 2,529 USD
Correlation of the two cracks: 0.18

The 3:2:1 is, exactly, two gasoline cracks plus one diesel crack. Its daily risk in 2025 (1,982 USD) is lower than the sum of the separate risks (2,529 USD) because the two cracks are weakly correlated with each other (0.18): their own components are independent in the data model, and they only share crude, which cancels out in both. Combining margins of different products diversifies.

  1. With the contract sizes for electricity (800 MWh) and gas (10,000 MMBtu) from the specification sheet and a heat rate of 7, find the whole contract ratio that replicates the spark spread with the least error, with up to 25 electricity contracts.
Solution
IN [10]python
mwh = esp.loc["ELB", "multiplicador"]          # MWh per contract
mmbtu = esp.loc["GNA", "multiplicador"]        # MMBtu per contract
exacto = 7 * mwh / mmbtu                       # GNA per ELB
print(f"Gas contracts per electricity contract: {exacto:.2f}")
mejor = []
for n_elb in range(1, 26):
    n_gna = round(n_elb * exacto)
    if n_gna:
        error = n_gna / n_elb / exacto - 1
        mejor.append((abs(error), n_elb, n_gna, error))
for _, a, b, e in sorted(mejor)[:4]:
    print(f"{a:2d} ELB : {b:2d} GNA  error {e:+.1%}")
OUT [10]A whole ratio for the spark spread
Gas contracts per electricity contract: 0.56
25 ELB : 14 GNA  error +0.0%
16 ELB :  9 GNA  error +0.4%
 9 ELB :  5 GNA  error -0.8%
18 ELB : 10 GNA  error -0.8%

An 800 MWh electricity contract burns 5,600 MMBtu of gas with a heat rate of 7: 0.56 gas contracts. The exact ratio with small numbers is 25 electricity contracts for 14 of gas; 16:9 leaves an error of 0.4% and 9:5, of 0.8%. With fewer contracts the error grows: with 2:1, the gas ends up 11% short. In a small position, the “exact” spark spread does not exist, and what is left over is an outright position in gas.

  1. A crusher hedges its margin by selling the board crush (sells meal and oil, buys soybeans). Explain which risk it hedges, which risk it does not hedge and why the result depends on the delivery month it chooses.
Solution

Selling the crush locks in today the margin of the months it chooses: if meal and oil later fall relative to soybeans, what it loses in the physical business it gains in the futures. It hedges the price of the three products in Chicago, not its own yield (if its plants get less meal per bushel than 0.022 tons, the real margin is different), nor the local basis (the difference between the price in its area and that of the contract), nor the operating costs, which are not in the formula. The month matters because only six months are common to the three legs and because each delivery month has its own crush level: the shape of each product's curve (old crop versus new crop) is priced into each contract. The hedge for the July month does not lock in the October margin.

Each answer is folded under its question

Self-assessment

  1. Why is a 1:1 gasoline crack done with one gasoline contract for each crude contract?

    • ABecause both are quoted in USD
    • BBecause the refinery produces the same amount of gasoline as crude
    • CBecause the exchange requires trading the legs in the same quantity
    • DBecause the gasoline contract (42,000 gallons) is equivalent to 1,000 barrels, the same volume as the crude one
    Show answer

    Answer D. Because the gasoline contract (42,000 gallons) is equivalent to 1,000 barrels, the same volume as the crude one

    A barrel has 42 gallons: 42,000 gallons are 1,000 barrels, the size of the crude contract. That is why the crack in USD per barrel is 42 × product price − crude price, and one contract on each side replicates it without rounding.

  2. The CME board crush is traded as 10 soybean contracts, 11 of meal and 9 of oil. Where does that proportion come from?

    • AFrom the physical yields: 50,000 bushels give 1,100 short tons of meal (11 contracts of 100) and 550,000 pounds of oil (9.17 contracts of 60,000, rounded to 9)
    • BFrom the liquidity of each contract
    • CFrom matching the notional of the three legs
    • DIt is a convention unrelated to the process
    Show answer

    Answer A. From the physical yields: 50,000 bushels give 1,100 short tons of meal (11 contracts of 100) and 550,000 pounds of oil (9.17 contracts of 60,000, rounded to 9)

    A 60-pound bushel gives about 44 pounds of meal and 11 of oil (CME crush guide). With contracts of 5,000 bushels, 100 short tons and 60,000 pounds, the closest whole proportion with few contracts is 10:11:9; the oil ends up 2% short.

  3. In the data, the measured half-life of every spread with reversion is shorter than the planted one (for example, 17.5 versus 29.1 sessions in the spark). Why?

    • ABecause the data have errors
    • BBecause of the bias of the AR(1) estimator in finite samples: the coefficient comes out smaller than the true one and the reversion looks faster (lesson 2.1.5)
    • CBecause the reversion speeds up over time
    • DBecause the planted half-lives are in calendar days
    Show answer

    Answer B. Because of the bias of the AR(1) estimator in finite samples: the coefficient comes out smaller than the true one and the reversion looks faster (lesson 2.1.5)

    The OLS estimator of the autoregressive coefficient is biased downward, more so the slower the reversion is relative to the sample. In addition, the measured half-life mixes the reverting component with other factors of the spread. What matters is the sign of the error: a backtest calibrated with the measured half-life expects a faster reversion than the real one.

  4. The gold/silver ratio of the data gives a half-life of 698 sessions over the whole sample and of 61 to 183 in three-year windows. What do you conclude?

    • AThat it reverts slowly and can be traded over long horizons
    • BThat it reverts quickly in short windows
    • CNothing in favor of reversion: it is a random walk by construction, the ADF does not reject (p = 0.58) and an estimated AR(1) always gives a finite half-life
    • DThat shorter windows should be used
    Show answer

    Answer C. Nothing in favor of reversion: it is a random walk by construction, the ADF does not reject (p = 0.58) and an estimated AR(1) always gives a finite half-life

    The regression of the change on the level almost always gives a negative coefficient in a finite sample, even if the process does not revert: the half-life comes out finite because of how the estimator is built. The test (ADF) is what says whether there is reversion; here, it does not reject at 5% in any window.

  5. The gasoline crack of the data is worth on average 33.8 USD per barrel in the May contracts and 9.3 in the November ones. Is it a seasonal opportunity?

    • AYes: buy the crack in November and sell it in May
    • BYes, because the difference is more than 20 USD
    • CNo, because the gasoline crack has no seasonality
    • DNot necessarily: it is the seasonal shape of the curve (summer gasoline is a different product and trades higher), and the market already prices it into each contract
    Show answer

    Answer D. Not necessarily: it is the seasonal shape of the curve (summer gasoline is a different product and trades higher), and the market already prices it into each contract

    The average level by delivery month is a property of each contract, known from the day it is listed. Profiting from it would require the May contract to rise while it is held, and that is a drift, something else. Whether it exists and whether it is tradable is studied in lesson 3.2.6.

Key points

  • An intercommodity spread pits different markets against each other: processing spreads (crack, spark, crush) have a physical link; relative value ones (gold/silver, wheat/corn), only one of substitution or of history.
  • Before subtracting you have to convert units (42 gallons per barrel, heat rate of 7 MMBtu per MWh, 0.022 tons of meal and 11 pounds of oil per bushel) and use the same delivery month in every leg.
  • The contract ratio comes from the sizes: 1:1 in the crack, 10:11:9 in the crush, 25:14 in the spark of the data; small ratios round.
  • The risk of the spread depends on the correlation of its legs: 16% a year in the gasoline crack (legs with 0.91 correlation) versus 27% in oil/meal (0.49).
  • The measured half-life is shorter than the planted one in every spread that reverts; the gold/silver control, a random walk, gives a finite half-life and an ADF that does not reject.

Resources