11 de setembro de 2026 · reprodutibilidade

Mesmo código, mesmos dados, dois Sharpes diferentes: a auditoria do não determinismo de que o seu backtest precisa

Mesmo código, mesmos dados, dois Sharpes diferentes: a auditoria do não determinismo de que o seu backtest precisa

Mesmo commit, mesmos ficheiros 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%. Número de operações: 1,418 e 1,421. E as duas curvas de capital coincidiram em todas as casas decimais apresentadas durante 11,205 barras consecutivas, antes de divergirem.

0.15de diferença no Sharpe, com dados de entrada idênticos
3 / 1,418operações diferentes
11,205barras idênticas antes da divergência

Os primeiros 3 números são frustrantes. O último é o interessante, porque mostra que o problema não é uma imprecisão de vírgula flutuante espalhada por toda a execução. Aconteceu algo discreto numa barra específica e, a partir daí, o efeito foi-se acumulando. Este artigo é sobre encontrar essa barra e perceber o que uma diferença de 0.15 significa para todas as pesquisas de parâmetros que já fez.

A estratégia: momentum transversal nos 30 contratos perpétuos USDⓈ-M com maior volume, rebalanceamento a cada 4 horas, posições longas nos 5 melhores pelo retorno a 12 horas, posições curtas nos 5 piores, 18 meses de histórico, comissões e funding cobrados por perna.

Encontrar a barra em que as execuções divergem

Se registar o capital por barra com precisão total, isto demora cerca de 4 minutos. Exporte as duas execuções para um CSV de (bar_index, equity, open_positions_hash), carregue ambos e encontre o primeiro índice em que diferem. Já tínhamos escrito esse script para corrigir outro erro, e essa é a única razão por que não passei a manhã inteira nisto.

Barra 11,206, 2025-03-14T08:00 UTC. O capital era idêntico na barra anterior até 13 algarismos significativos. Na barra 11,206, os hashes das posições diferem: a execução A está longa em SOL, a execução B está longa em AVAX. Mesmo lado, mesmo valor nocional, ativo diferente. Tudo o que se segue decorre daí.

Por isso, imprimi os dados de entrada da ordenação 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 registado uma barra de 4 horas sem transações dentro da janela retrospetiva; assim, o rácio entre o fecho atual e o anterior deu 1 e o logaritmo deu zero. Não foi um arredondamento para zero. Foi zero.

Dois símbolos empatados no 5.º lugar. A seleção escolhia os 5 primeiros. Qual dos dois entrava dependia da ordem em que a ordenação os deixava, e a ordenação não estava a fazer o que eu supunha.

Um empate, uma ordenação instável e o pormenor que ignorei durante 2 anos

A classificação passava por uma agregação agrupada, e o frame 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 obtenção assíncrona de dados. A ordem de iteração do conjunto varia com a semente de hash, e o Python aleatoriza a semente de hash das strings em cada processo, a menos que se fixe PYTHONHASHSEED. Por isso, a ordem das linhas antes da ordenação diferia entre execuções e, como a ordenação não era estável para uma chave empatada, o empate era resolvido de forma diferente.

Empates destes não são casos raros. São estruturais. Sempre que uma variável satura ou é limitada, cria-se uma igualdade exata: barras sem volume dão retornos exatamente iguais a zero, uma pontuação z limitada fixa-se em ±3.0, uma transformação em posições com poucos valores distintos produz dezenas de empates, um filtro booleano atribui 1.0 a tudo o que passa. Ao longo de 18 meses com barras de 4 horas, esta execução teve 47 barras com um empate no limite da seleção. Em 3 delas, o conjunto selecionado mudou. Nas restantes, o empate era entre dois ativos que já estavam ambos dentro ou ambos fora.

Durante cerca de um dia, tive a certeza de que o carregador de dados era não determinístico, porque essa era a resposta mais empolgante. Não era. Nunca é. É um conjunto, um empate e uma suposição sobre a estabilidade da ordenação que ninguém documentou.

Porque é que 3 operações valem 0.15 de Sharpe

É aqui que as pessoas contestam, e a resposta é que um backtest com dimensionamento por fração do capital é um sistema dependente da trajetória. Dimensionar cada perna a 8% do capital atual significa que uma diferença no capital na barra n altera todos os valores nocionais a partir da barra n.

A primeira divergência, por si só, custou muito pouco. A perna em AVAX da execução B perdeu 2.1% ao longo de 9 horas; a perna em SOL da execução A ganhou 0.4%. Diferença de capital após essa operação: 0.21%. Irrelevante. Mas, a partir daí, as duas execuções já não eram a mesma estratégia. Mantinham posições de dimensão ligeiramente diferente, por isso acumulavam funding ligeiramente diferente; além disso, 2 dos empates posteriores no limite da seleção foram novamente resolvidos de forma diferente, porque as pontuações que os alimentavam já provinham de posições mantidas ligeiramente diferentes. Um deles ocorreu em 2025-03-27, um dia antes de uma tendência de 6 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 mais tarde.

Diferença de retorno: 3.4 pontos. A diferença no Sharpe é maior do que a diferença no retorno sugere, porque as operações reordenadas da execução B coincidiram com um período mais volátil; o denominador subiu enquanto o numerador desceu. Causa pequena, 2 amplificadores.

Se o dimensionamento for por valor nocional fixo e as entradas não dependerem das posições atuais, fica muito mais protegido. A maioria das estratégias interessantes não é assim.

Os 5 pontos onde isto acontece

OrigemSintomaSolução
PYTHONHASHSEED sem semente fixa, com a ordem de iteração de conjuntos/dicionários a alimentar uma ordenaçãoA resolução dos empates varia entre execuções; a primeira divergência ocorre numa barra específicaFixe a semente; ordene por uma chave secundária explícita (símbolo) para resolver os empates de forma determinística
Ordenação não estável com uma chave empatada (quicksort predefinido no NumPy/pandas)O mesmo que acima; persiste mesmo com a semente fixakind="stable", ou torne a chave única
Gerador de números aleatórios sem semente em bootstrap, baralhamentos de treino/teste ou variação sintética de execuçãoDeriva ao longo de toda a execução, sem um ponto de divergência claroUse uma semente explícita por componente e registe-a no manifesto da execução
Redução paralela de valores de vírgula flutuante (ordem de soma dependente do número de threads)Diferenças nos últimos algarismos, geralmente inofensivas até cruzarem um limiar de comparaçãoFixe o número de threads nas execuções de investigação; nunca compare valores de vírgula flutuante com == num limite de decisão
Versões de bibliotecas sem controlo de versão fixoReprodutível hoje, não em novembroInclua o hash do lockfile no manifesto, junto com o hash do instantâneo dos dados

A quarta linha é menos importante do que as pessoas receiam; a primeira causa problemas constantemente.

A reprodutibilidade bit a bit é uma ferramenta, não uma virtude

Queremos determinismo para que, quando alteramos uma linha, possamos atribuir a essa linha a diferença na curva de capital. É esse o motivo. Agora, cada execução de agente no Stratmill regista um manifesto com o hash do instantâneo dos dados, o hash do lockfile e todas as sementes; uma nova execução que não reproduza a curva anterior bit a bit é uma compilação falhada, não uma curiosidade.

Mas, assim que conseguir reproduzir os resultados, quebre deliberadamente essa reprodutibilidade. Execute o sistema 64 vezes com 64 sementes e observe a dispersão:

A banda de variação. Mesma estratégia, mesmos dados, 64 permutações de resolução de empates e ordem de execução, com sementes fixas. Sharpe p5 1.12, mediana 1.27, p95 1.41. Largura da banda: 0.29.

Agora volte à pesquisa de parâmetros. A melhor configuração teve uma pontuação de 1.46. A configuração em 40.º lugar entre 96 teve 1.31. A diferença entre elas é 0.15, metade da banda. A pesquisa não ordenou essas 2 configurações. Amostrou um resultado de cada uma das suas distribuições e ordenou os resultados.

Essa nova perspetiva mudou a forma como escolhemos. O resultado de uma pesquisa só constitui uma classificação se as diferenças entre configurações forem maiores do que a variação de uma única configuração. Numa estratégia dependente da trajetória com 1,400 operações, a variação costuma ser suficiente para transformar o terço superior da classificação num empate. Quando isso acontece, escolha com base em algo que a banda não consiga ocultar: menor rotação da carteira, menos parâmetros, uma hipótese de custos que consiga defender perante um cético, ou melhor comportamento na divisão walk-forward de que menos gosta. Esses são critérios reais para desempatar. Uma vantagem de 0.15 no Sharpe não é.

Antes de confiar nos resultados, há mais uma coisa que vale a pena fazer. Execute o seu backtest 2 vezes agora mesmo, compare o capital por barra e descubra se está no grupo dos resultados idênticos bit a bit ou no grupo da diferença de 0.15. É uma experiência de 15 minutos que lhe diz quanto da sua investigação mediu a estratégia e quanto mediu uma semente de hash.

determinismobacktestingengenharia de dadossobreajustepython
← Todos os artigos