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.
- Module
- 3.2 · Futures spreads
- Duration
- 60 min
- Prerequisites
- 1.2.3 · Futures: specifications, margin, roll and contango, 1.2.7 · Fixed income and interest rates; CFDs and leveraged products, 2.1.6 · Kalman filter for dynamic hedge ratios, 3.2.1 · Calendar spreads, butterflies and condors
- Author
- Jesús Cuesta
- Updated
Level 3 lessons
3.1 · Futures in depth
3.2 · Futures spreads
- 3.2.1 Calendar spreads, butterflies and condors
- 3.2.2 Intercommodity spreads: crack, spark and crush
- 3.2.3 Rate and index spreads: 2s10s, NOB, NQ/ES and Europe versus the U.S.
- 3.2.4 Hedge ratios: by notional, by DV01 and by volatility
- 3.2.5 Spread data and backtesting: listed, synthetic and legged
- 3.2.6 Seasonality of spreads: multi-year analysis, patterns and traps
3.3 · Advanced forex
3.4 · Option theory and pricing
- 3.4.1 Payoffs, put-call parity and arbitrage bounds
- 3.4.2 Binomial trees, Black-Scholes-Merton, Black-76 and Monte Carlo
- 3.4.3 Greeks: delta, gamma, vega, theta, rho, vanna, volga and charm
- 3.4.4 Implied volatility: Newton-Raphson, Brent, py_vollib and QuantLib
- 3.4.5 Smile, skew and term structure; volatility surface with SVI
- 3.4.6 Realized vs implied volatility; volatility risk premium; VIX and VSTOXX
- 3.4.7 Advanced models: Heston, SABR and local volatility
3.5 · Option structures in Python
- 3.5.1 Directional and vertical structures: call, put, covered call, collar and spreads
- 3.5.2 Volatility and range structures: straddles, strangles, condors and butterflies
- 3.5.3 Calendars, diagonals, ratios, backspreads, risk reversals and jade lizard
- 3.5.4 A custom tool: payoff, aggregate Greeks and scenarios
- 3.5.5 Management: adjustments, rolls, early closes, early assignment and pin risk
3.6 · Systematic volatility and options strategies
3.7 · Crypto derivatives and specific strategies
- 3.7.1 Perpetuals: funding mechanics, mark price and liquidations
- 3.7.2 Funding arbitrage, basis with futures and cross-exchange arbitrage
- 3.7.3 Crypto options, on-chain data and DeFi
- 3.7.4 ccxt for trading on multiple exchanges
- 3.7.5 MiCA: the EU framework for crypto-assets and their service providers (CASPs)
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 (contracts of one leg for each contract of the other), decides which risk is canceled and which remains. With the price, the contract multiplier (what one price point is worth), the change in money of one contract for one basis point of rates and the volatility of the return, the four ratios of this lesson, to hedge one contract of leg with contracts of leg , are:
with 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 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:
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) 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.00The 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:
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) 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.0The 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:
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) 2s10s nob
nominal 0.54 1.08
dv01 1.52 2.09
volatilidad 1.41 2.15
min_varianza 1.18 2.03In 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 contracts of the short maturity and sells one of the long. Its daily P&L per long contract is , with 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:
The known answer: with the DV01 hedge, a parallel shift neither gains nor loses, so , and 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.
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) 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.0The 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
TPCand about 160 inTPD, according totipos3_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:
anual = R["2s10s"].resample("YE").last()
anual.index = anual.index.year
anual.loc[[2010, 2011, 2012, 2015, 2019, 2020, 2021, 2025]].T.round(2)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.18The reason lies in the short leg. With the 2-year rate near zero, the central bank anchors it and it barely moves:
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)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.6In 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:
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) 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.0With 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 , 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:
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}")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.9With 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:
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)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.730The 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 , which is the beta divided by the
correlation (, with 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:
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)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.02A 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 inTPC). 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
- Set up the 5s10s spread (
TPBversusTPC, 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
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)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.5With 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.
TPEis 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 fromfx3_diario.parquetand hedge it withTPCby DV01 (with the DV01 ofTPEconverted to USD). How much variance does the hedge remove since 2016? Correct the ratio with the beta of the changes in the yield ofTPEon those ofTPC, estimated in 2010-2015, and compare.
Solution
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}") 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.54Converted 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 (). 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).
- An account wants a market-neutral NQ/ES spread with about 300,000 USD of notional in the
RVBleg. 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
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} %")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
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.
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.
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.
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 = β / ρ.
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.