Backtest reproduzível em Python sem chave de API
Execute um backtest reproduzível em Python sem chave de API, com instalação fixada, dados de exemplo determinísticos e uma leitura honesta da curva de capital.
Um backtest reproduzível em Python é aquele que outra pessoa consegue executar novamente numa máquina limpa e obter exatamente os mesmos números, sem que uma conta ou uma chave de API sejam necessárias. A maioria dos tutoriais falha nesse teste já na primeira linha, quando um download em tempo real entrega ao leitor seguinte um histórico de preços ligeiramente diferente daquele recebido pelo autor. Este guia fixa um único motor open source, quantjourney-bt, na versão 0.12.4, executa o exemplo incluído sem credenciais e, depois, usa dados reais de mercado para mostrar o que uma única execução limpa ainda não consegue revelar.
O que torna um backtest reproduzível?
Aqui, reprodutibilidade tem um significado restrito e verificável: uma segunda pessoa executa um comando numa máquina limpa e obtém exatamente os mesmos números, até à última casa decimal. Duas situações comuns quebram esse resultado.
A primeira é o código. Uma biblioteca na versão 0.x não oferece garantia de compatibilidade entre versões menores. Uma função renomeada, um valor padrão alterado ou uma coluna reordenada podem fazer o script continuar a funcionar, mas apresentar silenciosamente outro resultado.
A segunda é o dado. Um tutorial cuja primeira linha faz um download em tempo real nunca foi reproduzível. Os fornecedores revêm o histórico, ajustam os dados por desdobramentos e preenchem lacunas. Por isso, o mesmo script pode apresentar números diferentes um mês depois. Posteriormente, não é possível distinguir uma alteração no código de uma alteração nos dados.
O padrão deste projeto que vale a pena reproduzir é combinar uma versão fixa do mecanismo com um pequeno conjunto de dados incluído no próprio pacote.
Fixe a versão: pip install quantjourney-bt==0.12.4
quantjourney-bt é o backtester QuantJourney, distribuído sob a Apache License 2.0 e compatível com Python 3.11 ou posterior. A versão 0.12.4 foi publicada em 21 de julho de 2026, e é a versão referida por todos os comandos abaixo, conforme a documentação de agosto de 2026.
Trabalhe em um ambiente isolado. python3 -m venv .venv cria o ambiente, source .venv/bin/activate entra nele e python -m pip install -U pip atualiza o instalador dentro dele. Em seguida, fixe a versão exata: pip install quantjourney-bt==0.12.4.
O projeto documenta a forma sem fixação de versão, pip install quantjourney-bt. A parte ==0.12.4 é sua responsabilidade e, nas versões 0.x, é especialmente importante. Registre a fixação onde a próxima pessoa possa encontrá-la: pip freeze > requirements.txt captura todas as dependências resolvidas, inclusive as que você nunca declarou.
Há dois extras opcionais. pip install "quantjourney-bt[wf]" adiciona o Optuna para os exemplos de walk-forward e otimização. pip install "quantjourney-bt[data]" adiciona um fallback com yfinance usado nos benchmarks.
A licença Apache-2.0 é permissiva. Você pode usar e modificar o código comercialmente, desde que mantenha os arquivos de licença e de avisos em qualquer redistribuição. Os contribuidores também concedem expressamente direitos sobre patentes.
Como executar o exemplo de SMA incluído sem uma chave de API
O repositório inclui um script de inicialização junto com cinquenta estratégias de exemplo executáveis. Elas estão divididas entre um fluxo baseado em pesos e outro baseado em ordens. Entre elas, há cinco workflows de walk-forward. ./strategy.sh --list exibe o catálogo. ./strategy.sh example_weights_01_sma_daily --check importa uma única estratégia e não acessa nenhum dado. É a confirmação mais rápida de que a instalação está correta.
A execução da demonstração é feita em uma linha: ./strategy.sh example_weights_01_sma_daily --sample-data --output /tmp/qj-sample
A flag --sample-data concentra o objetivo do exemplo. O projeto descreve assim o dataset usado:
O dataset de exemplo é intencionalmente pequeno e reproduzível. Ele é útil para verificar a instalação, gerar relatórios e acompanhar o fluxo do engine sem criar uma conta.
Fonte: README do quantjourney-bt, versão 0.12.4, consultado em 6 de agosto de 2026.
A execução grava um diretório, e não uma mensagem de resultado no console: summary.txt e summary.json, um metrics.csv, um equity_curve.csv ao lado do respectivo equity_curve.png, um dashboard.html, uma pasta plots/ e um run_metadata.json que registra como a execução foi configurada. Esse último arquivo é o que a maioria das pessoas ignora. É também o que permite auditar um resultado um ano depois.
Interprete as métricas resultantes com cautela. O dataset incluído é pequeno e ilustrativo. Portanto, o índice de Sharpe e o drawdown máximo exibidos em summary.txt descrevem um arquivo de amostra. Eles não são evidência sobre uma estratégia. Tratá-los como um resultado é o primeiro erro possível.
A execução comprova algo importante: a instalação funciona, e o fluxo completo do engine, do sinal aos pesos-alvo e à reconstrução do valor da carteira, gera seus artefatos na sua máquina sem uma única credencial. Existe um fluxo com credenciais para acessar o serviço de dados do próprio projeto e fazer backtests com histórico real. Esse fluxo está documentado. Este passo a passo termina aqui, na etapa que não exige nada de terceiros.
O que uma única curva de capital dentro da amostra não mostra
A execução de amostra gera uma curva de capital. Veja o que essa curva não consegue mostrar, medido com dados reais de mercado em vez de um arquivo de demonstração.
O painel abaixo aplica ao SPY a mesma ideia usada pela estratégia de exemplo: uma média móvel de 20 sessões cruzando uma de 50 sessões. O resultado é apresentado separadamente para cada ano-calendário, de 2017 a 2025. A posição em cada sessão é definida pelo fechamento da sessão anterior. Assim, a regra nunca negocia com base em um número que ainda não estava disponível.
O SQL exato por trás de cada número
WITH daily AS
(
SELECT
toDate(toTimeZone(window_start, 'America/New_York')) AS d,
toFloat64(argMax(close, window_start)) AS px
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY'
AND window_start >= '2016-01-01 00:00:00'
AND window_start < '2026-01-01 05:00:00'
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) >= 570
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) < 960
GROUP BY d
),
averaged AS
(
SELECT
d,
px,
avg(px) OVER (ORDER BY d ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS fast_ma,
avg(px) OVER (ORDER BY d ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS slow_ma,
row_number() OVER (ORDER BY d) AS session_no
FROM daily
),
positioned AS
(
SELECT
d,
px,
if(session_no >= 50 AND fast_ma > slow_ma, 1, 0) AS long_today,
lagInFrame(if(session_no >= 50 AND fast_ma > slow_ma, 1, 0), 1)
OVER (ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS long_prior,
lagInFrame(px, 1)
OVER (ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS px_prior
FROM averaged
)
SELECT
toYear(d) AS year,
round((exp(sum(log(if(long_prior = 1, px / px_prior, 1.0)))) - 1) * 100, 1) AS rule_pct,
round((exp(sum(log(px / px_prior))) - 1) * 100, 1) AS hold_pct,
countIf(long_today != long_prior) AS crossover_count
FROM positioned
WHERE px_prior > 0
AND toYear(d) >= 2017
GROUP BY year
ORDER BY yearUma única regra, medida 9 vezes separadas. Leia primeiro as duas colunas de retorno percentual. Em 2017, a regra encerrou o ano em 16%, contra 19.4% para manter o SPY durante o mesmo período. Em 2025, essas mesmas duas colunas mostram 10.4% e 16.4%. O código é idêntico nas duas linhas. Apenas a janela mudou.
A coluna de cruzamentos mostra como a evidência subjacente é limitada. 4 mudanças de posição em 2025 significam que um ano inteiro de curva de capital se baseia em poucas decisões. É uma amostra muito pequena para ser tratada como um resultado.
A mesma regra se comporta da mesma forma em outros ativos?
Alterar a janela de datas é uma forma de analisar uma única curva. Alterar o universo é a outra. O painel abaixo mantém os parâmetros fixos e aplica a mesma regra a cinco ativos líquidos ao longo dos cinco anos-calendário, de 2021 a 2025.
O SQL exato por trás de cada número
WITH daily AS
(
SELECT
ticker,
toDate(toTimeZone(window_start, 'America/New_York')) AS d,
toFloat64(argMax(close, window_start)) AS px
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker IN ('SPY', 'QQQ', 'AAPL', 'MSFT', 'KO')
AND window_start >= '2020-07-01 00:00:00'
AND window_start < '2026-01-01 05:00:00'
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) >= 570
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) < 960
GROUP BY ticker, d
),
averaged AS
(
SELECT
ticker,
d,
px,
avg(px) OVER (PARTITION BY ticker ORDER BY d ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS fast_ma,
avg(px) OVER (PARTITION BY ticker ORDER BY d ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS slow_ma,
row_number() OVER (PARTITION BY ticker ORDER BY d) AS session_no
FROM daily
),
positioned AS
(
SELECT
ticker,
d,
px,
lagInFrame(if(session_no >= 50 AND fast_ma > slow_ma, 1, 0), 1)
OVER (PARTITION BY ticker ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS long_prior,
lagInFrame(px, 1)
OVER (PARTITION BY ticker ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS px_prior
FROM averaged
)
SELECT
ticker AS symbol,
round((exp(sum(log(if(long_prior = 1, px / px_prior, 1.0)))) - 1) * 100, 1) AS rule_pct,
round((exp(sum(log(px / px_prior))) - 1) * 100, 1) AS hold_pct,
round(avg(long_prior) * 100, 0) AS days_long_pct
FROM positioned
WHERE px_prior > 0
AND d >= toDate('2021-01-01')
GROUP BY symbol
ORDER BY rule_pct DESCQQQ aparece no topo do painel, com 40.2%, enquanto a linha inferior, KO, fica em 3.8%. A coluna days_long_pct mostra quanto da janela cada versão passou mantendo alguma posição, 67% no caso da linha superior. Um conjunto de parâmetros, cinco universos e uma dispersão ampla o suficiente para que escolher o vencedor depois do fato não diga nada sobre a execução que ainda não foi realizada.
Nada disso é uma recomendação para operar uma estratégia de crossover. O crossover é uma referência para o backtest, e o backtest é o que estamos avaliando.
Onde o look-ahead bias entra em um backtest baseado em pesos
Um mecanismo baseado em pesos transforma um sinal em pesos-alvo, simula as execuções com base nesses pesos e depois reconstrói o valor da carteira a partir das posições resultantes. A falha se esconde na ligação entre o sinal e o peso. Se o peso de hoje vem do fechamento de hoje e depois captura o retorno de hoje, o backtest negociou com informação que ainda não existia quando a ordem teria sido enviada. Isso é viés de antecipação, e não gera nenhum erro. Apenas faz tudo parecer melhor.
O painel abaixo executa as duas versões de uma regra sobre o mesmo histórico de SPY.
O SQL exato por trás de cada número
WITH daily AS
(
SELECT
toDate(toTimeZone(window_start, 'America/New_York')) AS d,
toFloat64(argMax(close, window_start)) AS px
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY'
AND window_start >= '2016-01-01 00:00:00'
AND window_start < '2026-01-01 05:00:00'
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) >= 570
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) < 960
GROUP BY d
),
averaged AS
(
SELECT
d,
px,
avg(px) OVER (ORDER BY d ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS fast_ma,
avg(px) OVER (ORDER BY d ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS slow_ma,
row_number() OVER (ORDER BY d) AS session_no
FROM daily
),
positioned AS
(
SELECT
d,
px,
if(session_no >= 50 AND fast_ma > slow_ma, 1, 0) AS long_today,
lagInFrame(if(session_no >= 50 AND fast_ma > slow_ma, 1, 0), 1)
OVER (ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS long_prior,
lagInFrame(px, 1)
OVER (ORDER BY d ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS px_prior
FROM averaged
)
SELECT
toYear(d) AS year,
round((exp(sum(log(if(long_prior = 1, px / px_prior, 1.0)))) - 1) * 100, 1) AS next_bar_pct,
round((exp(sum(log(if(long_today = 1, px / px_prior, 1.0)))) - 1) * 100, 1) AS same_bar_pct,
round(abs(exp(sum(log(if(long_today = 1, px / px_prior, 1.0))))
- exp(sum(log(if(long_prior = 1, px / px_prior, 1.0))))) * 100, 1) AS gap_pp
FROM positioned
WHERE px_prior > 0
AND toYear(d) >= 2017
GROUP BY year
ORDER BY yearEm 2017, a versão baseada na sessão anterior registrou 16%, enquanto a versão baseada na mesma sessão registrou 17.1%, uma diferença de 1.1 pontos percentuais. Em 2025, a distância entre as duas mediu 1.6 pontos percentuais. Apenas uma dessas colunas pode ser produzida por uma máquina que ainda não soubesse onde a sessão terminaria, e a diferença entre elas é pura contabilidade, sem tese, habilidade ou operação por trás.
Este mecanismo declara sua própria posição sobre a questão do timing, o que vale mais do que uma promessa:
Para execuções na abertura, o slippage sensível à amplitude usa apenas a barra anterior já concluída, e a capacidade de volume é projetada a partir de observações defasadas; o mecanismo não usa a máxima, a mínima, o fechamento nem o volume total do dia em questão.
Fonte: README do quantjourney-bt, versão 0.12.4, consultado em 6 de agosto de 2026.
Uma premissa documentada pode ser verificada no código-fonte que você já instalou. Uma premissa não documentada é apenas uma suposição.
Por que o extra walk-forward existe
Os exemplos walk-forward, de WF01 a WF05, vêm com o extra [wf] e sua dependência do Optuna. O walk-forward ajusta os parâmetros em um recorte do histórico, mede o desempenho no recorte seguinte, avança o par de janelas e repete o processo. As variantes rolling e expanding diferem quanto à janela de ajuste: na primeira, os dados mais antigos são descartados à medida que a janela avança; na segunda, eles permanecem e a janela se amplia. Outro exemplo acrescenta purge e embargo em cada fronteira. Ele elimina as observações mais próximas da junção, para que o recorte usado no ajuste não vaze para o recorte em que será medido.
Nada disso transforma uma ideia fraca em uma estratégia funcional. O método substitui um único número por uma distribuição de números que pode ser questionada. Essa é toda a melhoria. A etapa seguinte ainda não envolve dinheiro real: o paper trading antes do dinheiro real mede o que um backtest não consegue observar estruturalmente, começando por verificar se sua ordem é executada a um preço próximo daquele assumido pelo simulador. Já os circuit breakers para bots de trading limitam o que seu código fará no dia em que algo der errado. Para as estatísticas que sustentam todo o processo, nossas notas sobre o livro de trading quantitativo de código aberto aprofundam o tema.
Perguntas frequentes
É possível fazer backtest de uma estratégia sem uma chave de API?
Sim. O quantjourney-bt inclui um conjunto de dados de exemplo incorporado, disponibilizado com a flag --sample-data, e as estratégias de exemplo são executadas com ele sem conta nem credenciais. O conjunto é pequeno e ilustrativo. Portanto, trate essa execução como uma verificação da instalação e do pipeline, não como evidência sobre uma estratégia.
Por que fixar a versão de um pacote Python de backtesting?
Um pacote na versão 0.x não oferece garantia de compatibilidade entre versões secundárias. Uma alteração no padrão ou a renomeação de uma métrica também pode ocorrer sem aviso. Fixar a versão com pip install quantjourney-bt==0.12.4 e registrar o ambiente em um arquivo de requisitos permite reproduzir, no próximo ano, um resultado obtido hoje usando o mesmo motor que o gerou.
Sob qual licença o quantjourney-bt é distribuído?
Apache License 2.0. Ela permite uso comercial e modificação, exige que os arquivos de licença e de avisos sejam mantidos em qualquer redistribuição e inclui uma concessão explícita de patentes pelos contribuidores. A versão 0.12.4 foi publicada em 21 de julho de 2026 e requer Python 3.11 ou mais recente.
Um resultado forte de backtest significa que a estratégia funciona?
Não. Um backtest é uma medição feita em uma janela, sobre um universo. Os painéis acima mostram uma única regra, sem alterações, gerando resultados anuais muito diferentes em um ticker e resultados muito diferentes entre cinco nomes. É essa diferença que a validação walk-forward e os testes fora da amostra procuram revelar.
Como os painéis acima foram calculados
Os fechamentos diários são o último print da sessão regular de cada data, obtido no horário de Nova York entre 9h30 e 16h. Isso mantém corretos os dias de meio período com fechamento antecipado, sem fixar manualmente a duração da sessão. A média rápida cobre 20 sessões e a média lenta, 50, ambas simples. As primeiras 49 sessões de cada série são o período de aquecimento e não mantêm posição. Os números anuais capitalizam a variação entre o fechamento de uma sessão e o da sessão seguinte em cada sessão na qual a regra manteve posição comprada. A coluna de posição mantida capitaliza todas as sessões do mesmo ano para comparação. Os cinco nomes da análise transversal foram escolhidos por terem históricos contínuos e nenhuma divisão de ações dentro da janela. Por isso, a série de fechamentos não precisa de ajuste. As janelas estão fixadas no passado. Assim, esses painéis retornam os mesmos números a cada nova geração.
Cada painel nesta página traz o SQL exato logo abaixo. Isso torna os números reproduzíveis, assim como a instalação com versão fixada. Para medir uma regra na sua própria janela antes de escrever qualquer código de backtest, faça a pergunta em inglês simples no terminal Strasmore.