Lección 5.9.1 · 70 min

MDP, Q-learning, PPO y entornos Gymnasium

El aprendizaje por refuerzo promete aprender a operar por prueba y error. Antes de creerlo en un mercado hay que verlo funcionar donde la respuesta se conoce: un problema de trading pequeño, con costes, cuya política óptima sale exacta por iteración de valor. Esta lección lo plantea como MDP, lo resuelve, lo aprende con Q-learning, lo empaqueta como entorno Gymnasium y mide a PPO y SAC contra el óptimo.

Fig. — P&L neto por paso de cada política en los mismos 40 episodios del MDP de regímenes con costes: el óptimo de la iteración de valor frente a los agentes y dos reglas fijas
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

Del backtest a la decisión secuencial

Todo lo que el nivel 5 ha hecho hasta aquí es aprendizaje supervisado: una tabla de features, una etiqueta y un modelo que predice la etiqueta. La decisión de operar venía después, como una regla fija encima de la predicción (comprar el decil alto, lección 5.4.4). El aprendizaje por refuerzo (RL, reinforcement learning) junta las dos cosas: un agente observa el estado, elige una acción, el entorno le devuelve una recompensa y un estado nuevo, y el agente aprende la regla de decisión que maximiza la suma de recompensas futuras. No hay etiqueta: solo la consecuencia de cada acción.

acciónrecompensaestado nuevoAgenteEntorno
Fuente Mermaid
flowchart LR
  A[Agente] -->|acción| E[Entorno]
  E -->|recompensa| A
  E -->|estado nuevo| A

Esa forma encaja con problemas de trading donde la acción de hoy cambia el problema de mañana: una posición abierta que cuesta cerrar, un inventario que hay que liquidar (lección 4.3.1), las cotizaciones de un creador de mercado que acumulan inventario (lección 2.3.6). El precio de la flexibilidad es alto: el agente aprende por prueba y error, con recompensas ruidosas, y nada garantiza que lo aprendido sea lo óptimo. Por eso esta lección empieza por un problema cuya respuesta exacta se puede calcular, y solo después deja que los algoritmos la busquen.

Un problema de trading como MDP

Un proceso de decisión de Markov (MDP) es una tupla (S,A,P,R,γ)(\mathcal{S}, \mathcal{A}, P, R, \gamma): estados, acciones, probabilidades de transición P(s′∣s,a)P(s' \mid s, a), recompensa esperada R(s,a)R(s, a) y factor de descuento γ∈[0,1)\gamma \in [0, 1). «De Markov» significa que el estado actual resume todo lo que importa del pasado para lo que viene.

El problema de la lección:

  • Régimen del mercado: bajista, neutro o alcista. Sigue una cadena de Markov: cada régimen extremo dura de media cinco pasos (probabilidad de seguir 0,8) y del neutro se sale a cualquiera de los dos lados con la misma probabilidad.
  • Rendimiento del paso siguiente: normal con media μs=−5,0,+5\mu_s = -5, 0, +5 pb según el régimen actual y desviación σ=20\sigma = 20 pb.
  • Acción: la posición nueva, corto (−1), fuera (0) o largo (+1).
  • Recompensa: el P&L neto, rt+1=at⋅rett+1−c⋅∣at−pt∣r_{t+1} = a_t \cdot \text{ret}_{t+1} - c \cdot |a_t - p_t|, con un coste c=6c = 6 pb por unidad negociada. Pasar de corto a largo cuesta 12 pb.
  • Estado: el par (régimen, posición actual). La posición tiene que estar en el estado: con costes, la mejor acción depende de dónde se está. Son 3 × 3 = 9 estados.
0,20,10,10,2BajistaNeutroAlcista
Fuente Mermaid
stateDiagram-v2
  Bajista --> Neutro: 0,2
  Neutro --> Bajista: 0,1
  Neutro --> Alcista: 0,1
  Alcista --> Neutro: 0,2

El régimen es observable, como si el agente tuviera un indicador perfecto del estado del mercado. Es una simplificación fuerte (en la realidad se estima con ruido, lección 2.2.7), pero aísla lo que interesa aquí: cómo interactúan una señal persistente y los costes.

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

P = np.array([[0.8, 0.2, 0.0],    # desde bajista
              [0.1, 0.8, 0.1],    # desde neutro
              [0.0, 0.2, 0.8]])   # desde alcista
MU = np.array([-5.0, 0.0, 5.0])   # rendimiento esperado, pb/paso
SIGMA, COSTE, GAMMA = 20.0, 6.0, 0.95
POS = np.array([-1.0, 0.0, 1.0])  # corto, fuera, largo
REG = ["bajista", "neutro", "alcista"]

def recompensas(pos):
    """R[s, p, a]: recompensa esperada en el régimen s con la
    posición p al pasar a la posición a."""
    nueva, actual = pos[None, None, :], pos[None, :, None]
    return (MU[:, None, None] * nueva
            - COSTE * np.abs(nueva - actual))

R = recompensas(POS)
w, v = np.linalg.eig(P.T)
estac = np.real(v[:, np.argmin(np.abs(w - 1))])
estac /= estac.sum()
print("distribución estacionaria:", np.round(estac, 3))
print("duración media de un régimen extremo:",
      round(1 / (1 - P[2, 2]), 1), "pasos")
OUT [01]El MDP: transiciones, medias y recompensa esperada
distribución estacionaria: [0.25 0.5  0.25]
duración media de un régimen extremo: 5.0 pasos

A largo plazo, el mercado pasa la mitad del tiempo en neutro y una cuarta parte en cada extremo. Un régimen alcista dura de media cinco pasos: si el agente entra en largo al empezar, espera ganar unos 25 pb antes de que cambie, con un coste de entrada de 6. La pregunta es si un algoritmo lo descubre, y para saberlo hace falta la respuesta.

La política óptima: iteración de valor

El valor de una política π\pi en un estado es la suma descontada de recompensas que se espera al seguirla desde ahí. La función de valor de acción óptima Q∗(s,a)Q^*(s, a) (lo que se espera ganar si se hace aa en ss y después se actúa de forma óptima) cumple la ecuación de Bellman:

Q∗(s,a)=R(s,a)+γ∑s′P(s′∣s,a)max⁡a′Q∗(s′,a′)Q^*(s, a) = R(s, a) + \gamma \sum_{s'} P(s' \mid s, a) \max_{a'} Q^*(s', a')

La política óptima es elegir en cada estado la acción con mayor Q∗Q^*. Cuando se conocen PP y RR, como aquí, la ecuación se resuelve aplicándola una y otra vez a partir de cualquier tabla inicial: es la iteración de valor, que converge porque cada pasada encoge el error por un factor γ\gamma. En este MDP, el estado siguiente es (régimen nuevo, posición elegida), así que max⁡a′Q∗(s′,a′)\max_{a'} Q^*(s', a') es el valor V∗V^* del régimen siguiente con la posición aa.

IN [02]python
def iteracion_valor(R, pos=POS, tol=1e-10):
    V = np.zeros((3, len(pos)))
    for it in range(1, 100_000):
        cont = np.einsum("st,ta->sa", P, V)  # E[V(s', a) | s]
        Q = R + GAMMA * cont[:, None, :]
        V_nuevo = Q.max(axis=2)
        if np.abs(V_nuevo - V).max() < tol:
            return Q, it
        V = V_nuevo

Q_opt, iters = iteracion_valor(R)
pi_opt = Q_opt.argmax(axis=2)  # índice de la posición nueva
print(f"converge en {iters} iteraciones")
pd.DataFrame(POS[pi_opt], index=REG,
             columns=["desde corto", "desde fuera", "desde largo"])
OUT [02]Iteración de valor: Q* y la política óptima
converge en 463 iteraciones
         desde corto  desde fuera  desde largo
bajista         -1.0         -1.0         -1.0
neutro          -1.0          0.0          1.0
alcista          1.0          1.0          1.0

La política óptima es sencilla de leer y no es la obvia. En régimen bajista, corto desde cualquier sitio; en alcista, largo desde cualquier sitio; en neutro, mantener lo que se tenga. La media en neutro es cero, así que cerrar solo cuesta, y desde el neutro se vuelve al régimen anterior o se pasa al contrario con la misma probabilidad. Es la histéresis que imponen los costes: una zona en la que no se negocia (la lección 2.4.7 la midió en estrategias reales; la 4.3.1, en ejecución).

Para comparar políticas no hace falta simular. Una política tabular convierte el MDP en una cadena de Markov sobre los 9 estados; su distribución estacionaria dice cuánto tiempo se pasa en cada estado, y la recompensa media a largo plazo es la media de las recompensas esperadas ponderada por ese tiempo. Es una cifra exacta, sin ruido de muestreo.

IN [03]python
def recompensa_media(pi, pos=POS):
    """pb/paso a largo plazo de una política tabular pi[s, p]."""
    n = len(pos)
    T = np.zeros((3 * n, 3 * n))
    r = np.zeros(3 * n)
    for s in range(3):
        for p in range(n):
            a = pi[s, p]
            r[s * n + p] = (MU[s] * pos[a]
                            - COSTE * abs(pos[a] - pos[p]))
            T[s * n + p, np.arange(3) * n + a] = P[s]
    w, v = np.linalg.eig(T.T)
    d = np.real(v[:, np.argmin(np.abs(w - 1))])
    return float(d @ r / d.sum())

sin_costes = np.tile([[0], [1], [2]], (1, 3))  # signo de la media
miope = R.argmax(axis=2)                        # solo el paso actual
ref = {"óptima (iteración de valor)": recompensa_media(pi_opt),
       "señal sin pensar en costes": recompensa_media(sin_costes),
       "miope (un paso)": recompensa_media(miope)}
for k, val in ref.items():
    print(f"{k:<30}{abs(val):5.2f} pb/paso")
OUT [03]Recompensa media exacta de cualquier política tabular
óptima (iteración de valor)    1.90 pb/paso
señal sin pensar en costes     1.30 pb/paso
miope (un paso)                0.00 pb/paso
PolíticaReglapb por paso
ÓptimaBellman con costes1,90
Señal sin costesposición = signo de la media, fuera en neutro1,30
Miopelo mejor para el paso actual0,00

La regla que ignora los costes pierde un tercio del óptimo por cerrar en cada neutro. La miope no opera nunca: entrar en largo en un régimen alcista da 5 − 6 = −1 pb en el primer paso, y solo cuenta ese paso. Es el error de evaluar una decisión por su primer periodo, muy común al diseñar reglas con costes. El abs() del print solo evita el «−0.00» del redondeo de la miope.

¿Y si la posición pudiera ser cualquier valor entre −1 y 1? SAC, más abajo, trabaja con acciones continuas. Repetir la iteración de valor con 21 posiciones posibles responde si el óptimo cambia:

IN [04]python
pos_fina = np.round(np.linspace(-1, 1, 21), 2)
Q_fina, _ = iteracion_valor(recompensas(pos_fina), pos_fina)
pi_fina = Q_fina.argmax(axis=2)
print("rejilla de 21 posiciones:",
      round(recompensa_media(pi_fina, pos_fina), 2), "pb/paso")
print("en neutro, desde 0.3 va a", pos_fina[pi_fina[1, 13]])
OUT [04]El óptimo con 21 posiciones posibles
rejilla de 21 posiciones: 1.9 pb/paso
en neutro, desde 0.3 va a 0.3

Mismo valor, 1,90 pb por paso. Con la recompensa lineal en la posición, los extremos dominan: en bajista y alcista, posición completa; en neutro, mantener cualquier posición que se tenga. Las posiciones intermedias no añaden nada. Así, el óptimo de la versión continua es el mismo, y cualquier diferencia de SAC será del agente, no del problema.

Q-learning: aprender sin conocer el modelo

La iteración de valor necesita PP y RR. En un mercado no se conocen: solo se observan transiciones. Q-learning (Watkins, 1989) aprende Q∗Q^* a partir de cada transición observada (s,a,r,s′)(s, a, r, s') con una actualización que imita a Bellman con una muestra en vez de la esperanza:

Q(s,a)←Q(s,a)+α[r+γmax⁡a′Q(s′,a′)−Q(s,a)]Q(s, a) \leftarrow Q(s, a) + \alpha \left[ r + \gamma \max_{a'} Q(s', a') - Q(s, a) \right]

El término entre corchetes es el error de diferencia temporal: lo que la tabla esperaba frente a lo que ha visto más lo que espera del estado siguiente. Converge a Q∗Q^* si cada par (estado, acción) se visita infinitas veces y el paso α\alpha decrece lo bastante despacio. Para que se visiten todos, el agente explora: con probabilidad ε=0,1\varepsilon = 0{,}1 elige una acción al azar y, si no, la mejor según su tabla (política ε\varepsilon-voraz). El paso es α=n−0,7\alpha = n^{-0{,}7}, con nn las visitas a ese par.

IN [05]python
rng = np.random.default_rng(0)
PASOS = 300_000
explora = rng.random(PASOS) < 0.1
azar = rng.integers(0, 3, PASOS)
ruido = rng.standard_normal(PASOS)
u = rng.random(PASOS)
acum = P.cumsum(axis=1)

Q = np.zeros((3, 3, 3))
visitas = np.zeros((3, 3, 3))
s, p = 1, 1
idx = np.indices((3, 3))
curva = []
for k in range(PASOS):
    a = azar[k] if explora[k] else int(Q[s, p].argmax())
    r = (POS[a] * (MU[s] + SIGMA * ruido[k])
         - COSTE * abs(POS[a] - POS[p]))
    s2 = int(np.searchsorted(acum[s], u[k], side="right"))
    visitas[s, p, a] += 1
    alfa = visitas[s, p, a] ** -0.7
    Q[s, p, a] += alfa * (r + GAMMA * Q[s2, a].max() - Q[s, p, a])
    s, p = s2, a
    if (k + 1) % 10_000 == 0:
        pi = Q.argmax(axis=2)
        err = np.abs(Q - Q_opt)
        curva.append({
            "pasos": k + 1, "aciertos": int((pi == pi_opt).sum()),
            "pb/paso": round(recompensa_media(pi), 2) + 0.0,
            "error Q máx.": round(err.max(), 1),
            "error Q óptima": round(err[idx[0], idx[1],
                                        pi_opt].max(), 1)})
curva = pd.DataFrame(curva).set_index("pasos")
pi_q = Q.argmax(axis=2)
curva.iloc[[0, 1, 3, 5, 9, 14, 19, 29]]
OUT [05]Q-learning tabular con recompensas ruidosas
        aciertos  pb/paso  error Q máx.  error Q óptima
pasos
10000          1     0.00          59.0            59.0
20000          2     0.00          55.5            55.5
40000          3     0.65          54.5            54.5
60000          4     0.65          48.2            48.2
100000         6     0.95          43.1            43.1
150000         9     1.90          29.3            17.2
200000         9     1.90          28.2            14.4
300000         9     1.90          27.3             9.7
Fig. — Recompensa media exacta de la política voraz de Q-learning cada 10.000 pasos, con los estados en que coincide con la óptima: la política llega al óptimo a los 140.000 pasos

La tabla y la figura cuentan dos convergencias distintas. La política acierta en los 9 estados a los 140.000 pasos (la figura tiene los 30 puntos; la tabla, ocho) y desde ahí gana el óptimo exacto, 1,90 pb por paso. Los valores van mucho más despacio: el error máximo de la tabla sigue en 27,3 pb al final, y el de las acciones óptimas baja hasta 9,7 pb, pero tampoco llega a cero. Las acciones que la política voraz no elige solo se prueban en el 10 % de exploración, y su valor se estima con pocas muestras de una recompensa con desviación de 20 pb. Para decidir basta con que, en cada estado, la acción buena tenga el valor más alto.

Hacen falta cientos de miles de pasos para un problema de 9 estados con una señal enorme. Un paso aquí es una sesión, un minuto o una operación según el problema; 140.000 sesiones son más de 550 años. Esa es la primera razón por la que el RL en trading se entrena casi siempre en simuladores, y la razón por la que el simulador tiene que ser bueno (lección 5.9.2).

Un entorno Gymnasium con costes en la recompensa

Para usar algoritmos ya escritos, el problema se empaqueta con la interfaz estándar de Gymnasium (1.4, la continuación de OpenAI Gym que mantiene la Farama Foundation): una clase con un espacio de observaciones, un espacio de acciones, reset() para empezar un episodio y step(acción) para avanzar un paso, que devuelve la observación, la recompensa, si el episodio ha terminado (terminated, un final del problema) o se ha cortado (truncated, un límite de tiempo) y un diccionario de información.

Decisiones de diseño, que en RL pesan tanto como el algoritmo:

  • Observación: el régimen en one-hot (tres columnas) y la posición actual. Una red neuronal aprende mejor con el régimen como tres entradas de 0 y 1 que como un número 0, 1, 2 que sugiere un orden.
  • Costes dentro de la recompensa: el agente optimiza lo que se le recompensa. Si el coste no está en la recompensa, no existe para él (la lección 5.9.3 lo mide).
  • Escala: la recompensa es el P&L dividido por σ = 20 pb. Las redes y los optimizadores de Stable-Baselines3 tienen hiperparámetros por defecto pensados para recompensas de orden 1; con el P&L en pb, los gradientes del crítico son cientos de veces mayores.
  • Episodios de 250 pasos, cortados con truncated y no con terminated: el mercado no termina, solo deja de observarse. Con terminated, el algoritmo creería que después del paso 250 no hay valor futuro.
  • Azar del entorno con self.np_random, que reset(seed=…) siembra: así cada episodio es reproducible.
IN [06]python
import gymnasium as gym
from gymnasium import spaces
from gymnasium.utils.env_checker import check_env

class RegimenEnv(gym.Env):
    """MDP de regímenes: observa el régimen (one-hot) y la posición;
    la acción es la posición nueva; la recompensa, el P&L neto."""

    def __init__(self, continua=False, pasos=250):
        self.continua, self.pasos = continua, pasos
        self.observation_space = spaces.Box(-1.0, 1.0, (4,),
                                            np.float32)
        if continua:
            self.action_space = spaces.Box(-1.0, 1.0, (1,),
                                           np.float32)
        else:
            self.action_space = spaces.Discrete(3)

    def _obs(self):
        o = np.zeros(4, np.float32)
        o[self.s], o[3] = 1.0, self.pos
        return o

    def reset(self, seed=None, options=None):
        super().reset(seed=seed)
        u = self.np_random.random()
        self.s = int(np.searchsorted(estac.cumsum(), u,
                                     side="right"))
        self.pos, self.t = 0.0, 0
        return self._obs(), {}

    def step(self, action):
        if self.continua:
            nueva = float(np.clip(action[0], -1.0, 1.0))
        else:
            nueva = float(POS[int(action)])
        ret = MU[self.s] + SIGMA * self.np_random.standard_normal()
        pnl = nueva * ret - COSTE * abs(nueva - self.pos)
        u = self.np_random.random()
        self.s = int(np.searchsorted(acum[self.s], u, side="right"))
        self.pos, self.t = nueva, self.t + 1
        truncado = self.t >= self.pasos
        return self._obs(), pnl / SIGMA, False, truncado, {"pnl": pnl}

check_env(RegimenEnv(), skip_render_check=True)
check_env(RegimenEnv(continua=True), skip_render_check=True)
print("check_env: sin errores en las dos versiones")
env = RegimenEnv()
obs, _ = env.reset(seed=1)
print(obs, env.observation_space, env.action_space)
OUT [06]El entorno: RegimenEnv(gym.Env) y check_env
check_env: sin errores en las dos versiones
[0. 1. 0. 0.] Box(-1.0, 1.0, (4,), float32) Discrete(3)

check_env comprueba la interfaz: que reset y step devuelvan lo que deben, que las observaciones estén dentro de su espacio y con su tipo (float32), que la semilla haga los episodios reproducibles. skip_render_check=True se salta la prueba de los modos de dibujo, que este entorno no tiene (sin ella, avisa de que el entorno no se creó con gymnasium.make). Lo que check_env no comprueba es la economía: un coste con el signo cambiado, un rendimiento del paso equivocado (el de hoy en vez del de mañana, una fuga como las de 5.4.1) o una posición que no se guarda pasan la prueba igual. Esas se cazan comparando con una solución conocida, como aquí, o con controles sin señal.

Policy gradient y PPO

Q-learning aprende valores y deriva de ellos la política. Los métodos de gradiente de la política (policy gradient) parametrizan la política directamente, πθ(a∣s)\pi_\theta(a \mid s) (una red que da probabilidades de cada acción), y suben por el gradiente de la recompensa esperada:

∇θJ(θ)=E[∑t∇θlog⁡πθ(at∣st) A^t]\nabla_\theta J(\theta) = \mathbb{E}\left[ \sum_t \nabla_\theta \log \pi_\theta(a_t \mid s_t)\, \hat{A}_t \right]

donde A^t\hat{A}_t es la ventaja: cuánto mejor fue la acción ata_t de lo que el crítico (una segunda red que estima el valor del estado) esperaba. Las acciones que salieron mejor de lo esperado se vuelven más probables. El problema es que un paso de gradiente grande puede estropear la política de golpe, y con recompensas ruidosas pasa a menudo.

PPO (Proximal Policy Optimization, Schulman et al., 2017) limita cuánto puede cambiar la política en cada actualización. Con ρt=πθ(at∣st)/πθold(at∣st)\rho_t = \pi_\theta(a_t \mid s_t) / \pi_{\theta_{\text{old}}}(a_t \mid s_t), el cociente entre la política nueva y la que recogió los datos, maximiza

LCLIP(θ)=Et[min⁡(ρtA^t, clip⁡(ρt,1−ϵ,1+ϵ) A^t)]L^{\text{CLIP}}(\theta) = \mathbb{E}_t\left[ \min\left( \rho_t \hat{A}_t,\ \operatorname{clip}(\rho_t, 1 - \epsilon, 1 + \epsilon)\, \hat{A}_t \right) \right]

con ϵ=0,2\epsilon = 0{,}2: más allá de un 20 % de cambio en la probabilidad de una acción, el objetivo deja de premiar el cambio. Es el algoritmo por defecto de muchos trabajos de RL por su estabilidad. Funciona con acciones discretas y continuas; aquí, con las tres posiciones.

La configuración: 8 copias del entorno en paralelo (make_vec_env, cada una con su semilla), 256 pasos por copia antes de cada actualización, red de dos capas de 32 neuronas, el mismo descuento que el MDP y seed=0 con device="cpu" y algoritmos deterministas de PyTorch. 60.000 pasos en tres bloques, y tras cada bloque se lee la acción determinista del agente en los 9 estados y se evalúa con la fórmula exacta.

IN [07]python
import torch
from stable_baselines3 import PPO, SAC
from stable_baselines3.common.env_util import make_vec_env

torch.manual_seed(0)
torch.use_deterministic_algorithms(True)

OBS9 = np.zeros((9, 4), np.float32)  # los 9 estados
for s in range(3):
    for p in range(3):
        OBS9[3 * s + p, s], OBS9[3 * s + p, 3] = 1.0, POS[p]

def tabla_politica(modelo):
    """Acción determinista del agente en los 9 estados."""
    a, _ = modelo.predict(OBS9, deterministic=True)
    return np.asarray(a).reshape(3, 3)

vec = make_vec_env(RegimenEnv, n_envs=8, seed=0)
ppo = PPO("MlpPolicy", vec, gamma=GAMMA, n_steps=256,
          batch_size=256, learning_rate=1e-3,
          policy_kwargs={"net_arch": [32, 32]},
          seed=0, device="cpu", verbose=0)
for bloque in range(3):
    ppo.learn(20_000, reset_num_timesteps=bloque == 0)
    pi_ppo = tabla_politica(ppo)
    print(f"{20_000 * (bloque + 1):>6} pasos: "
          f"{int((pi_ppo == pi_opt).sum())}/9 como el óptimo, "
          f"{recompensa_media(pi_ppo):.2f} pb/paso")
pd.DataFrame(POS[pi_ppo], index=REG,
             columns=["desde corto", "desde fuera", "desde largo"])
OUT [07]PPO de Stable-Baselines3 contra la política óptima
 20000 pasos: 9/9 como el óptimo, 1.90 pb/paso
 40000 pasos: 8/9 como el óptimo, 1.90 pb/paso
 60000 pasos: 8/9 como el óptimo, 1.90 pb/paso
         desde corto  desde fuera  desde largo
bajista         -1.0         -1.0         -1.0
neutro          -1.0         -1.0          1.0
alcista          1.0          1.0          1.0

PPO da con la política óptima a los 20.000 pasos y después cambia la acción de un estado: en neutro y sin posición, abre un corto. La recompensa media a largo plazo no se mueve (1,90 pb por paso) porque ese estado no forma parte del ciclo de la política: una vez dentro, nunca se vuelve a estar fuera. Solo se visita al empezar cada episodio, así que el agente tiene muy pocas muestras de él y la red lo decide por cercanía con los estados vecinos. Es un aviso general: un agente es fiable donde ha pasado tiempo y arbitrario donde no, y los estados raros de un mercado (una crisis, un hueco) son justo los que más importan.

SAC: acciones continuas y entropía

Con la posición continua entre −1 y 1, la elección habitual es SAC (Soft Actor-Critic, Haarnoja et al., 2018): un método off-policy (reutiliza transiciones viejas guardadas en un búfer, lo que lo hace más eficiente en muestras que PPO) que maximiza la recompensa más un premio por la entropía de la política:

J(π)=∑tE[rt+α H(π(⋅∣st))]J(\pi) = \sum_t \mathbb{E}\left[ r_t + \alpha\, \mathcal{H}\big(\pi(\cdot \mid s_t)\big) \right]

El término de entropía mantiene la exploración, y α\alpha fija cuánto. Stable-Baselines3 lo ajusta solo (ent_coef="auto") para alcanzar una entropía objetivo. En las pruebas previas a esta lección, con el mismo presupuesto de 8.000 pasos, el valor inicial por defecto de α\alpha (1,0) dejaba una política tímida, con posiciones de 0,1 a 0,45 en valor absoluto donde el óptimo pide 1: la entropía pesaba más que unas recompensas de unas décimas. Con "auto_0.01" (ajuste automático empezando en 0,01) se acerca mucho más. Y con la recompensa dividida por 100 (en porcentaje) en vez de por σ, SAC no aprendía nada: posiciones casi nulas. Son las dos perillas que más cambian el resultado y ninguna sale del problema financiero: es ingeniería del algoritmo.

IN [08]python
vec_c = make_vec_env(lambda: RegimenEnv(continua=True), n_envs=4,
                     seed=0)
sac = SAC("MlpPolicy", vec_c, gamma=GAMMA, learning_starts=1000,
          batch_size=128, ent_coef="auto_0.01",
          policy_kwargs={"net_arch": [32, 32]},
          seed=0, device="cpu", verbose=0)
sac.learn(8_000)
a_sac = sac.predict(OBS9, deterministic=True)[0].reshape(3, 3)
pd.DataFrame(a_sac, index=REG,
             columns=["desde corto", "desde fuera",
                      "desde largo"]).round(2)
OUT [08]SAC en la versión continua (8.000 pasos)
         desde corto  desde fuera  desde largo
bajista        -0.92        -0.65        -0.57
neutro         -0.82         0.42         0.95
alcista         0.87         0.96         0.98

SAC ha aprendido la forma de la política óptima: largo en alcista, corto en bajista y, en neutro, una posición que crece con la que ya tenía (la histéresis). Pero no llega a los extremos: en bajista se queda entre −0,57 y −0,92 en vez de −1. Con más pasos se acercaría; con 8.000 (4.000 actualizaciones de las redes, unos segundos de CPU), esto es lo que da. En producción se entrenaría más y con varias semillas, y aun así habría que medir el hueco.

La comparación honesta

La recompensa media exacta solo existe para políticas tabulares. Para comparar a todos, incluido SAC, se simulan los mismos 40 episodios de 250 pasos (semillas 10.000 a 10.039) con cada política: mismos regímenes y mismos rendimientos, de modo que la diferencia con el óptimo no depende del azar de los caminos, solo de las decisiones. El error típico de esa diferencia sale de los 40 episodios.

IN [09]python
def evalua(politica, episodios=40, pasos=250):
    """pb/paso medio de cada episodio, mismos caminos para todas."""
    res = []
    for e in range(episodios):
        env = RegimenEnv(continua=True, pasos=pasos)
        o, _ = env.reset(seed=10_000 + e)
        tot = 0.0
        for _ in range(pasos):
            a = np.array([politica(o)], np.float32)
            o, _, _, _, info = env.step(a)
            tot += info["pnl"]
        res.append(tot / pasos)
    return np.array(res)

def de_tabla(pi):
    return lambda o: POS[pi[int(o[:3].argmax()), int(o[3]) + 1]]

def de_sac(o):
    return float(sac.predict(o, deterministic=True)[0][0])

politicas = {"óptima": de_tabla(pi_opt), "Q-learning": de_tabla(pi_q),
             "PPO": de_tabla(pi_ppo), "SAC": de_sac,
             "sin costes": de_tabla(sin_costes),
             "miope": de_tabla(miope)}
res = {k: evalua(f) for k, f in politicas.items()}
filas = []
for k, x in res.items():
    d = x - res["óptima"]
    filas.append({"política": k, "pb/paso": round(x.mean(), 2),
                  "frente al óptimo": round(d.mean(), 2),
                  "error típico": round(d.std(ddof=1) / 40**0.5, 2)})
pd.DataFrame(filas).set_index("política")
OUT [09]Todas las políticas en los mismos 40 caminos
            pb/paso  frente al óptimo  error típico
política
óptima         1.99              0.00          0.00
Q-learning     1.99              0.00          0.00
PPO            1.93             -0.06          0.02
SAC            1.55             -0.44          0.13
sin costes     1.28             -0.71          0.15
miope          0.00             -1.99          0.24

La figura de la cabecera resume la tabla. En estos 40 caminos el óptimo gana 1,99 pb por paso (algo más que el 1,90 exacto: es una muestra). Q-learning, con la política óptima en los 9 estados, coincide con él al céntimo. PPO queda 0,06 pb por debajo, con un error típico de 0,02: es su error en el estado inicial, que sí cuenta en episodios cortos. SAC queda a 0,44 pb (error típico 0,13), un 22 % por debajo. La regla sin costes pierde 0,71 y la miope, todo.

AgentePasosResultado frente al óptimoQué falta
Q-learning tabular300.000igualnada en la política; los valores, lejos
PPO60.000−0,06 pb (−3 %)un estado raro mal aprendido
SAC8.000−0,44 pb (−22 %)presupuesto; no llega a posiciones completas

Tres lecciones, que valen para todo el módulo. Primera: sin el óptimo no se sabría que SAC deja un 22 % sobre la mesa ni que PPO falla en un estado; en un mercado no hay óptimo, así que hay que construir referencias (analíticas, como en 5.9.2) y controles sin señal (como en 5.9.3). Segunda: los algoritmos son sensibles a decisiones que no tienen nada de financiero (escala de la recompensa, entropía, presupuesto), y un resultado vale lo que su configuración. Tercera: incluso en un problema de 9 estados con una señal veinte veces mejor que la de un mercado, hacen falta decenas de miles de pasos.

Ejercicios

  1. Barre el coste por unidad negociada: 6, 12, 18 y 24 pb. Para cada uno, calcula la política óptima, en cuántos de los 9 estados cambia de posición y su recompensa media, junto a la de la regla sin costes. ¿Cuándo deja de compensar operar?
Solución
IN [10]python
for c in (6.0, 12.0, 18.0, 24.0):
    COSTE = c  # recompensas() y recompensa_media() lo leen
    pic = iteracion_valor(recompensas(POS))[0].argmax(axis=2)
    opera = int((POS[pic] != POS[None, :]).sum())
    print(f"coste {c:4.0f} pb: cambia en {opera}/9 estados, "
          f"óptima {abs(recompensa_media(pic)):5.2f}, "
          f"sin costes {recompensa_media(sin_costes):6.2f} pb/paso")
COSTE = 6.0  # se restaura para el resto de la lección
OUT [10]Barrido del coste
coste    6 pb: cambia en 4/9 estados, óptima  1.90, sin costes   1.30 pb/paso
coste   12 pb: cambia en 4/9 estados, óptima  1.30, sin costes   0.10 pb/paso
coste   18 pb: cambia en 4/9 estados, óptima  0.70, sin costes  -1.10 pb/paso
coste   24 pb: cambia en 0/9 estados, óptima  0.00, sin costes  -2.30 pb/paso

Hasta 18 pb la política óptima no cambia: opera en los mismos 4 estados (entrar o girar cuando llega un régimen extremo) y mantiene en los demás. Lo que baja es lo que gana: 1,90, 1,30 y 0,70 pb por paso, 0,60 pb menos por cada 6 pb de coste. Esa pendiente es su rotación, 0,1 unidades por paso; el bruto es siempre 2,5 pb (la mitad del tiempo en un régimen extremo, ganando 5). Con 24 pb deja de operar del todo: entrar cuesta casi lo que se espera ganar en todo el régimen, y girar, el doble. La regla sin costes rota dos veces más (0,2 unidades por paso) y pierde dinero desde 18 pb. Con una señal y unos costes dados, la frecuencia de negociación no es un parámetro libre: la decide Bellman.

  1. Repite el Q-learning con un paso constante α=0,05\alpha = 0{,}05 en vez de n−0,7n^{-0{,}7} y compara los aciertos y la recompensa media de su política a los 50.000, 150.000 y 300.000 pasos.
Solución
IN [11]python
Qc = np.zeros((3, 3, 3))
s, p = 1, 1
for k in range(PASOS):
    a = azar[k] if explora[k] else int(Qc[s, p].argmax())
    r = (POS[a] * (MU[s] + SIGMA * ruido[k])
         - COSTE * abs(POS[a] - POS[p]))
    s2 = int(np.searchsorted(acum[s], u[k], side="right"))
    Qc[s, p, a] += 0.05 * (r + GAMMA * Qc[s2, a].max() - Qc[s, p, a])
    s, p = s2, a
    if (k + 1) in (50_000, 150_000, 300_000):
        pi = Qc.argmax(axis=2)
        print(f"{k + 1:>7}: {int((pi == pi_opt).sum())}/9, "
              f"{recompensa_media(pi):.2f} pb/paso")
OUT [11]Q-learning con paso constante
  50000: 4/9, 0.95 pb/paso
 150000: 5/9, 0.65 pb/paso
 300000: 8/9, 1.81 pb/paso

Con paso constante, la tabla nunca deja de moverse: cada recompensa ruidosa la empuja un 5 %, así que los valores oscilan alrededor de Q* con una amplitud que depende de α y del ruido, en vez de converger. Aquí acierta en 4 de 9 estados a los 50.000 pasos, en 5 a los 150.000 (y gana menos que a los 50.000: 0,65 frente a 0,95 pb por paso) y en 8 a los 300.000, con 1,81 pb por paso: nunca llega a los 9, porque en los estados donde dos acciones tienen valores parecidos el ruido cambia el orden una y otra vez. Es la contrapartida de lo que en un mercado no estacionario sería una virtud: un paso constante olvida el pasado y sigue al régimen nuevo.

  1. Un compañero escribe el step de su entorno con la recompensa nueva * ret y descuenta los costes después, al calcular el P&L del backtest. Su agente opera en cada paso. ¿Qué ha pasado y qué habría detectado check_env?
Solución

El agente ha optimizado la recompensa que se le dio, en la que operar no cuesta nada: le da igual girar la posición cada paso, y en este MDP la política óptima sin costes es la de la señal sin histéresis (cerrar en neutro), que con costes gana 1,30 en vez de 1,90 pb por paso; con un coste mayor, perdería dinero. check_env no habría detectado nada: la interfaz es correcta. Los costes tienen que estar dentro de la recompensa, y el resultado del entrenamiento hay que evaluarlo siempre con el P&L neto completo, nunca con la recompensa de entrenamiento (lección 5.9.3, donde un error parecido en los costes produce un agente que parece bueno y no lo es).

Respuesta plegada en cada pregunta

Autoevaluación

  1. En el MDP de la lección, con un coste de 6 pb por unidad negociada, la política miope (la que maximiza solo la recompensa del paso actual) gana 0 pb por paso. ¿Por qué?

    • APorque entrar en largo en un régimen alcista da 5 − 6 = −1 pb en el primer paso, así que nunca entra, aunque el régimen dure de media cinco pasos
    • BPorque la señal de los regímenes no tiene información
    • CPorque el descuento γ = 0,95 anula las recompensas futuras
    • DPorque la política miope opera demasiado
    Ver respuesta

    Respuesta A. Porque entrar en largo en un régimen alcista da 5 − 6 = −1 pb en el primer paso, así que nunca entra, aunque el régimen dure de media cinco pasos

    La miope compara solo el primer paso, donde el coste pesa más que la ganancia. La óptima cuenta los pasos siguientes del régimen (Bellman) y entra: 1,90 pb por paso.

  2. La política óptima mantiene la posición cuando el régimen pasa a neutro; la regla «posición = signo de la media» cierra. ¿Qué gana la primera?

    • ANada: las dos ganan 1,90 pb por paso
    • BGana más porque en neutro el rendimiento esperado es positivo
    • CReduce la varianza sin cambiar la media
    • DAhorra costes de ida y vuelta: en neutro la media es cero, así que cerrar solo cuesta, y del neutro se vuelve al régimen anterior o al contrario con la misma probabilidad. 1,90 frente a 1,30 pb por paso
    Ver respuesta

    Respuesta D. Ahorra costes de ida y vuelta: en neutro la media es cero, así que cerrar solo cuesta, y del neutro se vuelve al régimen anterior o al contrario con la misma probabilidad. 1,90 frente a 1,30 pb por paso

    Es la histéresis que imponen los costes: una zona de no negociar. La política que ignora los costes rota más y pierde 0,60 pb por paso, un tercio del óptimo.

  3. Tras 300.000 pasos, Q-learning elige la acción óptima en los 9 estados, pero el error máximo de su tabla Q frente a Q* sigue siendo grande. ¿Qué lectura es correcta?

    • ALa tabla está mal: hay que repetir el entrenamiento
    • BQ-learning no converge nunca en problemas con ruido
    • CBasta con que el orden de las acciones sea el correcto en cada estado; las acciones poco exploradas tienen valores mal estimados, y eso no cambia la decisión
    • DEl error se debe al descuento γ
    Ver respuesta

    Respuesta C. Basta con que el orden de las acciones sea el correcto en cada estado; las acciones poco exploradas tienen valores mal estimados, y eso no cambia la decisión

    La política converge antes que los valores. Con ε = 0,1, las acciones peores se visitan poco y su Q aprende despacio; para decidir solo importa cuál es la mayor en cada estado.

  4. PPO coincide con el óptimo en 8 de 9 estados y su recompensa media a largo plazo es la misma, 1,90 pb por paso. En los 40 caminos de prueba queda 0,06 pb por debajo. ¿De dónde sale esa diferencia?

    • ADel azar de los caminos: no es una diferencia real
    • BDe la red neuronal, que redondea las acciones
    • CDel estado en que se equivoca (neutro y sin posición), que solo se visita al empezar cada episodio: no pesa a largo plazo, pero en episodios de 250 pasos sí cuenta
    • DDe que PPO usa otro descuento
    Ver respuesta

    Respuesta C. Del estado en que se equivoca (neutro y sin posición), que solo se visita al empezar cada episodio: no pesa a largo plazo, pero en episodios de 250 pasos sí cuenta

    Un estado que la política óptima casi nunca visita da pocas muestras para aprender. La evaluación exacta a largo plazo no lo ve; la evaluación por episodios, sí. Hay que mirar las dos.

  5. SAC en la versión continua del problema queda en 1,55 pb por paso frente a 1,99 del óptimo en los mismos caminos. ¿Qué conclusión es la honesta?

    • ASAC no sirve para trading
    • BCon 8.000 pasos y estos hiperparámetros, SAC aprende la forma de la política pero no llega a los extremos; el resultado depende del presupuesto, de la escala de la recompensa y del coeficiente de entropía, y solo se sabe cuánto falta porque se conoce el óptimo
    • CSAC es mejor que PPO porque usa acciones continuas
    • DLa versión continua tiene un óptimo distinto
    Ver respuesta

    Respuesta B. Con 8.000 pasos y estos hiperparámetros, SAC aprende la forma de la política pero no llega a los extremos; el resultado depende del presupuesto, de la escala de la recompensa y del coeficiente de entropía, y solo se sabe cuánto falta porque se conoce el óptimo

    La iteración de valor con 21 posiciones da el mismo óptimo que con tres, así que el hueco es del agente. Sin un óptimo de referencia, ese hueco sería invisible.

Ideas clave

  • Un MDP es (estados, acciones, transiciones, recompensa, descuento). En trading, el estado incluye la posición, porque con costes la mejor acción depende de dónde se está.
  • En el MDP de la lección (tres regímenes, μ = ±5 pb, σ = 20 pb, coste de 6 pb), la iteración de valor da la política óptima exacta: 1,90 pb por paso, frente a 1,30 de la regla que ignora los costes y 0 de la miope.
  • Q-learning tabular recupera la política óptima en los 9 estados a los 140.000 pasos; sus valores siguen lejos de Q*. La política converge antes que los valores.
  • Un entorno gymnasium.Env es reset, step y dos espacios. Los costes van dentro de la recompensa; check_env comprueba la interfaz, no la economía.
  • PPO (60.000 pasos) acierta en 8 de 9 estados: el que falla casi no se visita. SAC (8.000 pasos) aprende la forma pero se queda en 1,55 pb por paso. La escala de la recompensa y la entropía deciden si aprende.
  • Conocer el óptimo es lo que permite medir a un agente. En un mercado no hay óptimo, y por eso las lecciones 5.9.2 y 5.9.3 comparan siempre con referencias analíticas y con controles sin señal.

Recursos