Lección 4.2.1 · 65 min
Riesgo por operación, stops y riesgo de ruina
Antes de entrar en una operación se decide cuánto se está dispuesto a perder si sale mal: el riesgo por operación, R. De él salen el tamaño de la posición y el sentido del stop. Esta lección mide sobre un sistema de tendencia en 24 futuros qué hace cada tipo de stop (fijo, por ATR, trailing, temporal), cuánto cuesta y cuál es su peor caso, y calcula el riesgo de ruina con fórmulas y con simulación, para ver dónde dejan de valer las fórmulas.
- Módulo
- 4.2 · Gestión del riesgo y tamaño de posición
- Duración
- 65 min
Lecciones del nivel 4
4.1 · Teoría y optimización de carteras
- 4.1.1 Markowitz: frontera eficiente y restricciones reales
- 4.1.2 Estimación de covarianzas: shrinkage, modelos factoriales y denoising
- 4.1.3 Black-Litterman: del equilibrio del mercado a las visiones
- 4.1.4 Risk parity, mínima varianza, HRP y HERC
- 4.1.5 Optimización con CVaR, drawdown, rotación y costes
- 4.1.6 Modelos factoriales: CAPM, Fama-French, PCA y atribución
- 4.1.7 Rebalanceo y carteras de estrategias
- 4.1.8 Herramientas de carteras: Riskfolio-Lib, PyPortfolioOpt, skfolio y bt
4.2 · Gestión del riesgo y tamaño de posición
- 4.2.1 Riesgo por operación, stops y riesgo de ruina
- 4.2.2 Criterio de Kelly y volatility targeting: cuánto arriesgar
- 4.2.3 VaR, Expected Shortfall y backtesting del VaR
- 4.2.4 Stress testing y riesgos de correlación, liquidez, gap y contraparte
- 4.2.5 Control del drawdown y kill switch
- 4.2.6 Riesgo de derivados: griegas de cartera, margen y liquidaciones
4.3 · Microestructura y ejecución
4.4 · Brokers y APIs
- 4.4.1 Elegir bróker desde España o la UE: regulación, activos, costes y API
- 4.4.2 Interactive Brokers: TWS e IB Gateway, ib_async, contratos, datos y órdenes
- 4.4.3 Alpaca, Tradier, MetaTrader 5 con Python y OANDA v20
- 4.4.4 APIs de exchanges cripto: ccxt, Binance, Bybit, Kraken y Coinbase
- 4.4.5 Credenciales seguras: variables de entorno, gestores de secretos y permisos mínimos
4.5 · Sistema de trading en vivo
- 4.5.1 Arquitectura de un sistema en vivo: de los datos a la reconciliación
- 4.5.2 Gestión de órdenes: estados, fills parciales, cancelaciones y reconexiones
- 4.5.3 Paper trading, shadow trading y seguimiento del slippage
- 4.5.4 Programación de tareas y alertas: cron, APScheduler, Prefect, Telegram, Slack y email
- 4.5.5 Tolerancia a fallos: idempotencia, estado persistente y reconciliación al arrancar
- 4.5.6 NautilusTrader o LEAN en real: del backtest a producción con el mismo código
El riesgo de una operación se decide antes de entrar
Un sistema con ventaja puede arruinar a su operador si el tamaño de cada operación es demasiado grande. La forma más extendida de controlarlo es fijar de antemano cuánto se pierde si la operación sale mal: el riesgo por operación, una fracción del capital (los manuales suelen hablar de un 0,5 % a un 2 %). A esa pérdida planeada se le llama R, y los resultados de las operaciones se miden en múltiplos de R: una operación de +3 R gana el triple de lo que se arriesgaba; una de −1 R pierde lo previsto. Para que la pérdida sea la prevista hace falta un stop, un precio al que se cierra la posición, y el tamaño sale de la distancia hasta él:
con el capital, la distancia al stop en tanto por uno del precio, el multiplicador del contrato y el precio. El suelo es importante: con futuros solo se negocian contratos enteros.
Fuente Mermaid
flowchart TB S[Señal de entrada] --> D[Distancia al stop] V[ATR] --> D D --> N[Contratos] N --> O[Orden y stop] O --> R[Resultado en R]
Los datos son los futuros sintéticos del nivel 2 (24 mercados de 2000 a 2025, con la tendencia plantada de su README: un 0,15·σ anual en la dirección de los últimos 12 meses). Solo tienen cierres, así que la volatilidad se mide con un «ATR de cierres», la media de Wilder del cambio absoluto de cierre a cierre (la sección siguiente lo compara con el ATR completo). Con un stop a 3 ATR de cierres y un 0,5 % de riesgo:
import numpy as np
import pandas as pd
fut = pd.read_parquet("futuros2_continuos.parquet")
esp = pd.read_csv("futuros2_especificaciones.csv", index_col="mercado")
AJ = fut.pivot(index="fecha", columns="mercado",
values="cierre_ajustado")
P = fut.pivot(index="fecha", columns="mercado", values="cierre")
CONTRATO = fut.pivot(index="fecha", columns="mercado",
values="contrato")
esp = esp.loc[AJ.columns]
# «ATR de cierres»: media de Wilder de |ΔC| en % del precio
atr = AJ.diff().abs().ewm(alpha=1 / 20, adjust=False).mean() / AJ
K_STOP = 3.0 # distancia del stop: 3 ATR de cierres
def contratos(capital, f, dia):
riesgo = (K_STOP * atr.loc[dia] * P.loc[dia]
* esp["multiplicador"]) # USD por contrato
n = np.floor(capital * f / riesgo)
return pd.DataFrame({"riesgo_contrato": riesgo,
"contratos": n,
"riesgo_real_%": 100 * n * riesgo / capital})
dia = "2025-12-31"
pd.concat({f"{c:,.0f}": contratos(c, 0.005, dia).loc[
["IDA", "BOA", "BOC", "ENA", "MEA", "AGA", "DIA"]]
for c in (1_000_000, 100_000)}, axis=1).round(2) 1,000,000 100,000
riesgo_contrato contratos riesgo_real_% riesgo_contrato contratos riesgo_real_%
mercado
IDA 9371.01 0.0 0.00 9371.01 0.0 0.00
BOA 405.08 12.0 0.49 405.08 1.0 0.41
BOC 660.84 7.0 0.46 660.84 0.0 0.00
ENA 2716.93 1.0 0.27 2716.93 0.0 0.00
MEA 764.58 6.0 0.46 764.58 0.0 0.00
AGA 528.64 9.0 0.48 528.64 0.0 0.00
DIA 924.37 5.0 0.46 924.37 0.0 0.00Con un millón de USD, el presupuesto por operación es de 5.000 USD. El índice IDA tiene un riesgo de 9.371 USD
por contrato hasta el stop: no cabe ni uno, y el sistema no puede operarlo con ese riesgo. El crudo (ENA, 2.717 USD
por contrato) admite uno, con un riesgo real del 0,27 % en lugar del 0,5 %. Los demás quedan cerca del objetivo (del
0,46 % al 0,49 %). Con 100.000 USD solo el bono a 2 años (BOA, 405 USD por contrato) admite un contrato. Es el
problema de la granularidad de la lección 3.1.4, visto desde el riesgo por operación: con poco capital, la regla del
«1 % por operación» no se puede aplicar a contratos grandes, y las salidas son los micros, menos mercados o aceptar
riesgos desiguales. El sistema de la lección, con un millón, se salta las operaciones que no admiten un contrato.
ATR: la distancia que se adapta
El ATR (average true range, Wilder, 1978; lección 1.7.1) es la media del rango verdadero: el máximo entre el rango del día y la distancia desde el cierre anterior al máximo o al mínimo, para contar los huecos. Con barras diarias de KAPA (el ETF de las barras de 5 minutos del nivel 2), el ATR de 14 sesiones de TA-Lib y uno propio con la media de Wilder:
import talib
b = pd.read_parquet("indice2_5min.parquet")
b["dia"] = b["ts"].dt.tz_convert("America/New_York").dt.date
d = b.groupby("dia").agg(apertura=("apertura", "first"),
maximo=("maximo", "max"),
minimo=("minimo", "min"),
cierre=("cierre", "last"))
d.index = pd.to_datetime(d.index)
d["atr"] = talib.ATR(d["maximo"], d["minimo"], d["cierre"],
timeperiod=14)
previo = d["cierre"].shift(1)
rango = pd.concat([d["maximo"] - d["minimo"],
(d["maximo"] - previo).abs(),
(d["minimo"] - previo).abs()], axis=1).max(axis=1)
propio = pd.Series(np.nan, index=d.index)
propio.iloc[14] = rango.iloc[1:15].mean() # semilla de Wilder
for i in range(15, len(d)):
propio.iloc[i] = (13 * propio.iloc[i - 1] + rango.iloc[i]) / 14
print(f"máxima diferencia TA-Lib - propio: "
f"{(d['atr'] - propio).abs().max():.1e}")
print(f"ATR / media de |ΔC|: "
f"{rango.mean() / d['cierre'].diff().abs().mean():.2f}")
(100 * d["atr"] / d["cierre"]).groupby(d.index.year).mean().round(2)máxima diferencia TA-Lib - propio: 5.3e-15
ATR / media de |ΔC|: 1.88
dia
2022 1.92
2023 1.34
2024 1.37
2025 1.58
dtype: float64TA-Lib y la implementación propia coinciden hasta el redondeo (5e-15). Dos datos útiles. El rango verdadero medio es 1,88 veces el cambio absoluto medio de cierre a cierre: el «ATR de cierres» que usa el sistema de futuros es más pequeño que el ATR completo, y 3 ATR de cierres equivalen a 1,6 ATR completos. Y el ATR, en porcentaje del precio, varía de un año a otro: 1,92 % de media en 2022, 1,34 % en 2023, 1,37 % en 2024 y 1,58 % en 2025. Esa variación es la que estropea un stop fijo:
ayer = d.shift(1)
stops = pd.DataFrame({
"fijo_2%": d["minimo"] <= ayer["cierre"] * 0.98,
"1,3_ATR": d["minimo"] <= ayer["cierre"] - 1.3 * ayer["atr"]}
).loc[ayer["atr"].notna()]
distancia = (100 * 1.3 * ayer["atr"] / ayer["cierre"]).loc[stops.index]
pd.concat([stops.groupby(stops.index.year).mean().mul(100),
distancia.groupby(distancia.index.year).mean()
.rename("dist_ATR_%")], axis=1).round(2) fijo_2% 1,3_ATR dist_ATR_%
dia
2022 8.47 4.24 2.50
2023 4.00 7.20 1.74
2024 2.78 7.54 1.79
2025 5.20 4.80 2.05Para una posición comprada al cierre anterior, un stop al 2 % se toca el 8,47 % de los días de 2022 y solo el 2,78 % de los de 2024: el mismo stop es tres veces más «nervioso» en un año agitado. Un stop a 1,3 ATR (de media, unos 2 %) se toca entre el 4,24 % y el 7,54 % de los días, porque su distancia se abre al 2,50 % en 2022 y se cierra al 1,74 % en 2023. No es constante: el ATR se calcula con las 14 sesiones anteriores y llega tarde a los cambios de régimen. Pero la diferencia de significado entre mercados, que es la grande, desaparece.
Un sistema de tendencia y cinco salidas
Para medir los stops hace falta un sistema. El de la lección es deliberadamente simple: en cada uno de los 24
futuros, cuando la media exponencial de 20 sesiones cruza la de 100, se entra en la dirección del cruce y se sale al
cruce contrario (una entrada por tramo de señal). Todas las decisiones se toman con el cierre de ayer y se ejecutan al
cierre de hoy; el tamaño es el del 0,5 % de riesgo con el stop a 3 ATR de cierres, en contratos enteros; cada cambio
de posición paga los ticks de deslizamiento y la comisión de futuros2_especificaciones.csv, y cada roll, dos lados.
Las cinco variantes solo cambian la salida:
- sin stop: solo el cruce contrario;
- fijo 5 %: además, se sale si la operación pierde un 5 % del precio de entrada;
- ATR 3: se sale si pierde 3 ATR de cierres (el stop con el que se dimensiona: −1 R);
- trailing 10 ATR: se sale si el precio retrocede 10 ATR desde el mejor cierre de la operación;
- tiempo 20: se sale si a las 20 sesiones la operación no gana.
r = AJ.pct_change()
senal = np.sign(AJ.ewm(span=20).mean() - AJ.ewm(span=100).mean())
CAPITAL, F = 1_000_000, 0.005
mult = esp["multiplicador"].to_numpy()
coste_lado = (esp["coste_tipico_ticks"] * esp["valor_tick"]
+ esp["comision_usd"]).to_numpy()
cambio_ctr = (CONTRATO.ne(CONTRATO.shift(1))
& CONTRATO.shift(1).notna())
def sistema(salida="ninguna", k=3.0, fijo=0.05, n=20, azar=False,
datos=None, semilla=1, plazo=60):
"""Tendencia (cruce de medias 20/100) en 24 futuros. Decide con
el cierre de ayer y opera al cierre de hoy. Tamaño: 0,5 % del
capital inicial hasta un stop a K_STOP ATR (contratos enteros).
salida: 'ninguna' (solo la señal contraria), 'fijo' (pérdida
del `fijo` %), 'atr' (k ATR desde la entrada), 'trailing'
(k ATR desde el mejor cierre) o 'tiempo' (n sesiones sin
ganar). azar=True: entradas y lados al azar, salida a `plazo`."""
Aj, Sg, At = datos if datos is not None else (AJ, senal, atr)
rng = np.random.default_rng(semilla)
a, s, v, p = (Aj.to_numpy(), Sg.to_numpy(), At.to_numpy(),
P.to_numpy())
T, M = a.shape
N, ops = np.zeros((T, M)), []
for j in range(M):
lado = 0
hecho = 0
for t in range(120, T):
if lado:
N[t, j] = lado * nc # mantenida en la sesión t
mov = (a[t - 1, j] / e - 1) * lado
dias = t - 1 - t0
motivo = None
if azar and dias >= plazo:
motivo = "plazo"
elif not azar and s[t - 1, j] != lado:
motivo = "señal"
if salida == "fijo" and mov <= -fijo:
motivo = "stop"
if salida == "atr" and mov <= -k * a0:
motivo = "stop"
if salida == "trailing":
mejor = max(mejor, mov)
if mov <= mejor - k * a0:
motivo = "stop"
if salida == "tiempo" and dias >= n and mov <= 0:
motivo = "tiempo"
if motivo is None:
continue
ops.append((j, t0, t, lado, nc, motivo)) # sale en t
lado = 0
if np.isnan(s[t - 1, j]) or np.isnan(v[t - 1, j]):
continue
if azar:
entra = rng.random() < 1 / 40
else: # una entrada por tramo de señal
entra = s[t - 1, j] != 0 and s[t - 1, j] != hecho
if not entra:
continue
a0 = v[t - 1, j]
nc = np.floor(CAPITAL * F
/ (K_STOP * a0 * p[t - 1, j] * mult[j]))
if nc < 1:
continue
lado = (int(rng.choice([-1, 1])) if azar
else int(s[t - 1, j]))
hecho = s[t - 1, j]
e, t0, mejor = a[t, j], t, 0.0 # entra al cierre de t
N = pd.DataFrame(N, index=AJ.index, columns=AJ.columns)
rr = Aj.pct_change()
pyg = (N * mult * P.shift(1) * rr).fillna(0)
operado = N.diff().abs().shift(-1).fillna(0) # al cierre de t
coste = operado * coste_lado + 2 * N.abs() * cambio_ctr * coste_lado
neto = (pyg.sum(axis=1) - coste.sum(axis=1)) / CAPITAL
ops = pd.DataFrame(ops, columns=["j", "t0", "t", "lado", "nc",
"motivo"])
ops["mercado"] = AJ.columns[ops["j"]]
ops["R"] = [pyg.iloc[i + 1:k_ + 1, j].sum() / (CAPITAL * F)
for j, i, k_ in zip(ops["j"], ops["t0"], ops["t"])]
ops["dias"] = ops["t"] - ops["t0"]
return neto.loc["2001":], ops
def resumen(neto, ops):
eq = (1 + neto).cumprod()
dd = eq / eq.cummax() - 1
R = ops["R"]
return {"ops": len(ops), "gan_%": 100 * (R > 0).mean(),
"E[R]": R.mean(), "peor_R": R.min(),
"p1_R": R.quantile(0.01),
"stop_%": 100 * (ops["motivo"] != "señal").mean(),
"sharpe": neto.mean() / neto.std() * np.sqrt(252),
"caida_%": 100 * dd.min(),
"peor_mes_%": 100 * neto.resample("ME").sum().min()}El P&L se calcula contrato a contrato en dólares (contratos × multiplicador × precio de ayer × rendimiento de hoy de la serie ajustada) y el R de cada operación es su resultado bruto dividido por los 5.000 USD de riesgo planeado. Las cinco salidas:
SALIDAS = {"sin stop": {},
"fijo 5 %": {"salida": "fijo"},
"ATR 3": {"salida": "atr", "k": 3},
"trailing 10 ATR": {"salida": "trailing", "k": 10},
"tiempo 20": {"salida": "tiempo", "n": 20}}
RES = {k: sistema(**kw) for k, kw in SALIDAS.items()}
pd.DataFrame({k: resumen(*v) for k, v in RES.items()}).T.round(2) ops gan_% E[R] peor_R p1_R stop_% sharpe caida_% peor_mes_%
sin stop 1841.0 31.29 0.43 -4.63 -3.56 0.00 0.67 -42.73 -19.69
fijo 5 % 1846.0 28.01 0.38 -4.51 -3.16 31.53 0.63 -32.44 -18.06
ATR 3 1849.0 24.07 0.29 -3.70 -2.44 62.57 0.54 -31.59 -16.21
trailing 10 ATR 1842.0 34.09 0.28 -4.25 -3.42 45.82 0.56 -43.00 -14.05
tiempo 20 1851.0 29.23 0.26 -4.25 -3.36 50.08 0.48 -30.27 -13.79La figura de la cabecera resume la tabla. Sin stop, el sistema hace 1.841 operaciones en 25 años; solo el 31,3 % gana, pero la esperanza es de +0,43 R por operación (las ganadoras son grandes: es una tendencia), con un Sharpe neto de 0,67, una caída máxima del 42,7 % y un peor mes del −19,7 %. Cada stop cuesta Sharpe: 0,63 el fijo del 5 %, 0,54 el de 3 ATR, 0,56 el trailing y 0,48 el temporal, porque cada uno corta operaciones que, aguantando, habrían terminado bien. Lo que compran es una cola más corta: con el stop de 3 ATR, el percentil 1 por operación pasa de −3,56 R a −2,44 R, la caída máxima del 42,7 % al 31,6 % y el peor mes del −19,7 % al −16,2 %. El stop temporal da la caída más pequeña (30,3 %) y el peor mes más suave (−13,8 %), con el peor Sharpe.
No hay una salida «mejor»: hay un intercambio entre rentabilidad y forma de las pérdidas. Lo que sí hay es una regla que no conviene seguir, la del stop fijo en porcentaje del precio, y se ve por sectores:
o = RES["fijo 5 %"][1]
o["sector"] = esp.loc[o["mercado"], "sector"].to_numpy()
o.groupby("sector").agg(
operaciones=("R", "size"),
stop_pct=("motivo", lambda m: 100 * (m == "stop").mean()),
E_R=("R", "mean")).round(2) operaciones stop_pct E_R
sector
agricolas 313 50.80 0.42
bonos 401 0.25 0.38
divisas 268 7.46 0.00
energia 277 66.43 -0.01
metales 338 39.35 0.31
renta_variable 249 34.14 1.25El mismo 5 % no se toca nunca en los bonos (un 0,25 % de las operaciones, con volatilidades del 2 % al 12 % anual) y corta dos de cada tres operaciones en energía (66,4 %, con volatilidades del 33 % al 50 %). En bonos es como no tener stop; en energía es una salida casi aleatoria, y la esperanza de esas operaciones queda en −0,01 R. Un stop tiene que medirse en unidades de la volatilidad del mercado, que es lo que hacen el de ATR y el trailing.
El control: ¿crea ventaja un stop?
Una idea muy repetida es que un buen stop convierte cualquier entrada en rentable («corta las pérdidas y deja correr las ganancias»). Se puede comprobar con dos controles: entradas al azar (cada día, una probabilidad de 1/40 de entrar en cada mercado, en un lado al azar, y salida a las 60 sesiones si no salta antes el stop) y los mismos futuros con los rendimientos diarios permutados dentro de cada mercado, que conservan la distribución de cada día y destruyen la tendencia (como en la lección 2.5.5):
rng = np.random.default_rng(11)
perm = r.copy()
for m in perm.columns:
v = perm[m].dropna()
perm.loc[v.index, m] = rng.permutation(v.to_numpy())
AJp = ((1 + perm.fillna(0)).cumprod() * AJ.bfill().iloc[0]).where(
AJ.notna())
sin_tend = (AJp,
np.sign(AJp.ewm(span=20).mean()
- AJp.ewm(span=100).mean()),
AJp.diff().abs().ewm(alpha=1 / 20, adjust=False).mean()
/ AJp)
filas = {}
for nombre, kw in [("señal", {}), ("señal + ATR 3", {"salida": "atr"}),
("azar", {"azar": True}),
("azar + ATR 3", {"azar": True, "salida": "atr"}),
("azar + trailing", {"azar": True, "k": 10,
"salida": "trailing"})]:
m_real = resumen(*sistema(**kw))
m_perm = resumen(*sistema(datos=sin_tend, **kw))
filas[nombre] = {"E[R]": m_real["E[R]"], "sharpe": m_real["sharpe"],
"peor_R": m_real["peor_R"],
"E[R]_sin_tend": m_perm["E[R]"],
"sharpe_sin_tend": m_perm["sharpe"]}
pd.DataFrame(filas).T.round(2) E[R] sharpe peor_R E[R]_sin_tend sharpe_sin_tend
señal 0.43 0.67 -4.63 0.00 -0.13
señal + ATR 3 0.29 0.54 -3.70 -0.00 -0.14
azar -0.02 -0.15 -16.80 -0.02 -0.25
azar + ATR 3 0.10 0.31 -3.65 -0.04 -0.37
azar + trailing 0.21 0.47 -6.26 -0.06 -0.36Con los datos reales del curso, las entradas al azar sin stop dan −0,02 R por operación y un Sharpe de −0,15 (los costes), con una peor operación de −16,8 R: sin stop y con 60 sesiones de plazo, una operación al azar puede salir muy mal. Con un stop de 3 ATR, las mismas entradas al azar ganan +0,10 R y un Sharpe de 0,31; con un trailing de 10 ATR, +0,21 R y 0,47. ¿Crean ventaja los stops? Con los rendimientos permutados, sin tendencia, la respuesta es no: todas las variantes pierden (de −0,00 a −0,06 R, Sharpe de −0,13 a −0,37), y los stops no arreglan las entradas al azar. Lo que pasa en los datos reales es que un stop es una regla de tendencia: cerrar lo que va mal y mantener lo que va bien es apostar a que los movimientos continúan, y en datos con tendencia plantada eso gana. En un mercado sin tendencia (o con reversión a la media, lección 2.3.2), el mismo stop pierde. Antes de atribuir el resultado de un sistema a su stop, conviene hacer este control.
El peor episodio: más de 1 R
El stop de 3 ATR define −1 R. La figura enseña cómo queda la distribución de los resultados con y sin él:
Sin stop, las pérdidas se reparten entre 0 y −4,6 R; con él, se amontonan entre 0 y −1,5 R. La cola derecha, la que paga el sistema (49 operaciones de más de 10 R con stop, 67 sin él), apenas cambia. Pero el stop no corta la cola izquierda en −1 R:
o = RES["ATR 3"][1]
fechas = AJ.index
peores = o.nsmallest(5, "R").assign(
entrada=lambda x: fechas[x["t0"]].date,
salida=lambda x: fechas[x["t"]].date)
print(peores[["mercado", "lado", "entrada", "salida", "motivo",
"R"]].round(2).to_string(index=False))
stop = o[o["motivo"] == "stop"]["R"]
print(f"salidas por stop: {len(stop)}, R medio {stop.mean():.2f}, "
f"peor que -1,5 R: {(stop < -1.5).mean():.1%}")mercado lado entrada salida motivo R
MEC -1 2002-02-06 2002-05-13 stop -3.70
BOA 1 2023-10-27 2023-10-31 stop -3.62
AGD -1 2024-07-16 2024-08-13 stop -3.13
MEC 1 2024-07-19 2024-11-01 stop -3.09
BOD 1 2023-10-05 2023-10-31 stop -2.90
salidas por stop: 1157, R medio -1.07, peor que -1,5 R: 16.8%De 1.157 salidas por stop, el resultado medio es −1,07 R, pero el 16,8 % pierde más de 1,5 R y la peor, −3,70 R (una venta de cobre en 2002). La segunda es instructiva: una compra del bono a 2 años el 27 de octubre de 2023, con el ATR en mínimos (un 0,075 % diario), que en dos sesiones cae unos 6 y 5 ATR de los de la entrada; el stop se detecta al cierre y se ejecuta al siguiente, y la pérdida llega a 3,62 R. Es el peor episodio de la regla: un stop en ATR es más estrecho justo cuando la volatilidad ha estado baja, y un cambio de régimen lo atraviesa. Con stops intradía pasa lo mismo con los huecos de apertura (lección 2.4.8). Un stop limita la pérdida típica, no la máxima; el dimensionamiento tiene que contar con pérdidas de varios R.
Riesgo de ruina: fórmulas
El riesgo de ruina es la probabilidad de perder una parte del capital que obliga a parar: todo, la mitad o la caída que el operador o sus clientes no aguantan. La fórmula clásica es la del jugador: con apuestas de una unidad, probabilidad de ganar una unidad y de perderla, y un capital de unidades, la probabilidad de perderlo todo alguna vez es
def ruina_jugador(p, unidades):
"""P(perderlo todo) apostando 1 unidad a cara o cruz con P(ganar)
= p > 0,5 frente a un rival infinito."""
return ((1 - p) / p) ** unidades
rng = np.random.default_rng(5)
p, U, caminos, pasos = 0.55, 10, 20_000, 2_000
x = np.where(rng.random((caminos, pasos)) < p, 1, -1)
minimo = U + np.minimum.accumulate(x.cumsum(axis=1), axis=1)[:, -1]
print(f"fórmula {ruina_jugador(p, U):.4f}, "
f"simulación {(minimo <= 0).mean():.4f}")fórmula 0.1344, simulación 0.1333Con un 55 % de aciertos y diez unidades, la probabilidad de ruina es del 13,4 %, y 20.000 caminos simulados de 2.000 apuestas dan un 13,3 %. Un trader no apuesta unidades fijas: arriesga una fracción del capital actual, y entonces el capital se multiplica por en cada operación. En logaritmos es un paseo aleatorio con pasos , y perder la mitad del capital es bajar . Dos fórmulas:
- Difusión: si los pasos fueran normales, con media y varianza , .
- Lundberg (Cramér-Lundberg): con la raíz de , . No supone normalidad: usa la distribución entera de los pasos. Es una cota superior, que se alcanza cuando la caída no se pasa de largo al cruzar el umbral.
Con los resultados en R de las 1.849 operaciones del sistema con stop de 3 ATR, y una simulación que los remuestrea como si fueran independientes (1.000 operaciones, 4.000 caminos):
from scipy.optimize import brentq
R = RES["ATR 3"][1]["R"].to_numpy()
L = 0.5 # «ruina»: perder la mitad del capital
D = -np.log(L)
def ruina_formulas(f):
x = np.log1p(f * R) # crecimiento log por operación
difusion = np.exp(-2 * x.mean() * D / x.var())
s = brentq(lambda s: np.mean(np.exp(-s * x)) - 1, 1e-6, 500)
return difusion, np.exp(-s * D)
def ruina_simulada(f, n=1000, caminos=4000, semilla=3, nivel=L):
"""Operaciones remuestreadas (independientes) con riesgo f."""
rng = np.random.default_rng(semilla)
capital = np.cumprod(1 + f * rng.choice(R, (caminos, n)), axis=1)
desde_inicio = (capital.min(axis=1) <= nivel).mean()
pico = np.maximum.accumulate(np.maximum(capital, 1), axis=1)
desde_pico = ((capital / pico).min(axis=1) <= nivel).mean()
return desde_inicio, desde_pico
filas = {}
for f in (0.005, 0.01, 0.02, 0.03, 0.05):
dif, lund = ruina_formulas(f)
ini, pico = ruina_simulada(f)
filas[f"{100 * f:g} %"] = {"difusion": dif, "lundberg": lund,
"simulada": ini, "caida_50%_pico": pico}
print(f"{len(R)} operaciones: E[R] {R.mean():.3f}, "
f"desv. {R.std():.2f}, ganadoras {(R > 0).mean():.1%}")
pd.DataFrame(filas).T.round(4)1849 operaciones: E[R] 0.292, desv. 3.60, ganadoras 24.1%
difusion lundberg simulada caida_50%_pico
0.5 % 0.0022 0.0004 0.0002 0.0015
1 % 0.0531 0.0256 0.0272 0.2218
2 % 0.2682 0.2106 0.1955 0.9445
3 % 0.4719 0.4267 0.4132 1.0000
5 % 0.7660 0.7536 0.7040 1.0000Las operaciones tienen una esperanza de 0,292 R con una desviación de 3,60 R y solo un 24,1 % de ganadoras: una distribución muy asimétrica. La fórmula de Lundberg y la simulación coinciden dentro del error de la simulación (unos ±0,3 puntos con 4.000 caminos al 1 %): 2,6 % y 2,7 % de probabilidad de perder la mitad del capital arriesgando un 1 % por operación; 21 % y 20 % con un 2 %; 75 % y 70 % con un 5 %. La de difusión exagera (5,3 % al 1 %): trata la distribución como normal y la gran cola de ganancias, que aleja la ruina, entra solo a través de la varianza, como si fuera riesgo.
La última columna es la que más importa en la práctica: la probabilidad de que en algún momento el capital caiga un 50 % desde un máximo, no desde el inicio. Al 1 % es del 22 %; al 2 %, del 94 %. La ruina desde el capital inicial se aleja a medida que se gana; la caída desde un máximo no, y es la que sufre quien empezó a operar en un buen momento. Las cifras dependen de 1.000 operaciones: con más operaciones, la probabilidad desde el máximo sube.
Las operaciones no van de una en una
Las fórmulas y la simulación anterior tratan las operaciones en fila: una termina y empieza la siguiente. En el sistema de la lección hay muchas abiertas a la vez:
o = RES["ATR 3"][1]
abiertas = np.zeros(len(AJ))
for i, k_ in zip(o["t0"], o["t"]):
abiertas[i + 1:k_ + 1] += 1
abiertas = pd.Series(abiertas, AJ.index).loc["2001":]
print(f"operaciones abiertas: media {abiertas.mean():.1f}, "
f"máximo {abiertas.max():.0f}")
print(f"riesgo abierto medio {0.5 * abiertas.mean():.1f} % del "
f"capital, máximo {0.5 * abiertas.max():.0f} %")
_, p30 = ruina_simulada(0.005, n=len(R), nivel=0.7)
eq = (1 + RES["ATR 3"][0]).cumprod()
print(f"en serie: P(caída del 30 % desde el máximo) = {p30:.3f}; "
f"real: {100 * (eq / eq.cummax() - 1).min():.1f} %")operaciones abiertas: media 16.7, máximo 24
riesgo abierto medio 8.4 % del capital, máximo 12 %
en serie: P(caída del 30 % desde el máximo) = 0.308; real: -31.6 %De media hay 16,7 operaciones abiertas, y hasta 24 (una por mercado). El riesgo abierto (la suma de los R de las posiciones vivas) es de un 8,4 % del capital de media y llega al 12 %: el «0,5 % por operación» es en realidad un riesgo de cartera que depende de cuántas posiciones hay y de cómo se mueven juntas. En este sistema, la caída real de 31,6 % cabe en lo que da la simulación en serie con las mismas 1.849 operaciones (un 30,8 % de probabilidad de una caída del 30 % desde un máximo), a diferencia de la lección 2.5.5, donde la secuencia real era peor que cualquier reordenación. Pero el tiempo es otro: en serie, esas 1.849 operaciones se suceden; en la cuenta, se acumulan en los mismos meses, y el peor mes del sistema pierde un 16,2 %. Por eso el dimensionamiento de una cartera se hace con la volatilidad conjunta de las posiciones (volatility targeting, lección 4.2.2) y se vigila con límites de caída (4.2.5), no solo con el riesgo de cada operación.
Errores típicos
- Dimensionar por el stop y olvidar el redondeo. Con contratos enteros, el riesgo real puede ser la mitad del planeado o, si se redondea hacia arriba, el doble.
- Usar el mismo stop en porcentaje para todos los mercados. Mide la volatilidad, no la idea.
- Atribuir al stop la ventaja del sistema. Haz el control con entradas al azar y con datos sin tendencia.
- Suponer que −1 R es la pérdida máxima. Los huecos y la ejecución al cierre siguiente la superan.
- Calcular la ruina desde el capital inicial y con operaciones en fila. La caída desde un máximo y las posiciones simultáneas dan probabilidades mucho mayores.
Ejercicios
- Repite el sistema con un stop de 2 ATR y otro de 5 ATR. ¿Cómo cambian el Sharpe, el percentil 1 y la caída máxima? ¿Hay un punto en que el stop deja de costar?
Solución
pd.DataFrame({f"ATR {k}": resumen(*sistema(salida="atr", k=k))
for k in (2, 3, 5)}).T[
["gan_%", "E[R]", "p1_R", "stop_%", "sharpe", "caida_%"]].round(2) gan_% E[R] p1_R stop_% sharpe caida_%
ATR 2 21.36 0.23 -1.97 73.95 0.47 -34.09
ATR 3 24.07 0.29 -2.44 62.57 0.54 -31.59
ATR 5 29.27 0.36 -2.82 37.45 0.60 -34.67Con 2 ATR, el stop salta en el 74 % de las operaciones, el percentil 1 baja a −1,97 R y el Sharpe a 0,47; con 5 ATR salta en el 37 %, el Sharpe sube a 0,60 y el percentil 1 empeora a −2,82 R. La caída máxima no sigue a la anchura: es del 34,1 % con 2 ATR, del 31,6 % con 3 y del 34,7 % con 5, porque la peor racha del sistema depende de muchas operaciones simultáneas y no solo de cada stop. En este sistema el stop no deja de costar a ninguna anchura: cuanto más ancho, más se parece al sistema sin stop (Sharpe 0,67) y menos recorta la cola. La anchura es una elección entre rentabilidad y forma de las pérdidas, y elegirla mirando el backtest es otra fuente de sobreajuste (lección 2.5.6).
- Calcula con las fórmulas de Lundberg y de difusión el riesgo por operación que da un 1 % de probabilidad de perder la mitad del capital con las operaciones del sistema con stop de 3 ATR.
Solución
for i, nombre in enumerate(["difusión", "Lundberg"]):
f_max = brentq(lambda f: ruina_formulas(f)[i] - 0.01, 0.004, 0.05)
print(f"{nombre:9s}: f = {100 * f_max:.2f} %, simulada "
f"{ruina_simulada(f_max)[0]:.4f}")difusión : f = 0.66 %, simulada 0.0027
Lundberg : f = 0.82 %, simulada 0.0118Para un 1 % de probabilidad de perder la mitad del capital (en 1.000 operaciones independientes), la fórmula de Lundberg permite arriesgar un 0,82 % por operación y la de difusión, más prudente por su error, un 0,66 %. La simulación con el 0,82 % da un 1,18 %, cerca del objetivo (la diferencia es el error de la simulación y el horizonte finito); con el 0,66 %, un 0,27 %. Son cifras para operaciones en fila: con 17 posiciones abiertas a la vez, el riesgo de la cartera manda, y por eso este cálculo es un techo para el riesgo por operación, no una receta.
- Un curso en internet promete que «con un stop siempre a 1:3 (arriesgar 1 para ganar 3) y un 40 % de aciertos se gana siempre». ¿Qué supone y qué omite?
Solución
Supone que cada operación termina exactamente en −1 R o en +3 R, que los aciertos son independientes y que su probabilidad es del 40 %. Con esos números la esperanza es 0,4·3 − 0,6·1 = +0,6 R por operación, y la ruina con riesgo fijo pequeño es improbable. Omite casi todo lo que mide esta lección: que las pérdidas pasan de −1 R (huecos, ejecución al cierre siguiente: aquí, un 16,8 % de las salidas por stop pasan de −1,5 R); que el 40 % de aciertos con un objetivo a +3 R no lo da la regla de salida sino la entrada (con entradas al azar y sin tendencia, la esperanza es negativa por los costes); que las operaciones se solapan y se correlacionan; y que «siempre» no existe: incluso con esperanza positiva, una caída del 50 % desde un máximo tiene una probabilidad alta si el riesgo por operación es del 2 % o más.
Respuesta plegada en cada pregunta
Autoevaluación
Con 100.000 USD y un 0,5 % de riesgo por operación (500 USD), el riesgo de un contrato hasta el stop es de 660 USD. ¿Cuántos contratos se compran?
- AUno: se redondea hacia arriba
- B0,76 contratos
- CNinguno: con contratos enteros hacia abajo, el riesgo de un contrato ya supera el presupuesto; o se renuncia a la operación o se acepta más riesgo del planeado
- DDepende del margen
Ver respuesta
Respuesta C. Ninguno: con contratos enteros hacia abajo, el riesgo de un contrato ya supera el presupuesto; o se renuncia a la operación o se acepta más riesgo del planeado
floor(500 / 660) = 0. Con 100.000 USD, en la lección solo uno de los siete mercados de la tabla admite un contrato. Con poco capital, el riesgo por operación de los manuales no se puede aplicar a futuros grandes: hay que usar micros o aceptar posiciones de riesgo desigual.
En KAPA, un stop fijo del 2 % bajo el cierre anterior se toca el 8,5 % de los días de 2022 y el 2,8 % de los de 2024. ¿Por qué?
- APorque 2022 tuvo más sesiones
- BPorque la volatilidad diaria (ATR medio del 1,92 % en 2022 y del 1,37 % en 2024) cambia y un stop en porcentaje fijo queda relativamente más cerca en los años agitados
- CPorque el stop está mal calculado
- DPor los huecos de apertura
Ver respuesta
Respuesta B. Porque la volatilidad diaria (ATR medio del 1,92 % en 2022 y del 1,37 % en 2024) cambia y un stop en porcentaje fijo queda relativamente más cerca en los años agitados
Un 2 % es poco más de un ATR en 2022 y casi un ATR y medio en 2024. Un stop a 1,3 ATR mantiene una distancia proporcional a la volatilidad reciente, y su frecuencia de disparo varía menos entre años (del 4,2 % al 7,5 %), aunque no es constante porque el ATR se mueve con retraso.
En el sistema de tendencia, el stop de 3 ATR baja el Sharpe de 0,67 a 0,54. ¿Qué compra a cambio?
- AUna cola de pérdidas más corta: el percentil 1 por operación pasa de −3,56 R a −2,44 R, la caída máxima del 42,7 % al 31,6 % y el peor mes del −19,7 % al −16,2 %
- BNada: es peor en todo
- CMás rentabilidad media
- DMenos operaciones
Ver respuesta
Respuesta A. Una cola de pérdidas más corta: el percentil 1 por operación pasa de −3,56 R a −2,44 R, la caída máxima del 42,7 % al 31,6 % y el peor mes del −19,7 % al −16,2 %
El stop corta las operaciones que se tuercen, y con ellas algunas que habrían acabado bien (la tendencia necesita aguantar retrocesos): la esperanza por operación baja de 0,43 R a 0,29 R. A cambio, el peor caso es menos malo. Si eso compensa depende de lo que cueste al operador (o a sus clientes) una caída del 40 %.
Con un stop de 3 ATR, ¿por qué la peor operación pierde 3,7 R y no 1 R?
- APor un error del backtest
- BPorque los costes son de 2,7 R
- CPorque el stop solo se aplica a las compras
- DPorque el stop se comprueba al cierre y se ejecuta al cierre siguiente, y en ese tiempo el precio puede seguir moviéndose muchos ATR, sobre todo si la volatilidad estaba baja al entrar
Ver respuesta
Respuesta D. Porque el stop se comprueba al cierre y se ejecuta al cierre siguiente, y en ese tiempo el precio puede seguir moviéndose muchos ATR, sobre todo si la volatilidad estaba baja al entrar
Un stop limita la pérdida esperada, no la máxima. En la lección, el 16,8 % de las salidas por stop pierde más de 1,5 R; en el bono a 2 años, con un ATR muy bajo al entrar, dos sesiones de unos 6 y 5 ATR llevaron la pérdida a 3,6 R. Con stops intradía pasa lo mismo con los huecos (lección 2.4.8).
La fórmula de Lundberg y la simulación coinciden: arriesgando un 1 % por operación, la probabilidad de perder la mitad del capital es de un 2,6-2,7 %. ¿Por qué no basta para dimensionar?
- APorque las fórmulas no sirven
- BPorque el 1 % es demasiado poco
- CPorque la simulación tiene pocos caminos
- DPorque suponen operaciones independientes y de una en una; el sistema tiene 16,7 abiertas de media, y una caída del 50 % desde un máximo (no desde el inicio) tiene una probabilidad del 22 % con ese mismo 1 %
Ver respuesta
Respuesta D. Porque suponen operaciones independientes y de una en una; el sistema tiene 16,7 abiertas de media, y una caída del 50 % desde un máximo (no desde el inicio) tiene una probabilidad del 22 % con ese mismo 1 %
La ruina «desde el capital inicial» se aleja a medida que se gana; la caída desde un máximo, no. Y el riesgo por operación multiplicado por las operaciones abiertas a la vez (un 8,4 % de media con el 0,5 %) es el que sufre la cuenta en un mal mes. La lección 4.2.2 dimensiona con la volatilidad de la cartera.
Ideas clave
- El tamaño por riesgo es capital × f / (distancia al stop × multiplicador × precio), en contratos enteros: con un millón de USD y un 0,5 %, el índice de la lección no admite ni un contrato; con 100.000 USD, solo el bono a 2 años.
- Un stop fijo en porcentaje cambia de significado con la volatilidad (se dispara del 0,25 % de las operaciones en bonos al 66 % en energía); un stop en múltiplos de ATR se adapta a cada mercado y a cada régimen.
- Los stops no crean ventaja: en el sistema de tendencia bajan el Sharpe (0,67 sin stop, 0,54 con 3 ATR) y acortan la cola (percentil 1 de −3,56 R a −2,44 R, caída máxima del 42,7 % al 31,6 %). Con entradas al azar, un stop parece ganar solo si los datos tienen tendencia; sin tendencia, pierde los costes.
- Un stop limita la pérdida típica, no la peor: con stop de 3 ATR al cierre, la peor operación pierde 3,7 R y el 16,8 % de las salidas por stop pasa de 1,5 R.
- Las fórmulas de ruina (jugador, difusión, Lundberg) coinciden con la simulación si se cumplen sus supuestos; la de difusión exagera con colas gruesas. Con un 1 % por operación, perder la mitad del capital inicial tiene un 2,7 % de probabilidad, pero una caída del 50 % desde un máximo, un 22 %.