Lección 5.9.2 · 75 min
RL para ejecución, market making y cobertura dinámica
Donde el aprendizaje por refuerzo tiene argumentos serios en trading es en problemas de control con un simulador creíble y un objetivo claro: liquidar una posición, cotizar como creador de mercado, cubrir una opción. Esta lección entrena un agente en cada uno y lo mide en caminos de prueba fijos contra la solución analítica que ya se conoce. Gana donde la referencia hace supuestos que el simulador no cumple; donde no, como mucho la iguala.
- Módulo
- 5.9 · Aprendizaje por refuerzo
- Duración
- 75 min
Lecciones del nivel 5
5.1 · Fundamentos de ML aplicados a finanzas
5.2 · Ingeniería de características
5.3 · Etiquetado y muestreo
- 5.3.1 Etiquetado de horizonte fijo y sus problemas
- 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
- 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
- 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
- 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
5.4 · Validación para series financieras
5.5 · Modelos clásicos
- 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
- 5.5.2 Árboles de decisión, Random Forest y bagging
- 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
- 5.5.4 Optuna y el riesgo de sobreajustar la validación
- 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
- 5.5.6 Learning to rank para la selección de acciones
5.6 · Deep learning con Keras 3
5.7 · Deep learning con PyTorch
- 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
- 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
- 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
- 5.7.4 Redes de grafos para relaciones entre activos
- 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
5.8 · Modelos fundacionales de series temporales
- 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
- 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
- 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
- 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
- 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
5.9 · Aprendizaje por refuerzo
5.10 · Interpretabilidad, robustez y riesgo de modelo
Dónde el RL tiene argumentos
La lección 5.9.1 terminó con una advertencia: un agente solo se puede juzgar si hay algo con qué compararlo. En trading direccional no lo hay (y la lección 5.9.3 enseña lo que pasa). En tres problemas de control, sí:
| Problema | Acción | Objetivo | Referencia analítica | Lección |
|---|---|---|---|---|
| Ejecución óptima | cuánto vender en cada minuto | coste + λ·varianza | Almgren-Chriss | 4.3.1 |
| Market making | dónde poner las cotizaciones | P&L − riesgo de inventario | Avellaneda-Stoikov | 2.3.6 |
| Cobertura dinámica | cuánto subyacente tener | P&L − λ·varianza | delta de Black-Scholes | 3.6.2 |
Los tres tienen en común lo que le falta al trading direccional: un simulador que se puede creer en lo esencial (impacto, llegadas de órdenes, dinámica del subyacente), un objetivo que no depende de predecir el mercado, y una solución de referencia con supuestos conocidos. El agente puede ganar a la referencia donde el simulador no cumple esos supuestos; donde los cumple, lo mejor que puede hacer es igualarla. El método de la lección es siempre el mismo:
Fuente Mermaid
flowchart LR A[Simulador] --> B[Entrenar agente] B --> C[Caminos de prueba] D[Referencia] --> C C --> E[Diferencia y error]
Los caminos de prueba son fijos y comunes: los mismos para el agente y para las referencias, nunca vistos al entrenar. Están en los datos del nivel 5, con los parámetros y las cifras de referencia que el README ya publica.
Ejecución: la recompensa que suma el objetivo de Almgren-Chriss
El problema es el de la lección 4.3.1 con impacto lineal: vender X = 200.000 acciones a S0 = 50 USD en 60 pasos de un minuto. El medio sigue (impacto permanente ) y la venta del paso , acciones a ritmo , se ejecuta a (horquilla e impacto temporal). El coste es el implementation shortfall y el objetivo, minimizar . Almgren y Chriss dan la trayectoria óptima de inventario en forma cerrada, , que está en el JSON.
import json
import math
import numpy as np
import pandas as pd
cfg = json.load(open("rl5_entornos.json", encoding="utf-8"))
ej = cfg["ejecucion"]
X, N, S0, TAU = ej["X"], ej["N"], ej["S0"], ej["tau"]
SIG, ETA, GAM = ej["sigma"], ej["eta"], ej["gamma"]
EPS, LAM = ej["epsilon"], ej["lam"]
x_ac = np.array(ej["trayectoria_optima"])
x_tw = X * (1 - np.arange(N + 1) / N)
cam = pd.read_parquet("rl5_caminos_ejecucion.parquet")
xi = (cam.pivot(index="camino", columns="paso", values="xi")
.to_numpy()[:, 1:]) # el paso 0 es S0, sin innovación
print(xi.shape, f"kappa*T = {ej['kappa'] * N:.2f}",
f"sigma diaria = {SIG * math.sqrt(390) / S0:.1%}")(1000, 60) kappa*T = 2.01 sigma diaria = 2.0%Antes de entrenar nada, el simulador tiene que reproducir las cifras publicadas. Dos funciones: el coste realizado en unos caminos y el objetivo teórico de una trayectoria determinista (fórmulas de Almgren-Chriss):
def coste_is(x, xi):
"""Implementation shortfall (USD) de vender según el inventario
x (N+1 valores) en los caminos de innovaciones xi (m, N)."""
n = -np.diff(x)
v = n / TAU
dS = SIG * math.sqrt(TAU) * xi - TAU * GAM * v
S = S0 + np.cumsum(dS, axis=1)
S_ant = np.column_stack([np.full(len(xi), S0), S[:, :-1]])
return X * S0 - (n * (S_ant - EPS - ETA * v)).sum(axis=1)
def objetivo_teorico(x):
n = -np.diff(x)
E = (0.5 * GAM * X**2 + EPS * X
+ (ETA - 0.5 * GAM * TAU) / TAU * (n**2).sum())
V = SIG**2 * TAU * (x[1:] ** 2).sum()
return E + LAM * V
def resumen(c):
return {"media": c.mean(), "desv.": c.std(ddof=1),
"media + λ·var": c.mean() + LAM * c.var(ddof=1)}
ref = {"TWAP": x_tw, "Almgren-Chriss": x_ac}
tabla = pd.DataFrame({k: resumen(coste_is(x, xi))
for k, x in ref.items()}).T
tabla["teórico E + λV"] = [objetivo_teorico(x) for x in ref.values()]
tabla.round(0) media desv. media + λ·var teórico E + λV
TWAP 31866.0 46054.0 38228.0 36584.0
Almgren-Chriss 32301.0 36915.0 36389.0 35385.0Las cifras coinciden con las del README (38.228 y 36.389 USD medidos; 36.584 y 35.385 teóricos): el simulador es el mismo. Almgren-Chriss vende más deprisa al principio (paga más impacto) para quitarse antes el riesgo de precio, y gana 1.839 USD de objetivo a TWAP en los caminos de prueba.
Para que un agente optimice ese objetivo, la recompensa de cada paso tiene que sumar exactamente . Escribiendo el precio de cada venta como más los movimientos anteriores, el coste se reparte por pasos:
con el inventario que queda tras la venta . El primer término es el coste de ejecutar; el segundo, el impacto permanente que se come el inventario restante; el tercero, lo que se gana o se pierde por el movimiento del precio con ese inventario. La varianza de una trayectoria determinista es , así que la recompensa del paso es
El último término es ruido de media cero: es independiente de la decisión. Tiene una desviación enorme (con 100.000 acciones en cartera, unos 5.000 USD por minuto) frente a diferencias de unos cientos de USD entre estrategias en todo el episodio. Quitarlo no cambia la esperanza de la recompensa ni la política óptima, y reduce mucho su varianza: es una variable de control, y la penalización ya representa el riesgo. El entorno admite las dos versiones.
import gymnasium as gym
from gymnasium import spaces
from gymnasium.utils.env_checker import check_env
MULT = np.array([0.5, 0.75, 1.0, 1.5, 2.0, 2.5])
class EjecucionEnv(gym.Env):
"""Observa (fracción del tiempo, fracción del inventario); vende
MULT[a] veces el ritmo TWAP del inventario que queda."""
def __init__(self, ruido=True):
self.ruido = ruido
self.observation_space = spaces.Box(0.0, 1.0, (2,),
np.float32)
self.action_space = spaces.Discrete(len(MULT))
def _obs(self):
return np.array([self.k / N, self.x / X], np.float32)
def reset(self, seed=None, options=None):
super().reset(seed=seed)
self.k, self.x = 0, float(X)
return self._obs(), {}
def step(self, a):
quedan = N - self.k
n = self.x if quedan == 1 else min(
self.x, self.x / quedan * MULT[int(a)])
v = n / TAU
self.x -= n
self.k += 1
coste = (n * (EPS + ETA * v) + self.x * TAU * GAM * v
+ LAM * SIG**2 * TAU * self.x**2)
if self.ruido:
xi_k = self.np_random.standard_normal()
coste -= self.x * SIG * math.sqrt(TAU) * xi_k
return self._obs(), -coste / 1000, self.k == N, False, {}
check_env(EjecucionEnv(), skip_render_check=True)
print("check_env: sin errores")check_env: sin erroresDecisiones de diseño. La acción es un múltiplo del ritmo TWAP del inventario restante (0,5 a 2,5 veces), así que
cualquier política vende todo a tiempo y el último paso liquida lo que quede; Almgren-Chriss empieza vendiendo unas
2,1 veces el ritmo TWAP y termina en 1, dentro del rango. La observación no incluye el precio: en este modelo la
trayectoria óptima no depende de él, y dárselo solo le daría al agente ruido al que reaccionar. La recompensa va en
miles de USD (escala de orden 1, como en 5.9.1). El episodio termina de verdad al vender todo (terminated), y sin
descuento (): el coste de un minuto pesa lo mismo al principio que al final.
Ejecución: PPO con y sin el ruido
Primero, con la recompensa realizada, la que vería un agente que solo observa el P&L. PPO con 8 entornos, sin
descuento, gae_lambda=1 (la ventaja con el retorno completo del episodio) y tasa de aprendizaje 0,001; tras cada
bloque de 30.000 pasos (500 episodios) se lee la trayectoria determinista del agente y se calcula su objetivo
teórico:
import torch
from stable_baselines3 import PPO
from stable_baselines3.common.env_util import make_vec_env
torch.manual_seed(0)
torch.use_deterministic_algorithms(True)
def trayectoria(modelo):
env = EjecucionEnv(ruido=False)
o, _ = env.reset(seed=0)
x = [float(X)]
for _ in range(N):
a, _ = modelo.predict(o, deterministic=True)
o, *_ = env.step(a)
x.append(env.x)
return np.array(x)
def entrena_ejecucion(ruido, bloques):
vec = make_vec_env(lambda: EjecucionEnv(ruido), n_envs=8,
seed=0)
m = PPO("MlpPolicy", vec, gamma=1.0, gae_lambda=1.0,
n_steps=240, batch_size=240, learning_rate=1e-3,
policy_kwargs={"net_arch": [32, 32]},
seed=0, device="cpu", verbose=0)
for b in range(bloques):
m.learn(30_000, reset_num_timesteps=b == 0)
x = trayectoria(m)
print(f"{30_000 * (b + 1):>6} pasos: objetivo teórico "
f"{objetivo_teorico(x):>7,.0f} USD; inventario en el "
f"minuto 20: {x[20]:>7,.0f}")
return m, x
print(f"referencias: AC {objetivo_teorico(x_ac):,.0f}, TWAP "
f"{objetivo_teorico(x_tw):,.0f}; inventario AC en el "
f"minuto 20: {x_ac[20]:,.0f}")
ppo_ruido, x_ruido = entrena_ejecucion(ruido=True, bloques=2)referencias: AC 35,385, TWAP 36,584; inventario AC en el minuto 20: 97,150
30000 pasos: objetivo teórico 35,623 USD; inventario en el minuto 20: 108,522
60000 pasos: objetivo teórico 36,275 USD; inventario en el minuto 20: 112,630Con la recompensa realizada, el agente aprende algo en los primeros 30.000 pasos (35.623 USD de objetivo teórico, mejor que TWAP y a 238 de Almgren-Chriss) y lo pierde en los 30.000 siguientes (36.275): su inventario en el minuto 20 sube de 108.522 a 112.630 acciones, más lejos de las 97.150 de la trayectoria óptima. El ruido de cada episodio (decenas de miles de USD) tapa las diferencias entre trayectorias (unos cientos), y cada actualización de PPO empuja en la dirección de los episodios que tuvieron suerte. El resultado depende de dónde se pare el entrenamiento, y con un mercado real no habría forma de saber cuál de los dos quedarse.
Ahora con la recompensa sin el término de ruido, la misma esperanza:
ppo_exec, x_rl = entrena_ejecucion(ruido=False, bloques=3) 30000 pasos: objetivo teórico 35,993 USD; inventario en el minuto 20: 71,420
60000 pasos: objetivo teórico 35,772 USD; inventario en el minuto 20: 82,671
90000 pasos: objetivo teórico 35,503 USD; inventario en el minuto 20: 94,107Sin el término de ruido, el aprendizaje es estable: 35.993, 35.772 y 35.503 USD, cada bloque más cerca de los 35.385 de Almgren-Chriss, y el inventario en el minuto 20 converge hacia el de la trayectoria óptima (94.107 frente a 97.150). La figura de la cabecera dibuja las trayectorias de inventario: el agente reproduce la curva de Almgren-Chriss, que vende deprisa al principio, y se separa de la recta de TWAP. No es casualidad que la mejora venga del diseño de la recompensa y no del algoritmo: en RL aplicado, la recompensa es la mitad del modelo.
La evaluación que cuenta es en los 1.000 caminos de prueba. Como las tres estrategias se evalúan en los mismos caminos, la diferencia de costes camino a camino tiene mucho menos ruido que cada coste por separado; su error típico sale de esas diferencias:
estr = {"TWAP": x_tw, "Almgren-Chriss": x_ac,
"PPO, recompensa realizada": x_ruido,
"PPO, recompensa esperada": x_rl}
costes = {k: coste_is(x, xi) for k, x in estr.items()}
tabla = pd.DataFrame({k: resumen(c) for k, c in costes.items()}).T
d = costes["PPO, recompensa esperada"] - costes["Almgren-Chriss"]
print(f"PPO (esperada) − AC, camino a camino: {d.mean():.0f} USD "
f"de coste medio (error típico {d.std(ddof=1) / 1000**0.5:.0f})")
tabla.round(0)PPO (esperada) − AC, camino a camino: 332 USD de coste medio (error típico 63)
media desv. media + λ·var
TWAP 31866.0 46054.0 38228.0
Almgren-Chriss 32301.0 36915.0 36389.0
PPO, recompensa realizada 32206.0 42584.0 37646.0
PPO, recompensa esperada 32633.0 35551.0 36425.0En los caminos de prueba, el agente con la recompensa esperada queda en 36.425 USD de media + λ·varianza: 36 por encima de Almgren-Chriss (36.389) y 1.803 por debajo de TWAP (38.228). Camino a camino, paga 332 USD más de coste medio (error típico 63), porque vende algo más deprisa al principio, y lo compensa casi entero con menos varianza (desviación de 35.551 frente a 36.915). El agente de la recompensa realizada, parado a los 60.000 pasos, queda a medio camino: 37.646.
Es el resultado que se espera, y el bueno. El simulador cumple los supuestos de Almgren-Chriss (impacto lineal, precio aritmético, objetivo media-varianza), así que su fórmula es la óptima y un agente solo puede acercarse. Ha necesitado 90.000 pasos (1.500 episodios simulados) y una recompensa rediseñada para llegar a donde la fórmula llega con una línea. Si hubiera quedado por debajo de Almgren-Chriss por más que su error de muestreo, lo correcto sería buscar el error en el entorno, no celebrar.
Market making: el agente contra Avellaneda-Stoikov
El creador de mercado de la lección 2.3.6 cotiza a los dos lados alrededor de un precio de reserva que se aleja del medio en contra de su inventario , con una horquilla total . Las órdenes de los demás llegan con intensidad , que cae con la distancia de la cotización al medio. Avellaneda y Stoikov derivan esas fórmulas para un agente con utilidad exponencial de la riqueza final (aversión ), con aproximaciones.
El simulador del README (parámetros del artículo: σ = 2, T = 1, dt = 0,005, γ = 0,1, A = 140, k = 1,5) se reescribe vectorizado, para evaluar cualquier regla de sesgo en 1.000 simulaciones con los mismos números aleatorios:
mm = cfg["market_making"]
SM, SIGM, TM, DT = mm["S0"], mm["sigma"], mm["T"], mm["dt"]
GMM, KMM, AMM = mm["gamma"], mm["k"], mm["A"]
PASOS = int(round(TM / DT))
def horquilla(tl):
return GMM * SIGM**2 * tl + 2 / GMM * math.log(1 + GMM / KMM)
def simula_mm(sesgo, n=1000, semilla=592):
"""P&L final y |inventario| final de cotizar con el centro en
S + sesgo(q, t restante) y la horquilla de Avellaneda-Stoikov."""
rng = np.random.default_rng(semilla)
z = rng.choice([-1.0, 1.0], (n, PASOS))
u1, u2 = rng.random((n, PASOS)), rng.random((n, PASOS))
S, q, caja = np.full(n, SM), np.zeros(n), np.zeros(n)
for i in range(PASOS):
tl = TM - i * DT
centro, h = S + sesgo(q, tl), horquilla(tl) / 2
pa, pb = centro + h, centro - h
vende = u1[:, i] < AMM * np.exp(-KMM * (pa - S)) * DT
compra = u2[:, i] < AMM * np.exp(-KMM * (S - pb)) * DT
q += compra.astype(float) - vende
caja += vende * pa - compra * pb
S = S + SIGM * math.sqrt(DT) * z[:, i]
return caja + q * S, np.abs(q)
def resumen_mm(sesgo, **kw):
w, q = simula_mm(sesgo, **kw)
return {"P&L medio": w.mean(), "desv.": w.std(ddof=1),
"|q| final": q.mean(),
"media − γ/2·var": w.mean() - GMM / 2 * w.var(ddof=1)}
def avellaneda(q, tl):
return -q * GMM * SIGM**2 * tl
refs_mm = {"Avellaneda-Stoikov": avellaneda,
"simétrica": lambda q, tl: 0 * q}
pd.DataFrame({k: resumen_mm(f) for k, f in refs_mm.items()}).T.round(2) P&L medio desv. |q| final media − γ/2·var
Avellaneda-Stoikov 65.01 6.65 2.19 62.80
simétrica 67.68 13.89 6.97 58.03Con números aleatorios distintos de los del README, las cifras cambian un poco (65,01 de P&L medio para Avellaneda-Stoikov, frente a 65,12), pero el patrón es el del artículo: la estrategia de inventario gana algo menos en media que la simétrica (65,01 frente a 67,68) con la mitad de desviación (6,65 frente a 13,89) y la tercera parte de inventario final. Con el criterio media − (γ/2)·varianza, la aproximación de segundo orden de la utilidad exponencial con γ = 0,1, gana con claridad: 62,80 frente a 58,03.
El entorno da al agente la misma horquilla de Avellaneda-Stoikov y le deja elegir solo el sesgo: el centro de las cotizaciones es , con entre seis valores (Avellaneda-Stoikov equivale a , de 0,4 al principio a 0 al final). La recompensa de cada paso es lo capturado al ejecutarse (la distancia de la cotización al medio) menos la penalización de inventario , cuya suma esperada es la parte de la varianza del P&L que se debe al inventario. El término (ganancia o pérdida del inventario por el movimiento del medio) tiene media cero y se quita, como en la ejecución.
BETAS = np.array([0.0, 0.1, 0.2, 0.3, 0.4, 0.6])
class MMEnv(gym.Env):
def __init__(self):
self.observation_space = spaces.Box(-1.0, 1.0, (2,),
np.float32)
self.action_space = spaces.Discrete(len(BETAS))
def _obs(self):
return np.array([np.clip(self.q / 10, -1, 1), self.i / PASOS],
np.float32)
def reset(self, seed=None, options=None):
super().reset(seed=seed)
self.S, self.q, self.i = SM, 0, 0
return self._obs(), {}
def step(self, a):
tl = TM - self.i * DT
centro = self.S - BETAS[int(a)] * self.q
h = horquilla(tl) / 2
pa, pb = centro + h, centro - h
u = self.np_random.random(3)
captura = 0.0
if u[0] < AMM * math.exp(-KMM * (pa - self.S)) * DT:
self.q -= 1
captura += pa - self.S
if u[1] < AMM * math.exp(-KMM * (self.S - pb)) * DT:
self.q += 1
captura += self.S - pb
self.S += SIGM * math.sqrt(DT) * (1.0 if u[2] < 0.5 else -1.0)
self.i += 1
r = captura - GMM / 2 * SIGM**2 * self.q**2 * DT
return self._obs(), r, self.i == PASOS, False, {}
check_env(MMEnv(), skip_render_check=True)
vec = make_vec_env(MMEnv, n_envs=8, seed=0)
ppo_mm = PPO("MlpPolicy", vec, gamma=0.99, n_steps=400,
batch_size=400, learning_rate=3e-4,
policy_kwargs={"net_arch": [32, 32]},
seed=0, device="cpu", verbose=0)
ppo_mm.learn(80_000)
def sesgo_agente(q, tl):
obs = np.column_stack([np.clip(q / 10, -1, 1),
np.full(len(q), (TM - tl) / TM)])
a, _ = ppo_mm.predict(obs.astype(np.float32), deterministic=True)
return -BETAS[a] * q
rejilla = [(q, t) for t in (0.0, 0.5, 0.9) for q in (-6, -2, 2, 6)]
obs = np.array([[q / 10, t] for q, t in rejilla], np.float32)
print("β elegido:", BETAS[ppo_mm.predict(obs, deterministic=True)[0]])β elegido: [0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1]El agente, tras 80.000 pasos (400 sesiones simuladas), ha aprendido una regla más simple que la del artículo: β = 0,1 en los doce puntos de la rejilla, con cualquier inventario y en cualquier momento de la sesión. No reduce el sesgo hacia el cierre, como hace Avellaneda-Stoikov, y empieza con un sesgo cuatro veces menor.
# búsqueda directa: β constante, elegido con otras simulaciones
busqueda = {b: resumen_mm(lambda q, tl, b=b: -b * q, semilla=1592)
for b in BETAS}
mejor = max(busqueda, key=lambda b: busqueda[b]["media − γ/2·var"])
print("β elegido por búsqueda directa (semilla 1592):", mejor)
todas = dict(refs_mm)
todas["agente PPO"] = sesgo_agente
todas[f"búsqueda directa, β = {mejor}"] = (
lambda q, tl: -mejor * q)
pd.DataFrame({k: resumen_mm(f) for k, f in todas.items()}).T.round(2)β elegido por búsqueda directa (semilla 1592): 0.1
P&L medio desv. |q| final media − γ/2·var
Avellaneda-Stoikov 65.01 6.65 2.19 62.80
simétrica 67.68 13.89 6.97 58.03
agente PPO 66.58 6.99 1.16 64.13
búsqueda directa, β = 0.1 66.58 6.99 1.16 64.13El agente gana a Avellaneda-Stoikov con el criterio de la tabla: 64,13 frente a 62,80, con más P&L medio (66,58 frente a 65,01), una desviación parecida (6,99 frente a 6,65) y la mitad de inventario final (1,16 frente a 2,19). La figura sitúa las cuatro estrategias en el plano de media y desviación.
¿Ha descubierto el RL algo que el artículo no sabía? No. Avellaneda-Stoikov es óptimo para su objetivo (utilidad exponencial de la riqueza final, con una aproximación de las llegadas para inventarios pequeños y sesiones largas), no para la media − varianza de este simulador con su paso de tiempo. El agente optimiza otro criterio y gana con ese criterio. Y la búsqueda directa (probar los seis valores de β constante en otras 1.000 simulaciones y quedarse con el mejor) encuentra exactamente lo mismo, β = 0,1, en un segundo y sin redes. Cuando la política óptima cabe en un parámetro, un agente de RL es la forma más cara de encontrarlo. Lo que el RL podría aportar está en lo que este simulador no tiene: llegadas que dependen del flujo informado y de la selección adversa (lección 2.3.6), colas en el libro, varios activos correlacionados. Ahí no hay fórmula cerrada, y también es donde el simulador es más difícil de creer.
Cobertura: deep hedging ligero bajo Heston
El último problema es el de la lección 3.6.2: se vende una call ATM a 21 sesiones sobre 100 acciones, cobrando la prima de Black-Scholes con σ = 20 % (230,30 USD), y se cubre al cierre de cada sesión con el subyacente, pagando un 0,05 % del nominal negociado. El subyacente no sigue Black-Scholes sino Heston: volatilidad estocástica con correlación negativa con el precio (ρ = −0,7) y volatilidad de la volatilidad 0,5. La delta de Black-Scholes es la cobertura óptima sin costes y con volatilidad constante; aquí no se cumple ninguna de las dos cosas.
Deep hedging (Buehler et al., 2019) plantea la cobertura como una política: una red decide la posición de cada día a partir de lo observable, y se entrena maximizando directamente el criterio de riesgo del P&L final sobre muchos caminos simulados. Como el P&L es una función diferenciable de las posiciones, el gradiente se calcula con retropropagación a través de toda la simulación, sin estimar ventajas como PPO: es una búsqueda directa de la política con un simulador diferenciable, el pariente eficiente del RL cuando el simulador lo permite. La versión ligera de la lección:
- Entradas de la red, observables al cierre de cada día: log(S/K), el tiempo restante, la posición actual y la delta de Black-Scholes con σ = 20 %. La varianza de Heston no se observa.
- Salida: una corrección de la delta, . La red parte de la delta y aprende cuánto desviarse.
- Criterio: maximizar media − 0,01·varianza del P&L final en USD, la última columna de la tabla del README.
- Entrenamiento: 20.000 caminos de Heston simulados en la lección con otra semilla (mismos parámetros que el JSON) y minilotes de 4.000. Evaluación: los 2.000 caminos de prueba del Parquet, que la red no ve.
co = cfg["cobertura"]
K, ACC, C_TR = co["S0"], co["acciones"], co["coste"]
ND, SIG_BS, PRIMA = co["T_dias"], co["sigma_impl"], co["prima_usd"]
cob = pd.read_parquet("rl5_caminos_cobertura.parquet")
S_prueba = torch.tensor(cob.pivot(index="camino", columns="dia",
values="precio").to_numpy())
def heston(m, rng):
"""Caminos diarios de Heston (Euler con truncamiento)."""
dt = 1 / co["dias_ano"] / co["subpasos"]
S, v = np.full(m, co["S0"]), np.full(m, co["v0"])
sal = [S.copy()]
rho = co["rho"]
for _ in range(ND):
for _ in range(co["subpasos"]):
z1 = rng.standard_normal(m)
z2 = (rho * z1
+ math.sqrt(1 - rho**2) * rng.standard_normal(m))
vp = np.maximum(v, 0)
S = S * np.exp(-vp / 2 * dt + np.sqrt(vp * dt) * z1)
v = (v + co["kappa"] * (co["theta"] - vp) * dt
+ co["xi"] * np.sqrt(vp * dt) * z2)
sal.append(S.copy())
return torch.tensor(np.array(sal).T)
S_ent = heston(20_000, np.random.default_rng(11))
def delta_bs(S, tau):
d1 = ((torch.log(S / K) + SIG_BS**2 * tau / 2)
/ (SIG_BS * math.sqrt(tau)))
return 0.5 * (1 + torch.erf(d1 / math.sqrt(2)))
def pnl_cobertura(S, politica):
"""P&L final (USD) de vender la call y cubrir cada cierre con
politica(S_d, tau, pos, delta) -> posición nueva por acción."""
m = S.shape[0]
pos = torch.zeros(m, dtype=S.dtype)
caja = torch.full((m,), PRIMA, dtype=S.dtype)
for d in range(ND):
tau = (ND - d) / co["dias_ano"]
nueva = politica(S[:, d], tau, pos, delta_bs(S[:, d], tau))
dq = (nueva - pos) * ACC
caja = caja - dq * S[:, d] - C_TR * torch.abs(dq) * S[:, d]
pos = nueva
final = pos * ACC * S[:, -1]
caja = caja + final - C_TR * torch.abs(final)
return caja - torch.clamp(S[:, -1] - K, min=0) * ACC
def criterio(p):
return float(p.mean() - 0.01 * p.var())
def banda(b):
return lambda S, tau, pos, de: torch.where(
torch.abs(de - pos) > b, de, pos)
refs_co = {"sin cubrir": lambda S, tau, pos, de: 0 * de,
"delta BS diaria": lambda S, tau, pos, de: de,
"delta BS con banda 0,05": banda(0.05)}
filas = {}
for k, f in refs_co.items():
p = pnl_cobertura(S_prueba, f)
filas[k] = {"media": float(p.mean()), "desv.": float(p.std()),
"cuantil 5 %": float(torch.quantile(p, 0.05)),
"media − 0,01·var": criterio(p)}
pd.DataFrame(filas).T.round(2) media desv. cuantil 5 % media − 0,01·var
sin cubrir -1.91 296.71 -604.05 -882.28
delta BS diaria -5.37 57.11 -98.08 -37.99
delta BS con banda 0,05 -4.64 58.05 -97.94 -38.34Las cifras son las del README: la delta diaria con costes da −37,99 de criterio; la banda de 0,05, que opera menos, no lo mejora (−38,34). Sin cubrir, el criterio es −882: la cobertura reduce la desviación de 297 a 57 USD.
torch.manual_seed(0)
red = torch.nn.Sequential(
torch.nn.Linear(4, 32), torch.nn.ReLU(),
torch.nn.Linear(32, 32), torch.nn.ReLU(),
torch.nn.Linear(32, 1)).double()
def politica_red(S, tau, pos, de):
entrada = torch.stack([torch.log(S / K) * 5,
torch.full_like(S, tau * 12), pos, de], 1)
return torch.clamp(de + 0.5 * torch.tanh(red(entrada)[:, 0]),
0, 1)
delta_ent = pnl_cobertura(S_ent, refs_co["delta BS diaria"])
print(f"delta BS en los caminos de entrenamiento: "
f"{criterio(delta_ent):.2f}")
opt = torch.optim.Adam(red.parameters(), lr=1e-3)
gen = torch.Generator().manual_seed(0)
for it in range(1, 201):
lote = torch.randperm(len(S_ent), generator=gen)[:4000]
p = pnl_cobertura(S_ent[lote], politica_red)
perdida = -(p.mean() - 0.01 * p.var())
opt.zero_grad()
perdida.backward()
opt.step()
if it % 50 == 0:
with torch.no_grad():
ent = criterio(pnl_cobertura(S_ent, politica_red))
pru = criterio(pnl_cobertura(S_prueba, politica_red))
print(f"iteración {it:>3}: criterio entrenamiento {ent:7.2f}"
f", prueba {pru:7.2f}")delta BS en los caminos de entrenamiento: -42.26
iteración 50: criterio entrenamiento -40.30, prueba -36.63
iteración 100: criterio entrenamiento -39.79, prueba -36.12
iteración 150: criterio entrenamiento -39.57, prueba -35.94
iteración 200: criterio entrenamiento -39.45, prueba -35.84Los dos criterios mejoran a la vez. En los caminos de entrenamiento, de −42,26 con la delta a −39,45 con la red (2,81 USD); en los de prueba, de −37,99 a −35,84. Los niveles no son comparables entre conjuntos (son caminos distintos y los de entrenamiento salen más dispersos), pero las mejoras sí, y la de prueba es algo menor que la de entrenamiento, como debe ser. Con 20.000 caminos para unos 1.200 parámetros, el sobreajuste a la muestra es pequeño; con 2.000 caminos sería otra historia. En producción se entrenaría más (aquí, 200 pasos de Adam para que la lección corra en segundos) y con más caminos.
with torch.no_grad():
p_red = pnl_cobertura(S_prueba, politica_red)
p_bs = pnl_cobertura(S_prueba, refs_co["delta BS diaria"])
# costes de transacción pagados por cada estrategia
sin_coste = {}
for nombre, f in (("red", politica_red),
("delta", refs_co["delta BS diaria"])):
C_TR, guarda = 0.0, C_TR
sin_coste[nombre] = pnl_cobertura(S_prueba, f)
C_TR = guarda
for nombre, p in (("delta BS diaria", p_bs), ("deep hedging", p_red)):
clave = "delta" if nombre.startswith("delta") else "red"
costes = float((sin_coste[clave] - p).mean())
print(f"{nombre:<16} media {float(p.mean()):6.2f} desv. "
f"{float(p.std()):5.2f} costes {costes:5.2f} "
f"criterio {criterio(p):7.2f}")
rng = np.random.default_rng(0)
mejoras = []
for _ in range(1000):
i = torch.from_numpy(rng.integers(0, len(p_red), len(p_red)))
mejoras.append(criterio(p_red[i]) - criterio(p_bs[i]))
print(f"mejora del criterio: {criterio(p_red) - criterio(p_bs):.2f}"
f" USD (error típico bootstrap {np.std(mejoras):.2f})")delta BS diaria media -5.37 desv. 57.11 costes 12.23 criterio -37.99
deep hedging media -4.66 desv. 55.84 costes 11.23 criterio -35.84
mejora del criterio: 2.14 USD (error típico bootstrap 0.65)La red mejora el criterio de la delta en 2,14 USD, con un error típico bootstrap de 0,65 (más de tres errores típicos), y lo hace por las dos vías que la teoría anticipa: opera algo menos (11,23 USD de costes de transacción frente a 12,23, un 8 % menos) y cubre algo mejor bajo Heston (desviación de 55,84 frente a 57,11). La figura compara el criterio de las coberturas. Es una mejora pequeña, unos 2 USD sobre una prima de 230, pero sistemática: en un libro de opciones que se cubre cada día, es dinero. Y llega donde la banda fija de no negociar, la regla manual para operar menos, empeoraba el resultado (−38,34).
Qué aporta el agente y qué no
| Problema | Referencia | Agente en los caminos de prueba | Por qué |
|---|---|---|---|
| Ejecución | Almgren-Chriss | +36 USD de objetivo: igual | el simulador cumple sus supuestos |
| Market making | Avellaneda-Stoikov | 64,13 frente a 62,80, igual que la búsqueda directa | otro criterio; un parámetro basta |
| Cobertura | delta BS con costes | +2,14 USD de criterio (error típico 0,65) | Heston y costes rompen sus supuestos |
El patrón de los tres casos es el que anticipaba la introducción. Donde el simulador cumple los supuestos de la solución analítica (ejecución), el agente la iguala, y más caro. Donde el agente parece ganar a la referencia (market making), conviene preguntar con qué criterio: si el negocio quiere media − varianza del P&L de la sesión, la pregunta útil es por qué usar una fórmula derivada para otro objetivo, y una búsqueda directa responde más barato que un agente. Donde la referencia hace supuestos que el simulador no cumple (cobertura con costes bajo Heston), un modelo aprendido mejora, poco y de forma medible. En los tres, lo que hace creíble el resultado es lo mismo: una referencia conocida, caminos de prueba comunes que el agente no ha visto y el error típico de la diferencia.
Ejercicios
- Repite la búsqueda directa del sesgo de market making con un criterio más averso, media − 0,2·varianza (en vez de γ/2 = 0,05). ¿Qué β elige y cómo queda Avellaneda-Stoikov con ese criterio?
Solución
def crit_02(sesgo, semilla):
w, _ = simula_mm(sesgo, semilla=semilla)
return w.mean() - 0.2 * w.var(ddof=1)
b02 = {b: crit_02(lambda q, tl, b=b: -b * q, 1592) for b in BETAS}
mejor02 = max(b02, key=b02.get)
print("β elegido:", mejor02)
prueba = crit_02(lambda q, tl: -mejor02 * q, 592)
print(f"en la prueba: búsqueda {prueba:.2f}, "
f"Avellaneda-Stoikov {crit_02(avellaneda, 592):.2f}")β elegido: 0.1
en la prueba: búsqueda 56.80, Avellaneda-Stoikov 56.17Con un criterio cuatro veces más averso, la búsqueda vuelve a elegir β = 0,1 (la rejilla es gruesa: 0,1 y 0,2 son los únicos candidatos razonables) y sigue por delante de Avellaneda-Stoikov en la prueba (56,80 frente a 56,17), pero la distancia se reduce de 1,33 a 0,63. Cuanto más pesa la varianza, más se acerca la fórmula, que reduce el inventario con fuerza al principio de la sesión. Con una rejilla más fina o con β dependiente del tiempo el resultado podría cambiar; es justo el tipo de pregunta que una búsqueda directa responde en segundos.
- Evalúa la red de deep hedging ya entrenada en caminos de Heston con una volatilidad de la volatilidad de 0,8 en vez de 0,5 (2.000 caminos nuevos, semilla 12), junto a la delta BS. ¿Se mantiene la mejora fuera del simulador de entrenamiento?
Solución
co_orig = co["xi"]
co["xi"] = 0.8
S_otro = heston(2_000, np.random.default_rng(12))
co["xi"] = co_orig
with torch.no_grad():
for nombre, f in (("delta BS diaria", refs_co["delta BS diaria"]),
("deep hedging", politica_red)):
p = pnl_cobertura(S_otro, f)
print(f"{nombre:<16} desv. {float(p.std()):6.2f} "
f"criterio {criterio(p):8.2f}")delta BS diaria desv. 77.14 criterio -59.02
deep hedging desv. 73.55 criterio -52.53Con ξ = 0,8 las dos coberturas empeoran (desviación de 77,14 y 73,55 USD) y la ventaja de la red crece: 6,49 USD de criterio (−52,53 frente a −59,02). La corrección que aprendió, operar algo menos y ajustar la cobertura a la correlación negativa entre precio y volatilidad, va en la buena dirección también con más volatilidad de la volatilidad. No es una garantía: cambiar un parámetro dentro de la misma familia de modelos mide la robustez a la calibración, no al error de modelo (saltos, regímenes, costes que no son proporcionales).
- En el entorno de ejecución, ¿qué pasaría si se añadiera a la observación el movimiento del precio desde el inicio y se entrenara con la recompensa realizada? ¿Podría el agente mejorar a Almgren-Chriss?
Solución
En este modelo, no. El precio sigue un paseo aleatorio aritmético con innovaciones independientes y el impacto no depende del precio, así que el movimiento pasado no dice nada del futuro: la trayectoria óptima para media − λ·varianza es determinista (Almgren y Chriss la llaman estática, y para este criterio y este modelo las estrategias que reaccionan al precio solo mejoran de forma marginal y con otro planteamiento del objetivo). Con la recompensa realizada, el agente vería correlaciones espurias entre el precio observado y la recompensa de cada episodio y aprendería a reaccionar a ellas: en los caminos de entrenamiento podría parecer mejor; en los de prueba, sería igual o peor que Almgren-Chriss, con más varianza. Es el sobreajuste de la lección 5.9.3 en pequeño. Si el precio tuviera reversión a la media o el impacto dependiera del estado del libro, la observación sí aportaría y un agente podría ganar a la fórmula estática, que es justo lo que exploran trabajos como Nevmyvaka, Feng y Kearns (2006) con datos de libro reales.
Respuesta plegada en cada pregunta
Autoevaluación
En el entorno de ejecución, la recompensa de cada paso incluye el término x_k·σ·√τ·ξ_k, la ganancia o pérdida por el movimiento del precio con el inventario que queda. ¿Por qué se puede quitar al entrenar sin cambiar la política óptima?
- APorque es pequeño frente a los costes de impacto
- BPorque su esperanza es cero dada cualquier decisión (ξ_k es independiente de x_k), y el riesgo que representa ya está en la penalización λ·σ²·τ·x_k²; quitarlo reduce la varianza de la recompensa, no cambia su esperanza
- CPorque Almgren-Chriss supone que el precio no se mueve
- DPorque PPO no admite recompensas negativas
Ver respuesta
Respuesta B. Porque su esperanza es cero dada cualquier decisión (ξ_k es independiente de x_k), y el riesgo que representa ya está en la penalización λ·σ²·τ·x_k²; quitarlo reduce la varianza de la recompensa, no cambia su esperanza
Es una variable de control: el objetivo esperado es el mismo con o sin él. Con él, la recompensa de un episodio tiene una desviación de decenas de miles de USD frente a diferencias de unos cientos entre estrategias, y PPO no distingue bien.
El agente de ejecución entrenado con la recompensa sin ruido queda en los caminos de prueba a 36 USD de Almgren-Chriss en media + λ·varianza, y 1.803 por delante de TWAP. ¿Cómo se lee?
- AQue ha aprendido una trayectoria casi igual a la óptima: en este modelo (impacto lineal, precio aritmético), la solución analítica es exacta, y lo máximo que puede hacer un agente es igualarla
- BQue el agente ha superado a Almgren-Chriss
- CQue TWAP es la mejor estrategia
- DQue hay una fuga en el entorno
Ver respuesta
Respuesta A. Que ha aprendido una trayectoria casi igual a la óptima: en este modelo (impacto lineal, precio aritmético), la solución analítica es exacta, y lo máximo que puede hacer un agente es igualarla
Cuando el simulador cumple los supuestos del modelo analítico, el RL solo puede reproducirlo, y más caro. Si el agente ganara por más que el error de muestreo, habría un error en el entorno (lo advierte el README de los datos).
En el market making, un sesgo de inventario constante (β = 0,1) da más media − (γ/2)·varianza que la fórmula de Avellaneda-Stoikov. ¿Qué se concluye?
- AQue Avellaneda-Stoikov está mal
- BQue el agente es más listo que el modelo
- CQue la fórmula es óptima para su propio objetivo (utilidad exponencial con aproximaciones) y el agente optimiza otro (media − varianza del P&L con este simulador): la comparación depende del criterio, y una búsqueda directa del parámetro da lo mismo que PPO
- DQue el market making no tiene riesgo de inventario
Ver respuesta
Respuesta C. Que la fórmula es óptima para su propio objetivo (utilidad exponencial con aproximaciones) y el agente optimiza otro (media − varianza del P&L con este simulador): la comparación depende del criterio, y una búsqueda directa del parámetro da lo mismo que PPO
Ganar a una referencia con un criterio que no es el suyo no es una victoria del RL. Lo útil es la pregunta que obliga a hacer: qué objetivo tiene el negocio y si la referencia lo optimiza.
El deep hedging de la lección mejora la media − 0,01·varianza del P&L de la delta de Black-Scholes con costes en los 2.000 caminos de prueba. ¿Por qué es plausible que un modelo aprendido gane aquí?
- APorque la red ve la varianza verdadera de Heston
- BPorque los caminos de prueba se usaron para entrenar
- CPorque las redes neuronales siempre ganan a las fórmulas
- DPorque la delta de Black-Scholes es óptima solo sin costes y con volatilidad constante; bajo Heston con correlación negativa y con costes, operar algo menos y ajustar la cobertura a la asimetría reduce la varianza y los costes
Ver respuesta
Respuesta D. Porque la delta de Black-Scholes es óptima solo sin costes y con volatilidad constante; bajo Heston con correlación negativa y con costes, operar algo menos y ajustar la cobertura a la asimetría reduce la varianza y los costes
El agente gana donde la referencia hace supuestos que el simulador no cumple. Se entrena con caminos propios y se evalúa en los de prueba, que nunca ha visto; la mejora se da con su error típico.
¿Cuál es el principal riesgo de llevar a producción cualquiera de estos tres agentes?
- AQue PPO no es determinista
- BQue los caminos de prueba son pocos
- CQue el agente aprende el simulador: si el impacto real no es lineal, o la volatilidad no sigue Heston, lo aprendido puede ser peor que la fórmula, y en los caminos de prueba del mismo simulador no se ve
- DQue las redes son demasiado pequeñas
Ver respuesta
Respuesta C. Que el agente aprende el simulador: si el impacto real no es lineal, o la volatilidad no sigue Heston, lo aprendido puede ser peor que la fórmula, y en los caminos de prueba del mismo simulador no se ve
Los caminos de prueba miden el sobreajuste a la semilla de entrenamiento, no el error de modelo. Hace falta probar el agente en simuladores con supuestos distintos (impacto en raíz cuadrada de 4.3.1, otros parámetros de Heston) y, con datos reales, con un backtest de ejecución.
Ideas clave
- Los tres problemas tienen un simulador, un objetivo claro (media − λ·varianza) y una referencia analítica: es donde el RL en trading tiene sentido y donde se puede medir.
- Ejecución: con la recompensa realizada, PPO aprende y desaprende (35.623 y 36.275 USD de objetivo teórico a los 30.000 y 60.000 pasos); quitando el ruido de media cero, queda a 36 USD de Almgren-Chriss en los caminos de prueba (36.425 frente a 36.389; TWAP, 38.228).
- Cuando el simulador cumple los supuestos de la solución analítica, el agente como mucho la iguala. Si gana, hay un error en el entorno.
- Market making: el agente elige un sesgo constante β = 0,1 que da 64,13 de media − (γ/2)·varianza frente a 62,80 de Avellaneda-Stoikov, porque optimiza otro criterio; una búsqueda directa del parámetro encuentra lo mismo en un segundo.
- Cobertura: un deep hedging ligero (red de 32 × 32, entrenado con 20.000 caminos Heston propios) mejora en 2,14 USD (error típico 0,65) el criterio de la delta BS con costes en los 2.000 caminos de prueba, con menos desviación y menos costes. Gana porque la delta BS es óptima con supuestos que Heston y los costes no cumplen.
- El riesgo de los tres es el mismo: el agente aprende el simulador. Los caminos de prueba miden el sobreajuste a la semilla, no el error de modelo.
Recursos
Documentación
- Almgren, R. y Chriss, N. (2000/2001): Optimal execution of portfolio transactions. Journal of Risk 3(2), 5-39
- Avellaneda, M. y Stoikov, S. (2008): High-frequency trading in a limit order book. Quantitative Finance 8(3)
- Buehler, H., Gonon, L., Teichmann, J. y Wood, B. (2019): Deep hedging. Quantitative Finance 19(8). arXiv:1802.03042
- Nevmyvaka, Y., Feng, Y. y Kearns, M. (2006): Reinforcement learning for optimized trade execution. ICML
- Stable-Baselines3: PPO