Mesmo commit, mesmos arquivos parquet, mesma máquina, duas execuções com uma hora de intervalo. Sharpe 1.34 e Sharpe 1.19. Retorno total de 41.2% e 37.8%. Contagem de trades: 1,418 e 1,421. E as duas curvas de patrimônio coincidiram até cada casa decimal exibida por 11,205 barras consecutivas antes de divergirem.
Os três primeiros números são incômodos. O último é o interessante, porque mostra que o problema não é um erro de ponto flutuante impreciso espalhado por toda a execução. Algo discreto aconteceu em uma barra específica, e todo o resto foi efeito acumulado. Este post é sobre encontrar essa barra e entender o que essa diferença de 0.15 significa para cada busca de parâmetros que você já executou.
A estratégia: momentum cross-sectional nos 30 perps USDⓈ-M de maior volume, rebalanceamento a cada 4 horas, posição comprada nos cinco primeiros por retorno em 12 horas, vendida nos cinco últimos, histórico de 18 meses, com taxas e funding cobrados por perna.
Encontrando a barra em que as execuções divergem
Se você registra o patrimônio por barra com precisão total, isso leva cerca de quatro minutos. Exporte as duas execuções para CSV de (bar_index, equity, open_positions_hash), carregue ambos e encontre o primeiro índice em que divergem. Já tínhamos escrito esse script para outro bug; foi a única razão para eu não passar a manhã nisso.
Barra 11,206, 2025-03-14T08:00 UTC. O patrimônio era idêntico na barra anterior até 13 algarismos significativos. Na 11,206, os hashes das posições divergem: a execução A está comprada em SOL, a execução B está comprada em AVAX. Mesmo lado, mesmo valor nocional, ativo diferente. Tudo depois disso é consequência.
Então imprimi os dados de entrada do ranking para essa barra nas duas execuções. Eram idênticos. Byte a byte, os mesmos 30 símbolos, as mesmas 30 pontuações. Duas pontuações eram 0.0. Exatamente zero, ambas, porque os dois ativos tinham registrado uma barra de 4 horas sem trades dentro da janela de análise; assim, a razão entre fechamento atual e anterior ficou em um, e o log resultou em zero. Não era um valor arredondado para zero. Era zero.
Dois símbolos empataram na quinta posição. A seleção pegava os cinco primeiros. Qual dos dois entrava dependia da ordem em que a ordenação os deixava, e ela não estava fazendo o que eu supunha.
Um empate, uma ordenação instável e algo que ignorei por dois anos
O ranking passava por uma agregação agrupada, e o dataframe que a alimentava vinha de uma compreensão de dicionário sobre um conjunto de símbolos, reconstruído a cada barra a partir de uma busca assíncrona. A ordem de iteração do conjunto muda com a semente de hash, e o Python randomiza a semente de hash das strings por processo, a menos que você fixe PYTHONHASHSEED. Então a ordem das linhas antes da ordenação era diferente entre as execuções e, com uma ordenação não estável por uma chave empatada, o desempate também mudava.
Empates assim não são casos excepcionais. São estruturais. Sempre que uma feature satura ou é limitada, você cria igualdades exatas: barras sem volume produzem retornos exatamente iguais a zero, um z-score limitado fixa em ±3.0, uma transformação de ranking com poucos valores distintos gera dezenas de empates, um filtro booleano dá pontuação 1.0 a tudo que passa. Ao longo de 18 meses de barras de 4 horas, essa execução teve 47 barras com empate no limite da seleção. Três delas mudaram a cesta selecionada. Nas demais, o empate era entre dois ativos que já estavam ambos dentro ou ambos fora.
Por cerca de um dia, eu estava convencido de que o carregador de dados era não determinístico, porque essa seria a resposta mais interessante. Não era. Nunca é. É um conjunto, um empate e uma suposição sobre a estabilidade da ordenação que ninguém documentou.
Por que três trades valem 0.15 de Sharpe
É aqui que as pessoas contestam, e a resposta é que um backtest com dimensionamento como fração do patrimônio é um sistema dependente da trajetória. Dimensionar cada perna em 8% do patrimônio atual significa que uma diferença no patrimônio na barra n implica uma diferença em todo valor nocional a partir da barra n.
A primeira divergência, por si só, custou pouco. A perna em AVAX da execução B perdeu 2.1% em nove horas; a perna em SOL da execução A ganhou 0.4%. Diferença no patrimônio após esse trade: 0.21%. Irrelevante. Mas, dali em diante, as duas execuções já não são a mesma estratégia. Mantêm posições de tamanhos ligeiramente diferentes, então acumulam funding um pouco diferente; além disso, mais tarde, dois empates no limite foram desfeitos de outra forma, porque as pontuações usadas agora vinham de posições mantidas ligeiramente diferentes. Um deles aconteceu em 2025-03-27, um dia antes de uma tendência de seis dias que gerou cerca de um terço do PnL total da execução. A execução A esteve posicionada durante todo o movimento; a execução B entrou um rebalanceamento depois.
Diferença de retorno: 3.4 pontos. A diferença de Sharpe é maior do que a diferença de retorno sugere porque os trades reordenados da execução B coincidiram com um período mais volátil: o denominador aumentou enquanto o numerador caiu. Causa pequena, dois amplificadores.
Se seu dimensionamento usa valor nocional fixo e suas entradas não dependem das posições atuais, você está bem mais protegido. A maioria das estratégias interessantes não se encaixa em nenhum desses casos.
Os cinco pontos em que isso realmente aparece
| Origem | Sintoma | Correção |
|---|---|---|
PYTHONHASHSEED sem semente fixa, com a ordem de iteração de conjuntos/dicionários alimentando uma ordenação | Desempates mudam entre execuções; primeira divergência em uma barra específica | Fixe a semente; ordene por uma chave secundária explícita (símbolo) para tornar determinísticos os empates |
Ordenação não estável em uma chave empatada (quicksort padrão em NumPy/pandas) | Mesmo caso acima; continua ocorrendo mesmo com a semente fixa | kind="stable", ou torne a chave totalmente ordenável |
| RNG sem semente fixa em bootstrap, embaralhamentos de treino/teste ou variação sintética de execução | Deriva ao longo de toda a execução, sem um ponto claro de divergência | Use uma semente explícita por componente e registre-a no manifesto da execução |
| Redução paralela de floats (ordem de soma dependente do número de threads) | Diferenças nos últimos bits, geralmente inofensivas até cruzarem um limite de comparação | Fixe o número de threads nas execuções de pesquisa; nunca compare floats com == em um limite de decisão |
| Versões de bibliotecas sem fixação | Reproduzível hoje, não em novembro | Inclua o hash do lockfile no manifesto, junto com o hash do snapshot dos dados |
A quarta linha importa com menos frequência do que as pessoas temem; a primeira causa problemas o tempo todo.
Reprodutibilidade bit a bit é uma ferramenta, não uma virtude
Você quer determinismo para que, ao mudar uma linha, a diferença na curva de patrimônio possa ser atribuída àquela linha. Esse é o motivo. Agora, cada execução de agente no Stratmill grava um manifesto com o hash do snapshot dos dados, o hash do lockfile e todas as sementes; se uma nova execução não reproduzir a curva anterior bit a bit, a compilação falhou — não é apenas uma curiosidade.
Mas, depois de garantir a reprodutibilidade, quebre-a de propósito. Rode o processo 64 vezes com 64 sementes e observe a dispersão:
A faixa de oscilação. Mesma estratégia, mesmos dados, 64 permutações de desempate e ordem de execução, cada uma com uma semente. Sharpe p5 1.12, mediana 1.27, p95 1.41. Largura da faixa: 0.29.
Agora volte à busca de parâmetros. A melhor configuração marcou 1.46. A configuração na 40ª posição entre 96 marcou 1.31. A diferença entre elas é 0.15, metade da faixa. A busca não classificou essas duas configurações. Ela sorteou um resultado de cada distribuição e ordenou os resultados.
Essa reformulação mudou como escolhemos. O resultado de uma busca só é um ranking quando as diferenças entre configurações superam a oscilação de uma única configuração; em uma estratégia dependente da trajetória com 1,400 trades, a oscilação costuma ser grande o bastante para transformar o terço superior da classificação em um empate. Quando isso acontece, escolha com base em algo que a faixa não esconda: menor giro, menos parâmetros, uma premissa de custos que você defenderia diante de um cético, melhor comportamento no período de validação walk-forward de que você menos gosta. Esses são critérios reais de desempate. Uma vantagem de 0.15 no Sharpe não é.
Mais uma coisa que vale fazer antes de confiar em qualquer resultado. Rode seu backtest duas vezes agora, compare o patrimônio por barra e descubra se você está no grupo das execuções idênticas bit a bit ou no grupo da diferença de 0.15. É um experimento de quinze minutos que mostra quanto do seu histórico de pesquisa mediu a estratégia e quanto mediu uma semente de hash.
← Todos os artigos


