Lesson 3.5.4 · 70 min

A custom tool: payoff, aggregate Greeks and scenarios

A four-leg position no longer fits in your head: you need to be able to compute its value, its Greeks and its P&L in any scenario. This lesson builds that tool from scratch in about 150 lines, tests it against QuantLib and against its own derivatives, and uses it with an iron condor opened on the synthetic chain on February 19, 2020, right before the crash.

Fig. — Short iron condor on ORX on 2020-02-19 (2800/2900 puts, 3050/3100 calls, 30 days): payoff at expiry and P&L today and in 15 days, with each strike's volatility unchanged
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 the tool has to do

Lessons 3.5.1 to 3.5.3 drew structures with two, three and four legs. Trading them takes more than a drawing: knowing how much the position is worth if the index falls 5% tomorrow, how much it loses if volatility rises ten points, what part of yesterday's P&L is due to each Greek and when the linear approximation stops being reliable. Broker platforms show part of it; a tool of your own lets you put it into a backtest, a daily risk control or an alert, with conventions you have chosen and tested.

The one in this lesson does five things, and nothing more:

  1. It values a European option and its Greeks with Black-Scholes-Merton (lesson 3.4.2), vectorized.
  2. It aggregates legs into a position: value, delta, gamma, vega and theta in USD.
  3. It computes the payoff at expiration and the P&L before expiration at any price, volatility and date.
  4. It explains one day's P&L with the previous day's Greeks.
  5. It computes a price × volatility × time scenario grid in one go.
Day's chainLegPositionbsmGreeks in USDP&L and payoffExplained P&LGridTests
Mermaid source
flowchart TB
  C[Day's chain] --> P[Leg]
  P --> Q[Position]
  B[bsm] --> Q
  Q --> G[Greeks in USD]
  Q --> L[P&L and payoff]
  G --> E[Explained P&L]
  L --> R[Grid]
  T[Tests] -.-> B
  T -.-> Q

Before the code, the conventions. Half of the errors with options are about units, not the model:

QuantityTool conventionCommon mistake
PricePer unit of the underlying (ORX points); the position multiplies by 100 USD per pointMixing price per share with price per contract
TimeT = calendar days to expiration / 365Using sessions / 252 in the formula and calendar days in theta
Risk-free rate and dividendsContinuous, as a decimal fraction (tipo_1m / 100)Passing 0.55 instead of 0.0055
VolatilityAnnual, as a decimal fraction (0.18 is 18%)Passing 18
DeltaUSD per index point in the position (quantity × 100 × delta)Confusing it with dollar delta (× S)
VegaUSD per volatility point (per 0.01)Per unit of volatility (100 times more)
ThetaUSD per calendar dayPer year (365 times more) or per session

ORX options imitate SPX: European, cash-settled and 100 USD per point. In real SPX options the multiplier is also 100 and the style is European, as the Cboe specifications state; the monthlies settle at a value calculated from the opening prices on expiration day (in the course data, at the close, for simplicity). A tool for American options on stocks needs another engine (the binomial one from 3.4.2); the leg and position structure is the same.

The engine: vectorized Black-Scholes-Merton

The bsm function returns the price and Greeks of a European option with a continuous dividend yield. It accepts arrays in S, K, T and vol and combines them by broadcasting: with an array of 141 prices it returns 141 prices, and with a mesh of 7 × 5 × 3 scenarios, a mesh. A single sign w (+1 call, −1 put) avoids duplicating the formulas:

V=w(Se−qTN(wd1)−Ke−rTN(wd2)),d1,2=ln⁡(S/K)+(r−q±σ2/2) TσTV = w\left(S e^{-qT} N(w d_1) - K e^{-rT} N(w d_2)\right), \qquad d_{1,2} = \frac{\ln(S/K) + (r - q \pm \sigma^2/2)\,T}{\sigma\sqrt{T}}
IN [01]python
import numpy as np
import pandas as pd
from scipy.special import ndtr

AÑO = 365.0  # T = calendar days / 365


def bsm(tipo, S, K, T, vol, r, q):
    """Price and Greeks of a European option, per unit.

    T in years, r and q continuous, vol as a decimal fraction.
    Vega per volatility point; theta per calendar day. With
    T <= 0 it returns the intrinsic value."""
    S, K, T, vol = np.broadcast_arrays(
        *(np.asarray(x, dtype=float) for x in (S, K, T, vol)))
    vivo = T > 0
    Tv = np.where(vivo, T, 1.0)
    raiz = vol * np.sqrt(Tv)
    d1 = (np.log(S / K) + (r - q + vol**2 / 2) * Tv) / raiz
    d2 = d1 - raiz
    dq, dr = np.exp(-q * Tv), np.exp(-r * Tv)
    n1 = np.exp(-d1**2 / 2) / np.sqrt(2 * np.pi)
    w = 1.0 if tipo == "C" else -1.0  # +1 call, -1 put
    precio = w * (S * dq * ndtr(w * d1) - K * dr * ndtr(w * d2))
    delta = w * dq * ndtr(w * d1)
    theta = (-S * dq * n1 * vol / (2 * np.sqrt(Tv))
             + w * q * S * dq * ndtr(w * d1)
             - w * r * K * dr * ndtr(w * d2))
    intr = np.maximum(w * (S - K), 0.0)
    return {
        "precio": np.where(vivo, precio, intr),
        "delta": np.where(vivo, delta, w * (intr > 0)),
        "gamma": np.where(vivo, dq * n1 / (S * raiz), 0.0),
        "vega": np.where(vivo, S * dq * n1 * np.sqrt(Tv) / 100, 0.0),
        "theta": np.where(vivo, theta / AÑO, 0.0),
    }


prueba = bsm("P", 5000, 4900, 30 / AÑO, 0.18, 0.04, 0.015)
{k: round(float(v), 4) for k, v in prueba.items()}
OUT [01]Vectorized Black-Scholes-Merton
{'precio': 56.0149, 'delta': -0.3234, 'gamma': 0.0014, 'vega': 5.145, 'theta': -1.4266}

The test put (S = 5000, K = 4900, 30 days, 18%, r = 4%, q = 1.5%) is worth 56.01 points, 5,601 USD per contract. Its delta, −0.3234, says that it gains 0.32 points for every point the index falls; its vega, 5.145, that it gains 5.15 points (514.5 USD per contract) if volatility rises from 18% to 19%; its theta, −1.4266, that it loses 1.43 points per calendar day with everything else equal. With T <= 0 it returns the intrinsic value and zero Greeks (delta 0 or ±1): that is what allows the payoff to be computed with the same function.

Before trusting it, it is compared with an independent implementation. QuantLib (lesson 3.4.4) has an analytical engine for the same option; the only precaution is to give it the same time convention (Actual365Fixed) and to remember that its vega() is per unit of volatility and its theta() per year (thetaPerDay() divides by 365):

IN [02]python
import QuantLib as ql

fecha_ql = ql.Date(2, 1, 2025)
ql.Settings.instance().evaluationDate = fecha_ql
dc = ql.Actual365Fixed()  # the same T = days / 365


def curva(tipo):
    return ql.YieldTermStructureHandle(
        ql.FlatForward(fecha_ql, tipo, dc))


vol = ql.BlackVolTermStructureHandle(
    ql.BlackConstantVol(fecha_ql, ql.NullCalendar(), 0.18, dc))
proceso = ql.BlackScholesMertonProcess(
    ql.QuoteHandle(ql.SimpleQuote(5000.0)), curva(0.015),
    curva(0.04), vol)
put = ql.VanillaOption(ql.PlainVanillaPayoff(ql.Option.Put, 4900),
                       ql.EuropeanExercise(fecha_ql + 30))
put.setPricingEngine(ql.AnalyticEuropeanEngine(proceso))

ref = {"precio": put.NPV(), "delta": put.delta(),
       "gamma": put.gamma(), "vega": put.vega() / 100,
       "theta": put.thetaPerDay()}
pd.DataFrame({"own": {k: float(v) for k, v in prueba.items()},
              "QuantLib": ref}).assign(
    dif=lambda t: t["own"] - t["QuantLib"]).round(6)
OUT [02]Check against QuantLib
              own   QuantLib  dif
precio  56.014945  56.014945 -0.0
delta   -0.323396  -0.323396  0.0
gamma    0.001391   0.001391 -0.0
vega     5.145002   5.145002  0.0
theta   -1.426610  -1.426610 -0.0

They match to the sixth decimal place in all five quantities. If QuantLib's theta had been compared with theta() instead of thetaPerDay(), the difference would have been a factor of 365: it is the kind of error this comparison exists to catch.

Legs and position

A leg is an immutable record: type, signed quantity, strike, days to expiration, volatility and entry price. Type S represents the underlying itself (for delta hedges). Validation at creation stops the two most frequent convention errors: a type that does not exist and a volatility written as a percentage.

IN [03]python
from dataclasses import dataclass


@dataclass(frozen=True)
class Pata:
    tipo: str              # "C", "P" or "S" (the underlying)
    cantidad: float        # contracts: + bought, - sold
    strike: float = 0.0
    dias: float = 0.0      # calendar days to expiration
    iv: float = 0.0        # leg volatility, decimal fraction
    prima: float = 0.0     # entry price per unit

    def __post_init__(self):
        if self.tipo not in ("C", "P", "S"):
            raise ValueError(f"type {self.tipo!r}: use C, P or S")
        if self.tipo == "S":
            return
        if self.strike <= 0:
            raise ValueError("an option needs strike > 0")
        if not 0 < self.iv < 3:
            raise ValueError(f"iv {self.iv}: as a decimal fraction "
                             "(0.18 is 18%)")
IN [04]python
Pata("P", -1, strike=4900, dias=30, iv=18)
OUT [04]A badly written leg fails on creation
ValueError: iv 18: as a decimal fraction (0.18 is 18%)

The position adds up its legs. Everything it computes comes from a single function, griegas, which accepts a price, a volatility shift dvol (the same for all legs: a parallel shift) and dt elapsed days. Since bsm broadcasts, if S, dvol and dt are arrays of compatible shapes, the result is the whole mesh. The P&L is the market value minus what the position cost (negative if premium was collected); the payoff is the P&L at the position's first expiration, so that a calendar (lesson 3.5.3) is evaluated with the long leg still alive, valued with its volatility.

IN [05]python
GRIEGAS = ("delta", "gamma", "vega", "theta")


@dataclass
class Posicion:
    patas: list
    r: float           # continuous rate, decimal fraction
    q: float           # continuous dividend yield
    mult: float = 100  # USD per index point

    def griegas(self, S, dvol=0.0, dt=0.0):
        """Value and Greeks in USD with the underlying at S, the
        volatility of each leg shifted by dvol (decimal fraction)
        and dt calendar days elapsed. S, dvol and dt accept
        arrays that are combined by broadcasting."""
        S = np.asarray(S, dtype=float)
        tot = dict.fromkeys(("valor",) + GRIEGAS, 0.0)
        for p in self.patas:
            if p.tipo == "S":
                g = {"precio": S, "delta": 1.0, "gamma": 0.0,
                     "vega": 0.0, "theta": 0.0}
            else:
                vol = np.maximum(p.iv + np.asarray(dvol), 0.01)
                T = (p.dias - np.asarray(dt)) / AÑO
                g = bsm(p.tipo, S, p.strike, T, vol,
                        self.r, self.q)
            n = p.cantidad * self.mult
            tot["valor"] = tot["valor"] + n * g["precio"]
            for k in GRIEGAS:
                tot[k] = tot[k] + n * g[k]
        return tot

    def coste(self):
        return sum(p.cantidad * self.mult * p.prima
                   for p in self.patas)

    def pnl(self, S, dvol=0.0, dt=0.0):
        return self.griegas(S, dvol, dt)["valor"] - self.coste()

    def vencimiento(self):
        """Days to the position's first expiration."""
        return min(p.dias for p in self.patas if p.tipo != "S")

    def payoff(self, S):
        return self.pnl(S, dt=self.vencimiento())

Two deliberate simplifications. The S leg has no theta: the cost of financing the underlying and the dividend over a few days is ignored. And the volatility of each leg moves in parallel with dvol: the tool has no surface. Both are discussed at the end, against the data.

Tests before the data

A risk tool that nobody has tested is worse than having none: it gives numbers with the same confidence when they are wrong. The following tests are test_* functions with assert, the pytest format; here they run in a loop so that the output is deterministic, and in a project they would go in test_opciones.py and be run with pytest -q. Each one checks a property that does not depend on the data:

  • Put-call parity: C−P=Se−qT−Ke−rTC - P = S e^{-qT} - K e^{-rT} to 10⁻⁹ (lesson 3.4.1).
  • Synthetic: a long call and a short put with the same strike is a forward. Zero gamma and vega at any price and delta equal to 100 e−qT100\,e^{-qT}.
  • Finite differences: delta and gamma against central derivatives with half an index point; vega against the difference between ±0.5 volatility points (one point in total); theta against the difference between ±0.5 days. It checks the formula and the units at the same time.
  • Payoff: at expiration, the P&L of a long put is (max(K − S, 0) − premium) × 100.
  • QuantLib: the five quantities of the previous cell.
IN [06]python
def test_paridad_put_call():
    S, K, T, r, q = 5000.0, 4900.0, 0.25, 0.04, 0.015
    c = bsm("C", S, K, T, 0.2, r, q)["precio"]
    p = bsm("P", S, K, T, 0.2, r, q)["precio"]
    assert abs(c - p - (S * np.exp(-q * T)
                        - K * np.exp(-r * T))) < 1e-9


def test_sintetico_no_tiene_gamma_ni_vega():
    pos = Posicion([Pata("C", 1, 5000, 30, 0.2),
                    Pata("P", -1, 5000, 30, 0.2)], 0.04, 0.015)
    g = pos.griegas(np.array([4500.0, 5000.0, 5500.0]))
    assert np.allclose(g["gamma"], 0) and np.allclose(g["vega"], 0)
    assert np.allclose(g["delta"], 100 * np.exp(-0.015 * 30 / AÑO))


def test_griegas_frente_a_diferencias_finitas():
    pos = Posicion([Pata("P", -2, 4800, 45, 0.21, 40.0),
                    Pata("C", 1, 5200, 45, 0.15, 25.0)], 0.04, 0.015)
    S, h = 5000.0, 0.5
    g = pos.griegas(S)
    v = lambda s=S, dv=0.0, dt=0.0: pos.griegas(s, dv, dt)["valor"]
    cerca = lambda a, b: np.isclose(a, b, rtol=1e-4)
    # central derivatives: 1 index point, 1 vol point, 1 day
    assert cerca((v(S + h) - v(S - h)) / (2 * h), g["delta"])
    assert cerca((v(S + h) - 2 * v(S) + v(S - h)) / h**2, g["gamma"])
    assert cerca(v(dv=0.005) - v(dv=-0.005), g["vega"])
    assert cerca(v(dt=0.5) - v(dt=-0.5), g["theta"])


def test_payoff_al_vencimiento():
    pos = Posicion([Pata("P", 1, 4900, 30, 0.2, 50.0)], 0.04, 0.0)
    S = np.array([4000.0, 4900.0, 5500.0])
    assert np.allclose(pos.payoff(S),
                       100 * (np.maximum(4900 - S, 0) - 50))


def test_coincide_con_quantlib():
    for k in ref:
        assert abs(float(prueba[k]) - ref[k]) < 1e-6, k


for nombre, f in sorted(globals().copy().items()):
    if nombre.startswith("test_") and callable(f):
        f()
        print("ok ", nombre)
OUT [06]Tests
ok  test_coincide_con_quantlib
ok  test_griegas_frente_a_diferencias_finitas
ok  test_paridad_put_call
ok  test_payoff_al_vencimiento
ok  test_sintetico_no_tiene_gamma_ni_vega
pytest -q test_opciones.py

A real iron condor: the chain of February 19, 2020

With the tool tested, a position from the data. The daily chain opciones3_cadena.parquet has the bid, the ask and the model's true implied volatility (iv_modelo) of each ORX option. February 19, 2020 is the index high before the planted pandemic crash (−32% to March 23, per the data README): a good day to sell an iron condor and see what the tool says before and during the crisis. Legs are bought at the ask and sold at the bid, with the volatility of their strike:

IN [07]python
ind = pd.read_csv("opciones3_indice.csv", parse_dates=["fecha"])
ind = ind.set_index("fecha")
cad = pd.read_parquet("opciones3_cadena.parquet")
cad["medio"] = (cad["bid"] + cad["ask"]) / 2

hoy, venc = pd.Timestamp("2020-02-19"), pd.Timestamp("2020-03-20")
S0 = ind.at[hoy, "cierre"]
r0 = ind.at[hoy, "tipo_1m"] / 100
q0 = ind.at[hoy, "rentabilidad_dividendo"] / 100
dias = (venc - hoy).days
dia = cad[(cad["fecha"] == hoy) & (cad["vencimiento"] == venc)]
dia = dia.set_index(["tipo", "strike"])


def pata(tipo, strike, cantidad, fecha_cadena=dia):
    fila = fecha_cadena.loc[(tipo, strike)]
    precio = fila["ask"] if cantidad > 0 else fila["bid"]
    return Pata(tipo, cantidad, strike, dias, fila["iv_modelo"],
                precio)


condor = Posicion([pata("P", 2800, 1), pata("P", 2900, -1),
                   pata("C", 3050, -1), pata("C", 3100, 1)], r0, q0)
print(f"ORX {S0}  r {r0:.4f}  q {q0:.4f}  {dias} days")
pd.DataFrame(condor.patas).drop(columns="dias")
OUT [07]An iron condor on the 2020-02-19 chain
ORX 2995.33  r 0.0055  q 0.0168  30 days
  tipo  cantidad  strike      iv  prima
0    P         1    2800  0.1601    4.5
1    P        -1    2900  0.1288   11.8
2    C        -1    3050  0.0762    6.9
3    C         1    3100  0.0700    1.0

ORX closes at 2995.33, the one-month rate is 0.55% and the dividend yield 1.68%. Implied volatility ranges from 16.0% at the 2800 strike to 7.0% at the 3100: the skew of an index in calm (lesson 3.4.5). The condor sells the 2900 put and the 3050 call (deltas of −0.19 and +0.20) and buys the 2800 put and the 3100 call as wings:

IN [08]python
filas = {}
for p in condor.patas:
    g = Posicion([p], r0, q0).griegas(S0)
    filas[f"{p.cantidad:+.0f} {p.tipo} {p.strike:.0f}"] = g
tabla = pd.DataFrame(filas).T.astype(float)
tabla.loc["condor"] = tabla.sum()
tabla["delta_usd"] = tabla["delta"] * S0
print(f"Credit received: {-condor.coste():.0f} USD")
tabla.round(2)
OUT [08]Greeks per leg and aggregated
Credit received: 1320 USD
             valor  delta  gamma    vega  theta  delta_usd
+1 P 2800   436.58  -7.04   0.10  115.78 -31.54  -21089.86
-1 P 2900 -1193.66  19.21  -0.25 -234.45  52.08   57527.59
-1 C 3050  -699.47 -19.48  -0.42 -236.50  28.23  -58355.88
+1 C 3100    96.98   4.02   0.14   74.31  -8.30   12047.03
condor    -1359.57  -3.30  -0.43 -280.86  40.48   -9871.12

13.2 points are collected, 1,320 USD per condor. The table separates what each leg contributes. The condor's vega, −280.86 USD per point, comes almost entirely from the two short legs (−234 and −237), partly offset by the wings; the theta, +40.48 USD a day, is what the position collects each day if nothing moves. The aggregate delta, −3.30 USD per point (−9,871 USD of dollar delta, the equivalent of being short 3.3 units of ORX), is almost neutral: the deltas of the legs, of ±19 USD per point, offset each other. The valor column is the signed market value: −1,359.57 USD, somewhat more than the credit received because the entry sold at the bid and bought at the ask.

Payoff and P&L before expiration

IN [09]python
S = np.arange(2600.0, 3301.0, 5.0)
curvas = pd.DataFrame({"expiration": condor.payoff(S),
                       "today": condor.pnl(S),
                       "in 15 days": condor.pnl(S, dt=15)},
                      index=S)
alv = curvas["expiration"]
print(f"Max. gain {alv.max():.0f} · max. loss {alv.min():.0f}")
# breakeven points: linear interpolation where the sign changes
i = np.flatnonzero(np.diff(np.sign(alv.to_numpy())))
eq = [np.interp(0, alv.iloc[[j, j + 1]].sort_values(),
                alv.iloc[[j, j + 1]].sort_values().index) for j in i]
print("Breakeven at expiration:", np.round(eq, 1))
curvas.loc[[2700, 2850, 2900, 2950, 3000, 3050, 3100]].round(0)
OUT [09]Payoff at expiration and P&L before
Max. gain 1320 · max. loss -8680
Breakeven at expiration: [2886.8 3063.2]
        expiration   today  in 15 days
2700.0     -8680.0 -7290.0     -8113.0
2850.0     -3680.0 -2911.0     -3186.0
2900.0      1320.0 -1373.0     -1091.0
2950.0      1320.0  -306.0       305.0
3000.0      1320.0   -60.0       707.0
3050.0      1320.0  -813.0      -303.0
3100.0     -3680.0 -2107.0     -2243.0

The header figure draws the three columns across the whole range. At expiration, the condor gains the whole credit (+1,320 USD) between 2900 and 3050 and loses at most 8,680 USD below 2800 (100 points of width minus 13.2 of credit) and 3,680 above 3100 (50 of width minus 13.2). The breakeven points are strike ± credit: 2886.8 and 3063.2. The wings are not symmetric because the strikes were chosen by delta and the volatility of the short put (12.9%) is almost double that of the short call (7.6%).

Today's curve is something else: −60 USD with ORX at 3000, the bid-ask spread paid on entry, and below zero across the whole range. Making money with a short condor is a matter of time: at 15 days, with the same volatility, the curve already rises to +707 USD at 3000. That is theta, seen as a shift of the whole curve.

Aggregate Greeks across the price

A Greek at a single point does not say what happens if the index moves. The same function, with an array of prices, gives the full profile:

IN [10]python
Sg = np.array([2800.0, 2900.0, 2975.0, 3050.0, 3150.0])
perfil = pd.concat(
    {f"{d} days": pd.DataFrame(condor.griegas(Sg, dt=dias - d),
                               index=Sg)[list(GRIEGAS)]
     for d in (30, 5)}, axis=1)
perfil.round(1)
OUT [10]Aggregate Greeks across the price
       30 days                    5 days
         delta gamma   vega theta  delta gamma   vega  theta
2800.0    33.1   0.1  117.3 -39.1   49.0   0.7  122.1 -194.0
2900.0    27.2  -0.2 -104.7  10.2   47.0  -0.8 -112.0  141.1
2975.0     5.1  -0.4 -259.9  37.6    4.2  -0.2  -35.2   44.1
3050.0   -23.2  -0.2 -181.9  27.0  -47.2  -1.3 -123.6   91.2
3150.0   -14.1   0.2  126.2 -13.9   -2.6   0.2   22.3  -15.9
Fig. — Aggregate greeks of the iron condor against the ORX level, 30 and 5 days before expiry (delta in USD per point, vega in USD per volatility point): delta steepens near the short strikes and vega concentrates on them

At 30 days, the condor's delta moves smoothly between +33 and −25 USD per point. Five days before expiration the profile hardens: +70 at 2850 and −47 at 3050, with almost zero in the center. It is the gamma of the short legs concentrating around their strikes: five days before expiration, a 125-point fall (4%) takes delta from +4 to +70 USD per point, the equivalent of 70 units of the index. Vega changes sign with price: in the center the position is short volatility (−282 USD per point at 3000), but outside the wings it is long (+180 at 2750). With the index far below 2800, more volatility means more probability of coming back, and the put spread is worth less. The scenario grid will confirm it.

Explaining one day's P&L

The daily control of an options portfolio is P&L explain: breaking down what was gained or lost with the previous day's Greeks and seeing how much remains unexplained. With the second-order Taylor approximation in price and first-order in volatility and time,

ΔV≈δ ΔS+12 Γ (ΔS)2+ν Δσ+θ Δt\Delta V \approx \delta\,\Delta S + \tfrac{1}{2}\,\Gamma\,(\Delta S)^2 + \nu\,\Delta\sigma + \theta\,\Delta t

with Δσ\Delta\sigma in points and Δt\Delta t in calendar days. The function uses the volatility of each strike on each date (not a common shift) and compares with the real P&L at mid prices:

IN [11]python
def explica(pos, fecha, fecha_cad):
    """One day's P&L of the position: real (at mid prices) and
    its Taylor approximation with the previous day's Greeks."""
    s1 = ind.at[fecha, "cierre"]
    dt = (fecha - hoy).days
    real = 0.0
    comp = dict.fromkeys(["delta", "gamma", "vega", "theta"], 0.0)
    for p in pos.patas:
        g = bsm(p.tipo, S0, p.strike, p.dias / AÑO, p.iv, r0, q0)
        fila = fecha_cad.loc[(p.tipo, p.strike)]
        n = p.cantidad * pos.mult
        dS, dvol = s1 - S0, (fila["iv_modelo"] - p.iv) * 100
        comp["delta"] += n * g["delta"] * dS
        comp["gamma"] += n * 0.5 * g["gamma"] * dS**2
        comp["vega"] += n * g["vega"] * dvol
        comp["theta"] += n * g["theta"] * dt
        real += n * (fila["medio"] - dia.at[(p.tipo, p.strike),
                                            "medio"])
    comp["sum"] = sum(comp.values())
    comp["real"] = real
    return pd.Series(comp).round(0)


f1 = pd.Timestamp("2020-02-20")
cad1 = cad[(cad["fecha"] == f1) & (cad["vencimiento"] == venc)]
cad1 = cad1.set_index(["tipo", "strike"])
print(f"ORX {S0} to {ind.at[f1, 'cierre']}")
explica(condor, f1, cad1)
OUT [11]One day's explained P&L
ORX 2995.33 to 2998.58
delta    -11.0
gamma     -2.0
vega    -406.0
theta     40.0
sum     -379.0
real    -372.0
dtype: float64
Fig. — One-day P&L of the iron condor (2020-02-19 to 20) explained with the previous day's greeks: the index rises 3 points and the position loses through vega

On February 20 ORX rises 3.25 points and the condor loses 372 USD. Delta (−11 USD) and gamma (−2) barely count, theta contributes +40, and vega explains almost everything: −406 USD, because the implied volatility of the four strikes rose between 1.1 and 1.7 points with the index almost flat. The sum, −379, is 7 USD away from the real P&L. It is the first sign of the crisis in the data: the index has not moved, but the surface has. A short vega position can lose money on a day the index rises.

Scenario grid: price × volatility × time

Greeks are valid for small moves. For large ones, the whole position has to be revalued in each scenario. The grid combines index moves, parallel volatility shifts and elapsed days in a three-dimensional mesh and evaluates it with a single call to pnl:

IN [12]python
def rejilla(pos, S0, movs, dvols, dts):
    """P&L in USD for each combination (3D broadcasting)."""
    m, v, t = np.meshgrid(movs, dvols, dts, indexing="ij")
    pnl = pos.pnl(S0 * (1 + m), v, t)
    idx = pd.MultiIndex.from_arrays(
        [m.ravel(), v.ravel(), t.ravel()],
        names=["mov", "dvol", "dt"])
    return pd.Series(pnl.ravel(), idx)


movs = np.array([-0.20, -0.10, -0.05, -0.02, 0, 0.02, 0.05])
dvols = np.array([-0.03, 0.0, 0.05, 0.10, 0.25])
esc = rejilla(condor, S0, movs, dvols, [0, 10, 29])
tabla10 = esc.xs(10, level="dt").unstack("dvol")
tabla10.index = [f"{x:+.0%}" for x in tabla10.index]
tabla10.columns = [f"{x * 100:+.0f} pt" for x in tabla10.columns]
tabla10.round(0)
OUT [12]Scenario grid: price × volatility × time
       -3 pt   +0 pt   +5 pt  +10 pt  +25 pt
-20% -8677.0 -8677.0 -8674.0 -8652.0 -8268.0
-10% -8269.0 -7885.0 -7192.0 -6590.0 -5663.0
-5%  -3359.0 -3260.0 -3202.0 -3305.0 -3949.0
-2%    221.0  -237.0 -1065.0 -1837.0 -3255.0
+0%   1043.0   433.0  -691.0 -1541.0 -2991.0
+2%   -132.0  -661.0 -1269.0 -1773.0 -2875.0
+5%  -3591.0 -3222.0 -2744.0 -2590.0 -2912.0
Fig. — Scenario grid of the iron condor at 10 days: P&L in USD by ORX move and parallel volatility shift, in four bands

The table and the figure are the grid at 10 days. With the index and volatility unchanged the condor gains 433 USD (ten days of theta minus the bid-ask spread); if volatility also drops 3 points, 1,043. It only takes a 5-point rise to lose 691 with the index still, and 2,991 if it rises 25. In the figure only 8 of the 152 cells gain: moves from −2% to +2% with volatility equal or lower. It is the profile of a premium sale: the gain is narrow and the loss, wide.

The table also shows the positive vega of the wings. With the index 10% lower, the loss is 7,885 USD with volatility unchanged and 5,663 with 25 points more: the put spread is so deep in the money that more volatility makes it cheaper. At −20% the loss is the maximum, 8,677, whatever volatility does.

The grid versus what happened

The test of a scenario grid is to compare it with reality. Three dates from the crash: for each one, the grid scenario with the actual index move and a parallel shift equal to the change in at-the-money volatility; the same position revalued with the volatility each of its strikes had that day; and the real P&L at mid prices.

IN [13]python
from dataclasses import replace


def marca_real(pos, fecha):
    """Real P&L at mid prices from that date's chain."""
    g = cad[(cad["fecha"] == fecha) & (cad["vencimiento"] == venc)]
    g = g.set_index(["tipo", "strike"])["medio"]
    return sum(p.cantidad * pos.mult * g[(p.tipo, p.strike)]
               for p in pos.patas) - pos.coste()


atm0 = dia.at[("P", 3000), "iv_modelo"]
filas = []
for f in ["2020-02-28", "2020-03-09", "2020-03-16"]:
    f = pd.Timestamp(f)
    s1 = ind.at[f, "cierre"]
    g = cad[(cad["fecha"] == f) & (cad["vencimiento"] == venc)]
    g = g.set_index(["tipo", "strike"])
    # listed strike closest to the money
    k = g.xs("P").index[np.abs(g.xs("P").index - s1).argmin()]
    datm = g.at[("P", k), "iv_modelo"] - atm0
    dt = (f - hoy).days
    # the same position with the iv each strike has that day
    con_iv = Posicion([replace(p, iv=g.at[(p.tipo, p.strike),
                                          "iv_modelo"])
                       for p in condor.patas], r0, q0)
    filas.append({"fecha": f.date(), "mov": s1 / S0 - 1,
                  "dvol_atm": datm * 100,
                  "rejilla": condor.pnl(s1, datm, dt),
                  "iv_por_strike": con_iv.pnl(s1, 0.0, dt),
                  "real": marca_real(condor, f)})
pd.DataFrame(filas).set_index("fecha").round(
    {"mov": 3, "dvol_atm": 1, "rejilla": 0, "iv_por_strike": 0,
     "real": 0})
OUT [13]The grid versus what happened
              mov  dvol_atm  rejilla  iv_por_strike    real
fecha
2020-02-28 -0.021       8.8  -1753.0        -1258.0 -1258.0
2020-03-09 -0.064      37.9  -4611.0        -4126.0 -4130.0
2020-03-16 -0.326      62.9  -8679.0        -8679.0 -9070.0

On February 28 ORX has fallen 2.1% and at-the-money volatility has risen 8.8 points. The grid predicts −1,753 USD; with the volatility of each strike, −1,258, the same as the real P&L. The difference does not come from the valuation model but from the scenario: that day's “at-the-money” volatility is that of the 2950 strike, lower than the 3000 of the entry day and with more volatility because of the skew. The position's strikes rose between 7.1 and 8.7 points, not 8.8. A parallel-shift scenario mixes the change in the surface with the slide along the skew (sticky strike versus sticky moneyness, lesson 3.4.5). On March 9 the same happens: −4,611 versus −4,130.

On March 16, with ORX 32.6% lower, the grid and the per-strike revaluation agree on the maximum loss, −8,679, but the P&L at mid prices is −9,070: 391 USD more than the maximum possible loss at expiration. The 2900 put is quoted at 874.2/894.1 and the 2800 at 771.4/789.1: bid-ask spreads of almost 20 points, with noise in the mid. The mid of the put spread is 103.9 points, above the 100 of width, something that cannot last until expiration. Closing that day at market (buying the 2900 at the ask and selling the 2800 at the bid) would cost 122.7 points. Lesson 3.5.5 returns to this decision.

What is missing and how to extend it

The tool fits in about 150 lines because it leaves many things out, and it is worth knowing which:

  • Volatility surface. dvol is a number; a better version would take a function of strike and expiration (for example, the SVI from lesson 3.4.5) and revalue each leg with its volatility in the scenario.
  • American options and discrete dividends. Stock options need the binomial tree (3.4.2) and its exercise boundary; lesson 3.5.5 uses it with the KRN stock.
  • Several underlyings. Greeks of legs on different underlyings do not add up without a correlation model (beta, or joint scenarios).
  • Costs. P&L is computed at mid prices; the exit bid-ask spread is a real cost, larger in a crisis.

With those limitations in view, the tool already serves what it is used for in the rest of the module: drawing the payoff of any structure, monitoring the Greeks of a portfolio and running scenarios on it before opening it.

Exercises

  1. Delta hedge with the S leg. Add to the 2020-02-19 condor an S leg with the quantity that brings the aggregate delta to zero and check with griegas that delta is zero at 2995.33 and that gamma and vega do not change.
Solution

The condor's delta is −3.30 USD per point; an S leg contributes quantity × 100 per point, so you need to buy 0.033 contract units (3.3 units of the index). Gamma, vega and theta do not change because the underlying has none.

IN [14]python
d0 = condor.griegas(S0)["delta"]
cubierto = Posicion(condor.patas + [Pata("S", -d0 / 100, prima=S0)],
                    r0, q0)
g1 = cubierto.griegas(S0)
print({k: round(float(g1[k]), 4) for k in GRIEGAS})
OUT [14]Delta-hedged condor
{'delta': 0.0, 'gamma': -0.4257, 'vega': -280.8596, 'theta': 40.4759}
  1. A calendar. With the same day's chain, sell the 2950 put of the 2020-03-20 expiration and buy the one of 2020-04-17. Compute its payoff at the first expiration (with the long leg valued with its volatility) and its aggregate vega. Why does the payoff of a calendar depend on volatility and that of a condor does not?
Solution

The long leg is still alive at the first expiration, so its value depends on the volatility it has then. The calendar's vega is positive (+145.2 USD per point today): the far option has more vega than the near one. At the first expiration, with ORX at 2950, the calendar gains 2,180 USD with volatility unchanged and 3,807 if it rises 5 points; at 2800 and 3100 it loses between 1,297 and 1,448 USD (675 and 693 with 5 points more). In a condor all legs expire at the same time and the payoff depends only on the price.

IN [15]python
v2 = pd.Timestamp("2020-04-17")
lejos = cad[(cad["fecha"] == hoy) & (cad["vencimiento"] == v2)]
lejos = lejos.set_index(["tipo", "strike"])
f_l = lejos.loc[("P", 2950.0)]
cal = Posicion([pata("P", 2950, -1),
                Pata("P", 1, 2950, (v2 - hoy).days, f_l["iv_modelo"],
                     f_l["ask"])], r0, q0)
Sc = np.array([2800.0, 2900.0, 2950.0, 3000.0, 3100.0])
print("payoff:", cal.payoff(Sc).round(0))
print("payoff with +5 pt:",
      cal.pnl(Sc, 0.05, cal.vencimiento()).round(0))
print("vega today:", round(float(cal.griegas(S0)["vega"]), 1))
OUT [15]2950 put calendar
payoff: [-1297.   214.  2180.   174. -1448.]
payoff with +5 pt: [-675. 1661. 3807. 1680. -693.]
vega today: 145.2
  1. Scenario with skew. Replace the parallel shift with one that depends on the strike: dvol of each leg equal to 0.088 + 0.0003 × (3000 − K) for 2020-02-28. Does the P&L get closer to the real one (−1,258 USD)? Which parameter would you be fitting after the fact?
Solution

With that shift the 2800 leg rises 14.8 points and the 3100 one 5.8: the scenario exaggerates the steepening of the skew, because in the data the four legs rose between 7.1 and 8.7 points. The 0.0003 coefficient was chosen after seeing the result: it is fitting the scenario to the data point. A useful stress scenario sets the shape of the shift with past episodes (for example, the average slope of the change in volatility by strike in the falls of the sample) and applies it to dates that were not used to estimate it.

Each answer is folded under its question

Self-assessment

  1. The tool returns a vega of −280.86 for an iron condor. What does it mean, with the conventions of the lesson?

    • AThat the position loses 280.86 USD if the implied volatility of all legs rises one point (from 12% to 13%, for example)
    • BThat it loses 280.86 USD if volatility rises from 12% to 112%
    • CThat it loses 280.86 index points per day
    • DThat it loses 2.8086 USD per contract and per point
    Show answer

    Answer A. That the position loses 280.86 USD if the implied volatility of all legs rises one point (from 12% to 13%, for example)

    The vega of bsm is divided by 100 (per volatility point, not per unit) and the position multiplies it by quantity × 100 USD per index point. It is a linear sensitivity: for large changes in volatility you have to revalue (the grid), not multiply.

  2. On 2020-02-20 ORX rises 3.25 points and the short condor loses 372 USD. Which term of the Greek-based explanation accounts for almost all of it?

    • ADelta
    • BGamma
    • CVega: the implied volatility of the four strikes rose between 1.1 and 1.7 points with the index almost flat
    • DTheta
    Show answer

    Answer C. Vega: the implied volatility of the four strikes rose between 1.1 and 1.7 points with the index almost flat

    Delta −11 and gamma −2 USD are small because the index barely moved; theta adds +40. Vega contributes −406 USD. A short volatility position can lose on a day the index rises if implied volatility rises.

  3. Why does the finite-difference test use central derivatives with one index point, one volatility point and one day?

    • ABecause it is faster than the analytical formula
    • BBecause it checks the formula and the units at the same time: if vega were per unit of volatility or theta per year, the comparison would fail by a factor of 100 or 365
    • CBecause QuantLib does not provide Greeks
    • DBecause analytical Greeks are only valid at the money
    Show answer

    Answer B. Because it checks the formula and the units at the same time: if vega were per unit of volatility or theta per year, the comparison would fail by a factor of 100 or 365

    The central difference between dvol = +0.005 and −0.005 is the change in value for one volatility point; between dt = +0.5 and −0.5 days, the change for one day. If they match vega and theta, the units are the ones the documentation states.

  4. The grid with a parallel shift equal to the change in ATM volatility predicts −1,753 USD for 2020-02-28; the position revalued with the volatility of each strike gives −1,258. Where does the difference come from?

    • AFrom an error in the Black-Scholes formula
    • BFrom the bid-ask spread
    • CFrom the ATM volatility of that day being that of another strike, lower and with more volatility because of the skew: the position's strikes rose 7 points, not 8.8
    • DFrom dividends
    Show answer

    Answer C. From the ATM volatility of that day being that of another strike, lower and with more volatility because of the skew: the position's strikes rose 7 points, not 8.8

    With the index 2.1% lower, the at-the-money strike is 2950, not 3000. ATM volatility mixes the movement of the surface with the shift along the skew. The parallel grid is a useful approximation, but not a surface.

  5. On 2020-03-16 the position marked at mid prices loses 9,070 USD, more than the maximum loss at expiration (8,680). What is happening?

    • AThe tool computes the maximum loss incorrectly
    • BThe mid of two deep in-the-money puts with bid-ask spreads of almost 20 points does not respect the bound of the spread: mid-price valuation in a crisis is noisy, and closing at bid/ask would cost even more
    • CThe index kept falling after expiration
    • DDividends increase the loss
    Show answer

    Answer B. The mid of two deep in-the-money puts with bid-ask spreads of almost 20 points does not respect the bound of the spread: mid-price valuation in a crisis is noisy, and closing at bid/ask would cost even more

    The 2900 put is quoted at 874.2/894.1 and the 2800 at 771.4/789.1: the mid of the spread is 103.9 points, above the 100 of width. At expiration (European, cash-settled) the loss stays at 8,680; closing that day buying at the ask and selling at the bid would cost 122.7 points.

Key points

  • An options tool starts with the conventions: price per unit and per contract, T in calendar days / 365, continuous rates, vega per point and theta per calendar day. They are written into the code and tested.
  • A position is a list of legs; value, Greeks, payoff and P&L are per-leg sums with broadcasting, so a grid of thousands of scenarios is a single call.
  • Tests (parity, synthetics, finite differences, QuantLib) come before the data: they catch sign and unit errors that a chart does not show.
  • Explaining each day's P&L with the previous day's Greeks is the daily control of an options portfolio: what the Greeks do not explain is a jump, a change in the shape of the surface or an error.
  • A grid with parallel volatility shifts is an approximation: it does not capture the skew or the dynamics of the surface, and mid-price valuation in a crisis carries bid-ask noise.

Resources

Data