Lesson 3.2.4 · 60 min

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.

Fig. — The data's 2s10s steepener: 2-year note contracts per 10-year note contract under three hedge ratios, at each month-end from 2010 to 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)

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 hh (contracts of one leg for each contract of the other), decides which risk is canceled and which remains. With FF the price, mm the contract multiplier (what one price point is worth), DV01\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 LL with contracts of leg CC, are:

hnotional=FL mLFC mC,hDV01=DV01LDV01C,hσ=σL FL mLσC FC mC,hMV=Cov⁡(ΔVL,ΔVC)Var⁡(ΔVC)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 ΔV\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).

RatioWhat it matchesWhat it needsWhen it makes sense
NotionalMoney in each legPrice and multiplierRelative return of two similar assets
DV01Sensitivity to 1 bpDV01 per contractRate spreads: isolating the slope
VolatilityRecent money at riskEstimated σLegs with no structural relationship
Minimum variancePast total riskP&L regressionReducing risk, without saying which
UnitsPhysical quantityContract specificationPhysical 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:

ratesmarketphysical margintotal riskWhat do you cancel?DV01BetaUnitsVolatility or MV
Mermaid source
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:

IN [01]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)
OUT [01]DV01 per contract of the rate futures
        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:

IN [02]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)
OUT [02]Price change with the same contract
              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:

IN [03]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)
OUT [03]Four ratios as of December 31, 2025
              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.

What each hedge leaves

A steepener gains if the curve steepens: it buys hh contracts of the short maturity and sells one of the long. Its daily P&L per long contract is h ΔVC−ΔVLh\,\Delta V_C - \Delta V_L, with hh 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:

P&Lt=a+βlevel ΔytL+βslope Δ(ytL−ytC)+εt\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 βlevel≈0\beta_{\text{level}} \approx 0, and βslope\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.

IN [04]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)
OUT [04]What each hedge leaves: level and slope
                             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

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:

IN [05]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)
OUT [05]The ratio year by year
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:

IN [06]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)
OUT [06]The short leg of the 2s10s near zero rates
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).

Whole contracts and rebalancing

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

IN [07]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)
OUT [07]Whole contracts
        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

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.09h = 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:

IN [08]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}")
OUT [08]Rebalance every day or only at the roll
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:

IN [09]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)
OUT [09]NQ/ES: notional, beta and volatility
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 σB/σA\sigma_B/\sigma_A, which is the beta divided by the correlation (β=ρ σB/σA\beta = \rho\,\sigma_B/\sigma_A, with ρ≈0.91\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:

IN [10]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)
OUT [10]Gasoline crack: barrels, value and volatility
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
IN [11]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)
OUT [11]The 5s10s with the four ratios
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.

  1. 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
IN [12]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}")
OUT [12]Hedging between two curves
        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 (ρ2≈0.49\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).

  1. 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
IN [13]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} %")
OUT [13]NQ/ES with whole contracts and micros
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.

Each answer is folded under its question

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?

    • ANone: the notional is matched
    • BA 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
    • COnly currency risk
    • DOnly roll risk
    Show answer

    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?

    • AThat the DV01 is miscalculated
    • BThat 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
    • CThat the 10-year has more DV01 than ever
    • DThat the spread is in contango
    Show answer

    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?

    • A32% too much DV01 in the short leg: with two 10-year contracts and three 2-year contracts the error falls to 1%
    • BAn almost perfect spread
    • CA volatility-neutral spread
    • D50% too little DV01
    Show answer

    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?

    • AThe notional one
    • BThe volatility one: 1.20 RVA contracts for each RVB contract on average
    • CThe beta one: the notional multiplied by the beta of the fictitious Nasdaq on the fictitious S&P, 1.15 in the data
    • DNone
    Show answer

    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?

    • AThe volatility one
    • BThe notional value one
    • CThe minimum variance one, always
    • DThe 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
    Show answer

    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

Data