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.

Fig. — Un sistema de tendencia en 24 futuros con cinco reglas de salida, neto de costes (2001-2025): Sharpe y percentil 1 del resultado por operación en múltiplos de R
Lecciones del nivel 4
  1. 4.1 · Teoría y optimización de carteras

    1. 4.1.1 Markowitz: frontera eficiente y restricciones reales
    2. 4.1.2 Estimación de covarianzas: shrinkage, modelos factoriales y denoising
    3. 4.1.3 Black-Litterman: del equilibrio del mercado a las visiones
    4. 4.1.4 Risk parity, mínima varianza, HRP y HERC
    5. 4.1.5 Optimización con CVaR, drawdown, rotación y costes
    6. 4.1.6 Modelos factoriales: CAPM, Fama-French, PCA y atribución
    7. 4.1.7 Rebalanceo y carteras de estrategias
    8. 4.1.8 Herramientas de carteras: Riskfolio-Lib, PyPortfolioOpt, skfolio y bt
  2. 4.2 · Gestión del riesgo y tamaño de posición

    1. 4.2.1 Riesgo por operación, stops y riesgo de ruina
    2. 4.2.2 Criterio de Kelly y volatility targeting: cuánto arriesgar
    3. 4.2.3 VaR, Expected Shortfall y backtesting del VaR
    4. 4.2.4 Stress testing y riesgos de correlación, liquidez, gap y contraparte
    5. 4.2.5 Control del drawdown y kill switch
    6. 4.2.6 Riesgo de derivados: griegas de cartera, margen y liquidaciones
  3. 4.3 · Microestructura y ejecución

    1. 4.3.1 Implementation shortfall, VWAP, TWAP y Almgren-Chriss
    2. 4.3.2 Order flow imbalance, señales de microestructura y latencia
  4. 4.4 · Brokers y APIs

    1. 4.4.1 Elegir bróker desde España o la UE: regulación, activos, costes y API
    2. 4.4.2 Interactive Brokers: TWS e IB Gateway, ib_async, contratos, datos y órdenes
    3. 4.4.3 Alpaca, Tradier, MetaTrader 5 con Python y OANDA v20
    4. 4.4.4 APIs de exchanges cripto: ccxt, Binance, Bybit, Kraken y Coinbase
    5. 4.4.5 Credenciales seguras: variables de entorno, gestores de secretos y permisos mínimos
  5. 4.5 · Sistema de trading en vivo

    1. 4.5.1 Arquitectura de un sistema en vivo: de los datos a la reconciliación
    2. 4.5.2 Gestión de órdenes: estados, fills parciales, cancelaciones y reconexiones
    3. 4.5.3 Paper trading, shadow trading y seguimiento del slippage
    4. 4.5.4 Programación de tareas y alertas: cron, APScheduler, Prefect, Telegram, Slack y email
    5. 4.5.5 Tolerancia a fallos: idempotencia, estado persistente y reconciliación al arrancar
    6. 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 ff 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:

N=⌊K⋅fd⋅M⋅P⌋N = \left\lfloor \frac{K \cdot f}{d \cdot M \cdot P} \right\rfloor

con KK el capital, dd la distancia al stop en tanto por uno del precio, MM el multiplicador del contrato y PP el precio. El suelo es importante: con futuros solo se negocian contratos enteros.

Señal de entradaDistancia al stopATRContratosOrden y stopResultado en R
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:

IN [01]python
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)
OUT [01]Contratos para arriesgar un 0,5 % por operación
              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.00

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

IN [02]python
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)
OUT [02]ATR de Wilder con TA-Lib y a mano, en barras diarias de KAPA
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: float64

TA-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:

IN [03]python
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)
OUT [03]Un stop fijo del 2 % y uno de 1,3 ATR, año a año
      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.05

Para 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.
IN [04]python
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:

IN [05]python
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)
OUT [05]Las cinco salidas, con costes
                    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.79

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

IN [06]python
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)
OUT [06]El stop fijo del 5 % por sectores
                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.25

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

IN [07]python
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)
OUT [07]Los mismos stops con entradas al azar y sin tendencia
                 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.36

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

Fig. — Distribución del resultado por operación, en múltiplos del riesgo planeado R, del sistema de tendencia sin stop y con un stop de 3 ATR (1 R) en 24 futuros (2001-2025); las operaciones de más de 10 R van en la última barra

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:

IN [08]python
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%}")
OUT [08]Las peores operaciones con stop: más de 1 R
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 p>12p > \tfrac12 de ganar una unidad y 1−p1-p de perderla, y un capital de UU unidades, la probabilidad de perderlo todo alguna vez es

Pruina=(1−pp)UP_{\text{ruina}} = \left(\frac{1-p}{p}\right)^{U}
IN [09]python
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}")
OUT [09]La ruina del jugador: la fórmula y una simulación
fórmula 0.1344, simulación 0.1333

Con 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 ff del capital actual, y entonces el capital se multiplica por (1+f Ri)(1 + f\,R_i) en cada operación. En logaritmos es un paseo aleatorio con pasos xi=ln⁡(1+fRi)x_i = \ln(1 + f R_i), y perder la mitad del capital es bajar D=ln⁡2D = \ln 2. Dos fórmulas:

  • Difusión: si los pasos fueran normales, con media μ\mu y varianza σ2\sigma^2, Pruina=e−2μD/σ2P_{\text{ruina}} = e^{-2\mu D/\sigma^2}.
  • Lundberg (Cramér-Lundberg): con s>0s > 0 la raíz de E[e−s x]=1\mathbb{E}[e^{-s\,x}] = 1, Pruina≤e−sDP_{\text{ruina}} \le e^{-s D}. 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):

IN [10]python
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)
OUT [10]Ruina con riesgo fijo por operación: fórmula y simulación
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.0000
Fig. — Probabilidad de ruina en 1.000 operaciones según el riesgo por operación, con los resultados en R del sistema con stop de 3 ATR remuestreados (operaciones independientes)

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

IN [11]python
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} %")
OUT [11]Las operaciones no van de una en una
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

  1. 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
IN [12]python
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)
OUT [12]Stops más estrechos y más anchos
       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.67

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

  1. 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
IN [13]python
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}")
OUT [13]El riesgo por operación para un 1 % de ruina
difusión : f = 0.66 %, simulada 0.0027
Lundberg : f = 0.82 %, simulada 0.0118

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

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

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

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

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

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

  5. 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 %.

Recursos

Datos