---
title: "3.2.2 Intercommodity spreads: crack, spark and crush"
description: "Spreads between markets: crack, spark, crush, gold/silver and wheat/corn. Units, contract ratios, risk and measured versus planted reversion."
url: "https://pythonparatrading.com/en/python-trading-course/level-3/intercommodity-spreads-crack-spark-crush/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-3/intercommodity-spreads-crack-spark-crush.png"
---

# 3.2.2 · 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.

- Course: [Level 3: Derivatives: futures, spreads, forex and options](https://pythonparatrading.com/en/python-trading-course/level-3/)
- Module: 3.2 · Futures spreads
- Duration: 65 min
- Prerequisites: [3.2.1 · Calendar spreads, butterflies and condors](https://pythonparatrading.com/en/python-trading-course/level-3/calendar-spreads-butterflies-condors/), [2.1.5 · Cointegration: Engle-Granger, Johansen and the half-life of mean reversion](https://pythonparatrading.com/en/python-trading-course/level-2/cointegration-engle-granger-johansen-half-life/)

## By the end of this lesson

- Build the processing spreads (1:1 and 3:2:1 crack, spark, crush) and the relative value ones (gold/silver, wheat/corn) converting units and with the same delivery month in every leg.
- Go from the formula of a spread to the whole number of contracts of each leg (10:11:9 in the crush, 1:1 in the crack) and measure the rounding error.
- Measure the risk of a spread versus that of its legs and relate it to the correlation between them.
- Estimate the half-life of the reversion, compare it with the known answer and recognize the false positive of a ratio without a physical link.

## 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**).

```mermaid
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.

```mermaid
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:

```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"]]
```
```output
                                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:

| Spread | Formula | Unit | Conversion |
|---|---|---|---|
| 1:1 gasoline crack | $42 \cdot \text{PEG} - \text{PEC}$ | USD/barrel | 42 gallons per barrel |
| 1:1 diesel crack | $42 \cdot \text{PEH} - \text{PEC}$ | USD/barrel | 42 gallons per barrel |
| 3:2:1 crack | $(2 \cdot 42 \cdot \text{PEG} + 42 \cdot \text{PEH} - 3 \cdot \text{PEC})/3$ | USD/barrel | two of gasoline for one of diesel |
| Spark | $\text{ELB} - 7 \cdot \text{GNA}$ | USD/MWh | heat rate of 7 MMBtu per MWh |
| Crush | $0.022 \cdot \text{GRH} + 0.11 \cdot \text{GRA} - \text{GRS}/100$ | USD/bushel | 0.022 t of meal and 11 lb of oil per bushel |
| Gold/silver | $\text{MTO} / \text{MTP}$ | ratio | ounces of silver per ounce of gold |
| Wheat/corn | $\text{GRT} / \text{GRM}$ | ratio | same 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:

```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")
```
```output
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).* Horizontal bars in USD per bushel with the July 2025 contracts on 4 June: the meal from one bushel (0.022 short tons at 340.2 USD) is worth 7.48; the oil (11 pounds at 50.17 cents), 5.52; the soybeans cost 10.89. The crush margin, highlighted, is 7.48 + 5.52 − 10.89 = 2.12 USD per bushel.

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.

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

The formulas in the table use the quotation units. Soybeans and soybean oil are quoted in cents (per bushel and per
pound), meal in USD per short ton: hence the $/100$ for soybeans and the 0.11 (11 pounds × 0.01 USD) for oil. A unit
error in a spread does not give a small error: it gives a different spread.

</aside>

## 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:

```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"]
```
```output
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:

```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)
```
```output
                    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:

```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
```
```output
              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.

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

That the May crack trades higher than the November one does not mean that buying the crack in November and selling it
in May makes money: the May contract already trades high the day it is listed. The seasonal shape of the curve is
priced in (in the data, by construction). What could make money is a seasonal **drift**, the contract being held
rising predictably in certain months. Separating one thing from the other, and not fooling yourself with 16 years of
data, is the subject of lesson 3.2.6.

</aside>

## Risk: it depends on the correlation of the legs

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

$$
\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:

```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
```
```output
               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 $\kappa_P$, and its half-life in sessions is
$\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:

$$
\Delta x_t = a + b \, x_{t-1} + \varepsilon_t, \qquad \text{half-life} = \frac{\ln 2}{-b}.
$$

```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})
```
```output
                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.* Log scatter. Every spread sits below the diagonal: the measured half-life is shorter than the planted one (spark 17.5 against 29.1 sessions; gasoline crack 36.6 against 43.7; crush 54.6 against 69.9; wheat/corn 71.4 against 174.7; oil/meal 157.6 against 218.3). Highlighted, the gold/silver ratio, a random walk with no reversion (infinite half-life), gives a measured half-life of 698 sessions.

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.

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

The `control` column of `spreads3_verdad.csv` refers to **seasonality** (lesson 3.2.6): the diesel crack and oil
versus meal have no planted seasonal drift, but they do revert. The control for reversion is the gold/silver ratio.

</aside>

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

```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}")
```
```output
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.

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

A mean reversion strategy on a ratio without a physical link is calibrated with a half-life that the estimator itself
manufactures. In a three-year window, the gold/silver ratio “reverts” with a half-life of 61 sessions; the next window
gives 153 and nothing guarantees that the ratio will come back. Before trading the reversion of a spread you have to
test it (ADF, KPSS, lesson 2.1.2) and, if possible, explain what produces it.

</aside>

## 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.**

```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}")
```
```output
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.

2. 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.**

```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%}")
```
```output
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.

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

## Self-assessment

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

   - A. Because both are quoted in USD
   - B. Because the refinery produces the same amount of gasoline as crude
   - C. Because the exchange requires trading the legs in the same quantity
   - D. Because the gasoline contract (42,000 gallons) is equivalent to 1,000 barrels, the same volume as the crude one

   *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?

   - 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)
   - B. From the liquidity of each contract
   - C. From matching the notional of the three legs
   - D. It is a convention unrelated to the process

   *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?

   - A. Because the data have errors
   - 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)
   - C. Because the reversion speeds up over time
   - D. Because the planted half-lives are in calendar days

   *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?

   - A. That it reverts slowly and can be traded over long horizons
   - B. That it reverts quickly in short windows
   - 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
   - D. That shorter windows should be used

   *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?

   - A. Yes: buy the crack in November and sell it in May
   - B. Yes, because the difference is more than 20 USD
   - C. No, because the gasoline crack has no seasonality
   - 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

   *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

### Documentation

- [CME Group: Trading Crack Spreads (1:1 and 3:2:1 crack, conversion to barrels, margin offsets)](https://www.cmegroup.com/articles/2024/trading-crack-spreads.html) · cmegroup.com
- [CME Group: An Introduction to Crack Spreads (1:1, 3:2:1, 5:3:2 and 2:1:1 ratios)](https://www.cmegroup.com/articles/whitepapers/an-introduction-to-crack-spreads.html) · cmegroup.com
- [CME Group: Soybean Crush Reference Guide (yields per bushel and board crush)](https://www.cmegroup.com/education/files/soybean-crush-reference-guide.pdf) · cmegroup.com
- [CME Group: Soybean Crush Spreads (10 soybeans, 11 meal, 9 oil)](https://www.cmegroup.com/trading/agricultural/grain-and-oilseed/soybean-crush-spreads.html) · cmegroup.com
- [U.S. EIA: An introduction to spark spreads (2013)](https://www.eia.gov/todayinenergy/detail.php?id=9911) · eia.gov
- [U.S. EIA: Today in Energy, Daily Prices (spark spread with a reference heat rate of 7,000 Btu/kWh)](https://www.eia.gov/todayinenergy/prices.php) · eia.gov

### Data

- [Level 3 data: the truth of the spreads (planted parameters and controls)](https://pythonparatrading.com/curso/datos/nivel-3/spreads3_verdad.csv) · Download · spreads3_verdad.csv

Previous lesson: [3.2.1 · Calendar spreads, butterflies and condors](https://pythonparatrading.com/en/python-trading-course/level-3/calendar-spreads-butterflies-condors/)

Next lesson: [3.2.3 · Rate and index spreads: 2s10s, NOB, NQ/ES and Europe versus the U.S.](https://pythonparatrading.com/en/python-trading-course/level-3/interest-rate-and-index-spreads/)
