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.

Fig. — Iron condor vendido sobre ORX el 19-02-2020 (puts 2800/2900, calls 3050/3100, 30 días): payoff al vencimiento y P&L hoy y a 15 días, con la volatilidad de cada strike sin cambios
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)

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:

  1. Valora una opción europea y sus griegas con Black-Scholes-Merton (lección 3.4.2), vectorizado.
  2. Agrega patas en una posición: valor, delta, gamma, vega y theta en USD.
  3. Calcula el payoff al vencimiento y el P&L antes del vencimiento en cualquier precio, volatilidad y fecha.
  4. Explica el P&L de un día con las griegas de la víspera.
  5. Calcula una rejilla de escenarios precio × volatilidad × tiempo de una sola vez.
Cadena del díaPataPosicionbsmGriegas en USDP&L y payoffP&L explicadoRejillaPruebas
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:

MagnitudConvención de la herramientaError típico
PrecioPor unidad del subyacente (puntos de ORX); la posición multiplica por 100 USD por puntoMezclar precio por acción con precio por contrato
TiempoT = días naturales hasta el vencimiento / 365Usar sesiones / 252 en la fórmula y días naturales en la theta
Tipo libre y dividendosContinuos, en tanto por uno (tipo_1m / 100)Pasar 0,55 en vez de 0,0055
VolatilidadAnual, en tanto por uno (0,18 es un 18 %)Pasar 18
DeltaUSD por punto del índice en la posición (cantidad × 100 × delta)Confundirla con la delta en USD (× S)
VegaUSD por punto de volatilidad (por 0,01)Por unidad de volatilidad (100 veces más)
ThetaUSD por día naturalPor 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:

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

AÑO = 365.0  # T = 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()}
OUT [01]Black-Scholes-Merton vectorizado
{'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):

IN [02]python
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)
OUT [02]Contraste con QuantLib
           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.0

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

IN [03]python
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 %)")
IN [04]python
Pata("P", -1, strike=4900, dias=30, iv=18)
OUT [04]Una pata mal escrita falla al crearla
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.

IN [05]python
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: C−P=Se−qT−Ke−rTC - P = S e^{-qT} - K e^{-rT} 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 100 e−qT100\,e^{-qT}.
  • 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.
IN [06]python
def test_paridad_put_call():
    S, K, T, r, q = 5000.0, 4900.0, 0.25, 0.04, 0.015
    c = bsm("C", S, K, T, 0.2, r, q)["precio"]
    p = bsm("P", S, K, T, 0.2, r, q)["precio"]
    assert abs(c - p - (S * np.exp(-q * T)
                        - K * np.exp(-r * T))) < 1e-9


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


def test_griegas_frente_a_diferencias_finitas():
    pos = Posicion([Pata("P", -2, 4800, 45, 0.21, 40.0),
                    Pata("C", 1, 5200, 45, 0.15, 25.0)], 0.04, 0.015)
    S, h = 5000.0, 0.5
    g = pos.griegas(S)
    v = lambda s=S, dv=0.0, dt=0.0: pos.griegas(s, dv, dt)["valor"]
    cerca = lambda a, b: np.isclose(a, b, rtol=1e-4)
    # 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)
OUT [06]Pruebas
ok  test_coincide_con_quantlib
ok  test_griegas_frente_a_diferencias_finitas
ok  test_paridad_put_call
ok  test_payoff_al_vencimiento
ok  test_sintetico_no_tiene_gamma_ni_vega
pytest -q test_opciones.py

Un 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:

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

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


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


condor = Posicion([pata("P", 2800, 1), pata("P", 2900, -1),
                   pata("C", 3050, -1), pata("C", 3100, 1)], r0, q0)
print(f"ORX {S0}  r {r0:.4f}  q {q0:.4f}  {dias} días")
pd.DataFrame(condor.patas).drop(columns="dias")
OUT [07]Un iron condor en la cadena del 19-02-2020
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.0

ORX 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:

IN [08]python
filas = {}
for p in condor.patas:
    g = Posicion([p], r0, q0).griegas(S0)
    filas[f"{p.cantidad:+.0f} {p.tipo} {p.strike:.0f}"] = g
tabla = pd.DataFrame(filas).T.astype(float)
tabla.loc["condor"] = tabla.sum()
tabla["delta_usd"] = tabla["delta"] * S0
print(f"Crédito cobrado: {-condor.coste():.0f} USD")
tabla.round(2)
OUT [08]Griegas por pata y agregadas
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.12

Se 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

IN [09]python
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)
OUT [09]Payoff al vencimiento y P&L antes
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.0

La 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:

IN [10]python
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)
OUT [10]Griegas agregadas a lo largo del precio
       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.9
Fig. — Griegas agregadas del iron condor frente al nivel de ORX, a 30 y a 5 días del vencimiento (delta en USD por punto, vega en USD por punto de volatilidad): la delta se vuelve más empinada cerca de los strikes vendidos y la vega se concentra en ellos

A 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,

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

con Δσ\Delta\sigma en puntos y Δt\Delta t 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:

IN [11]python
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)
OUT [11]P&L explicado de un día
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: float64
Fig. — P&L de un día del iron condor (19 a 20-02-2020) explicado con las griegas de la víspera: el índice sube 3 puntos y la posición pierde por la vega

El 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:

IN [12]python
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)
OUT [12]Rejilla de escenarios: precio × volatilidad × tiempo
       -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. — Rejilla de escenarios del iron condor a 10 días: P&L en USD por movimiento de ORX y desplazamiento paralelo de la volatilidad, en cuatro tramos

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

IN [13]python
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})
OUT [13]La rejilla frente a lo que pasó
              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

El 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. dvol es 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

  1. Cobertura delta con la pata S. Añade al condor del 19-02-2020 una pata S con la cantidad que deja la delta agregada en cero y comprueba con griegas que 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.

IN [14]python
d0 = condor.griegas(S0)["delta"]
cubierto = Posicion(condor.patas + [Pata("S", -d0 / 100, prima=S0)],
                    r0, q0)
g1 = cubierto.griegas(S0)
print({k: round(float(g1[k]), 4) for k in GRIEGAS})
OUT [14]Condor con cobertura delta
{'delta': 0.0, 'gamma': -0.4257, 'vega': -280.8596, 'theta': 40.4759}
  1. 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.

IN [15]python
v2 = pd.Timestamp("2020-04-17")
lejos = cad[(cad["fecha"] == hoy) & (cad["vencimiento"] == v2)]
lejos = lejos.set_index(["tipo", "strike"])
f_l = lejos.loc[("P", 2950.0)]
cal = Posicion([pata("P", 2950, -1),
                Pata("P", 1, 2950, (v2 - hoy).days, f_l["iv_modelo"],
                     f_l["ask"])], r0, q0)
Sc = np.array([2800.0, 2900.0, 2950.0, 3000.0, 3100.0])
print("payoff:", cal.payoff(Sc).round(0))
print("payoff con +5 pt:",
      cal.pnl(Sc, 0.05, cal.vencimiento()).round(0))
print("vega hoy:", round(float(cal.griegas(S0)["vega"]), 1))
OUT [15]Calendario de puts 2950
payoff: [-1297.   214.  2180.   174. -1448.]
payoff con +5 pt: [-675. 1661. 3807. 1680. -693.]
vega hoy: 145.2
  1. Escenario con skew. Sustituye el desplazamiento paralelo por uno que dependa del strike: dvol de 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

  1. 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 bsm está 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.

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

  3. ¿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 vega y theta, las unidades son las que dice la documentación.

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

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

Recursos

Datos