Lesson 3.5.3 · 75 min
Calendars, diagonals, ratios, backspreads, risk reversals and jade lizard
With two expirations, the P&L of a structure is no longer a kinked line: it depends on the volatility left in the long option. With different quantities in each leg, one side with unlimited risk appears. And with the skew, the same shape opens for a debit with calls and for a credit with puts. The lesson builds these structures with the chain of the ORX index and follows each one for 142 months.
- Module
- 3.5 · Option structures in Python
- Duration
- 75 min
- Prerequisites
- 3.4.3 · Greeks: delta, gamma, vega, theta, rho, vanna, volga and charm, 3.4.5 · Smile, skew and term structure; volatility surface with SVI, 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
- 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)
Two expirations, different quantities and the skew
Lessons 3.5.1 and 3.5.2 combined options of the same expiration with quantities of one at a time. This lesson breaks both rules and uses a third thing that until now only appeared in passing: the skew.
- Time structures. A calendar sells a short-dated option and buys the one with the same strike and a longer expiration. A diagonal does the same with different strikes. A double calendar combines a put calendar below the index and a call calendar above it. They gain from the passage of time on the short leg, and their result depends on the volatility left in the long one.
- Ratios and backspreads. A 1×2 ratio buys one option and sells two farther away; a backspread does the opposite. With more options on one side than on the other, one of the ends has no limit.
- Skew structures. A risk reversal sells an out-of-the-money put and buys an out-of-the-money call (or the other way around). A jade lizard sells a put and a call spread, with enough credit to have no upside risk.
The data is the same as in the previous lessons: the synthetic chain of the ORX index on December 31, 2025, now with two expirations, January 16 (16 days) and February 20, 2026 (51 days); prices per unit (× 100 USD per contract), T in calendar days / 365, continuous r and q for each expiration, theta per calendar day and vega per volatility point. The first cell repeats the functions from 3.5.1. The data is synthetic, with a planted surface, skew and volatility premium: it teaches the mechanics, not what a structure yields in the real market.
import numpy as np
import pandas as pd
from scipy.stats import norm
cadena = pd.read_parquet("opciones3_cadena.parquet")
indice = pd.read_csv("opciones3_indice.csv", parse_dates=["fecha"],
index_col="fecha")
verdad = pd.read_parquet("opciones3_verdad.parquet")
HOY = pd.Timestamp("2025-12-31")
ENE = pd.Timestamp("2026-01-16")
VTO = FEB = pd.Timestamp("2026-02-20")
S0 = indice.loc[HOY, "cierre"]
def mercado(vto, fecha=HOY):
"""Continuous rate r and dividend q (decimal fraction) of an
expiration: r from the curve; q implied by the forward."""
v = verdad[(verdad.fecha == fecha)
& (verdad.vencimiento == vto)].iloc[0]
T = v.dias / 365 # calendar days / 365
S = indice.loc[fecha, "cierre"]
return v.tipo_libre, v.tipo_libre - np.log(v.forward / S) / T
def bsm(S, K, T, sig, tipo, r, q):
"""Price (points), delta, gamma (per point), vega (per
volatility point) and theta (per calendar day).
S can be an array; with T = 0 it returns the payoff."""
S = np.asarray(S, dtype=float)
if T <= 0:
pago = np.maximum(S - K, 0) if tipo == "C" else \
np.maximum(K - S, 0)
signo = 1.0 if tipo == "C" else -1.0
delta = np.where(signo * (S - K) > 0, signo, 0.0)
cero = np.zeros_like(S)
return dict(precio=pago, delta=delta, gamma=cero,
vega=cero, theta=cero)
raiz = sig * np.sqrt(T)
d1 = (np.log(S / K) + (r - q + sig**2 / 2) * T) / raiz
d2 = d1 - raiz
dq, dr = np.exp(-q * T), np.exp(-r * T)
gamma = dq * norm.pdf(d1) / (S * raiz)
vega = S * dq * norm.pdf(d1) * np.sqrt(T)
decay = -S * dq * norm.pdf(d1) * sig / (2 * np.sqrt(T))
if tipo == "C":
precio = S * dq * norm.cdf(d1) - K * dr * norm.cdf(d2)
delta = dq * norm.cdf(d1)
theta = (decay - r * K * dr * norm.cdf(d2)
+ q * S * dq * norm.cdf(d1))
else:
precio = K * dr * norm.cdf(-d2) - S * dq * norm.cdf(-d1)
delta = -dq * norm.cdf(-d1)
theta = (decay + r * K * dr * norm.cdf(-d2)
- q * S * dq * norm.cdf(-d1))
return dict(precio=precio, delta=delta, gamma=gamma,
vega=vega / 100, theta=theta / 365)
def pata(tipo, K, n, vto=VTO, fecha=HOY):
"""A leg: n > 0 long, n < 0 short. Type "S" is the index
itself (a portfolio that replicates it)."""
if tipo == "S":
return dict(tipo="S", K=0.0, n=n, bid=S0, ask=S0,
iv=0.0, vto=vto, r=0.0, q=0.0)
o = cadena[(cadena.fecha == fecha)
& (cadena.vencimiento == vto)
& (cadena.tipo == tipo)
& (cadena.strike == K)].iloc[0]
rr, qq = mercado(vto, fecha)
return dict(tipo=tipo, K=K, n=n, bid=o.bid, ask=o.ask,
iv=o.iv_modelo, vto=vto, r=rr, q=qq)
def apertura(patas):
"""Cost in points at mid and crossing the bid-ask spread:
long legs at the ask, short legs at the bid (> 0 = debit)."""
medio = sum(p["n"] * (p["bid"] + p["ask"]) / 2 for p in patas)
real = sum(p["n"] * (p["ask"] if p["n"] > 0 else p["bid"])
for p in patas)
return medio, real
def valor(patas, S, fecha, griega="precio"):
"""Value (or Greek) of the structure if the index is at S
on `fecha`, with each leg's IV frozen."""
S = np.asarray(S, dtype=float)
total = np.zeros_like(S)
for p in patas:
if p["tipo"] == "S":
x = {"precio": S, "delta": np.ones_like(S)}
total += p["n"] * x.get(griega, np.zeros_like(S))
continue
T = (p["vto"] - fecha).days / 365
g = bsm(S, p["K"], T, p["iv"], p["tipo"], p["r"], p["q"])
total += p["n"] * g[griega]
return total
REJILLA = np.arange(1.0, 3 * S0, 1.0) # from 1 point to 3 times S0
def resumen(patas):
"""Cost, maximum, minimum and breakeven points at expiration
(the structure's first one), in points per unit."""
medio, real = apertura(patas)
fin = min(p["vto"] for p in patas)
pnl = valor(patas, REJILLA, fin) - real
i = np.nonzero(np.diff(np.sign(pnl)))[0]
muertos = REJILLA[i] - pnl[i] / (pnl[i + 1] - pnl[i])
muertos = np.unique(np.round(muertos, 1))
final = pnl[-1] - pnl[-2] # slope toward large S
tol = 0.01 # with two expirations, the dividend leaves a
return {"medio": round(float(medio), 2), # tiny slope
"real": round(float(real), 2),
"horquilla": round(float(real - medio), 2),
"maximo": "unlimited" if final > tol
else round(float(pnl.max()), 2),
"minimo": "unlimited" if final < -tol
else round(float(pnl.min()), 2),
"muertos": [round(float(x), 1) for x in muertos]}
def griegas(patas, S=S0, fecha=HOY):
"""Aggregate Greeks per unit (× 100 USD per contract)."""
g = {k: float(valor(patas, S, fecha, k))
for k in ["delta", "gamma", "vega", "theta"]}
return {"delta": round(g["delta"], 3),
"gamma": round(g["gamma"], 5),
"vega": round(g["vega"], 2),
"theta": round(g["theta"], 2)}
def con_delta(fecha=HOY, vto=VTO):
"""The chain of one expiration with the BSM delta of each row."""
d = cadena[(cadena.fecha == fecha)
& (cadena.vencimiento == vto)].copy()
rr, qq = mercado(vto, fecha)
S, T = indice.loc[fecha, "cierre"], (vto - fecha).days / 365
for t in ("C", "P"):
m = d.tipo == t
d.loc[m, "delta"] = bsm(S, d.strike[m].to_numpy(), T,
d.iv_modelo[m].to_numpy(), t,
rr, qq)["delta"]
return d
def strike_delta(d, tipo, objetivo):
x = d[d.tipo == tipo]
return float(x.strike.iloc[(x.delta - objetivo).abs().argmin()])Three expirations, three volatilities
The daily chain lists the three nearest monthly expirations. What changes between them is what drives the time structures:
def iv_delta(d, tipo, objetivo):
"""IV of the strike with the delta closest to the target."""
k = strike_delta(d, tipo, objetivo)
return d.iv_modelo[(d.tipo == tipo) & (d.strike == k)].iloc[0]
filas = []
for vto in sorted(cadena.vencimiento[cadena.fecha == HOY].unique()):
d = con_delta(HOY, vto)
c = d[(d.tipo == "C") & (d.strike == 6100)].iloc[0]
r_, q_ = mercado(vto)
filas.append({"vencimiento": vto.date(),
"dias": (vto - HOY).days, "r": r_, "q": q_,
"call_6100": (c.bid + c.ask) / 2,
"iv_6100": c.iv_modelo,
"iv_c25": iv_delta(d, "C", 0.25),
"iv_p25": iv_delta(d, "P", -0.25)})
pd.DataFrame(filas).set_index("vencimiento").round(4) dias r q call_6100 iv_6100 iv_c25 iv_p25
vencimiento
2026-01-16 16 0.0369 0.0117 46.2 0.0946 0.0740 0.1195
2026-02-20 51 0.0368 0.0117 119.3 0.1258 0.1007 0.1539
2026-03-20 79 0.0366 0.0117 167.6 0.1390 0.1093 0.1728The implied volatility of the 6,100 call rises with maturity: 0.0946 at 16 days, 0.1258 at 51 and 0.1390 at 79. It is the normal term structure of a calm market, in contango (lesson 3.4.5); in the data it inverts in crises. The skew also grows in points with maturity: the difference between the 25D call and put (delta of 0.25 in absolute value) is −4.55 points in January, −5.32 in February and −6.35 in March.
A calendar that sells January and buys February does not buy “February volatility”, but that of the stretch the February option has ahead of it when the January one expires. Since total variances add up over time, that forward volatility can be solved from the two implied volatilities:
t1, t2 = 16 / 365, 51 / 365
s1, s2 = 0.0946, 0.1258 # IV of the 6100 call
var_plazo = (s2**2 * t2 - s1**2 * t1) / (t2 - t1)
print(f"forward volatility January 16-February 20: "
f"{np.sqrt(var_plazo):.4f}")forward volatility January 16-February 20: 0.137713.77%, above either of the two implied volatilities: the market prices in that the January-to-February stretch will be more volatile than the next 16 days. Whether the forward volatility is expensive or cheap compared with what is expected is the question a calendar asks. Lesson 3.6.2 covers term structure trades systematically.
The call calendar
calendario = [pata("C", 6100, -1, ENE), pata("C", 6100, 1, FEB)]
print(resumen(calendario))
print(griegas(calendario))
for p in calendario:
print(p["vto"].date(), p["n"],
{k: round(float(valor([p], S0, HOY, k)), 3)
for k in ["vega", "theta", "gamma"]}){'medio': 73.1, 'real': 73.9, 'horquilla': 0.8, 'maximo': 28.15, 'minimo': -73.9, 'muertos': [6041.7, 6168.3]}
{'delta': 0.032, 'gamma': -0.00192, 'vega': 3.97, 'theta': 0.38}
2026-01-16 -1 {'vega': -5.082, 'theta': 1.704, 'gamma': -0.003}
2026-02-20 1 {'vega': 9.049, 'theta': -1.323, 'gamma': 0.001}The 6,100 calendar costs 73.9 points crossing the bid-ask spread (73.1 at mid): the January call is sold at the bid and the February one bought at the ask. Its Greeks are the sum of two very different options. The January one, at 16 days, has more theta (+1.70 points a day sold) and more gamma; the February one has more vega (9.05 versus 5.08). The balance: positive theta (+0.38), positive vega (+3.97), negative gamma (−0.00192) and delta almost zero (0.032). The calendar collects the passage of time like a premium sale, but it is long volatility: if implied volatility rises, it gains.
resumen values it on January 16, the first expiration: the January call settles at its intrinsic value and the
February one is valued with BSM at 35 days with today's volatility. The maximum gain, 28.15 points, is at the strike;
the maximum loss is the whole debit, when the index moves so far in either direction that both calls are worth the
same (both almost nothing, or both their intrinsic value). The breakeven points, 6,041.7 and 6,168.3, leave a range of
±1% around the strike.
Mermaid source
flowchart TB H[Dec 31: open] --> C[Sell 6100 January] H --> L[Buy 6100 Feb.] C --> E[Jan 16: intrinsic] L --> V[Jan 16: BSM with IV] E --> P[Calendar P&L] V --> P
On January 16, the February leg no longer has a known volatility. The header figure draws the P&L on that day under three assumptions:
def con_iv(patas, dv):
"""The same legs with the IV shifted by dv (decimal fraction)."""
return [{**p, "iv": p["iv"] + dv} for p in patas]
coste = apertura(calendario)[1]
S = np.array([5800.0, 5950, 6050, 6100, 6150, 6250, 6400])
tabla = pd.DataFrame({
f"IV {dv * 100:+.0f}": valor(con_iv(calendario, dv), S, ENE)
- coste for dv in (-0.03, 0.0, 0.03)}, index=S)
tabla.index.name = "ORX on January 16"
tabla.round(1) IV -3 IV +0 IV +3
ORX on January 16
5800.0 -70.0 -61.8 -49.8
5950.0 -50.9 -32.9 -13.5
6050.0 -18.5 3.7 25.9
6100.0 5.7 28.2 50.7
6150.0 -14.8 6.8 28.8
6250.0 -41.1 -24.3 -5.4
6400.0 -56.2 -48.4 -36.6With the index at 6,100, the calendar gains 28.2 if February volatility stays the same, 50.7 if it rises 3 points and only 5.7 if it drops 3. Three volatility points are worth about 22 points of P&L, almost the same as the maximum gain: the vega of the February call at 35 days is about 7.5 points per point. A 3-point drop is not a rare scenario: it takes February volatility (0.1258) to the level of January's (0.0946), something that happens every time the market calms down and the term structure flattens. At 5,800 or at 6,400 the calendar loses between 36 and 70 points with any volatility: far from the strike, vega no longer saves anything.
Diagonal and double calendar
diagonal = [pata("C", 6200, -1, ENE), pata("C", 6000, 1, FEB)]
doble = [pata("P", 5900, -1, ENE), pata("P", 5900, 1, FEB),
pata("C", 6300, -1, ENE), pata("C", 6300, 1, FEB)]
for nombre, e in [("diagonal 6000 Feb / 6200 Jan", diagonal),
("double calendar 5900/6300", doble)]:
print(nombre, resumen(e))
print(" ", griegas(e))diagonal 6000 Feb / 6200 Jan {'medio': 181.9, 'real': 182.7, 'horquilla': 0.8, 'maximo': 62.5, 'minimo': -182.7, 'muertos': [6116.6]}
{'delta': 0.506, 'gamma': -0.00119, 'vega': 5.62, 'theta': -0.7}
double calendar 5900/6300 {'medio': 71.7, 'real': 72.65, 'horquilla': 0.95, 'maximo': 33.83, 'minimo': -93.41, 'muertos': [5831.8, 5992.6, 6239.6, 6348.8]}
{'delta': 0.072, 'gamma': 0.00064, 'vega': 10.86, 'theta': -0.49}- 6,000 February / 6,200 January diagonal. It buys an in-the-money February call and sells an out-of-the-money January one. It costs 182.7 and has a delta of 0.506: it is a bullish position, a covered call with the February option instead of the index (lesson 3.5.1 explains the covered call). It gains at most 62.5 if the index ends at 6,200 on January 16, and its theta is negative (−0.70): the 6,000 February call loses time value faster than the 6,200 January one collects it, being far out of the money at 16 days. A diagonal is not a calendar with another strike: it is a different position.
- 5,900/6,300 double calendar. Two calendars, a put one at 5,900 and a call one at 6,300. It costs 72.65 and has two humps, with four breakeven points (5,831.8, 5,992.6, 6,239.6 and 6,348.8): it gains if the index ends near one of the two strikes and loses if it stays in the center or moves far away. Its vega is the highest in the lesson (10.86) and today its gamma is positive and its theta negative: with the index between the two strikes, the short January options are far out of the money at 16 days and worth little; the long February ones dominate. It turns short gamma when the index approaches one of the strikes.
Ratios and backspreads
ratio_c = [pata("C", 6100, 1), pata("C", 6300, -2)]
ratio_p = [pata("P", 6100, 1), pata("P", 5900, -2)]
back_c = [pata("C", 6100, -1), pata("C", 6300, 2)]
back_p = [pata("P", 6100, -1), pata("P", 5900, 2)]
for nombre, e in [("call ratio 1x2 6100/6300", ratio_c),
("put ratio 1x2 6100/5900", ratio_p),
("call backspread 6100/6300", back_c),
("put backspread 6100/5900", back_p)]:
print(nombre, resumen(e))
print(" ", griegas(e))call ratio 1x2 6100/6300 {'medio': 64.5, 'real': 65.6, 'horquilla': 1.1, 'maximo': 134.4, 'minimo': 'unlimited', 'muertos': [6165.6, 6434.4]}
{'delta': 0.095, 'gamma': -0.00115, 'vega': -4.21, 'theta': 0.16}
put ratio 1x2 6100/5900 {'medio': -5.0, 'real': -3.7, 'horquilla': 1.3, 'maximo': 203.7, 'minimo': -5695.3, 'muertos': [5696.3]}
{'delta': 0.047, 'gamma': -0.00047, 'vega': -5.74, 'theta': 1.09}
call backspread 6100/6300 {'medio': -64.5, 'real': -63.4, 'horquilla': 1.1, 'maximo': 'unlimited', 'minimo': -136.6, 'muertos': [6163.4, 6436.6]}
{'delta': -0.095, 'gamma': 0.00115, 'vega': 4.21, 'theta': -0.16}
put backspread 6100/5900 {'medio': 5.0, 'real': 6.3, 'horquilla': 1.3, 'maximo': 5692.7, 'minimo': -206.3, 'muertos': [5693.7]}
{'delta': -0.047, 'gamma': 0.00047, 'vega': 5.74, 'theta': -1.09}The 1×2 call ratio buys the 6,100 and sells two 6,300. It costs 65.6 and gains at most 134.4 if the index ends at 6,300; above 6,434.4 it loses without limit, because one short call is left uncovered. The put ratio, with the same middle strike and the 5,900 put, collects 3.7 points: it gains that if the index rises, gains up to 203.7 at 5,900 and loses without limit below 5,696.3 (6.5% below the index). The difference is the skew: the 25D put has a volatility of 15.39% and the 25D call, 10.07%. Two 5,900 puts are worth almost the same as one 6,100; two 6,300 calls are worth less than half of one 6,100.
Mermaid source
flowchart TB S[Put skew] --> RP[Put ratio] S --> RR[Risk reversal] S --> JL[Jade lizard] RP --> C[Left tail] RR --> C JL --> C
Backspreads are the mirror image. The call backspread sells the 6,100 and buys two 6,300: it opens for a credit of 63.4 and gains without limit on strong rallies, but loses up to 136.6 if the index ends at 6,300. The put one sells the 6,100 and buys two 5,900: it costs 6.3, loses up to 206.3 at 5,900 and gains on sharp falls. It is left-tail protection with a weak spot in the moderate-decline zone. Its Greeks say so: vega +5.74 and theta −1.09; it pays for time and for the skew.
Risk reversal and jade lizard
risk_rev = [pata("P", 5900, -1), pata("C", 6300, 1)]
jade = [pata("P", 6000, -1), pata("C", 6200, -1), pata("C", 6300, 1)]
for nombre, e in [("risk reversal 5900/6300", risk_rev),
("jade lizard 6000/6200/6300", jade)]:
print(nombre, resumen(e))
print(" ", griegas(e))
iv_c = risk_rev[1]["iv"]
iv_p = risk_rev[0]["iv"]
print(f"IV call 25D {iv_c:.4f} IV put 25D {iv_p:.4f}"
f" risk reversal {100 * (iv_c - iv_p):.2f} points")risk reversal 5900/6300 {'medio': -29.5, 'real': -28.8, 'horquilla': 0.7, 'maximo': 'unlimited', 'minimo': -5870.2, 'muertos': [5871.2]}
{'delta': 0.475, 'gamma': 0.00034, 'vega': -0.76, 'theta': 0.26}
jade lizard 6000/6200/6300 {'medio': -114.9, 'real': -113.7, 'horquilla': 1.2, 'maximo': 113.7, 'minimo': -5885.3, 'muertos': [5886.3]}
{'delta': 0.195, 'gamma': -0.00137, 'vega': -10.42, 'theta': 1.36}
IV call 25D 0.1007 IV put 25D 0.1539 risk reversal -5.32 pointsThe 5,900/6,300 risk reversal sells the 25D put and buys the 25D call. It collects 28.8 points: the put costs more than the call because its volatility is 5.32 points higher. That −5.32 is the market definition of the risk reversal as a measure of skew (the volatility of the 25D call minus that of the 25D put), and the structure is the way to trade it: delta 0.475, vega almost zero (−0.76), theta +0.26. It is a synthetic long in the index with half the delta, which collects the skew in exchange for the left tail: below 5,871.2 it loses like a short put.
The 6,000/6,200/6,300 jade lizard sells the 6,000 put and the 6,200/6,300 call spread. It collects 113.7 points, more than the width of the call spread (100): if the index rises above 6,300 it gains 13.7 instead of losing. It has no upside risk. It has all the risk on the downside (breakeven point at 5,886.3) and it is the shortest-volatility structure in the lesson: vega −10.42, theta +1.36.
What the extra legs cost
estructuras = {"calendar": calendario, "diagonal": diagonal,
"double calendar": doble, "call ratio": ratio_c,
"put ratio": ratio_p, "put backspread": back_p,
"risk reversal": risk_rev, "jade lizard": jade}
filas = []
for nombre, e in estructuras.items():
medio, real = apertura(e)
filas.append({"estructura": nombre,
"contratos": sum(abs(p["n"]) for p in e),
"prima_medio": medio, "horquilla": real - medio,
"pct_prima": 100 * (real - medio) / abs(medio)})
pd.DataFrame(filas).set_index("estructura").round(2) contratos prima_medio horquilla pct_prima
estructura
calendar 2 73.1 0.80 1.09
diagonal 2 181.9 0.80 0.44
double calendar 4 71.7 0.95 1.32
call ratio 3 64.5 1.10 1.71
put ratio 3 -5.0 1.30 26.00
put backspread 3 5.0 1.30 26.00
risk reversal 2 -29.5 0.70 2.37
jade lizard 3 -114.9 1.20 1.04With two legs and a large net premium, the bid-ask spread is less than 1.1% of the premium (calendar, diagonal, jade lizard). Structures with an almost zero net premium are another story: the put ratio and the put backspread, with a mid premium of 5 points, lose 26% of it crossing the spread. A structure that “opens for free” at mid does not open for free.
The skew over twelve years
The risk reversal is also a time series: how much more expensive the put is than the call with the same delta, month by month. The cell also prepares the entries for the history in the next section, which needs the chain of the short expiration and that of the long one:
vtos = np.sort(cadena.vencimiento.unique())
sesiones = indice.index
por_dia = dict(tuple(cadena.groupby(["fecha", "vencimiento"])))
entradas = [] # (session, short exp, long exp, chains)
for ant, v1, v2 in zip(vtos[:-2], vtos[1:-1], vtos[2:]):
if v2 > sesiones[-1]:
break
f = sesiones[sesiones.searchsorted(ant, side="right")]
if (f, v1) in por_dia and (f, v2) in por_dia:
entradas.append((f, v1, v2, con_delta(f, v1),
con_delta(f, v2)))
rr = pd.DataFrame({
"rr25": [100 * (iv_delta(d1, "C", 0.25) - iv_delta(d1, "P", -0.25))
for f, v1, v2, d1, d2 in entradas],
"iv_atm": [100 * iv_delta(d1, "C", 0.50)
for f, v1, v2, d1, d2 in entradas]},
index=[e[0] for e in entradas])
print(len(rr), "entries;", rr.rr25.describe().round(2).to_dict())
rr.loc[[rr.rr25.idxmin(), rr.rr25.idxmax()]].round(2)142 entries; {'count': 142.0, 'mean': -7.08, 'std': 2.79, 'min': -22.65, '25%': -8.19, '50%': -6.7, '75%': -5.1, 'max': -2.91}
rr25 iv_atm
2020-03-23 -22.65 62.80
2014-01-21 -2.91 8.97In the 142 entries the 25D risk reversal of the next monthly expiration is always negative: the median, −6.7 points; the least negative, −2.91 in January 2014, with ATM volatility at 8.97%; the most, −22.65 on March 23, 2020, with ATM at 62.80%. The data README gives a median of −5.4 points in calm periods for 25- to 35-day slices; here all regimes are included, and the skew in points grows with the level of volatility. A risk reversal sold “because the skew is expensive” is sold when the market is already scared: the −22.65 of March 2020 came after the fall, not before.
Twelve years of each structure
The history in this lesson has one difference from the previous ones: the time structures have legs that do not expire. The rule: on the session after each monthly expiration the structure is opened with the next expiration (short) and the one after (long), at the bid and at the ask; at the short expiration, the short legs settle at their intrinsic value and the long ones are sold at that day's bid, with the actual chain (what they were really worth, with whatever volatility they had). Single-expiration structures are settled as in 3.5.1. When the strike of a long leg is not listed in its expiration, the closest one is taken.
def cerca(d, K):
"""The listed strike closest to K (the grids of two
expirations do not always match)."""
ks = d.strike.unique()
return float(ks[np.abs(ks - K).argmin()])
def cierre(d, tipo, K, n):
"""Price to close a leg: sell what was bought at the bid and
buy back what was sold at the ask."""
o = d[(d.tipo == tipo) & (d.strike == K)].iloc[0]
return o.bid if n > 0 else o.ask
def historia(receta):
"""Opens at bid/ask on the session after an expiration and
closes everything at the next expiration: the short legs
settle at intrinsic value; the long ones are sold at the bid."""
filas = []
for f, v1, v2, d1, d2 in entradas:
S, ST = indice.loc[f, "cierre"], indice.loc[v1, "cierre"]
d_fin = por_dia.get((v1, v2))
coste = final = 0.0
for tipo, K, n, cual in receta(d1, d2):
d = d1 if cual == 1 else d2
K = cerca(d, K)
o = d[(d.tipo == tipo) & (d.strike == K)].iloc[0]
coste += n * (o.ask if n > 0 else o.bid)
if cual == 1:
final += n * (max(ST - K, 0) if tipo == "C"
else max(K - ST, 0))
else:
final += n * cierre(d_fin, tipo, K, n)
filas.append({"entrada": f, "pnl": 100 * (final - coste) / S})
return pd.DataFrame(filas).set_index("entrada")["pnl"]
sd = strike_delta
recetas = {
"ATM calendar": lambda a, b: [
("C", sd(a, "C", 0.5), -1, 1), ("C", sd(a, "C", 0.5), 1, 2)],
"double calendar 25D": lambda a, b: [
("P", sd(a, "P", -0.25), -1, 1), ("P", sd(a, "P", -0.25), 1, 2),
("C", sd(a, "C", 0.25), -1, 1), ("C", sd(a, "C", 0.25), 1, 2)],
"diagonal 60D/25D": lambda a, b: [
("C", sd(a, "C", 0.25), -1, 1), ("C", sd(b, "C", 0.60), 1, 2)],
"call ratio 1x2": lambda a, b: [
("C", sd(a, "C", 0.5), 1, 1), ("C", sd(a, "C", 0.25), -2, 1)],
"put ratio 1x2": lambda a, b: [
("P", sd(a, "C", 0.5), 1, 1), ("P", sd(a, "P", -0.25), -2, 1)],
"put backspread": lambda a, b: [
("P", sd(a, "C", 0.5), -1, 1), ("P", sd(a, "P", -0.25), 2, 1)],
"risk reversal 25D": lambda a, b: [
("P", sd(a, "P", -0.25), -1, 1), ("C", sd(a, "C", 0.25), 1, 1)],
"jade lizard 25D/10D": lambda a, b: [
("P", sd(a, "P", -0.25), -1, 1), ("C", sd(a, "C", 0.25), -1, 1),
("C", sd(a, "C", 0.10), 1, 1)],
}
hist = pd.DataFrame({k: historia(v) for k, v in recetas.items()})
pd.DataFrame({
"media": hist.mean(), "mediana": hist.median(),
"pct_pos": 100 * (hist > 0).mean(), "desv": hist.std(),
"peor": hist.min(),
"fecha_peor": hist.idxmin().dt.strftime("%Y-%m-%d"),
"mejor": hist.max()}).round(2) media mediana pct_pos desv peor fecha_peor mejor
ATM calendar -0.02 -0.04 47.89 0.59 -2.02 2020-04-20 2.04
double calendar 25D 0.08 0.09 57.04 0.73 -2.74 2020-04-20 2.17
diagonal 60D/25D 0.10 0.61 61.27 1.88 -7.58 2020-04-20 6.63
call ratio 1x2 -0.31 -0.21 40.85 1.98 -12.86 2020-07-20 2.57
put ratio 1x2 0.33 -0.00 50.00 2.38 -21.33 2020-02-24 6.75
put backspread -0.38 -0.04 45.77 2.38 -6.88 2015-08-24 21.29
risk reversal 25D 0.76 0.36 87.32 3.82 -24.65 2020-02-24 19.57
jade lizard 25D/10D 0.22 0.81 70.42 2.55 -23.41 2020-02-24 3.87- Calendars: the ATM one loses 0.02% on average and wins in 48% of the months; the double calendar gains 0.08% and wins in 57%. Their worst entries are small (−2.0% and −2.7%), both on April 20, 2020. It is not the volatility premium that drives a calendar, but whether the index stays near the strike and how the term structure changes.
- 60D/25D diagonal: it gains 0.10% on average and in 61% of the months; its worst, −7.6%, is a fall with the long delta of the February call.
- 1×2 call ratio: it loses 0.31% on average, and its worst entry is that of July 20, 2020 (−12.9%): the 23.5% rise to expiration went through the uncovered short call. 1×2 put ratio: it gains 0.33%, with a median of zero and a worst month of −21.3% (February 24, 2020). Put backspread: the mirror image; it loses 0.38% on average and gains 21.3% in February 2020; its worst entry is that of August 24, 2015, the day of the last of the planted jumps of August 20-24: the index fell another 6.9% to expiration and ended near the long puts, the weak spot of the backspread, the moderate decline.
- 25D risk reversal: the one that gains the most (0.76% on average, positive in 87% of the months), because the index in the data rises and the skew pays for the call. Its worst entry, −24.6%, loses almost as much as the whole index (−28.1% in lesson 3.5.1) even though its initial delta was half: as the index falls, the short put gains delta until it behaves like a full index.
- 25D/10D jade lizard: 0.22% on average, 70% positive months, −23.4% in the worst.
2020 month by month shows how the time structures respond to a crash:
hist.loc["2020-01":"2020-07", ["ATM calendar", "double calendar 25D",
"put ratio 1x2",
"put backspread"]].round(2) ATM calendar double calendar 25D put ratio 1x2 put backspread
entrada
2020-01-21 0.11 0.40 -0.18 0.13
2020-02-24 -0.53 -1.01 -21.33 21.29
2020-03-23 -1.09 -0.59 -1.34 1.07
2020-04-20 -2.02 -2.74 -1.07 0.85
2020-05-18 1.50 0.03 1.82 -2.04
2020-06-22 -1.10 -1.23 -0.09 -0.05
2020-07-20 -1.60 -2.37 -0.27 0.17The ATM calendar did not gain from the volatility explosion: it lost 0.53% in the February entry, 1.09% in the March one and 2.02% in the April one. Its positive vega does not offset its negative gamma when the index moves 28% away from the strike or rebounds 30%; and in March and April, with the term structure inverted, it bought the volatility of a later month cheaper than the one it sold. The put ratio and the put backspread are exact opposites in February (−21.33% and +21.29%).
The jade lizard recipe has a problem that does not show in the table:
sin_riesgo = []
for f, v1, v2, d1, d2 in entradas:
x = d1.set_index(["tipo", "strike"])
kp, kc = sd(d1, "P", -0.25), sd(d1, "C", 0.25)
kc2 = sd(d1, "C", 0.10)
credito = (x.loc[("P", kp), "bid"] + x.loc[("C", kc), "bid"]
- x.loc[("C", kc2), "ask"])
sin_riesgo.append(credito >= kc2 - kc)
print(f"{100 * np.mean(sin_riesgo):.1f} % of entries")2.1 % of entriesOnly in 2.1% of the entries did the credit exceed the width of the 25D/10D call spread. In the remaining months, the structure in the table had upside risk: it was not a jade lizard, but a short put with a short call spread. A recipe that chooses strikes by delta has to check the condition that defines the structure (credit greater than or equal to the width) and, if it does not hold, change the strikes (like the 6,000/6,200/6,300 in the example, which meets it) or not open.
Common mistakes
- Valuing a calendar with today's volatility. Its result depends on the volatility of the long leg on the day the short one expires: 3 points change the P&L as much as its maximum gain.
- Confusing a diagonal with a calendar. With the long leg in the money, the diagonal is a directional position with negative theta.
- Forgetting the unlimited side. Ratios and backspreads have an uncovered end; put ones, the end of the crises.
- Believing that a credit is free. The put ratio and the risk reversal collect the skew; they pay for it in the tail.
- Trusting the name of the structure. The delta-based jade lizard was only one in 2.1% of the months.
- Ignoring the bid-ask spread of structures with an almost zero premium. 26% of the premium in the example put ratio.
- American options. With stocks, an in-the-money short leg of a calendar or a ratio can be assigned before expiration, especially before a dividend (lesson 3.5.5).
Exercises
- Build the 5,900 put calendar (sell January, buy February). Compare its cost, its maximum gain, its breakeven points and its Greeks with those of the 6,100 call calendar.
Solution
cal_put = [pata("P", 5900, -1, ENE), pata("P", 5900, 1, FEB)]
print(resumen(cal_put))
griegas(cal_put){'medio': 44.7, 'real': 45.3, 'horquilla': 0.6, 'maximo': 59.68, 'minimo': -66.06, 'muertos': [5762.1, 6066.7]}
{'delta': -0.129, 'gamma': -0.00026, 'vega': 4.66, 'theta': 0.15}The 5,900 put calendar costs 45.3 (less than the 6,100 call calendar, 73.9, because its strike is 3.1% out of the money) and gains at most 59.68 if the index ends at 5,900 on January 16, with breakeven points at 5,762.1 and 6,066.7. Its maximum loss (66.06) exceeds the debit: with the index far down, the short January put settles at its intrinsic value and the long February one, deep in the money, is worth somewhat less than its own because of the interest rate (the same thing seen with the in-the-money put in 3.5.2). Its Greeks are those of an out-of-the-money calendar: vega +4.66, theta +0.15, small gamma (−0.00026) and a delta of −0.129 that the at-the-money call calendar does not have: it needs the index to drop toward 5,900.
- Split the entries of the ATM calendar by the slope of the term structure on the entry day (ATM IV of the long expiration minus that of the short one). Does the calendar gain more when the structure is in contango or when it is inverted?
Solution
pendiente = pd.Series(
[100 * (iv_delta(d2, "C", 0.5) - iv_delta(d1, "C", 0.5))
for f, v1, v2, d1, d2 in entradas],
index=[e[0] for e in entradas])
cal = hist["ATM calendar"]
grupo = np.where(pendiente > 0, "contango", "inverted")
pd.DataFrame({"entradas": cal.groupby(grupo).size(),
"pendiente_media": pendiente.groupby(grupo).mean(),
"media": cal.groupby(grupo).mean(),
"pct_pos": 100 * (cal > 0).groupby(grupo).mean(),
"peor": cal.groupby(grupo).min()}).round(2) entradas pendiente_media media pct_pos peor
contango 92 1.22 -0.03 46.74 -1.60
inverted 50 -1.35 0.01 50.00 -2.02There is almost no difference: 92 entries in contango (average slope of +1.22 points) with an average of −0.03% and 46.7% positive months, and 50 with the structure inverted (−1.35 points) with +0.01% and 50%. The intuition (“buy the long one when it is cheap relative to the short one”) separates nothing in this data with such a simple rule: what decides the calendar is where the index ends relative to the strike, and the worst entry (−2.02%) falls in the inverted group, in April 2020. With 50 and 92 observations and a standard deviation of 0.59 points, the standard error of each average is around 0.06-0.08 points: any difference of this size is noise.
- A colleague proposes selling the 25D risk reversal (short put and long call) every month “because over twelve years it has won 87% of the months and it opens for a credit”. What would you tell them?
Solution
That the credit is the skew premium for taking on the left tail and that its 87% of positive months comes from an index that rises 1% a month on average in the data: it is, above all, a long with half a delta (0.475). Its worst entry loses 24.6% of the index, like the put ratio and the jade lizard, in the same month. If they want that risk, the question is whether the skew compensates for the tail, and that does not show in the hit rate: you have to compare the average with that of half the index (the index gains 1.05% a month in lesson 3.5.1, so half the index would gain somewhat more than 0.5%, and the risk reversal gains 0.76%), size by the loss in a fall like that of 2020 and not by the margin of a quiet day (lesson 3.6.1), and remember that in a crisis the skew spikes (−22.7 points in March 2020) and closing the short put costs much more than what was collected.
Each answer is folded under its question
Self-assessment
The 6,100 call calendar (sell January, buy February) costs 73.9 points. On January 16, with the index at 6,100, it gains 28.2 if February volatility does not change and 5.7 if it drops 3 points. What position is it, above all?
- AA bullish directional bet
- BA riskless volatility sale
- CA purchase of the February volatility left after the January expiration (positive vega in the long leg) with the index still near the strike
- DA position with no Greeks, because both legs have the same strike
Show answer
Answer C. A purchase of the February volatility left after the January expiration (positive vega in the long leg) with the index still near the strike
Its aggregate vega is positive (3.97 points per volatility point): the long leg has more vega than the short one. Its gamma is negative: a large move in either direction takes it to the maximum loss. It gains with the index still and February volatility firm; 3 volatility points move the result by about 22 points.
With the same middle strike, the 6,100/6,300 1×2 call ratio costs 65.6 and the 6,100/5,900 put ratio opens for a credit of 3.7. Why?
- ABecause of the skew: the 25D put (IV 0.1539) is much more expensive than the 25D call (IV 0.1007), and selling two puts pays for the at-the-money put
- BBecause puts are more liquid
- CBecause the interest rate favors puts
- DBecause of an error in the chain
Show answer
Answer A. Because of the skew: the 25D put (IV 0.1539) is much more expensive than the 25D call (IV 0.1007), and selling two puts pays for the at-the-money put
The February 25D risk reversal is −5.32 points: the market (here, the generator) charges more for the left tail. The put ratio collects that excess, and that is why its risk is where the skew says the danger is: below 5,696.3 it loses without limit.
The 25D risk reversal (short put, long call) won in 87% of the months in the history and its worst entry lost 24.6% of the index. What is it really?
- AA hedge
- BA volatility-neutral strategy
- CA riskless position because it opens for a credit
- DA synthetic long with a delta of 0.475 financed by the skew: it gains with the rising index of the data and loses in falls as if it held half the index
Show answer
Answer D. A synthetic long with a delta of 0.475 financed by the skew: it gains with the rising index of the data and loses in falls as if it held half the index
Its vega is almost zero (−0.76) and its delta, 0.475: it is direction. The short put concentrates the risk in sharp falls (February 24, 2020), and the credit of 28.8 points is the skew premium for taking it on.
A 6,000/6,200/6,300 jade lizard (short 6,000 put, short 6,200/6,300 call spread) collects 113.7. What happens if the index rises a lot?
- AIt loses without limit
- BIt gains 13.7 points: the credit (113.7) exceeds the width of the call spread (100), so it has no upside risk
- CIt loses 100 points
- DIt loses the credit
Show answer
Answer B. It gains 13.7 points: the credit (113.7) exceeds the width of the call spread (100), so it has no upside risk
Above 6,300 the call spread loses its width, 100, and the credit covers that loss with 13.7 to spare. The condition (credit greater than or equal to the width) does not hold by itself: with 25D/10D strikes, in the history it only held in 2.1% of the entries. The real risk is on the downside: below 5,886.3 it loses like a short put.
The implied volatility of the 6,100 call is 0.0946 at 16 days and 0.1258 at 51 days. What volatility does a calendar that sells January and buys February buy?
- A0.1258, that of February
- BThe forward volatility between the two expirations, the square root of (0.1258²·51 − 0.0946²·16)/35, about 0.1377
- CThe average, 0.1102
- DThe difference, 0.0312
Show answer
Answer B. The forward volatility between the two expirations, the square root of (0.1258²·51 − 0.0946²·16)/35, about 0.1377
Total variances add up over time: that of February is that of January plus that of the January 16-February 20 stretch. Solving, the volatility of that stretch is 0.1377, higher than either implied volatility. It is the volatility that remains alive in the long option when the short one expires.
Key points
- A calendar sells the short expiration and buys the long one: positive vega, negative gamma; it buys the forward volatility between the two expirations and gains with the index still near the strike.
- With two expirations, the P&L at the short expiration depends on the volatility of the long leg: 3 volatility points move the example calendar by about 22 points, almost the same as its maximum gain.
- Ratios and backspreads have an unlimited side; the skew makes the put ratio open for a credit and sell the left tail.
- The risk reversal measures the skew (−5.32 points in February, a median of −6.7 over twelve years, −22.7 in March 2020) and, as a structure, it is a synthetic long financed by it: it wins in 87% of the months and loses 24.6% in the worst.
- Delta-based recipes do not guarantee the structure: the 25D/10D jade lizard only had the credit above the width of the call spread in 2.1% of the entries.