Cache Parquet Agregado: Como Acelerar Backtests Multi-Timeframe Centenas de Vezes
Uma estratégia multi-timeframe usa vários timeframes simultaneamente: o diário determina a direção da tendência, o horário identifica os pontos de entrada, e o de 5 minutos define o momento exato de execução. Cada timeframe requer seus próprios indicadores: médias móveis, osciladores, níveis.
Para um único backtest, tudo é simples — recalcular os timeframes a partir de dados de um minuto, calcular os indicadores, executar a estratégia. Mas durante a otimização em massa — quando é preciso testar milhares de combinações de parâmetros — recalcular timeframes e indicadores a cada iteração se torna um gargalo. Uma única passagem por dados de um minuto ao longo de dois anos significa processar mais de um milhão de barras, e repetir isso mil vezes é um desperdício.
A solução: pré-calcular tudo uma vez e armazenar em cache num arquivo parquet.
O Problema: Cálculos Redundantes Durante a Otimização
Um pipeline típico de backtest multi-timeframe se parece com isto:
for params in parameter_grid:
df_1m = load_candles("ETHUSDT", "1m", start, end)
df_5m = resample_ohlcv(df_1m, "5m")
df_1h = resample_ohlcv(df_1m, "1h")
df_4h = resample_ohlcv(df_1m, "4h")
df_1d = resample_ohlcv(df_1m, "D")
ma_1h = compute_ma(df_1h["close"], length=params["ma_1h_len"])
ma_4h = compute_ma(df_4h["close"], length=params["ma_4h_len"])
ma_1d = compute_ma(df_1d["close"], length=params["ma_1d_len"])
result = run_strategy(df_1m, ma_1h, ma_4h, ma_1d, params)
A cada iteração, as etapas 1-3 são recalculadas mesmo que os dados sejam os mesmos. Apenas os parâmetros de limiar da estratégia mudam (etapa 4). É como reconstruir uma casa inteira toda vez que se quer apenas experimentar uma cor diferente de parede.
A Ideia: Calcular Uma Vez, Salvar, Reutilizar Muitas Vezes
A observação-chave: os timeframes e indicadores dependem apenas dos dados de um minuto e dos parâmetros dos indicadores, não dos parâmetros da estratégia. Se fixarmos o conjunto de indicadores necessários, podemos calculá-los uma vez e salvá-los.
O esquema:
Step 1 (once):
Minute candles -> Timeframe resampling -> Indicator computation -> Parquet file
Step 2 (many times):
Parquet file -> Strategy with different parameters -> Result
Emulando Timeframes a Partir de Candles de Um Minuto

Dispomos de um arquivo completo de candles de um minuto. A partir dele, podemos reproduzir com precisão qualquer timeframe superior. Mas há uma sutileza: com um resample padrão, obtemos uma linha por período (uma linha por hora, uma por 4 horas, etc.). Isso não funciona para um backtesting minuto a minuto — precisamos conhecer o valor do indicador a cada minuto.
Por isso, emulamos os valores dos timeframes superiores para cada candle de um minuto, modelando como o bot vê os dados em tempo real:
- O bot recebe o próximo candle de um minuto
- Atualiza a barra atual (não fechada) do timeframe superior — recalcula High, Low, Close, Volume
- Recalcula o indicador sobre todas as barras fechadas mais a barra parcial atual
- Quando o período termina — a barra é finalizada e uma nova começa
Essa abordagem garante que o backtest veja exatamente os mesmos dados que o bot vê em tempo real. Sem olhar para o futuro — cada candle de um minuto é processado estritamente com os dados que estariam disponíveis naquele momento.
class RunningCandleBuffer:
"""
Emulates real-time updates of a higher timeframe bar
using 1-minute candles.
"""
def __init__(self, period_seconds: int):
self.period = period_seconds # 86400 for Daily, 3600 for 1h
self.closed_bars = []
self.current_bar = None
def update(self, timestamp, open_, high, low, close, volume):
bar_start = self._align_to_period(timestamp)
if self.current_bar is None or bar_start != self.current_bar['start']:
if self.current_bar is not None:
self.closed_bars.append(self.current_bar)
self.current_bar = {
'start': bar_start,
'open': open_, 'high': high,
'low': low, 'close': close,
'volume': volume,
}
else:
self.current_bar['high'] = max(self.current_bar['high'], high)
self.current_bar['low'] = min(self.current_bar['low'], low)
self.current_bar['close'] = close
self.current_bar['volume'] += volume
return self.closed_bars + [self.current_bar]
Um RunningCandleBuffer separado é criado para cada timeframe superior. A cada candle de um minuto, todos os buffers são atualizados, dando-nos o estado atual de cada timeframe — como se o bot estivesse rodando em tempo real.
Estrutura do Cache Parquet
O resultado do pré-cálculo é um único arquivo parquet onde cada linha corresponde a um candle de um minuto, e as colunas contêm:
timestamp — minute candle timestamp
open, high, low, — minute candle OHLCV
close, volume
close_5m — Close of the emulated 5m candle at this moment
close_1h — Close of the emulated 1h candle
close_4h — Close of the emulated 4h candle
close_1d — Close of the emulated daily candle
ma_20_1h — MA(20) on 1h, recalculated at this minute
ma_50_1h — MA(50) on 1h
ma_20_4h — MA(20) on 4h
ma_50_4h — MA(50) on 4h
ma_6_1d — MA(6) on Daily
ma_12_1d — MA(12) on Daily
cross_ma_1h — MA crossover signal on 1h ('buy'/'sell'/None)
cross_ma_4h — MA crossover signal on 4h
cross_ma_1d — MA crossover signal on Daily
separation_1h — MA divergence in % on 1h
separation_4h — MA divergence in % on 4h
separation_1d — MA divergence in % on Daily
Cada valor reflete o estado real do indicador no momento do candle de um minuto correspondente — levando em conta as barras não fechadas de timeframes superiores.
Precompute: Construindo o Cache
def precompute_cache(
df_1m: pd.DataFrame,
timeframes: dict[str, int], # {"5m": 300, "1h": 3600, "4h": 14400, "D": 86400}
indicators: dict, # {"ma_20": 20, "ma_50": 50}
) -> pd.DataFrame:
"""
Single pass through all minute candles.
Returns a DataFrame with emulated timeframes and indicators.
"""
buffers = {tf: RunningCandleBuffer(secs) for tf, secs in timeframes.items()}
n = len(df_1m)
result = {}
for tf_name, buf in buffers.items():
closes = np.zeros(n)
ma_values = {name: np.full(n, np.nan) for name in indicators}
for i in range(n):
row = df_1m.iloc[i]
bars = buf.update(
df_1m.index[i],
row['open'], row['high'], row['low'], row['close'], row['volume']
)
all_closes = [b['close'] for b in bars]
closes[i] = all_closes[-1]
for ind_name, length in indicators.items():
if len(all_closes) >= length:
ma_values[ind_name][i] = np.mean(all_closes[-length:])
result[f'close_{tf_name}'] = closes
for ind_name in indicators:
result[f'{ind_name}_{tf_name}'] = ma_values[ind_name]
cache_df = pd.DataFrame(result, index=df_1m.index)
cache_df = pd.concat([df_1m[['open', 'high', 'low', 'close', 'volume']], cache_df], axis=1)
return cache_df
cache = precompute_cache(
df_1m,
timeframes={"5m": 300, "1h": 3600, "4h": 14400, "D": 86400},
indicators={"ma_20": 20, "ma_50": 50, "ma_6": 6, "ma_12": 12},
)
cache.to_parquet("cache_ETHUSDT_2024_2026.parquet")
Usando o Cache Durante a Otimização

Agora a otimização se parece com isto:
cache = pd.read_parquet("cache_ETHUSDT_2024_2026.parquet")
for params in parameter_grid:
result = run_strategy(cache, params)
A estratégia trabalha com colunas já construídas — sem passagens repetidas por um milhão de barras, sem recálculos de médias móveis, sem emulação de timeframes. Apenas leitura de um DataFrame e verificação das condições de entrada/saída.
Por Que Parquet
Parquet é um formato de armazenamento de dados colunar, ideal para essa tarefa:
- Compressão. O Parquet comprime dados numéricos de 5 a 10 vezes. Um cache de 1,1 milhão de linhas com 30 colunas ocupa ~50 MB em vez de ~500 MB em CSV.
- Leitura colunar. Se a estratégia usa apenas
ma_20_4hema_50_4h, o parquet lê somente essas colunas, ignorando o restante. - Preservação de tipos. Os tipos de dados (float64, int64, string) são preservados sem perdas — não é necessário fazer parsing de strings ao carregar.
- Velocidade de leitura. Carregar parquet no pandas leva dezenas de milissegundos, uma ordem de grandeza mais rápido que CSV.
Estendendo o Cache: Adicionando Novos Indicadores
Se a estratégia exigir um novo indicador (RSI, MACD, Bandas de Bollinger), basta:
- Recalcular apenas o novo indicador a partir dos mesmos dados de um minuto
- Adicionar as colunas ao arquivo parquet existente
- Todas as colunas previamente calculadas permanecem intactas
cache = pd.read_parquet("cache_ETHUSDT_2024_2026.parquet")
rsi_cols = compute_rsi_for_timeframes(df_1m, timeframes, length=14)
cache = pd.concat([cache, rsi_cols], axis=1)
cache.to_parquet("cache_ETHUSDT_2024_2026.parquet")
Resumo: Comparação de Abordagens
| Abordagem Ingênua | Cache Agregado | |
|---|---|---|
| Resampling de timeframes | A cada iteração | Uma vez |
| Cálculo de indicadores | A cada iteração | Uma vez |
| Tempo por iteração | Minutos | Menos de um segundo |
| 1000 iterações | Dias | Minutos |
| Consumo de memória | Carregar 1m + recalcular | Um único DataFrame |
| Paridade backtest-live | Depende da implementação | Garantida (emulação = tempo real) |
Conclusão
A abordagem de cache parquet agregado resolve dois problemas simultaneamente:
-
Correção. A emulação de timeframes a partir de candles de um minuto via RunningCandleBuffer garante que o backtest veja os mesmos dados que o bot em tempo real — sem olhar para o futuro e sem atrasos artificiais.
-
Velocidade. Timeframes e indicadores pré-calculados permitem testar milhares de combinações de parâmetros em minutos em vez de dias.
A ideia é simples: calcular uma vez — reutilizar muitas vezes. Os candles de um minuto são o dado de origem. Tudo o mais é derivado e pode ser pré-calculado e armazenado em cache. O Parquet torna esse cache compacto, rápido e conveniente.
Para mais informações sobre como melhorar a precisão da simulação de execuções com drill-down adaptativo de minutos para segundos e milissegundos, veja o artigo Drill-down adaptativo: backtest com granularidade variável.
Links Úteis
- Apache Parquet — data storage format
- pandas — working with parquet
- Lopez de Prado — Advances in Financial Machine Learning
- Ernest Chan — Quantitative Trading
Citação
@article{soloviov2026parquetcache,
author = {Soloviov, Eugen},
title = {Aggregated Parquet Cache: How to Speed Up Multi-Timeframe Backtests by Hundreds of Times},
year = {2026},
url = {https://marketmaker.cc/ru/blog/post/parquet-cache-multitimeframe-backtest},
description = {How to precompute timeframes and indicators from minute candles, save them to parquet, and use them for mass strategy testing without redundant recalculations.}
}
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.