Paridade backtest-live: por que seu bot opera diferente do backtest
Você rodou uma estratégia em um backtest. Sharpe 2,1, MaxDD -8%, PnL +67%. Você lançou o bot. Um mês depois você compara: os mesmos sinais, o mesmo período — mas o PnL ao vivo é 40% menor. O drawdown é uma vez e meia mais profundo. Duas em cada dez operações simplesmente não foram executadas.
Isso não é um bug. Isso é divergência backtest-live — uma discrepância sistemática entre os resultados do backtest e o trading real. Todo mundo tem esse problema. A única questão é se você sabe disso e se consegue controlá-lo.
Este artigo apresenta uma taxonomia completa das divergências, padrões arquiteturais para minimizá-las e uma checklist prática para monitorar a paridade em produção.
A síndrome do "funcionou no backtest"

Todo algotrader passa por esse ciclo:
- Escreveu uma estratégia em um notebook Jupyter
- Rodou um backtest em CSV histórico — os resultados são ótimos
- Reescreveu a lógica como um bot (frequentemente em outra linguagem ou framework)
- Lançou — os resultados não coincidem
- Procurou um bug, não encontrou nenhum — "o mercado mudou"
O problema não é o mercado. O problema é que o backtest e o bot são dois produtos de software diferentes que modelam a mesma realidade de maneiras distintas. Divergências são inevitáveis, mas podem ser sistematizadas e minimizadas.
Taxonomia das divergências

Todas as fontes de divergência se enquadram em quatro categorias. Para cada uma — uma classificação de severidade (de 1 a 5) e uma contribuição típica para a divergência de PnL.
1. Divergências de dados (severidade: 3/5)
Os dados que o backtest vê e os dados que o bot vê em tempo real não são a mesma coisa.
Timestamps. As exchanges entregam candles com regras diferentes de atribuição de timestamp. Uma exchange marca o candle com o início do período, outra com o final. Uma API REST pode retornar um candle com atraso de 1-3 segundos após o fechamento real. O backtest trabalha com timestamps "ideais" do arquivo histórico.
Agregação OHLCV. Os dados históricos costumam ser agregados pelo provedor de forma diferente da que a exchange usa em tempo real. A diferença está no último dígito — mas com sinais de limiar (cruzamento de médias móveis, rompimento de nível) isso determina se a estratégia entra em uma posição ou não.
Lacunas e dados faltantes. Os dados históricos costumam estar limpos — candles faltantes são preenchidos por interpolação. Em tempo real, um WebSocket pode cair, e o bot perde 30 segundos de dados.
Contribuição típica para a divergência de PnL: 2-5% do PnL anual.
2. Divergências de execução (severidade: 5/5)

A classe mais perigosa de divergências. O backtest simula a execução perfeitamente — a realidade está longe de ser ideal.
Slippage. O backtest preenche a ordem ao preço de fechamento (ou ao preço do sinal). Na realidade, uma ordem a mercado é executada no melhor bid/ask mais um slippage que depende do volume e da liquidez. Para uma posição de $10K em uma altcoin de liquidez média, o slippage pode ser de 0,05-0,3%.
Fórmula para slippage acumulado em operações:
onde é o slippage da -ésima operação, dependendo da profundidade do livro de ofertas:
Latência. Do momento em que um sinal é gerado até a execução da ordem, passa-se tempo: cálculo do sinal (1-50 ms), transmissão da requisição (10-200 ms), matching na exchange (1-10 ms). No backtest, a latência = 0. Ao vivo, o preço pode se mover.
Execuções parciais. O backtest assume que 100% da ordem é preenchida instantaneamente. Na realidade, uma ordem limite pode ser parcialmente preenchida — ou não ser preenchida se o preço reverter. Para uma ordem a mercado em um mercado ilíquido, a ordem "desliza" por vários níveis do livro de ofertas.
Prioridade de fila. Uma ordem limite colocada no melhor preço bid não será executada imediatamente — ela entra na fila atrás de todas as ordens já colocadas naquele nível. Um backtest que considera "preço tocado = ordem executada" superestima sistematicamente a taxa de preenchimento.
Contribuição típica para a divergência de PnL: 10-30% do PnL anual.
3. Divergências de lógica (severidade: 4/5)
São divergências no próprio código da estratégia entre o backtest e o bot.
Bases de código separadas. O antipadrão clássico: backtests/strategy_a.py e bot/strategy_a.py — dois arquivos separados que "fazem a mesma coisa". Após três meses de edições, eles inevitavelmente divergem. Alguém adicionou um filtro no backtest e esqueceu de replicá-lo no bot. Ou o contrário — um bug foi corrigido no bot mas permaneceu no backtest.
Frameworks diferentes. Backtest em pandas com operações vetorizadas, bot em asyncio com lógica orientada a eventos. Mesmo com uma estratégia idêntica, casos extremos são tratados de forma diferente: arredondamento, ordem de verificação de condições, tratamento de NaN.
Gerenciamento de estado. O backtest geralmente é stateless — ele itera sobre um array de dados. O bot é stateful — armazena posições, saldos, histórico de ordens. Reinício do bot, perda de estado, dessincronização com a exchange — tudo isso são fontes de divergência.
Contribuição típica para a divergência de PnL: 5-20% do PnL anual.
4. Divergências de custos (severidade: 3/5)
Divergências na modelagem de custos de trading.
Funding rates. A maioria dos backtests de futuros perpétuos não considera as funding rates de forma alguma. Com alavancagem de 10x e uma taxa média de 0,01% a cada 8 horas, isso equivale a ao ano — mais que o PnL da maioria das estratégias. Uma análise detalhada está no artigo As funding rates destroem sua alavancagem.
Comissões. As comissões maker/taker geralmente são modeladas, mas frequentemente com a taxa errada. Níveis VIP, descontos em BNB, rebates — tudo isso afeta o resultado final.
Spread. Um backtest baseado em candles não vê o spread bid-ask. Em um candle de 1 minuto, o fechamento = 3000, mas na realidade bid = 2999,5 e ask = 3000,5. Cada operação "custa" metade do spread.
Contribuição típica para a divergência de PnL: 5-15% do PnL anual.
Efeito cumulativo
As quatro categorias atuam simultaneamente e, em regra, em uma única direção — contra o trader:
Uma divergência total de 20-50% em relação ao PnL do backtest é normal para um sistema não refinado. Com alavancagem, o efeito é multiplicado.
Padrões arquiteturais para paridade
Padrão 1: Shared Core (extração de um núcleo comum)

A ideia: extrair o núcleo da estratégia — geração de sinais e lógica de execução — para um módulo separado usado tanto pelo backtest quanto pelo bot. Apenas a infraestrutura ao redor difere: a fonte de dados e o mecanismo de envio de ordens.
┌─────────────────────────────────────┐
│ strategy_core.py │
│ ┌─────────────┐ ┌───────────────┐ │
│ │ SignalEngine │ │ OrderManager │ │
│ └──────┬──────┘ └──────┬────────┘ │
│ │ │ │
│ generate_signal() create_order()│
└─────────┬───────────────┬───────────┘
│ │
┌─────┴─────┐ ┌─────┴──────┐
│ Backtest │ │ Live │
│ DataFeed │ │ DataFeed │
│ FillModel │ │ Exchange │
└────────────┘ └────────────┘
from dataclasses import dataclass
from typing import Optional
import numpy as np
@dataclass
class Signal:
side: str # 'long' | 'short'
entry_price: float
sl_price: float
tp_price: float
size: float
timestamp: int
@dataclass
class OrderRequest:
side: str
order_type: str # 'market' | 'limit'
price: float
size: float
class StrategyCore:
"""
Strategy core. Identical code for backtest and live.
Depends only on data, not on infrastructure.
"""
def __init__(self, params: dict):
self.fast_period = params.get('fast_ma', 20)
self.slow_period = params.get('slow_ma', 50)
self.sl_pct = params.get('sl_pct', 0.02)
self.tp_pct = params.get('tp_pct', 0.04)
self.position: Optional[Signal] = None
self._closes: list[float] = []
def on_candle(self, timestamp: int, o: float, h: float,
l: float, c: float, v: float) -> Optional[OrderRequest]:
"""
Process a new candle. Returns an OrderRequest or None.
This method is called identically from the backtest and the bot.
"""
self._closes.append(c)
if len(self._closes) < self.slow_period:
return None
fast_ma = np.mean(self._closes[-self.fast_period:])
slow_ma = np.mean(self._closes[-self.slow_period:])
if self.position is not None:
exit_order = self._check_exit(h, l, c)
if exit_order:
self.position = None
return exit_order
if self.position is None:
if fast_ma > slow_ma and self._prev_fast_ma <= self._prev_slow_ma:
self.position = Signal(
side='long', entry_price=c,
sl_price=c * (1 - self.sl_pct),
tp_price=c * (1 + self.tp_pct),
size=1.0, timestamp=timestamp,
)
return OrderRequest('buy', 'market', c, 1.0)
self._prev_fast_ma = fast_ma
self._prev_slow_ma = slow_ma
return None
def _check_exit(self, high: float, low: float,
close: float) -> Optional[OrderRequest]:
pos = self.position
if pos.side == 'long':
if low <= pos.sl_price:
return OrderRequest('sell', 'market', pos.sl_price, pos.size)
if high >= pos.tp_price:
return OrderRequest('sell', 'market', pos.tp_price, pos.size)
return None
Agora o backtest e o bot usam o mesmo StrategyCore:
from strategy_core import StrategyCore
def run_backtest(candles, params, fill_model):
core = StrategyCore(params)
trades = []
for candle in candles:
order = core.on_candle(
candle['timestamp'], candle['open'], candle['high'],
candle['low'], candle['close'], candle['volume'],
)
if order:
fill_price = fill_model.simulate_fill(order, candle)
trades.append({'price': fill_price, 'side': order.side})
return trades
from strategy_core import StrategyCore
async def run_live(exchange, symbol, params):
core = StrategyCore(params)
async for candle in exchange.stream_candles(symbol, '1m'):
order = core.on_candle(
candle['timestamp'], candle['open'], candle['high'],
candle['low'], candle['close'], candle['volume'],
)
if order:
await exchange.place_order(symbol, order.side,
order.order_type, order.size)
A regra fundamental: StrategyCore não sabe de onde vêm os dados nem para onde as ordens são enviadas. Ele recebe OHLCV e retorna uma OrderRequest. Todo o resto é responsabilidade da camada de infraestrutura.
Padrão 2: Unificação orientada a eventos (abordagem NautilusTrader)

O NautilusTrader implementa a paridade por meio de um NautilusKernel unificado — um motor nativo em Rust com um núcleo determinístico orientado a eventos e resolução em nanossegundos. A mesma implementação de estratégia funciona tanto no backtest quanto no trading ao vivo.
A arquitetura é construída sobre o padrão portas e adaptadores (arquitetura hexagonal):
┌──────────────────────────────────┐
│ NautilusKernel │
│ ┌───────────┐ ┌─────────────┐ │
│ │ Strategy │ │ RiskEngine │ │
│ │ (Python) │ │ (Rust) │ │
│ └─────┬─────┘ └──────┬──────┘ │
│ │ │ │
│ ┌─────┴───────────────┴──────┐ │
│ │ Message Bus (Rust) │ │
│ └─────┬───────────────┬──────┘ │
└────────┼───────────────┼─────────┘
│ │
┌─────┴─────┐ ┌─────┴──────┐
│ Backtest │ │ Live │
│ Adapter │ │ Adapter │
│ FillModel │ │ Exchange │
│ (L2 book) │ │ Gateway │
└────────────┘ └────────────┘
Vantagens:
- Replay determinístico. Os eventos são processados em uma ordem estritamente definida — o resultado do backtest é reproduzível bit a bit.
- FillModel personalizado. Simulação do livro de ofertas L2 para cada execução — o slippage é simulado com base na profundidade real do livro de ofertas.
- Desempenho. Até 5 milhões de linhas/seg, processando dados que não cabem na RAM.
- Redis + PostgreSQL. Cache e message bus via Redis, persistência via PostgreSQL — infraestrutura idêntica para backtest e live.
Padrão 3: Strategy Interface (abordagem Freqtrade)
O Freqtrade usa uma interface unificada IStrategy: a mesma classe de estratégia funciona tanto no backtest quanto ao vivo. A única diferença é a camada de persistência.
class IStrategy:
"""Unified interface — the implementation does not know if this is a backtest or live."""
def populate_indicators(self, dataframe, metadata):
"""Compute indicators."""
dataframe['fast_ma'] = dataframe['close'].rolling(20).mean()
dataframe['slow_ma'] = dataframe['close'].rolling(50).mean()
return dataframe
def populate_entry_trend(self, dataframe, metadata):
"""Determine entry signals."""
dataframe.loc[
(dataframe['fast_ma'] > dataframe['slow_ma']) &
(dataframe['fast_ma'].shift(1) <= dataframe['slow_ma'].shift(1)),
'enter_long'
] = 1
return dataframe
def populate_exit_trend(self, dataframe, metadata):
"""Determine exit signals."""
dataframe.loc[
(dataframe['fast_ma'] < dataframe['slow_ma']),
'exit_long'
] = 1
return dataframe
O Freqtrade também oferece:
- Hyperopt via Optuna — otimização dos parâmetros da estratégia
--timeframe-detail— aprofundamento para um timeframe mais fino para refinar os fills (semelhante ao drill-down adaptativo)
Comparação dos padrões
| Shared Core | Orientado a eventos (NautilusTrader) | Strategy Interface (Freqtrade) | |
|---|---|---|---|
| Complexidade de implementação | Baixa | Alta | Média |
| Nível de paridade | Médio | Máximo | Alto |
| Simulação de fills | FillModel separado | Livro de ofertas L2 | --timeframe-detail |
| Linguagem do núcleo | Python | Rust + Python | Python |
| Indicado para | Motores personalizados | Trading institucional | Início rápido |
Precisão da simulação de fills

A simulação de fills é a principal fonte de divergência de execução. Três níveis de precisão:
Nível 1: Ingênuo (fill ao preço de fechamento)
fill_price = candle['close']
Erro: não considera slippage, spread ou execuções parciais. Superestima sistematicamente o PnL.
Nível 2: Modelo de slippage
def simulate_fill(order, candle, slippage_bps=5):
"""Fill with slippage."""
base_price = candle['close']
slip = base_price * slippage_bps / 10000
if order.side == 'buy':
return base_price + slip # Buy at a higher price
else:
return base_price - slip # Sell at a lower price
Erro: um slippage fixo não considera liquidez nem o tamanho da ordem. Melhor que o ingênuo, mas ainda um modelo grosseiro.
Nível 3: Drill-down adaptativo com dados de 1s/100ms
A melhor opção: usar dados reais de granularidade fina para determinar com precisão a ordem de preenchimento de SL/TP. Descrito em detalhes no artigo Drill-down adaptativo: backtesting com granularidade variável.
class RealisticFillModel:
"""
Combined fill model: slippage + spread + volume impact.
"""
def __init__(self, avg_spread_bps=3, impact_coeff=0.1):
self.avg_spread_bps = avg_spread_bps
self.impact_coeff = impact_coeff
def simulate_fill(self, order, candle, order_size_usd):
base_price = candle['close']
spread_cost = base_price * self.avg_spread_bps / 20000
candle_volume_usd = candle['volume'] * candle['close']
participation_rate = order_size_usd / max(candle_volume_usd, 1)
impact = base_price * self.impact_coeff * np.sqrt(participation_rate)
if order.side == 'buy':
return base_price + spread_cost + impact
else:
return base_price - spread_cost - impact
Fórmula de impacto de mercado (modelo simplificado de Almgren-Chriss):
onde é a volatilidade, é o coeficiente de impacto, é o volume da ordem e é o volume do mercado no período.
Checklist prática de paridade

Antes de lançar o bot ao vivo, verifique cada item:
Código:
- A estratégia usa um shared core (um módulo para backtest e live)
- Sem duplicação da lógica de sinal em dois lugares
- Testes unitários verificam saídas idênticas do núcleo para entradas idênticas
- A ordem de verificação das condições é idêntica (SL antes do TP? TP antes do SL?)
Dados:
- O formato de timestamp é idêntico (UTC, mesmo provedor)
- A agregação OHLCV usa as mesmas regras
- O tratamento de candles faltantes é idêntico
- Sem look-ahead bias — o backtest não espia o futuro
Execução:
- O modelo de slippage é calibrado com dados reais
- As execuções parciais são modeladas (ou pelo menos estimadas de forma pessimista)
- As ordens limite têm um modelo de prioridade de fila
- A latência é considerada (atraso de 100-500 ms do sinal ao fill)
Custos:
- As comissões maker/taker estão incluídas com a taxa atual
- As funding rates são consideradas em futuros perpétuos
- O spread é modelado (pelo menos a média)
Infraestrutura:
- Persistência de estado: o bot recupera posições após reinício
- Lógica de reconexão: o WebSocket se reconecta sem perda de dados
- Logging: todas as ordens e execuções são registradas para análise post-mortem
Monitoramento da divergência em produção
Paridade não é uma verificação pontual, mas um processo contínuo. Após o lançamento do bot, as divergências devem ser rastreadas em tempo real.
Modo shadow (paper trading)

Rode o bot em paralelo com o backtest sobre os mesmos dados. O bot gera sinais mas não envia ordens — apenas registra. Simultaneamente, o backtest processa os mesmos dados. Compare:
class DivergenceMonitor:
"""
Compares backtest and live bot signals in real time.
"""
def __init__(self, tolerance_pct=0.5):
self.tolerance = tolerance_pct / 100
self.divergences = []
def compare_signal(self, backtest_signal, live_signal, timestamp):
"""Compare backtest and live signals."""
if backtest_signal is None and live_signal is None:
return # Both silent — OK
if (backtest_signal is None) != (live_signal is None):
self.divergences.append({
'timestamp': timestamp,
'type': 'signal_mismatch',
'backtest': backtest_signal,
'live': live_signal,
'severity': 'HIGH',
})
return
price_diff = abs(
backtest_signal.entry_price - live_signal.entry_price
) / backtest_signal.entry_price
if price_diff > self.tolerance:
self.divergences.append({
'timestamp': timestamp,
'type': 'price_divergence',
'diff_pct': price_diff * 100,
'severity': 'MEDIUM',
})
def compare_fill(self, backtest_fill, live_fill, timestamp):
"""Compare execution."""
if backtest_fill and live_fill:
slippage = (live_fill['price'] - backtest_fill['price']
) / backtest_fill['price']
self.divergences.append({
'timestamp': timestamp,
'type': 'fill_divergence',
'slippage_bps': slippage * 10000,
'severity': 'LOW' if abs(slippage) < 0.001 else 'MEDIUM',
})
def report(self):
"""Weekly divergence report."""
from collections import Counter
severity_counts = Counter(d['severity'] for d in self.divergences)
return {
'total_divergences': len(self.divergences),
'by_severity': dict(severity_counts),
'avg_slippage_bps': np.mean([
d['slippage_bps'] for d in self.divergences
if d['type'] == 'fill_divergence'
]) if any(d['type'] == 'fill_divergence'
for d in self.divergences) else 0,
}
Métricas do dashboard
| Métrica | Fórmula | Limiar de alerta |
|---|---|---|
| Taxa de correspondência de sinais | < 95% | |
| Slippage médio | (bps) | > 10 bps |
| Taxa de preenchimento | < 90% | |
| Divergência de PnL | > 20% | |
| Latência p99 | 99º percentil sinal-para-fill | > 500 ms |
Calibração do modelo de slippage

Após acumular dados por 2-4 semanas, você pode calibrar o modelo de slippage do backtest com dados reais:
def calibrate_slippage(live_fills: list[dict]) -> dict:
"""
Calibrate slippage model using real fills.
live_fills: [{'expected_price': ..., 'actual_price': ..., 'size_usd': ..., 'volume_usd': ...}]
"""
slippages = []
participation_rates = []
for fill in live_fills:
slip = abs(fill['actual_price'] - fill['expected_price']
) / fill['expected_price']
part = fill['size_usd'] / max(fill['volume_usd'], 1)
slippages.append(slip)
participation_rates.append(part)
slippages = np.array(slippages)
participation_rates = np.array(participation_rates)
from scipy.optimize import curve_fit
def model(x, k, base):
return k * np.sqrt(x) + base
popt, _ = curve_fit(model, participation_rates, slippages,
p0=[0.1, 0.0001])
return {
'impact_coeff': popt[0],
'base_slippage': popt[1],
'mean_slippage_bps': np.mean(slippages) * 10000,
'p95_slippage_bps': np.percentile(slippages, 95) * 10000,
}
Conexões com outras ferramentas
A paridade backtest-live não é uma tarefa isolada. Ela se cruza com outras ferramentas da série "Backtests sem ilusões":
- Drill-down adaptativo — melhora a precisão da simulação de fills, um componente-chave da paridade de execução.
- Funding rates — se o backtest não modela o funding, a paridade é impossível com alavancagem > 3x.
- Cache Parquet — timeframes e indicadores pré-calculados garantem que o backtest veja os mesmos dados que o bot. A emulação do RunningCandleBuffer = atualização em tempo real.
- Polars vs Pandas — ao migrar de pandas (backtest) para Polars (live), é preciso garantir que os resultados numéricos coincidam.
- Walk-Forward — o walk-forward em dados out-of-sample mostra como a estratégia se degrada — isso está mais próximo do live do que um backtest in-sample.
Recomendações
-
Shared core é obrigatório. Uma única base de código para geração de sinais é o requisito mínimo para paridade. Dois arquivos com lógica idêntica garantem divergência em um mês.
-
Calibre o modelo de fill. Um slippage fixo de 5 bps é melhor que nada. Um modelo de slippage calibrado com dados reais é significativamente melhor.
-
Use o modo shadow nas primeiras 2-4 semanas. Não opere com dinheiro real até que a taxa de correspondência de sinais atinja 95%+.
-
Modele as funding rates. Para futuros perpétuos, isso não é opcional — é obrigatório. O funding pode consumir todo o PnL com alavancagem > 5x.
-
Registre tudo. Cada sinal, cada ordem, cada fill — com timestamps. Sem logs, a análise post-mortem é impossível.
-
Automatize a comparação. Um relatório semanal do DivergenceMonitor deve chegar automaticamente. Não espere o PnL ficar negativo.
-
Backtest pessimista por padrão. É melhor subestimar as expectativas no backtest e ser agradavelmente surpreendido ao vivo do que o contrário. O modelo de slippage deve ser conservador.
Conclusão

A paridade backtest-live não é uma propriedade de um sistema, mas um processo. A paridade perfeita não existe: um backtest é, por definição, um modelo da realidade, e um modelo sempre simplifica. Mas a diferença entre "o modelo diverge 5%" e "o modelo diverge 50%" é determinada pela arquitetura.
Três níveis de maturidade:
- Básico. Shared core, slippage fixo, comissões. Divergência: 10-20%.
- Avançado. Arquitetura orientada a eventos, drill-down adaptativo, modelo de funding, modo shadow. Divergência: 5-10%.
- Institucional. Simulação do livro de ofertas L2, modelo de impacto calibrado, monitoramento de divergência em tempo real. Divergência: 2-5%.
Sua tarefa é determinar em qual nível você está e entender qual divergência você considera aceitável para o tamanho da sua posição e alavancagem.
Links úteis
- NautilusTrader — High-Performance Algorithmic Trading Platform
- Freqtrade — Free, open source crypto trading bot
- Almgren, R., Chriss, N. — Optimal Execution of Portfolio Transactions (2001)
- Lopez de Prado — Advances in Financial Machine Learning, Chapter 12: Backtesting
- Ernest Chan — Quantitative Trading: How to Build Your Own Algorithmic Trading Business
- Hexagonal Architecture (Ports and Adapters) — Alistair Cockburn
- Optuna — Hyperparameter Optimization Framework
Citação
@article{soloviov2026backtestliveparity,
author = {Soloviov, Eugen},
title = {Backtest-live parity: why your bot trades differently from the backtest},
year = {2026},
url = {https://marketmaker.cc/ru/blog/post/backtest-live-parity},
description = {Complete taxonomy of divergences between backtesting and live trading: from slippage and partial fills to codebase desynchronization. Architectural patterns for achieving parity and a production monitoring checklist.}
}
Authors
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.