Um Bug de Timezone Quase Me Fez Abandonar uma Estratégia Lucrativa
Cinco linhas de parsing errado de timestamp transformaram +350 pips em -870 pips. Quase matei uma estratégia validada porque esqueci que o MetaTrader exporta em EET, não UTC. Uma história de debugging forense com números reais, config drift, e a verdade desconfortável de que backtests concordando ≠ prova ao vivo.
−870 Pips em Cinco Linhas
Semanas construindo e validando uma estratégia quantitativa de FX. Backtest walk-forward em 2,5 anos de tick data da Darwinex: 302 trades, 59,6% de win rate, profit factor 1,48, sobreviveu cinco rodadas de deflação honesta. Deploy ao vivo num VPS com MetaTrader 5.
Primeira semana: 1 win, 5 losses, −€504. Cinco por cento de drawdown em dias.
Re-backtestei em dados independentes — barras OHLC do próprio MT5 em vez de ticks da Darwinex — pra checar se o edge era real ou artefato. Se a estratégia reproduzisse em dados diferentes, os losses ao vivo eram variância. Se não, curve-fitting.
O re-backtest: 445 trades, 15,5% win rate, −870 pips. Catastrófico.
Minha conclusão: “O edge só existe nos dados da Darwinex. Overfitting.” Estratégia abandonada.
Errado. O re-backtest tinha um bug de timezone que deslocou cada barra em 2–3 horas, destruindo o sinal completamente. Corrigido: 401 trades, 51,9% WR, +350,6 pips, PF 1,21.
A estratégia funciona. Quase a matei por cinco linhas de parsing de timestamp.
EET ≠ UTC
O MetaTrader 5 exporta timestamps em CSV no horário do servidor Darwinex — EET/EEST (Eastern European Time). UTC+2 no inverno, UTC+3 no verão.
Meu código de re-backtest tratou como UTC.
CSV do MT5 diz: 2023-06-15 14:00:00
Eu parseei como: 2023-06-15 14:00:00 UTC
Na verdade era: 2023-06-15 14:00:00 EEST = 2023-06-15 11:00:00 UTC
Três horas no verão. Duas no inverno. Sem erro. Sem crash. Output limpo com 445 trades. Só os 445 trades errados.
Por Que Três Horas Matam Esta Estratégia
Uma média móvel não liga pra timezone — um EMA de preço é um EMA de preço independente dos rótulos das barras. Mas esta estratégia é construída sobre estrutura temporal:
-
Filtros de sessão. London Open é 07:00–11:00 UTC. Desloca 3 horas e você tá colocando ordens durante a calmaria da sessão asiática. O edge se concentra nas transições de sessão — mostramos isso no post do filtro NN. Timezone errado, sessões erradas, zero edge.
-
Limites dos candles. Uma barra de 30 minutos começando às 14:00 EET captura price action diferente de uma começando às 14:00 UTC. O padrão multi-bar que gera o sinal de entrada é sensível ao tempo. Desalinha as barras e você detecta setups que não existem — ou perde os que existem.
-
Cálculos de range diário. Zonas de range multi-timeframe calculadas a partir de barras diárias. Desloca o limite do dia e toda zona se desloca junto.
-
Filtragem de fim de semana. Fechamento de sexta às 23:30 EET vira sábado 01:30 UTC. O código vê “barras de fim de semana” e inclui candles fantasma ou exclui dados válidos de sexta.
Resultado: 2,2× mais sinais detectados (ruído de limites de candle desalinhados), zero sobreposição com os setups validados, filtro de sessão checando horários completamente errados. Lixo entra, −870 pips sai.
O Teste do Fechamento de Sexta
Barras de fechamento de sexta-feira sempre terminam às 23:30 no CSV — tanto no inverno quanto no verão. Mercados FX fecham por volta de 21:00–22:00 UTC nas sextas:
23:30 EET = 21:30 UTC (inverno) ✓ — bate com fechamento FX
23:30 EEST = 20:30 UTC (verão) ✓ — bate com fechamento FX
23:30 UTC = ... 1,5h depois do fechamento? Impossível.
Cross-reference de OHLC: uma barra rotulada “14:00” no CSV do MT5 bateu exatamente com a barra de 11:00 UTC da Darwinex. Offset de 3 horas. Verão. EEST.
Transições de DST confirmadas: offset muda no último domingo de março e outubro, exatamente seguindo as regras EET/EEST.
A Correção
from zoneinfo import ZoneInfo
EET_TZ = ZoneInfo("Europe/Bucharest") # EET/EEST com DST automático
# Antes (errado):
dt = datetime.strptime(row["timestamp"], "%Y-%m-%d %H:%M:%S")
ts_ms = int(dt.replace(tzinfo=timezone.utc).timestamp() * 1000)
# Depois (correto):
naive_dt = datetime.strptime(row["timestamp"], "%Y-%m-%d %H:%M:%S")
local_dt = naive_dt.replace(tzinfo=EET_TZ)
utc_dt = local_dt.astimezone(timezone.utc)
ts_ms = int(utc_dt.timestamp() * 1000)
Cinco linhas. −870 pips → +351.
Os Números
| Métrica | Quebrado (parseado como UTC) | Corrigido (EET→UTC) | Backtest golden (tick data) |
|---|---|---|---|
| Trades | 445 | 401 | 302 |
| Win rate | 15,5% | 51,9% | 59,6% |
| Profit factor | negativo | 1,21 | 1,48 |
| Pips totais | −870 | +350,6 | +463,5 |
Os resultados do MT5 corrigido não batem exatamente com o backtest golden. Esperado:
- Gap de resolução. O backtest golden resolve SL/TP em tick data real com granularidade de milissegundos. Re-backtest do MT5 usa resolução de barra — high/low das barras subsequentes, sempre checando SL antes do TP. Viés conservador embutido.
- Diferenças de fonte de dados. Barras do MT5 são pré-agregadas pela corretora; backtest golden agrega de ticks raw. Diferenças menores de OHLC existem.
- Mesma direção. Ambos lucrativos, ambos com PF positivo, ambos mostram longs superando shorts. A versão bar-level é um piso, não um teto.
Cinco Bugs a Mais Escondidos à Vista
O timezone não estava sozinho. O pipeline live tinha desviado da config validada — cinco parâmetros, cada um uma decisão de deploy “razoável”, cada um erodindo o edge:
| Parâmetro | Backtest golden | Live (quebrado) | O que fez |
|---|---|---|---|
| Tamanho mínimo do sinal | 3,0 pips | 0,5 pips | 6× mais setups, maioria ruído |
| Lookback 30m | Histórico completo | 500 barras (~10 dias) | Perdeu sinais válidos mais antigos (estratégia usa setups de até 20 dias) |
| Lookback diário | Histórico completo | 90 barras | Insuficiente pra cálculos de range multi-semana |
| Barras de fim de semana | Filtradas | Incluídas | Candles fantasma criando sinais falsos |
| Filtro de sessão | Rejeição hard | Apenas label | Permitiu trades fora de London/NY (onde o edge não existe) |
O equivalente quant de deployar código que não bate com o que foi testado. Cada mudança fazia sentido isoladamente — “0,5 pips pega mais setups,” “500 barras economiza memória.” Juntas, destruíram o edge.
Sinais Pequenos São Ruído
Parameter sweep no tamanho mínimo do sinal pra verificar que 3,0 pips não é arbitrário:
| Threshold | Sinais | Trades | WR% | PF | Pips |
|---|---|---|---|---|---|
| 0,5 | 6.966 | 467 | 52,2% | 1,14 | +256 |
| 1,0 | 5.844 | 459 | 52,3% | 1,16 | +281 |
| 2,0 | 4.273 | 436 | 52,1% | 1,17 | +298 |
| 3,0 | 3.220 | 401 | 51,9% | 1,21 | +351 |
| 4,0 | 2.496 | 355 | 52,1% | 1,25 | +386 |
| 5,0 | 1.934 | 296 | 49,3% | 1,22 | +318 |
Sinais sub-3-pip são ruído de microestrutura, não deslocamento de preço significativo. Os trades extras diluem o edge. Em 4,0 o PF melhora mais, mas trade count cai pra 355. Em 5,0 desaba. O sweep valida 3,0 como piso sensato, não ótimo cherry-picked.
Dois Backtests, Zero Prova ao Vivo
Dois backtests independentes agora concordam: a versão golden com tick data (+463 pips, PF 1,48) e a versão MT5 bar-level corrigida (+351 pips, PF 1,21). Ambos lucrativos. Ambos mostram os mesmos padrões estruturais — edge de sessão, longs > shorts, dominância de segunda-feira.
Backtests concordando entre si não é backtests concordando com a realidade. A estratégia não foi re-testada ao vivo com os parâmetros corrigidos. A semana 1 com −€504 teve múltiplas causas: bug de timezone, config drift, e possivelmente apenas variância numa amostra pequena.
O percentil 5 do Monte Carlo do backtest golden foi +188 pips. O edge é real mas fino. Um filtro de rede neural empurra o profit factor de 1,48 pra 2,22 descartando setups de baixa confiança — mas nem isso enfrentou mercados ao vivo com a config corrigida.
Estou tradando de novo. De olhos abertos.
A Perícia
Bugs de timezone são assassinos silenciosos. Sem exceções, sem crashes, sem warnings. Execução limpa, output plausível. Você nunca saberia a não ser que checasse as barras de fechamento de sexta.
“Não funciona em dados diferentes” frequentemente é “você processou os dados errado.” Quando você vê −870 pips, o caminho de menor resistência emocional é “nunca foi real.” Às vezes a explicação é mais mundana: você alimentou a estratégia com lixo e ela devolveu lixo.
Config drift é bug de deploy. Engenheiros de software têm pipelines CI/CD pra isso. Quants deployam com parâmetros “perto o suficiente” e se perguntam por que o live diverge do backtest. Trate config de trading como código — versione, teste, não faça no olho.
Timestamps de FX são campo minado. API Python do MT5 retorna epochs UTC. Export CSV do MT5 usa horário do servidor. Darwinex usa EET/EEST. Outras corretoras usam EST, GMT, ou horário local próprio. Nunca assuma UTC. Verifique contra âncora conhecida — fechamento de sexta é a mais fácil.
Cross-validation exige alinhamento de timezone. Usar dados do MT5 pra validar ticks da Darwinex é correto em princípio. A execução introduziu um bug pior do que o que tentava detectar.
Posts anteriores nesta série: Começando pelo Fim (validação Monte Carlo), De PyTorch a 27 Megabytes (filtro NN de trades). Ambos referenciam a mesma estratégia antes desse bug ser descoberto.