Lección 5.2.3 · 60 min

Diferenciación fraccional para features

La diferenciación fraccional promete lo mejor de dos mundos: una serie estacionaria que conserva la memoria del nivel del precio. Esta lección la usa como feature de un modelo y mide qué hay detrás de la promesa: qué parte de la memoria son rendimientos pasados y qué parte es nivel, por qué pasar el ADF en el entrenamiento no evita que el test quede fuera de rango, cómo el nivel arrastra sus defectos y cuánto aporta a un LightGBM frente a un conjunto de rendimientos bien elegido.

Fig. — Índice de vol5: al subir d, la diferencia fraccional pierde memoria del nivel y sus valores de 2018-2025 entran en el rango de 2008-2017; el ADF se satisface mucho antes que el rango
Lecciones del nivel 5
  1. 5.1 · Fundamentos de ML aplicados a finanzas

    1. 5.1.1 Por qué el machine learning financiero es distinto
    2. 5.1.2 Aprendizaje supervisado, no supervisado y por refuerzo; pipelines de scikit-learn
    3. 5.1.3 Métricas económicas para modelos de trading: más allá del acierto
  2. 5.2 · Ingeniería de características

    1. 5.2.1 Features de precio, volumen y microestructura
    2. 5.2.2 Fundamentales point-in-time, macro y datos alternativos
    3. 5.2.3 Diferenciación fraccional para features
    4. 5.2.4 Normalización sin leakage y rank transversal
    5. 5.2.5 Importancia de features: MDI, MDA, SFI y SHAP
  3. 5.3 · Etiquetado y muestreo

    1. 5.3.1 Etiquetado de horizonte fijo y sus problemas
    2. 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
    3. 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
    4. 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
    5. 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
  4. 5.4 · Validación para series financieras

    1. 5.4.1 Por qué falla el k-fold aleatorio en datos financieros
    2. 5.4.2 Purged k-fold con embargo y CPCV para modelos de ML
    3. 5.4.3 Walk-forward con reentrenamiento: ventanas y frecuencia
    4. 5.4.4 Backtest de señales de ML con costes y Deflated Sharpe
  5. 5.5 · Modelos clásicos

    1. 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
    2. 5.5.2 Árboles de decisión, Random Forest y bagging
    3. 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
    4. 5.5.4 Optuna y el riesgo de sobreajustar la validación
    5. 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
    6. 5.5.6 Learning to rank para la selección de acciones
  6. 5.6 · Deep learning con Keras 3

    1. 5.6.1 Keras 3: un modelo, varios motores de cálculo
    2. 5.6.2 Un MLP sobre un panel de acciones: regularizar y comparar
    3. 5.6.3 CNN 1D, LSTM y GRU: ventanas, escalado y fugas
    4. 5.6.4 Callbacks, una pérdida de Sharpe y el modelo en producción
  7. 5.7 · Deep learning con PyTorch

    1. 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
    2. 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
    3. 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
    4. 5.7.4 Redes de grafos para relaciones entre activos
    5. 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
  8. 5.8 · Modelos fundacionales de series temporales

    1. 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
    2. 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
    3. 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
    4. 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
    5. 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
  9. 5.9 · Aprendizaje por refuerzo

    1. 5.9.1 MDP, Q-learning, PPO y entornos Gymnasium
    2. 5.9.2 RL para ejecución, market making y cobertura dinámica
    3. 5.9.3 Por qué el RL sobreajusta en trading direccional
  10. 5.10 · Interpretabilidad, robustez y riesgo de modelo

    1. 5.10.1 SHAP, dependencia parcial y estabilidad de las importancias
    2. 5.10.2 Deriva de datos y de concepto: detectarla y qué hacer
    3. 5.10.3 Ensembles, combinación de señales y datos sintéticos

Lo que ya sabes y lo que falta

La lección 2.1.2 explicó la diferenciación fraccional: el operador (1−B)d(1-B)^d con dd real, sus pesos wk=−wk−1(d−k+1)/kw_k = -w_{k-1}(d-k+1)/k, la versión de ventana fija (FFD) que corta los pesos por debajo de un umbral, y el barrido de dd que busca la más pequeña que pasa el ADF conservando la correlación con el nivel. Allí el objetivo era tener un regresor estacionario. Aquí la pregunta es otra, la que importa para un modelo de machine learning: ¿una FFD es mejor feature que los rendimientos?

López de Prado (2018)1Nota 1 · M. López de Prado, Advances in Financial Machine Learning, Wiley, 2018, capítulo 5 («Fractionally Differentiated Features»); el operador fraccional viene de J. R. M. Hosking, «Fractional differencing», Biometrika 68(1), 1981, pp. 165-176, doi:10.1093/biomet/68.1.165. la propone precisamente para eso: las features estacionarias por diferenciación entera pierden toda la memoria, y la FFD conserva parte. Para medir esa promesa hace falta saber qué es exactamente la memoria que conserva. Se reescriben las funciones de la lección 2.1.2 y se añade una cuenta:

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

def pesos(d, umbral=1e-4, kmax=100_000):
    """w_0 = 1, w_k = -w_(k-1)·(d - k + 1)/k hasta |w_k| < umbral."""
    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)

def ffd(x, d, umbral=1e-4):
    """Diferencia fraccional de ventana fija (sin datos futuros)."""
    w = pesos(d, umbral)
    v = np.convolve(x.to_numpy(), w, mode="valid")
    return pd.Series(v, index=x.index[len(w) - 1:])

for d in [0.1, 0.3, 0.5, 0.7]:
    w = pesos(d)
    c = np.cumsum(w)  # sumas parciales: peso de cada rendimiento
    print(f"d={d:.1f}: {len(w):3d} pesos; rendimientos de hace "
          f"1, 5, 21 y 63 sesiones {np.round(c[[1, 5, 21, 63]], 3)}; "
          f"resto de nivel {c[-1]:.3f}")
OUT [01]Pesos de la diferencia fraccional de ventana fija
d=0.1: 503 pesos; rendimientos de hace 1, 5, 21 y 63 sesiones [0.9   0.79  0.689 0.618]; resto de nivel 0.502
d=0.3: 388 pesos; rendimientos de hace 1, 5, 21 y 63 sesiones [0.7   0.466 0.308 0.222]; resto de nivel 0.129
d=0.5: 200 pesos; rendimientos de hace 1, 5, 21 y 63 sesiones [0.5   0.246 0.122 0.071]; resto de nivel 0.040
d=0.7:  97 pesos; rendimientos de hace 1, 5, 21 y 63 sesiones [0.3   0.106 0.039 0.018]; resto de nivel 0.014

Agrupando los términos de la suma, una FFD con LL pesos se escribe como rendimientos pasados más un resto de nivel:

∑k=0L−1wk yt−k  =  ∑j=0L−2cj rt−j  +  cL−1 yt−L+1,cj=∑k=0jwk,rt=yt−yt−1.\sum_{k=0}^{L-1} w_k\, y_{t-k} \;=\; \sum_{j=0}^{L-2} c_j\, r_{t-j} \;+\; c_{L-1}\, y_{t-L+1}, \qquad c_j = \sum_{k=0}^{j} w_k,\quad r_t = y_t - y_{t-1}.

El peso del rendimiento de hace jj sesiones es la suma parcial cjc_j, que empieza en 1 (el de hoy) y decae como una potencia, cj≈j−d/Γ(1−d)c_j \approx j^{-d}/\Gamma(1-d): con d=0,3d = 0{,}3, el rendimiento de hace 21 sesiones aún pesa 0,31 y el de hace 63, 0,22; con d=0,7d = 0{,}7, 0,04 y 0,02. La última suma parcial, que es la suma de todos los pesos, es el peso que queda al nivel de la serie al principio de la ventana: 0,50 con d=0,1d = 0{,}1, 0,13 con 0,3, 0,04 con 0,5. Con la ventana infinita sería cero para cualquier d>0d > 0; la ventana fija lo deja ahí.

Así que la «memoria» de una FFD tiene dos partes de naturaleza distinta. Una es una media de rendimientos con pesos que decaen despacio, algo que se puede aproximar con rendimientos a varios horizontes (lección 5.2.1). La otra es el nivel de hace LL sesiones, y esa sí es nueva: ningún conjunto de rendimientos la contiene. La figura dibuja la primera parte; las secciones siguientes miden las dos.

Fig. — Peso de cada rendimiento pasado en la diferencia fraccional de ventana fija (sumas parciales de los pesos, umbral 1e-4, escalas logarítmicas); el último valor de cada curva es el resto de nivel: 0,13, 0,04 y 0,01

Estacionaria en el ADF, fuera de rango en la prueba

El criterio de la lección 2.1.2 elige la dd más pequeña que pasa el ADF. El ADF contrasta si la serie vuelve a su media dentro de la muestra. Un modelo necesita otra cosa: que los valores que verá en producción se parezcan a los que vio al entrenar. Con el índice de vol5 (log del precio desde 2008, construido acumulando rendimientos hacia delante: point-in-time), entrenamiento 2008-2017 y prueba 2018-2025, se mide para cada dd el ADF del entrenamiento, la fracción de valores de prueba fuera del rango de entrenamiento y la memoria (correlación con el log del precio en la prueba):

IN [02]python
from statsmodels.tsa.stattools import adfuller

v5 = pd.read_parquet("vol5_diario.parquet")
r_ind = v5[v5["activo"] == "indice"].set_index("fecha")["rendimiento"]
lp = np.log1p(r_ind).cumsum()  # log del precio, 0 el primer día

filas = []
for d in np.round(np.arange(0, 1.01, 0.05), 2):
    x = ffd(lp, d) if d > 0 else lp
    ent, prueba = x.loc[:"2017-12-31"], x.loc["2018-01-01":]
    adf = adfuller(ent, maxlag=1, regression="c", autolag=None,
                   result_object=True)
    fuera = ((prueba < ent.min()) | (prueba > ent.max())).mean()
    memoria = np.corrcoef(prueba, lp.loc[prueba.index])[0, 1]
    filas.append({"d": d, "adf": adf.statistic, "fuera": fuera,
                  "memoria": memoria})
t = pd.DataFrame(filas).set_index("d")
critico = adf.critical_values["5%"]
d_adf = t.index[t["adf"] < critico].min()
d_rango = t.index[t["fuera"] < 0.05].min()
print(f"valor crítico al 5 %: {critico:.2f}; d mínima por el "
      f"ADF: {d_adf}; por el rango (< 5 % fuera): {d_rango}")
t.loc[[0.0, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.7, 1.0]].round(3)
OUT [02]Índice de vol5: ADF, rango y memoria según d
valor crítico al 5 %: -2.86; d mínima por el ADF: 0.1; por el rango (< 5 % fuera): 0.5
         adf  fuera  memoria
d
0.00  -2.630  0.879    1.000
0.05  -2.506  0.832    0.999
0.10  -3.328  0.769    0.996
0.20  -5.110  0.656    0.975
0.30  -7.499  0.546    0.928
0.40 -10.968  0.321    0.847
0.50 -15.390  0.033    0.725
0.70 -25.172  0.000    0.409
1.00 -36.912  0.000    0.018

La figura de la cabecera dibuja la tabla. Con d=0,1d = 0{,}1 la FFD ya pasa el ADF (−3,33 frente a −2,86) y conserva una correlación de 0,996 con el precio, pero el 77 % de sus valores de 2018-2025 está fuera del rango de 2008-2017: el índice subió y la FFD, con medio peso en el nivel, subió con él. Para que menos del 5 % de la prueba quede fuera hace falta d=0,5d = 0{,}5, y entonces la memoria baja a 0,725. Entre las dos hay un intervalo de dd en el que la serie es «estacionaria» según el contraste y no lo es para un modelo.

El ADF no está mal: con diez años de datos y un resto de nivel que cambia despacio, la serie revierte lo bastante dentro de la muestra para rechazar la raíz unitaria. El problema es que el nivel de la prueba no está en la muestra. Lo que hace un modelo con eso depende del modelo; un árbol lo hace de forma muy visible:

IN [03]python
import lightgbm as lgb

y21 = lp.shift(-21) - lp  # log-rendimiento de las 21 siguientes

def arbol(d):
    x = (ffd(lp, d) if d > 0 else lp).rename("x")
    df = pd.concat([x, y21.rename("y")], axis=1, sort=True).dropna()
    ent = df.loc[:"2017-12-31"]
    prueba = df.loc["2018-01-01":"2025-10-31"]
    m = lgb.LGBMRegressor(n_estimators=100, learning_rate=0.05,
                          num_leaves=8, min_child_samples=100,
                          random_state=0, n_jobs=4, deterministic=True,
                          force_row_wise=True, verbose=-1)
    m.fit(ent[["x"]], ent["y"])
    pred = m.predict(prueba[["x"]])
    fuera = prueba["x"] > ent["x"].max()
    return fuera.mean(), np.unique(pred[fuera.to_numpy()]).size

for d in [0.0, 0.1, 0.5]:
    f, n = arbol(d)
    print(f"d={d:.2f}: {f:.0%} de la prueba por encima del máximo "
          f"de entrenamiento; predicciones distintas ahí: {n}")
OUT [03]Lo que hace un árbol con lo que no ha visto
d=0.00: 88% de la prueba por encima del máximo de entrenamiento; predicciones distintas ahí: 1
d=0.10: 76% de la prueba por encima del máximo de entrenamiento; predicciones distintas ahí: 1
d=0.50: 3% de la prueba por encima del máximo de entrenamiento; predicciones distintas ahí: 1

Un árbol parte el eje de la feature en umbrales aprendidos con el entrenamiento. Todo lo que cae por encima del último umbral recibe la predicción de la última hoja: en el log del precio, el 88 % de la prueba recibe una sola predicción; con d=0,1d = 0{,}1, el 76 %. El modelo no extrapola, se queda en el extremo. Con d=0,5d = 0{,}5 solo el 3 % cae ahí. Una regresión lineal haría lo contrario, extrapolar sin límite, que no es mejor. El objetivo de esta celda (el rendimiento de las 21 sesiones siguientes) es impredecible en vol5 por construcción (README de los datos): la celda no mide si el modelo acierta, sino cómo trata lo que no ha visto.

La memoria del nivel hereda sus defectos

El resto de nivel es la parte de la FFD que no se puede construir con rendimientos. Si el nivel lleva información, la FFD la tiene; si el nivel lleva un defecto, también. El panel de acciones sirve para verlo en dos versiones del mismo precio: la publicada, que acumula rendimientos hacia delante desde un precio inicial y es point-in-time, y la misma serie normalizada por su último precio (log⁡Pt−log⁡PT\log P_t - \log P_T), que es lo que hace quien divide cada serie por su último valor para compararlas, y lo que hace, en esencia, un ajuste hacia atrás. Para cada una se calcula la FFD completa y sin el resto de nivel, y su IC con el rendimiento de la semana siguiente:

IN [04]python
from scipy.signal import lfilter

px = pd.read_parquet("panel5_precios.parquet")
LP = np.log(px.pivot(index="fecha", columns="ticker", values="cierre"))
fe = pd.read_parquet("panel5_features.parquet")
et = pd.read_parquet("panel5_etiquetas.parquet")
fechas = pd.DatetimeIndex(fe["fecha"].unique())
Y = et.pivot(index="fecha", columns="ticker",
             values="ret_sig_semana").loc[fechas]

def ic(X):
    """IC de rangos semanal con la semana siguiente: media y t."""
    X = X.reindex(index=fechas, columns=Y.columns)
    v = X.rank(axis=1).corrwith(Y.rank(axis=1), axis=1).dropna()
    t = v.mean() / v.std() * np.sqrt(len(v))
    return round(float(v.mean()), 4), round(float(t), 1)

def ffd_panel(L, d):
    """FFD por columnas: completa y sin el resto de nivel."""
    w = pesos(d)
    completa = pd.DataFrame(lfilter(w, [1.0], L.to_numpy(), axis=0),
                            index=L.index, columns=L.columns)
    completa.iloc[:len(w) - 1] = np.nan
    return completa, completa - w.sum() * L.shift(len(w) - 1)

LN = LP - LP.iloc[-1]  # normalizada: 0 el último día de la muestra
for nombre, L in [("publicado", LP), ("normalizado", LN)]:
    print(f"{nombre}: log del precio, IC, t = {ic(L)}")
    for d in [0.3, 0.5, 0.7, 1.0]:
        completa, rendim = ffd_panel(L, d)
        print(f"  d={d:.1f}: completa {ic(completa)}, "
              f"solo rendimientos {ic(rendim)}")
OUT [04]Diferencia fraccional del panel: precio publicado y precio normalizado al final
publicado: log del precio, IC, t = (-0.0002, -0.1)
  d=0.3: completa (-0.0008, -0.3), solo rendimientos (-0.0014, -0.4)
  d=0.5: completa (-0.0039, -1.4), solo rendimientos (-0.0058, -1.6)
  d=0.7: completa (-0.0061, -1.9), solo rendimientos (-0.0086, -2.3)
  d=1.0: completa (-0.004, -1.0), solo rendimientos (-0.004, -1.0)
normalizado: log del precio, IC, t = (-0.0661, -15.5)
  d=0.3: completa (-0.0553, -15.0), solo rendimientos (-0.0014, -0.4)
  d=0.5: completa (-0.0455, -12.7), solo rendimientos (-0.0058, -1.6)
  d=0.7: completa (-0.032, -8.7), solo rendimientos (-0.0086, -2.3)
  d=1.0: completa (-0.004, -1.0), solo rendimientos (-0.004, -1.0)

Con el precio publicado, el nivel no dice nada de la semana siguiente (IC −0,0002) y la FFD completa da casi lo mismo que su parte de rendimientos: IC pequeños y negativos (−0,009 como mucho, con d=0,7d = 0{,}7), la reversión semanal plantada vista a través de una media de rendimientos. El resto de nivel no añade información, pero tampoco hace daño.

Con el precio normalizado al final, el log del precio pasa a un IC de −0,066 con tt = −15,5: más del doble del techo del oráculo del README (0,029), que conoce el rendimiento esperado verdadero. Fuera de muestra, ninguna feature puede superar al oráculo, así que esto es una fuga. La FFD completa la hereda (−0,055 con d=0,3d = 0{,}3, −0,046 con 0,5, −0,032 con 0,7) aunque su resto de nivel pese 0,13, 0,04 o 0,01, porque el nivel varía mucho más entre acciones que una suma de rendimientos. La parte de rendimientos es idéntica en las dos versiones: normalizar resta una constante por acción, que en los rendimientos se cancela.

Fig. — Panel de 400 acciones: valor absoluto del IC de la diferencia fraccional del log del precio con el rendimiento de la semana siguiente; con el precio normalizado por su último valor supera el techo del oráculo porque hereda una fuga del nivel

¿De dónde sale la fuga? Restar el log del último precio hace que el nivel de hoy sea, exactamente, menos el rendimiento de hoy hasta el final de la muestra:

IN [05]python
hasta_el_final = LP.iloc[-1] - LP  # log-rendimiento de t a 2025-12-31
for f in ["2013-06-28", "2016-06-30", "2019-06-28", "2022-06-30"]:
    pub = np.corrcoef(LP.loc[f], hasta_el_final.loc[f])[0, 1]
    nor = np.corrcoef(LN.loc[f], hasta_el_final.loc[f])[0, 1]
    print(f"{f}: corr. con el rendimiento hasta el final: "
          f"publicado {pub:+.2f}, normalizado {nor:+.2f}")
OUT [05]El nivel de hoy y el rendimiento hasta el final de la muestra
2013-06-28: corr. con el rendimiento hasta el final: publicado +0.03, normalizado -1.00
2016-06-30: corr. con el rendimiento hasta el final: publicado -0.02, normalizado -1.00
2019-06-28: corr. con el rendimiento hasta el final: publicado +0.01, normalizado -1.00
2022-06-30: corr. con el rendimiento hasta el final: publicado -0.02, normalizado -1.00

Con el precio publicado, la correlación transversal entre el nivel y el rendimiento hasta 2025 es nula (entre −0,02 y +0,03); con el normalizado es −1 por construcción. La semana siguiente forma parte de ese rendimiento futuro, y de ahí sale el IC imposible. No es un ejemplo de laboratorio: la primera versión de estos datos sintéticos reescalaba cada serie para que su último precio cayera en un rango verosímil, tenía exactamente este defecto y se detectó así, con una feature de nivel que superaba al oráculo.

En datos reales el mismo problema aparece de forma más suave. Un precio ajustado hacia atrás por dividendos y splits (lección 1.4.3) multiplica el pasado por factores que dependen de dividendos y splits posteriores; una serie continua de futuros con ajuste proporcional (lección 1.4.6), por los saltos de rolls posteriores; una serie normalizada a 100 en su último día, por todo el rendimiento futuro. Los rendimientos de todas ellas son point-in-time; el nivel, no. Cualquier feature que conserve memoria del nivel (una FFD, una distancia a un máximo histórico en dólares, el propio precio como columna de un árbol) hay que construirla sobre un nivel que se conociera en cada fecha: precios sin ajustar, o rendimientos acumulados hacia delante desde un día fijo, como el precio publicado del panel o el lp del índice de vol5. Y el IC de cualquier feature nueva se compara con el techo del oráculo, o, en datos reales, con lo que es verosímil.

¿Aporta en un modelo? FNX con LightGBM

Queda la pregunta principal: con un nivel point-in-time y un modelo no lineal, ¿la FFD aporta algo sobre los rendimientos? Los datos de FNX (lección 5.2.1) tienen una estructura que favorece a una feature con memoria: en el régimen de reversión, el precio vuelve hacia su media exponencial de 30 minutos, es decir, hacia una media ponderada de su propio pasado, que es lo que mide la parte de rendimientos de una FFD. El nivel dentro del año, en cambio, no dice nada del minuto siguiente.

El montaje: features calculadas dentro de cada sesión (la ventana no cruza la noche: umbral 10−310^{-3}, ventanas de 26 a 66 minutos), objetivo el log-rendimiento de los 10 minutos siguientes, entrenamiento en la primera mitad de las sesiones y prueba en la segunda, con una observación cada 10 minutos para que los objetivos no se solapen. Se comparan cuatro conjuntos: cinco rendimientos (1, 5, 10, 30 y 60 minutos), tres FFD completas (dd = 0,3, 0,5 y 0,7), las mismas sin el resto de nivel y rendimientos más FFD completas; cada uno, solo y con el desequilibrio de órdenes de 30 minutos de la lección 5.2.1.

IN [06]python
b = pd.read_parquet("hf5_barras_1min.parquet")
n_ses = b["ts"].dt.date.nunique()
P = np.log(b["cierre"].to_numpy()).reshape(n_ses, 390)  # sesión × min
VC = b["volumen_comprador"].to_numpy().reshape(n_ses, 390)
VT = b["volumen"].to_numpy().reshape(n_ses, 390)

def por_sesion(w):
    """Filtro con los pesos w dentro de cada sesión (sin cruzarlas)."""
    X = lfilter(w, [1.0], P, axis=1)
    X[:, :len(w) - 1] = np.nan
    return X

def retardo(A, k):
    out = np.full_like(A, np.nan, dtype=float)
    out[:, k:] = A[:, :-k]
    return out

H = 10
feats = {}
for k in [1, 5, 10, 30, 60]:
    feats[f"r{k}"] = P - retardo(P, k)
for d in [0.3, 0.5, 0.7]:
    w = pesos(d, umbral=1e-3)  # ventanas de 26 a 66 minutos
    completa = por_sesion(w)
    feats[f"ffd{d}"] = completa
    feats[f"ffdr{d}"] = completa - w.sum() * retardo(P, len(w) - 1)
cv, ct = np.cumsum(VC, axis=1), np.cumsum(VT, axis=1)
feats["di30"] = 2 * (cv - retardo(cv, 30)) / (ct - retardo(ct, 30)) - 1
y = np.full_like(P, np.nan)
y[:, :-H] = P[:, H:] - P[:, :-H]  # log-rendimiento de 10 minutos

X = pd.DataFrame({k: v.reshape(-1) for k, v in feats.items()})
y = y.reshape(-1)
sesion = np.repeat(np.arange(n_ses), 390)
minuto = np.tile(np.arange(390), n_ses)
ok = X.notna().all(axis=1).to_numpy() & np.isfinite(y)
ent = ok & (sesion < n_ses // 2)
prueba = ok & (sesion >= n_ses // 2) & (minuto % H == 0)
et_ = 1 / np.sqrt(prueba.sum())
print(f"{ent.sum()} filas de entrenamiento; {prueba.sum()} de prueba "
      f"sin solapamiento; error típico ≈ {et_:.3f}")
OUT [06]Features por sesión: rendimientos y diferencias fraccionales
38745 filas de entrenamiento; 3844 de prueba sin solapamiento; error típico ≈ 0.016

Con 3.844 observaciones de prueba, el error típico de una correlación es de unos 0,016.

IN [07]python
conjuntos = {
    "rendimientos": ["r1", "r5", "r10", "r30", "r60"],
    "ffd completa": ["ffd0.3", "ffd0.5", "ffd0.7"],
    "ffd sin nivel": ["ffdr0.3", "ffdr0.5", "ffdr0.7"],
    "rend. + ffd completa": ["r1", "r5", "r10", "r30", "r60",
                             "ffd0.3", "ffd0.5", "ffd0.7"],
}
res = {}
for nombre, cols in conjuntos.items():
    for extra in [[], ["di30"]]:
        m = lgb.LGBMRegressor(
            n_estimators=200, learning_rate=0.03, num_leaves=15,
            min_child_samples=200, subsample=0.5, subsample_freq=1,
            random_state=0, n_jobs=4, deterministic=True,
            force_row_wise=True, verbose=-1)
        m.fit(X.loc[ent, cols + extra], y[ent])
        pred = m.predict(X.loc[prueba, cols + extra])
        etiqueta = "con di30" if extra else "solo"
        res[(nombre, etiqueta)] = np.corrcoef(pred, y[prueba])[0, 1]
pd.Series(res).unstack().round(3)
OUT [07]LightGBM con cada conjunto de features
                      con di30   solo
ffd completa             0.039 -0.012
ffd sin nivel            0.102  0.069
rend. + ffd completa     0.074  0.032
rendimientos             0.094  0.060

Tres conclusiones, por orden de importancia:

  • El resto de nivel perjudica. Las FFD completas dan −0,012, peor que nada, y añadidas a los rendimientos los empeoran de 0,060 a 0,032: el árbol parte por el nivel de FNX, que no tiene relación con el objetivo, y aprende ruido del entrenamiento.
  • La parte útil de la FFD es una media de rendimientos. Sin el resto, las tres FFD dan 0,069, lo mismo que los cinco rendimientos (0,060) dentro del error típico. La FFD es una forma compacta de dar al modelo una media de rendimientos con pesos que decaen como una potencia; un conjunto de rendimientos a varios horizontes contiene casi la misma información.
  • Lo que mejora el modelo es información nueva. El desequilibrio de órdenes sube todos los conjuntos unas 3-5 centésimas (0,094 con rendimientos, 0,102 con FFD sin nivel). Transformar los mismos precios de otra forma mueve el resultado mucho menos que añadir un dato que el precio no tiene.
Fig. — FNX: correlación fuera de muestra de un LightGBM con el log-rendimiento de los 10 minutos siguientes según sus features (raya: ±1,96 errores típicos); el resto de nivel de la diferencia fraccional empeora el modelo

Fuera de estos datos, el resultado dependerá del activo, del horizonte y del modelo: la FFD conviene probarla como una feature más, con la misma validación que cualquier otra, y no como sustituto automático de los rendimientos.

Cuándo no hace falta: una serie que ya es estacionaria

El otro extremo: una serie que ya es estacionaria y tiene memoria larga. La varianza realizada es el caso típico; su logaritmo es persistente pero vuelve a su media, y el modelo HAR de Corsi2Nota 2 · F. Corsi, «A Simple Approximate Long-Memory Model of Realized Volatility», Journal of Financial Econometrics 7(2), 2009, pp. 174-196, doi:10.1093/jjfinec/nbp001. aproxima esa memoria larga con tres medias (1, 5 y 22 días). En vol5, el HAR es el modelo verdadero. Diferenciar fraccionalmente el log de la varianza realizada para predecir la del día siguiente:

IN [08]python
from sklearn.linear_model import LinearRegression

def r2_fuera(X, y):
    df = pd.concat([X, y.rename("y")], axis=1, sort=True).dropna()
    a = df.loc["2009-01-01":"2017-12-31"]
    b_ = df.loc["2018-01-01":]
    m = LinearRegression().fit(a.drop(columns="y"), a["y"])
    e = b_["y"] - m.predict(b_.drop(columns="y"))
    return 1 - (e ** 2).sum() / ((b_["y"] - a["y"].mean()) ** 2).sum()

filas = {}
for activo, g in v5.groupby("activo"):
    lrv = np.log(g.set_index("fecha")["rv_5min"])
    obj = lrv.shift(-1)
    fila = {f"d={d}": r2_fuera((ffd(lrv, d, 1e-3) if d else lrv)
                                .to_frame("x"), obj)
            for d in [0, 0.2, 0.5, 1.0]}
    har = pd.concat({"d1": lrv, "d5": lrv.rolling(5).mean(),
                     "d22": lrv.rolling(22).mean()}, axis=1)
    fila["HAR"] = r2_fuera(har, obj)
    filas[activo] = fila
pd.DataFrame(filas).T.round(3)
OUT [08]Log-varianza realizada: d = 0 frente a d > 0 y frente al HAR
          d=0  d=0.2  d=0.5  d=1.0    HAR
accion  0.275  0.117  0.025  0.003  0.393
bono    0.443  0.217  0.052  0.009  0.543
cripto  0.339  0.125  0.022  0.003  0.488
divisa  0.375  0.186  0.044  0.004  0.466
indice  0.505  0.293  0.082  0.014  0.568
oro     0.480  0.235  0.054  0.007  0.580

Cada paso de dd hacia 1 destruye capacidad de predicción: en el índice, el R2R^2 fuera de muestra pasa de 0,505 con el propio log de la varianza (d=0d = 0) a 0,293 con d=0,2d = 0{,}2, 0,082 con 0,5 y 0,014 con 1. La memoria era la información, y la serie no necesitaba ser «más estacionaria». El HAR, con tres medias de la serie sin diferenciar, llega a 0,568. Es la regla de la lección 2.1.2 (no diferenciar lo que ya es estacionario) vista con un modelo.

Las decisiones de la lección, en un diagrama:

sínonosínosí¿Estacionaria?No diferenciar¿Importa el nivel?Rendimientos¿Nivel PIT?FFD con d fija
Fuente Mermaid
flowchart TB
  S{¿Estacionaria?} -- sí --> N[No diferenciar]
  S -- no --> O{¿Importa el nivel?}
  O -- no --> R[Rendimientos]
  O -- sí --> P{¿Nivel PIT?}
  P -- no --> R
  P -- sí --> F[FFD con d fija]

Errores típicos

  • Elegir dd con toda la muestra. El barrido de dd es un ajuste más: se hace en el entrenamiento y se mantiene fijo en la prueba.
  • Confiar en el ADF como criterio para un modelo. Pasa con dd pequeñas que dejan la mayor parte de la prueba fuera del rango de entrenamiento. Hay que medir también la estabilidad de la distribución.
  • Aplicar la FFD a un nivel que no es point-in-time. Precios ajustados hacia atrás, series continuas con ajuste proporcional o series normalizadas por su último valor llevan el futuro en el nivel, y la FFD lo conserva.
  • Ventanas que cruzan sesiones o huecos. Con datos intradía, una ventana de cientos de minutos mezcla días y arrastra el salto de la apertura; se calcula por sesión con un umbral mayor o con una ventana fija.
  • Diferenciar lo que ya es estacionario. La log-varianza realizada, un spread cointegrado o un tipo de interés en un rango acotado pierden información al diferenciarse.
  • No comparar con una baseline. Una FFD solo merece entrar en el modelo si mejora, fuera de muestra y más allá de su error típico, a un conjunto sencillo de rendimientos.

Ejercicios

  1. Comprueba con el control de vol5 que una FFD no crea predictibilidad donde no la hay: para cada activo, calcula el R2R^2 fuera de muestra (2018-2025) del rendimiento del día siguiente con la FFD del log del precio (d=0,5d = 0{,}5) y con el rendimiento de hoy (d=1d = 1).
Solución
IN [09]python
filas = {}
for activo, g in v5.groupby("activo"):
    r = g.set_index("fecha")["rendimiento"]
    logp = np.log1p(r).cumsum()
    obj = r.shift(-1)
    filas[activo] = {f"d={d}": r2_fuera(ffd(logp, d).to_frame("x"), obj)
                     for d in [0.5, 1.0]}
(pd.DataFrame(filas).T * 100).round(2)  # en %
OUT [09]El control: rendimientos impredecibles
        d=0.5  d=1.0
accion   0.11  -0.05
bono    -0.05  -0.08
cripto  -0.06  -0.26
divisa   0.10  -0.13
indice  -4.54  -0.26
oro     -0.36   0.13

En cinco de los seis activos, los dos R2R^2 están entre −0,4 % y +0,1 %: nada, como corresponde a unos rendimientos impredecibles por construcción. El índice da −4,5 % con la FFD: la regresión aprende en 2008-2017 una pendiente con el resto de nivel y la extrapola a un nivel de 2018-2025 que no había visto, con lo que predice peor que la media histórica. Una FFD no fabrica señal; lo que sí puede fabricar es un error de extrapolación.

  1. Elige dd para el oro y la cripto de vol5 con un criterio que no mire la prueba: la dd más pequeña (en pasos de 0,05) que deja menos del 5 % de 2015-2017 fuera del rango de 2008-2014. Después mide la fracción fuera de rango de 2018-2025 respecto a 2008-2017 con esa dd.
Solución
IN [10]python
def fuera(x, a, b):
    # fracción de x en el tramo b fuera del rango de x en el tramo a
    xa, xb = x.loc[a[0]:a[1]], x.loc[b[0]:b[1]]
    return ((xb < xa.min()) | (xb > xa.max())).mean()

for activo in ["oro", "cripto"]:
    r = v5[v5["activo"] == activo].set_index("fecha")["rendimiento"]
    logp = np.log1p(r).cumsum()
    for d in np.round(np.arange(0.05, 1.01, 0.05), 2):
        x = ffd(logp, d)
        if fuera(x, ("2008", "2014"), ("2015", "2017")) < 0.05:
            break
    f = fuera(x, ("2008", "2017"), ("2018", "2025"))
    print(f"{activo}: d elegida {d}; fuera de rango en 2018-2025: "
          f"{f:.1%}")
OUT [10]d elegida sin mirar la prueba
oro: d elegida 0.05; fuera de rango en 2018-2025: 86.1%
cripto: d elegida 0.25; fuera de rango en 2018-2025: 31.6%

Para el oro, la validación 2015-2017 cabe entera en el rango de 2008-2014 con d=0,05d = 0{,}05, pero en 2018-2025 el 86 % de los valores queda fuera: el oro de los datos sale del rango histórico en la prueba, y un solo bloque de validación no lo podía saber. Para la cripto el criterio elige d=0,25d = 0{,}25 y aun así queda fuera un 32 %. Un criterio de rango evaluado una vez no protege de un cambio de régimen; en un walk-forward hay que recalcular la fracción fuera de rango en cada bloque, reentrenar (y revisar dd) cuando crece, y preferir una dd algo mayor que la mínima cuando el modelo es un árbol.

  1. La serie continua de un futuro del nivel 2 (futuros2_continuos.parquet, columna cierre_ajustado) tiene ajuste proporcional hacia atrás. Un compañero calcula su FFD con d=0,3d = 0{,}3 para un modelo de tendencia. ¿Qué problema tiene y cómo lo arreglarías?
Solución

El ajuste proporcional hacia atrás multiplica todo el pasado por el cociente de cada roll posterior: el nivel de una fecha depende de los saltos entre contratos que todavía no habían ocurrido (lección 1.4.6). Con d=0,3d = 0{,}3 la FFD conserva un 13 % del nivel del principio de la ventana, y con él esa información del futuro, como el precio normalizado de esta lección. Los rendimientos de la serie ajustada, en cambio, son los del contrato que se tenía y sí son point-in-time. El arreglo es construir el nivel hacia delante: acumular los rendimientos de la serie desde una fecha fija (como el lp de vol5) y calcular la FFD sobre ese nivel, que en cada fecha solo usa el pasado. Después, como en la lección, elegir dd con el entrenamiento, comprobar el rango en la prueba y compararla con un conjunto de rendimientos.

Footnotes

  1. M. López de Prado, Advances in Financial Machine Learning, Wiley, 2018, capítulo 5 («Fractionally Differentiated Features»); el operador fraccional viene de J. R. M. Hosking, «Fractional differencing», Biometrika 68(1), 1981, pp. 165-176, doi:10.1093/biomet/68.1.165. ↩

  2. F. Corsi, «A Simple Approximate Long-Memory Model of Realized Volatility», Journal of Financial Econometrics 7(2), 2009, pp. 174-196, doi:10.1093/jjfinec/nbp001. ↩

Respuesta plegada en cada pregunta

Autoevaluación

  1. Con un umbral de 0,0001, la diferencia fraccional con d = 0,3 usa 388 pesos y la suma de sus pesos es 0,13. ¿Qué significa esa suma?

    • AQue la serie no es estacionaria
    • BQue la feature es 0,13 veces el nivel del precio de hace 387 sesiones más una suma ponderada de los 387 rendimientos posteriores: queda un resto de nivel
    • CQue el 13 % de los pesos son positivos
    • DNada: la suma de los pesos siempre es 0,13
    Ver respuesta

    Respuesta B. Que la feature es 0,13 veces el nivel del precio de hace 387 sesiones más una suma ponderada de los 387 rendimientos posteriores: queda un resto de nivel

    Agrupando términos, la suma de w_k·y_(t−k) es igual a la suma de c_j·r_(t−j) más c_(L−1)·y_(t−L+1), con c_j las sumas parciales de los pesos. La última suma parcial es la suma de todos los pesos: el peso que le queda al nivel de la serie al principio de la ventana. Con la ventana infinita sería cero para cualquier d > 0.

  2. En el índice de vol5, la diferencia fraccional con d = 0,1 ya pasa el ADF al 5 % en 2008-2017, pero el 77 % de sus valores de 2018-2025 queda fuera del rango de entrenamiento. ¿Qué hará un árbol entrenado con ella?

    • AAsignar a todos esos valores la hoja del extremo del rango de entrenamiento, como si fueran el máximo observado
    • BExtrapolar la tendencia aprendida
    • CDar un error
    • DIgnorar la feature
    Ver respuesta

    Respuesta A. Asignar a todos esos valores la hoja del extremo del rango de entrenamiento, como si fueran el máximo observado

    Un árbol parte el eje en umbrales aprendidos con los datos de entrenamiento; cualquier valor por encima del último umbral cae en la misma hoja. El ADF mide si la serie vuelve a su media dentro de la muestra, no si la muestra futura está dentro del rango de la pasada.

  3. En el panel, el logaritmo del precio normalizado por su último valor tiene un IC de −0,066 con el rendimiento de la semana siguiente, más del doble del techo del oráculo (0,029); el del precio publicado, −0,0002. ¿Qué indica?

    • AUn efecto de precio bajo muy fuerte
    • BQue el oráculo está mal calculado
    • CUna fuga: normalizar por el último precio hace que el nivel de hoy sea menos el rendimiento de hoy hasta el final de la muestra, que incluye la semana siguiente; una diferencia fraccional con d pequeña la hereda
    • DQue hace falta más datos
    Ver respuesta

    Respuesta C. Una fuga: normalizar por el último precio hace que el nivel de hoy sea menos el rendimiento de hoy hasta el final de la muestra, que incluye la semana siguiente; una diferencia fraccional con d pequeña la hereda

    Ninguna feature puede superar fuera de muestra al rendimiento esperado verdadero. El nivel de un precio normalizado por su último valor (como una serie ajustada hacia atrás) no es point-in-time; los rendimientos sí. La diferencia fraccional conserva la memoria del nivel, con sus defectos.

  4. En FNX, un LightGBM con tres diferencias fraccionales completas tiene una correlación fuera de muestra de −0,012 y uno con cinco rendimientos, de 0,060. Con solo la parte de rendimientos de las diferencias fraccionales, 0,069. ¿Qué se concluye?

    • AQue la diferencia fraccional es mejor que los rendimientos
    • BQue LightGBM no sirve para datos intradía
    • CQue hay que usar d = 1
    • DQue el resto de nivel es ruido para este objetivo y perjudica; la parte útil de la diferencia fraccional es una suma ponderada de rendimientos, equivalente a un buen conjunto de rendimientos (la diferencia entre 0,060 y 0,069 está dentro del error típico de 0,016)
    Ver respuesta

    Respuesta D. Que el resto de nivel es ruido para este objetivo y perjudica; la parte útil de la diferencia fraccional es una suma ponderada de rendimientos, equivalente a un buen conjunto de rendimientos (la diferencia entre 0,060 y 0,069 está dentro del error típico de 0,016)

    El nivel de FNX dentro del año no dice nada del rendimiento de los 10 minutos siguientes, y un árbol que lo usa para partir aprende ruido. La memoria útil aquí es la de los rendimientos recientes, que la diferencia fraccional resume con pesos que decaen como una potencia.

Ideas clave

  • Una diferencia fraccional de ventana fija es una suma de rendimientos pasados con pesos que decaen como una potencia más un resto del nivel inicial de la ventana (la suma de los pesos: 0,13 con d = 0,3 y 0,04 con d = 0,5).
  • El ADF del entrenamiento no basta: en el índice, d = 0,1 pasa el ADF y deja el 77 % de la prueba fuera del rango de entrenamiento. Para un árbol, el criterio útil es la estabilidad de la distribución entre entrenamiento y prueba.
  • La memoria del nivel hereda los defectos del nivel. Con el precio del panel normalizado por su último valor (como un ajuste hacia atrás), el nivel da un IC imposible (−0,066, más del doble del oráculo) y la diferencia fraccional lo hereda (−0,055 con d = 0,3); con el precio publicado, point-in-time, el nivel no aporta nada.
  • Como feature de un LightGBM en FNX, la parte de rendimientos de la diferencia fraccional rinde como cinco rendimientos (0,069 frente a 0,060, error típico 0,016); el resto de nivel empeora el modelo.
  • Si la serie ya es estacionaria (la log-varianza realizada), no se diferencia: en el índice, con d = 0 se explica el 50 % de la varianza del día siguiente y con d = 0,5 solo el 8 %.

Recursos