Lección 2.4.8 · 65 min
Modelado de fills: órdenes limitadas, cola, fills parciales, huecos y stops
Un backtest decide en cada barra si una orden se ha ejecutado y a qué precio. Con órdenes a mercado el error es pequeño; con limitadas y stops, la regla de ejecución puede convertir una estrategia perdedora en ganadora. Esta lección compara los modelos de fill con el libro orden a orden de NOVA y con las barras de 5 minutos de KAPA, y mide cuánto infla el resultado cada supuesto.
- Módulo
- 2.4 · Backtesting: motores y paradigmas
- Duración
- 65 min
Lecciones del nivel 2
2.1 · Estadística y econometría para trading
- 2.1.1 Contrastes de hipótesis, p-valores, bootstrap y regresión con errores robustos
- 2.1.2 Estacionariedad: ADF, KPSS, diferenciación y diferenciación fraccional
- 2.1.3 Modelos ARMA y ARIMA: autocorrelación simple (ACF) y parcial (PACF)
- 2.1.4 Modelos de volatilidad: ARCH, GARCH, EGARCH y GJR con la librería arch
- 2.1.5 Cointegración: Engle-Granger, Johansen y vida media de la reversión
- 2.1.6 Filtro de Kalman para hedge ratios dinámicos
- 2.1.7 Detección de regímenes: modelos de Markov ocultos y cambios estructurales
- 2.1.8 El problema de las pruebas múltiples: Bonferroni, FDR y el «factor zoo»
2.2 · Anatomía de un sistema de trading
- 2.2.1 Hipótesis de ventaja: riesgo, comportamiento, estructura e información
- 2.2.2 Universo, señal, filtro, entrada, salida, sizing y gestión del riesgo
- 2.2.3 Frecuencias: intradía, swing y posicional, y la capacidad de una estrategia
- 2.2.4 Diario de investigación y registro de experimentos contra el data snooping
2.3 · Familias de estrategias
- 2.3.1 Seguimiento de tendencia y momentum
- 2.3.2 Reversión a la media: RSI y Bollinger, reversión intradía y huecos
- 2.3.3 Pairs trading y arbitraje estadístico: distancia, cointegración y Kalman
- 2.3.4 Carry en divisas, futuros y bonos; estacionalidad y efectos calendario
- 2.3.5 Factores (value, calidad, baja volatilidad, tamaño) y estrategias de eventos
- 2.3.6 Introducción al market making: inventario, spread y selección adversa (Avellaneda-Stoikov)
- 2.3.7 Estrategias específicas de cripto: momentum, arbitraje de funding y basis
2.4 · Backtesting: motores y paradigmas
- 2.4.1 Backtest vectorizado desde cero: matrices, desfase, costes y límites
- 2.4.2 Backtest por eventos desde cero: bucle, cartera, órdenes y fills
- 2.4.3 vectorbt: barridos masivos de parámetros y simulación de carteras
- 2.4.4 backtesting.py y backtrader: dos motores didácticos y su estado
- 2.4.5 NautilusTrader: arquitectura, datos, estrategias y el mismo código en backtest y en real
- 2.4.6 LEAN/QuantConnect y Zipline Reloaded; bt para asignación de activos
- 2.4.7 Modelado de costes: comisiones, horquilla, impacto, financiación y préstamo de títulos
- 2.4.8 Modelado de fills: órdenes limitadas, cola, fills parciales, huecos y stops
2.5 · Sesgos y validación
- 2.5.1 Sesgos de backtest: anticipación, supervivencia, selección y narrativa
- 2.5.2 In-sample y out-of-sample: walk-forward anclado y móvil
- 2.5.3 Validación cruzada combinatoria purgada para estrategias
- 2.5.4 Deflated Sharpe Ratio y Probability of Backtest Overfitting (PBO)
- 2.5.5 Monte Carlo: remuestreo de trades, bootstrap de bloques y permutaciones de señales
- 2.5.6 Sensibilidad de parámetros: mesetas frente a picos, robustez entre activos y periodos
- 2.5.7 Datos sintéticos para pruebas de estrés: GBM, GARCH y bootstrapping
- 2.5.8 Checklist de «backtest honesto» antes de pasar a paper trading
2.6 · Métricas de rendimiento
El fill también es un supuesto
Un backtest no ejecuta nada: decide, con los datos que tiene, si una orden se habría ejecutado y a qué precio. Esa decisión es el modelo de fill. Con órdenes a mercado pequeñas el modelo importa poco: se ejecutan seguro, y el precio es el de la barra más el deslizamiento y los costes de la lección 2.4.7. Con órdenes limitadas y stops, el modelo decide la estrategia: una limitada que se da por ejecutada cuando no lo habría estado, o al precio de un stop que el mercado saltó, mejora el resultado en cada operación, siempre en el mismo sentido.
| Modelo | Regla para una compra limitada a | Datos que necesita | Sesgo |
|---|---|---|---|
| Por toque | Se ejecuta si el precio negocia a o menos | Barras u operaciones | Optimista: supone ser el primero de la cola |
| Por cruce | Solo si el precio negocia por debajo de | Barras u operaciones | Pesimista en cantidad; capta la selección adversa |
| Por cola | Cuando se agota la cantidad que había delante a | L3, o L2 más operaciones | El más realista; exige datos de libro |
| Probabilístico | Por toque, con probabilidad | Barras | Ajusta cuántas, no cuáles |
| Participación | Como mucho una fracción del volumen de cada barra | Volumen | Fills parciales; no mide el impacto |
Los datos de esta lección son sintéticos: la sesión del 2 de junio de 2025 de NOVA (nivel 1), simulada orden a orden,
con su libro L2, sus mensajes L3 y sus operaciones, y las barras de 5 minutos del ETF ficticio KAPA de 2022 a 2025
(nivel 2). Ninguno es de un mercado real.
Una orden en la cola: NOVA a las 10:00
Los mensajes L3 de NOVA cubren de 10:00 a 10:05 de Nueva York (14:00 a 14:05 UTC) y empiezan con una foto de todas las órdenes vivas en orden de prioridad (lección 1.1.3). Con ellos se puede seguir exactamente qué habría pasado con una orden propia que llegara a las 10:00 al final de la cola del mejor precio:
- delante tiene las órdenes que ya estaban a ese precio;
- la cola avanza cuando una de ellas se ejecuta, se cancela o reduce su tamaño;
- cuando no queda nadie delante, la siguiente ejecución a ese precio habría sido contra la orden propia.
import numpy as np
import pandas as pd
l3 = pd.read_parquet("nova_libro_l3.parquet")
foto, mensajes = l3.iloc[:358], l3.iloc[358:]
def simular_cola(lado, precio, tamano=500):
delante = foto[(foto["lado"] == lado) & (foto["precio"] == precio)]
vivas = dict(zip(delante["id_orden"], delante["tamano"]))
inicio = sum(vivas.values())
toque = fill = None
lleno = 0
m = mensajes[(mensajes["lado"] == lado)
& (mensajes["precio"] == precio)]
for msg in m.itertuples():
if msg.evento == "ejecucion" and toque is None:
toque = msg.ts # modelo por toque
if msg.id_orden in vivas: # avanza la cola
if msg.evento == "cancelacion":
vivas.pop(msg.id_orden)
elif msg.evento == "modificacion":
vivas[msg.id_orden] = msg.tamano
elif msg.evento == "ejecucion":
vivas[msg.id_orden] -= msg.tamano
if vivas[msg.id_orden] <= 0:
vivas.pop(msg.id_orden)
elif msg.evento == "ejecucion" and not vivas:
lleno += msg.tamano # contra la propia
if lleno >= tamano:
fill = msg.ts
break
return inicio, toque, fill
for lado, precio in [("bid", 53.08), ("ask", 53.09)]:
inicio, toque, fill = simular_cola(lado, precio)
seg = lambda t: None if t is None else round(
(t - pd.Timestamp("2025-06-02 14:00", tz="UTC"))
.total_seconds(), 1)
print(f"{lado} {precio}: {inicio} acciones delante · toque a",
f"{seg(toque)} s · fill por cola a {seg(fill)} s")bid 53.08: 5966 acciones delante · toque a 0.5 s · fill por cola a None s
ask 53.09: 2488 acciones delante · toque a 2.4 s · fill por cola a 27.0 sLas dos órdenes cuentan la historia entera de las limitadas.
- La compra a 53,08 se habría dado por ejecutada en el modelo por toque a los 0,5 segundos: una venta agresiva negoció a 53,08 casi enseguida. Pero delante había 5.966 acciones en ocho órdenes. La cola llegó a vaciarse (por ejecuciones y cancelaciones) y, justo entonces, el precio subió y nadie más vendió a 53,08: la orden propia no se ejecuta nunca en la ventana. El precio se fue al alza, que es justo cuando el comprador quería estar dentro.
- La venta a 53,09 se toca a los 2,4 segundos y se ejecuta de verdad a los 27 segundos, cuando se agotan las 2.488 acciones de delante. Para entonces, el precio ya subía.
cot = pd.read_parquet("nova_cotizaciones.parquet")
cot["medio"] = (cot["bid"] + cot["ask"]) / 2
ventana = cot[(cot["ts"] >= "2025-06-02 14:00")
& (cot["ts"] <= "2025-06-02 14:05")]
ventana.set_index("ts")["medio"].resample("1min").last()ts
2025-06-02 14:00:00+00:00 53.085
2025-06-02 14:01:00+00:00 53.140
2025-06-02 14:02:00+00:00 53.155
2025-06-02 14:03:00+00:00 53.155
2025-06-02 14:04:00+00:00 53.175
Freq: min, Name: medio, dtype: float64El punto medio pasa de 53,085 a 53,175 en cuatro minutos. El modelo por toque compra a 53,08 en el primer medio segundo y gana con la subida; el modelo por cola no compra (y se pierde la subida) y vende a 53,09 justo antes de que el precio suba casi nueve céntimos. Es la selección adversa de los creadores de mercado de la lección 2.3.6, vista desde una sola orden: a una limitada la ejecutan sobre todo cuando no conviene.
Fuente Mermaid
stateDiagram-v2 [*] --> EN_COLA EN_COLA --> EN_COLA: avanza la cola EN_COLA --> PRIMERA: cola vacía PRIMERA --> EJECUTADA: llega un agresor PRIMERA --> SIN_FILL: el precio se aleja EN_COLA --> SIN_FILL: el precio se aleja EJECUTADA --> [*] SIN_FILL --> [*]
Toque, cruce y cola en toda la sesión
Una orden no es una muestra. Los mensajes L3 solo cubren cinco minutos, pero las fotos L2 de cada 10 segundos y las
operaciones de toda la sesión permiten aproximar la cola en cualquier momento: la orden propia llega al final del mejor
bid de la foto, con la cantidad del nivel delante, y la cola avanza con el volumen negociado a ese precio en los centros
con libro. Es una aproximación conservadora (no ve las cancelaciones de delante, que adelantarían la orden) y es la
que se puede hacer con datos L2 y de operaciones; NautilusTrader hace algo parecido con queue_position=True.
Para cada una de las 2.251 fotos entre las 9:35 y las 15:50 se simula una compra limitada de 200 acciones en el mejor bid, válida 60 segundos, con tres modelos: toque (cualquier operación de la cinta consolidada a ese precio o menos), cruce (una operación por debajo) y cola (el volumen negociado a ese precio en los libros visibles supera la cantidad de delante más la propia, o el precio cruza).
l2 = pd.read_parquet("nova_libro_l2.parquet")
op = pd.read_parquet("nova_operaciones.parquet")
con_libro = op[op["centro"].isin(["PRINCIPAL", "MTF_A", "MTF_B"])
& (op["condicion"] == "regular")].reset_index(drop=True)
mejor_bid = l2[(l2["nivel"] == 1) & (l2["lado"] == "bid")]
mejor_bid = mejor_bid[mejor_bid["ts"].between(
"2025-06-02 13:35", "2025-06-02 19:50")]
H, TAM = pd.Timedelta(seconds=60), 200
def tramo(df, desde, hasta):
i = df["ts"].searchsorted(desde, side="right")
j = df["ts"].searchsorted(hasta, side="right")
return df.iloc[i:j]
filas = []
for foto_l2 in mejor_bid.itertuples():
p, delante = foto_l2.precio, foto_l2.tamano
cinta = tramo(op, foto_l2.ts, foto_l2.ts + H)
libro = tramo(con_libro, foto_l2.ts, foto_l2.ts + H)
toque = cinta.loc[cinta["precio"] <= p, "ts"].min()
cruce = libro.loc[libro["precio"] < p, "ts"].min()
en_p = libro["tamano"].where(libro["precio"] == p, 0).cumsum()
cola = libro.loc[en_p >= delante + TAM, "ts"].min()
cola = min(t for t in [cola, cruce] if pd.notna(t)) if (
pd.notna(cola) or pd.notna(cruce)) else pd.NaT
filas.append({"ts": foto_l2.ts, "precio": p, "delante": delante,
"toque": toque, "cruce": cruce, "cola": cola})
fills = pd.DataFrame(filas)
print(len(fills), "órdenes · cantidad delante (mediana):",
int(fills["delante"].median()), "acciones")
fills[["toque", "cruce", "cola"]].notna().mean().round(3)2251 órdenes · cantidad delante (mediana): 6900 acciones
toque 0.868
cruce 0.413
cola 0.444
dtype: float64Con el modelo por toque, el 87 % de las órdenes se ejecuta en un minuto; con el de cola, el 44 %, y con el de cruce, el 41 %. La diferencia es casi entera la cola: la mediana de lo que hay delante en el mejor bid son 6.900 acciones, treinta y tantas veces la orden propia, y en un minuto no siempre se negocian.
La selección adversa: a quién y cuándo ejecutan
El porcentaje de ejecuciones es la mitad del problema. La otra mitad es cuáles se ejecutan. El markout mide la calidad de una ejecución: dónde está el punto medio un tiempo después, comparado con el precio pagado. Para una compra, un markout negativo significa que el precio siguió cayendo después de comprar.
medio = cot.set_index("ts")["medio"]
ask = cot.set_index("ts")["ask"]
def a_los(serie, ts, segundos):
i = serie.index.searchsorted(
ts + pd.Timedelta(seconds=segundos)) - 1
return serie.iloc[i].to_numpy()
markout = {}
for modelo in ["toque", "cruce", "cola"]:
f = fills[fills[modelo].notna()]
fila = {}
for s in [5, 30, 60, 300]:
m = a_los(medio, f[modelo], s)
fila[f"{s}s"] = (1e4 * (m - f["precio"]) / f["precio"]).mean()
a = a_los(ask, f[modelo], 60)
fila["ida_vuelta_60s"] = (1e4 * (a - f["precio"])
/ f["precio"]).mean()
fila["p_fill"] = len(f) / len(fills)
markout[modelo] = fila
pd.DataFrame(markout).T.round(2) 5s 30s 60s 300s ida_vuelta_60s p_fill
toque 0.80 0.11 -0.57 -2.74 0.39 0.87
cruce -1.14 -3.32 -5.95 -13.20 -4.98 0.41
cola -0.94 -2.98 -5.38 -12.27 -4.40 0.44En los tres modelos el markout empeora con el tiempo: las compras pasivas se llenan cuando el flujo es vendedor, y el flujo vendedor sigue. Pero el modelo por toque está mucho más arriba en todos los horizontes: incluye las ejecuciones «gratis» de los toques en que el precio rebota, que en la cola real no llegan a ejecutarse.
La columna ida_vuelta_60s es una estrategia de creador de mercado mínima: comprar en el bid y vender en el ask 60
segundos después, suponiendo (con generosidad, en los tres casos por igual) que la salida se ejecuta. Con el modelo
por toque gana 0,39 pb por operación; con el de cola, pierde 4,40. El mismo código, los mismos datos y la misma
salida: solo cambia la regla de entrada, y la estrategia pasa de ganar a perder. La figura de la cabecera lo resume en
dos ejes, la probabilidad de ejecución y el resultado de cada ejecución.
¿Arregla el sesgo una probabilidad de fill, como el prob_fill_on_limit de NautilusTrader (lección 2.4.5)? Se puede
comprobar: ejecutar al azar el 44 % de los toques, la misma proporción que da el modelo por cola (sobre todas las
órdenes, el 38,9 %).
rng = np.random.default_rng(2028)
p_cola = fills["cola"].notna().mean()
azar = fills[fills["toque"].notna()
& (rng.random(len(fills)) < p_cola)]
a = a_los(ask, azar["toque"], 60)
m = a_los(medio, azar["toque"], 60)
print(f"fills: {len(azar) / len(fills):.1%}")
print(f"markout 60 s: {(1e4 * (m / azar['precio'] - 1)).mean():.2f} pb")
print(f"ida y vuelta: {(1e4 * (a / azar['precio'] - 1)).mean():.2f} pb")fills: 38.9%
markout 60 s: -0.50 pb
ida y vuelta: 0.47 pbNo lo arregla: el ida y vuelta sigue en +0,47 pb, frente a los −4,40 del modelo por cola. La probabilidad ajusta cuántas órdenes se ejecutan, pero las elige al azar entre los toques, que son las buenas y las malas mezcladas. Las reales son las malas. Para una estrategia que vive de limitadas, la probabilidad de fill aleatoria es mejor que nada para el número de operaciones y casi nada para el resultado.
Fills parciales: participar en el volumen
Una orden grande no se ejecuta en una barra. La forma habitual de modelarlo es un límite de participación: en cada
barra se ejecuta como mucho una fracción del volumen negociado (aquí, el 10 %) y el resto espera a la siguiente. Con
las barras de KAPA, para cada una de las 1.003 sesiones, una compra que empieza a las 10:00 y se ejecuta al cierre de
cada barra:
barras = pd.read_parquet("indice2_5min.parquet")
ny = barras["ts"].dt.tz_convert("America/New_York")
barras["dia"], barras["hora"] = ny.dt.date, ny.dt.strftime("%H:%M")
def participar(sesion, total, part=0.10, inicio="10:00"):
s = sesion[sesion["hora"] >= inicio]
llegada = s["apertura"].iloc[0] # precio de decisión
resto, gasto, n = total, 0.0, 0
for b in s.itertuples():
q = min(resto, int(part * b.volumen))
gasto, resto, n = gasto + q * b.cierre, resto - q, n + 1
if resto == 0:
break
medio = gasto / (total - resto)
return n, resto > 0, 1e4 * (medio / llegada - 1)
tabla = {}
for total in [50_000, 500_000, 2_000_000, 10_000_000]:
r = pd.DataFrame([participar(g, total)
for _, g in barras.groupby("dia")],
columns=["barras", "incompleta", "desl_pb"])
tabla[f"{total:,}"] = {
"barras_mediana": r["barras"].median(),
"incompleta_pct": 100 * r["incompleta"].mean(),
"desl_medio_pb": r["desl_pb"].mean(),
"desl_desv_pb": r["desl_pb"].std()}
pd.DataFrame(tabla).T.round(2) barras_mediana incompleta_pct desl_medio_pb desl_desv_pb
50,000 1.0 0.0 0.59 12.62
500,000 6.0 0.0 0.75 18.21
2,000,000 31.0 0.0 1.42 30.36
10,000,000 72.0 98.4 3.23 48.43Con 50.000 acciones la orden cabe en una barra; con dos millones tarda una mediana de 31 barras, dos horas y media,
y con diez millones no se completa en el día en el 98 % de las sesiones. El deslizamiento medio frente al precio de
llegada es pequeño y positivo (KAPA sube en la muestra: comprar más tarde sale algo más caro), pero su
desviación típica crece con el tiempo de ejecución, de 13 a 48 pb. Repartir una orden reduce el impacto, pero la
expone al mercado mientras se ejecuta: el coste se convierte en riesgo.
Un aviso sobre estos datos: los precios de KAPA no reaccionan a la orden simulada, como no reacciona ningún histórico.
El modelo de participación decide cuánto y cuándo se ejecuta; el impacto que esas compras habrían causado hay que
sumarlo aparte, con la ley de la raíz cuadrada de la lección 2.4.7. Y si una estrategia solo funciona con órdenes que
necesitan días para ejecutarse, el problema no es el modelo de fill: es su capacidad (lección 2.2.3).
Stops y huecos
Un stop es una orden de mercado dormida (lección 1.1.4): cuando el precio llega al disparo, se ejecuta al primer precio disponible. Dentro de una sesión continua ese precio está cerca del disparo. Entre sesiones no: si el precio abre por debajo de un stop de venta, la primera operación es la apertura, y la orden se ejecuta ahí. El modelo optimista, que ejecuta siempre al precio del stop, regala la diferencia.
Con KAPA: cada día se compra al cierre y se protege la posición con un stop de venta a un porcentaje del cierre. La
ejecución honrada es la apertura si la sesión abre por debajo del stop, y el stop en otro caso (en estas barras la
apertura de cada barra es el cierre de la anterior, así que dentro de la sesión no hay huecos):
diario = barras.groupby("dia").agg(
apertura=("apertura", "first"), minimo=("minimo", "min"),
cierre=("cierre", "last"))
diario["cierre_previo"] = diario["cierre"].shift()
diario = diario.dropna()
filas, huecos_1pct = [], None
for pct in [0.005, 0.01, 0.015, 0.02]:
stop = (diario["cierre_previo"] * (1 - pct)).round(2)
dispara = diario["minimo"] <= stop
hueco = diario["apertura"] <= stop
ejecucion = diario["apertura"].where(hueco, stop)
desl = 1e4 * (stop - ejecucion) / stop
filas.append({"stop_pct": 100 * pct, "disparos": dispara.sum(),
"en_hueco": hueco.sum(),
"desl_medio_hueco_pb": desl[hueco].mean(),
"desl_max_pb": desl.max(),
"coste_total_pb": desl.sum()})
if pct == 0.01:
huecos_1pct = diario[hueco].assign(stop=stop, desl_pb=desl)
pd.DataFrame(filas).set_index("stop_pct").round(1) disparos en_hueco desl_medio_hueco_pb desl_max_pb coste_total_pb
stop_pct
0.5 565 122 27.9 138.1 3405.5
1.0 277 18 27.9 88.5 502.2
1.5 127 4 19.1 38.0 76.6
2.0 52 0 NaN 0.0 0.0Con un stop al 1 %, 18 de los 277 disparos llegan en un hueco, y en esos el modelo optimista se ahorra una media de 28 pb por operación; en el peor (18 de mayo de 2022) son 88 pb. Cuanto más cerca está el stop, más disparos hay en hueco. Un stop al 2 % no se salta nunca en estos datos, pero la cola de los huecos de un ETF sobre un índice es corta; en una acción individual, el día después de unos resultados, el hueco puede ser de varias veces el stop (lección 1.1.4, el hueco del 5,1 % de NOVA).
huecos_1pct.sort_values("desl_pb", ascending=False)[
["cierre_previo", "stop", "apertura", "desl_pb"]].head(4).round(2) cierre_previo stop apertura desl_pb
dia
2022-05-18 243.12 240.69 238.56 88.50
2023-12-22 366.15 362.49 360.05 67.31
2024-02-29 372.71 368.98 366.62 63.96
2025-03-27 365.66 362.00 359.89 58.29Hay una segunda trampa con stops sobre barras: si en la misma barra caen el stop y el objetivo de beneficio, la barra
no dice cuál se tocó primero. La regla prudente es suponer que el stop (la pérdida) fue primero. NautilusTrader recorre
cada barra en el orden apertura, máximo, mínimo, cierre, o con bar_adaptive_high_low_ordering=True, primero el extremo
más cercano a la apertura (lección 2.4.5): las dos son convenciones, no información.
Qué modelo usar
Fuente Mermaid
flowchart TB
O{Tipo de orden} --> M[Mercado]
O --> L[Limitada]
O --> S[Stop]
M --> MP[Precio y costes]
L --> D{Datos de libro}
D -- L3 --> CE[Cola exacta]
D -- L2 y cinta --> CA[Cola aproximada]
D -- barras --> CR[Por cruce]
S --> HA[Apertura en hueco]| Situación | Modelo razonable | En NautilusTrader 1.231 |
|---|---|---|
| Órdenes a mercado pequeñas | Precio de la barra (o la apertura siguiente) más deslizamiento y costes | FillModel(prob_slippage=…), FeeModel |
| Órdenes a mercado grandes | Participación en el volumen más impacto | Libros sintéticos (ThreeTierFillModel…) o un modelo propio |
| Limitadas con datos L3 | Cola exacta | book_type=BookType.L3_MBO y datos de libro |
| Limitadas con L2 y operaciones | Cola aproximada | trade_execution=True, queue_position=True |
| Limitadas con solo barras | Por cruce (y aceptar que es pesimista en cantidad) | Sin equivalente directo: prob_fill_on_limit ajusta la cantidad, no la selección |
| Stops | Apertura si hay hueco; el stop primero si hay duda | order_factory.stop_market con barras, bar_adaptive_high_low_ordering |
La regla general es elegir el modelo pesimista que los datos permitan y comprobar cuánto cambia el resultado con el optimista. Si la estrategia solo funciona con el optimista, no funciona. En el registro de experimentos (lección 2.2.4), el modelo de fill va junto a los costes: dos backtests con distinto modelo son dos experimentos distintos.
Ejercicios
- Repite el estudio de los tres modelos para ventas limitadas en el mejor ask (
lado == "ask", ejecución con operaciones a ese precio o más). ¿Se mantiene la conclusión?
Solución
mejor_ask = l2[(l2["nivel"] == 1) & (l2["lado"] == "ask")]
mejor_ask = mejor_ask[mejor_ask["ts"].between(
"2025-06-02 13:35", "2025-06-02 19:50")]
bid = cot.set_index("ts")["bid"]
res = {"toque": [], "cola": []}
for foto_l2 in mejor_ask.itertuples():
p, delante = foto_l2.precio, foto_l2.tamano
cinta = tramo(op, foto_l2.ts, foto_l2.ts + H)
libro = tramo(con_libro, foto_l2.ts, foto_l2.ts + H)
toque = cinta.loc[cinta["precio"] >= p, "ts"].min()
cruce = libro.loc[libro["precio"] > p, "ts"].min()
en_p = libro["tamano"].where(libro["precio"] == p, 0).cumsum()
cola = libro.loc[en_p >= delante + TAM, "ts"].min()
cola = min([t for t in [cola, cruce] if pd.notna(t)],
default=pd.NaT)
for modelo, t in [("toque", toque), ("cola", cola)]:
if pd.notna(t):
b = a_los(bid, pd.Series([t]), 60)[0]
# vende en el ask, recompra en el bid
res[modelo].append(1e4 * (p - b) / p)
for modelo, v in res.items():
print(f"{modelo:6} p_fill {len(v) / len(mejor_ask):.1%}",
f"· ida y vuelta 60 s {np.mean(v):.2f} pb")toque p_fill 84.9% · ida y vuelta 60 s 0.88 pb
cola p_fill 43.1% · ida y vuelta 60 s -4.20 pbLa conclusión se mantiene en el otro lado del libro: el modelo por toque ejecuta casi todas las ventas y les da un resultado mucho mejor que el de cola, que solo llena las que el precio atraviesa o que agotan la cola, en los momentos de flujo comprador. La selección adversa es simétrica.
- En el estudio de los stops, calcula el coste total del modelo optimista en USD para una posición de 1.000 acciones con el stop al 1 %, y compáralo con lo que costaría una comisión de 1 USD por orden en los 277 disparos.
Solución
desl_usd = 1000 * (huecos_1pct["stop"] - huecos_1pct["apertura"])
print(f"deslizamiento escondido: {desl_usd.sum():,.0f} USD",
f"en {len(huecos_1pct)} huecos")
print(f"comisiones de 277 ventas a 1 USD: {277 * 1:,} USD")deslizamiento escondido: 15,940 USD en 18 huecos
comisiones de 277 ventas a 1 USD: 277 USDEl deslizamiento que esconde el modelo optimista en solo 18 huecos, 15.940 USD, es más de cincuenta veces lo que costarían las comisiones de todas las ventas (277 USD). Es la diferencia entre modelar un coste que se ve en la tarifa y uno que solo aparece cuando el mercado salta.
- Un compañero presenta una estrategia de reversión a la media en barras diarias que compra con limitadas un 1 % por debajo del cierre y vende al cierre siguiente, con ejecución «si el mínimo del día toca el precio». ¿Qué tres cambios le pedirías en el backtest antes de discutir el resultado?
Solución
Primero, el modelo de fill: ejecutar solo si el mínimo queda al menos un tick por debajo del límite (cruce), y comparar el resultado con el del toque; si la diferencia es grande, la estrategia vive de los toques que la cola real no habría ejecutado. Segundo, los huecos: si la sesión abre por debajo del límite, la compra se ejecuta a la apertura (mejor precio que el límite, pero en el peor momento), y eso hay que tratarlo igual que un stop. Tercero, el tamaño: con qué fracción del volumen opera y qué impacto tendría, porque una compra limitada grande no se llena entera al tocar el precio. Y, si es posible, contrastarlo con datos L2 o de operaciones en una muestra para medir la selección adversa, como en la sesión de NOVA.
Respuesta plegada en cada pregunta
Autoevaluación
Un backtest con barras da por ejecutada una compra limitada a 53,08 en cuanto el mínimo de la barra toca 53,08. ¿Qué supone sin decirlo?
- AQue la orden era la primera de la cola en ese precio
- BQue el precio atraviesa la orden
- CQue hay impacto de mercado
- DNada: es el modelo exacto
Ver respuesta
Respuesta A. Que la orden era la primera de la cola en ese precio
Que el precio llegue a tu nivel no basta: antes se ejecutan las órdenes que estaban delante en la cola. En la sesión de NOVA, la orden de compra a 53,08 de las 10:00 se habría «ejecutado» por toque en medio segundo; con la cola real, delante había 5.966 acciones y el precio se fue sin llegar a ejecutarla.
En la sesión de NOVA, una compra limitada en el bid se ejecuta en 60 segundos el 87 % de las veces por toque y el 44 % por cola. ¿Qué pasa con el resultado medio de cada ejecución?
- AEs el mismo en los dos modelos
- BEs peor en el modelo por cola: las ejecuciones que solo ocurren cuando la cola se agota son las de los momentos en que el precio sigue cayendo
- CEs mejor en el modelo por cola
- DDepende de la comisión
Ver respuesta
Respuesta B. Es peor en el modelo por cola: las ejecuciones que solo ocurren cuando la cola se agota son las de los momentos en que el precio sigue cayendo
Es la selección adversa: una compra pasiva se llena cuando muchos venden. A 60 segundos, el punto medio está 0,57 pb por debajo del precio de compra en el modelo por toque y 5,38 pb en el modelo por cola.
¿Por qué no basta con ejecutar al azar un 44 % de los toques (como hace
prob_fill_on_limit=0.44en NautilusTrader con barras) para corregir el sesgo del modelo por toque?- APorque 44 % es poco
- BPorque reduce el número de ejecuciones, pero elige al azar cuáles: conserva la calidad optimista de las del toque, que no tienen la selección adversa de las reales
- CPorque NautilusTrader no admite ese parámetro
- DSí basta
Ver respuesta
Respuesta B. Porque reduce el número de ejecuciones, pero elige al azar cuáles: conserva la calidad optimista de las del toque, que no tienen la selección adversa de las reales
El problema del toque no es solo cuántas órdenes se llenan, sino cuáles. Las que de verdad se ejecutan son las peores. En NOVA, el 44 % aleatorio de los toques conserva un resultado de ida y vuelta positivo, mientras que el modelo por cola lo da claramente negativo.
Un stop de venta a 368,98 en
KAPAse dispara el 29 de febrero de 2024 porque la sesión abre a 366,62. ¿A qué precio es honrado ejecutarlo en el backtest?- AA 368,98, el precio del stop
- BA la apertura, 366,62, porque el precio saltó por encima del stop sin negociarse en él
- CAl mínimo del día
- DAl cierre del día
Ver respuesta
Respuesta B. A la apertura, 366,62, porque el precio saltó por encima del stop sin negociarse en él
Un stop es una orden de mercado dormida: cuando se dispara se ejecuta al primer precio disponible. Si la sesión abre por debajo del stop, ese precio es la apertura. Suponerlo a 368,98 ahorra 64 pb que no existen.
Una compra de 2 millones de acciones de
KAPAcon un límite de participación del 10 % del volumen tarda una mediana de 31 barras. ¿Qué cambia frente a ejecutarla de golpe?- ANada
- BSe reduce el impacto, pero el precio medio queda expuesto a lo que haga el mercado durante dos horas y media: la desviación típica del deslizamiento frente al precio de llegada sube a 30 pb
- CSe garantiza el precio de llegada
- DAumenta la comisión
Ver respuesta
Respuesta B. Se reduce el impacto, pero el precio medio queda expuesto a lo que haga el mercado durante dos horas y media: la desviación típica del deslizamiento frente al precio de llegada sube a 30 pb
Repartir la orden convierte una parte del coste (el impacto) en riesgo de ejecución. En estos datos sintéticos los precios no reaccionan a la orden, así que la simulación solo mide ese riesgo; el impacto se añade con el modelo de la lección 2.4.7.
Ideas clave
- El modelo de fill es un supuesto del backtest, como el de costes, y hay que declararlo: mercado al precio de la barra, limitada por toque, por cruce, por cola o probabilística.
- Una orden limitada se ejecuta cuando se agota la cola que tiene delante; sin datos L3 se aproxima con el tamaño del nivel y el volumen negociado en él.
- Las limitadas sufren selección adversa: se llenan cuando el precio va en contra. En NOVA, el modelo por toque convierte un ida y vuelta pasivo de −4,4 pb en uno de +0,4 pb.
- Una probabilidad de fill aleatoria reduce cuántas órdenes se llenan, pero no cuáles: no corrige la selección adversa.
- Los fills parciales con límite de participación reducen el impacto y añaden riesgo de ejecución; los stops se ejecutan a la apertura cuando el precio salta por encima de ellos.