Lección 5.9.3 · 65 min
Por qué el RL sobreajusta en trading direccional
Entrenar un agente para que compre y venda un activo es la aplicación del aprendizaje por refuerzo que más se publica y la que peor resiste una validación honesta. Esta lección lo hace con un control sin ninguna predictibilidad y con una serie gemela que lleva una ventaja pequeña plantada, y mide lo que importa: la distancia entre dentro y fuera de muestra, cuánto cambia el resultado con la semilla y qué aprende un agente cuando la recompensa tiene un error en los costes.
- Módulo
- 5.9 · Aprendizaje por refuerzo
- Duración
- 65 min
Lecciones del nivel 5
5.1 · Fundamentos de ML aplicados a finanzas
5.2 · Ingeniería de características
5.3 · Etiquetado y muestreo
- 5.3.1 Etiquetado de horizonte fijo y sus problemas
- 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
- 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
- 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
- 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
5.4 · Validación para series financieras
5.5 · Modelos clásicos
- 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
- 5.5.2 Árboles de decisión, Random Forest y bagging
- 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
- 5.5.4 Optuna y el riesgo de sobreajustar la validación
- 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
- 5.5.6 Learning to rank para la selección de acciones
5.6 · Deep learning con Keras 3
5.7 · Deep learning con PyTorch
- 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
- 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
- 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
- 5.7.4 Redes de grafos para relaciones entre activos
- 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
5.8 · Modelos fundacionales de series temporales
- 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
- 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
- 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
- 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
- 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
5.9 · Aprendizaje por refuerzo
5.10 · Interpretabilidad, robustez y riesgo de modelo
El peor caso para el aprendizaje por refuerzo
Las dos lecciones anteriores enseñan el RL en sus mejores condiciones: un MDP con la respuesta conocida (5.9.1) y tres problemas de control con simulador y referencia analítica (5.9.2). El trading direccional, decidir cada día si estar largo, corto o fuera de un activo, es lo contrario, y es lo que más se publica. Cuatro razones lo hacen el peor caso:
- Una sola historia. El agente de 5.9.2 se entrena con miles de caminos simulados. Aquí solo hay un camino, el histórico, y no se puede volver a tirar el dado. Diez años de sesiones son 2.500 transiciones, y las «épocas» de entrenamiento las recorren una y otra vez.
- Señal/ruido ínfima. Un IC de unas centésimas (lección 5.1.1) significa que casi toda la recompensa de cada paso es ruido. En el MDP de 5.9.1 la señal era de un cuarto de la desviación por paso y Q-learning necesitó 140.000 pasos.
- No estacionariedad. El mercado cambia (lección 5.10.2): lo que se aprende de 2008-2017 no tiene por qué valer en 2018-2025.
- Políticas muy flexibles. Una red con miles de parámetros y una decisión en cada paso puede ajustar cualquier secuencia, y los hiperparámetros (red, tasa de aprendizaje, pasos, semilla) multiplican las pruebas.
Fuente Mermaid
flowchart TB A[Una sola historia] --> E[Agente flexible] B[Señal mínima] --> E C[Mercado que cambia] --> E E --> F[Aprende el ruido] F --> G[Brillante dentro] F --> H[Nada fuera]
La lección lo mide con datos en los que se sabe qué hay que aprender: una serie sin nada que aprender y su gemela con una ventaja pequeña plantada.
Un control y una ventaja plantada
El control es la divisa de vol5_diario.parquet: un tipo de cambio sintético cuya media condicional es constante e
igual a cero (μ = 0 en la tabla del README), con volatilidad estocástica pero sin ninguna predictibilidad en los
rendimientos. El README lo comprueba: un AR(5) da un R² fuera de muestra de −0,15 %. Cualquier cosa que un agente
«gane» aquí, fuera de muestra, es ruido.
La serie gemela añade una ventaja pequeña con respuesta conocida. Una señal , un AR(1) con persistencia 0,9 y varianza 1, predice el rendimiento del día siguiente:
con la desviación diaria de la divisa en el periodo de entrenamiento. El IC verdadero es de 0,10, generoso para un mercado real y lo bastante pequeño para que el ruido de la muestra pese. El agente observa lo mismo en las dos series: los 10 últimos rendimientos tipificados, la señal y su posición. En el control, es un ruido sin relación con nada. Entrenamiento: 2008-2017; prueba: 2018-2025.
import zlib
import numpy as np
import pandas as pd
v = pd.read_parquet("vol5_diario.parquet")
div = v[v["activo"] == "divisa"].set_index("fecha")["rendimiento"]
r_ctrl, fechas = div.to_numpy(), div.index
corte = int((fechas < "2018-01-01").sum())
SD = r_ctrl[:corte].std()
rng = np.random.default_rng([2029, zlib.crc32(b"n5_l593_senal")])
e = rng.standard_normal(len(r_ctrl))
s = np.zeros(len(r_ctrl))
for t in range(1, len(s)):
s[t] = 0.9 * s[t - 1] + np.sqrt(1 - 0.9**2) * e[t]
KAPPA = 0.10
r_edge = r_ctrl.copy()
r_edge[1:] += KAPPA * SD * s[:-1]
SERIES = {"control": r_ctrl, "ventaja": r_edge}
def ic(r, a, b):
return np.corrcoef(s[a:b - 1], r[a + 1:b])[0, 1]
print(f"{corte} sesiones de entrenamiento, {len(s) - corte} de "
f"prueba; desviación diaria {SD:.2%}")
for k, r in SERIES.items():
print(f"{k:<8} IC de la señal: entrenamiento "
f"{ic(r, 0, corte):+.3f}, prueba {ic(r, corte, len(r)):+.3f}")2518 sesiones de entrenamiento, 2011 de prueba; desviación diaria 0.56%
control IC de la señal: entrenamiento +0.025, prueba -0.019
ventaja IC de la señal: entrenamiento +0.122, prueba +0.077La muestra ya enseña el problema antes de entrenar nada. En el control, la señal no tiene ninguna relación con los rendimientos, y aun así su IC medido es de +0,025 en entrenamiento y −0,019 en prueba: con 2.500 días, el error típico de un IC es de 1/√2.500 = 0,02, así que cualquier cosa entre −0,04 y +0,04 es compatible con cero. En la serie con ventaja, el IC verdadero es 0,10, pero el medido es 0,122 en entrenamiento (la muestra tuvo suerte) y 0,077 en prueba (menos suerte). Un agente entrenado con 2008-2017 ve una ventaja un 22 % mayor que la real; uno evaluado en 2018-2025, una un 23 % menor. Las dos son la misma ventaja.
El entorno direccional
El entorno sigue las reglas de 5.9.1: posición en la observación, costes dentro de la recompensa, escala de orden 1.
Cada episodio es una ventana de 250 sesiones elegida al azar dentro del periodo de entrenamiento (si no, el agente
vería siempre la misma secuencia en el mismo orden). El coste es de 5 pb por unidad negociada, algo menos de una décima
de la desviación diaria. El parámetro coste_solo_al_abrir reproduce un error de programación que se usa más abajo.
import gymnasium as gym
from gymnasium import spaces
from gymnasium.utils.env_checker import check_env
L, COSTE = 10, 0.0005 # 10 rendimientos retardados; 5 pb
class DireccionalEnv(gym.Env):
def __init__(self, r, ini=L, fin=corte, ventana=250,
coste_solo_al_abrir=False):
self.r, self.ini, self.fin = r, ini, fin
self.ventana, self.solo_abrir = ventana, coste_solo_al_abrir
self.observation_space = spaces.Box(-10.0, 10.0, (L + 2,),
np.float32)
self.action_space = spaces.Discrete(3)
def _obs(self):
t = self.t
lags = np.clip(self.r[t - L + 1:t + 1] / SD, -10, 10)
return np.r_[lags, s[t], self.pos].astype(np.float32)
def reset(self, seed=None, options=None):
super().reset(seed=seed)
hi = self.fin - self.ventana
self.t = int(self.np_random.integers(self.ini, hi))
self.t_fin, self.pos = self.t + self.ventana, 0.0
return self._obs(), {}
def step(self, a):
nueva = float(a) - 1.0
if self.solo_abrir: # error: girar y cerrar, gratis
coste = COSTE * abs(nueva) if self.pos == 0 else 0.0
else:
coste = COSTE * abs(nueva - self.pos)
pnl = nueva * self.r[self.t + 1] - coste
self.pos, self.t = nueva, self.t + 1
return self._obs(), pnl / SD, False, self.t >= self.t_fin, {}
check_env(DireccionalEnv(r_ctrl), skip_render_check=True)
print("check_env: sin errores")check_env: sin erroresLa evaluación no usa el entorno: recorre el periodo entero, de una vez, con la política determinista del agente y calcula el P&L con los costes correctos, sea cual sea la recompensa con la que se entrenó. Como la decisión depende de la posición actual, que solo puede ser −1, 0 o 1, se pide al agente su acción para las tres posiciones posibles en todas las fechas de golpe y luego se encadena la posición.
def evalua(politica, r, ini, fin):
"""Sharpe anual y rotación diaria de una política en [ini, fin)
con el coste real, encadenando la posición desde 0."""
t = np.arange(max(ini, L), fin - 1)
lags = np.clip(np.stack([r[t - L + 1 + j] for j in range(L)],
axis=1) / SD, -10, 10)
acc = np.array([politica(np.column_stack(
[lags, s[t], np.full(len(t), p)]).astype(np.float32))
for p in (-1.0, 0.0, 1.0)])
pos, pnl, rot = 0.0, np.empty(len(t)), 0.0
for i, tt in enumerate(t):
nueva = acc[int(pos) + 1, i]
pnl[i] = nueva * r[tt + 1] - COSTE * abs(nueva - pos)
rot += abs(nueva - pos)
pos = nueva
sharpe = pnl.mean() / pnl.std() * np.sqrt(252) if pnl.std() else 0.0
return round(sharpe, 2), round(rot / len(t), 2)
def de_agente(m):
return lambda o: m.predict(o, deterministic=True)[0] - 1.0
largo = lambda o: np.ones(len(o))
oraculo = lambda o: np.sign(o[:, L]) # signo de la señal verdadera
filas = []
for k, r in SERIES.items():
for nombre, f in (("comprar y mantener", largo),
("oráculo: signo de s", oraculo)):
filas.append([k, nombre, *evalua(f, r, 0, corte),
*evalua(f, r, corte, len(r))])
ref = pd.DataFrame(filas, columns=["serie", "regla", "Sharpe dentro",
"rotación dentro", "Sharpe fuera",
"rotación fuera"])
ref serie regla Sharpe dentro rotación dentro Sharpe fuera rotación fuera
0 control comprar y mantener 0.60 0.00 -0.18 0.00
1 control oráculo: signo de s -0.13 0.34 -0.65 0.26
2 ventaja comprar y mantener 0.57 0.00 -0.13 0.00
3 ventaja oráculo: signo de s 1.08 0.34 0.58 0.26Las dos reglas fijas dan la vara de medir. Comprar y mantener la divisa gana un Sharpe de 0,60 en 2008-2017 y pierde −0,18 en 2018-2025, con una media verdadera de cero: la deriva de la muestra de entrenamiento es un accidente de unos dos errores típicos, y cualquier agente que aprenda a estar largo lo heredará. El oráculo (posición = signo de la señal verdadera, cambiando cada vez que cambia el signo) da 1,08 dentro y 0,58 fuera en la serie con ventaja, con una rotación de 0,26 a 0,34 unidades por día; en el control pierde, porque paga costes por seguir un ruido. El oráculo ingenuo no es la mejor política posible con costes: como en 5.9.1, operar menos (una zona de no negociar) mejora el neto.
Dentro y fuera de muestra, con tres semillas
Cada agente es un PPO con una red de dos capas de 64 neuronas, 8 entornos en paralelo y descuento 0,9 (un horizonte de unos 10 días: la señal tiene una vida media de 6,6), entrenado 60.000 pasos (unas 24 pasadas por los 2.500 días de entrenamiento) en dos bloques. Tras cada bloque se evalúa dentro y fuera de muestra. Tres semillas por serie: la semilla cambia la inicialización de la red, las ventanas de los episodios y la exploración.
import torch
from stable_baselines3 import PPO
from stable_baselines3.common.env_util import make_vec_env
torch.use_deterministic_algorithms(True)
def entrena(r, semilla, bloques=2, **kw_env):
torch.manual_seed(semilla)
vec = make_vec_env(lambda: DireccionalEnv(r, **kw_env), n_envs=8,
seed=semilla)
m = PPO("MlpPolicy", vec, gamma=0.9, n_steps=250, batch_size=500,
n_epochs=5, learning_rate=3e-4,
policy_kwargs={"net_arch": [64, 64]},
seed=semilla, device="cpu", verbose=0)
hist = []
for b in range(bloques):
m.learn(30_000, reset_num_timesteps=b == 0)
f = de_agente(m)
hist.append([30_000 * (b + 1), *evalua(f, r, 0, corte),
*evalua(f, r, corte, len(r))])
return m, hist
filas, agentes = [], {}
for k, r in SERIES.items():
for semilla in range(3):
m, hist = entrena(r, semilla)
agentes[(k, semilla)] = m
filas += [[k, semilla, *h] for h in hist]
res = pd.DataFrame(filas, columns=["serie", "semilla", "pasos",
"Sharpe dentro", "rot. dentro",
"Sharpe fuera", "rot. fuera"])
res serie semilla pasos Sharpe dentro rot. dentro Sharpe fuera rot. fuera
0 control 0 30000 0.65 0.22 -1.17 0.22
1 control 0 60000 0.56 0.02 -0.19 0.02
2 control 1 30000 0.34 0.18 -0.74 0.17
3 control 1 60000 0.73 0.08 -0.53 0.07
4 control 2 30000 0.34 0.21 -0.65 0.21
5 control 2 60000 0.45 0.02 0.03 0.01
6 ventaja 0 30000 1.60 0.14 0.38 0.15
7 ventaja 0 60000 1.84 0.11 0.63 0.11
8 ventaja 1 30000 1.46 0.14 0.49 0.13
9 ventaja 1 60000 1.98 0.10 0.80 0.10
10 ventaja 2 30000 1.42 0.11 0.52 0.10
11 ventaja 2 60000 1.77 0.08 0.81 0.08La figura de la cabecera pone los doce puntos en el plano de dentro y fuera de muestra. Tres lecturas:
- Control. A los 60.000 pasos, los tres agentes tienen un Sharpe dentro de muestra de 0,45 a 0,73 y casi no operan (rotación de 0,02 a 0,08): han aprendido a estar largos, la deriva casual de 2008-2017. Fuera de muestra, de −0,53 a +0,03. A los 30.000 pasos todavía operaban (rotación de 0,2 al día) siguiendo patrones del ruido, y fuera perdían entre −0,65 y −1,17. Ningún agente del control ha aprendido nada, y dentro de muestra todos parecían ganar.
- Ventaja. Los agentes recogen la ventaja plantada: fuera de muestra, de 0,63 a 0,81, por encima del oráculo ingenuo (0,58) porque aprenden a operar menos (rotación de 0,08 a 0,11 frente a 0,26). Es lo único bueno de la tabla, y es posible porque hay algo que aprender y los costes están bien puestos en la recompensa.
- La distancia. Dentro de muestra, los agentes de la serie con ventaja prometen de 1,77 a 1,98: más del doble de lo que dan fuera. Entrenar más sube el Sharpe de dentro en las tres semillas (de 30.000 a 60.000 pasos); el de fuera, en esta configuración, también, pero el ejercicio 1 enseña que eso no dura. Con datos reales solo se vería la columna de dentro de muestra si se validara mal, y la diferencia entre 1,9 y 0,7 de Sharpe es la diferencia entre una estrategia excepcional y una corriente.
La semilla también es un hiperparámetro
Seis agentes, seis resultados. Con datos reales nadie sabe cuál es el oráculo, y la tentación es presentar el agente con mejor Sharpe dentro de muestra, o peor, el de mejor Sharpe fuera de muestra después de haber visto los seis. Las dos cosas son pruebas múltiples (lección 2.5.4). El error típico de un Sharpe anual medido en años es de unos , y el máximo de Sharpes sin ninguna ventaja tiene esperanza positiva:
from scipy.stats import norm
def error_sharpe(sr, anos):
return np.sqrt((1 + sr**2 / 2) / anos)
def maximo_esperado(n, error):
"""Máximo esperado de n Sharpes con media 0 (Bailey y López de
Prado; lección 2.5.4)."""
g = 0.5772156649 # constante de Euler-Mascheroni
return error * ((1 - g) * norm.ppf(1 - 1 / n)
+ g * norm.ppf(1 - 1 / (n * np.e)))
anos_dentro, anos_fuera = corte / 252, (len(s) - corte) / 252
print(f"error típico del Sharpe: {error_sharpe(0, anos_dentro):.2f}"
f" en {anos_dentro:.0f} años, {error_sharpe(0, anos_fuera):.2f}"
f" en {anos_fuera:.0f} años")
for n in (3, 6, 20):
print(f"máximo esperado de {n:>2} Sharpes sin ventaja (fuera): "
f"{maximo_esperado(n, error_sharpe(0, anos_fuera)):.2f}")
fin = res[res["pasos"] == 60_000].set_index(["serie", "semilla"])
for k in SERIES:
x = fin.loc[k]
mejor = x["Sharpe dentro"].idxmax()
print(f"{k:<8} fuera: media {x['Sharpe fuera'].mean():+.2f}, "
f"rango {x['Sharpe fuera'].min():+.2f} a "
f"{x['Sharpe fuera'].max():+.2f}; el mejor dentro "
f"(semilla {mejor}) da {x.loc[mejor, 'Sharpe fuera']:+.2f}")error típico del Sharpe: 0.32 en 10 años, 0.35 en 8 años
máximo esperado de 3 Sharpes sin ventaja (fuera): 0.30
máximo esperado de 6 Sharpes sin ventaja (fuera): 0.46
máximo esperado de 20 Sharpes sin ventaja (fuera): 0.67
control fuera: media -0.23, rango -0.53 a +0.03; el mejor dentro (semilla 1) da -0.53
ventaja fuera: media +0.75, rango +0.63 a +0.81; el mejor dentro (semilla 1) da +0.80Con un error típico de 0,35 en 8 años, la dispersión entre semillas de la serie con ventaja (0,63 a 0,81) es pequeña frente al error de la propia muestra: las tres semillas ven los mismos 2.011 días de prueba, y si esos días hubieran sido otros, las tres cifras se moverían juntas. En el control, la dispersión es mayor (−0,53 a +0,03) y elegir por el Sharpe de dentro lleva a la peor semilla de fuera (la 1). La tabla de máximos esperados da la escala: con 6 agentes sin ninguna ventaja, el mejor Sharpe fuera de muestra esperado ya es 0,46, y con 20 configuraciones (unas pocas arquitecturas, tasas de aprendizaje y semillas, algo que cualquier proyecto alcanza en una tarde), 0,67. Un 0,7 fuera de muestra elegido entre veinte intentos no demuestra nada; aquí demuestra algo solo porque se conoce el control. El Deflated Sharpe de la lección 2.5.4 formaliza la corrección; Henderson et al. (2018) documentan el mismo problema de semillas en los artículos de RL en general, fuera de las finanzas.
Reward hacking: un error en los costes
El último experimento es un error de programación muy común. Quien escribe el entorno cobra el coste «al abrir una
posición»: si se está fuera y se entra, se paga; si ya se está dentro, no. Parece razonable hasta que se piensa en el
giro: pasar de largo a corto, que en la realidad cuesta el doble que abrir, sale gratis, y cerrar también. Es la
variante coste_solo_al_abrir=True del entorno. Se entrena un agente con ella en la serie con ventaja (semilla 0) y se
evalúa, como siempre, con los costes correctos.
m_hack, hist_hack = entrena(SERIES["ventaja"], 0,
coste_solo_al_abrir=True)
honesto = res[(res["serie"] == "ventaja") & (res["semilla"] == 0)]
comp = pd.DataFrame(
[["coste correcto", *honesto.iloc[-1, 3:]],
["coste solo al abrir", *hist_hack[-1][1:]]],
columns=["recompensa de entrenamiento", "Sharpe dentro",
"rot. dentro", "Sharpe fuera", "rot. fuera"])
def sharpe_segun_recompensa(m, r):
"""Sharpe dentro de muestra con los costes del entorno trucado:
lo que el agente cree que gana."""
env = DireccionalEnv(r, ventana=corte - L - 1,
coste_solo_al_abrir=True)
o, _ = env.reset(seed=0)
env.t, env.t_fin, env.pos = L, corte - 1, 0.0
o, pnl, fin = env._obs(), [], False
while not fin:
a = m.predict(o, deterministic=True)[0]
o, rec, _, fin, _ = env.step(a)
pnl.append(rec * SD)
pnl = np.array(pnl)
return round(pnl.mean() / pnl.std() * np.sqrt(252), 2)
print("Sharpe dentro según la recompensa trucada:",
sharpe_segun_recompensa(m_hack, SERIES["ventaja"]))
compSharpe dentro según la recompensa trucada: 2.03
recompensa de entrenamiento Sharpe dentro rot. dentro Sharpe fuera rot. fuera
0 coste correcto 1.84 0.11 0.63 0.11
1 coste solo al abrir 1.48 0.40 0.37 0.34El agente de la recompensa trucada cree que gana un Sharpe de 2,03 dentro de muestra, más que el honesto. Con los costes reales, 1,48 dentro y 0,37 fuera, frente a 1,84 y 0,63 del agente con el coste bien programado. La figura compara los dos. La causa está en la rotación: 0,40 unidades por día dentro de muestra, casi cuatro veces la del agente honesto (0,11). Para él, girar de largo a corto y cerrar no cuesta nada, así que sigue cada cambio de la señal y del ruido de los retardos; la evaluación con los costes reales le cobra todos esos giros. Es reward hacking (o specification gaming): el agente optimiza la recompensa que se le dio, no la que se quería darle, y es mejor que una persona encontrando el agujero.
Dos defensas, las dos baratas. Evaluar siempre con un cálculo del P&L independiente del entorno de entrenamiento (como
evalua en esta lección), y vigilar la rotación: un agente que rota mucho más que el oráculo o que una regla simple
con la misma información está explotando algo. Un test unitario del entorno con giros conocidos (de +1 a −1 debe costar
dos veces el coste unitario) habría cazado este error antes de entrenar; check_env no, porque comprueba la interfaz,
no la contabilidad.
FinRL como referencia crítica
FinRL es la librería de RL para trading más conocida: la mantiene la AI4Finance Foundation, la describe un artículo de 2020 (Liu et al., arXiv:2011.09607) y ofrece entornos con la interfaz de Gymnasium para acciones, cripto y carteras, agentes de Stable-Baselines3, ElegantRL y RLlib, descargadores de datos y tutoriales de principio a fin. Lo comprobado en sus fuentes a 23 de octubre de 2026:
| Dato | Valor | Fuente |
|---|---|---|
| Licencia | MIT | GitHub (API del repositorio) y PyPI |
| Última versión en PyPI | 0.3.7, publicada el 12 de abril de 2024 | pypi.org/pypi/finrl/json |
| Última versión etiquetada en GitHub | v0.3.8, 20 de marzo de 2026 | API de releases de GitHub |
| Actividad | último commit en master el 20 de octubre de 2026 | API de commits de GitHub |
Recompensa de StockTradingEnv | cambio del valor total de la cuenta en cada paso, por reward_scaling | finrl/meta/env_stock_trading/env_stocktrading.py |
El paquete de PyPI va dos años y medio por detrás del repositorio: pip install finrl instala código de 2024. No está
instalado en el entorno del curso; el flujo típico de sus tutoriales, con su API actual leída en el repositorio, es
este (necesita red para descargar precios de Yahoo):
from finrl.agents.stablebaselines3.models import DRLAgent
from finrl.config import INDICATORS
from finrl.meta.env_stock_trading.env_stocktrading import (
StockTradingEnv)
from finrl.meta.preprocessor.preprocessors import (FeatureEngineer,
data_split)
from finrl.meta.preprocessor.yahoodownloader import YahooDownloader
df = YahooDownloader(start_date="2009-01-01", end_date="2024-01-01",
ticker_list=["AAPL", "MSFT", "JPM"]).fetch_data()
df = FeatureEngineer(use_technical_indicator=True,
tech_indicator_list=INDICATORS,
use_turbulence=True).preprocess_data(df)
train = data_split(df, "2009-01-01", "2020-07-01")
trade = data_split(df, "2020-07-01", "2024-01-01")
n = train["tic"].nunique()
kw = dict(stock_dim=n, hmax=100, initial_amount=1_000_000,
num_stock_shares=[0] * n, buy_cost_pct=[0.001] * n,
sell_cost_pct=[0.001] * n, reward_scaling=1e-4,
state_space=1 + 2 * n + len(INDICATORS) * n,
action_space=n, tech_indicator_list=INDICATORS)
env_train, _ = StockTradingEnv(df=train, **kw).get_sb_env()
agente = DRLAgent(env=env_train)
ppo = agente.get_model("ppo")
ppo = DRLAgent.train_model(ppo, tb_log_name="ppo",
total_timesteps=50_000)
cuenta, acciones = DRLAgent.DRL_prediction(
model=ppo, environment=StockTradingEnv(df=trade, **kw))Nada de esto es incorrecto en sí: es una forma cómoda de montar el experimento. El problema es lo que no hace, y que un resultado de un tutorial se lea como evidencia:
- Una sola partición entrenamiento-prueba, sin walk-forward (lección 5.4.3) ni purga; si los hiperparámetros o el número de pasos se ajustan mirando el periodo de prueba, la prueba deja de serlo.
- Una semilla por defecto (
seed=Noneenget_model): el resultado cambia en cada ejecución, y sin varias semillas no se sabe cuánto (este módulo: varios décimos de Sharpe). - Universo de componentes actuales: en octubre de 2026 el propio repositorio fusionó la pull request 1462, que aclara el sesgo de supervivencia del backtest del Dow de un tutorial y pasa a componentes point-in-time. Los resultados publicados antes con ese tutorial llevan el sesgo dentro (lecciones 1.4.4 y 2.5.1).
- Sin control: ningún tutorial compara con el mismo agente entrenado sobre una serie sin señal, que es lo que separa aprender una ventaja de aprender la muestra.
- Comparación con comprar y mantener o con un índice en un periodo concreto, sin error típico: con 3 años de prueba, el error típico del Sharpe es de casi 0,6.
Antes de creer un agente direccional
- ¿Hay un control? El mismo agente, con la misma configuración, entrenado en una serie sin señal (permutada o sintética) tiene que dar fuera de muestra lo que se espera del azar.
- ¿Fuera de muestra de verdad? Prueba posterior al entrenamiento y a la elección de hiperparámetros, mejor en walk-forward con reentrenamiento (lección 5.4.3).
- ¿Cuántas semillas y configuraciones? Todas, con su dispersión, y el Sharpe deflactado por el número de pruebas (lección 2.5.4).
- ¿P&L neto completo? Calculado fuera del entorno de entrenamiento, con costes, deslizamiento y financiación reales, y la rotación a la vista.
- ¿Contra qué? Comprar y mantener, la regla lineal más simple con la misma información (5.5.1) y, si existe, una referencia analítica. Un agente que no gana a una regresión con las mismas features no ha aprendido nada que valga su complejidad.
- ¿Datos point-in-time? Universo con las empresas que existían en cada fecha y features con la fecha en que se conocían (lección 5.2.2).
Ejercicios
- Repite el entrenamiento de la serie con ventaja, semilla 0, con el doble de pasos (cuatro bloques de 30.000). ¿Sube el Sharpe dentro de muestra? ¿Y el de fuera?
Solución
_, hist_largo = entrena(SERIES["ventaja"], 0, bloques=4)
pd.DataFrame(hist_largo, columns=["pasos", "Sharpe dentro",
"rot. dentro", "Sharpe fuera",
"rot. fuera"]) pasos Sharpe dentro rot. dentro Sharpe fuera rot. fuera
0 30000 1.60 0.14 0.38 0.15
1 60000 1.84 0.11 0.63 0.11
2 90000 2.10 0.11 0.71 0.12
3 120000 2.06 0.15 0.55 0.16Dentro de muestra, el Sharpe sube a 2,10 a los 90.000 pasos y se queda en 2,06 a los 120.000. Fuera, sube a 0,71 y cae a 0,55, con más rotación (0,16 al día): el agente empieza a ajustar detalles de la muestra de entrenamiento que no se repiten. Si el número de pasos se eligiera mirando el periodo de prueba (90.000, el mejor), esa elección sería otra prueba múltiple, y el 0,71 dejaría de ser una medida fuera de muestra. En una validación honesta, el número de pasos se elige con una validación dentro del periodo de entrenamiento (walk-forward o purged k-fold, lecciones 5.4.2 y 5.4.3) y el periodo de prueba se mira una sola vez.
- Con el error típico del Sharpe, ¿cuántos años de datos harían falta para que el Sharpe fuera de muestra del oráculo de la serie con ventaja (el esperado, no el realizado) estuviera dos errores típicos por encima de cero?
Solución
# Sharpe anual esperado del oráculo bruto: kappa · E|s| · raíz(252)
sr = KAPPA * np.sqrt(2 / np.pi) * np.sqrt(252)
neto = sr - COSTE * 0.2 / SD * np.sqrt(252) # rotación ≈ 0,2/día
for nombre, x in (("bruto", sr), ("neto de costes", neto)):
anos = 4 * (1 + x**2 / 2) / x**2
print(f"Sharpe esperado {nombre}: {x:.2f}; años para 2 errores "
f"típicos: {anos:.0f}")Sharpe esperado bruto: 1.27; años para 2 errores típicos: 4
Sharpe esperado neto de costes: 0.98; años para 2 errores típicos: 6Con un IC verdadero de 0,10, el oráculo tiene un Sharpe esperado bruto de 1,27 y, con una rotación de unas 0,2 unidades al día y 5 pb de coste, de 0,98 neto. Para que el Sharpe realizado quede a dos errores típicos de cero hacen falta 4 años en bruto y 6 en neto. Con una ventaja de la mitad (IC de 0,05, más realista) el Sharpe bruto se queda en 0,63 y los costes no bajan: harían falta unos 12 años en bruto y unos 35 en neto. Por eso los 8 años de prueba de esta lección bastan para la ventaja plantada (y aun así el oráculo realizado, 0,58, queda más de un error típico por debajo de su esperanza), y por eso con señales reales de IC de unas centésimas un backtest de pocos años no distingue un agente bueno de uno con suerte.
- Un artículo presenta un agente de RL que, en cinco años de prueba sobre 30 acciones, obtiene un Sharpe de 2,1 frente a 0,8 de comprar y mantener. No dice cuántas configuraciones probó ni cuántas semillas. ¿Qué tres datos pedirías y por qué?
Solución
Primero, el número de configuraciones y semillas probadas y la dispersión de sus resultados: con N pruebas, el máximo esperado sin ventaja crece con N (con un error típico de 0,45 en cinco años y N = 20, ya ronda 0,86 de Sharpe sobre el comparador), y hace falta deflactar (lección 2.5.4). Segundo, cómo se eligió el universo de 30 acciones y los hiperparámetros: si las acciones son las que hoy forman un índice, hay sesgo de supervivencia (el que FinRL corrigió en su tutorial del Dow); si el número de pasos o la arquitectura se eligió mirando el periodo de prueba, no hay periodo de prueba. Tercero, el P&L neto completo con la rotación: un agente que gira la cartera a diario con costes del 0,1 % pierde unos 25 puntos de rentabilidad al año por cada unidad de rotación diaria (0,1 % × 252 sesiones), y el reward hacking de esta lección enseña que una recompensa con los costes mal programados produce exactamente esa rotación. Con esos tres datos, y un control sin señal, el 2,1 se puede empezar a discutir.
Respuesta plegada en cada pregunta
Autoevaluación
En la serie de control (rendimientos con media condicional constante e igual a cero), los tres agentes PPO acaban con un Sharpe dentro de muestra de entre 0,45 y 0,73. ¿Qué explica esa cifra?
- AQue el agente ha encontrado una ventaja que el README no conoce
- BQue PPO siempre da Sharpe positivo
- CQue la muestra de entrenamiento tuvo, por azar, una deriva positiva (comprar y mantener da 0,60 en esos años) y el agente aprendió a estar largo; fuera de muestra la deriva no existe
- DQue los costes no se cobran dentro de muestra
Ver respuesta
Respuesta C. Que la muestra de entrenamiento tuvo, por azar, una deriva positiva (comprar y mantener da 0,60 en esos años) y el agente aprendió a estar largo; fuera de muestra la deriva no existe
Con 10 años de datos, el error típico de un Sharpe anual es de unos 0,3: un Sharpe de 0,6 en un activo sin deriva es un accidente de la muestra de dos errores típicos, y el agente lo aprende como si fuera una regla.
En la serie con ventaja (IC verdadero 0,10), los agentes dan dentro de muestra un Sharpe de 1,77 a 1,98 y fuera, de 0,63 a 0,81. ¿Qué lectura es la correcta?
- AQue el agente ha dejado de funcionar en 2018
- BQue el Sharpe de dentro mide sobre todo el ajuste a la muestra (incluida la suerte de un IC medido de 0,122); el de fuera, lo aprendido de verdad, que aquí es real porque la ventaja está plantada
- CQue hay que entrenar más hasta que los dos coincidan
- DQue el resultado de fuera es azar, porque supera al oráculo
Ver respuesta
Respuesta B. Que el Sharpe de dentro mide sobre todo el ajuste a la muestra (incluida la suerte de un IC medido de 0,122); el de fuera, lo aprendido de verdad, que aquí es real porque la ventaja está plantada
Los agentes superan al oráculo ingenuo (0,58) porque operan menos, no por azar. Pero el Sharpe de dentro de muestra promete más del doble de lo que dan: con datos reales, esa distancia es la que separa una estrategia excepcional de una corriente.
Se entrenan 6 agentes (3 semillas × 2 configuraciones) y se presenta el de mejor Sharpe dentro de muestra. ¿Qué corrección corresponde?
- ANinguna: la semilla no es un parámetro
- BDividir el Sharpe entre 6
- CRepetir el entrenamiento con la misma semilla
- DTratar las semillas como pruebas múltiples: comparar el Sharpe elegido con el máximo esperado de 6 Sharpes sin ventaja (Deflated Sharpe, lección 2.5.4)
Ver respuesta
Respuesta D. Tratar las semillas como pruebas múltiples: comparar el Sharpe elegido con el máximo esperado de 6 Sharpes sin ventaja (Deflated Sharpe, lección 2.5.4)
Elegir la mejor de N semillas es elegir el máximo de N variables aleatorias. Con un error típico de 0,35 (8 años) y N = 6, el máximo esperado sin ninguna ventaja ya es de 0,46 de Sharpe anual.
La recompensa de entrenamiento cobra el coste solo al abrir una posición desde fuera; girar de largo a corto sale gratis. ¿Qué hace el agente?
- AExplota el error: rota casi cuatro veces más que el agente honesto, porque para él girar y cerrar no cuestan; cree que gana un Sharpe de 2,03 y, con los costes reales, da 0,37 fuera de muestra frente a 0,63
- BNada distinto, porque los costes son pequeños
- CDeja de operar
- DSe queda siempre fuera de mercado
Ver respuesta
Respuesta A. Explota el error: rota casi cuatro veces más que el agente honesto, porque para él girar y cerrar no cuestan; cree que gana un Sharpe de 2,03 y, con los costes reales, da 0,37 fuera de muestra frente a 0,63
Es reward hacking: el agente optimiza la recompensa que se le da, no la que se quería darle. La única defensa es evaluar con el P&L neto completo, calculado fuera del entorno de entrenamiento.
¿Qué comprobación es la más importante antes de creer un backtest de un agente de RL direccional hecho con FinRL o con cualquier otra librería?
- AQue use PPO y no DQN
- BQue el periodo de prueba esté fuera del de entrenamiento y de la elección de hiperparámetros, con universo point-in-time, costes realistas, varias semillas y una comparación con comprar y mantener y con un control sin señal
- CQue la curva de capital sea suave
- DQue tenga muchas estrellas en GitHub
Ver respuesta
Respuesta B. Que el periodo de prueba esté fuera del de entrenamiento y de la elección de hiperparámetros, con universo point-in-time, costes realistas, varias semillas y una comparación con comprar y mantener y con un control sin señal
La librería no valida nada por el usuario. La propia FinRL corrigió en octubre de 2026 el sesgo de supervivencia de un tutorial que usaba los componentes actuales del Dow.
Ideas clave
- El trading direccional es el peor caso para el RL: una sola historia que no se puede volver a simular, una señal de unas centésimas de IC, un mercado que cambia y una red con miles de parámetros.
- En el control sin predictibilidad, los agentes PPO aprenden la deriva casual de la muestra de entrenamiento (Sharpe dentro de 0,45 a 0,73, casi sin operar) y fuera de muestra dan de −0,53 a +0,03.
- Con una ventaja plantada de IC 0,10, los agentes la recogen y aprenden a operar menos que el oráculo ingenuo: fuera, de 0,63 a 0,81 frente a 0,58. Dentro de muestra prometían de 1,77 a 1,98, más del doble.
- La semilla es un hiperparámetro: elegir la mejor de N es una prueba múltiple, y hay que deflactar el Sharpe (lección 2.5.4).
- Reward hacking: con el coste mal programado (gratis al girar y al cerrar), el agente rota casi cuatro veces más y su Sharpe fuera cae de 0,63 a 0,37, mientras su recompensa le decía 2,03. Se evalúa siempre con el P&L completo, fuera del entorno de entrenamiento.
- FinRL (licencia MIT) es útil como catálogo de entornos y para aprender, no como evidencia. En PyPI su última versión es la 0.3.7, de abril de 2024, mientras el repositorio sigue activo; sus tutoriales no sustituyen a una validación honesta.
Recursos
Documentación
- Henderson, P. et al. (2018): Deep Reinforcement Learning that Matters. AAAI. arXiv:1709.06560
- Moody, J. y Saffell, M. (2001): Learning to trade via direct reinforcement. IEEE Transactions on Neural Networks 12(4)
- Liu, X.-Y. et al. (2020): FinRL: A Deep Reinforcement Learning Library for Automated Stock Trading in Quantitative Finance. arXiv:2011.09607
- FinRL en GitHub (AI4Finance-Foundation, licencia MIT)
- FinRL, pull request 1462: sesgo de supervivencia en el backtest del Dow