---
title: "3.5.4 Option payoff, Greeks and scenario tool"
description: "A custom Python options tool: payoff, aggregate Greeks, explained P&L and a price × volatility × time scenario grid, with tests."
url: "https://pythonparatrading.com/en/python-trading-course/level-3/option-payoff-greeks-scenario-tool/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-3/option-payoff-greeks-scenario-tool.png"
---

# 3.5.4 · 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.

- Course: [Level 3: Derivatives: futures, spreads, forex and options](https://pythonparatrading.com/en/python-trading-course/level-3/)
- Module: 3.5 · Option structures in Python
- Duration: 70 min
- Prerequisites: [3.4.2 · Binomial trees, Black-Scholes-Merton, Black-76 and Monte Carlo](https://pythonparatrading.com/en/python-trading-course/level-3/binomial-black-scholes-black-76-monte-carlo/), [3.4.3 · Greeks: delta, gamma, vega, theta, rho, vanna, volga and charm](https://pythonparatrading.com/en/python-trading-course/level-3/first-and-second-order-option-greeks/), [3.5.2 · Volatility and range structures: straddles, strangles, condors and butterflies](https://pythonparatrading.com/en/python-trading-course/level-3/straddles-strangles-condors-butterflies/)

## By the end of this lesson

- Write down the conventions of an options tool (price per unit and per contract, T in calendar days / 365, continuous rates, vega per point and theta per calendar day) and check them against QuantLib.
- Represent a position as a list of legs and compute its payoff at expiration, its P&L before expiration and its aggregate Greeks in USD with `numpy` and broadcasting.
- Write pytest-style tests (put-call parity, synthetics, finite differences) before using the tool with data.
- Explain one day's P&L with the Taylor approximation by Greeks and recognize when vega dominates.
- Build a price × volatility × time scenario grid and compare it with what happened, separating the error of the parallel shift from the noise of mid prices.

## 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.

```mermaid
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:

| Quantity | Tool convention | Common mistake |
|---|---|---|
| Price | Per unit of the underlying (ORX points); the position multiplies by 100 USD per point | Mixing price per share with price per contract |
| Time | T = calendar days to expiration / 365 | Using sessions / 252 in the formula and calendar days in theta |
| Risk-free rate and dividends | Continuous, as a decimal fraction (`tipo_1m` / 100) | Passing 0.55 instead of 0.0055 |
| Volatility | Annual, as a decimal fraction (0.18 is 18%) | Passing 18 |
| Delta | USD per index point in the position (quantity × 100 × delta) | Confusing it with dollar delta (× S) |
| Vega | USD per volatility point (per 0.01) | Per unit of volatility (100 times more) |
| Theta | USD per calendar day | Per 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\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}}
$$

```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()}
```
```output
{'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):

```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)
```
```output
              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.

```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%)")
```

```python raises
Pata("P", -1, strike=4900, dias=30, iv=18)
```
```output
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.

```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 = 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^{-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.

```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)
```
```output
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
```

```bash
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:

```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")
```
```output
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:

```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)
```
```output
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

```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)
```
```output
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:

```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)
```
```output
       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.* Two panels with ORX from 2700 to 3250 across. Top, delta in USD per point: at 30 days it is smooth, between +33 and −25; at 5 days (highlighted) it reaches +70 at 2850 and −47 between 3050 and 3100, and is almost zero in the centre (−1.9 at 3000). Bottom, vega in USD per volatility point: at 30 days, −282 at 3000 and positive outside the wings (up to +180 at 2750 and +126 at 3150); at 5 days it is smaller and concentrated next to each strike.

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,

$$
\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 $\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:

```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)
```
```output
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.* Horizontal bars in USD: delta −11, gamma −2, vega −406 (highlighted), theta +40; the sum of the four terms, −379, against the actual P&L at mid prices, −372. ORX moves from 2995.33 to 2998.58 and the implied volatility of the four strikes rises by 1.1 to 1.7 points.

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.

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

What remains unexplained is as informative as what is explained. If the residual is large on a given day, either there
is a jump (the second-order approximation does not hold for moves of several standard deviations), or the surface has
changed shape (skew, term structure), or there is an error in the data or in the position. On a trading desk, an
unexplained residual is investigated the same day.

</aside>

## 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`:

```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)
```
```output
       -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.* Map of 19 ORX moves (from −12% to +6%) by 8 volatility changes (from −5 to +30 points), at 10 days. Only 8 of the 152 cells gain: moves of −2% to +2% with volatility unchanged or lower. 19 lose less than 2000 USD, 84 between 2000 and 5000 and 41 more than 5000, mostly with falls of 7% or more. Highlighted, the cell closest to what happened on 2020-02-28 (−2% and +10 points): −1837 USD.

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.

```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})
```
```output
              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.

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

A grid with parallel shifts is the minimum, not the maximum. It does not capture that in a fall volatility rises more
at the low strikes, nor one-day jumps (on March 16, 2020 the index falls 12.8% in one session), nor liquidity: in a
crisis bid-ask spreads widen and mid-price valuation stops being an executable price. Real stress scenarios are built
with historical episodes of the entire surface (lesson 3.6.1).

</aside>

## 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.

```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})
```
```output
{'delta': 0.0, 'gamma': -0.4257, 'vega': -280.8596, 'theta': 40.4759}
```

2. **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.

```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))
```
```output
payoff: [-1297.   214.  2180.   174. -1448.]
payoff with +5 pt: [-675. 1661. 3807. 1680. -693.]
vega today: 145.2
```

3. **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.

## 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?

   - A. That the position loses 280.86 USD if the implied volatility of all legs rises one point (from 12% to 13%, for example)
   - B. That it loses 280.86 USD if volatility rises from 12% to 112%
   - C. That it loses 280.86 index points per day
   - D. That it loses 2.8086 USD per contract and per point

   *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?

   - A. Delta
   - B. Gamma
   - C. Vega: the implied volatility of the four strikes rose between 1.1 and 1.7 points with the index almost flat
   - D. Theta

   *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?

   - A. Because it is faster than the analytical formula
   - 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
   - C. Because QuantLib does not provide Greeks
   - D. Because analytical Greeks are only valid at the money

   *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?

   - A. From an error in the Black-Scholes formula
   - B. From the bid-ask spread
   - 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
   - D. From dividends

   *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?

   - A. The tool computes the maximum loss incorrectly
   - 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
   - C. The index kept falling after expiration
   - D. Dividends increase the loss

   *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

### Research

- [Uses of Volatility and Current Volatility - VIX1D](https://pythonparatrading.com/en/uses-of-volatility/) · Research-0003 · 5 min

### Documentation

- [Cboe: S&P 500 Index Options (SPX) specifications](https://www.cboe.com/tradable_products/sp_500/spx_options/specifications/) · cboe.com
- [QuantLib-Python: pricing engines (AnalyticEuropeanEngine)](https://quantlib-python-docs.readthedocs.io/en/latest/pricing_engines.html) · quantlib-python-docs.readthedocs.io
- [NumPy: broadcasting](https://numpy.org/doc/stable/user/basics.broadcasting.html) · numpy.org
- [pytest: documentation](https://docs.pytest.org/en/stable/) · docs.pytest.org

### Books

- [Sheldon Natenberg, Option Volatility and Pricing, 2nd ed. (McGraw-Hill, 2015)](https://www.mheducation.com/highered/mhp/product/option-volatility-pricing-advanced-trading-strategies-techniques-2nd-edition.html) · mheducation.com

### Data

- [Level 3 data: daily options chain of ORX (Parquet)](https://pythonparatrading.com/curso/datos/nivel-3/opciones3_cadena.parquet) · Download · opciones3_cadena.parquet

Previous lesson: [3.5.3 · Calendars, diagonals, ratios, backspreads, risk reversals and jade lizard](https://pythonparatrading.com/en/python-trading-course/level-3/calendars-diagonals-ratios-risk-reversals/)

Next lesson: [3.5.5 · Management: adjustments, rolls, early closes, early assignment and pin risk](https://pythonparatrading.com/en/python-trading-course/level-3/managing-option-positions/)
