Lección 3.2.6 · 70 min

Estacionalidad de spreads: análisis multi-año, patrones y trampas

Los spreads de materias primas tienen fama de estacionales: el crack de la gasolina antes del verano, el gas antes del invierno, la soja tras la cosecha. Esta lección mide la estacionalidad en nueve spreads de los datos, cinco con un patrón plantado y cuatro controles sin ninguno, y enseña por qué con 16 años de datos un control puede parecer mejor que un efecto real.

Fig. — Estrategia estacional en los nueve spreads de los datos, 2010-2025: Sharpe anual y dos errores típicos a cada lado (fórmula de Lo, 16 años); C = control sin estacionalidad plantada
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)

Dos estacionalidades distintas

Que el crack de la gasolina «sube antes del verano» o que el gas de invierno «es caro» son frases con dos sentidos que conviene separar desde el principio.

  • La forma estacional de la curva. Los contratos de entrega en verano de gasolina valen más que los de invierno; el gas de enero vale más que el de octubre. Es una diferencia de nivel entre meses de entrega, conocida por todos y descontada en los precios. La Energy Information Administration de EE. UU. explica las razones físicas: los márgenes de refino varían por estaciones y la demanda de gasolina suele subir en verano; el almacenamiento de gas cubre los picos de demanda de invierno (enlaces en los recursos). Comprar el contrato barato y vender el caro no gana esa diferencia: los dos contratos convergen cada uno a su propio precio de entrega.
  • La deriva estacional. Lo que se puede operar es otra cosa: que el spread de dos contratos fijos tienda a subir en una parte del año y a bajar en otra, más de lo que la curva descontaba. Si existe, es la forma de ganar dinero con la estacionalidad.

Los datos del nivel separan las dos a propósito. Su modelo tiene una forma estacional de la curva (descontada) y, en cinco spreads, una deriva estacional plantada cuya suma acumulada es

A cos⁡(2π (d−d∗)365,25)A\,\cos\left(\frac{2\pi\,(d - d^{\ast})}{365{,}25}\right)

con dd el día del año, d∗d^{\ast} el día del máximo y AA la amplitud en logaritmos: el spread sube del valle al máximo durante medio año y baja el otro medio. La curva no la descuenta, pero la atenúa en los contratos lejanos: un par de contratos con vencimiento a τ\tau años la recibe multiplicada por e−κQτe^{-\kappa_Q \tau} (κQ\kappa_Q es la velocidad de reversión que descuenta la curva). Los otros cuatro spreads son controles: no tienen ninguna deriva estacional. Son datos sintéticos: ninguna cifra de esta lección dice que un spread real sea estacional ni cuánto.

IN [01]python
import numpy as np
import pandas as pd
from scipy import stats

ver = pd.read_csv("spreads3_verdad.csv", index_col="spread")
NUEVE = ["crack_gasolina", "spark", "invierno_verano_gas", "crush",
         "trigo_maiz", "crack_gasoleo", "aceite_harina", "oro_plata",
         "calendario_crudo"]
v = ver.loc[NUEVE, ["patas", "control", "amplitud_estacional_log",
                    "fecha_pico", "kappa_q"]]
v.columns = ["patas", "control", "A", "pico", "kappa_q"]
v
OUT [01]Los nueve spreads y su respuesta conocida
                               patas  control      A   pico  kappa_q
spread
crack_gasolina               PEG/PEC    False  0.030  05-31      4.0
spark                        ELB/GNA    False  0.080  07-16      6.0
invierno_verano_gas  GNA ene/GNA oct    False  0.035  09-30      1.0
crush                    GRH/GRA/GRS    False  0.025  12-15      2.5
trigo_maiz                   GRT/GRM    False  0.018  01-14      1.0
crack_gasoleo                PEH/PEC     True    NaN    NaN      3.0
aceite_harina                GRA/GRH     True    NaN    NaN      0.8
oro_plata                    MTO/MTP     True    NaN    NaN      0.0
calendario_crudo         PEC 1.º/2.º     True    NaN    NaN      1.8

El crack de la gasolina tiene su máximo plantado el 31 de mayo; el spark (electricidad punta frente a gas), el 16 de julio; la prima de invierno del gas (enero frente a octubre), el 30 de septiembre; el crush de la soja, el 15 de diciembre, y el trigo frente al maíz, el 14 de enero. Los controles son el crack del gasóleo, el reparto del crush entre aceite y harina, el ratio oro/plata (un paseo aleatorio con un guion de episodios) y el calendario 1.º-2.º del crudo. Todos los spreads se miden en logaritmos: el de dos patas es ln⁡F1−ln⁡F2\ln F_1 - \ln F_2, la apuesta de igual valor en las dos patas de la lección 3.2.4.

El análisis honesto tiene seis pasos, y cada uno evita una trampa de las que se ven más abajo:

Contratos fijosCambios diariosArmónico y FCalendario previoSharpe y su errorPruebas múltiples
Fuente Mermaid
flowchart TB
  A[Contratos fijos] --> B[Cambios diarios]
  B --> C[Armónico y F]
  C --> D[Calendario previo]
  D --> E[Sharpe y su error]
  E --> F[Pruebas múltiples]

Construir el spread sin saltos

La estacionalidad se mide sobre el cambio diario de un spread con los mismos contratos, nunca sobre una serie que cambia de contrato. Para un spread intercommodity (lección 3.2.2), las dos patas tienen que tener el mismo mes de entrega y cambiar de mes a la vez: se toma el primer mes común cuya fecha de roll no ha llegado en ninguna de las patas. El crush tiene tres patas (harina y aceite contra soja, con los pesos del margen de molienda), así que cada lado es una cesta.

IN [02]python
cur = pd.read_parquet("futuros3_curvas.parquet")
ctr = pd.read_csv("futuros3_contratos.csv",
                  parse_dates=["vencimiento", "fecha_roll"])
px = cur.set_index(["fecha", "contrato"])["liquidacion"]
nombre = ctr.set_index(["mercado", "mes_entrega"])["contrato"]
fechas = pd.DatetimeIndex(np.sort(cur["fecha"].unique()))
f0 = np.r_[fechas[:1], fechas[:-1]]       # sesión anterior
LETRAS = "FGHJKMNQUVXZ"

def mes_comun(mercados, letras=None):
    """Mes de entrega que se tiene en todas las patas: el primero
    cuya fecha de roll no ha llegado en ninguna de las patas."""
    t = ctr[ctr["mercado"].isin(mercados)]
    if letras:
        mes = t["mes_entrega"].str[5:].astype(int)
        t = t[mes.map(lambda k: LETRAS[k - 1]).isin(list(letras))]
    g = t.groupby("mes_entrega").agg(n=("mercado", "nunique"),
                                     roll=("fecha_roll", "min"))
    roll = g.loc[g["n"] == len(mercados), "roll"].sort_index()
    roll = roll[::-1].cummin()[::-1]
    i = np.searchsorted(roll.to_numpy(), fechas.to_numpy(), "right")
    meses = np.r_[roll.index.to_numpy(), [None]]
    return pd.Series(meses[i], index=fechas)

def precio(f, contratos):
    return px.reindex(list(zip(f, contratos))).to_numpy()

def cesta(patas, f, mes):
    """Valor de una cesta [(mercado, peso)] con el mes `mes`."""
    v = np.zeros(len(f))
    for mk, peso in patas:
        c = nombre.reindex(list(zip([mk] * len(mes), mes))).to_numpy()
        v = v + peso * precio(f, c)
    return v

def simple(x):
    return x - 1

def cambio(largo, corto, letras=None, fn=np.log):
    """Cambio diario con los contratos del día anterior (sin saltos
    de roll): del log-spread ln(largo) - ln(corto) o, con fn=simple,
    rendimiento simple del largo menos el del corto."""
    mes = mes_comun({m for m, _ in largo + corto}, letras).shift(1)
    d = (fn(cesta(largo, fechas, mes) / cesta(largo, f0, mes))
         - fn(cesta(corto, fechas, mes) / cesta(corto, f0, mes)))
    return pd.Series(d, index=fechas).iloc[1:]

PATAS = {
    "crack_gasolina": ([("PEG", 42)], [("PEC", 1)], None),
    "crack_gasoleo": ([("PEH", 42)], [("PEC", 1)], None),
    "spark": ([("ELB", 1)], [("GNA", 7)], None),
    "crush": ([("GRH", 0.022), ("GRA", 0.11)], [("GRS", 0.01)],
              "FHKNQU"),
    "aceite_harina": ([("GRA", 1)], [("GRH", 1)], "FHKNQUVZ"),
    "oro_plata": ([("MTO", 1)], [("MTP", 1)], None),
    "trigo_maiz": ([("GRT", 1)], [("GRM", 1)], "HKNUZ"),
}
D = {k: cambio(*v) for k, v in PATAS.items()}               # log
R = {k: cambio(*v, fn=simple) for k, v in PATAS.items()}    # simple
print({k: int(v.notna().sum()) for k, v in D.items()})
OUT [02]Cambio diario de un spread con los mismos contratos
{'crack_gasolina': 4023, 'crack_gasoleo': 4023, 'spark': 4023, 'crush': 4023, 'aceite_harina': 4023, 'oro_plata': 4023, 'trigo_maiz': 4023}

Las siete series tienen las 4.023 sesiones con cambio de 2010 a 2025. La regla del mes común importa: con la fecha de roll más tardía de las patas, en lugar de la más temprana, en los cracks una pata ya vencida deja unas veinte sesiones al año sin precio (el crudo deja de cotizar antes que la gasolina del mismo mes). La tabla de medidas del README de los datos usa esa otra regla, y por eso sus cifras de los cracks no coinciden con las de esta lección: la construcción del spread ya mueve el resultado.

Los dos calendarios se construyen aparte: el del crudo con el par cotizado de spreads3_cotizados.parquet (lección 3.2.5), y la prima de invierno del gas con el par fijo de enero frente al octubre anterior, que se mantiene hasta que vence octubre:

IN [03]python
cot = pd.read_parquet("spreads3_cotizados.parquet")
pec = cot[cot["mercado"] == "PEC"].set_index("fecha")
c1 = pec["pata_cerca"].shift(1).reindex(fechas)
c2 = pec["pata_lejos"].shift(1).reindex(fechas)

def par(a, b, fn):
    return (fn(precio(fechas, a) / precio(f0, a))
            - fn(precio(fechas, b) / precio(f0, b)))

# Gas: enero del año siguiente frente a octubre, hasta que vence octubre
ano = np.where(fechas.month <= 10, fechas.year, fechas.year + 1)
ene = pd.Series([f"GNAF{(a + 1) % 100:02d}" for a in ano], fechas)
octb = pd.Series([f"GNAV{a % 100:02d}" for a in ano], fechas)
e1, o1 = ene.shift(1), octb.shift(1)
vivo = fechas <= ctr.set_index("contrato")["vencimiento"].reindex(o1)
for dic, fn in ((D, np.log), (R, simple)):
    dic["calendario_crudo"] = pd.Series(par(c1, c2, fn),
                                        index=fechas).iloc[1:]
    gas = np.where(vivo, par(e1, o1, fn), np.nan)
    dic["invierno_verano_gas"] = pd.Series(gas, index=fechas).iloc[1:]
D = {k: v.replace([np.inf, -np.inf], np.nan) for k, v in D.items()}
R = {k: v.replace([np.inf, -np.inf], np.nan) for k, v in R.items()}
pd.Series({k: v.std() * np.sqrt(252) for k, v in D.items()},
          name="vol_anual").round(3)
OUT [03]Los dos calendarios: crudo 1.º-2.º y gas enero-octubre
crack_gasolina         0.163
crack_gasoleo          0.140
spark                  0.287
crush                  0.103
aceite_harina          0.272
oro_plata              0.180
trigo_maiz             0.166
calendario_crudo       0.040
invierno_verano_gas    0.156
Name: vol_anual, dtype: float64

Las volatilidades van del 4 % anual del calendario del crudo al 29 % del spark. Importan para lo que viene: una deriva estacional de unos pocos puntos en un spread que se mueve un 20 % al año es difícil de ver con pocos años.

El gráfico estacional y su ruido

La herramienta clásica del análisis estacional es el perfil multi-año: el cambio acumulado del spread desde el 1 de enero, año a año, y su media. Es lo que publican los servicios de estacionalidad de futuros. Con los dos cracks:

IN [04]python
def perfil(serie):
    """Cambio acumulado del log-spread desde el 1 de enero de cada
    año, medido cada semana: media y cuartiles entre años."""
    s = serie.fillna(0)
    acum = s.groupby(s.index.year).cumsum()
    semana = np.minimum((s.index.dayofyear - 1) // 7 + 1, 52)
    tabla = acum.groupby([s.index.year, semana]).last().unstack(0)
    return pd.DataFrame({"media": tabla.mean(axis=1),
                         "p25": tabla.quantile(0.25, axis=1),
                         "p75": tabla.quantile(0.75, axis=1),
                         "error": tabla.std(axis=1) / np.sqrt(16)})

PERF = {k: perfil(D[k]) for k in ["crack_gasolina", "crack_gasoleo"]}
for k, p in PERF.items():
    print(f"{k:15s} media: mínimo semana {p['media'].idxmin():2d} "
          f"({p['media'].min():+.3f}), máximo semana "
          f"{p['media'].idxmax():2d} ({p['media'].max():+.3f}); "
          f"error típico de la media en la semana 22: "
          f"{p.at[22, 'error']:.3f}")
OUT [04]Perfil estacional: la media de 16 años
crack_gasolina  media: mínimo semana 36 (-0.009), máximo semana 16 (+0.068); error típico de la media en la semana 22: 0.030
crack_gasoleo   media: mínimo semana 19 (-0.013), máximo semana 26 (+0.013); error típico de la media en la semana 22: 0.019
Fig. — Perfil estacional de dos cracks de los datos: cambio acumulado del log-spread desde el 1 de enero, media de 16 años (línea continua) y cuartiles entre años (discontinuas)

La media del crack de la gasolina sube un 6,8 % hasta la semana 16 (mediados de abril) y baja después; el máximo plantado está en la semana 22 (31 de mayo). El del gasóleo, sin estacionalidad, se queda entre −1,3 % y +1,3 %. El perfil apunta en la dirección correcta, pero dos cosas obligan a no fiarse de él a simple vista. La primera es su error: la media de 16 años tiene un error típico de 3 puntos en la semana 22, del mismo orden que la propia subida media en esas semanas. La segunda son los cuartiles de la figura: en los dos spreads, la mitad de los años quedan en una banda de unos ±10 puntos; los años individuales no siguen el perfil, y el control tiene una dispersión casi igual. Un gráfico de medias enseña una curva suave aunque los datos sean ruido, porque promediar suaviza; por eso hace falta un contraste.

Un armónico anual con su contraste

Un contraste natural para una deriva con forma de coseno es una regresión del cambio diario Δst\Delta s_t sobre los incrementos diarios de un coseno y un seno anuales:

Δst=c+a Δcos⁡(2πdt365,25)+b Δsin⁡(2πdt365,25)+εt\Delta s_t = c + a\,\Delta\cos\left(\frac{2\pi d_t}{365{,}25}\right) + b\,\Delta\sin\left(\frac{2\pi d_t}{365{,}25}\right) + \varepsilon_t

La amplitud es a2+b2\sqrt{a^2 + b^2} y el máximo, el día atan2⁡(b,a)⋅365,25/2π\operatorname{atan2}(b, a) \cdot 365{,}25 / 2\pi. El contraste F de a=b=0a = b = 0 pregunta si hay estacionalidad. Tiene dos ventajas sobre el gráfico: usa todos los días (no 16 años) y busca una forma concreta, la del modelo, en vez de cualquier curva.

IN [05]python
W = 2 * np.pi / 365.25

def armonico(s):
    """Regresión del cambio diario sobre los incrementos diarios de
    cos y sin anuales: amplitud, día del máximo y p-valor F."""
    doy = s.index.dayofyear.to_numpy()
    dc = np.r_[np.nan, np.diff(np.cos(W * doy))]
    ds = np.r_[np.nan, np.diff(np.sin(W * doy))]
    ok = np.isfinite(s.to_numpy()) & np.isfinite(dc)
    X = np.c_[np.ones(ok.sum()), dc[ok], ds[ok]]
    y = s.to_numpy()[ok]
    b = np.linalg.lstsq(X, y, rcond=None)[0]
    rss1 = ((y - X @ b) ** 2).sum()
    rss0 = ((y - y.mean()) ** 2).sum()
    gl = len(y) - 3
    p = stats.f.sf((rss0 - rss1) / 2 / (rss1 / gl), 2, gl)
    pico = (np.arctan2(b[2], b[1]) / W) % 365.25
    return np.hypot(b[1], b[2]), pico, p

def fecha(dia):
    return (pd.Timestamp("2025-01-01")
            + pd.Timedelta(days=round(dia) - 1)).strftime("%m-%d")

filas = {}
for k in NUEVE:
    a, pico, p = armonico(D[k])
    filas[k] = {"control": ver.at[k, "control"],
                "A_plantada": ver.at[k, "amplitud_estacional_log"],
                "pico_plantado": ver.at[k, "fecha_pico"],
                "A_medida": a, "pico_medido": fecha(pico),
                "p_F": p}
med = pd.DataFrame.from_dict(filas, orient="index")
med.round(3)
OUT [05]Un armónico anual: amplitud, máximo y contraste F
                     control  A_plantada pico_plantado  A_medida pico_medido    p_F
crack_gasolina         False       0.030         05-31     0.032       05-21  0.000
spark                  False       0.080         07-16     0.052       08-17  0.001
invierno_verano_gas    False       0.035         09-30     0.016       10-16  0.095
crush                  False       0.025         12-15     0.013       12-28  0.042
trigo_maiz             False       0.018         01-14     0.021       01-12  0.033
crack_gasoleo           True         NaN           NaN     0.007       07-23  0.592
aceite_harina           True         NaN           NaN     0.010       07-01  0.767
oro_plata               True         NaN           NaN     0.016       06-11  0.204
calendario_crudo        True         NaN           NaN     0.001       09-02  0.915

Contra la respuesta conocida:

  • El crack de la gasolina y el spark se recuperan. Máximos medidos el 21 de mayo y el 17 de agosto frente al 31 de mayo y el 16 de julio plantados, con p de 0,0002 y 0,0008. La amplitud medida del spark (0,052) es menor que la plantada (0,080): su κQ=6\kappa_Q = 6 la atenúa en los contratos que se tienen. La del crack de la gasolina (0,032) es algo mayor que la plantada atenuada: el ruido también suma.
  • El trigo-maíz y el crush se rozan. Máximos casi exactos (12 de enero frente a 14, 28 de diciembre frente a 15) y p de 0,033 y 0,042: significativos al 5 % uno a uno, por poco.
  • La prima de invierno del gas no se ve. Máximo el 16 de octubre (plantado: 30 de septiembre), pero p = 0,095. Es un efecto real que el contraste no detecta con 16 años: un falso negativo.
  • Los controles no dan nada. p entre 0,20 (oro-plata) y 0,91 (calendario del crudo), con amplitudes de 0,001 a 0,016. El oro-plata tiene una amplitud medida (0,016) mayor que la del crush (0,013), que sí tiene efecto: su volatilidad hace que el contraste no la tome en serio.

La trampa de la serie continua

La forma más rápida de hacer un perfil estacional es tomar la serie continua sin ajustar del primer contrato de cada pata (lección 1.4.6) y restar. Parece inofensivo. Con los mismos datos:

IN [06]python
def frente(mk):
    """Precio del contrato que se tiene, sin ajustar (lección 1.4.6)."""
    cm = ctr[ctr["mercado"] == mk].sort_values("fecha_roll")
    i = np.searchsorted(cm["fecha_roll"], fechas, side="right")
    return pd.Series(precio(fechas, cm["contrato"].to_numpy()[i]),
                     index=fechas)

ing = {}
for k in ["crack_gasolina", "spark", "trigo_maiz",
          "crack_gasoleo", "aceite_harina"]:
    (a, _), (b, _) = PATAS[k][0][0], PATAS[k][1][0]
    nivel = np.log(frente(a)) - np.log(frente(b))
    A1, p1, f1 = armonico(nivel.diff())
    ing[k] = {"A_bien": med.at[k, "A_medida"],
              "pico_bien": med.at[k, "pico_medido"],
              "A_serie": A1, "pico_serie": fecha(p1), "p_serie": f1}
pd.DataFrame.from_dict(ing, orient="index").round(3)
OUT [06]La trampa de la serie continua
                A_bien pico_bien  A_serie pico_serie  p_serie
crack_gasolina   0.032     05-21    0.090      04-30    0.000
spark            0.052     08-17    0.106      07-06    0.010
trigo_maiz       0.021     01-12    0.043      11-09    0.001
crack_gasoleo    0.007     07-23    0.004      07-18    0.911
aceite_harina    0.010     07-01    0.010      07-26    0.771
Fig. — Amplitud y fecha del máximo del armónico anual en tres spreads con estacionalidad plantada (máximos plantados: 31 de mayo, 16 de julio y 14 de enero), medidos con los mismos contratos y con la serie continua sin ajustar del contrato que se tiene en cada pata

En los tres spreads con estacionalidad, la serie continua duplica o triplica la amplitud y mueve el máximo: el crack de la gasolina pasa de 0,032 a 0,090 con el máximo un mes antes (30 de abril), y el trigo-maíz, de enero a noviembre. Los contrastes salen más significativos que con los contratos fijos (p prácticamente 0 en el crack). La razón es la forma estacional de la curva: cada roll cambia de un mes de entrega a otro con distinto nivel estacional, y como los rolls caen en las mismas fechas cada año, sus saltos se repiten por calendario. El armónico los toma por una deriva. En los dos controles de la tabla los saltos del roll no tienen ese patrón anual y la serie continua no cambia el resultado (p de 0,91 y 0,77). Es el error más fácil de cometer y el más difícil de ver: la serie continua da un patrón más limpio y más significativo que el verdadero, y en otra fecha.

La misma confusión entre nivel y deriva aparece con el gas, donde la forma de la curva es enorme:

IN [07]python
g = cur[cur["mercado"] == "GNA"].pivot(index="fecha",
                                         columns="contrato",
                                         values="liquidacion")
nivel, subida = [], []
for a in range(2010, 2025):
    ene_a, oct_a = f"GNAF{(a + 1) % 100:02d}", f"GNAV{a % 100:02d}"
    par = np.log(g[ene_a] / g[oct_a])
    nivel.append(par.dropna().mean())
    tramo = D["invierno_verano_gas"].loc[f"{a}-04-10":f"{a}-09-20"]
    subida.append(tramo.sum())
print(f"Nivel medio de ln(enero / octubre): {np.mean(nivel):+.3f}")
print(f"Subida media del 10-04 al 20-09: {np.mean(subida):+.3f} "
      f"(años positivos: {np.sum(np.array(subida) > 0)} de 15)")
OUT [07]El nivel de la curva no es una deriva
Nivel medio de ln(enero / octubre): +0.170
Subida media del 10-04 al 20-09: +0.052 (años positivos: 12 de 15)

El contrato de enero vale de media un 17 % más que el octubre anterior (0,170 en log): es la prima de invierno que cualquier productor o almacenista conoce y que la curva descuenta. La subida media del spread entre abril y septiembre, el tramo que opera la estrategia, es de 0,052, con 12 años positivos de 15. Las dos cifras son estacionales, pero solo la segunda es una ganancia posible, y es un tercio de la primera.

Una estrategia por calendario y el error de su Sharpe

La estrategia más sencilla sobre una deriva estacional es mecánica: largo en el spread desde 10 días después del valle hasta 10 días antes del máximo, corto desde 10 días después del máximo hasta 10 días antes del valle, cada año, con la posición del día anterior. Los controles usan el calendario de la gasolina (máximo el 31 de mayo). El resultado de cada día es el de una posición con el mismo valor en las dos patas: el rendimiento simple de la pata larga menos el de la corta (la serie R de la celda de construcción); el armónico, en cambio, se estima con los cambios del log-spread (D). Con 16 años de datos hay, en la práctica, 16 observaciones de cada calendario, y el error típico de un Sharpe anual, con rendimientos aproximadamente normales e independientes (Lo, 2002; lección 2.5.4), es

ET(SR^)≈1+SR^2/2T\mathrm{ET}(\widehat{SR}) \approx \sqrt{\frac{1 + \widehat{SR}^2/2}{T}}

con TT en años: unos 0,25 con 16 años, sea cual sea el spread.

IN [08]python
def posicion(indice, pico):
    """+1 de valle + 10 días a máximo - 10; -1 de máximo + 10 a
    valle - 10; 0 el resto."""
    doy = indice.dayofyear.to_numpy()
    valle = (pico - 182.6) % 365.25
    sube = ((doy - (valle + 10)) % 365.25) < 162.6
    baja = ((doy - (pico + 10)) % 365.25) < 162.6
    return pd.Series(np.where(sube, 1.0, np.where(baja, -1.0, 0.0)),
                     index=indice)

def pico_de(k):   # los controles usan el calendario de la gasolina
    p = ver.at[k, "dia_pico"]
    return p if p == p else ver.at["crack_gasolina", "dia_pico"]

PNL = {}
filas = {}
for k in NUEVE:
    s = R[k]                    # rendimientos simples de las patas
    pnl = (posicion(s.index, pico_de(k)).shift(1) * s).fillna(0)
    PNL[k] = pnl
    anos = len(pnl) / 252
    sr = pnl.mean() / pnl.std() * np.sqrt(252)
    ee = np.sqrt((1 + sr**2 / 2) / anos)       # Lo (2002), anual
    por_ano = pnl.groupby(pnl.index.year).sum()
    filas[k] = {"control": ver.at[k, "control"], "sharpe": sr,
                "error_tipico": ee, "t": sr / ee,
                "anos_positivos":
                    f"{(por_ano > 0).sum()}/{len(por_ano)}"}
estr = pd.DataFrame.from_dict(filas, orient="index")
estr = estr.sort_values("sharpe", ascending=False)
estr.round(2)
OUT [08]Una estrategia por calendario y el error de su Sharpe
                     control  sharpe  error_tipico     t anos_positivos
trigo_maiz             False    0.70          0.28  2.52          13/16
invierno_verano_gas    False    0.59          0.27  2.18          13/16
crack_gasolina         False    0.54          0.27  2.03          11/16
spark                  False    0.51          0.27  1.91          13/16
oro_plata               True    0.39          0.26  1.52           9/16
crush                  False    0.37          0.26  1.45          12/16
aceite_harina           True    0.32          0.26  1.25          11/16
crack_gasoleo           True    0.08          0.25  0.30           8/16
calendario_crudo        True   -0.08          0.25 -0.31           7/16

La figura de la cabecera dibuja la tabla con barras de dos errores típicos. Los cuatro primeros, trigo-maíz (0,70), prima de invierno del gas (0,59), crack de la gasolina (0,54) y spark (0,51), tienen estacionalidad plantada. El quinto es un control: el oro-plata, con 0,39, por encima del crush (0,37), que sí la tiene, y con solo 9 años positivos de 16. El otro control con calendario prestado, aceite-harina, da 0,32 con 11 años positivos de 16. Las barras se solapan casi todas: con 16 años, un Sharpe de 0,3 a 0,6 no distingue un efecto de una coincidencia. Fíjate también en lo que no se ve en el contraste F: la prima de invierno del gas, que el contraste no detectaba (p = 0,095), es aquí la segunda mejor; y el oro-plata, que el contraste descartaba (p = 0,20), parece prometedor. El Sharpe y el contraste miden cosas distintas: el F, si hay una forma anual concreta; el Sharpe, si una regla gana dinero, por la razón que sea.

Con nueve spreads probados a la vez, además, hay que corregir por pruebas múltiples (lección 2.1.8):

IN [09]python
from statsmodels.stats.multitest import multipletests

t = estr["t"]
umbral = stats.norm.isf(0.05 / len(t))      # Bonferroni, unilateral
p_holm = multipletests(med["p_F"], alpha=0.05, method="holm")[1]
print(f"t que pide Bonferroni con 9 pruebas: {umbral:.2f}, "
      f"un Sharpe de {umbral * estr['error_tipico'].mean():.2f}")
pd.DataFrame({"control": estr["control"], "t": t.round(2),
              "pasa_bonferroni": t > umbral,
              "p_F": med["p_F"].reindex(estr.index).round(4),
              "p_F_holm": pd.Series(p_holm, med.index)
              .reindex(estr.index).round(3)})
OUT [09]Nueve pruebas a la vez
t que pide Bonferroni con 9 pruebas: 2.54, un Sharpe de 0.67
                     control     t  pasa_bonferroni     p_F  p_F_holm
trigo_maiz             False  2.52            False  0.0327     0.229
invierno_verano_gas    False  2.18            False  0.0955     0.477
crack_gasolina         False  2.03            False  0.0002     0.002
spark                  False  1.91            False  0.0008     0.006
oro_plata               True  1.52            False  0.2039     0.816
crush                  False  1.45            False  0.0415     0.249
aceite_harina           True  1.25            False  0.7670     1.000
crack_gasoleo           True  0.30            False  0.5924     1.000
calendario_crudo        True -0.31            False  0.9145     1.000

Bonferroni con nueve pruebas exige una t de 2,54 (unilateral): un Sharpe de unos 0,67 con 16 años. Ninguno pasa; el trigo-maíz se queda en 2,52. Con la corrección de Holm sobre los p del contraste F, que usa los datos diarios y la forma del patrón, sí pasan dos: el crack de la gasolina (p corregido 0,002) y el spark (0,006). El resto, incluidos tres efectos reales, no se distinguen del azar con estos datos. Es la conclusión honesta de 16 años de datos con efectos de este tamaño, y es la que el análisis estacional de spreads suele saltarse.

Elegir el calendario sin mirar el futuro

En la tabla anterior, el calendario era el verdadero, que en datos reales nadie conoce. Lo normal es estimarlo: el día del máximo sale del perfil o del armónico. Si se estima con toda la muestra y se mide la estrategia en la misma muestra, el día del máximo es un parámetro ajustado a los datos (lección 2.5.1). La forma honesta es estimarlo cada año con los años anteriores, desde 2014 para tener al menos cuatro años:

IN [10]python
def sharpe(x):
    return x.mean() / x.std() * np.sqrt(252)

fuera = {}
for k in NUEVE:
    s, lg = R[k], D[k]          # P&L simple; calendario con el log
    trozos = []
    for a in range(2014, 2026):
        _, pico, _ = armonico(lg[lg.index.year < a])
        sa = s[s.index.year == a]
        trozos.append(posicion(sa.index, pico).shift(1).fillna(0) * sa)
    wf = pd.concat(trozos).fillna(0)
    s2 = s[s.index.year >= 2014]
    _, pico_todo, _ = armonico(lg)
    dentro = (posicion(s2.index, pico_todo).shift(1) * s2).fillna(0)
    verdad = (posicion(s2.index, pico_de(k)).shift(1) * s2).fillna(0)
    fuera[k] = {"control": ver.at[k, "control"],
                "calendario_verdadero": sharpe(verdad),
                "estimado_con_todo": sharpe(dentro),
                "estimado_antes": sharpe(wf)}
pd.DataFrame.from_dict(fuera, orient="index").round(2)
OUT [10]Fuera de muestra: el calendario con los años anteriores
                     control  calendario_verdadero  estimado_con_todo  estimado_antes
crack_gasolina         False                  0.30               0.39            0.24
spark                  False                  0.52               0.62            0.56
invierno_verano_gas    False                  0.51               0.47            0.68
crush                  False                  0.24               0.65            0.38
trigo_maiz             False                  0.94               0.97            0.83
crack_gasoleo           True                  0.32               0.29            0.06
aceite_harina           True                  0.13               0.38            0.05
oro_plata               True                  0.24               0.26            0.26
calendario_crudo        True                 -0.01              -0.20           -0.44

Las tres columnas cubren los mismos años, 2014-2025. Con el calendario estimado con toda la muestra, el crush pasa de 0,24 (calendario verdadero) a 0,65, y el control aceite-harina, de 0,13 a 0,38: la estimación busca las fechas que mejor encajan con el ruido de esos años. Con el calendario estimado solo con los años anteriores, el crush da 0,38 y el aceite-harina, 0,05; el crack del gasóleo, 0,06. La validación fuera de muestra quita la mayor parte de la ventaja falsa, pero no toda: el oro-plata da entre 0,24 y 0,26 en las tres columnas. No es un error del método. El ratio oro/plata de los datos sigue un guion de episodios (subidas y caídas largas con fecha fija) y, con 12 años, algunos caen en las mismas mitades del año. Fuera de muestra, un control puede ganar durante una década por coincidencia de calendario.

Lo que la estacionalidad exige en real

  • Contratos fijos o series ajustadas. El perfil se calcula con el par que se tiene, sin saltos. Una serie continua sin ajustar fabrica estacionalidad en los mercados con forma estacional de curva, que son justo los estacionales.
  • Pocas observaciones. Dieciséis años son dieciséis repeticiones de cada calendario. Con un Sharpe de 0,5 y 16 años, el intervalo al 95 % va de casi 0 a 1. Más años ayudan poco si el mercado ha cambiado (nuevas especificaciones, exportaciones de gas licuado, biocombustibles): el patrón de 1995 no tiene por qué ser el de 2025.
  • Ventanas y márgenes. Los 10 días de margen en cada extremo son un parámetro más; cambiarlos cambia el resultado (ejercicio 1). Elegirlos mirando el resultado es otra forma de ajuste (lección 2.5.6).
  • Costes. Una estrategia estacional entra y sale dos veces al año, y además rola: en un spread de calendario o intercommodity, cada operación paga las dos patas (lección 3.2.5).
  • Riesgo de cola. Los spreads de energía y de electricidad tienen saltos (huracanes, olas de frío, paradas de refinería). El Sharpe de una estrategia estacional no enseña su peor año: hay que mirarlo (ejercicio 2).

Ejercicios

  1. Repite la estrategia del crack de la gasolina y del control oro-plata con márgenes de 0, 10, 20 y 30 días en cada extremo de las dos mitades del año. ¿Cambia el orden entre los dos?
Solución
IN [11]python
def posicion_margen(indice, pico, margen):
    doy = indice.dayofyear.to_numpy()
    valle = (pico - 182.6) % 365.25
    largo_tramo = 182.6 - 2 * margen
    sube = ((doy - (valle + margen)) % 365.25) < largo_tramo
    baja = ((doy - (pico + margen)) % 365.25) < largo_tramo
    return pd.Series(np.where(sube, 1.0, np.where(baja, -1.0, 0.0)),
                     index=indice)

tabla = {}
for k in ["crack_gasolina", "oro_plata"]:
    s = R[k]
    tabla[k] = {m: sharpe((posicion_margen(s.index, pico_de(k), m)
                           .shift(1) * s).fillna(0))
                for m in [0, 10, 20, 30]}
pd.DataFrame(tabla).T.round(2)
OUT [11]Márgenes de la ventana
                  0     10    20    30
crack_gasolina  0.49  0.54  0.70  0.65
oro_plata       0.27  0.39  0.39  0.24

El orden no cambia: el crack de la gasolina queda por encima del oro-plata con cualquier margen. Pero su Sharpe va de 0,49 a 0,70 solo por el margen, una diferencia de casi un error típico; quien publique el de 20 días sin decir que probó cuatro está informando del máximo de cuatro intentos (lección 2.5.4). El control tiene su mejor resultado con márgenes de 10 y 20 días (0,39 en los dos) y baja a 0,24 con 30: también tiene «un margen óptimo», sin ningún efecto detrás.

  1. Para los nueve spreads, calcula el peor año de la estrategia (suma de sus rendimientos diarios) y el Sharpe en dos mitades, 2010-2017 y 2018-2025. ¿Cuántos mantienen el signo? ¿Hay diferencia entre spreads plantados y controles?
Solución
IN [12]python
mitades = {}
for k in NUEVE:
    pnl = PNL[k]
    mitades[k] = {"control": ver.at[k, "control"],
                  "peor_ano": pnl.groupby(pnl.index.year).sum().min(),
                  "sharpe_2010_17": sharpe(pnl.loc[:"2017"]),
                  "sharpe_2018_25": sharpe(pnl.loc["2018":])}
pd.DataFrame.from_dict(mitades, orient="index").round(2)
OUT [12]Peor año y dos mitades
                     control  peor_ano  sharpe_2010_17  sharpe_2018_25
crack_gasolina         False     -0.19            0.80            0.30
spark                  False     -0.53            0.67            0.34
invierno_verano_gas    False     -0.11            0.70            0.49
crush                  False     -0.13            0.21            0.53
trigo_maiz             False     -0.22            0.38            1.06
crack_gasoleo           True     -0.25           -0.22            0.38
aceite_harina           True     -0.49            0.46            0.17
oro_plata               True     -0.15            0.65            0.11
calendario_crudo        True     -0.08            0.19           -0.38

Los cinco spreads con estacionalidad plantada ganan en las dos mitades. De los cuatro controles, dos cambian de signo (crack del gasóleo, de −0,22 a 0,38; calendario del crudo, de 0,19 a −0,38) y dos se mantienen positivos (aceite-harina, 0,46 y 0,17; oro-plata, 0,65 y 0,11): partir la muestra en dos ayuda poco con ocho años por mitad. El peor año dice lo que el Sharpe calla: el spark perdió en un año 0,53 (suma de los rendimientos diarios de la posición) y el control aceite-harina, 0,49. Una estrategia estacional sobre un spread volátil necesita un tamaño que aguante un año así.

  1. Diseña, sin código, el estudio de la estacionalidad del crack de la gasolina con datos reales de CME (gasolina RBOB y crudo WTI). ¿Qué datos pedirías, cómo construirías el spread, qué contrastarías y qué tendría que salir para operarlo?
Solución

Datos: las liquidaciones diarias de todos los contratos listados de las dos patas, con su calendario (vencimiento y primer día de aviso), no una serie continua; con Databento, el esquema statistics para las liquidaciones y definition para los vencimientos (lección 3.2.5). Construcción: pares del mismo mes de entrega con una regla de roll fija y anterior a que venza la primera pata, en logaritmos (la apuesta de igual valor de la lección 3.2.4) o en barriles si lo que interesa es el margen de refino. Contraste: el armónico anual (o una regresión con varias frecuencias) sobre los cambios diarios con los contratos fijos, con errores robustos a la autocorrelación (lección 2.1.1), y su estabilidad por subperiodos; el calendario, estimado cada año con los años anteriores. Para operarlo tendría que salir un patrón con razón física (la EIA describe la demanda de verano y los márgenes estacionales de refino), un contraste que sobreviva a la corrección por el número de spreads y ventanas probados, un resultado fuera de muestra positivo neto de los costes de las dos patas y de los rolls, y un peor año aceptable para el tamaño de la posición. Si solo sale un Sharpe de 0,4 con 15 años, lo honesto es decir que no se distingue del azar.

Respuesta plegada en cada pregunta

Autoevaluación

  1. El spread oro-plata de los datos, un control sin estacionalidad, da un Sharpe de 0,39 con la estrategia estacional, por encima del crush, que sí la tiene. Con 16 años, el error típico es 0,26. ¿Qué concluyes?

    • AQue el oro-plata tiene una estacionalidad que el modelo no recoge
    • BQue con 16 años un Sharpe de 0,39 está a 1,5 errores típicos de cero: es compatible con no tener nada, y su contraste F (p = 0,20) tampoco ve estacionalidad
    • CQue hay que operar el oro-plata y no el crush
    • DQue el error típico está mal calculado
    Ver respuesta

    Respuesta B. Que con 16 años un Sharpe de 0,39 está a 1,5 errores típicos de cero: es compatible con no tener nada, y su contraste F (p = 0,20) tampoco ve estacionalidad

    El error típico de un Sharpe anual es aproximadamente √((1 + SR²/2) / años). Con 16 años, 0,26: cualquier Sharpe entre −0,5 y +0,5 es compatible con cero a dos errores típicos. El orden de los Sharpes entre 0,3 y 0,6 es ruido.

  2. Con la serie continua sin ajustar del contrato que se tiene en cada pata, el crack de la gasolina tiene una amplitud estacional de 0,090 y máximo el 30 de abril; con contratos fijos, 0,032 y el 21 de mayo. ¿De dónde sale la diferencia?

    • ADe los saltos del roll: cada año, en las mismas fechas, la serie pasa de un mes de entrega a otro con distinta forma estacional en la curva, y esos saltos repetidos por calendario parecen estacionalidad
    • BDe un error de redondeo
    • CDe que la serie continua tiene más datos
    • DDe que la gasolina es más volátil en abril
    Ver respuesta

    Respuesta A. De los saltos del roll: cada año, en las mismas fechas, la serie pasa de un mes de entrega a otro con distinta forma estacional en la curva, y esos saltos repetidos por calendario parecen estacionalidad

    La curva de la gasolina tiene una forma estacional por mes de entrega (los contratos de verano valen más). Al rodar de un mes a otro siempre en las mismas fechas, la serie sin ajustar salta igual cada año. El armónico lo recoge como si fuera una deriva: triplica la amplitud y adelanta el máximo un mes. En el trigo-maíz lo mueve de enero a noviembre.

  3. En los datos, el contrato de gas de enero cotiza de media un 17 % por encima del de octubre anterior. ¿Es una oportunidad?

    • ASí: comprar octubre y vender enero gana un 17 %
    • BNo por sí mismo: esa diferencia de nivel es la forma estacional de la curva, que ya está en los precios; lo operable es cuánto se mueve el spread de dos contratos fijos a lo largo del año, que es otra cosa
    • CSí, pero solo en invierno
    • DNo, porque el gas no es estacional
    Ver respuesta

    Respuesta B. No por sí mismo: esa diferencia de nivel es la forma estacional de la curva, que ya está en los precios; lo operable es cuánto se mueve el spread de dos contratos fijos a lo largo del año, que es otra cosa

    Que un contrato de invierno valga más que uno de otoño es lo esperable con almacenamiento caro y demanda de calefacción, y el mercado lo descuenta. El rendimiento de una posición depende de cómo cambia el spread, no de su nivel. En los datos, la subida media del spread entre abril y septiembre es de 0,052 en log, un tercio del nivel.

  4. Se estima el día del máximo estacional con los 16 años y se mide el Sharpe de la estrategia en esos mismos años. ¿Qué pasa en los datos?

    • ANada: el día del máximo no es un parámetro
    • BEl Sharpe baja siempre
    • CSolo cambia en los controles
    • DEl Sharpe se infla: en 2014-2025 el crush pasa de 0,24 con el calendario verdadero a 0,65 con el estimado con toda la muestra, y el control aceite-harina, de 0,13 a 0,38; estimado solo con los años anteriores, 0,38 y 0,05
    Ver respuesta

    Respuesta D. El Sharpe se infla: en 2014-2025 el crush pasa de 0,24 con el calendario verdadero a 0,65 con el estimado con toda la muestra, y el control aceite-harina, de 0,13 a 0,38; estimado solo con los años anteriores, 0,38 y 0,05

    El día del máximo se elige para que la estrategia gane en la muestra: es un parámetro ajustado, y el resultado dentro de muestra lleva el sesgo de selección. La forma honesta es estimarlo cada año con los años anteriores (fuera de muestra, lección 2.5.2).

  5. Se prueban nueve spreads con la estrategia estacional. Con Bonferroni al 5 % (unilateral), ¿qué t hace falta y qué Sharpe supone con 16 años?

    • At = 1,65, un Sharpe de 0,4
    • Bt = 1,96, un Sharpe de 0,5
    • Ct = 2,54, un Sharpe de unos 0,67: ninguno de los nueve pasa, ni siquiera el trigo-maíz (0,70, t = 2,52)
    • DNo hace falta corregir: cada spread es independiente
    Ver respuesta

    Respuesta C. t = 2,54, un Sharpe de unos 0,67: ninguno de los nueve pasa, ni siquiera el trigo-maíz (0,70, t = 2,52)

    Con 9 pruebas el umbral de cada una baja a 0,05 / 9 y la t necesaria sube a 2,54. Con un error típico medio de 0,26, eso es un Sharpe de 0,67. El contraste F diario, que usa la forma del patrón, tiene más potencia: con la corrección de Holm, el crack de la gasolina y el spark pasan.

Ideas clave

  • La estacionalidad de un spread se mide con contratos fijos: el cambio diario de un par que no cambia. Con la serie continua sin ajustar, los rolls en fechas fijas fabrican estacionalidad: la amplitud del crack de la gasolina pasa de 0,032 a 0,090.
  • El nivel estacional de la curva (enero un 17 % por encima de octubre en el gas) está descontado; lo operable es la deriva de un par fijo, mucho menor.
  • El armónico anual con su contraste F recupera los efectos plantados más fuertes (crack de la gasolina, spark: p < 0,001), roza los demás y no ve nada en los cuatro controles (p entre 0,20 y 0,91).
  • Con 16 años, el error típico de un Sharpe anual es de unos 0,26: el control oro-plata (0,39) queda por encima del crush (0,37), que sí tiene efecto, y ninguna estrategia pasa Bonferroni con nueve pruebas (hace falta un Sharpe de 0,67).
  • El calendario elegido con toda la muestra infla el resultado (crush de 0,24 a 0,65; el control aceite-harina, de 0,13 a 0,38). Hay que estimarlo con los años anteriores.
  • Incluso fuera de muestra, un control puede ganar doce años seguidos por coincidencia de calendario (oro-plata, 0,26): la estacionalidad necesita una razón física, no solo un Sharpe.

Recursos

Datos