---
title: "5.9.3 Por qué el RL sobreajusta en trading direccional"
description: "Un agente PPO en una serie sin predictibilidad y en otra con una ventaja pequeña: dentro y fuera de muestra, semillas, recompensas trucadas y FinRL."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/por-que-el-rl-sobreajusta-en-trading-direccional/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/por-que-el-rl-sobreajusta-en-trading-direccional.png"
---

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

- Curso: [Nivel 5: Machine learning y deep learning (Keras/PyTorch)](https://pythonparatrading.com/curso-python-trading/nivel-5/)
- Módulo: 5.9 · Aprendizaje por refuerzo
- Duración: 65 min
- Requisitos: [5.9.1 · MDP, Q-learning, PPO y entornos Gymnasium](https://pythonparatrading.com/curso-python-trading/nivel-5/mdp-q-learning-ppo-y-entornos-gymnasium/), [5.9.2 · RL para ejecución, market making y cobertura dinámica](https://pythonparatrading.com/curso-python-trading/nivel-5/rl-para-ejecucion-market-making-y-hedging/), [2.5.4 · Deflated Sharpe Ratio y Probability of Backtest Overfitting (PBO)](https://pythonparatrading.com/curso-python-trading/nivel-2/deflated-sharpe-ratio-y-pbo/), [5.4.3 · Walk-forward con reentrenamiento: ventanas y frecuencia](https://pythonparatrading.com/curso-python-trading/nivel-5/walk-forward-con-reentrenamiento/)

## Al terminar esta lección

- Explicar por qué el trading direccional es el peor caso para el RL: una sola historia, señal/ruido muy baja, no estacionariedad y políticas muy flexibles.
- Entrenar un agente PPO en un control sin predictibilidad y en una serie con una ventaja plantada, y comparar dentro y fuera de muestra con el oráculo y con comprar y mantener.
- Medir la varianza entre semillas y tratar las semillas como pruebas múltiples (Deflated Sharpe).
- Reconocer el reward hacking: un agente que explota un error en los costes de la recompensa, y por qué la evaluación tiene que usar siempre el P&L neto completo.
- Leer FinRL con criterio: qué es, su licencia y mantenimiento, y qué hay que comprobar antes de creer un resultado hecho con él.

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

```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 $s_t$, un AR(1) con persistencia 0,9 y
varianza 1, predice el rendimiento del día siguiente:

$$
r^{B}_{t+1} = r_{t+1} + \kappa\,\sigma\,s_t, \qquad \kappa = 0{,}10
$$

con $\sigma$ 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 $s_t$ y su posición. En el control, $s_t$ es un ruido sin
relación con nada. Entrenamiento: 2008-2017; prueba: 2018-2025.

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

`vol5_diario.parquet` son datos sintéticos (README del nivel 5) y la ventaja de la serie B se planta en esta lección.
Ninguna cifra de aquí dice nada sobre si el RL gana dinero en un mercado real; dicen cuánto se puede equivocar uno al
medirlo.

</aside>

```python
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}")
```
```output
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.077
```

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

```python
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")
```
```output
check_env: sin errores
```

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

```python
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
```
```output
     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.26
```

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

```python
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
```
```output
      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.08
```

La 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 $T$ años es de unos
$\sqrt{(1 + SR^2/2)/T}$, y el máximo de $N$ Sharpes sin ninguna ventaja tiene esperanza positiva:

```python
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}")
```
```output
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.80
```

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

```python
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"]))
comp
```
```output
Sharpe 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.34
```

*Fig. — Reward hacking: el agente entrenado con el coste cobrado solo al abrir cree que gana más que el honesto; con los costes reales rota casi cuatro veces más y gana menos dentro y fuera de muestra.* Barras horizontales de Sharpe anual: según la recompensa trucada 2,03 (destacado); el mismo agente con los costes reales 1,48 dentro (rotación 0,40 al día) y 0,37 fuera (0,34); el agente con el coste correcto 1,84 dentro y 0,63 fuera (rotación 0,11).

El 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):

```python skip
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=None` en `get_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.

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

FinRL sirve para lo que sirve una buena librería de ejemplos: ver cómo se monta un entorno de cartera, reutilizar
descargadores y comparar algoritmos con poco código. Lo que no puede hacer, y ninguna librería puede, es validar el
resultado por el usuario. Las preguntas de la lista de abajo valen igual para un artículo, un repositorio o un
proveedor.

</aside>

## Antes de creer un agente direccional

1. **¿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.
2. **¿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).
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).
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.
5. **¿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.
6. **¿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

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

```python
_, hist_largo = entrena(SERIES["ventaja"], 0, bloques=4)
pd.DataFrame(hist_largo, columns=["pasos", "Sharpe dentro",
                                  "rot. dentro", "Sharpe fuera",
                                  "rot. fuera"])
```
```output
    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.16
```

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

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

```python
# 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}")
```
```output
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: 6
```

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

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

## Autoevaluación

1. 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?

   - A. Que el agente ha encontrado una ventaja que el README no conoce
   - B. Que PPO siempre da Sharpe positivo
   - 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
   - D. Que los costes no se cobran dentro de muestra

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

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

   - A. Que el agente ha dejado de funcionar en 2018
   - 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
   - C. Que hay que entrenar más hasta que los dos coincidan
   - D. Que el resultado de fuera es azar, porque supera al oráculo

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

3. Se entrenan 6 agentes (3 semillas × 2 configuraciones) y se presenta el de mejor Sharpe dentro de muestra. ¿Qué corrección corresponde?

   - A. Ninguna: la semilla no es un parámetro
   - B. Dividir el Sharpe entre 6
   - C. Repetir el entrenamiento con la misma semilla
   - 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)

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

4. 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?

   - 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
   - B. Nada distinto, porque los costes son pequeños
   - C. Deja de operar
   - D. Se queda siempre fuera de mercado

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

5. ¿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?

   - A. Que use PPO y no DQN
   - 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
   - C. Que la curva de capital sea suave
   - D. Que tenga muchas estrellas en GitHub

   *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

### Research

- [Estacionalidad Intradiaria IV - Busqueda de campo](https://pythonparatrading.com/estacionalidad-intradia-optimizacion/) · Research-0027 · 7 min

### Documentación

- [Henderson, P. et al. (2018): Deep Reinforcement Learning that Matters. AAAI. arXiv:1709.06560](https://arxiv.org/abs/1709.06560) · arxiv.org
- [Moody, J. y Saffell, M. (2001): Learning to trade via direct reinforcement. IEEE Transactions on Neural Networks 12(4)](https://doi.org/10.1109/72.935097) · doi.org
- [Liu, X.-Y. et al. (2020): FinRL: A Deep Reinforcement Learning Library for Automated Stock Trading in Quantitative Finance. arXiv:2011.09607](https://arxiv.org/abs/2011.09607) · arxiv.org
- [FinRL en GitHub (AI4Finance-Foundation, licencia MIT)](https://github.com/AI4Finance-Foundation/FinRL) · github.com
- [FinRL, pull request 1462: sesgo de supervivencia en el backtest del Dow](https://github.com/AI4Finance-Foundation/FinRL/pull/1462) · github.com

### Datos

- [Datos del nivel 5: rendimientos y varianza realizada de seis activos (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/vol5_diario.parquet) · Descarga · vol5_diario.parquet

Lección anterior: [5.9.2 · RL para ejecución, market making y cobertura dinámica](https://pythonparatrading.com/curso-python-trading/nivel-5/rl-para-ejecucion-market-making-y-hedging/)

Lección siguiente: [5.10.1 · SHAP, dependencia parcial y estabilidad de las importancias](https://pythonparatrading.com/curso-python-trading/nivel-5/shap-dependencia-parcial-y-estabilidad/)
