Cache Parquet Agregado: Cómo Acelerar Backtests Multi-Timeframe Cientos de Veces
Una estrategia multi-timeframe utiliza varios marcos temporales simultáneamente: el diario determina la dirección de la tendencia, el horario identifica los puntos de entrada y el de 5 minutos precisa el momento de ejecución. Cada timeframe requiere sus propios indicadores: medias móviles, osciladores, niveles.
Para un único backtest todo es sencillo: recalcular los timeframes a partir de datos de un minuto, calcular los indicadores, ejecutar la estrategia. Pero durante la optimización masiva —cuando se necesita probar miles de combinaciones de parámetros— recalcular timeframes e indicadores en cada iteración se convierte en un cuello de botella. Un solo recorrido por datos de un minuto durante dos años implica procesar más de un millón de barras, y repetirlo mil veces es un desperdicio.
La solución: precalcular todo una vez y guardarlo en caché en un archivo parquet.
El Problema: Cálculos Redundantes Durante la Optimización
Una canalización típica de backtest multi-timeframe se ve así:
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)
En cada iteración se recalculan los pasos 1-3 aunque los datos sean los mismos. Solo cambian los parámetros de umbral de la estrategia (paso 4). Es como reconstruir una casa entera cada vez que solo se quiere probar un color de pared distinto.
La Idea: Calcular una Vez, Guardar, Reutilizar Muchas Veces
La observación clave: los timeframes y los indicadores dependen únicamente de los datos de un minuto y de los parámetros de los indicadores, no de los parámetros de la estrategia. Si fijamos el conjunto de indicadores necesarios, podemos calcularlos una vez y guardarlos.
El esquema:
Step 1 (once):
Minute candles -> Timeframe resampling -> Indicator computation -> Parquet file
Step 2 (many times):
Parquet file -> Strategy with different parameters -> Result
Emulación de Timeframes a Partir de Velas de un Minuto

Disponemos de un archivo completo de velas de un minuto. A partir de él podemos reproducir con precisión cualquier timeframe superior. Pero hay un matiz: con un resample estándar obtenemos una fila por periodo (una fila por hora, una por 4 horas, etc.). Esto no funciona para un backtesting minuto a minuto: necesitamos conocer el valor del indicador en cada minuto.
Por eso emulamos los valores de los timeframes superiores para cada vela de un minuto, modelando cómo ve el bot los datos en tiempo real:
- El bot recibe la siguiente vela de un minuto
- Actualiza la barra actual (no cerrada) del timeframe superior — recalcula High, Low, Close, Volume
- Recalcula el indicador sobre todas las barras cerradas más la barra parcial actual
- Cuando termina el periodo, la barra se cierra y comienza una nueva
Este enfoque garantiza que el backtest ve exactamente los mismos datos que el bot en tiempo real. Sin mirar al futuro: cada vela de un minuto se procesa estrictamente con los datos que habrían estado disponibles en ese 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]
Se crea un RunningCandleBuffer independiente para cada timeframe superior. En cada vela de un minuto se actualizan todos los buffers, lo que nos da el estado actual de cada timeframe, como si el bot estuviera funcionando en tiempo real.
Estructura del Cache Parquet
El resultado del precálculo es un único archivo parquet donde cada fila corresponde a una vela de un minuto, y las columnas contienen:
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 refleja el estado real del indicador en el momento de la vela de un minuto correspondiente, teniendo en cuenta las barras no cerradas de los timeframes superiores.
Precompute: Construcción del 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")
Uso del Cache Durante la Optimización

Ahora la optimización se ve así:
cache = pd.read_parquet("cache_ETHUSDT_2024_2026.parquet")
for params in parameter_grid:
result = run_strategy(cache, params)
La estrategia trabaja con columnas ya construidas: sin recorridos repetidos por un millón de barras, sin recálculos de medias móviles, sin emulación de timeframes. Solo lectura de un DataFrame y comprobación de las condiciones de entrada/salida.
Por Qué Parquet
Parquet es un formato de almacenamiento de datos columnar, óptimo para esta tarea:
- Compresión. Parquet comprime los datos numéricos entre 5 y 10 veces. Un cache de 1,1 millones de filas con 30 columnas ocupa ~50 MB en lugar de ~500 MB en CSV.
- Lectura columnar. Si la estrategia solo utiliza
ma_20_4hyma_50_4h, parquet lee únicamente esas columnas, omitiendo el resto. - Preservación de tipos. Los tipos de datos (float64, int64, string) se conservan sin pérdida, sin necesidad de parsear cadenas al cargar.
- Velocidad de lectura. Cargar parquet en pandas tarda decenas de milisegundos, un orden de magnitud más rápido que CSV.
Ampliar el Cache: Añadir Nuevos Indicadores
Si la estrategia requiere un nuevo indicador (RSI, MACD, Bandas de Bollinger), basta con:
- Recalcular solo el nuevo indicador a partir de los mismos datos de un minuto
- Añadir las columnas al archivo parquet existente
- Todas las columnas calculadas previamente permanecen 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")
Resumen: Comparación de Enfoques
| Enfoque Ingenuo | Cache Agregado | |
|---|---|---|
| Resampling de timeframes | Cada iteración | Una vez |
| Cálculo de indicadores | Cada iteración | Una vez |
| Tiempo por iteración | Minutos | Menos de un segundo |
| 1000 iteraciones | Días | Minutos |
| Consumo de memoria | Cargar 1m + recalcular | Un único DataFrame |
| Paridad backtest-live | Depende de la implementación | Garantizada (emulación = tiempo real) |
Conclusión
El enfoque de cache parquet agregado resuelve dos problemas simultáneamente:
-
Corrección. La emulación de timeframes a partir de velas de un minuto mediante RunningCandleBuffer garantiza que el backtest vea los mismos datos que el bot en tiempo real, sin mirar al futuro y sin retrasos artificiales.
-
Velocidad. Los timeframes e indicadores precalculados permiten probar miles de combinaciones de parámetros en minutos en lugar de días.
La idea es simple: calcular una vez, reutilizar muchas veces. Las velas de un minuto son el dato de origen. Todo lo demás se deriva y puede precalcularse y guardarse en caché. Parquet hace que este cache sea compacto, rápido y práctico.
Para más información sobre cómo mejorar la precisión de la simulación de ejecuciones con un drill-down adaptativo de minutos a segundos y milisegundos, consulta el artículo Drill-down adaptativo: backtest con granularidad variable.
Enlaces Útiles
- Apache Parquet — data storage format
- pandas — working with parquet
- Lopez de Prado — Advances in Financial Machine Learning
- Ernest Chan — Quantitative Trading
Cita
@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.