← Volver a los artículos
July 8, 2026
5 min de lectura

Demostrando la ausencia de look-ahead en backtests multi-timeframe: perturbar el futuro para probar que el pasado no puede verlo

Demostrando la ausencia de look-ahead en backtests multi-timeframe: perturbar el futuro para probar que el pasado no puede verlo
#algotrading
#backtest
#sesgo look-ahead
#multi-timeframe
#filtración de datos
#validación
🎯
Part 2 of 9 · Collection
Backtesting Without Fooling Yourself

Parte de la serie "Backtests sin ilusiones".

El artículo anterior terminó con un diagnóstico: la prueba de desplazamiento de una vela. Mueve cada ejecución una vela más tarde; si el rendimiento se derrumba, estabas operando en el pasado. Esa prueba es suficiente para una estrategia de una sola temporalidad, donde el look-ahead se esconde en la ejecución — decidiste en la vela i y ejecutaste en la vela i.

Las estrategias multi-timeframe tienen un segundo lugar, más silencioso, donde pueden filtrar información, y la prueba de desplazamiento no llega hasta ahí. Cuando una decisión en una vela de 1 minuto consulta una tendencia de 1 hora, la filtración ya no está en la ejecución — está en el indicador, calculado a partir de una vela de temporalidad superior que aún no ha terminado de formarse. Este artículo trata sobre esa filtración, y sobre cómo demostrar — mecánicamente, no mirando fijamente el código — que tu motor no la tiene.

La afirmación que hacemos al final es contundente: nuestro motor multi-TF pasó 25 de 25 comprobaciones de paridad y filtración sobre 86.400 velas de datos reales de ETHUSDT en 1m, incluyendo una sonda de futuro desplazado que perturba cada vela futura y confirma que ni una sola decisión pasada se mueve. Y como el motor es bit a bit idéntico al núcleo canónico del bot en vivo, la prueba es una propiedad del sistema en vivo, no solo del backtest.

El campo minado del multi-timeframe

Una decisión de 1 minuto que alcanza simultáneamente tres temporalidades, con la vela de temporalidad más alta aún en formación y su cierre final oculto tras un límite temporal

Una estrategia multi-timeframe (multi-TF) toma una decisión en un reloj rápido mientras consulta relojes lentos. Nuestro motor es una instancia concreta: una regla de momentum de triple TF donde una tendencia de temporalidad superior (HTF) y una tendencia de temporalidad media (MTF) filtran (gate) las entradas, y un cruce de temporalidad inferior (LTF) las dispara. En el reloj base de 1m, una entrada se activa solo cuando un cruce de HMA/HMA3 en 1m coincide con la dirección de la tendencia de 1h y la tendencia de 15m; una salida se activa cuando una tendencia de temporalidad superior se revierte contra la posición, o el LTF vuelve a cruzar.

El peligro es estructural. Cada uno de esos valores de temporalidad superior debe responderse en cada vela de 1m — 60 veces por hora — y cada respuesta debe usar únicamente lo que un bot en vivo conocería en ese instante. En el momento en que una sola de esas aproximadamente 180 lecturas por vela (tres temporalidades, tendencias de entrada y salida, filtros de separación) toca una vela que aún no se ha impreso, hay look-ahead. La superficie para un error de desfase (off-by-one) es enorme, y a diferencia de una filtración de ejecución, no se anuncia a sí misma desplazando la ejecución.

Una vela en formación aún no tiene cierre final

Aquí está la trampa exacta. Supón que son las 10:37 y estás decidiendo sobre la vela de 1m que acaba de cerrar. Tu regla necesita la tendencia de 1h. La vela de 1h que cubre 10:00–11:00 todavía se está formando — su cierre final no se conocerá hasta las 10:59:59. ¿Qué sabes realmente a las 10:37? Solo el cierre en curso de esa vela hasta ese momento, que es el cierre de la vela de las 10:37. El cierre final de la vela está 23 minutos en el futuro.

El backtest multi-TF ingenuo hace algo que parece completamente inocente: resamplea toda la serie de 1m a 1h una sola vez, por adelantado, y luego, para cada vela de 1m, lee "el cierre de 1h". Pero el valor que lee para cada vela de 1m entre las 10:00 y las 11:00 es el cierre final de 10:00–11:00 — un número que, en tiempo real, no existe hasta que termina la hora. Cada decisión dentro de esa hora es entregada silenciosamente hasta 59 minutos hacia el futuro. Y esto no es una filtración pequeña: el cierre de temporalidad superior es prácticamente el predictor más fuerte posible de los retornos de 1m del futuro cercano que estás a punto de operar, así que filtrarlo es casi como leer la clave de respuestas. Este es el canal de filtración por indicador de la taxonomía, amplificado: el vistazo no es de una vela, puede llegar a ser un período HTF completo.

La prueba de desplazamiento de una vela de la entrega anterior no detecta esto. Puedes desplazar la ejecución una vela más tarde y la serie de 1h resampleada sigue estando contaminada — la filtración vive en cómo se construyó el indicador, no en cuándo se transaccionó.

La regla del bot en vivo, reproducida exactamente: semántica de vela cerrada

El búfer de temporalidad superior en la vela i: una fila de cierres de velas cerradas y finalizadas seguida de una vela en formación cuyo valor es el cierre base actual, con el cierre final aún oculto tachado

La regla correcta es la que ya ejecuta el bot en vivo. En nuestra base de código es una clase pequeña, RunningCandleBuffer, trasladada palabra por palabra desde el simulador de ticks en vivo. Transmite las velas base a velas de período fijo y, en cada vela base, calcula el indicador HTF a partir de un array muy específico:

all_closes = np.array(self.closes + [self.current_close], dtype=np.float64)

Léelo literalmente. self.closes son los cierres finales de velas que ya han cerrado — una vela se agrega solo cuando se cruza un nuevo límite de período, y su valor almacenado es el cierre de la última vela base dentro del período (candle_buffer.py:39–44). La vela en formación aporta exactamente un número, self.current_close, que es el cierre en curso — el cierre de la vela base más reciente, base_close[i]. Esa es una cantidad conocida en la vela i por definición. El cierre final de la vela en formación nunca se usa, porque todavía no existe.

Así que el indicador HTF a las 10:37 ve [..., close(9:00 candle), close(10:37 so far)]. Cuando se imprime la vela de las 10:38, el último slot se actualiza a close(10:38). Cuando se cruza las 11:00, close(10:59) se convierte en el valor final de la vela recién cerrada y se abre un nuevo slot en formación. En ningún momento una decisión dentro de la hora toca el cierre final de 10:00–11:00. Eso es la semántica de vela cerrada: las velas cerradas aportan cierres terminados, la vela en formación aporta solo su cierre en curso.

Nuestro motor rápido (engine_multitf.py) es una reimplementación vectorizada y compilada con numba de esto. En lugar de un bucle de Python con una lista creciente, precalcula, para cada vela base i, cuántas velas se han cerrado completamente (n_closed[i]) y dispone la ventana de HMA/HMA3 como [closed candle closes…, base_close[i]] — el cierre en curso fijado en el último slot (engine_multitf.py:168–169). Es la misma matemática, desenrollada por velocidad a través de tres temporalidades con filtros de separación direccional. El contrato es explícito: el valor en la vela i depende únicamente de base_close[0..i].

Esa es la afirmación. El resto del artículo trata de cómo la demostramos, porque una afirmación en un docstring no vale nada.

La paridad es necesaria pero no suficiente

Un motor numba vectorizado que desenrolla una clase de streaming en bucles explícitos es exactamente donde se crían los errores de desfase (off-by-one). Así que la primera compuerta es una prueba de paridad bit a bit contra la referencia canónica, sobre un tramo real — ETHUSDT 1m, enero–febrero de 2024, 86.400 velas.

Verificamos dos cosas independientes contra dos referencias independientes:

  • Indicadores y cruces frente a RunningCandleBuffer ejecutado vela por vela. Para cada temporalidad, reproducimos la clase en vivo sobre las 86.400 velas y comparamos los eventos de cruce — la vela, la dirección, la separación — buscando una coincidencia exacta, además de los valores de HMA/HMA3 con tolerancia de punto flotante (la referencia usa np.dot, el motor usa bucles explícitos, así que el orden de suma difiere en ~1e-15). Los cruces coinciden exactamente: 408 cruces en el HTF (1h, longitud de HMA 21), 2.792 en el MTF (15m/14), 3.691 en el LTF (1m/50). Ni un solo evento de cruce difiere en vela o dirección.
  • Operaciones frente a una simulación independiente en Python puro de las reglas de trading, impulsada por los propios cruces de la referencia. Esto reproduce el bucle del backtest en vivo — las tendencias de entrada filtran las entradas, una reversión de la tendencia de salida o un cruce LTF opuesto cierra, ejecuciones en open[i+1], comisión de ida y vuelta del 0,09%, cierre forzado en la última vela — sin usar nada de la maquinaria numba del motor. Luego comparamos operación por operación: velas de entrada/salida, lados, precios de entrada/salida, PnL, motivos de salida, y tiempo total activo en posición.

Los umbrales de separación en la prueba no son valores por defecto benignos. Se eligen para dar en una esquina incómoda — un umbral de salida de MTF fijado más alto que el umbral de entrada coincidente — que fuerza la rama de "primera definición de una tendencia de salida mientras hay una posición abierta", que la referencia trata como una reversión. La paridad tiene que sostenerse en los casos límite, no solo en el camino fácil.

Campo por campo, las operaciones son idénticas: 466 operaciones para la configuración dual, 211 operaciones para la triple, con PnL total coincidiendo hasta 1e-12 y todos los campos de cada operación iguales dentro de la tolerancia. Dos implementaciones que no comparten código — un motor compilado y vectorizado y un bucle de Python ingenuo sobre los cruces de una tercera implementación — producen las mismas operaciones hasta el último decimal.

Ese es un resultado sólido, y no es una prueba de ausencia de look-ahead. La paridad dice que el motor rápido reproduce fielmente la referencia. Si la propia referencia filtrara información — si RunningCandleBuffer estuviera espiando — la paridad reproduciría fielmente esa filtración y pasaría la prueba. La concordancia entre implementaciones te dice que son lo mismo, no que son causales. Para la causalidad necesitas otro tipo de prueba, una que le pregunte directamente al motor si el pasado puede ver el futuro.

La sonda de futuro desplazado: la prueba real

Un corte vertical al 60 por ciento de la serie: todo a la derecha multiplicado y sacudido, todo a la izquierda congelado bit a bit idéntico

La definición de look-ahead es operacional, así que pruébala operacionalmente. Look-ahead significa que una decisión pasada depende de datos futuros. El contrarrecíproco es una prueba que puedes ejecutar: si cambias el futuro y alguna decisión pasada se mueve, el pasado estaba leyendo el futuro. Así que cambia el futuro — brutalmente — y observa el pasado.

Elige un punto de corte j en el 60% de la serie (la vela 51.840 de 86.400). Perturba cada vela desde j en adelante: multiplica todos los cierres y aperturas futuros por 1,05. Recalcula toda la pila de señales para las tres temporalidades sobre los datos perturbados. Luego verifica que todo estrictamente anterior a j es idéntico bit a bit a la ejecución sin perturbar:

j = int(n * 0.6)                       # bar 51,840
cl2 = cl.copy(); cl2[j:] *= 1.05       # shove the future up 5%
op2 = op.copy(); op2[j:] *= 1.05

base = [precompute_tf_signals(cl,  ts, p, L) for (p, L) in tf_params]
pert = [precompute_tf_signals(cl2, ts, p, L) for (p, L) in tf_params]

for s0, s1 in zip(base, pert):
    assert eq_nan(s0.hma[:j],  s1.hma[:j])      # HMA identical, NaNs included
    assert eq_nan(s0.hma3[:j], s1.hma3[:j])
    assert np.array_equal(s0.cross[:j], s1.cross[:j])   # every cross event
    assert np.array_equal(s0.sep[:j],   s1.sep[:j])     # every separation

Nada de "cercano". Nada de "dentro de tolerancia". np.array_equal, con los NaN obligados a coincidir con NaN — cada valor de HMA, cada valor de HMA3, cada bandera de cruce, y cada separación en las 51.840 velas pasadas deben ser el mismo número de punto flotante. Luego la misma aserción sobre las operaciones: cada operación cuya salida caiga antes de j debe permanecer inalterada campo por campo. Si un empujón del 5% al futuro mueve un solo HMA pasado en la duodécima cifra decimal, una decisión pasada consultó el futuro, y la sonda falla.

Nuestro motor la supera — en las tres temporalidades, y tanto en la simulación dual como en la triple de operaciones. Perturbar 34.560 velas futuras deja 51.840 velas pasadas y cada operación que se cerró entre ellas exactamente como estaban. Eso no es concordancia entre implementaciones; es una demostración directa de que el límite de información en el tiempo se mantiene.

Una prueba que no puede fallar no demuestra nada

Hay una forma de pasar la sonda anterior que no demuestra absolutamente nada: si la perturbación fuera un no-op — si multiplicar el futuro por 1,05 no cambiara nada en ninguna parte — entonces "el pasado no cambió" es trivialmente cierto y completamente poco informativo. Un check en verde en una prueba que no puede fallar es peor que no tener prueba, porque fabrica una falsa confianza. Así que la sonda lleva dos aserciones más que le dan mordiente.

El futuro debe cambiar realmente. Verificamos que la perturbación alteró los cruces en algún punto de [j, n):

assert not np.array_equal(s0.cross[j:], s1.cross[j:])   # probe has teeth

Ahora el resultado es significativo: el mismo empujón del 5% que reescribió el futuro dejó el pasado bit a bit idéntico. La perturbación es real, se propaga hacia adelante, y se detiene en seco en el corte. Una filtración unilateral — el pasado lee el futuro — se habría filtrado hacia atrás a través de j; no lo hace.

El límite está exactamente en la vela actual — ni una antes, ni una después. Un fallo más sutil sería un motor que es causal pero desactualizado (stale): ignora el cierre en curso de la vela actual y reacciona una vela tarde (sin filtración, pero con un retraso que el trading en vivo no tendría), o uno que reacciona una vela antes (una filtración de una vela). Así que perturbamos una única vela j (multiplicándola por 1,02) y verificamos dos cosas a la vez: el pasado [0, j) está intacto, y hma[j] reacciona de inmediato.

cl3 = cl.copy(); cl3[j] *= 1.02        # nudge exactly one bar
s3 = precompute_tf_signals(cl3, ts, p_ltf, L_ltf)
assert eq_nan(s0.hma[:j], s3.hma[:j])  # nothing before j moves
assert s0.hma[j] != s3.hma[j]          # bar j reacts on the same bar

Esto fija el límite con precisión. El cierre en curso de la vela en formación entra al indicador en la vela j con retraso cero y anticipación cero: la vela j ve su propio cierre instantáneamente, y ninguna vela anterior lo ve en absoluto. Ese es exactamente el filo de navaja en el que se supone que se asienta la semántica de vela cerrada, y la prueba confirma que el motor se asienta ahí.

Aquí está la compuerta completa — las 25 comprobaciones que se interponen entre este motor y un backtest fabricado:

Grupo Qué verifica cada comprobación Cantidad
Indicadores y cruces (×3 temporalidades) eventos de cruce exactos frente a RunningCandleBuffer; separación en los cruces; valores de HMA/HMA3 (rtol 1e-9) 9
Operaciones (dual + triple) cantidad de operaciones; idéntico campo por campo; PnL total hasta 1e-12; tiempo activo en posición 8
Sonda de futuro desplazado (dual + triple) señales pasadas bit a bit inalteradas; la sonda tiene mordiente (el futuro cambió); operaciones antes de j inalteradas; perturbación de una sola vela localizada 8
Total 25

Los dos primeros grupos establecen que el motor rápido es la referencia en vivo. El tercero establece que la referencia es causal. Necesitas los tres: un motor rápido que filtra, un motor que filtra pero es causal, y un motor causal pero con retraso son tres fallos distintos, y la compuerta descarta cada uno.

Por qué la sonda es agnóstica a la temporalidad

La elegancia de la sonda de futuro desplazado es que no sabe ni le importa dónde viviría una filtración. Nunca menciona temporalidades, resampleo, o límites de vela. Solo pregunta: ¿perturbar el futuro mueve el pasado? Eso la convierte en la herramienta correcta precisamente para la filtración multi-TF, que una prueba de desplazamiento de la ejecución no detecta.

Considera directamente el error ingenuo de resamplear toda la serie por adelantado. Si el flujo de 1h se hubiera construido resampleando toda la serie por adelantado, el "cierre de 1h" leído en la vela j-1000 (bien dentro de la hora que se cierra después de j) sería el cierre final de una vela cuyo cierre final depende de velas en y más allá de j. Multiplica el futuro por 1,05 y ese cierre final cambia — así que el indicador HTF en j-1000 cambia, el filtro en j-1000 cambia, y una decisión pasada se mueve. La sonda se activaría instantáneamente en el flujo HTF, en una vela mil pasos antes del corte.

El flujo HTF de nuestro motor no se mueve, porque en j-1000 la vela en formación aporta únicamente base_close[j-1000] — un cierre pasado — y el cierre final de la vela simplemente nunca se consulta hasta que se cruza el límite. La sonda es ciega al mecanismo y aun así atrapa el error, que es exactamente lo que se quiere de una prueba: restringe el comportamiento (ninguna decisión pasada depende de datos futuros) en lugar de auditar la implementación (¿indexamos correctamente el resampleo?). El comportamiento es lo que se opera; la implementación es lo que uno espera que coincida con él.

Backtest y en vivo comparten una sola verdad

Dos paneles compartiendo una única columna vertebral etiquetada RunningCandleBuffer: un barrido de backtest a un lado, un bot en vivo al otro, ambos conectados al mismo núcleo

Hay una razón más por la que esto importa más que una auditoría de backtest típica. La referencia contra la que se prueba el motor — RunningCandleBuffer — no es un fixture de prueba escrito para hacer que el backtest luzca bien. Es la propia lógica de velas del bot en vivo, extraída palabra por palabra del simulador de ticks que corre en producción. La regla de vela cerrada que la sonda valida es la regla que el bot en vivo ejecuta, vela por vela.

Así que la compuerta de paridad cumple doble función. Demuestra que el motor rápido coincide con la referencia, y como la referencia es el núcleo en vivo, demuestra que el motor rápido coincide con el sistema en vivo. La entrega anterior advirtió que una filtración es la explicación más limpia para una brecha de paridad backtest-vivo — el bot en vivo es el único lugar donde mecánicamente no se puede espiar, así que un backtest que espía diverge en el momento en que entra en producción. Aquí esa brecha se cierra por construcción: el backtest y el bot comparten un búfer de velas, una regla de cruce, una definición de "conocido en la vela i". El número que optimiza la búsqueda es el número que el bot puede realmente operar.

Ese es todo el sentido de demostrar la ausencia de look-ahead en lugar de asumirla. Una búsqueda multi-TF ejecuta miles de configuraciones; si el motor filtrara información, la búsqueda encontraría la configuración que explota esa filtración más agresivamente y te entregaría un ganador fabricado — el modo de fallo que la taxonomía midió en un Sharpe de 15 a partir de puro ruido. La sonda es lo que te permite confiar en que el ganador es real antes de conectarlo al capital.

Qué demuestra la sonda y qué no demuestra

El rigor sobre la prueba corta en ambas direcciones, así que hay que ser preciso sobre su alcance. La sonda de futuro desplazado demuestra una propiedad única y específica: en estos datos, ninguna decisión de señal u operación en la vela j o antes depende de ninguna vela posterior a j — el límite de información en el tiempo se mantiene a través del indicador, el cruce y el camino de la operación. Eso es exactamente la filtración multi-TF que nos propusimos eliminar, y es la propiedad que una revisión de código no puede establecer.

No demuestra que la estrategia tenga una ventaja (edge). Un motor perfectamente causal puede perder dinero honestamente, y la sonda guarda silencio al respecto — como debe ser; demostrar la ausencia de filtración y encontrar una ventaja real son trabajos separados, y confundirlos es cómo se despliegan los backtests filtrados. No cubre los sesgos no temporales: el sesgo de supervivencia en el instrumento, el sesgo de selección por ejecutar la sonda solo después de que el motor luciera bien, o un modelo de comisiones demasiado generoso. Y no demuestra, por sí sola, que las ejecuciones en vivo coincidan con las ejecuciones del backtest — el slippage y la latencia son brechas reales que la sonda no puede ver, porque opera sobre el camino de decisión, no sobre el lugar de ejecución. Lo que cierra esa brecha es el hecho separado de que el núcleo de velas del motor es bit a bit idéntico al del bot en vivo.

Una advertencia honesta sobre el propio diseño de la sonda: corta en un único j (60% de la serie). La propiedad que verifica es uniforme respecto a j — no hay nada especial en la vela 51.840 — así que un corte es una prueba justa de una propiedad estructural, pero una versión paranoica barrería j a través de la serie. Consideramos que un corte bien elegido más la comprobación de localización de una sola vela son suficientes, porque una filtración que se esconde de una perturbación futura de 34.560 velas pero aparece en algún otro corte tendría que ser un error muy extraño. El punto es conocer los límites de tu evidencia, no fingir que una sola prueba es un cuantificador universal.

Conclusiones

  1. Las estrategias multi-timeframe filtran a través de la vela en formación, no de la ejecución. Una decisión dentro de un período HTF no terminado debe usar únicamente el cierre en curso de esa vela (el último cierre base), nunca su cierre final. Los backtests que resamplean toda la serie por adelantado entregan cada decisión intra-período hasta un período HTF completo de futuro.
  2. La prueba de desplazamiento de una vela no llega a esta filtración. Detecta el look-ahead de ejecución; la filtración multi-TF vive en cómo se construyó el indicador. Se necesita una sonda diferente.
  3. Reproduce la regla en vivo exactamente, y luego pruébala. Reconstruimos la semántica de vela cerrada del RunningCandleBuffer del bot como un motor vectorizado y lo condicionamos a 25 comprobaciones: cruces exactos frente a la referencia (408 / 2.792 / 3.691), operaciones idénticas campo por campo (466 dual, 211 triple), PnL hasta 1e-12.
  4. La paridad es necesaria pero no suficiente. Coincidir con una referencia demuestra que eres lo mismo, no que eres causal. Una referencia filtrada, reproducida fielmente, sigue filtrando.
  5. La sonda de futuro desplazado es la prueba real. Perturba cada vela en o después de j; verifica que cada señal y operación antes de j permanece idéntica bit a bit. Si el futuro puede mover el pasado, hay look-ahead.
  6. Dale mordiente a la sonda. Verifica que el futuro cambió (la perturbación no es un no-op) y que un pequeño empujón de una sola vela reacciona en esa misma vela (sin filtración, sin retraso). Una prueba que no puede fallar no demuestra nada.
  7. Cuando el backtest y el sistema en vivo comparten un núcleo, la prueba se transfiere. Como el motor es bit a bit idéntico a la lógica de velas del bot en vivo, la ausencia de look-ahead es también una propiedad del sistema en vivo — y la brecha de paridad backtest-vivo se cierra por construcción.

La entrega anterior mostró cómo una filtración de una sola línea fabrica un Sharpe de 15. Esta muestra la disciplina opuesta: no "cómo las filtraciones te engañan", sino cómo demostrar, mecánicamente y sobre datos reales, que un motor específico no filtra información. Perturba el futuro. Si el pasado no se inmuta, estás operando en el presente.

blog.disclaimer

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

Mantente a la vanguardia

Suscríbete a nuestro boletín para recibir información exclusiva sobre trading con IA, análisis de mercado y actualizaciones de la plataforma.

Respetamos tu privacidad. Puedes darte de baja en cualquier momento.