---
title: "3.2.4 Hedge ratios: notional, DV01 and volatility"
description: "How many contracts of each leg a spread carries: notional, DV01, volatility and minimum variance ratios, the risk each leaves and how to round."
url: "https://pythonparatrading.com/en/python-trading-course/level-3/hedge-ratios-notional-dv01-volatility/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-3/hedge-ratios-notional-dv01-volatility.png"
---

# 3.2.4 · Hedge ratios: by notional, by DV01 and by volatility

> A spread is not just “long one and short another”: the number of contracts of each leg decides what risk remains. This lesson computes the ratios by notional value, by DV01, by volatility and by minimum variance in the level's rate, index and energy futures, and measures with the data what each hedge leaves inside.

- Course: [Level 3: Derivatives: futures, spreads, forex and options](https://pythonparatrading.com/en/python-trading-course/level-3/)
- Module: 3.2 · Futures spreads
- Duration: 60 min
- Prerequisites: [1.2.3 · Futures: specifications, margin, roll and contango](https://pythonparatrading.com/en/python-trading-course/level-1/futures-contract-specs-margin-and-roll/), [1.2.7 · Fixed income and interest rates; CFDs and leveraged products](https://pythonparatrading.com/en/python-trading-course/level-1/fixed-income-rates-and-leveraged-products/), [2.1.6 · Kalman filter for dynamic hedge ratios](https://pythonparatrading.com/en/python-trading-course/level-2/kalman-filter-dynamic-hedge-ratio/), [3.2.1 · Calendar spreads, butterflies and condors](https://pythonparatrading.com/en/python-trading-course/level-3/calendar-spreads-butterflies-condors/)

## By the end of this lesson

- Compute the hedge ratio of a spread by notional value, by DV01, by volatility and by minimum variance, with the multipliers of each contract.
- Measure with a regression on level and slope what exposure each ratio leaves in a rate spread, and check it against the known answer.
- Explain why the volatility ratio fails when one leg stops moving, and why the DV01 ratio is stable.
- Round a ratio to whole contracts, choose the minimum size that respects it and decide how often to rebalance it.
- Choose the ratio according to what you want to trade: rates, market direction, money at risk or physical units.

## What each ratio matches

A futures spread has two legs: you buy one and sell the other. The practical question is **how many contracts of
each**. That figure, the **hedge ratio** $h$ (contracts of one leg for each contract of the other), decides which risk
is canceled and which remains. With $F$ the price, $m$ the contract multiplier (what one price point is worth),
$\mathrm{DV01}$ the change in money of one contract for one basis point of rates and $\sigma$ the volatility of the
return, the four ratios of this lesson, to hedge one contract of leg $L$ with contracts of leg $C$, are:

$$
h_{\text{notional}} = \frac{F_L\,m_L}{F_C\,m_C}, \qquad
h_{\mathrm{DV01}} = \frac{\mathrm{DV01}_L}{\mathrm{DV01}_C}, \qquad
h_{\sigma} = \frac{\sigma_L\,F_L\,m_L}{\sigma_C\,F_C\,m_C}, \qquad
h_{\mathrm{MV}} = \frac{\operatorname{Cov}(\Delta V_L, \Delta V_C)}{\operatorname{Var}(\Delta V_C)}
$$

with $\Delta V$ the daily change in the value of one contract in money. The first matches the money invested; the
second, the sensitivity to a one basis point shift in rates; the third, the money at risk with recent volatility; the
fourth, the **minimum variance** one, is the slope of a regression: the one that would have left the lowest total risk
in the estimation window (lesson 2.1.6 makes it dynamic with a Kalman filter).

| Ratio | What it matches | What it needs | When it makes sense |
|---|---|---|---|
| Notional | Money in each leg | Price and multiplier | Relative return of two similar assets |
| DV01 | Sensitivity to 1 bp | DV01 per contract | Rate spreads: isolating the slope |
| Volatility | Recent money at risk | Estimated σ | Legs with no structural relationship |
| Minimum variance | Past total risk | P&L regression | Reducing risk, without saying which |
| Units | Physical quantity | Contract specification | Physical margins: crack, crush, spark |

None is “the correct one” in the abstract. The choice depends on what you want to leave out of the spread:

```mermaid
flowchart TB
  Q{What do you cancel?} -- rates --> D[DV01]
  Q -- market --> B[Beta]
  Q -- physical margin --> U[Units]
  Q -- total risk --> V[Volatility or MV]
```

The data in this lesson are **synthetic**: fictitious rate, index and energy futures that imitate real ones (`TPA` the
2-year, `TPC` the 10-year, `TPD` the 30-year bond, `RVA` and `RVB` the S&P 500 and Nasdaq-100 minis, `PEC` and `PEG`
crude and gasoline). Their advantage is that the answer is known: the file `tipos3_dv01.csv` contains the exact DV01
of each contract with the model that generated the prices, and the beta of the fictitious Nasdaq on the market factor
is set at 1.15.

## Three rate legs and their DV01

In bond futures, the DV01 of a contract is the change in its price for one basis point of yield of the bond that
determines it (in real contracts, the cheapest to deliver; here, one standard bond per contract), in money. Lesson
1.2.7 computed it for a bond; `tipos3_dv01.csv` gives it for the contract held in each session:

```python
import numpy as np
import pandas as pd

dv = pd.read_csv("tipos3_dv01.csv", parse_dates=["fecha"])
esp = pd.read_csv("futuros3_especificaciones.csv",
                  index_col="mercado")
mult = esp["multiplicador"]

fin = dv[dv["fecha"] == "2025-12-31"].set_index("mercado")
fin["nominal"] = fin["liquidacion"] * mult.reindex(fin.index)
fin[["contrato", "liquidacion", "rendimiento_ctd",
     "duracion_modificada", "dv01", "nominal"]].round(2)
```
```output
        contrato  liquidacion  rendimiento_ctd  duracion_modificada    dv01    nominal
mercado
TPA       TPAH26       104.27             3.67                 2.04   42.55  208546.88
TPB       TPBH26       108.75             3.73                 3.97   43.21  108750.00
TPC       TPCH26       112.55             3.83                 5.73   64.47  112546.88
TPD       TPDH26       121.62             4.13                11.06  134.46  121625.00
TPE       TPEH26       126.91             2.51                 7.24   91.88  126910.00
```

The 2-year contract (`TPA`) has twice the notional of the others (the multiplier is 2,000 USD per price point, versus
1,000), but its duration is 2 years: its DV01, 42.55 USD per bp, is less than a third of that of the 30-year bond
(134.46). Matching notionals and matching sensitivities to rates therefore give very different figures.

To measure the P&L of each leg you need the price change of the **same contract** from one day to the next: that of
the series of the contract held jumps at each roll, when you move from one expiration to the next (lesson 1.4.6). The
following function takes the contract held yesterday and looks up its price yesterday and today in
`futuros3_curvas.parquet`:

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

def cambio_mismo_contrato(contratos):
    """Today's change of the contract held yesterday: without the
    roll jump (lesson 1.4.6)."""
    ayer = contratos.shift(1).iloc[1:]
    f, f0 = contratos.index[1:], contratos.index[:-1]
    hoy = px.reindex(list(zip(f, ayer))).to_numpy()
    antes = px.reindex(list(zip(f0, ayer))).to_numpy()
    return pd.Series(hoy - antes, index=f)

D = dv.pivot(index="fecha", columns="mercado", values="dv01")
F = dv.pivot(index="fecha", columns="mercado", values="liquidacion")
Y = dv.pivot(index="fecha", columns="mercado",
             values="rendimiento_ctd")
C = dv.pivot(index="fecha", columns="mercado", values="contrato")
dP = pd.DataFrame({k: cambio_mismo_contrato(C[k])
                   for k in ["TPA", "TPB", "TPC", "TPD"]})
usd = dP * mult[dP.columns]         # USD per contract and session
ret = dP / F[dP.columns].shift(1)   # return of the contract
usd.loc["2025-12-24":].round(1)
```
```output
              TPA    TPB     TPC     TPD
fecha
2025-12-24   70.3   70.3    15.6  -312.5
2025-12-26  265.6   85.9  -109.4  -843.8
2025-12-29  328.1  140.6     0.0  -468.8
2025-12-30  507.8  656.2  1062.5  2250.0
2025-12-31 -500.0 -437.5  -578.1 -1000.0
```

The figures are multiples of the tick value of each contract (15.625 USD in `TPC`, 31.25 in `TPD`, 7.8125 in `TPA`):
settlements are rounded to the tick, as in a real market.

## Four ratios for the 2s10s and the NOB

Two classic rate spreads serve as a laboratory. The **2s10s** pits the 2-year note against the 10-year; the **NOB**
(*notes over bonds*), the 10-year against the 30-year bond. Lesson 3.2.3 builds them and studies their behavior; here
the only question is how many contracts each leg carries. In both, the ratio is expressed as contracts of the short
maturity for each contract of the long one:

```python
def ratios(corto, largo):
    """Contracts of the short leg per contract of the long leg."""
    nominal = (F[largo] * mult[largo]) / (F[corto] * mult[corto])
    dv01 = D[largo] / D[corto]
    sig = ret.rolling(63).std()
    vol = nominal * sig[largo] / sig[corto]
    mv = (usd[largo].rolling(252).cov(usd[corto])
          / usd[corto].rolling(252).var())
    return pd.DataFrame({"nominal": nominal, "dv01": dv01,
                         "volatilidad": vol, "min_varianza": mv})

R = {"2s10s": ratios("TPA", "TPC"), "nob": ratios("TPC", "TPD")}
pd.DataFrame({k: v.iloc[-1] for k, v in R.items()}).round(2)
```
```output
              2s10s   nob
nominal        0.54  1.08
dv01           1.52  2.09
volatilidad    1.41  2.15
min_varianza   1.18  2.03
```

In the 2s10s, the notional asks for half a 2-year contract for each 10-year contract (the 2-year has twice the
notional) and the DV01, one and a half: almost three times as much. In the NOB, the notional asks for slightly more
than one and the DV01 slightly more than two. The 63-session volatility and the 252-session regression give, at the
end of 2025, values close to the DV01 one, because in 2025 the two maturities move similarly in basis points. It is
not always so, as shown below.

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

Exchanges list rate spreads with fixed contract ratios. The CME Globex documentation cites the NOB and other CBOT
intercommodity spreads among those with a leg “in a ratio greater than one” (link in the resources). A fixed market
ratio is convenient (it trades as one instrument), but it is set in advance: between revisions, the DV01 of the legs
keeps moving.

</aside>

## What each hedge leaves

A steepener gains if the curve steepens: it buys $h$ contracts of the short maturity and sells one of the long. Its
daily P&L per long contract is $h\,\Delta V_C - \Delta V_L$, with $h$ the ratio **of the previous session** (today's
would use today's close: look-ahead). To know what risk remains, you regress that P&L on the change in the yield of the
long leg (the level) and that of the slope (long minus short), in basis points:

$$
\mathrm{P\&L}_t = a + \beta_{\text{level}}\,\Delta y^{L}_t + \beta_{\text{slope}}\,\Delta\left(y^{L}_t - y^{C}_t\right) + \varepsilon_t
$$

**The known answer**: with the DV01 hedge, a parallel shift neither gains nor loses, so
$\beta_{\text{level}} \approx 0$, and $\beta_{\text{slope}}$ is the DV01 of the long leg (one more basis point of
slope, with the short maturity still, is one more basis point on the leg sold). With the beta to the level alone,
without the slope in the regression, there is no reason for it to come out 0: in the data model, level and slope are
correlated.

```python
PATAS = {"2s10s": ("TPA", "TPC"), "nob": ("TPC", "TPD")}

def betas(nombre, ratio, desde, hasta):
    corto, largo = PATAS[nombre]
    h = R[nombre][ratio].shift(1)       # the previous session's
    pnl = h * usd[corto] - usd[largo]   # steepener
    nivel = Y[largo].diff() * 100       # bp
    pend = (Y[largo] - Y[corto]).diff() * 100
    m = pd.concat([pnl, nivel, pend], axis=1).loc[desde:hasta]
    m = m.dropna()
    m.columns = ["pnl", "nivel", "pend"]
    X = np.c_[np.ones(len(m)), m["nivel"], m["pend"]]
    b = np.linalg.lstsq(X, m["pnl"], rcond=None)[0]
    sola = np.polyfit(m["nivel"], m["pnl"], 1)[0]
    return {"beta_sola": sola, "beta_nivel": b[1],
            "beta_pend": b[2], "desv_dia": m["pnl"].std()}

filas = {(n, r, e): betas(n, r, *p)
         for n in PATAS
         for e, p in {"2010-15": ("2010", "2015"),
                      "2016-25": ("2016", "2025")}.items()
         for r in R[n].columns}
tabla = pd.DataFrame(filas).T
tabla.index.names = ["spread", "ratio", "periodo"]
tabla.round(1)
```
```output
                             beta_sola  beta_nivel  beta_pend  desv_dia
spread ratio        periodo
2s10s  nominal      2010-15       57.9        45.5       26.0     365.6
       dv01         2010-15       34.0        -0.2       71.7     269.4
       volatilidad  2010-15       11.9       -33.4       93.1     257.9
       min_varianza 2010-15       19.8       -22.7       88.0     254.9
       nominal      2016-25       50.1        44.8       26.3     373.6
       dv01         2016-25       15.1         1.1       69.5     300.7
       volatilidad  2016-25       11.8        -0.5       61.1     300.3
       min_varianza 2016-25       20.8         9.0       58.4     296.5
nob    nominal      2010-15       82.8        76.2       80.2     557.7
       dv01         2010-15       13.0         0.1      156.6     361.0
       volatilidad  2010-15       11.1        -2.0      160.2     370.7
       min_varianza 2010-15       23.0        10.9      148.9     372.2
       nominal      2016-25       86.7        79.7       83.1     676.7
       dv01         2016-25       14.2         1.0      158.2     412.7
       volatilidad  2016-25        9.3        -4.1      160.2     420.2
       min_varianza 2016-25       18.1         5.2      154.5     421.0
```

*Fig. — Rate-level beta of the steepener's daily P&L under each ratio, with the slope in the regression: hollow circle 2010-2015, filled 2016-2025.* Two dot panels, 2s10s and NOB, with four rows. The DV01 hedge, highlighted, stays at zero in both periods: −0.2 / 1.1 USD per bp in the 2s10s and 0.1 / 1.0 in the NOB. The notional hedge is far away: 45.5 / 44.8 and 76.2 / 79.7. The volatility hedge gives −33.4 / −0.5 in the 2s10s and minimum variance −22.7 / 9.0: they change sign between periods.

The table has four readings:

- **DV01 recovers the known answer.** The beta to the level is −0.2 and 1.1 USD per bp in the 2s10s and 0.1 and 1.0 in
  the NOB, in both periods; the beta to the slope, 71.7 and 69.5 in the 2s10s and 156.6 and 158.2 in the NOB, is the
  mean DV01 of the long leg (about 70 in `TPC` and about 160 in `TPD`, according to `tipos3_dv01.csv`). That is what
  you are after: a spread that only responds to the slope.
- **The notional leaves a duration bet.** With 0.54 2-year contracts, the steepener has a beta to the level of 45 USD
  per bp (80 in the NOB) and only 26 to the slope: it responds more to a parallel shift than to what it was meant to
  trade.
- **Volatility and minimum variance change sign.** In 2010-2015 the volatility hedge has a beta to the level of −33.4
  and in 2016-2025, of −0.5; the minimum variance one goes from −22.7 to +9.0. They reduce the daily risk (their
  standard deviation is the lowest: 257.9 and 254.9 USD in 2010-2015 versus 269.4 with DV01), but they do so by mixing
  in a level bet nobody asked for, and of variable sign.
- **The “alone” beta misleads.** Without the slope in the regression, even the DV01 steepener seems to have a beta to
  the level of 34 USD per bp in 2010-2015. It is not a failure of the hedge: in those years, near zero rates, the
  2-year maturity barely moves, and when the level rises the curve steepens at the same time. A regression that omits
  a correlated variable attributes its effect to the one that remains.

## The ratio moves

The figure at the top tracks the 2s10s ratios month by month from 2010 to 2025. The notional one barely changes
(between 0.52 and 0.63) and the DV01 one not much either (between 1.42 and 1.83): the duration of each contract varies
slowly, with the level of rates and with the bond that determines it at each expiration. The volatility one, on the
other hand, jumps:

```python
anual = R["2s10s"].resample("YE").last()
anual.index = anual.index.year
anual.loc[[2010, 2011, 2012, 2015, 2019, 2020, 2021, 2025]].T.round(2)
```
```output
fecha         2010  2011  2012  2015  2019   2020  2021  2025
nominal       0.54  0.58  0.55  0.56  0.58   0.58  0.59  0.54
dv01          1.50  1.63  1.55  1.59  1.66   1.64  1.67  1.52
volatilidad   3.21  4.99  3.88  1.70  1.62  13.17  2.27  1.41
min_varianza   NaN  2.07  3.56  1.60  1.29   2.29  2.24  1.18
```

The reason lies in the short leg. With the 2-year rate near zero, the central bank anchors it and it barely moves:

```python
vol_pb = Y.diff().mul(100).rolling(252).std().resample("YE").last()
vol_pb.index = vol_pb.index.year
vol_pb.loc[[2011, 2013, 2015, 2018, 2020, 2021, 2023, 2025],
           ["TPA", "TPC"]].T.round(1)
```
```output
fecha    2011  2013  2015  2018  2020  2021  2023  2025
mercado
TPA       3.8   2.9   5.7   6.6   3.6   2.8   9.7   7.1
TPC       6.3   5.9   6.7   6.6   7.2   5.4   9.2   6.6
```

In 2011 the 2-year yield moved 3.8 bp a day and the 10-year, 6.3; in 2021, 2.8 versus 5.4. At the same time, the
duration of the 2-year contract is a third of that of the 10-year: its price moves very little and its 63-session
volatility falls even more in the months of anchored rates. The volatility ratio compensates by asking for 3, 5 or 13
2-year contracts for each 10-year contract (3.21 at the end of 2010, 4.99 at the end of 2011, 13.17 at the end of
2020). If the short rate later starts moving again, as in 2023 (9.7 bp a day in the model), that excess of contracts
turns the spread into a long duration position several times larger than intended. The DV01 does not depend on how
much the price moved: it is a sensitivity, and that is why it does not jump. Minimum variance has the same problem,
somewhat dampened by its 252-session window (3.56 at the end of 2012).

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

The volatility ratio and the minimum variance one look backward: they assume the legs will move as in the estimation
window. In a regime change (rates leaving the floor, a leg that stops being liquid), the hedge that worked best is the
one that fails most. In rate spreads, the DV01 is the reference; volatility, a check.

</aside>

## Whole contracts and rebalancing

A ratio of 1.52 cannot be bought. You have to round, and the error depends on the size:

```python
def enteros(nombre, maximo=6):
    corto, largo = PATAS[nombre]
    d_c, d_l = D[corto].iloc[-1], D[largo].iloc[-1]
    filas = []
    for n_l in range(1, maximo + 1):
        n_c = round(n_l * d_l / d_c)
        resto = n_c * d_c - n_l * d_l
        filas.append({"largos": n_l, "cortos": n_c,
                      "dv01_resto": resto,
                      "resto_pct": 100 * resto / (n_l * d_l)})
    return pd.DataFrame(filas).set_index("largos")

enteros("2s10s").round(1)
```
```output
        cortos  dv01_resto  resto_pct
largos
1            2        20.6       32.0
2            3        -1.3       -1.0
3            5        19.3       10.0
4            6        -2.6       -1.0
5            8        18.0        5.6
6            9        -3.9       -1.0
```

*Fig. — DV01 left unhedged when the 31 December 2025 ratio (1.52 in the 2s10s, 2.09 in the NOB) is rounded to whole contracts, by number of long-tenor contracts.* Two lines with dots from 1 to 8 contracts. In the 2s10s, one 10-year and two 2-year contracts leave 32% too much DV01; two and three, highlighted, leave 1% short; then it alternates between 10% and 1% and falls. In the NOB, dashed, the error stays around 4% at any size up to 5 contracts and falls to 1.9% with 8.

With one 10-year contract and two 2-year contracts there is 32% too much DV01 in the short leg: 20.6 USD per bp net, a
third of a 10-year contract bet on rates falling. With two and three, the error is 1%. The reasonable minimum size is
not “one spread”: it is the number of contracts that leaves the remainder below what you tolerate. In the NOB, with
$h = 2.09$, rounding to 2 per contract leaves 4% from 1 to 5 long contracts; you need 6 or more for the decimal part
of the ratio to count. When smaller contracts exist (index or crude micros, a tenth of the original), they serve to
adjust the remainder.

The ratio also has to be maintained. Recomputing it every session costs commissions; leaving it fixed between rolls
lets the DV01 drift out of line:

```python
corto, largo = PATAS["2s10s"]
h_diario = R["2s10s"]["dv01"].shift(1)
roll = C[largo] != C[largo].shift(1)
h_roll = R["2s10s"]["dv01"].where(roll).ffill().shift(1)
desfase = (h_roll * D[corto] - D[largo]) / D[largo]
print(f"Unhedged DV01 with the ratio fixed between rolls: "
      f"median {100 * desfase.abs().median():.1f} %, "
      f"p95 {100 * desfase.abs().quantile(0.95):.1f} %")
for nombre, h in {"daily": h_diario, "at roll": h_roll}.items():
    pnl = h * usd[corto] - usd[largo]
    nivel = Y[largo].diff() * 100
    pend = (Y[largo] - Y[corto]).diff() * 100
    m = pd.concat([pnl, nivel, pend], axis=1).loc["2016":].dropna()
    X = np.c_[np.ones(len(m)), m.iloc[:, 1], m.iloc[:, 2]]
    b = np.linalg.lstsq(X, m.iloc[:, 0], rcond=None)[0]
    print(f"{nombre:>10}: beta to the level {b[1]:5.1f} USD/bp, "
          f"to the slope {b[2]:5.1f}")
```
```output
Unhedged DV01 with the ratio fixed between rolls: median 3.5 %, p95 8.8 %
     daily: beta to the level   1.1 USD/bp, to the slope  69.5
   at roll: beta to the level   3.6 USD/bp, to the slope  66.9
```

With the ratio set on the roll date and left untouched during the quarter, the unhedged DV01 is 3.5% at the median
and 8.8% on the worst 5% of days; the beta to the level since 2016 rises from 1.1 to 3.6 USD per bp. It is a small
mismatch compared with the rounding of an account with a single spread, and it leads to a practical rule: rebalance at
the roll and when the mismatch exceeds a threshold (for example, half a contract of the short leg), not every day.

## Indices and commodities: beta and units

Outside rates there is no DV01, but the question still stands. In a spread between indices such as NQ/ES (lesson
3.2.3), what you want to cancel is usually the market: the fictitious Nasdaq `RVB` loads 1.15 on the common factor and
the fictitious S&P `RVA`, 1. The `continuo` function rebuilds the contract held of each market with the roll rule of
`futuros3_contratos.csv`:

```python
ctr = pd.read_csv("futuros3_contratos.csv",
                  parse_dates=["fecha_roll"])

def continuo(cod):
    """Contract held (roll on `fecha_roll`), its price and its
    price change with the same contract."""
    fechas = pd.DatetimeIndex(
        cur.loc[cur["mercado"] == cod, "fecha"].unique()).sort_values()
    cm = ctr[ctr["mercado"] == cod].sort_values("fecha_roll")
    i = np.searchsorted(cm["fecha_roll"], fechas, side="right")
    tiene = pd.Series(cm["contrato"].to_numpy()[i], index=fechas)
    precio = px.reindex(list(zip(fechas, tiene))).to_numpy()
    return pd.DataFrame({"precio": precio,
                         "dP": cambio_mismo_contrato(tiene)},
                        index=fechas)

A, B = continuo("RVA"), continuo("RVB")
ra = A["dP"] / A["precio"].shift(1)
rb = B["dP"] / B["precio"].shift(1)
nom = (B["precio"] * mult["RVB"]) / (A["precio"] * mult["RVA"])
h_ind = pd.DataFrame({
    "nominal": nom,
    "beta": nom * rb.rolling(252).cov(ra) / ra.rolling(252).var(),
    "volatilidad": nom * rb.rolling(63).std() / ra.rolling(63).std(),
})
nominal_b = B["precio"].shift(1) * mult["RVB"]
filas = {}
for k in h_ind:
    cob = h_ind[k].shift(1) * A["dP"] * mult["RVA"]
    pnl = (B["dP"] * mult["RVB"] - cob) / nominal_b
    m = pd.concat([pnl, ra], axis=1).loc["2011":].dropna()
    filas[k] = {
        "ratio_medio": h_ind[k].loc["2011":].mean(),
        "beta_resto": np.polyfit(m.iloc[:, 1], m.iloc[:, 0], 1)[0],
        "vol_anual_pct": 100 * m.iloc[:, 0].std() * np.sqrt(252)}
vol_b = 100 * rb.loc["2011":].std() * np.sqrt(252)
print(f"Annual vol. of RVB alone: {vol_b:.1f} %")
pd.DataFrame(filas).T.round(3)
```
```output
Annual vol. of RVB alone: 24.0 %
             ratio_medio  beta_resto  vol_anual_pct
nominal            0.903       0.151         10.486
beta               1.035       0.007         10.123
volatilidad        1.198      -0.122         10.730
```

The three hedges bring the volatility down from 24% for `RVB` alone to slightly more than 10%, and the three are
similar in total risk. The difference lies in the exposure to the market. With the same notional, the spread stays
long 15% of the benchmark index (beta 0.151): it is the known answer, the beta of 1.15 minus one. The beta hedge leaves
it at 0.007. The volatility one overshoots, −0.122: it matches $\sigma_B/\sigma_A$, which is the beta divided by the
correlation ($\beta = \rho\,\sigma_B/\sigma_A$, with $\rho \approx 0.91$ in the data), so it sells more index than
needed. It is the same error as the 2s10s on a small scale: volatility matches risks, not exposures.

In commodities a fifth criterion appears: **units**. The gasoline crack spread measures a refiner's margin, which is
counted per barrel:

```python
G, P = continuo("PEG"), continuo("PEC")
barriles = mult["PEG"] / 42 / mult["PEC"]     # 42 gallons per barrel
valor = (G["precio"] * mult["PEG"]) / (P["precio"] * mult["PEC"])
rg = G["dP"] / G["precio"].shift(1)
rc = P["dP"] / P["precio"].shift(1)
vol = valor * rg.rolling(63).std() / rc.rolling(63).std()
cr = pd.DataFrame({"valor": valor, "volatilidad": vol}).loc["2011":]
print(f"Barrels: {barriles:.2f} PEC per PEG, always")
cr.describe().loc[["mean", "min", "50%", "max"]].round(2)
```
```output
Barrels: 1.00 PEC per PEG, always
      valor  volatilidad
mean   1.26         1.43
min    0.91         0.96
50%    1.25         1.42
max    1.63         2.02
```

A gasoline contract in the data (42,000 gallons) is 1,000 barrels, the same as a crude one: the crack in barrels is
1:1 and never changes. Matching the value asks for between 0.91 and 1.63 crude contracts for each gasoline contract
(1.26 on average), because gasoline is usually worth more per barrel than crude; matching the risk, even more (1.43 on
average). They are different spreads: the barrels one is the refining margin, what a refinery hedges and what lesson
3.2.2 studies; the value one, the relative return of the two prices (the spread in logarithms used in lesson 3.2.6);
the volatility one, an equal-risk bet with no physical meaning.

## Common mistakes

- **Ratio of prices instead of ratio of values.** Dividing 112.55 by 104.27 gives nothing useful: you have to multiply
  each price by its multiplier (2,000 in `TPA`, 1,000 in `TPC`). With different currencies, convert to the same
  currency first (lesson 3.3.3).
- **Today's ratio in today's P&L.** The ratio is computed at the close and applied from the next session. Using that
  of the same session is look-ahead (lesson 2.5.1).
- **Regression on price levels.** A regression of the price of one leg on that of the other gives a meaningless slope
  if the prices are not cointegrated (lesson 2.1.5). The minimum variance one is done on changes in value, in money.
- **The rounding remainder.** A 1 against 2 spread with a ratio of 1.52 carries a third of its risk in rates without
  knowing it. Measure the net DV01 of the portfolio, not that of an ideal spread.
- **Trusting the recent volatility of an anchored leg.** Rates near zero, a currency with a band, an illiquid contract:
  low volatility is not absence of risk, it is deferred risk.

## Exercises

1. Set up the 5s10s spread (`TPB` versus `TPC`, the one CBOT lists as FYT) and repeat the 2016-2025 beta table with
   the four ratios. Which one leaves the beta to the level at zero? Why does the volatility one resemble the DV01 one
   more here than in the 2s10s?

**Solution.**

```python
PATAS["5s10s"] = ("TPB", "TPC")
R["5s10s"] = ratios("TPB", "TPC")
print(R["5s10s"].iloc[-1].round(2).to_string())
pd.DataFrame({r: betas("5s10s", r, "2016", "2025")
              for r in R["5s10s"]}).T.round(1)
```
```output
nominal         1.03
dv01            1.49
volatilidad     1.50
min_varianza    1.43
              beta_sola  beta_nivel  beta_pend  desv_dia
nominal            24.0        21.6       49.2     191.5
dv01                3.8         0.4       68.8     120.4
volatilidad         1.9        -1.3       62.8     115.8
min_varianza        3.4         0.2       64.8     118.5
```

With DV01 (1.49 `TPB` contracts for each `TPC`), the beta to the level is 0.4 USD per bp and the beta to the slope,
68.8, the DV01 of `TPC`: the known answer again. Here the volatility ratio (1.50) almost coincides with the DV01 one
and leaves a beta to the level of −1.3, and the minimum variance one, 0.2. The reason is that the 5-year is not
anchored like the 2-year: with rates near zero it keeps moving almost as much as the 10-year in basis points, and then
matching risk and matching sensitivity are almost the same thing. The notional (1.03, because the two contracts have
the same multiplier) again leaves a duration bet: 21.6 USD per bp.

2. `TPE` is a future on a 10-year bond in evros (QEV), the level's other currency. Compute its daily P&L in USD with
   the exchange rate from `fx3_diario.parquet` and hedge it with `TPC` by DV01 (with the DV01 of `TPE` converted to
   USD). How much variance does the hedge remove since 2016? Correct the ratio with the beta of the changes in the
   yield of `TPE` on those of `TPC`, estimated in 2010-2015, and compare.

**Solution.**

```python
fx = pd.read_parquet("fx3_diario.parquet")
x = fx[fx["divisa"] == "QEV"].set_index("fecha")["usd_por_unidad"]
x = x.reindex(D.index).ffill()
usd_e = cambio_mismo_contrato(C["TPE"]) * mult["TPE"] * x.shift(1)
dy = Y[["TPE", "TPC"]].diff().loc[:"2015"]
beta_y = dy.cov().loc["TPE", "TPC"] / dy["TPC"].var()
h_dv = (D["TPE"] * x / D["TPC"]).shift(1)
for nombre, h in {"DV01": h_dv, "DV01 x beta": h_dv * beta_y}.items():
    cub = usd_e - h * usd["TPC"]
    m = pd.concat([usd_e, cub], axis=1).loc["2016":].dropna()
    quita = 1 - m.iloc[:, 1].var() / m.iloc[:, 0].var()
    print(f"{nombre:>12}: final ratio {h.iloc[-1]:.2f}, "
          f"variance removed {100 * quita:.0f} %")
print(f"Beta of the yields (2010-2015): {beta_y:.2f}")
```
```output
        DV01: final ratio 1.97, variance removed 16 %
 DV01 x beta: final ratio 1.06, variance removed 49 %
Beta of the yields (2010-2015): 0.54
```

Converted to USD, the DV01 of `TPE` asks for 1.97 `TPC` contracts for each one, and that hedge removes only 16% of the
variance since 2016. The reason: the DV01 assumes that the two yields move by the same amount in basis points, and the
Vesperian bond moves, on average, 0.54 bp for each bp of the U.S. one (estimated in 2010-2015, before the period in
which it is tested). With the DV01 multiplied by that beta (1.06 contracts), the hedge removes 49%, almost the maximum
allowed by a correlation of 0.70 between the changes of the two yields ($\rho^2 \approx 0.49$). Between different
curves, the DV01 is necessary but not sufficient: the beta of the rates is missing, which is estimated and changes
(and the currency risk, which lesson 3.3.3 treats separately).

3. An account wants a market-neutral NQ/ES spread with about 300,000 USD of notional in the `RVB` leg. With the data
   of 2025-12-31, how many contracts of each leg does it need? And if it uses the micros (`RVAM`, `RVBM`, a tenth of
   the multiplier)? What beta remainder is left in each case?

**Solution.**

```python
pB, pA = B["precio"].iloc[-1], A["precio"].iloc[-1]
beta_fin = h_ind["beta"].iloc[-1] / nom.iloc[-1]
print(f"Notional of one contract: RVB {pB * mult['RVB']:,.0f} USD, "
      f"RVA {pA * mult['RVA']:,.0f} USD; beta {beta_fin:.2f}")
for etiqueta, k in {"standard": 1, "micro": 10}.items():
    nom_b, nom_a = pB * mult["RVB"] / k, pA * mult["RVA"] / k
    n_b = max(1, round(300_000 / nom_b))
    objetivo = n_b * nom_b * beta_fin    # RVA notional it hedges
    n_a = round(objetivo / nom_a)
    resto = (objetivo - n_a * nom_a) / objetivo
    print(f"{etiqueta:>8}: {n_b} RVB against {n_a} RVA, "
          f"unhedged beta {100 * resto:+.1f} %")
```
```output
Notional of one contract: RVB 437,815 USD, RVA 337,350 USD; beta 1.17
standard: 1 RVB against 2 RVA, unhedged beta -31.7 %
   micro: 7 RVB against 11 RVA, unhedged beta -3.5 %
```

An `RVB` contract has almost 438,000 USD of notional: a 300,000 spread cannot be built with standard contracts. With 1
`RVB` contract and 2 `RVA` contracts (the beta of 1.17 asks for 1.52), the account sells 31.7% more market than it
hedges: a short of about 160,000 USD of index. With the micros (a tenth), 7 `RVBM` contracts (306,000 USD) against 11
`RVAM` leave −3.5%. The micros do not change the ratio, they change the resolution: the same thing that happened with
the number of contracts in the 2s10s.

## Self-assessment

1. A 2s10s steepener is set up with 0.54 2-year contracts for each 10-year contract, the notional ratio. What exposure does it have left?

   - A. None: the notional is matched
   - B. A net position in rates: the 10-year contract moves much more per basis point, so the spread gains or loses with a parallel shift almost as much as with the slope
   - C. Only currency risk
   - D. Only roll risk

   *Answer B: A net position in rates: the 10-year contract moves much more per basis point, so the spread gains or loses with a parallel shift almost as much as with the slope.* Matching the notional does not match the sensitivity to rates. In the data, the notional hedge leaves a beta to the level of about 45 USD per basis point and per contract in both periods, more than half the DV01 of the long leg (about 70): half a duration bet.

2. At the end of 2020 the 2s10s volatility ratio is 13.2 and the DV01 ratio, 1.64. What has happened?

   - A. That the DV01 is miscalculated
   - B. That the 2-year rate, near zero, barely moves: its volatility collapses and the volatility ratio asks for many more contracts, which leave a long in rates as soon as the 2-year starts moving again
   - C. That the 10-year has more DV01 than ever
   - D. That the spread is in contango

   *Answer B: That the 2-year rate, near zero, barely moves: its volatility collapses and the volatility ratio asks for many more contracts, which leave a long in rates as soon as the 2-year starts moving again.* The volatility ratio matches the money at risk of the two legs with how much they moved over the last 63 days. When one leg is anchored (rates near zero), its volatility tends to zero and the ratio explodes. In 2010-2015, with that hedge, the steepener had a beta to the level of −33 USD per bp: a long duration position shaped like a spread.

3. The 2s10s DV01 ratio is 1.52 and a small account trades one 10-year contract against two 2-year contracts. What does it have?

   - A. 32% too much DV01 in the short leg: with two 10-year contracts and three 2-year contracts the error falls to 1%
   - B. An almost perfect spread
   - C. A volatility-neutral spread
   - D. 50% too little DV01

   *Answer A: 32% too much DV01 in the short leg: with two 10-year contracts and three 2-year contracts the error falls to 1%.* 2 × 42.55 = 85.1 USD per bp versus 64.47 for the long leg: 20.6 USD per bp too many, 32%. Rounding a non-integer ratio can only be fixed with more contracts or with smaller contracts, if they exist.

4. In NQ/ES, a notional-matched spread leaves a beta of 0.15 to the benchmark index. Which ratio cancels it?

   - A. The notional one
   - B. The volatility one: 1.20 RVA contracts for each RVB contract on average
   - C. The beta one: the notional multiplied by the beta of the fictitious Nasdaq on the fictitious S&P, 1.15 in the data
   - D. None

   *Answer C: The beta one: the notional multiplied by the beta of the fictitious Nasdaq on the fictitious S&P, 1.15 in the data.* The tech index of the data loads 1.15 on the market factor: with the same notional, the spread stays 15% long the market. The volatility one overshoots (beta −0.12), because the correlation is not 1: σ_B / σ_A = β / ρ.

5. A refiner wants to hedge its gasoline margin. Which ratio does it use?

   - A. The volatility one
   - B. The notional value one
   - C. The minimum variance one, always
   - D. The units one: one gasoline contract (42,000 gallons, 1,000 barrels) for each crude contract (1,000 barrels), because its margin is measured per barrel

   *Answer D: The units one: one gasoline contract (42,000 gallons, 1,000 barrels) for each crude contract (1,000 barrels), because its margin is measured per barrel.* The refining margin is the price of the products minus that of crude, per barrel. Hedging it requires the same number of barrels in each leg. Matching the value (1.26 crude contracts for each gasoline contract on average in the data) or the risk is a different bet: that of the relative return of the two prices.

## Key points

- The hedge ratio is the number of contracts of one leg per contract of the other, and it decides what risk remains: there is no “correct” ratio without saying what you want to neutralize.
- By notional you match the money; by DV01, the sensitivity to one basis point; by volatility, the recent money at risk; by minimum variance, the estimated total risk. In the 2s10s of the data, at the end of 2025, they give 0.54, 1.52, 1.41 and 1.18.
- Only the DV01 hedge leaves the beta to the level of rates at zero in both periods (−0.2 and 1.1 USD per bp) and the beta to the slope equal to the DV01 of the long leg: the known answer.
- The volatility ratio and the minimum variance one depend on what moved recently: with the 2-year rate anchored near zero they reach 3, 5 and even 13 contracts, and turn the spread into a duration bet.
- Rounding matters: 1 against 2 leaves 32% of the DV01 unhedged; 2 against 3, 1%. Rebalancing only at the roll leaves a median mismatch of 3.5%.
- In indices, the ratio that neutralizes the market is the beta one (NQ/ES: 1.15 times the notional); in commodities, the units one when what is traded is a physical margin.

## Resources

### Research

- [Impact of Seasonal Factor on US Bonds](https://pythonparatrading.com/en/seasonality-usa-yields/) · Research-0017 · 2 min

### Documentation

- [CME Group Client Systems Wiki: Implied Orders (CBOT intercommodity spreads with legs in a ratio greater than one: NOB, FYT, TUB)](https://cmegroupclientsite.atlassian.net/wiki/spaces/EPICSANDBOX/pages/457095346/Implied+Orders) · cmegroupclientsite.atlassian.net
- [NumPy: numpy.linalg.lstsq (least squares)](https://numpy.org/doc/stable/reference/generated/numpy.linalg.lstsq.html) · numpy.org

### Books

- [Robert Carver, “Systematic Trading” (Harriman House): volatility scaling](https://www.harriman-house.com/systematic-trading) · harriman-house.com

### Data

- [Level 3 data: DV01 of the rate futures (CSV)](https://pythonparatrading.com/curso/datos/nivel-3/tipos3_dv01.csv) · Download · tipos3_dv01.csv
- [Level 3 data: futures specifications (CSV)](https://pythonparatrading.com/curso/datos/nivel-3/futuros3_especificaciones.csv) · Download · futuros3_especificaciones.csv

Previous 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/)

Next lesson: [3.2.5 · Spread data and backtesting: listed, synthetic and legged](https://pythonparatrading.com/en/python-trading-course/level-3/spread-data-and-legged-backtesting/)
