⚠ El trading implica un riesgo significativo de pérdida. Todo el contenido de este sitio es únicamente educativo y no constituye asesoramiento financiero.
Lección 8 de 13 · Gratis

Un backtest basado en eventos: un motor en bucle con stops por ATR, dimensionamiento de posiciones y una lista de operaciones en múltiplos R

Construye en Python un bucle de backtest barra a barra que dimensiona las posiciones según el riesgo medido con el ATR, respeta los stops incluidos los gaps y registra cada operación en múltiplos R.

Autor: Moustafa HijaziContenido revisado: 2026-10-307 min de lecturaCurso de autoaprendizaje

Qué aprenderás

  • Por qué un backtest vectorizado no puede modelar stops, ejecuciones parciales ni dimensionamiento basado en el riesgo, y cuándo se necesita un motor en bucle.
  • El estado que un motor basado en eventos debe arrastrar de una barra a otra: efectivo, posición, precio de entrada, nivel de stop y operación abierta.
  • Cómo dimensionar cada posición a partir del riesgo de la cuenta y del ATR, y cómo registrar el resultado como múltiplo R.
  • Cómo se tratan con honestidad en el código los gaps que saltan un stop.

Explicación

El backtest vectorizado de la lección 7 calcula la posición de cada barra a partir de toda la serie de una vez. Es rápido, pero no puede expresar reglas que dependen de la trayectoria: un stop 2 ATR por debajo de la entrada depende de dónde entraste, y el tamaño depende del valor de la cuenta en ese momento. Un backtest basado en eventos recorre las barras de una en una, usa solo la información disponible hasta esa barra y actualiza su estado exactamente como lo haría un trader.

El estado y el orden de los eventos

El motor mantiene un pequeño conjunto de variables: cash, position (número de acciones, 0 cuando está fuera), entry, stop, risk_per_share y una lista trades. En cada barra, el orden de las comprobaciones importa y debe fijarse por escrito una vez y respetarse:

  1. Primero las salidas. Si hay una posición abierta y el mínimo de hoy está en el stop o por debajo, se sale. El precio de ejecución es el stop, salvo que la apertura ya esté por debajo del stop (un gap), en cuyo caso la ejecución es la apertura. Aquí es donde una pérdida planificada de 1R puede convertirse en 1.4R o más.
  2. Actualizar los stops dinámicos con el cierre y el ATR de hoy.
  3. Las entradas al final, y solo en la apertura de la barra siguiente, porque una señal calculada con el cierre de hoy no puede ejecutarse al cierre de hoy. En código, esto significa que una señal en la barra t activa un indicador, y la ejecución se produce en open[t+1].

Dimensionamiento de posiciones en el bucle

El dimensionamiento basado en el riesgo se enseñó en Gestión del riesgo y de las operaciones (lección 2) y en Trading sistemático y backtesting (lección 9). En código es una sola línea: size = floor(equity * risk_pct / (entry - stop)), donde el denominador es el riesgo por acción (2 * ATR aquí). Añade un límite como size * entry <= 0.25 * equity para que un ATR minúsculo no cree una posición que la cuenta no pueda sostener.

Registrar las operaciones en R

Cada operación cerrada se añade como un diccionario con fechas, precios, tamaño, beneficio en euros y el múltiplo R = beneficio / (tamaño × riesgo por acción). pd.DataFrame(trades) convierte la lista en una tabla de operaciones para las funciones de métricas de la lección 9 y para el cálculo de la esperanza matemática de Gestión del riesgo y de las operaciones (lección 1).

Un motor mínimo

  • def run(df, risk_pct=0.01, atr_mult=2.0, start_cash=20_000):
  • cash, pos, entry, stop, rps = start_cash, 0, None, None, None
  • trades, equity, pending = [], [], False
  • for i in range(1, len(df)):
  • o, h, l, cl, atr = df.open[i], df.high[i], df.low[i], df.close[i], df.atr[i]
  • if pos > 0 and l <= stop: # salida: stop o gap
  • fill = min(o, stop)
  • pnl = pos * (fill - entry)
  • trades.append(dict(exit=df.index[i], pnl=pnl, R=pnl / (pos * rps)))
  • cash += pos * fill; pos = 0
  • if pending and pos == 0: # entrada en la apertura de esta barra
  • entry, rps = o, atr_mult * df.atr[i-1]
  • pos = int(cash * risk_pct // rps)
  • stop = entry - rps; cash -= pos * entry; pending = False
  • if pos == 0 and df.signal[i]:
  • pending = True # operar en la siguiente apertura
  • equity.append(cash + pos * cl)
  • return pd.DataFrame(trades), pd.Series(equity, index=df.index[1:])

Dos detalles: el ATR usado para dimensionar es df.atr[i-1], el valor conocido la tarde anterior a la entrada, y el stop se comprueba contra el mínimo, no contra el cierre. Un stop dinámico es una línea más, stop = max(stop, cl - rps), después del bloque de salida. Los costes pueden restarse de pnl como en la lección 7; aquí se omiten para mayor claridad.

Ejemplo práctico

Ejemplo hipotético: capital inicial de €20,000, riesgo del 1 % por operación (€200), stop a 2 × ATR. Los precios y el ATR son números redondos inventados; se excluyen comisiones, spread e impuestos.

  1. Operación 1. Señal el lunes; apertura del martes en €50.00, ATR anterior de €2.50. Riesgo por acción = 2 × 2.50 = €5.00; stop = €45.00. Tamaño = floor(200 / 5) = 40 acciones (€2,000 de exposición, el 10 % del capital, dentro de un límite del 25 %). Salida posterior por señal en €58.00: beneficio = 40 × 8 = €320 = 320 / 200 = +1.6R.
  2. Operación 2. Misma configuración, entrada en €50.00, stop en €45.00, 40 acciones. Noticia nocturna: la acción abre en €43.00. El motor ejecuta en min(apertura, stop) = €43.00. Pérdida = 40 × 7 = €280 = −1.4R, no el −1.0R planificado. Una prueba vectorizada habría mostrado −1.0R o nada en absoluto.
  3. Operación 3. Entrada en €50.00, stop dinámico; el stop ha subido a €47.50 cuando el mínimo lo toca. Pérdida = 40 × 2.50 = €100 = −0.5R.
  4. Lista de operaciones. Múltiplos R +1.6, −1.4, −0.5: suma −0.3R, media −0.1R. Tres operaciones no demuestran nada, pero la tabla tiene la forma correcta para cientos.

Un segundo instrumento a €120 con un ATR de €6 da un tamaño de floor(200 / 12) = 16 acciones: el mismo riesgo de €200, menos acciones porque la volatilidad es mayor.

Error frecuente

Ejecutar al precio del stop cuando la barra lo saltó con un gapUn código como if l <= stop: fill = stop supone en silencio que el mercado cotizó en tu stop. En un gap, no fue así. Usa siempre min(open, stop) para las salidas de posiciones largas (y max(open, stop) para las cortas). La misma lógica se aplica a las entradas con una orden stop: un buy-stop (stop de compra) en €52 se ejecuta en la apertura si la apertura es €54.

Actividad práctica

En papel, sigue el motor para una operación: capital de €15,000, riesgo del 1.5 %, ATR de la tarde anterior a la entrada de €1.80, multiplicador del stop de 2.5, entrada en la siguiente apertura a €36.00. (a) Calcula el riesgo por acción, el nivel del stop y el tamaño de la posición (redondeado hacia abajo). (b) El día de la salida abre en €37.00, baja hasta un mínimo de €31.00 y cierra en €32.00. ¿Cuál es la ejecución, el beneficio en euros y el múltiplo R? (c) ¿Sería distinto el resultado si comprobaras el stop contra el cierre en lugar del mínimo?

Mostrar solución

(a) Riesgo por operación = 0.015 × 15,000 = €225. Riesgo por acción = 2.5 × 1.80 = €4.50. Stop = 36.00 − 4.50 = €31.50. Tamaño = floor(225 / 4.50) = 50 acciones.

(b) La apertura de €37.00 está por encima del stop, así que no hay gap; el mínimo de €31.00 está en €31.50 o por debajo, así que la ejecución es min(37.00, 31.50) = €31.50. Pérdida = 50 × (31.50 − 36.00) = −€225 = −1.0R.

(c) Comprobar el cierre (€32.00) también activaría aquí la salida, pero en €32.00 en lugar de €31.50, de modo que la pérdida sería de €200 (−0.89R) y parecería mejor que la realidad. En otras barras, el mínimo puede tocar el stop mientras el cierre se recupera por encima; una comprobación basada en el cierre pasaría entonces por alto por completo una salida real.

Limitaciones: con barras diarias no se conoce el orden intradía del máximo y el mínimo; cuando tanto el stop como un objetivo quedan dentro de una misma barra, supón el peor resultado.

Ideas clave

  • Usa un motor en bucle cuando las reglas dependen de la trayectoria: stops, stops dinámicos, dimensionamiento basado en el riesgo y salidas parciales.
  • Fija el orden de los eventos en cada barra: salidas, actualización de stops y, después, entradas en la siguiente apertura.
  • Dimensiona como capital × % de riesgo dividido entre el riesgo por acción, con un límite de exposición; guarda cada operación con su múltiplo R.
  • Ejecuta las salidas con gap en la apertura, no en el stop; de ahí proceden las pérdidas superiores a 1R.

Siguiente paso

La lección 9 convierte la curva de capital y la lista de operaciones que ahora produces en funciones de rendimiento reutilizables y probadas: CAGR, drawdown, Sharpe, Sortino y factor de beneficio.

Autoevaluación

Responde a las preguntas y luego comprueba tus respuestas. Puedes volver a intentarlo tantas veces como quieras.

1. Capital de €20,000, riesgo del 1 % por operación, stop a 2 × ATR con ATR = €2.50, entrada en €50. El día de la salida la acción abre en €43 y el stop está en €45. ¿Qué pérdida registra un motor honesto?

2. ¿Por qué usa el motor df.atr[i-1] y no df.atr[i] al dimensionar una entrada en la apertura de la barra i?

3. ¿Qué conjunto de reglas requiere realmente un bucle basado en eventos en lugar del enfoque vectorizado de la lección 7?

Autoevaluación automática para tu propio repaso. Tus respuestas se comprueban en tu navegador y no las vemos ni evaluamos. No es un examen ni una titulación.

Tu progreso

«Completada» solo significa que marcaste la lección como terminada. No es una prueba de tu comprensión. El progreso y las notas se guardan solo en este navegador y en este dispositivo, no en una cuenta.