---
title: "2.1.2 Estacionariedad: ADF, KPSS y diferenciación"
description: "ADF y KPSS sobre precios, rentabilidades y spreads con respuesta conocida, las cuatro combinaciones y la diferenciación fraccional que conserva memoria."
url: "https://pythonparatrading.com/curso-python-trading/nivel-2/estacionariedad-adf-kpss-y-diferenciacion-fraccional/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-2/estacionariedad-adf-kpss-y-diferenciacion-fraccional.png"
---

# 2.1.2 · Estacionariedad: ADF, KPSS, diferenciación y diferenciación fraccional

> Casi todos los métodos estadísticos suponen que la serie no cambia de naturaleza con el tiempo. Los precios no cumplen esa condición; las rentabilidades sí, pero a cambio olvidan dónde estaba el precio. Esta lección mide la estacionariedad con dos contrastes de hipótesis opuestas y busca el término medio: la diferencia fraccional más pequeña que la consigue.

- Curso: [Nivel 2: Estrategias sistemáticas y backtesting riguroso](https://pythonparatrading.com/curso-python-trading/nivel-2/)
- Módulo: 2.1 · Estadística y econometría para trading
- Duración: 65 min
- Requisitos: [1.6.2 · Hechos estilizados: colas gruesas, agrupamiento, autocorrelación y apalancamiento](https://pythonparatrading.com/curso-python-trading/nivel-1/hechos-estilizados-de-los-mercados/), [2.1.1 · Contrastes de hipótesis, p-valores, bootstrap y regresión con errores robustos](https://pythonparatrading.com/curso-python-trading/nivel-2/contrastes-bootstrap-y-errores-robustos/)

## Al terminar esta lección

- Explicar qué es una serie estacionaria y por qué un precio no lo es, con la regresión de Dickey-Fuller y su distribución especial.
- Aplicar ADF (nula: raíz unitaria) y KPSS (nula: estacionaria) con statsmodels y leer las cuatro combinaciones de resultados.
- Comprobar en spreads con respuesta conocida cuándo los contrastes aciertan, cuándo les falta muestra y cuándo la persistencia los confunde.
- Programar la diferenciación fraccional de ventana fija y encontrar la d mínima que pasa el ADF conservando la correlación con el precio.

## Qué significa estacionaria

Una serie es **estacionaria** (en sentido débil) si su media, su varianza y la covarianza entre dos observaciones
separadas $k$ periodos no dependen de la fecha. Dicho de otra forma: un tramo de 2005 y uno de 2023 son dos muestras
de la misma distribución. Es lo que da sentido a estimar una media, una volatilidad o una regresión con datos del
pasado y usarla en el futuro.

Los precios no son estacionarios. Un índice que cotizaba a 1.000 en 2003 y a 5.000 en 2024 no tiene «una media» a la
que volver; su varianza crece con el horizonte. El modelo más sencillo que lo recoge es el **paseo aleatorio**: el
precio de hoy es el de ayer más un choque, $p_t = p_{t-1} + \varepsilon_t$. Su diferencia, la rentabilidad, sí es
estacionaria. En la jerga, el precio es **integrado de orden 1**, $I(1)$, y la rentabilidad, $I(0)$.

```mermaid
flowchart LR
  P[Precio I1] --> L[Logaritmo]
  L --> D1[Diferencia d=1]
  L --> DF[Diferencia fracc.]
  D1 --> R[Rentabilidad I0]
  DF --> M[I0 con memoria]
```

Por qué importa en un sistema de trading:

- **Regresiones espurias.** Dos series integradas independientes dan correlaciones y t enormes por azar (lección
  0.7.2). Una regresión de un precio sobre otro solo tiene sentido si están cointegrados (lección 2.1.5).
- **Reversión a la media.** Una regla que compra cuando el precio está «por debajo de su media» supone que hay una
  media a la que volver. En un precio $I(1)$ no la hay; en un spread estacionario, sí (lección 2.3.3).
- **Variables de un modelo.** Un modelo entrenado con el nivel de un precio aprende la relación de un periodo que no
  se repetirá (nivel 3).

Los datos de esta lección son sintéticos (README del nivel 2): un futuro de índice que es un paseo aleatorio con
deriva, futuros de materias primas que vuelven muy despacio a un ancla (vidas medias de años) y pares cuyo spread
verdadero es estacionario o no lo es, según el par. Se sabe la respuesta de antemano; eso permite ver
dónde aciertan los contrastes y dónde no.

## Dickey-Fuller: contrastar la raíz unitaria

La idea del contraste de Dickey-Fuller es estimar cuánto «vuelve» la serie. En la regresión

$$
\Delta y_t = a + \gamma\, y_{t-1} + \sum_{i=1}^{p} \phi_i\, \Delta y_{t-i} + \varepsilon_t
$$

un $\gamma$ negativo significa que, cuando $y$ está alto, tiende a bajar: reversión. Si $\gamma = 0$, la serie es un
paseo aleatorio (tiene **raíz unitaria**). El contraste ADF (*augmented*, por los retardos $\Delta y_{t-i}$, que
absorben la autocorrelación de corto plazo) usa la nula $H_0: \gamma = 0$ frente a $\gamma < 0$.

La trampa es que, bajo la nula, $y_{t-1}$ no es estacionario y el estadístico t de $\gamma$ no sigue una t de Student.
Sigue la distribución de Dickey-Fuller, más desplazada a la izquierda. Se ve simulando paseos aleatorios con semilla
y calculando el estadístico en cada uno:

```python
import numpy as np
import pandas as pd
from scipy import stats

rng = np.random.default_rng(212)
sims, n = 4000, 1000
y = rng.standard_normal((sims, n)).cumsum(axis=1)  # paseos
dy, ylag = np.diff(y, axis=1), y[:, :-1]

def t_gamma(dy, ylag):
    # MCO de dy = a + g·ylag, fila a fila
    xm = ylag - ylag.mean(1, keepdims=True)
    g = (xm * dy).sum(1) / (xm**2).sum(1)
    a = dy.mean(1) - g * ylag.mean(1)
    e = dy - a[:, None] - g[:, None] * ylag
    s2 = (e**2).sum(1) / (dy.shape[1] - 2)
    return g / np.sqrt(s2 / (xm**2).sum(1))

t = t_gamma(dy, ylag)
print(f"percentil 5 % de la t de Dickey-Fuller: "
      f"{np.percentile(t, 5):.2f}  (normal: "
      f"{stats.norm.ppf(0.05):.2f})")
print(f"paseos «estacionarios» con el umbral de la normal: "
      f"{np.mean(t < stats.norm.ppf(0.05)):.1%}")
```
```output
percentil 5 % de la t de Dickey-Fuller: -2.88  (normal: -1.64)
paseos «estacionarios» con el umbral de la normal: 45.3%
```

*Fig. — Estadístico t de Dickey-Fuller (regresión con constante) en 4.000 paseos aleatorios simulados de 1.000 pasos, frente a la normal estándar.* Histograma del estadístico de Dickey-Fuller en barras de 0,25, centrado en −1,5, con la curva de la normal estándar superpuesta, centrada en 0. Dos líneas verticales: el percentil 5 % de la simulación, −2,88, destacado, y el de la normal, −1,64. Un 45 % de los paseos aleatorios queda a la izquierda de −1,64.

El 5 % inferior del estadístico simulado está en −2,88 (el valor crítico tabulado para la regresión con constante y
esta longitud es −2,86; la diferencia es el ruido de 4.000 simulaciones), y no en el −1,64 de la normal. Quien use el
umbral de la t de Student declarará estacionario un 45,3 % de los paseos aleatorios: casi la mitad. `statsmodels.tsa.stattools.adfuller` ya usa los valores críticos correctos (de MacKinnon) y elige
el número de retardos $p$ por criterio de información (`autolag="AIC"`).

## Dos nulas opuestas: ADF y KPSS

El ADF tiene un problema de diseño: la nula es la raíz unitaria, así que «no rechazar» puede significar que la serie
es un paseo aleatorio o que la muestra es demasiado corta para verlo. El contraste **KPSS** (Kwiatkowski, Phillips,
Schmidt y Shin, 1992) invierte los papeles: su nula es que la serie **es** estacionaria alrededor de una constante, y
la rechaza cuando la suma acumulada de las desviaciones crece demasiado. Usar los dos juntos da más información que
cualquiera por separado.

```python
import warnings
from statsmodels.tsa.stattools import adfuller, kpss
from statsmodels.tools.sm_exceptions import InterpolationWarning

# kpss avisa cuando el estadístico cae fuera de su tabla de
# p-valores (los recorta a 0,01 o 0,10): el aviso es esperado
warnings.filterwarnings("ignore", category=InterpolationWarning)

def contrastes(x):
    x = pd.Series(x).dropna()
    a = adfuller(x, regression="c", autolag="AIC",
                 result_object=True)
    k = kpss(x, regression="c", nlags="auto", result_object=True)
    return {"n": len(x), "adf": a.statistic, "p_adf": a.pvalue,
            "kpss": k.statistic, "p_kpss": k.pvalue}

cont = pd.read_parquet("futuros2_continuos.parquet")
ida = np.log(cont[cont["mercado"] == "IDA"]
             .set_index("fecha")["cierre_ajustado"])
pd.DataFrame({"log precio IDA": contrastes(ida),
              "rentabilidad IDA": contrastes(ida.diff())}).T.round(3)
```
```output
                       n     adf  p_adf    kpss  p_kpss
log precio IDA    6539.0   0.176  0.971  10.817    0.01
rentabilidad IDA  6538.0 -26.296  0.000   0.264    0.10
```

Con `result_object=True`, las dos funciones devuelven un objeto con nombres (`statistic`, `pvalue`,
`critical_values`) en lugar de una tupla cuya longitud cambia según los argumentos; statsmodels avisa con un
`FutureWarning` de que ese será el comportamiento por defecto, y pedirlo ya lo silencia. `kpss` devuelve p-valores
interpolados en una tabla que va de 0,01 a 0,10; fuera de ese rango los recorta y emite un `InterpolationWarning`. El aviso no indica un error, sino que el p-valor real es menor que 0,01 o mayor que 0,10;
se silencia solo esa categoría para que la salida sea limpia. Los valores críticos del KPSS con constante son 0,347
(10 %), 0,463 (5 %) y 0,739 (1 %).

El logaritmo del precio de IDA, un índice ficticio de renta variable construido como paseo aleatorio con deriva, da
un ADF de 0,18 (p = 0,97): no se rechaza la raíz unitaria. El KPSS vale 10,82, muy por encima de 0,463: se rechaza la
estacionariedad. Los dos dicen lo mismo con lenguajes opuestos. La rentabilidad diaria da lo contrario: ADF de −26,3
(p ≈ 0) y KPSS de 0,26, por debajo del umbral. Es el caso de libro.

## Spreads con respuesta conocida

Los pares de `pares2.parquet` permiten ir más allá. Todas sus relaciones son en logaritmos: en los pares A, B y C, el
spread $u = \ln y - \alpha - \beta \ln x$ es un proceso de Ornstein-Uhlenbeck **estacionario** con vida media de 5,
15 y 40 sesiones (un porcentaje, con desviación típica del 2 % al 4,5 %); en E, el mismo spread es un **paseo
aleatorio**, aunque los dos activos tienen una correlación de rendimientos de 0,86. Aquí se usan los
$\alpha$ y $\beta$ verdaderos de `pares2_verdad.csv`: estimarlos cambia los valores críticos (Engle-Granger, lección
2.1.5).

```python
pares = pd.read_parquet("pares2.parquet").pivot(
    index="fecha", columns="activo", values="precio")
verdad = pd.read_csv("pares2_verdad.csv").set_index("par")

def spread(par):
    # todas las relaciones son en logaritmos (formulacion = "log")
    v = verdad.loc[par]
    y, x = pares[v["activo_y"]], pares[v["activo_x"]]
    return np.log(y) - v["alfa"] - v["beta"] * np.log(x)

tabla = {}
for par in ["A", "B", "C", "E"]:
    vm = verdad.loc[par, "vida_media_sesiones"]
    s = spread(par)
    tabla[f"{par}, vida media {vm:g}"] = contrastes(s)
    tabla[f"{par}, último año"] = contrastes(s.iloc[-252:])
pd.DataFrame(tabla).T.round(3)
```
```output
                        n     adf  p_adf   kpss  p_kpss
A, vida media 5    3018.0 -14.929  0.000  0.108   0.100
A, último año       252.0  -4.251  0.001  0.287   0.100
B, vida media 15   3018.0  -8.975  0.000  0.385   0.084
B, último año       252.0  -2.825  0.055  0.133   0.100
C, vida media 40   3018.0  -5.562  0.000  0.826   0.010
C, último año       252.0  -1.643  0.461  0.333   0.100
E, vida media nan  3018.0  -2.012  0.281  4.775   0.010
E, último año       252.0  -2.371  0.150  0.586   0.024
```

Con los doce años completos (3.018 sesiones), el ADF acierta en los cuatro: rechaza la raíz unitaria en A, B y C
(estadísticos de −14,9, −9,0 y −5,6) y no la rechaza en E (p = 0,28). El KPSS acierta en A y en E, pero rechaza la
estacionariedad de C (0,826 > 0,739, p ≤ 0,01), que sí es estacionario. Con un solo año de datos, la imagen cambia: el
ADF ya no rechaza ni en B (p = 0,055) ni en C (p = 0,46). Con una vida media de 40 sesiones, un año contiene unos seis
ciclos de reversión, y eso no basta para distinguirlos de un paseo.

### Las cuatro combinaciones

| ADF rechaza (raíz unitaria) | KPSS rechaza (estacionaria) | Lectura | Ejemplo en los datos |
|---|---|---|---|
| Sí | No | Estacionaria | Rentabilidad de IDA; spread A |
| No | Sí | Raíz unitaria | Precio de IDA; spread E |
| Sí | Sí | Estacionaria pero muy persistente, con rupturas o con memoria larga | Spread C, 12 años |
| No | No | La muestra no tiene información suficiente | Spread C, último año |

*Fig. — Estadístico KPSS (eje horizontal, logarítmico) frente al ADF (vertical) del precio y la rentabilidad de IDA y de los spreads verdaderos de cuatro pares, con 12 años y con el último año.* Plano dividido en cuatro cuadrantes por los valores críticos del 5 % (KPSS 0,463 y ADF −2,86). Abajo a la izquierda, estacionaria: rentabilidad de IDA (ADF −26,3), spreads A y B con 12 años y A con un año. Arriba a la derecha, raíz unitaria: precio de IDA (KPSS 10,8) y spread E. Abajo a la derecha, los dos rechazan: spread C con 12 años, destacado (ADF −5,6, KPSS 0,83). Arriba a la izquierda, falta muestra: spreads B y C con un año.

Las dos filas de arriba son la respuesta clara. Las dos de abajo dicen que el contraste no puede responder con estos
datos, y son más frecuentes de lo que parece con series cortas o persistentes, que son justo las que interesan para
la reversión a la media.

```mermaid
flowchart TB
  A{¿ADF rechaza?} -- sí --> K1{¿KPSS rechaza?}
  A -- no --> K2{¿KPSS rechaza?}
  K1 -- no --> E[Estacionaria]
  K1 -- sí --> P[Muy persistente]
  K2 -- sí --> U[Raíz unitaria]
  K2 -- no --> M[Falta muestra]
```

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

Un cambio estructural engaña al ADF. El spread del par H es estacionario hasta el 15 de junio de 2021 y, desde ese día,
un paseo aleatorio. Con la muestra completa, el ADF no rechaza la raíz unitaria (lo que es cierto desde 2021), pero
con el tramo anterior a la ruptura la rechaza con claridad. Un spread que fue estacionario puede dejar de serlo, y el
contraste sobre el pasado no avisa. La lección 2.1.7 trata los cambios estructurales.

</aside>

```python
h = spread("H")
ruptura = verdad.loc["H", "fecha_ruptura"]
pd.DataFrame({"completa": contrastes(h),
              "antes de la ruptura": contrastes(h[:ruptura].iloc[:-1]),
              }).T.round(3)
```
```output
                          n    adf  p_adf   kpss  p_kpss
completa             3018.0 -1.448  0.559  6.643   0.010
antes de la ruptura  1875.0 -5.702  0.000  0.407   0.074
```

## Diferenciar: lo que se gana y lo que se pierde

Diferenciar una vez ($d = 1$) convierte el precio en rentabilidad y resuelve la estacionariedad. El coste es la
**memoria**: la rentabilidad de hoy no dice nada del nivel en que está el precio. Se mide con la correlación entre la
serie transformada y la original:

```python
r = ida.diff().dropna()
print(f"correlación de la rentabilidad con el log precio: "
      f"{np.corrcoef(r, ida.loc[r.index])[0, 1]:.3f}")

# sobrediferenciar un spread que ya era estacionario
ds = spread("A").diff().dropna()
print(f"autocorrelación en el retardo 1 del spread A: "
      f"{spread('A').autocorr(1):.3f}; de su diferencia: "
      f"{ds.autocorr(1):.3f}")
```
```output
correlación de la rentabilidad con el log precio: 0.025
autocorrelación en el retardo 1 del spread A: 0.865; de su diferencia: -0.055
```

La rentabilidad tiene una correlación de 0,025 con el nivel del precio: toda la información sobre dónde está el
mercado ha desaparecido. Y diferenciar lo que ya era estacionario estropea la serie: el spread A tiene una
autocorrelación de 0,865 en el retardo 1 (la teórica del Ornstein-Uhlenbeck es $0{,}5^{1/5} = 0{,}871$); su
diferencia tiene −0,055, cerca del valor de un AR(1) sobrediferenciado ($-(1-\phi)/2 = -0{,}065$): la persistencia
positiva que delataba la reversión ha desaparecido y queda una serie casi sin memoria.
La regla: diferenciar lo justo, no por reflejo.

## Diferenciación fraccional

¿Hay algo entre $d = 0$ (el precio, con toda la memoria y no estacionario) y $d = 1$ (la rentabilidad, estacionaria
y sin memoria)? Sí: el operador de diferencia $(1-B)^d$, con $B$ el operador de retardo ($B\,y_t = y_{t-1}$), admite
cualquier $d$ real. Su desarrollo binomial da una suma ponderada infinita del presente y el pasado,

$$
(1-B)^d\, y_t = \sum_{k=0}^{\infty} w_k\, y_{t-k}, \qquad w_0 = 1,\quad w_k = -w_{k-1}\,\frac{d-k+1}{k}
$$

Con $d = 1$ los pesos son $1, -1, 0, 0, \ldots$: la diferencia de siempre. Con $0 < d < 1$ todos los pesos pasados son
negativos y decaen despacio, como una potencia: la serie recuerda el nivel durante mucho tiempo.

```python
def pesos(d, umbral=1e-4, kmax=100_000):
    w = [1.0]
    for k in range(1, kmax):
        wk = -w[-1] * (d - k + 1) / k
        if abs(wk) < umbral:
            break
        w.append(wk)
    return np.array(w)

for d in [0.1, 0.3, 0.5, 1.0]:
    w = pesos(d)
    print(f"d={d:.1f}: {len(w):4d} pesos; primeros "
          f"{np.round(w[:5], 3).tolist()}")
```
```output
d=0.1:  503 pesos; primeros [1.0, -0.1, -0.045, -0.028, -0.021]
d=0.3:  388 pesos; primeros [1.0, -0.3, -0.105, -0.06, -0.04]
d=0.5:  200 pesos; primeros [1.0, -0.5, -0.125, -0.062, -0.039]
d=1.0:    2 pesos; primeros [1.0, -1.0]
```

*Fig. — Pesos de la diferencia fraccional, en valor absoluto, de los 20 primeros retardos para cuatro valores de d (escala logarítmica). Con d = 1 solo hay un peso, −1 en el retardo 1.* Cuatro curvas decrecientes con el retardo en el eje horizontal, de 1 a 20, y el valor absoluto del peso en escala logarítmica. Empiezan en d (0,1, 0,25, 0,5 y 0,75) en el retardo 1 y caen despacio, como una potencia: en el retardo 20 valen entre 0,001 y 0,005. La de d = 0,25, destacada, es la d mínima que hace estacionario el precio de IDA.

La versión de **ventana fija** (*fixed-width window fractional differencing*, López de Prado, 2018, capítulo 5) corta
la suma donde los pesos caen por debajo de un umbral (aquí $10^{-4}$) y aplica la misma ventana a todas las fechas.
Así todos los valores tienen el mismo peso de historia y ninguno usa datos futuros: el valor de la fecha $t$ solo
combina $y_t, y_{t-1}, \ldots$

```python
def ffd(x, d, umbral=1e-4):
    w = pesos(d, umbral)
    # convolución «valid»: el valor en t usa y_t … y_(t-L+1)
    v = np.convolve(x.to_numpy(), w, mode="valid")
    return pd.Series(v, index=x.index[len(w) - 1:])

# comprobación: d = 1 es la rentabilidad logarítmica
print(np.allclose(ffd(ida, 1.0), ida.diff().dropna()))
x3 = ffd(ida, 0.3)
print(f"d = 0,3: empieza el {x3.index[0]:%Y-%m-%d}, "
      f"{len(ida) - len(x3)} sesiones después que el precio")
```
```output
True
d = 0,3: empieza el 2001-07-17, 387 sesiones después que el precio
```

Ahora se barre $d$ y, para cada valor, se calcula el ADF de la serie transformada y su correlación con el logaritmo del
precio. Siguiendo a López de Prado, el ADF lleva un solo retardo (`maxlag=1`, sin selección automática), para que
todas las $d$ se comparen con la misma regresión:

```python
filas = []
for d in np.round(np.arange(0, 1.01, 0.05), 2):
    x = ffd(ida, d)
    a = adfuller(x, maxlag=1, regression="c", autolag=None,
                 result_object=True)
    crit = a.critical_values
    filas.append({"d": d, "adf": a.statistic, "p": a.pvalue,
                  "corr": np.corrcoef(x, ida.loc[x.index])[0, 1]})
barrido = pd.DataFrame(filas).set_index("d")
d_min = barrido.index[barrido["adf"] < crit["5%"]].min()
print(f"valor crítico al 5 %: {crit['5%']:.2f}; d mínima: {d_min}")
barrido.loc[[0.0, 0.1, 0.2, 0.25, 0.3, 0.5, 1.0]].round(3)
```
```output
valor crítico al 5 %: -2.86; d mínima: 0.25
         adf      p   corr
d
0.00   0.393  0.981  1.000
0.10  -0.877  0.795  0.998
0.20  -2.166  0.219  0.987
0.25  -3.152  0.023  0.977
0.30  -4.173  0.001  0.963
0.50 -12.092  0.000  0.845
1.00 -57.513  0.000  0.025
```

La figura de la cabecera dibuja el barrido completo. El ADF baja de forma continua al subir $d$ y cruza el valor
crítico (−2,86) en $d = 0{,}25$ (ADF de −3,15; con $d = 0{,}20$ aún vale −2,17); con esa $d$, la serie es
estacionaria según el ADF y conserva una correlación de 0,977 con el logaritmo del precio. La rentabilidad ($d = 1$)
es muchísimo más estacionaria de lo necesario (ADF de −57,5) y ha perdido toda la memoria (0,025). Entre las dos hay un abanico de series que conservan información del nivel sin
los problemas de un regresor no estacionario.

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

La $d$ mínima depende de la serie y del periodo: en estos datos, el crudo (ENA) pasa el ADF con $d = 0{,}10$ y el
oro (MEA) con $d = 0{,}15$ (ejercicio 1). No es casualidad: en el generador, los precios de las materias primas
vuelven a un ancla a través del carry (vida media de 1,6 años en el crudo y de 6,6 en el oro, README del nivel 2), así
que sus series ya están más cerca de rechazar la raíz unitaria con $d = 0$; el índice no tiene ancla. Calcula $d$
con datos de entrenamiento y mantenla fija después: elegirla mirando toda la muestra es otra forma de usar el futuro
(lección 2.5.1).

</aside>

## Lo que esto cambia en un sistema

| Situación | Qué contrastar | Qué hacer si falla |
|---|---|---|
| Regla de reversión sobre un precio | ADF y KPSS del precio | No hay media a la que volver: buscar un spread (lecciones 2.1.5 y 2.3.3) |
| Spread de un par | ADF con valores críticos de Engle-Granger | Muestra más larga; vigilar rupturas (2.1.7) |
| Variable de un modelo (nivel, media móvil) | ADF de la variable | Rentabilidad, z-score o diferencia fraccional con $d$ mínima |
| Regresión de una rentabilidad sobre un nivel | Estacionariedad del regresor | Diferenciar o usar errores robustos (lección 2.1.1) |

Tres advertencias. La primera, que «no rechazar» no es «aceptar»: un ADF que no rechaza con un año de datos no prueba
que el spread sea un paseo aleatorio. La segunda, que los contrastes de esta lección miden la estacionariedad **del
pasado**: el par H era estacionario hasta que dejó de serlo. La tercera, que barrer $d$, ventanas o pares hasta que
uno pase el ADF es una búsqueda múltiple como cualquier otra (lección 2.1.8).

## Ejercicios

1. Repite el barrido de $d$ con los futuros de oro (`MEA`) y de crudo ligero (`ENA`). ¿Qué $d$ mínima sale en cada
   uno y qué correlación conserva?

**Solución.**

```python
def d_minima(serie, crit=-2.86):
    for d in np.round(np.arange(0, 1.01, 0.05), 2):
        x = ffd(serie, d)
        adf = adfuller(x, maxlag=1, regression="c", autolag=None,
                       result_object=True).statistic
        if adf < crit:
            return d, adf, np.corrcoef(x, serie.loc[x.index])[0, 1]

for m in ["IDA", "MEA", "ENA"]:
    s = np.log(cont[cont["mercado"] == m]
               .set_index("fecha")["cierre_ajustado"])
    d, adf, c = d_minima(s)
    print(f"{m}: d mínima {d:.2f}, ADF {adf:.2f}, correlación {c:.3f}")
```
```output
IDA: d mínima 0.25, ADF -3.15, correlación 0.977
MEA: d mínima 0.15, ADF -3.05, correlación 0.981
ENA: d mínima 0.10, ADF -3.40, correlación 0.988
```

El crudo (0,10) y el oro (0,15) pasan con una $d$ menor que el índice (0,25) y conservan una correlación algo más
alta con su nivel (0,988 y 0,981 frente a 0,977). Es lo esperable: sus precios revierten, muy despacio, a un ancla, y
el del índice no. La $d$ mínima no es una constante del mercado: depende de la persistencia de cada serie, de su
ajuste por roll y del periodo.

2. El ADF tiene más potencia cuanto más larga es la muestra. Para el spread B (vida media de 15 sesiones), calcula el
   p-valor del ADF con los últimos 126, 252, 504 y 1.008 días. ¿Desde qué longitud rechaza la raíz unitaria al 5 %?

**Solución.**

```python
sb = spread("B")
for n in [126, 252, 504, 1008]:
    p = adfuller(sb.iloc[-n:], regression="c", autolag="AIC",
                 result_object=True).pvalue
    print(f"{n:5d} sesiones: p = {p:.4f}")
```
```output
  126 sesiones: p = 0.2449
  252 sesiones: p = 0.0548
  504 sesiones: p = 0.0090
 1008 sesiones: p = 0.0000
```

Con medio año y con un año, el ADF no rechaza al 5 %; con dos años, sí. Un spread que revierte en 15 sesiones
necesita del orden de varios cientos de sesiones para que el contraste lo distinga de un paseo aleatorio. Para una
estrategia de pares, eso es un límite real a cuánto se puede reestimar con ventanas cortas.

3. Un compañero propone una regla de reversión: comprar IDA cuando su precio está un 10 % por debajo de su media de
   200 sesiones. ¿Qué dicen los contrastes de esta lección sobre esa regla, y qué contrastarías en su lugar?

**Solución.**

El precio de IDA tiene raíz unitaria (ADF de 0,18, KPSS de 10,82): no hay una media a la que deba volver. La distancia a
la media de 200 sesiones sí puede ser estacionaria, porque la media móvil sigue al precio, pero que sea estacionaria
no implica que prediga nada: es un oscilador, y lo que hay que contrastar es si su valor de hoy predice la
rentabilidad de los días siguientes, con errores robustos al solape (lección 2.1.1) y fuera de la muestra (lección
2.5.2). En estos datos, la respuesta conocida dice que los futuros de índices no revierten a ningún ancla (solo lo
hacen, y despacio, las materias primas, las divisas y los bonos) y que lo que hay es un momentum pequeño, así que la
regla debería fallar.

## Autoevaluación

1. El ADF del logaritmo del precio de IDA da un estadístico de 0,18 y un p-valor de 0,97. ¿Qué se concluye?

   - A. Que el precio es estacionario
   - B. Que no se puede rechazar la raíz unitaria: el precio se comporta como un paseo aleatorio y no vuelve a ningún nivel
   - C. Que el precio tiene tendencia determinista
   - D. Que hay que usar más retardos

   *Respuesta B: Que no se puede rechazar la raíz unitaria: el precio se comporta como un paseo aleatorio y no vuelve a ningún nivel.* La nula del ADF es la raíz unitaria. Un p-valor alto significa que los datos son compatibles con ella. El KPSS, cuya nula es la contraria, la rechaza con fuerza (10,82 frente a un valor crítico de 0,46): los dos coinciden en que el precio no es estacionario.

2. ¿Por qué el ADF no usa los valores críticos de la t de Student?

   - A. Por costumbre
   - B. Porque bajo la nula de raíz unitaria el regresor (el nivel de ayer) no es estacionario y el estadístico t sigue la distribución de Dickey-Fuller, desplazada a la izquierda: el valor crítico al 5 % es −2,86, no −1,65
   - C. Porque el ADF es un contraste bilateral
   - D. Porque las rentabilidades tienen colas gruesas

   *Respuesta B: Porque bajo la nula de raíz unitaria el regresor (el nivel de ayer) no es estacionario y el estadístico t sigue la distribución de Dickey-Fuller, desplazada a la izquierda: el valor crítico al 5 % es −2,86, no −1,65.* En la simulación de la lección, con 4.000 paseos aleatorios, el 5 % inferior del estadístico está en −2,88 (el valor tabulado es −2,86); con el −1,64 de la normal se rechazaría la raíz unitaria en un 45,3 % de los paseos aleatorios.

3. El spread del par C (vida media verdadera de 40 sesiones) rechaza las dos nulas: el ADF dice estacionario y el KPSS dice no estacionario. ¿Cómo se interpreta?

   - A. Que los datos tienen un error
   - B. Que la serie es estacionaria pero muy persistente: el KPSS, con su corrección de autocorrelación, confunde la reversión lenta con una raíz unitaria
   - C. Que es un paseo aleatorio
   - D. Que hay que diferenciarla dos veces

   *Respuesta B: Que la serie es estacionaria pero muy persistente: el KPSS, con su corrección de autocorrelación, confunde la reversión lenta con una raíz unitaria.* La respuesta conocida es un Ornstein-Uhlenbeck estacionario. Con una vida media de 40 sesiones, la serie pasa meses lejos de su media y el KPSS lo toma por deriva. La combinación «los dos rechazan» pide mirar la persistencia, los cambios estructurales o la memoria larga.

4. La diferencia fraccional con d = 0,25 del logaritmo de IDA pasa el ADF y mantiene una correlación de 0,977 con el original. ¿Qué ventaja tiene sobre la rentabilidad (d = 1)?

   - A. Ninguna
   - B. Que conserva información del nivel del precio (correlación 0,977 frente a 0,025) siendo estacionaria: un modelo puede usarla sin los problemas de un regresor no estacionario
   - C. Que es más fácil de calcular
   - D. Que no necesita datos pasados

   *Respuesta B: Que conserva información del nivel del precio (correlación 0,977 frente a 0,025) siendo estacionaria: un modelo puede usarla sin los problemas de un regresor no estacionario.* Diferenciar con d = 1 hace la serie estacionaria pero borra casi toda la relación con el nivel. La diferencia fraccional quita solo la parte de la memoria que impide la estacionariedad. Es una herramienta para construir variables (nivel 3), no una estrategia.

5. Con la ventana fija y umbral 0,0001, la serie con d = 0,1 empieza 502 sesiones más tarde que el precio. ¿Por qué?

   - A. Por un error de pandas
   - B. Porque cada valor es una suma ponderada de las 503 observaciones anteriores (los pesos tardan en caer por debajo del umbral cuando d es pequeña): las primeras no tienen historia suficiente
   - C. Porque se descartan los festivos
   - D. Porque ADF necesita 500 observaciones

   *Respuesta B: Porque cada valor es una suma ponderada de las 503 observaciones anteriores (los pesos tardan en caer por debajo del umbral cuando d es pequeña): las primeras no tienen historia suficiente.* Con d pequeña los pesos decaen despacio, así que la ventana es larga. Es el precio de la memoria: cada dato fraccional depende de dos años de precios. Y es causal: solo usa el presente y el pasado.

## Ideas clave

- Estacionaria (en sentido débil) = media, varianza y autocovarianzas que no dependen de la fecha. Los precios no lo son; las rentabilidades y los spreads cointegrados, sí.
- ADF y KPSS tienen nulas opuestas: usarlos juntos da cuatro combinaciones, y dos de ellas (los dos rechazan o ninguno) dicen que la muestra o el modelo no bastan.
- El estadístico de Dickey-Fuller no sigue una t: su valor crítico al 5 % es −2,86. Con el −1,64 de la normal se declara estacionario un 45 % de los paseos aleatorios.
- La potencia depende de la persistencia y de la muestra: el spread con vida media de 40 sesiones no se distingue de un paseo aleatorio con un año de datos.
- Diferenciar con d = 1 compra estacionariedad al precio de la memoria (correlación 0,025 con el nivel); la diferencia fraccional con d = 0,25 pasa el ADF y conserva una correlación de 0,977.

## Recursos

### Research

- [Procesos Estocásticos - Paseos Aleatorios](https://pythonparatrading.com/procesos-estocasticos/) · Research-0005 · 12 min

### Documentación

- [statsmodels: adfuller](https://www.statsmodels.org/stable/generated/statsmodels.tsa.stattools.adfuller.html) · statsmodels.org
- [statsmodels: kpss](https://www.statsmodels.org/stable/generated/statsmodels.tsa.stattools.kpss.html) · statsmodels.org

### Libros

- [Kwiatkowski, D., Phillips, P. C. B., Schmidt, P. y Shin, Y. (1992): Testing the null hypothesis of stationarity against the alternative of a unit root. Journal of Econometrics 54(1-3), 159-178](https://doi.org/10.1016/0304-4076(92)90104-Y) · doi.org
- [Dickey, D. A. y Fuller, W. A. (1979): Distribution of the Estimators for Autoregressive Time Series with a Unit Root. Journal of the American Statistical Association 74(366), 427-431](https://doi.org/10.1080/01621459.1979.10482531) · doi.org
- [Marcos López de Prado: Advances in Financial Machine Learning (Wiley, 2018), capítulo 5, «Fractionally Differentiated Features»](https://www.wiley.com/en-us/Advances+in+Financial+Machine+Learning-p-9781119482086) · wiley.com

### Datos

- [Datos del nivel 2: pares sintéticos con su relación verdadera (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/pares2.parquet) · Descarga · pares2.parquet

Lección anterior: [2.1.1 · Contrastes de hipótesis, p-valores, bootstrap y regresión con errores robustos](https://pythonparatrading.com/curso-python-trading/nivel-2/contrastes-bootstrap-y-errores-robustos/)

Lección siguiente: [2.1.3 · Modelos ARMA y ARIMA: autocorrelación simple (ACF) y parcial (PACF)](https://pythonparatrading.com/curso-python-trading/nivel-2/modelos-arma-arima-acf-y-pacf/)
