---
title: "5.9.1 MDP, Q-learning, PPO y entornos Gymnasium"
description: "Un MDP de trading con costes y política óptima conocida: iteración de valor, Q-learning tabular, un entorno Gymnasium y PPO y SAC contra el óptimo."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/mdp-q-learning-ppo-y-entornos-gymnasium/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/mdp-q-learning-ppo-y-entornos-gymnasium.png"
---

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

- 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: 70 min
- Requisitos: [2.3.6 · Introducción al market making: inventario, spread y selección adversa (Avellaneda-Stoikov)](https://pythonparatrading.com/curso-python-trading/nivel-2/market-making-avellaneda-stoikov/), [2.4.7 · Modelado de costes: comisiones, horquilla, impacto, financiación y préstamo de títulos](https://pythonparatrading.com/curso-python-trading/nivel-2/modelado-de-costes/), [5.1.1 · Por qué el machine learning financiero es distinto](https://pythonparatrading.com/curso-python-trading/nivel-5/por-que-el-ml-financiero-es-distinto/)

## Al terminar esta lección

- Plantear un problema de trading como proceso de decisión de Markov: estados, acciones, transiciones, recompensa con costes y descuento.
- Calcular la política óptima exacta con iteración de valor y evaluar cualquier política tabular sin simular, con la distribución estacionaria.
- Programar Q-learning tabular y distinguir la convergencia de la política de la convergencia de los valores.
- Escribir un entorno `gymnasium.Env` con los costes dentro de la recompensa y validarlo con `check_env`.
- Entrenar PPO y SAC de Stable-Baselines3 de forma determinista y compararlos con el óptimo en los mismos caminos.

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

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

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

El MDP de esta lección es un juguete con una relación señal/ruido mucho mayor que la de cualquier mercado (una media
de 5 pb frente a una desviación de 20 pb por paso): está elegido para que los algoritmos converjan en segundos y se
pueda comparar lo que aprenden con el óptimo exacto. La lección 5.9.3 hace lo mismo con una señal realista, y el
resultado es otro.

</aside>

## Un problema de trading como MDP

Un **proceso de decisión de Markov** (MDP) es una tupla $(\mathcal{S}, \mathcal{A}, P, R, \gamma)$: estados, acciones,
probabilidades de transición $P(s' \mid s, a)$, recompensa esperada $R(s, a)$ y factor de descuento $\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 $\mu_s = -5, 0, +5$ pb según el régimen actual y desviación
  $\sigma = 20$ pb.
- **Acción**: la posición nueva, corto (−1), fuera (0) o largo (+1).
- **Recompensa**: el P&L neto, $r_{t+1} = a_t \cdot \text{ret}_{t+1} - c \cdot |a_t - p_t|$, con un coste $c = 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.

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

```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")
```
```output
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)$ (lo que se espera ganar si se hace $a$ en $s$ y después se
actúa de forma óptima) cumple la **ecuación de Bellman**:

$$
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^*$. Cuando se conocen $P$ y $R$, 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')$ es el valor $V^*$ del régimen siguiente con la posición
$a$.

```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"])
```
```output
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.

```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")
```
```output
ó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ítica | Regla | pb por paso |
|---|---|---|
| Óptima | Bellman con costes | 1,90 |
| Señal sin costes | posición = signo de la media, fuera en neutro | 1,30 |
| Miope | lo mejor para el paso actual | 0,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:

```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]])
```
```output
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 $P$ y $R$. En un mercado no se conocen: solo se observan transiciones. **Q-learning**
(Watkins, 1989) aprende $Q^*$ a partir de cada transición observada $(s, a, r, s')$ con una actualización que imita a
Bellman con una muestra en vez de la esperanza:

$$
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^*$ 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 $\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
$\alpha = n^{-0{,}7}$, con $n$ las visitas a ese par.

```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]]
```
```output
        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.* Línea en escalones de 0 a 300.000 pasos: 0 pb por paso hasta 20.000, 0,65 desde 30.000 (3 de 9 estados), 0,95 desde 70.000 (6 de 9 a los 100.000), 1,60 desde 120.000 (8 de 9) y 1,90, el óptimo exacto, desde 140.000 (destacado), cuando acierta en los 9 estados.

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.

```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)
```
```output
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, $\pi_\theta(a \mid s)$ (una red que da probabilidades de cada
acción), y suben por el gradiente de la recompensa esperada:

$$
\nabla_\theta J(\theta) = \mathbb{E}\left[ \sum_t \nabla_\theta \log \pi_\theta(a_t \mid s_t)\, \hat{A}_t \right]
$$

donde $\hat{A}_t$ es la **ventaja**: cuánto mejor fue la acción $a_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 $\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

$$
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 $\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.

```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"])
```
```output
 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(\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.

```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)
```
```output
         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.

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

| Agente | Pasos | Resultado frente al óptimo | Qué falta |
|---|---|---|---|
| Q-learning tabular | 300.000 | igual | nada en la política; los valores, lejos |
| PPO | 60.000 | −0,06 pb (−3 %) | un estado raro mal aprendido |
| SAC | 8.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.

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

Este entorno es estacionario: las probabilidades de los regímenes y las medias no cambian nunca. Un mercado cambia
(lección 5.10.2), y un agente entrenado con un pasado que no se repite aprende el pasado. Con una señal real de IC de
unas centésimas y unos pocos miles de sesiones, el riesgo de que el agente aprenda el ruido de la muestra es la regla,
no la excepción. Es el tema de la lección 5.9.3.

</aside>

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

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

2. Repite el Q-learning con un paso constante $\alpha = 0{,}05$ en vez de $n^{-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.**

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

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

## 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é?

   - 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
   - B. Porque la señal de los regímenes no tiene información
   - C. Porque el descuento γ = 0,95 anula las recompensas futuras
   - D. Porque la política miope opera demasiado

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

   - A. Nada: las dos ganan 1,90 pb por paso
   - B. Gana más porque en neutro el rendimiento esperado es positivo
   - C. Reduce la varianza sin cambiar la media
   - 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

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

   - A. La tabla está mal: hay que repetir el entrenamiento
   - B. Q-learning no converge nunca en problemas con ruido
   - 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
   - D. El error se debe al descuento γ

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

   - A. Del azar de los caminos: no es una diferencia real
   - B. De la red neuronal, que redondea las acciones
   - 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
   - D. De que PPO usa otro descuento

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

   - A. SAC no sirve para trading
   - 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
   - C. SAC es mejor que PPO porque usa acciones continuas
   - D. La versión continua tiene un óptimo distinto

   *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

### Research

- [Máquinas de Estado Finito para Ejecución de Trading Cuantitativo en Python](https://pythonparatrading.com/maquinas-estado-finito-trading-python/) · Research-0029 · 49 min

### Documentación

- [Gymnasium: crear un entorno propio](https://gymnasium.farama.org/introduction/create_custom_env/) · gymnasium.farama.org
- [Gymnasium: utilidades, incluido check_env](https://gymnasium.farama.org/api/utils/) · gymnasium.farama.org
- [Stable-Baselines3: PPO](https://stable-baselines3.readthedocs.io/en/master/modules/ppo.html) · stable-baselines3.readthedocs.io
- [Stable-Baselines3: SAC](https://stable-baselines3.readthedocs.io/en/master/modules/sac.html) · stable-baselines3.readthedocs.io
- [Schulman, J. et al. (2017): Proximal Policy Optimization Algorithms. arXiv:1707.06347](https://arxiv.org/abs/1707.06347) · arxiv.org

### Libros

- [Sutton, R. S. y Barto, A. G. (2018): Reinforcement Learning: An Introduction, 2.ª ed. MIT Press (el texto completo está también en incompleteideas.net, la web de los autores)](https://mitpress.mit.edu/9780262039246/reinforcement-learning/) · mitpress.mit.edu

Lección anterior: [5.8.5 · Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM](https://pythonparatrading.com/curso-python-trading/nivel-5/laboratorio-fundacional-frente-a-garch-y-lightgbm/)

Lección siguiente: [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/)
