---
title: "3.2.6 Estacionalidad de spreads"
description: "Análisis estacional multi-año de spreads de futuros con contratos fijos, armónico anual, error típico del Sharpe y las trampas: serie continua y azar."
url: "https://pythonparatrading.com/curso-python-trading/nivel-3/estacionalidad-de-spreads/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-3/estacionalidad-de-spreads.png"
---

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

- Curso: [Nivel 3: Derivados: futuros, spreads, forex y opciones](https://pythonparatrading.com/curso-python-trading/nivel-3/)
- Módulo: 3.2 · Spreads de futuros
- Duración: 70 min
- Requisitos: [1.4.6 · Series continuas de futuros: reglas de roll, ajuste por diferencia y ajuste por ratio](https://pythonparatrading.com/curso-python-trading/nivel-1/series-continuas-de-futuros/), [1.6.3 · Estacionalidad: día de la semana, mes, cambio de mes y sesiones intradía](https://pythonparatrading.com/curso-python-trading/nivel-1/estacionalidad-y-efectos-calendario/), [2.1.8 · El problema de las pruebas múltiples: Bonferroni, FDR y el «factor zoo»](https://pythonparatrading.com/curso-python-trading/nivel-2/pruebas-multiples-bonferroni-fdr-y-factor-zoo/), [2.5.4 · Deflated Sharpe Ratio y Probability of Backtest Overfitting (PBO)](https://pythonparatrading.com/curso-python-trading/nivel-2/deflated-sharpe-ratio-y-pbo/), [3.2.2 · Spreads intercommodity: crack, spark y crush](https://pythonparatrading.com/curso-python-trading/nivel-3/spreads-intercommodity-crack-spark-y-crush/), [3.2.5 · Datos y backtesting de spreads: cotizados, sintéticos y por patas](https://pythonparatrading.com/curso-python-trading/nivel-3/datos-y-backtesting-de-spreads-por-patas/)

## Al terminar esta lección

- Construir el cambio diario de un spread con contratos fijos, sin los saltos del roll, para cestas de dos y tres patas y para calendarios.
- Separar la forma estacional de la curva (descontada en el precio) de una deriva estacional (la parte que se puede operar).
- Medir la estacionalidad con un armónico anual y su contraste F, y compararla con la respuesta conocida en spreads plantados y en controles.
- Calcular el error típico de un Sharpe con pocos años, aplicar correcciones por pruebas múltiples y validar el calendario fuera de muestra.
- Reconocer las trampas del análisis estacional: la serie continua sin ajustar, el gráfico de medias, el calendario elegido con toda la muestra y el azar de 16 observaciones.

## 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\left(\frac{2\pi\,(d - d^{\ast})}{365{,}25}\right)
$$

con $d$ el día del año, $d^{\ast}$ el día del máximo y $A$ 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^{-\kappa_Q \tau}$ ($\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.

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

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

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

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

```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}")
```
```output
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).* Dos paneles por semana del año. En el crack de la gasolina, destacado, la media sube hasta 0,068 en la semana 16 y baja después, pero los cuartiles van de −0,09 a 0,12. En el crack del gasóleo, sin estacionalidad plantada, la media se queda entre −0,013 y 0,013 y los cuartiles van de −0,09 a 0,08: casi lo mismo.

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 $\Delta s_t$ sobre los
incrementos diarios de un coseno y un seno anuales:

$$
\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 $\sqrt{a^2 + b^2}$ y el máximo, el día $\operatorname{atan2}(b, a) \cdot 365{,}25 / 2\pi$. El contraste
F de $a = 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.

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

```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)
```
```output
                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.* Seis barras horizontales en parejas. Crack de la gasolina: 0,032 con máximo el 21 de mayo con los mismos contratos y 0,090 con máximo el 30 de abril con la serie continua, destacada. Spark: 0,052 (17 de agosto) frente a 0,106 (6 de julio). Trigo-maíz: 0,021 (12 de enero) frente a 0,043 (9 de noviembre). La serie continua duplica o triplica la amplitud y mueve el máximo semanas o meses.

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:

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

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

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

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

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

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

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

Un patrón estacional sin una razón física (almacenamiento, cosecha, demanda de calefacción o de conducción, cambio de
especificación del producto) es un candidato a coincidencia, por bueno que sea su Sharpe. Y uno con razón física puede
estar ya descontado en la curva. Antes de operarlo: contratos fijos, contraste, calendario fuera de muestra, corrección
por pruebas múltiples y costes de las dos patas y de los rolls (lección 3.2.5).

</aside>

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

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

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

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

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

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

   - A. Que el oro-plata tiene una estacionalidad que el modelo no recoge
   - 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
   - C. Que hay que operar el oro-plata y no el crush
   - D. Que el error típico está mal calculado

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

   - 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
   - B. De un error de redondeo
   - C. De que la serie continua tiene más datos
   - D. De que la gasolina es más volátil en abril

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

   - A. Sí: comprar octubre y vender enero gana un 17 %
   - 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
   - C. Sí, pero solo en invierno
   - D. No, porque el gas no es estacional

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

   - A. Nada: el día del máximo no es un parámetro
   - B. El Sharpe baja siempre
   - C. Solo cambia en los controles
   - 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

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

   - A. t = 1,65, un Sharpe de 0,4
   - B. t = 1,96, un Sharpe de 0,5
   - 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)
   - D. No hace falta corregir: cada spread es independiente

   *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

### Research

- [Estacionalidad en Python II - Backtesting](https://pythonparatrading.com/estacionalidad-intradia-backtesting/) · Research-0025 · 8 min

### Documentación

- [Lo (2002): The Statistics of Sharpe Ratios, Financial Analysts Journal 58(4)](https://doi.org/10.2469/faj.v58.n4.2453) · doi.org
- [EIA: Factors affecting gasoline prices (márgenes de refino y demanda de verano)](https://www.eia.gov/energyexplained/gasoline/factors-affecting-gasoline-prices.php) · eia.gov
- [EIA: Factors affecting natural gas prices (almacenamiento y demanda de invierno)](https://www.eia.gov/energyexplained/natural-gas/factors-affecting-natural-gas-prices.php) · eia.gov
- [statsmodels: multipletests (Bonferroni, Holm, FDR)](https://www.statsmodels.org/stable/generated/statsmodels.stats.multitest.multipletests.html) · statsmodels.org

### Datos

- [Datos del nivel 3: verdad de los spreads (CSV)](https://pythonparatrading.com/curso/datos/nivel-3/spreads3_verdad.csv) · Descarga · spreads3_verdad.csv
- [Datos del nivel 3: curvas completas de futuros (Parquet)](https://pythonparatrading.com/curso/datos/nivel-3/futuros3_curvas.parquet) · Descarga · futuros3_curvas.parquet

Lección anterior: [3.2.5 · Datos y backtesting de spreads: cotizados, sintéticos y por patas](https://pythonparatrading.com/curso-python-trading/nivel-3/datos-y-backtesting-de-spreads-por-patas/)

Lección siguiente: [3.3.1 · Microestructura FX: OTC, ECN, last look y sesiones](https://pythonparatrading.com/curso-python-trading/nivel-3/microestructura-fx-otc-ecn-y-last-look/)
