Lección 3.4.1 · 65 min

Payoffs, paridad put-call y límites de arbitraje

Antes de cualquier modelo, el no arbitraje ya acota el precio de una opción: cotas, pendientes, convexidad y la paridad put-call. Esta lección convierte esas cotas en un escáner, lo pasa por doce años de cadena del índice ficticio ORX y separa las violaciones que son datos malos de las ocho que se plantaron a propósito.

Fig. — Desviación de la paridad put-call por strike, ORX, 4 de octubre de 2024, vencimiento de diciembre (77 días)
Lecciones del nivel 3
  1. 3.1 · Futuros en profundidad

    1. 3.1.1 Contratos de futuros principales
    2. 3.1.2 Curva de futuros: base, cost of carry, contango y roll yield
    3. 3.1.3 Gestión del roll, márgenes, SPAN y apalancamiento implícito
    4. 3.1.4 Sistemas de futuros diversificados: el enfoque de Carver
  2. 3.2 · Spreads de futuros

    1. 3.2.1 Spreads de calendario, butterflies y condors
    2. 3.2.2 Spreads intercommodity: crack, spark y crush
    3. 3.2.3 Spreads de tipos y de índices: 2s10s, NOB, NQ/ES y Europa frente a EE. UU.
    4. 3.2.4 Ratios de cobertura: por nominal, por DV01 y por volatilidad
    5. 3.2.5 Datos y backtesting de spreads: cotizados, sintéticos y por patas
    6. 3.2.6 Estacionalidad de spreads: análisis multi-año, patrones y trampas
  3. 3.3 · Forex avanzado

    1. 3.3.1 Microestructura FX: OTC, ECN, last look y sesiones
    2. 3.3.2 Carry y riesgo de crash, momentum y value en divisas; cestas
    3. 3.3.3 Riesgo de divisa en carteras multi-moneda; ejecución vía OANDA, IBKR o MT5
  4. 3.4 · Teoría y valoración de opciones

    1. 3.4.1 Payoffs, paridad put-call y límites de arbitraje
    2. 3.4.2 Árboles binomiales, Black-Scholes-Merton, Black-76 y Monte Carlo
    3. 3.4.3 Griegas: delta, gamma, vega, theta, rho, vanna, volga y charm
    4. 3.4.4 Volatilidad implícita: Newton-Raphson, Brent, py_vollib y QuantLib
    5. 3.4.5 Sonrisa, skew y estructura temporal; superficie de volatilidad con SVI
    6. 3.4.6 Volatilidad realizada frente a implícita; prima de riesgo de volatilidad; VIX y VSTOXX
    7. 3.4.7 Modelos avanzados: Heston, SABR y volatilidad local
  5. 3.5 · Estructuras de opciones en Python

    1. 3.5.1 Estructuras direccionales y verticales: call, put, covered call, collar y spreads
    2. 3.5.2 Estructuras de volatilidad y rango: straddles, strangles, cóndores y mariposas
    3. 3.5.3 Calendarios, diagonales, ratios, backspreads, risk reversals y jade lizard
    4. 3.5.4 Herramienta propia: payoff, griegas agregadas y escenarios
    5. 3.5.5 Gestión: ajustes, roll, cierre anticipado, asignación temprana y pin risk
  6. 3.6 · Estrategias sistemáticas de volatilidad y opciones

    1. 3.6.1 Venta sistemática de prima: put-write, strangles y su riesgo de cola
    2. 3.6.2 Delta hedging, gamma scalping y dispersión
    3. 3.6.3 Estrategias con VIX y VSTOXX: futuros y ETP de volatilidad
    4. 3.6.4 Backtesting de opciones: datos, fills y ajustes por eventos
  7. 3.7 · Cripto derivados y estrategias específicas

    1. 3.7.1 Perpetuos: mecánica del funding, mark price y liquidaciones
    2. 3.7.2 Arbitraje de funding, basis con futuros y arbitraje entre exchanges
    3. 3.7.3 Opciones cripto, datos on-chain y DeFi
    4. 3.7.4 ccxt para operar en varios exchanges
    5. 3.7.5 MiCA: el marco de la UE para criptoactivos y sus proveedores (CASP)

Payoffs como piezas de construcción

La lección 1.2.5 dibujó el resultado al vencimiento de las cuatro posiciones básicas y comprobó la paridad put-call en una cadena de un solo día. Aquí se da un paso más: los payoffs se tratan como piezas que se suman, y de esas sumas salen todas las relaciones de no arbitraje que deben cumplir los precios, sin suponer ningún modelo de cómo se mueve el subyacente.

La idea es la de la réplica estática: si dos carteras pagan exactamente lo mismo al vencimiento en todos los escenarios, hoy deben costar lo mismo; si una paga siempre al menos lo que la otra, no puede costar menos. Con cuatro piezas (call, put, forward y bono cupón cero) se construye casi todo:

CarteraPaga al vencimientoNombre
Call comprada en K + put vendida en KST−KS_T - Kforward sintético comprado
Call comprada en K1 − call vendida en K2entre 0 y K2 − K1call spread (vertical)
Sintético comprado en K1 + sintético vendido en K2K2−K1K_2 - K_1 siemprecaja (box spread)
Call en K1 − 2 calls en K2 + call en K3triángulo, nunca negativomariposa
Call spread de anchura ΔK dividido por ΔK≈ 1 si ST>KS_T > Kdigital (aproximada)
Call compradaCartera APut vendidaPaga S − KForward a KMismo precio hoy
Fuente Mermaid
flowchart TB
  A[Call comprada] --> X[Cartera A]
  B[Put vendida] --> X
  X --> P["Paga S − K"]
  F[Forward a K] --> P
  P --> H[Mismo precio hoy]

La cartera A (call comprada y put vendida) y un forward comprado a K pagan ST−KS_T - K en todos los escenarios. Un forward a K cuesta hoy el valor presente de la diferencia entre el forward de mercado FF y KK, así que:

C−P=e−rT (F−K).C - P = e^{-rT}\,(F - K).

Con dos strikes, la diferencia de dos forwards sintéticos es la caja: paga K2−K1K_2 - K_1 pase lo que pase y vale e−rT(K2−K1)e^{-rT}(K_2 - K_1). Es un préstamo a tipo fijo construido con opciones, y es la forma más limpia de confirmar una violación de la paridad sin tocar el subyacente. La celda construye los payoffs con NumPy sobre una rejilla de precios finales para ver que la caja es plana y que la mariposa nunca es negativa:

IN [01]python
import numpy as np
import pandas as pd

ST = np.linspace(4000, 6000, 2001)       # índice al vencimiento
call = lambda K: np.maximum(ST - K, 0)
put = lambda K: np.maximum(K - ST, 0)

caja = (call(4900) - put(4900)) - (call(5000) - put(5000))
mariposa = call(4900) - 2 * call(5000) + call(5100)
digital = (call(4995) - call(5005)) / 10
print("caja: mín", caja.min(), "máx", caja.max())
print("mariposa: mín", mariposa.min(), "máx", mariposa.max())
print("digital en 4.990, 5.000 y 5.010:",
      digital[np.searchsorted(ST, [4990, 5000, 5010])])
OUT [01]Payoffs como sumas de piezas
caja: mín 100.0 máx 100.0
mariposa: mín 0.0 máx 100.0
digital en 4.990, 5.000 y 5.010: [0.  0.5 1. ]

La caja paga 100 puntos en toda la rejilla; la mariposa vale entre 0 y 100 (máximo en el strike central); el call spread estrecho, dividido por su anchura, salta de 0 a 1 alrededor del strike como una opción digital. De esas tres observaciones salen las cotas que siguen.

Las cotas de Merton

Merton (1973) reunió las condiciones que debe cumplir cualquier precio de opción sin arbitraje. Para opciones europeas sobre un índice con forward FF, factor de descuento DF=e−rTDF = e^{-rT} y strikes K1<K2<K3K_1 < K_2 < K_3:

CondiciónFórmulaPor qué
Cota inferior de la callC≥DF (F−K)+C \ge DF\,(F - K)^+la call paga al menos lo que el forward sintético, y nunca menos de 0
Cota superior de la callC≤DF F=S e−qTC \le DF\,F = S\,e^{-qT}nunca paga más que el índice
Cotas de la putDF (K−F)+≤P≤DF KDF\,(K - F)^+ \le P \le DF\,Kídem; nunca paga más que el strike
MonotoníaC(K1)≥C(K2)C(K_1) \ge C(K_2), P(K1)≤P(K2)P(K_1) \le P(K_2)la call de strike menor paga siempre al menos lo mismo
PendienteC(K1)−C(K2)≤DF (K2−K1)C(K_1) - C(K_2) \le DF\,(K_2 - K_1)el call spread paga como mucho K2−K1K_2 - K_1
ConvexidadλC(K1)+(1−λ)C(K3)≥C(K2)\lambda C(K_1) + (1-\lambda) C(K_3) \ge C(K_2)la mariposa paga algo no negativo; λ=K3−K2K3−K1\lambda = \frac{K_3 - K_2}{K_3 - K_1}

Ninguna depende de la volatilidad ni de la distribución del subyacente: valen para Black-Scholes (lección 3.4.2), para Heston (3.4.7) y para cualquier precio que publique un mercado sin arbitraje. Por eso son el primer filtro de cualquier conjunto de datos de opciones y, en la lección 3.4.5, las condiciones que debe respetar una superficie de volatilidad ajustada. La convexidad tiene además un significado económico: la segunda derivada del precio de la call respecto al strike es, descontada, la densidad de probabilidad neutral al riesgo del índice al vencimiento (Breeden y Litzenberger), y una densidad no puede ser negativa.

Hay una cota más entre vencimientos (a igualdad de moneyness en forward, más plazo no puede valer menos), que con dividendos y tipos se expresa mejor en varianza total; se trata con la superficie en la lección 3.4.5.

Doce años de cadena: el forward implícito

Los datos son la cadena diaria de opciones europeas sobre ORX, un índice ficticio de 500 valores que imita al S&P 500, de 2014 a 2025: los tres vencimientos mensuales más cercanos, con su bid, su ask, el volumen y el interés abierto. Liquidan en efectivo al cierre del día de vencimiento, con un multiplicador de 100 USD por punto, como las opciones SPX de Cboe (las mensuales reales liquidan a la apertura; aquí, al cierre, por simplicidad). Son datos sintéticos: los precios salen de una superficie sin arbitraje conocida (opciones3_verdad.parquet), con horquillas, ruido y una lista de defectos plantados (opciones3_incidencias.csv). La lista no se usa para buscar: se usa al final, para comprobar si el escáner los encuentra todos y solo ellos.

IN [02]python
cad = pd.read_parquet("opciones3_cadena.parquet")
ind = pd.read_csv("opciones3_indice.csv", parse_dates=["fecha"],
                  index_col="fecha")
ancho = cad.pivot_table(index=["fecha", "vencimiento", "strike"],
                        columns="tipo", values=["bid", "ask"])
ancho.columns = [f"{c}_{t.lower()}" for c, t in ancho.columns]
ancho = ancho.dropna().reset_index()
print(len(cad), "opciones;", len(ancho), "pares call-put;",
      ancho["fecha"].nunique(), "sesiones")
ancho.head(3)
OUT [02]La cadena en formato ancho
544548 opciones; 272274 pares call-put; 3017 sesiones
       fecha vencimiento  strike  ask_c  ask_p  bid_c  bid_p
0 2014-01-02  2014-01-17  1600.0  227.3    0.2  225.7   0.15
1 2014-01-02  2014-01-17  1650.0  177.4    0.5  176.1   0.45
2 2014-01-02  2014-01-17  1700.0  127.9    1.3  127.1   1.20

Para comprobar la paridad hacen falta rr y FF de cada fecha y vencimiento. El tipo sale de la curva publicada en opciones3_indice.csv (tipos continuos a 1, 3, 6, 12 y 24 meses), interpolada linealmente en el plazo y plana por debajo de un mes, como hace Cboe con el VIX. El plazo se mide en días naturales / 365, porque las opciones vencen al cierre de un día concreto y el interés corre también en fin de semana.

El forward no se observa: ORX no tiene futuro en los datos. Se deduce de la propia cadena. Despejando la paridad, cada strike da una estimación FK=K+(C−P)/DFF_K = K + (C - P)/DF con los precios medios; la mediana de las estimaciones de los strikes a menos de un 3 % del índice es robusta a que una de ellas esté mal, que es justo lo que se busca detectar:

IN [03]python
plazos = np.array([1 / 12, 0.25, 0.5, 1.0, 2.0])
cols = ["tipo_1m", "tipo_3m", "tipo_6m", "tipo_1a", "tipo_2a"]
ancho["T"] = (ancho["vencimiento"] - ancho["fecha"]).dt.days / 365
curva = ind.loc[ancho["fecha"], cols].to_numpy() / 100
ancho["r"] = [np.interp(t, plazos, c)
              for t, c in zip(ancho["T"], curva)]
ancho["DF"] = np.exp(-ancho["r"] * ancho["T"])
ancho["S"] = ind.loc[ancho["fecha"], "cierre"].to_numpy()
medio_c = (ancho["bid_c"] + ancho["ask_c"]) / 2
medio_p = (ancho["bid_p"] + ancho["ask_p"]) / 2
ancho["F_k"] = ancho["strike"] + (medio_c - medio_p) / ancho["DF"]
cerca = (ancho["strike"] / ancho["S"] - 1).abs() < 0.03
F = (ancho[cerca].groupby(["fecha", "vencimiento"])["F_k"]
     .median().rename("F"))
ancho = ancho.join(F, on=["fecha", "vencimiento"])

verdad = pd.read_parquet("opciones3_verdad.parquet")
cortes = (ancho.drop_duplicates(["fecha", "vencimiento"])
          .merge(verdad, on=["fecha", "vencimiento"]))
err = cortes["F"] - cortes["forward"]
print(f"{len(cortes)} cortes; error del forward: mediana "
      f"{err.median():.3f}, p99 {err.abs().quantile(0.99):.3f},"
      f" máximo {err.abs().max():.3f} puntos")
dr = (cortes["r"] - cortes["tipo_libre"]).abs().max()
print(f"Error máximo del tipo: {dr:.1e}")
OUT [03]Tipo de la curva y forward implícito
9051 cortes; error del forward: mediana 0.000, p99 0.181, máximo 0.457 puntos
Error máximo del tipo: 4.9e-07

En los 9.051 cortes (fecha y vencimiento) el forward estimado coincide con el verdadero con una mediana de error nula, un 99 % de los cortes a menos de 0,18 puntos y un máximo de 0,46 puntos sobre un índice de miles. El tipo interpolado es el de la verdad salvo redondeo. Con datos reales no hay verdad con la que comparar; este paso es el que permite fiarse del método antes de usarlo.

La paridad como escáner

Con FF y DFDF, cada par call-put admite dos operaciones de arbitraje ejecutables con los precios de pantalla:

  • Conversión (la call está cara): vender la call al bid, comprar la put al ask y comprar el forward. Gana Cbid−Pask−DF (F−K)C_{bid} - P_{ask} - DF\,(F - K) si es positivo.
  • Reversal o conversión inversa (la put está cara): comprar la call al ask, vender la put al bid y vender el forward. Gana DF (F−K)−(Cask−Pbid)DF\,(F - K) - (C_{ask} - P_{bid}).

Una propiedad útil: las dos condiciones juntas equivalen a que la desviación de la paridad calculada con los precios medios supere, en valor absoluto, la semisuma de las dos horquillas. Basta restar: Cbid−PaskC_{bid} - P_{ask} es el medio de C−PC - P menos esa semisuma. La celda lo comprueba sobre los 272.274 pares:

IN [04]python
teo = ancho["DF"] * (ancho["F"] - ancho["strike"])
ancho["conversion"] = ancho["bid_c"] - ancho["ask_p"] - teo
ancho["reversal"] = teo - (ancho["ask_c"] - ancho["bid_p"])
viol = ancho[(ancho["conversion"] > 0) | (ancho["reversal"] > 0)]
print(len(viol), "pares con arbitraje aparente de", len(ancho))

desv = medio_c - medio_p - teo
semi = ((ancho["ask_c"] - ancho["bid_c"])
        + (ancho["ask_p"] - ancho["bid_p"])) / 2
print("La misma cuenta con medios:", (desv.abs() > semi).sum())
OUT [04]Arbitrajes aparentes en doce años
346 pares con arbitraje aparente de 272274
La misma cuenta con medios: 346

346 pares de 272.274, un 0,13 %. La figura de la cabecera muestra cómo se ve uno en un día concreto: el 4 de octubre de 2024, vencimiento de diciembre (77 días), la desviación de cada strike está dentro de su banda ejecutable (las barras grises, de menos a más la semisuma de horquillas) salvo en la put de 4.950, la más cercana al índice, que está 4,09 puntos por debajo de la línea con una banda de 1,05.

Antes de llamar arbitraje a nada hay que preguntarse por qué existiría. Un 0,13 % de los pares es poco para un mercado eficiente y mucho para que todos sean dinero gratis. Los sospechosos habituales están en los datos:

  • Cotizaciones congeladas. Una opción que repite exactamente el bid y el ask del día anterior en una sesión en que el índice se movió más de un 1 % no se ha actualizado: el proveedor arrastra el último precio de un contrato que no ha cotizado.
  • Cotizaciones cruzadas. Un bid por encima del ask es imposible en un libro que funciona: es un error de captura o de sincronización.

Las dos reglas se aplican a cada pata, sin mirar la lista de incidencias:

IN [05]python
cad = cad.sort_values(["vencimiento", "tipo", "strike", "fecha"])
g = cad.groupby(["vencimiento", "tipo", "strike"])
igual = ((cad["bid"] == g["bid"].shift())
         & (cad["ask"] == g["ask"].shift()))
mov = np.log(ind["cierre"]).diff().abs()
cad["congelada"] = igual & (
    mov.reindex(cad["fecha"]).to_numpy() > 0.01)
cad["cruzada"] = cad["bid"] > cad["ask"]
print(cad[["congelada", "cruzada"]].sum().to_dict())

marcas = cad.groupby(["fecha", "vencimiento", "strike"])[
    ["congelada", "cruzada"]].any()
viol = viol.join(marcas, on=["fecha", "vencimiento", "strike"])
viol["causa"] = np.select(
    [viol["cruzada"], viol["congelada"]],
    ["cotización cruzada", "cotización congelada"], "ninguna")
viol["causa"].value_counts()
OUT [05]¿Datos malos o arbitraje?
{'congelada': 11336, 'cruzada': 12}
causa
cotización congelada    330
ninguna                   8
cotización cruzada        8
Name: count, dtype: int64

La regla de las congeladas es tosca: marca 11.336 cotizaciones de toda la cadena, la mayoría opciones muy fuera del dinero que siguen cotizando a 0 y 0,05 porque no valen nada y su precio no cambia aunque el índice se mueva. Para el escáner da igual, porque esas no generan violaciones. De las 346 violaciones, 330 tienen una pata congelada y 8 una cruzada. Solo quedan 8 sin explicación en los datos.

Fig. — Las 346 violaciones ejecutables de la paridad en la cadena de ORX, por causa
sínosínonosísínoViolación¿Cruzada?Dato erróneo¿Congelada?¿Caja confirma?Revisar F y r¿Cubre costes?CandidataDescartar
Fuente Mermaid
flowchart TB
  V[Violación] --> Q{¿Cruzada?}
  Q -- sí --> D1[Dato erróneo]
  Q -- no --> C{¿Congelada?}
  C -- sí --> D1
  C -- no --> B{¿Caja confirma?}
  B -- no --> D2[Revisar F y r]
  B -- sí --> K{¿Cubre costes?}
  K -- sí --> OP[Candidata]
  K -- no --> NO[Descartar]

Las 8 restantes se comparan ahora, por primera vez, con la lista de incidencias plantadas:

IN [06]python
inc = pd.read_csv("opciones3_incidencias.csv",
                  parse_dates=["fecha", "vencimiento"])
quedan = viol[viol["causa"] == "ninguna"].merge(
    inc, on=["fecha", "vencimiento", "strike"], how="left")
quedan["semi"] = ((quedan["ask_c"] - quedan["bid_c"])
                  + (quedan["ask_p"] - quedan["bid_p"])) / 2
quedan["usd"] = 100 * quedan["reversal"]
quedan[["fecha", "vencimiento", "strike", "tipo", "incidencia",
        "reversal", "semi", "usd"]].round(
    {"reversal": 2, "semi": 2, "usd": 2})
OUT [06]Las ocho que quedan
       fecha vencimiento  strike tipo       incidencia  reversal  semi      usd
0 2015-01-20  2015-04-17  2025.0    P  paridad_violada      1.51  0.50   150.90
1 2017-09-25  2017-12-15  2575.0    P  paridad_violada      1.79  0.55   178.68
2 2018-01-09  2018-03-16  2675.0    P  paridad_violada      1.50  0.50   150.00
3 2019-08-28  2019-11-15  2600.0    P  paridad_violada      1.77  0.60   176.54
4 2020-03-26  2020-06-19  2325.0    P  paridad_violada     11.85  3.90  1185.36
5 2022-09-26  2022-12-16  3100.0    P  paridad_violada      5.65  1.75   564.58
6 2024-10-04  2024-12-20  4950.0    P  paridad_violada      3.04  1.05   304.03
7 2024-10-07  2024-12-20  4950.0    P  paridad_violada      2.59  1.00   259.07

Las ocho son las paridad_violada de la lista, y no hay ninguna más: el escáner las encuentra todas y no confunde ninguna otra cosa con ellas. Todas son una put cercana al dinero del tercer vencimiento desplazada al alza, así que todas se explotan con una reversal. El beneficio en papel va de 1,50 a 11,85 puntos por par, de 150 a 1.185 USD por contrato antes de costes; el mayor, el 26 de marzo de 2020, en plena crisis, cuando las horquillas eran también las más anchas (semisuma de 3,90 puntos).

Confirmar con una caja

La reversal necesita vender el forward de ORX, que no existe. En la práctica se usaría un futuro sobre el índice, con otro vencimiento y otro horario, y la operación tendría riesgo de base. La caja evita el subyacente: con el strike sospechoso como K1K_1 y el siguiente como K2K_2, comprar la caja (call comprada y put vendida en K1K_1, call vendida y put comprada en K2K_2) cuesta Cask(K1)−Cbid(K2)+Pask(K2)−Pbid(K1)C_{ask}(K_1) - C_{bid}(K_2) + P_{ask}(K_2) - P_{bid}(K_1) y paga K2−K1K_2 - K_1 seguro:

IN [07]python
ancho = ancho.sort_values(["fecha", "vencimiento", "strike"])
sig = ancho.groupby(["fecha", "vencimiento"]).shift(-1)
caja = ancho[["fecha", "vencimiento", "strike"]].assign(
    K2=sig["strike"],
    coste=(ancho["ask_c"] - sig["bid_c"]
           + sig["ask_p"] - ancho["bid_p"]),
    valor=ancho["DF"] * (sig["strike"] - ancho["strike"]))
caja["beneficio"] = caja["valor"] - caja["coste"]
claves = quedan[["fecha", "vencimiento", "strike"]]
claves.merge(caja, on=["fecha", "vencimiento", "strike"]).round(
    {"coste": 2, "valor": 2, "beneficio": 2})
OUT [07]Cajas sobre las ocho violaciones
       fecha vencimiento  strike      K2  coste  valor  beneficio
0 2015-01-20  2015-04-17  2025.0  2050.0   23.9  25.00       1.10
1 2017-09-25  2017-12-15  2575.0  2600.0   23.6  24.94       1.34
2 2018-01-09  2018-03-16  2675.0  2700.0   23.9  24.94       1.04
3 2019-08-28  2019-11-15  2600.0  2625.0   23.7  24.89       1.19
4 2020-03-26  2020-06-19  2325.0  2350.0   16.9  25.00       8.10
5 2022-09-26  2022-12-16  3100.0  3150.0   45.9  49.66       3.76
6 2024-10-04  2024-12-20  4950.0  5000.0   47.5  49.47       1.97
7 2024-10-07  2024-12-20  4950.0  5000.0   47.8  49.49       1.69

Las ocho cajas confirman la violación con precios ejecutables: el beneficio, con bid y ask en las cuatro patas, va de 1,04 a 8,10 puntos. Es menor que el de la reversal porque la caja paga cuatro horquillas en lugar de dos más el forward. Una caja es, además, un préstamo a tipo fijo: su precio implica un tipo de interés que se puede comparar con el de la curva (ejercicio 2).

Pendiente y convexidad en toda la cadena

Las cotas de Merton se comprueban igual, con la condición de ejecución en cada pata: para la monotonía, comprar la call de strike menor al ask y vender la de strike mayor al bid; para la mariposa, comprar las alas al ask y vender el centro al bid. La función recibe la tabla ancha y devuelve cuántas filas incumplen cada cota; se aplica a la cadena entera y a la cadena sin las filas sospechosas:

IN [08]python
def cotas(a):
    a = a.sort_values(["fecha", "vencimiento", "strike"])
    g = a.groupby(["fecha", "vencimiento"])
    s1, s2 = g.shift(-1), g.shift(-2)
    dk = s1["strike"] - a["strike"]
    lam = (s2["strike"] - s1["strike"]) / (s2["strike"]
                                           - a["strike"])
    DF, F, K = a["DF"], a["F"], a["strike"]
    return pd.Series({
        "C >= DF(F-K)+": (a["ask_c"] < DF * (F - K).clip(0)).sum(),
        "P >= DF(K-F)+": (a["ask_p"] < DF * (K - F).clip(0)).sum(),
        "C <= DF·F": (a["bid_c"] > DF * F).sum(),
        "P <= DF·K": (a["bid_p"] > DF * K).sum(),
        "C decrece": (a["ask_c"] < s1["bid_c"]).sum(),
        "P crece": (s1["ask_p"] < a["bid_p"]).sum(),
        "pendiente C": (a["bid_c"] - s1["ask_c"] > DF * dk).sum(),
        "pendiente P": (s1["bid_p"] - a["ask_p"] > DF * dk).sum(),
        "mariposa C": (lam * a["ask_c"] + (1 - lam) * s2["ask_c"]
                       < s1["bid_c"]).sum(),
        "mariposa P": (lam * a["ask_p"] + (1 - lam) * s2["ask_p"]
                       < s1["bid_p"]).sum(),
    })

ancho = ancho.join(marcas, on=["fecha", "vencimiento", "strike"])
sosp = ancho["congelada"] | ancho["cruzada"]
pd.DataFrame({"todas": cotas(ancho),
              "sin sospechosas": cotas(ancho[~sosp])})
OUT [08]Cotas de no arbitraje, con y sin datos sospechosos
               todas  sin sospechosas
C >= DF(F-K)+      0                0
P >= DF(K-F)+      0                0
C <= DF·F          0                0
P <= DF·K          0                0
C decrece        138                0
P crece          204                1
pendiente C        4                0
pendiente P        0                0
mariposa C       278                0
mariposa P       434                8

Con la cadena entera, las cotas de nivel (inferior y superior) se cumplen siempre, pero hay cientos de violaciones de monotonía y de convexidad: una cotización congelada de ayer, tras un día de caída, deja una put más barata que la de strike inferior o una mariposa de precio negativo. Sin las filas sospechosas quedan solo 9 incumplimientos, todos en las puts: 8 mariposas centradas en la put plantada (que está cara respecto a sus vecinas) y una monotonía, la de marzo de 2020, donde el desplazamiento de 11,85 puntos deja la put de 2.325 por encima de la de 2.350. Las cotas de Merton y la paridad señalan los mismos ocho defectos, lo que confirma que la superficie verdadera no tiene arbitraje y que todo lo demás es ruido de datos.

Opciones americanas: la paridad se convierte en desigualdad

Con ejercicio anticipado la réplica de la paridad deja de ser exacta: la put americana puede ejercerse antes y la call puede ejercerse justo antes de un dividendo (lección 1.2.5). Lo que queda son dos desigualdades. Para una acción con dividendos discretos de valor presente DD hasta el vencimiento:

S−D−K  ≤  C−P  ≤  S−K e−rT.S - D - K \;\le\; C - P \;\le\; S - K\,e^{-rT}.

La de la derecha sale de que la put americana vale al menos la europea; la de la izquierda, de comparar la call con una cartera de put, efectivo KK y la acción. Los datos de KRN, una acción ficticia que paga un dividendo trimestral, traen opciones americanas con su precio de modelo (árbol binomial con dividendos discretos, lección 3.4.2) y el de la opción europea equivalente. Con ellos se comprueban la igualdad europea y las desigualdades americanas:

IN [09]python
krn = pd.read_parquet("accion3_opciones.parquet")
acc = pd.read_csv("accion3_diario.csv", parse_dates=["fecha"],
                  index_col="fecha")
ev = pd.read_csv("accion3_eventos.csv", parse_dates=["fecha"])
divs = ev[ev["evento"] == "dividendo"].set_index("fecha")[
    "dividendo"]
k = krn.pivot_table(index=["fecha", "vencimiento", "strike"],
                    columns="tipo",
                    values=["precio_modelo", "europea_modelo",
                            "bid", "ask"]).dropna()
k.columns = [f"{c}_{t.lower()}" for c, t in k.columns]
k = k.reset_index()
k["T"] = (k["vencimiento"] - k["fecha"]).dt.days / 365
curva = ind.loc[k["fecha"], cols].to_numpy() / 100
k["r"] = [np.interp(t, plazos, c) for t, c in zip(k["T"], curva)]
k["S"] = acc.loc[k["fecha"], "cierre"].to_numpy()

def vp_dividendos(f, v, r):
    d = divs[(divs.index > f) & (divs.index <= v)]
    t = (d.index - f).days / 365
    return float((d * np.exp(-r * t)).sum())

u = k.drop_duplicates(["fecha", "vencimiento"])
vp = {(f, v): vp_dividendos(f, v, r)
      for f, v, r in zip(u["fecha"], u["vencimiento"], u["r"])}
k["D"] = [vp[x] for x in zip(k["fecha"], k["vencimiento"])]
k["KDF"] = k["strike"] * np.exp(-k["r"] * k["T"])

eu = (k["europea_modelo_c"] - k["europea_modelo_p"]
      - (k["S"] - k["D"] - k["KDF"]))
cp = k["precio_modelo_c"] - k["precio_modelo_p"]
tol = 0.0051                  # el cierre está redondeado al céntimo
dentro = cp.between(k["S"] - k["D"] - k["strike"] - tol,
                    k["S"] - k["KDF"] + tol)
print(f"{len(k)} pares; paridad europea: error máx "
      f"{eu.abs().max():.4f}")
print(f"Americanas dentro de sus cotas: {dentro.mean():.2%}")
OUT [09]KRN: paridad europea frente a cotas americanas
66763 pares; paridad europea: error máx 0.0051
Americanas dentro de sus cotas: 100.00%

Las europeas cumplen la igualdad con un error máximo de medio céntimo, el redondeo del cierre de la acción. Las americanas, todas, caen entre sus dos cotas. La figura muestra por qué no cumplen la igualdad: en un día con un dividendo por delante, C−PC - P de las americanas se separa de la línea europea hacia arriba en los strikes bajos (calls dentro del dinero, que se ejercerían la víspera de la fecha ex) y hacia abajo en los altos (puts dentro del dinero, que se ejercerían para cobrar antes el strike).

Fig. — Opciones americanas de KRN entre las cotas de la paridad, 3 de noviembre de 2025, vencimiento del 21 de noviembre con un dividendo de 1,04 USD el día 13

Si se aplica la igualdad europea a las cotizaciones de las americanas, el escáner de la sección anterior encuentra miles de arbitrajes falsos:

IN [10]python
teo_eu = k["S"] - k["D"] - k["KDF"]
conv = k["bid_c"] - k["ask_p"] - teo_eu
rev = teo_eu - (k["ask_c"] - k["bid_p"])
print("conversiones aparentes:", (conv > 0).sum())
print("reversals aparentes:", (rev > 0).sum())
OUT [10]La igualdad europea aplicada a opciones americanas
conversiones aparentes: 1779
reversals aparentes: 1006

Son 2.785 pares de 66.763, un 4 %, y ninguno es real: la «conversión» vende una call americana que el comprador ejercerá antes del dividendo, y la «reversal» vende una put americana que le asignarán en cuanto convenga ejercerla.

Lo que cambia en el trading real

  • Primero los datos. En opciones, un escáner de arbitraje encuentra sobre todo errores: precios arrastrados, cotizaciones cruzadas, call y put tomadas en momentos distintos, un vencimiento mal asignado. Las reglas de la sección del escáner (congelada, cruzada) son también la primera limpieza de un backtest de opciones (lección 3.6.4).
  • El forward, de la cadena. Usar el contado con un dividendo supuesto en lugar del forward implícito produce violaciones sistemáticas en todos los strikes; la mediana de la paridad cerca del dinero es robusta y no necesita conocer el dividendo.
  • Ejecutable o nada. Una desviación con medios dentro de la semisuma de horquillas no es explotable, por grande que parezca en un gráfico.
  • Estilo de ejercicio. Con opciones americanas (acciones, ETF, muchas opciones sobre futuros) la paridad es una desigualdad y las cajas tienen riesgo de asignación: hay que conocer el estilo de cada contrato antes de valorarlo (lección 3.4.2).
  • Las cotas sirven para limpiar superficies. Antes de ajustar una sonrisa (lección 3.4.5) o calcular un índice de volatilidad (3.4.6), los puntos que incumplen monotonía o convexidad se quitan o se corrigen; si no, el ajuste hereda el arbitraje.

Ejercicios

  1. Repite el escáner de paridad con el forward y el tipo verdaderos de opciones3_verdad.parquet en lugar del forward estimado. ¿Cambia el número de violaciones? ¿Por qué no cambia apenas?
Solución
IN [11]python
con_verdad = ancho.merge(
    verdad[["fecha", "vencimiento", "forward", "tipo_libre"]],
    on=["fecha", "vencimiento"])
DFv = np.exp(-con_verdad["tipo_libre"] * con_verdad["T"])
teo_v = DFv * (con_verdad["forward"] - con_verdad["strike"])
conv_v = con_verdad["bid_c"] - con_verdad["ask_p"] - teo_v
rev_v = teo_v - (con_verdad["ask_c"] - con_verdad["bid_p"])
v2 = con_verdad[(conv_v > 0) | (rev_v > 0)]
print(len(v2), "violaciones con la verdad;",
      len(viol), "con el forward estimado")
OUT [11]Escáner con el forward verdadero
345 violaciones con la verdad; 346 con el forward estimado

Con la verdad salen 345 violaciones, una menos que con el forward estimado. El error del forward estimado es casi siempre de centésimas de punto, muy por debajo de la semisuma de horquillas (de 0,5 a varios puntos), así que solo cambia el veredicto de algún par que estaba justo en el borde de su banda. La mediana cerca del dinero es una buena estimación del forward; lo importante es que no se deje arrastrar por el strike defectuoso.

  1. Calcula el tipo implícito de las cajas. Con precios medios, C−P=DF F−DF KC - P = DF\,F - DF\,K es una recta en el strike cuya pendiente es −DF-DF: ajusta esa recta cada miércoles en el segundo vencimiento, con los strikes a menos de un 10 % del forward, y deduce rcaja=−ln⁡(DF)/Tr_{caja} = -\ln(DF)/T. Compáralo con el tipo de la curva. ¿Qué esperarías en datos reales?
Solución
IN [12]python
c2 = ancho.assign(
    medio_cp=(ancho["bid_c"] + ancho["ask_c"]
              - ancho["bid_p"] - ancho["ask_p"]) / 2)
c2 = c2[c2["fecha"].dt.weekday == 2]
orden = c2.groupby("fecha")["vencimiento"].rank(method="dense")
c2 = c2[(orden == 2) & ((c2["strike"] / c2["F"] - 1).abs() < 0.10)]
filas = []
for f, d in c2.groupby("fecha"):
    pendiente = np.polyfit(d["strike"], d["medio_cp"], 1)[0]
    T = d["T"].iloc[0]
    filas.append((f, -np.log(-pendiente) / T, d["r"].iloc[0],
                  len(d)))
cajas = pd.DataFrame(filas, columns=["fecha", "r_caja", "r", "n"])
dif = 1e4 * (cajas["r_caja"] - cajas["r"])
print(len(cajas), "miércoles; strikes por ajuste (mediana):",
      int(cajas["n"].median()))
print("diferencia (pb): mediana", round(dif.median(), 1),
      "| p5", round(dif.quantile(0.05), 1),
      "| p95", round(dif.quantile(0.95), 1))
OUT [12]Tipo implícito de las cajas
619 miércoles; strikes por ajuste (mediana): 15
diferencia (pb): mediana -0.6 | p5 -27.6 | p95 33.0

La pendiente de C−PC - P frente al strike es una caja con todos los strikes a la vez: equivale a promediar muchas cajas y reduce el ruido de cada precio medio. Aun así, el tipo implícito se mueve decenas de puntos básicos alrededor del de la curva: con 30 a 60 días de plazo, un error de pocas centésimas en la pendiente ya son muchos puntos básicos de tipo. Una caja entre dos strikes vecinos sería mucho peor, porque su error se divide por K2−K1K_2 - K_1 (25 o 50 puntos) en lugar de por todo el rango de strikes. En los datos sintéticos no hay ninguna prima: los precios salen de la curva. Con datos reales, van Binsbergen, Diamond y Grotteria (2022) obtienen tipos libres de riesgo con la paridad put-call de las opciones SPX (las «cajas») y encuentran que las letras del Tesoro rinden unos 40 puntos básicos menos: la prima que se paga por la liquidez y la seguridad de la deuda pública (convenience yield).

  1. Un compañero te enseña una «conversión» de 6 puntos en una call de delta 0,05 el día después de una caída del 3 % del índice, y propone automatizar la búsqueda. Explica qué comprobarías antes de enviar una orden.
Solución

Primero, si la call repite el bid y el ask del día anterior: tras una caída del 3 %, una call lejana con el precio de ayer está cara solo en el fichero, y es el perfil exacto de las 330 congeladas de la lección. Segundo, si el bid no supera al ask y si la call y la put llevan la misma marca de tiempo. Tercero, si la violación sobrevive con el forward estimado de la mediana y con una caja con el strike vecino. Cuarto, si el beneficio cubre las comisiones de cuatro patas, el margen y el riesgo de ejecución de una orden combinada. Y, si la opción es americana, si la igualdad europea siquiera se aplica. Automatizar la búsqueda tiene sentido como control de calidad de datos; como estrategia, solo con precios en tiempo real y órdenes combinadas.

Respuesta plegada en cada pregunta

Autoevaluación

  1. En un par call-put, la desviación de la paridad calculada con los precios medios es de −4,09 puntos y la semisuma de las dos horquillas es 1,05. ¿Qué se puede hacer?

    • ANada: con precios medios no se sabe si hay arbitraje
    • BComprar la call al ask, vender la put al bid y vender el forward: se ingresan unos 3 puntos más de lo que vale la posición
    • CVender la call y comprar la put, porque la call está cara
    • DEsperar a que la desviación supere el doble de la horquilla
    Ver respuesta

    Respuesta B. Comprar la call al ask, vender la put al bid y vender el forward: se ingresan unos 3 puntos más de lo que vale la posición

    Una desviación medida con medios mayor (en valor absoluto) que la semisuma de las horquillas equivale exactamente a una operación ejecutable con bid y ask. Aquí C − P está por debajo de su valor teórico: la put está cara y la reversal (call comprada, put vendida, forward vendido) gana unos 3,04 puntos antes de costes, como la put de 4.950 del 2024-10-04.

  2. El escáner de paridad encuentra 346 violaciones ejecutables en doce años de cadena. ¿Qué explica la mayoría?

    • AErrores en el forward estimado
    • BCotizaciones que repiten el bid y el ask del día anterior en sesiones en que el índice se movió más de un 1 %
    • COportunidades reales que nadie aprovechó
    • DEl tipo de interés de la curva
    Ver respuesta

    Respuesta B. Cotizaciones que repiten el bid y el ask del día anterior en sesiones en que el índice se movió más de un 1 %

    330 de las 346 tienen una pata congelada: el precio es el de ayer y el índice se ha movido. Otras 8 tienen una cotización cruzada (bid por encima del ask). Solo quedan 8, que son exactamente las violaciones plantadas en los datos. Un escáner de arbitraje es, sobre todo, un detector de datos malos.

  3. ¿Por qué una caja (box spread) sirve para confirmar una violación de la paridad sin necesidad de operar el subyacente?

    • APorque la caja no tiene horquilla
    • BPorque combina dos sintéticos del mismo vencimiento con strikes distintos y paga K2 − K1 en cualquier escenario: su precio solo depende del tipo de interés
    • CPorque la caja es una opción americana
    • DPorque no necesita margen
    Ver respuesta

    Respuesta B. Porque combina dos sintéticos del mismo vencimiento con strikes distintos y paga K2 − K1 en cualquier escenario: su precio solo depende del tipo de interés

    Call comprada y put vendida en K1 más call vendida y put comprada en K2 pagan siempre K2 − K1 al vencimiento. Vale el descuento de esa cantidad. Si la put de un strike está cara, la caja que la vende cuesta menos que ese valor: en las ocho violaciones, el beneficio de la caja con bid y ask va de 1,04 a 8,10 puntos.

  4. En una cadena limpia, ¿cuál de estas relaciones entre calls del mismo vencimiento puede incumplirse sin que haya arbitraje?

    • AC(K1) ≥ C(K2) si K1 < K2
    • BC(K1) − C(K2) ≤ e^(−rT)·(K2 − K1)
    • CLa mariposa C(K1) − 2C(K2) + C(K3) ≥ 0 con strikes equidistantes
    • DNinguna: las tres son cotas de no arbitraje
    Ver respuesta

    Respuesta D. Ninguna: las tres son cotas de no arbitraje

    Las tres salen de comparar carteras: la call de strike menor domina a la de strike mayor; la diferencia entre las dos no puede pagar más que K2 − K1; y la mariposa paga siempre algo no negativo. En la cadena de ORX, tras quitar las cotizaciones sospechosas, solo las incumplen las puts plantadas.

  5. KRN tiene opciones americanas y paga un dividendo antes del vencimiento. ¿Qué dice la paridad?

    • AC − P = S − D − K·e^(−rT), igual que para las europeas
    • BS − D − K ≤ C − P ≤ S − K·e^(−rT), con D el valor presente del dividendo
    • CC − P = S − K
    • DNo hay ninguna relación entre la call y la put americanas
    Ver respuesta

    Respuesta B. S − D − K ≤ C − P ≤ S − K·e^(−rT), con D el valor presente del dividendo

    Con ejercicio anticipado la paridad se convierte en dos desigualdades. En KRN, los 66.763 pares de precios del modelo caen dentro (con medio céntimo de redondeo), mientras que aplicar la igualdad europea a sus cotizaciones americanas daría 2.785 arbitrajes aparentes.

Ideas clave

  • Una call comprada y una put vendida del mismo strike pagan lo mismo que un forward comprado a ese strike: de ahí la paridad C − P = e^(−rT)(F − K) y, con dos strikes, el valor fijo de una caja.
  • Las cotas de Merton, la monotonía, la pendiente y la convexidad en el strike son condiciones de no arbitraje que no dependen de ningún modelo.
  • Una desviación de la paridad medida con medios es ejecutable si supera la semisuma de las horquillas: es la misma condición que comprar y vender con bid y ask.
  • En doce años de cadena hay 346 violaciones ejecutables: 330 por cotizaciones congeladas, 8 por cotizaciones cruzadas y 8 reales, las plantadas, que una caja confirma con 1,04 a 8,10 puntos de beneficio antes de costes.
  • Con opciones americanas y dividendos discretos la paridad es una desigualdad: aplicar la igualdad europea produce miles de arbitrajes falsos.

Recursos