Lección 3.5.4 · 70 min
Herramienta propia: payoff, griegas agregadas y escenarios
Una posición con cuatro patas ya no cabe en la cabeza: hay que poder calcular su valor, sus griegas y su P&L en cualquier escenario. Esta lección construye esa herramienta desde cero en unas 150 líneas, la prueba contra QuantLib y contra sus propias derivadas, y la usa con un iron condor abierto en la cadena sintética el 19 de febrero de 2020, justo antes de la caída.
- Módulo
- 3.5 · Estructuras de opciones en Python
- Duración
- 70 min
Lecciones del nivel 3
3.1 · Futuros en profundidad
3.2 · Spreads de futuros
- 3.2.1 Spreads de calendario, butterflies y condors
- 3.2.2 Spreads intercommodity: crack, spark y crush
- 3.2.3 Spreads de tipos y de índices: 2s10s, NOB, NQ/ES y Europa frente a EE. UU.
- 3.2.4 Ratios de cobertura: por nominal, por DV01 y por volatilidad
- 3.2.5 Datos y backtesting de spreads: cotizados, sintéticos y por patas
- 3.2.6 Estacionalidad de spreads: análisis multi-año, patrones y trampas
3.3 · Forex avanzado
3.4 · Teoría y valoración de opciones
- 3.4.1 Payoffs, paridad put-call y límites de arbitraje
- 3.4.2 Árboles binomiales, Black-Scholes-Merton, Black-76 y Monte Carlo
- 3.4.3 Griegas: delta, gamma, vega, theta, rho, vanna, volga y charm
- 3.4.4 Volatilidad implícita: Newton-Raphson, Brent, py_vollib y QuantLib
- 3.4.5 Sonrisa, skew y estructura temporal; superficie de volatilidad con SVI
- 3.4.6 Volatilidad realizada frente a implícita; prima de riesgo de volatilidad; VIX y VSTOXX
- 3.4.7 Modelos avanzados: Heston, SABR y volatilidad local
3.5 · Estructuras de opciones en Python
- 3.5.1 Estructuras direccionales y verticales: call, put, covered call, collar y spreads
- 3.5.2 Estructuras de volatilidad y rango: straddles, strangles, cóndores y mariposas
- 3.5.3 Calendarios, diagonales, ratios, backspreads, risk reversals y jade lizard
- 3.5.4 Herramienta propia: payoff, griegas agregadas y escenarios
- 3.5.5 Gestión: ajustes, roll, cierre anticipado, asignación temprana y pin risk
3.6 · Estrategias sistemáticas de volatilidad y opciones
3.7 · Cripto derivados y estrategias específicas
Qué tiene que hacer la herramienta
Las lecciones 3.5.1 a 3.5.3 dibujaron estructuras de dos, tres y cuatro patas. Para operarlas hace falta algo más que un dibujo: saber cuánto vale la posición si el índice cae un 5 % mañana, cuánto pierde si la volatilidad sube diez puntos, qué parte del P&L de ayer se debe a cada griega y cuándo deja de ser fiable la aproximación lineal. Las plataformas de los brókers enseñan una parte; una herramienta propia permite ponerla en un backtest, en un control de riesgo diario o en una alerta, con las convenciones que uno ha elegido y probado.
La de esta lección hace cinco cosas, y nada más:
- Valora una opción europea y sus griegas con Black-Scholes-Merton (lección 3.4.2), vectorizado.
- Agrega patas en una posición: valor, delta, gamma, vega y theta en USD.
- Calcula el payoff al vencimiento y el P&L antes del vencimiento en cualquier precio, volatilidad y fecha.
- Explica el P&L de un día con las griegas de la víspera.
- Calcula una rejilla de escenarios precio × volatilidad × tiempo de una sola vez.
Fuente Mermaid
flowchart TB C[Cadena del día] --> P[Pata] P --> Q[Posicion] B[bsm] --> Q Q --> G[Griegas en USD] Q --> L[P&L y payoff] G --> E[P&L explicado] L --> R[Rejilla] T[Pruebas] -.-> B T -.-> Q
Antes del código, las convenciones. La mitad de los errores con opciones son de unidades, no de modelo:
| Magnitud | Convención de la herramienta | Error típico |
|---|---|---|
| Precio | Por unidad del subyacente (puntos de ORX); la posición multiplica por 100 USD por punto | Mezclar precio por acción con precio por contrato |
| Tiempo | T = días naturales hasta el vencimiento / 365 | Usar sesiones / 252 en la fórmula y días naturales en la theta |
| Tipo libre y dividendos | Continuos, en tanto por uno (tipo_1m / 100) | Pasar 0,55 en vez de 0,0055 |
| Volatilidad | Anual, en tanto por uno (0,18 es un 18 %) | Pasar 18 |
| Delta | USD por punto del índice en la posición (cantidad × 100 × delta) | Confundirla con la delta en USD (× S) |
| Vega | USD por punto de volatilidad (por 0,01) | Por unidad de volatilidad (100 veces más) |
| Theta | USD por día natural | Por año (365 veces más) o por sesión |
Las opciones de ORX imitan a las SPX: europeas, liquidación en efectivo y 100 USD por punto. En las SPX reales el multiplicador también es 100 y el estilo es europeo, como dice la ficha de Cboe; las mensuales se liquidan con un valor calculado con los precios de apertura del día del vencimiento (en los datos del curso, al cierre, por simplicidad). Una herramienta para opciones americanas sobre acciones necesita otro motor (el binomial de 3.4.2); la estructura de patas y posición es la misma.
El motor: Black-Scholes-Merton vectorizado
La función bsm devuelve precio y griegas de una opción europea con rendimiento por dividendo continuo. Acepta arrays
en S, K, T y vol y los combina por broadcasting: con un array de 141 precios devuelve 141 precios, y con una
malla de 7 × 5 × 3 escenarios, una malla. Un solo signo w (+1 call, −1 put) evita duplicar las fórmulas:
import numpy as np
import pandas as pd
from scipy.special import ndtr
AÑO = 365.0 # T = días naturales / 365
def bsm(tipo, S, K, T, vol, r, q):
"""Precio y griegas de una opción europea, por unidad.
T en años, r y q continuos, vol en tanto por uno. Vega por
punto de volatilidad; theta por día natural. Con T <= 0
devuelve el valor intrínseco."""
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()}{'precio': 56.0149, 'delta': -0.3234, 'gamma': 0.0014, 'vega': 5.145, 'theta': -1.4266}La put de prueba (S = 5000, K = 4900, 30 días, 18 %, r = 4 %, q = 1,5 %) vale 56,01 puntos, 5.601 USD por contrato.
Su delta, −0,3234, dice que gana 0,32 puntos por cada punto que cae el índice; su vega, 5,145, que gana 5,15 puntos
(514,5 USD por contrato) si la volatilidad sube del 18 % al 19 %; su theta, −1,4266, que pierde 1,43 puntos por día
natural con todo lo demás igual. Con T <= 0 devuelve el valor intrínseco y griegas nulas (delta 0 o ±1): es lo que
permite calcular el payoff con la misma función.
Antes de fiarse de ella, se compara con una implementación independiente. QuantLib (lección 3.4.4) tiene un motor
analítico para la misma opción; la única precaución es darle el mismo convenio de tiempo (Actual365Fixed) y recordar
que su vega() es por unidad de volatilidad y su theta() por año (thetaPerDay() divide entre 365):
import QuantLib as ql
fecha_ql = ql.Date(2, 1, 2025)
ql.Settings.instance().evaluationDate = fecha_ql
dc = ql.Actual365Fixed() # el mismo T = días / 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({"propia": {k: float(v) for k, v in prueba.items()},
"QuantLib": ref}).assign(
dif=lambda t: t["propia"] - t["QuantLib"]).round(6) propia 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.0Coinciden hasta la sexta cifra decimal en las cinco magnitudes. Si la theta de QuantLib se hubiera comparado con
theta() en vez de thetaPerDay(), la diferencia sería de 365 veces: es el tipo de error que esta comparación
existe para atrapar.
Patas y posición
Una pata es un registro inmutable: tipo, cantidad con signo, strike, días hasta el vencimiento, volatilidad y precio
de entrada. El tipo S representa el propio subyacente (para coberturas delta). La validación al crearla detiene los
dos errores de convención más frecuentes: un tipo que no existe y una volatilidad escrita en porcentaje.
from dataclasses import dataclass
@dataclass(frozen=True)
class Pata:
tipo: str # "C", "P" o "S" (el subyacente)
cantidad: float # contratos: + comprados, - vendidos
strike: float = 0.0
dias: float = 0.0 # días naturales hasta el vencimiento
iv: float = 0.0 # volatilidad de la pata, tanto por uno
prima: float = 0.0 # precio de entrada por unidad
def __post_init__(self):
if self.tipo not in ("C", "P", "S"):
raise ValueError(f"tipo {self.tipo!r}: usa C, P o S")
if self.tipo == "S":
return
if self.strike <= 0:
raise ValueError("una opción necesita strike > 0")
if not 0 < self.iv < 3:
raise ValueError(f"iv {self.iv}: en tanto por uno "
"(0.18 es un 18 %)")Pata("P", -1, strike=4900, dias=30, iv=18)ValueError: iv 18: en tanto por uno (0.18 es un 18 %)La posición suma sus patas. Todo lo que calcula sale de una sola función, griegas, que acepta un precio, un
desplazamiento de volatilidad dvol (el mismo para todas las patas: un desplazamiento paralelo) y dt días
transcurridos. Como bsm hace broadcasting, si S, dvol y dt son arrays de formas compatibles, el resultado es la
malla entera. El P&L es el valor de mercado menos lo que costó la posición (negativo si se cobró prima); el payoff es
el P&L en el primer vencimiento de la posición, de modo que un calendario (lección 3.5.3) se evalúa con la pata larga
todavía viva, valorada con su volatilidad.
GRIEGAS = ("delta", "gamma", "vega", "theta")
@dataclass
class Posicion:
patas: list
r: float # tipo continuo, tanto por uno
q: float # rentabilidad por dividendo continua
mult: float = 100 # USD por punto del índice
def griegas(self, S, dvol=0.0, dt=0.0):
"""Valor y griegas en USD con el subyacente en S, la
volatilidad de cada pata desplazada dvol (tanto por uno)
y dt días naturales transcurridos. S, dvol y dt admiten
arrays que se combinan por 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):
"""Días hasta el primer vencimiento de la posición."""
return min(p.dias for p in self.patas if p.tipo != "S")
def payoff(self, S):
return self.pnl(S, dt=self.vencimiento())Dos simplificaciones deliberadas. La pata S no tiene theta: se ignora el coste de financiar el subyacente y el
dividendo durante unos días. Y la volatilidad de cada pata se mueve en paralelo con dvol: la herramienta no tiene
superficie. Las dos se discuten al final, contra los datos.
Pruebas antes de los datos
Una herramienta de riesgo que nadie ha probado es peor que no tenerla: da números con la misma seguridad cuando
están mal. Las pruebas siguientes son funciones test_* con assert, el formato de pytest; aquí se ejecutan en un
bucle para que la salida sea determinista, y en un proyecto irían en test_opciones.py y se lanzarían con
pytest -q. Cada una comprueba una propiedad que no depende de los datos:
- Paridad put-call: hasta 10⁻⁹ (lección 3.4.1).
- Sintético: call comprada y put vendida del mismo strike es un forward. Gamma y vega nulas en cualquier precio y delta igual a .
- Diferencias finitas: delta y gamma frente a derivadas centrales con medio punto de índice; vega frente a la diferencia entre ±0,5 puntos de volatilidad (un punto en total); theta frente a la diferencia entre ±0,5 días. Comprueba la fórmula y las unidades a la vez.
- Payoff: al vencimiento, el P&L de una put comprada es (máx(K − S, 0) − prima) × 100.
- QuantLib: las cinco magnitudes de la celda anterior.
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)
# derivadas centrales: 1 punto de índice, 1 punto de vol, 1 día
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)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_vegapytest -q test_opciones.pyUn iron condor real: la cadena del 19 de febrero de 2020
Con la herramienta probada, una posición de los datos. La cadena diaria opciones3_cadena.parquet tiene bid, ask y la
volatilidad implícita verdadera del modelo (iv_modelo) de cada opción de ORX. El 19 de febrero de 2020 es el máximo
del índice antes de la caída plantada de la pandemia (−32 % hasta el 23 de marzo, README de los datos): un buen día
para vender un iron condor y ver qué dice la herramienta antes y durante la crisis. Las patas se compran al ask y se
venden al bid, con la volatilidad de su strike:
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} días")
pd.DataFrame(condor.patas).drop(columns="dias")ORX 2995.33 r 0.0055 q 0.0168 30 días
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.0ORX cierra en 2995,33, el tipo a un mes es del 0,55 % y la rentabilidad por dividendo del 1,68 %. La volatilidad implícita va del 16,0 % del strike 2800 al 7,0 % del 3100: el skew de un índice en calma (lección 3.4.5). El condor vende la put 2900 y la call 3050 (deltas de −0,19 y +0,20) y compra la put 2800 y la call 3100 como alas:
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"Crédito cobrado: {-condor.coste():.0f} USD")
tabla.round(2)Crédito cobrado: 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.12Se cobran 13,2 puntos, 1.320 USD por condor. La tabla separa lo que aporta cada pata. La vega del condor, −280,86 USD
por punto, sale casi entera de las dos patas vendidas (−234 y −237), compensadas en parte por las alas; la theta,
+40,48 USD al día, es lo que la posición cobra cada día si nada se mueve. La delta agregada, −3,30 USD por punto
(−9.871 USD de delta en dólares, el equivalente a estar corto de 3,3 unidades de ORX), es casi neutral: las deltas de
las patas, de ±19 USD por punto, se compensan. La columna valor es el valor de mercado con signo: −1.359,57 USD,
algo más que el crédito cobrado porque se entró vendiendo al bid y comprando al ask.
Payoff y P&L antes del vencimiento
S = np.arange(2600.0, 3301.0, 5.0)
curvas = pd.DataFrame({"vencimiento": condor.payoff(S),
"hoy": condor.pnl(S),
"en 15 días": condor.pnl(S, dt=15)},
index=S)
alv = curvas["vencimiento"]
print(f"Máx. ganancia {alv.max():.0f} · máx. pérdida {alv.min():.0f}")
# puntos de equilibrio: interpolación lineal donde cambia el signo
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("Equilibrio al vencimiento:", np.round(eq, 1))
curvas.loc[[2700, 2850, 2900, 2950, 3000, 3050, 3100]].round(0)Máx. ganancia 1320 · máx. pérdida -8680
Equilibrio al vencimiento: [2886.8 3063.2]
vencimiento hoy en 15 días
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.0La figura de la cabecera dibuja las tres columnas en todo el rango. Al vencimiento, el condor gana el crédito entero (+1.320 USD) entre 2900 y 3050 y pierde como máximo 8.680 USD por debajo de 2800 (100 puntos de anchura menos 13,2 de crédito) y 3.680 por encima de 3100 (50 de anchura menos 13,2). Los puntos de equilibrio son strike ± crédito: 2886,8 y 3063,2. Las alas no son simétricas porque los strikes se eligieron por delta y la volatilidad de la put vendida (12,9 %) es casi el doble que la de la call vendida (7,6 %).
La curva de hoy es otra cosa: −60 USD con ORX en 3000, la horquilla pagada al entrar, y por debajo de cero en todo el rango. Ganar con un condor vendido es cuestión de tiempo: a 15 días, con la misma volatilidad, la curva ya sube a +707 USD en 3000. Esa es la theta, vista como desplazamiento de la curva entera.
Griegas agregadas a lo largo del precio
Una griega en un solo punto no dice qué pasa si el índice se mueve. La misma función, con un array de precios, da el perfil completo:
Sg = np.array([2800.0, 2900.0, 2975.0, 3050.0, 3150.0])
perfil = pd.concat(
{f"{d} días": pd.DataFrame(condor.griegas(Sg, dt=dias - d),
index=Sg)[list(GRIEGAS)]
for d in (30, 5)}, axis=1)
perfil.round(1) 30 días 5 días
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.9A 30 días, la delta del condor se mueve con suavidad entre +33 y −25 USD por punto. A 5 días del vencimiento el perfil se endurece: +70 en 2850 y −47 en 3050, con casi cero en el centro. Es la gamma de las patas vendidas concentrándose alrededor de sus strikes: cinco días antes del vencimiento, una caída de 125 puntos (un 4 %) lleva la delta de +4 a +70 USD por punto, el equivalente a 70 unidades del índice. La vega cambia de signo con el precio: en el centro la posición está corta de volatilidad (−282 USD por punto en 3000), pero fuera de las alas está larga (+180 en 2750). Con el índice muy por debajo de 2800, más volatilidad significa más probabilidad de volver, y el spread de puts vale menos. La rejilla de escenarios lo confirmará.
Explicar el P&L de un día
El control diario de una cartera de opciones es explicar el P&L: descomponer lo que se ganó o perdió con las griegas de la víspera y ver cuánto queda sin explicar. Con la aproximación de Taylor de segundo orden en el precio y de primero en la volatilidad y el tiempo,
con en puntos y en días naturales. La función usa la volatilidad de cada strike en cada fecha (no un desplazamiento común) y compara con el P&L real a precios medios:
def explica(pos, fecha, fecha_cad):
"""P&L de un día de la posición: real (a precios medios) y
su aproximación de Taylor con las griegas de la víspera."""
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["suma"] = 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} a {ind.at[f1, 'cierre']}")
explica(condor, f1, cad1)ORX 2995.33 a 2998.58
delta -11.0
gamma -2.0
vega -406.0
theta 40.0
suma -379.0
real -372.0
dtype: float64El 20 de febrero ORX sube 3,25 puntos y el condor pierde 372 USD. La delta (−11 USD) y la gamma (−2) apenas cuentan, la theta aporta +40, y la vega explica casi todo: −406 USD, porque la volatilidad implícita de los cuatro strikes subió entre 1,1 y 1,7 puntos con el índice casi plano. La suma, −379, se queda a 7 USD del P&L real. Es la primera señal de la crisis en los datos: el índice no se ha movido, pero la superficie sí. Una posición corta de vega puede perder dinero un día de subida del índice.
Rejilla de escenarios: precio × volatilidad × tiempo
Las griegas valen para movimientos pequeños. Para los grandes hay que revalorar la posición entera en cada escenario.
La rejilla combina movimientos del índice, desplazamientos paralelos de la volatilidad y días transcurridos en una
malla tridimensional y la evalúa con una sola llamada a pnl:
def rejilla(pos, S0, movs, dvols, dts):
"""P&L en USD para cada combinación (broadcasting 3D)."""
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) -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.0La tabla y la figura son la rejilla a 10 días. Con el índice y la volatilidad sin cambios el condor gana 433 USD (diez días de theta menos la horquilla); si además la volatilidad baja 3 puntos, 1.043. Basta con que suba 5 puntos para perder 691 con el índice quieto, y 2.991 si sube 25. En la figura solo 8 de las 152 celdas ganan: movimientos de −2 % a +2 % con la volatilidad igual o más baja. Es el perfil de una venta de prima: la ganancia es estrecha y la pérdida, ancha.
La tabla enseña también la vega positiva de las alas. Con el índice un 10 % más abajo, la pérdida es de 7.885 USD con la volatilidad sin cambios y de 5.663 con 25 puntos más: el spread de puts está tan dentro del dinero que más volatilidad lo abarata. Con −20 % la pérdida es la máxima, 8.677, haga lo que haga la volatilidad.
La rejilla frente a lo que pasó
La prueba de una rejilla de escenarios es compararla con la realidad. Tres fechas de la caída: para cada una, el escenario de la rejilla con el movimiento real del índice y un desplazamiento paralelo igual al cambio de la volatilidad en el dinero; la misma posición revalorada con la volatilidad que tenía ese día cada uno de sus strikes; y el P&L real a precios medios.
from dataclasses import replace
def marca_real(pos, fecha):
"""P&L real a precios medios de la cadena de esa fecha."""
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"])
# strike listado más cercano al dinero
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
# la misma posición con la iv que tiene ese día cada strike
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}) 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.0El 28 de febrero ORX ha caído un 2,1 % y la volatilidad en el dinero ha subido 8,8 puntos. La rejilla predice −1.753 USD; con la volatilidad de cada strike, −1.258, igual que el P&L real. La diferencia no es del modelo de valoración sino del escenario: la volatilidad «en el dinero» de ese día es la del strike 2950, más bajo que el 3000 del día de entrada y con más volatilidad por el skew. Los strikes de la posición subieron entre 7,1 y 8,7 puntos, no 8,8. Un escenario con desplazamiento paralelo mezcla el cambio de la superficie con el deslizamiento a lo largo del skew (sticky strike frente a sticky moneyness, lección 3.4.5). El 9 de marzo pasa lo mismo: −4.611 frente a −4.130.
El 16 de marzo, con ORX un 32,6 % más abajo, la rejilla y la revaloración por strike coinciden en la pérdida máxima, −8.679, pero el P&L a precios medios es de −9.070: 391 USD más que la pérdida máxima posible al vencimiento. La put 2900 cotiza 874,2/894,1 y la 2800, 771,4/789,1: horquillas de casi 20 puntos, con ruido en el medio. El medio del spread de puts es 103,9 puntos, por encima de los 100 de anchura, algo que no puede durar hasta el vencimiento. Cerrar ese día a mercado (comprar la 2900 al ask y vender la 2800 al bid) costaría 122,7 puntos. La lección 3.5.5 vuelve sobre esta decisión.
Lo que falta y cómo extenderla
La herramienta cabe en unas 150 líneas porque deja fuera muchas cosas, y conviene saber cuáles:
- Superficie de volatilidad.
dvoles un número; una versión mejor recibiría una función de strike y vencimiento (por ejemplo, el SVI de la lección 3.4.5) y revaloraría cada pata con su volatilidad en el escenario. - Opciones americanas y dividendos discretos. Las opciones sobre acciones necesitan el árbol binomial (3.4.2) y su frontera de ejercicio; la lección 3.5.5 lo usa con la acción KRN.
- Varios subyacentes. Las griegas de patas sobre subyacentes distintos no se suman sin un modelo de correlación (beta, o escenarios conjuntos).
- Costes. El P&L se calcula a precios medios; la horquilla de salida es un coste real, mayor en crisis.
Con esas limitaciones a la vista, la herramienta ya sirve para lo que se usa en el resto del módulo: dibujar el payoff de cualquier estructura, vigilar las griegas de una cartera y someterla a escenarios antes de abrirla.
Ejercicios
- Cobertura delta con la pata S. Añade al condor del 19-02-2020 una pata
Scon la cantidad que deja la delta agregada en cero y comprueba congriegasque la delta es nula en 2995,33 y que la gamma y la vega no cambian.
Solución
La delta del condor es −3,30 USD por punto; una pata S aporta cantidad × 100 por punto, así que hay que comprar
0,033 unidades de contrato (3,3 unidades del índice). Gamma, vega y theta no cambian porque el subyacente no las tiene.
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}){'delta': 0.0, 'gamma': -0.4257, 'vega': -280.8596, 'theta': 40.4759}- Un calendario. Con la cadena del mismo día, vende la put 2950 del vencimiento del 20-03-2020 y compra la del 17-04-2020. Calcula su payoff en el primer vencimiento (con la pata larga valorada con su volatilidad) y su vega agregada. ¿Por qué el payoff de un calendario depende de la volatilidad y el de un condor no?
Solución
La pata larga sigue viva en el primer vencimiento, así que su valor depende de la volatilidad que tenga entonces. La vega del calendario es positiva (+145,2 USD por punto hoy): la opción lejana tiene más vega que la cercana. En el primer vencimiento, con ORX en 2950, el calendario gana 2.180 USD con la volatilidad sin cambios y 3.807 si sube 5 puntos; en 2800 y 3100 pierde entre 1.297 y 1.448 USD (675 y 693 con 5 puntos más). En un condor todas las patas vencen a la vez y el payoff solo depende del precio.
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 con +5 pt:",
cal.pnl(Sc, 0.05, cal.vencimiento()).round(0))
print("vega hoy:", round(float(cal.griegas(S0)["vega"]), 1))payoff: [-1297. 214. 2180. 174. -1448.]
payoff con +5 pt: [-675. 1661. 3807. 1680. -693.]
vega hoy: 145.2- Escenario con skew. Sustituye el desplazamiento paralelo por uno que dependa del strike:
dvolde cada pata igual a 0,088 + 0,0003 × (3000 − K) para el 28-02-2020. ¿Se acerca el P&L al real (−1.258 USD)? ¿Qué parámetro estarías ajustando a posteriori?
Solución
Con ese desplazamiento la pata 2800 sube 14,8 puntos y la 3100 5,8: el escenario exagera el empinamiento del skew, porque en los datos las cuatro patas subieron entre 7,1 y 8,7 puntos. El coeficiente 0,0003 se ha elegido después de ver el resultado: es ajustar el escenario al dato. Un escenario de estrés útil fija la forma del desplazamiento con episodios pasados (por ejemplo, la pendiente media del cambio de volatilidad por strike en las caídas de la muestra) y la aplica a fechas que no se usaron para estimarla.
Respuesta plegada en cada pregunta
Autoevaluación
La herramienta devuelve para un iron condor una vega de −280,86. ¿Qué significa, con las convenciones de la lección?
- AQue la posición pierde 280,86 USD si la volatilidad implícita de todas las patas sube un punto (del 12 % al 13 %, por ejemplo)
- BQue pierde 280,86 USD si la volatilidad sube del 12 % al 112 %
- CQue pierde 280,86 puntos del índice por día
- DQue pierde 2,8086 USD por contrato y por punto
Ver respuesta
Respuesta A. Que la posición pierde 280,86 USD si la volatilidad implícita de todas las patas sube un punto (del 12 % al 13 %, por ejemplo)
La vega de
bsmestá dividida entre 100 (por punto de volatilidad, no por unidad) y la posición la multiplica por cantidad × 100 USD por punto del índice. Es una sensibilidad lineal: para cambios grandes de volatilidad hay que revalorar (la rejilla), no multiplicar.El 20-02-2020 ORX sube 3,25 puntos y el condor vendido pierde 372 USD. ¿Qué término de la explicación por griegas da cuenta de casi todo?
- ALa delta
- BLa gamma
- CLa vega: la volatilidad implícita de los cuatro strikes subió entre 1,1 y 1,7 puntos con el índice casi plano
- DLa theta
Ver respuesta
Respuesta C. La vega: la volatilidad implícita de los cuatro strikes subió entre 1,1 y 1,7 puntos con el índice casi plano
Delta −11 y gamma −2 USD son pequeñas porque el índice apenas se movió; theta suma +40. La vega aporta −406 USD. Una posición vendida de volatilidad puede perder un día de subida del índice si la volatilidad implícita sube.
¿Por qué la prueba de diferencias finitas usa derivadas centrales con un punto de índice, un punto de volatilidad y un día?
- APorque es más rápida que la fórmula analítica
- BPorque comprueba a la vez la fórmula y las unidades: si la vega estuviera por unidad de volatilidad o la theta por año, la comparación fallaría por un factor 100 o 365
- CPorque QuantLib no da griegas
- DPorque las griegas analíticas solo valen en el dinero
Ver respuesta
Respuesta B. Porque comprueba a la vez la fórmula y las unidades: si la vega estuviera por unidad de volatilidad o la theta por año, la comparación fallaría por un factor 100 o 365
La diferencia central entre dvol = +0,005 y −0,005 es el cambio de valor por un punto de volatilidad; entre dt = +0,5 y −0,5 días, el cambio por un día. Si coinciden con
vegaytheta, las unidades son las que dice la documentación.La rejilla con un desplazamiento paralelo igual al cambio de la volatilidad ATM predice −1.753 USD para el 28-02-2020; la posición revalorada con la volatilidad de cada strike da −1.258. ¿De dónde sale la diferencia?
- ADe un error en la fórmula de Black-Scholes
- BDe la horquilla
- CDe que la volatilidad ATM de ese día es la de otro strike, más bajo y con más volatilidad por el skew: los strikes de la posición subieron 7 puntos, no 8,8
- DDe los dividendos
Ver respuesta
Respuesta C. De que la volatilidad ATM de ese día es la de otro strike, más bajo y con más volatilidad por el skew: los strikes de la posición subieron 7 puntos, no 8,8
Con el índice un 2,1 % más abajo, el strike en el dinero es el 2950, no el 3000. La volatilidad ATM mezcla el movimiento de la superficie con el desplazamiento a lo largo del skew. La rejilla paralela es una aproximación útil, pero no una superficie.
El 16-03-2020 la posición marcada a precios medios pierde 9.070 USD, más que la pérdida máxima al vencimiento (8.680). ¿Qué ocurre?
- ALa herramienta calcula mal la pérdida máxima
- BEl medio de dos puts muy dentro del dinero con horquillas de casi 20 puntos no respeta la cota del spread: la valoración a medios en crisis es ruidosa, y cerrar a bid/ask costaría aún más
- CEl índice siguió cayendo después del vencimiento
- DLos dividendos aumentan la pérdida
Ver respuesta
Respuesta B. El medio de dos puts muy dentro del dinero con horquillas de casi 20 puntos no respeta la cota del spread: la valoración a medios en crisis es ruidosa, y cerrar a bid/ask costaría aún más
La put 2900 cotiza 874,2/894,1 y la 2800, 771,4/789,1: el medio del spread es 103,9 puntos, por encima de los 100 de anchura. A vencimiento (europea, en efectivo) la pérdida queda en 8.680; cerrar ese día comprando a ask y vendiendo a bid costaría 122,7 puntos.
Ideas clave
- Una herramienta de opciones empieza por las convenciones: precio por unidad y por contrato, T en días naturales / 365, tipos continuos, vega por punto y theta por día natural. Se escriben en el código y se prueban.
- Una posición es una lista de patas; valor, griegas, payoff y P&L son sumas por pata con broadcasting, así que una rejilla de miles de escenarios es una sola llamada.
- Las pruebas (paridad, sintéticos, diferencias finitas, QuantLib) van antes que los datos: detectan errores de signo y de unidades que un gráfico no enseña.
- Explicar el P&L de cada día con las griegas de la víspera es el control diario de una cartera de opciones: lo que no explican las griegas es un salto, un cambio de forma de la superficie o un error.
- Una rejilla con desplazamientos paralelos de volatilidad es una aproximación: no recoge el skew ni la dinámica de la superficie, y la valoración a medios en crisis tiene ruido de horquilla.