← Voltar aos artigos
July 8, 2026
5 min read

Provando a Ausência de Look-Ahead em Backtests Multi-Timeframe: Perturbe o Futuro, Prove que o Passado Não Pode Vê-lo

Provando a Ausência de Look-Ahead em Backtests Multi-Timeframe: Perturbe o Futuro, Prove que o Passado Não Pode Vê-lo
#negociação algorítmica
#backtest
#viés de look-ahead
#multi-timeframe
#vazamento de dados
#validação
🎯
Part 2 of 9 · Collection
Backtesting Without Fooling Yourself

Parte da série "Backtests Sem Ilusões".

O artigo anterior terminou com um diagnóstico: o teste de deslocamento de uma barra (one-bar shift test). Desloque cada execução (fill) uma barra para depois; se o desempenho desmoronar, você estava negociando no passado. Esse teste é suficiente para uma estratégia de timeframe único, onde o look-ahead se esconde na execução — você decidiu na barra i e executou na barra i.

Estratégias multi-timeframe têm um segundo lugar, mais silencioso, onde podem vazar, e o teste de deslocamento não o alcança. Quando uma decisão em uma barra de 1 minuto consulta uma tendência de 1 hora, o vazamento já não está na execução — está no indicador, calculado a partir de uma barra de timeframe superior que ainda não terminou de se formar. Este artigo trata desse vazamento, e de como provar — mecanicamente, não olhando fixamente para o código — que o seu motor (engine) não o possui.

A afirmação que fazemos ao final é forte: o nosso motor multi-TF passou em 25 de 25 verificações de paridade e vazamento em 86,400 barras de dados reais de ETHUSDT em 1m, incluindo uma sonda de futuro deslocado que perturba cada barra futura e confirma que nenhuma decisão passada se move. E, como o motor é bit a bit idêntico ao núcleo canônico do bot ao vivo, a prova é uma propriedade do sistema ao vivo, não apenas do backtest.

O campo minado multi-timeframe

Uma decisão de 1 minuto alcançando três timeframes ao mesmo tempo, com o candle do timeframe mais alto ainda em formação e seu fechamento final escondido atrás de uma fronteira temporal

Uma estratégia multi-timeframe (multi-TF) toma uma decisão em um relógio rápido enquanto consulta relógios mais lentos. O nosso motor é uma instância concreta: uma regra de momentum de três timeframes (triple-TF) na qual uma tendência de timeframe superior (HTF) e uma tendência de timeframe intermediário (MTF) condicionam (gate) as entradas, e um cruzamento de timeframe inferior (LTF) as dispara. No relógio-base de 1m, uma entrada dispara apenas quando um cruzamento HMA/HMA3 de 1m concorda com a direção da tendência de 1h e da tendência de 15m; uma saída dispara quando uma tendência de timeframe superior se reverte contra a posição, ou quando o LTF cruza de volta.

O perigo é estrutural. Cada um desses valores de timeframe superior precisa ser respondido em cada barra de 1m — 60 vezes por hora — e cada resposta deve usar apenas o que um bot ao vivo saberia naquele instante. No instante em que uma única dessas cerca de 180 leituras por barra (três timeframes, tendências de entrada e saída, condicionantes de separação) toca uma barra que ainda não foi impressa, você tem look-ahead. A superfície de risco para um erro de off-by-one é enorme e, ao contrário de um vazamento de execução, ele não se anuncia deslocando a execução (fill).

Uma barra em formação ainda não tem fechamento final

Eis a armadilha exata. Suponha que sejam 10:37 e você esteja decidindo na barra de 1m que acabou de fechar. Sua regra quer a tendência de 1h. O candle de 1h que cobre 10:00–11:00 ainda está se formando — seu fechamento final só será conhecido às 10:59:59. O que você realmente sabe às 10:37? Apenas o fechamento corrente (running close) desse candle até agora, que é o fechamento da barra de 10:37. O fechamento final do candle está 23 minutos no futuro.

O backtest multi-TF ingênuo faz algo que parece completamente inocente: reamostra a série inteira de 1m para 1h uma única vez, antecipadamente, e depois, para cada barra de 1m, lê "o fechamento de 1h". Mas o valor que lê para cada barra de 1m entre 10:00 e 11:00 é o fechamento final de 10:00–11:00 — um número que, em tempo real, não existe até o fim da hora. Cada decisão dentro da hora recebe, silenciosamente, até 59 minutos do futuro. E isso não é um vazamento pequeno: o fechamento de timeframe superior é, aproximadamente, o mais forte preditor possível dos retornos de 1m no futuro próximo que você está prestes a negociar, então vazá-lo é quase o mesmo que ler o gabarito. Este é o canal de vazamento por indicador da taxonomia, amplificado: a espiada não é de uma barra, é de até um período HTF inteiro.

O teste de deslocamento de uma barra do artigo anterior não captura isso. Você pode deslocar a execução uma barra para depois e a série de 1h reamostrada continua contaminada — o vazamento vive em como o indicador foi construído, não em quando você transacionou.

A regra do bot ao vivo, reproduzida exatamente: semântica de barra fechada

O buffer de timeframe superior na barra i: uma sequência de fechamentos de candles já fechados seguida de um candle em formação cujo valor é o fechamento-base atual, com o fechamento final ainda oculto riscado

A regra correta é a que o bot ao vivo já executa. Em nossa base de código, é uma pequena classe, RunningCandleBuffer, transportada literalmente (verbatim) do simulador de ticks ao vivo. Ela transforma o fluxo de barras-base em candles de período fixo e, em cada barra-base, calcula o indicador HTF a partir de um array bem específico:

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

Leia isso literalmente. self.closes são os fechamentos finais de candles que já fecharam — um candle só é anexado quando uma nova fronteira de período é cruzada, e seu valor armazenado é o fechamento da última barra-base dentro do período (candle_buffer.py:39–44). O candle em formação contribui com exatamente um número, self.current_close, que é o fechamento corrente (running) — o fechamento da barra-base mais recente, base_close[i]. Essa é uma quantidade conhecida na barra i por definição. O fechamento final do candle em formação nunca é usado, porque ainda não existe.

Assim, o indicador HTF às 10:37 vê [..., close(9:00 candle), close(10:37 so far)]. Quando 10:38 é impresso, o último slot é atualizado para close(10:38). Quando 11:00 é cruzado, close(10:59) se torna o valor final do candle recém-fechado e um novo slot em formação se abre. Em nenhum momento uma decisão dentro da hora toca o fechamento final de 10:00–11:00. Essa é a semântica de barra fechada: candles fechados contribuem com fechamentos concluídos, o candle em formação contribui apenas com seu fechamento corrente.

O nosso motor rápido (engine_multitf.py) é uma reimplementação vetorizada e compilada com numba disso. Em vez de um loop em Python com uma lista crescente, ele pré-calcula, para cada barra-base i, quantos candles fecharam completamente (n_closed[i]) e monta a janela HMA/HMA3 como [closed candle closes…, base_close[i]] — o fechamento corrente fixado no slot final (engine_multitf.py:168–169). É a mesma matemática, desenrolada (unrolled) para velocidade através de três timeframes com condicionantes de separação direcional. O contrato é explícito: o valor na barra i depende apenas de base_close[0..i].

Essa é a afirmação. O restante do artigo trata de como nós a provamos, porque uma afirmação em uma docstring não vale nada.

Paridade é necessária, mas não suficiente

Um motor vetorizado em numba que desenrola uma classe de streaming em loops explícitos é exatamente o lugar onde os erros de off-by-one proliferam. Então o primeiro portão (gate) é um teste de paridade bit a bit contra a referência canônica, em um recorte real de dados — ETHUSDT 1m, janeiro–fevereiro de 2024, 86,400 barras.

Verificamos duas coisas independentes contra duas referências independentes:

  • Indicadores e cruzamentos vs RunningCandleBuffer executado barra a barra. Para cada timeframe, replicamos a classe ao vivo em todas as 86,400 barras e comparamos os eventos de cruzamento — a barra, a direção, a separação — em busca de correspondência exata, além dos valores de HMA/HMA3 com tolerância de ponto flutuante (a referência usa np.dot, o motor usa loops explícitos, então a ordem de soma difere em ~1e-15). Os cruzamentos coincidem exatamente: 408 cruzamentos no HTF (1h, comprimento de HMA 21), 2,792 no MTF (15m/14), 3,691 no LTF (1m/50). Nenhum evento de cruzamento difere em barra ou direção.
  • Trades vs uma simulação independente em Python puro das regras de negociação, orientada pelos cruzamentos da própria referência. Isso reproduz o loop do backtest ao vivo — tendências de entrada condicionam as entradas, uma reversão de tendência de saída ou um cruzamento LTF oposto fecha a posição, execuções em open[i+1], taxa de 0.09% por ida e volta (round-trip), fechamento forçado na última barra — sem nenhum dos mecanismos numba do motor. Em seguida, comparamos trade a trade: barras de entrada/saída, lados, preços de entrada/saída, PnL, razões de saída e tempo ativo total em posição.

Os limiares de separação no teste não são padrões benignos. Foram escolhidos para atingir um canto incômodo — um limiar de saída do MTF definido mais alto do que o limiar de entrada correspondente — o que força o ramo de "primeira definição de uma tendência de saída enquanto uma posição está aberta", que a referência trata como uma reversão. A paridade precisa se sustentar nos casos de canto (corner cases), não apenas no caminho fácil.

Campo a campo, os trades são idênticos: 466 trades para a configuração dual, 211 trades para a tripla, com o PnL total coincidindo até 1e-12 e todos os campos de cada trade iguais dentro da tolerância. Duas implementações que não compartilham nenhum código — um motor vetorizado compilado e um loop Python ingênuo sobre os cruzamentos de uma terceira implementação — produzem os mesmos trades até a última casa decimal.

Esse é um resultado forte, e não é uma prova de ausência de look-ahead. A paridade diz que o motor rápido reproduz fielmente a referência. Se a própria referência vazasse — se RunningCandleBuffer espiasse — a paridade reproduziria fielmente o vazamento e passaria. Concordância entre implementações diz que elas são iguais, não que são causais. Para causalidade, você precisa de um tipo diferente de teste, um que pergunte ao motor diretamente se o passado consegue ver o futuro.

A sonda de futuro deslocado: a prova de fato

Um corte vertical em 60 por cento da série: tudo à direita multiplicado e sacudido, tudo à esquerda congelado, idêntico bit a bit

A definição de look-ahead é operacional, então teste-a operacionalmente. Look-ahead significa uma decisão passada depende de dados futuros. A contrapositiva é um teste que você pode executar: se você mudar o futuro e alguma decisão passada se mover, o passado estava lendo o futuro. Então mude o futuro — brutalmente — e observe o passado.

Escolha um ponto de corte j em 60% da série (barra 51,840 de 86,400). Perturbe cada barra a partir de j em diante: multiplique todos os fechamentos e aberturas futuros por 1.05. Recalcule toda a pilha de sinais para os três timeframes sobre os dados perturbados. Depois, verifique que tudo estritamente antes de j é idêntico bit a bit à execução não perturbada:

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

Não "próximo". Não "dentro da tolerância". np.array_equal, com NaNs exigidos para corresponder a NaNs — todo valor de HMA, todo valor de HMA3, toda flag de cruzamento e toda separação nas 51,840 barras passadas devem ser exatamente o mesmo float. Depois, a mesma verificação sobre os trades: todo trade cuja saída ocorra antes de j deve permanecer inalterado campo a campo. Se um empurrão de 5% no futuro mover um único HMA passado na décima segunda casa decimal, uma decisão passada consultou o futuro, e a sonda falha.

O nosso motor passa nele — para os três timeframes, e para as simulações de trade tanto dual quanto tripla. Perturbar 34,560 barras futuras deixa as 51,840 barras passadas e todo trade que fechou entre elas exatamente como estavam. Isso não é concordância entre implementações; é uma demonstração direta de que a fronteira de informação no tempo se mantém.

Um teste que não pode falhar não prova nada

Há uma forma de passar na sonda acima que não prova absolutamente nada: se a perturbação fosse um no-op — se multiplicar o futuro por 1.05 não mudasse nada em lugar nenhum — então "o passado permanece inalterado" é trivialmente verdadeiro e completamente sem informação. Um check verde em um teste que não pode falhar é pior do que nenhum teste, porque fabrica falsa confiança. Por isso a sonda carrega mais duas verificações que lhe dão dentes.

O futuro precisa de fato mudar. Verificamos que a perturbação realmente alterou cruzamentos em algum ponto de [j, n):

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

Agora o resultado é significativo: o mesmo empurrão de 5% que reescreveu o futuro deixou o passado idêntico bit a bit. A perturbação é real, ela se propaga para frente, e para completamente no corte. Um vazamento unilateral — o passado lendo o futuro — teria sangrado para trás através de j; isso não acontece.

A fronteira está exatamente na barra atual — nem uma antes, nem uma depois. Uma falha mais sutil seria um motor que é causal, mas desatualizado (stale): ele ignora o fechamento corrente da barra atual e reage uma barra depois (sem vazamento, mas com um atraso que a negociação ao vivo não teria), ou um que reage uma barra antes (um vazamento de uma barra). Então perturbamos uma única barra j (em 1.02) e verificamos duas coisas ao mesmo tempo: o passado [0, j) permanece intocado, e hma[j] reage imediatamente.

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

Isso fixa a fronteira com precisão. O fechamento corrente do candle em formação entra no indicador na barra j com atraso zero e antecipação zero: a barra j vê seu próprio fechamento instantaneamente, e nenhuma barra anterior o vê de forma alguma. Esse é exatamente o fio da navalha em que a semântica de barra fechada deve se apoiar, e o teste confirma que o motor se apoia nele.

Eis o portão (gate) completo — as 25 verificações que se interpõem entre este motor e um backtest fabricado:

Grupo O que cada verificação afirma Quantidade
Indicadores e cruzamentos (×3 timeframes) eventos de cruzamento exatos vs RunningCandleBuffer; separação nos cruzamentos; valores de HMA/HMA3 (rtol 1e-9) 9
Trades (dual + tripla) contagem de trades; idênticos campo a campo; PnL total até 1e-12; tempo ativo em posição 8
Sonda de futuro deslocado (dual + tripla) sinais passados bit a bit inalterados; sonda tem dentes (o futuro de fato mudou); trades antes de j inalterados; perturbação de barra única localizada 8
Total 25

Os dois primeiros grupos estabelecem que o motor rápido é a referência ao vivo. O terceiro estabelece que a referência é causal. Você precisa dos três: um motor rápido que vaza, um motor que vaza mas é causal, e um motor causal que atrasa são três falhas diferentes, e o portão descarta cada uma delas.

Por que a sonda é agnóstica a timeframe

A elegância da sonda de futuro deslocado é que ela não sabe nem se importa onde um vazamento viveria. Ela nunca menciona timeframes, reamostragem ou fronteiras de candles. Ela apenas pergunta: perturbar o futuro move o passado? Isso a torna a ferramenta certa precisamente para o vazamento multi-TF, que um teste de deslocamento da execução (shift-the-fill) não alcança.

Considere diretamente o bug ingênuo de reamostrar a série inteira. Se o fluxo de 1h fosse construído reamostrando a série completa antecipadamente, o "fechamento de 1h" lido na barra j-1000 (bem dentro da hora que fecha depois de j) seria o fechamento final de um candle cujo fechamento final depende de barras em e além de j. Multiplique o futuro por 1.05 e esse fechamento final muda — então o indicador HTF em j-1000 muda, o condicionante (gate) em j-1000 muda, e uma decisão passada se move. A sonda acenderia no fluxo HTF instantaneamente, em uma barra mil passos antes do corte.

O fluxo HTF do nosso motor não se move, porque em j-1000 o candle em formação contribui apenas com base_close[j-1000] — um fechamento passado — e o fechamento final do candle simplesmente nunca é consultado até que a fronteira seja ultrapassada. A sonda é cega ao mecanismo e ainda assim captura o bug, o que é exatamente o que se quer de uma prova: ela restringe o comportamento (nenhuma decisão passada depende de dados futuros) em vez de auditar a implementação (indexamos a reamostragem corretamente?). Comportamento é o que negocia; implementação é o que se espera que corresponda a ele.

Backtest e ao vivo compartilham uma única verdade

Dois painéis compartilhando uma única espinha rotulada RunningCandleBuffer: uma varredura de backtest de um lado, um bot ao vivo do outro, ambos conectados ao mesmo núcleo

Há mais um motivo pelo qual isso importa mais do que uma auditoria de backtest típica. A referência contra a qual o motor é provado — RunningCandleBuffer — não é um fixture de teste escrito para fazer o backtest parecer bom. É a própria lógica de candles do bot ao vivo, retirada literalmente (verbatim) do simulador de ticks que roda em produção. A regra de barra fechada que a sonda valida é a regra que o bot ao vivo executa, barra a barra.

Assim, o portão de paridade cumpre dupla função. Ele prova que o motor rápido corresponde à referência e, como a referência é o núcleo ao vivo, prova que o motor rápido corresponde ao ao vivo. O artigo anterior alertou que um vazamento é a explicação mais limpa para uma lacuna de paridade backtest-ao vivo — o bot ao vivo é o único lugar onde você mecanicamente não pode espiar, então um backtest que espia diverge no momento em que vai para produção. Aqui essa lacuna é fechada por construção: backtest e bot compartilham um único buffer de candles, uma única regra de cruzamento, uma única definição de "conhecido na barra i". O número que a busca otimiza é o número que o bot pode de fato negociar.

Esse é todo o sentido de provar a ausência de look-ahead em vez de simplesmente presumi-la. Uma busca multi-TF executa milhares de configurações; se o motor vazasse, a busca encontraria a configuração que explora o vazamento de forma mais agressiva e entregaria a você um vencedor fabricado — o modo de falha que a taxonomia mediu em um Sharpe de 15 a partir de puro ruído. A sonda é o que permite confiar que o vencedor é real antes de conectá-lo ao capital.

O que a sonda prova e o que não prova

O rigor sobre o teste corta dos dois lados, então sejamos precisos quanto ao seu escopo. A sonda de futuro deslocado prova uma única propriedade específica: nestes dados, nenhuma decisão de sinal ou de trade na barra j ou antes dela depende de qualquer barra após j — a fronteira de informação no tempo se mantém ao longo do caminho de indicador, cruzamento e trade. Essa é exatamente o vazamento multi-TF que nos propusemos a eliminar, e é a propriedade que uma revisão de código não consegue estabelecer.

Ela não prova que a estratégia tem uma vantagem (edge). Um motor perfeitamente causal pode perder dinheiro honestamente, e a sonda é silenciosa quanto a isso — como deveria ser; provar a ausência de vazamento e encontrar uma vantagem real são tarefas separadas, e confundi-las é como backtests vazados acabam sendo colocados em produção. Ela não cobre os vieses não temporais: sobrevivência (survivorship) no instrumento, viés de seleção por executar a sonda somente depois que o motor já parecia bom, ou um modelo de taxas generoso demais. E ela não prova, por si só, que as execuções ao vivo correspondem às execuções do backtest — slippage e latência são lacunas reais que a sonda não consegue ver, porque ela opera no caminho da decisão, não na praça de execução (venue). O que fecha essa lacuna é o fato separado de que o núcleo de candles do motor é idêntico bit a bit ao do bot ao vivo.

Uma ressalva honesta sobre o próprio desenho da sonda: ela corta em um único j (60% da série). A propriedade que ela verifica é uniforme em j — não há nada de especial na barra 51,840 — então um corte é um teste justo de uma propriedade estrutural, mas uma versão paranoica varreria j ao longo de toda a série. Consideramos suficiente um corte bem escolhido mais a verificação de localização de barra única, porque um vazamento que se esconde de uma perturbação futura de 34,560 barras, mas aparece em algum outro corte, teria que ser um bug muito estranho. O importante é conhecer os limites da sua evidência, não fingir que um único teste é um quantificador universal.

Principais conclusões

  1. Estratégias multi-timeframe vazam através da barra em formação, não da execução. Uma decisão dentro de um período HTF inacabado deve usar apenas o fechamento corrente desse candle (o fechamento-base mais recente), nunca seu fechamento final. Backtests que reamostram a série inteira entregam cada decisão intra-período até um período HTF inteiro do futuro.
  2. O teste de deslocamento de uma barra não alcança esse vazamento. Ele captura o look-ahead de execução; o vazamento multi-TF vive em como o indicador foi construído. Você precisa de uma sonda diferente.
  3. Reproduza a regra ao vivo exatamente, depois prove-a. Reconstruímos a semântica de barra fechada do RunningCandleBuffer do bot como um motor vetorizado e o condicionamos atrás de 25 verificações: cruzamentos exatos em relação à referência (408 / 2,792 / 3,691), trades idênticos campo a campo (466 dual, 211 tripla), PnL até 1e-12.
  4. Paridade é necessária, mas não suficiente. Corresponder a uma referência prova que você é igual a ela, não que é causal. Uma referência vazada reproduzida fielmente ainda vaza.
  5. A sonda de futuro deslocado é a prova de fato. Perturbe toda barra em j ou depois; verifique que todo sinal e trade antes de j está bit a bit inalterado. Se o futuro pode mover o passado, você tem look-ahead.
  6. Dê dentes à sonda. Verifique que o futuro de fato mudou (a perturbação não é um no-op) e que um leve empurrão em uma única barra reage nessa mesma barra (sem vazamento, sem atraso). Um teste que não pode falhar não prova nada.
  7. Quando backtest e ao vivo compartilham um único núcleo, a prova se transfere. Como o motor é idêntico bit a bit à lógica de candles do bot ao vivo, a ausência de look-ahead também é uma propriedade do sistema ao vivo — e a lacuna de paridade backtest-ao vivo se fecha por construção.

O artigo anterior mostrou como um vazamento de uma linha fabrica um Sharpe de 15. Este mostra a disciplina oposta: não "como os vazamentos enganam você", mas como provar, mecanicamente e com dados reais, que um motor específico não vaza. Perturbe o futuro. Se o passado não vacilar, você está negociando no 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

Fique à frente do mercado

Assine nossa newsletter para insights exclusivos sobre trading com IA, análises de mercado e atualizações da plataforma.

Respeitamos sua privacidade. Cancele a inscrição a qualquer momento.