Cache Parquet Agrégé : Comment Accélérer les Backtests Multi-Timeframe des Centaines de Fois
Une stratégie multi-timeframe utilise plusieurs unités de temps simultanément : le journalier détermine la direction de la tendance, l'horaire identifie les points d'entrée, et le 5 minutes précise le moment d'exécution. Chaque timeframe nécessite ses propres indicateurs : moyennes mobiles, oscillateurs, niveaux.
Pour un seul backtest, tout est simple — recalculer les timeframes à partir des données minute, calculer les indicateurs, exécuter la stratégie. Mais lors d'une optimisation массive — quand il faut tester des milliers de combinaisons de paramètres — recalculer les timeframes et indicateurs à chaque itération devient un goulot d'étranglement. Un seul passage sur des données minute couvrant deux ans représente le traitement de plus d'un million de barres, et répéter cela mille fois est un gaspillage.
La solution : précalculer tout une seule fois et le mettre en cache dans un fichier parquet.
Le Problème : Calculs Redondants Pendant l'Optimisation
Un pipeline de backtest multi-timeframe typique ressemble à ceci :
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)
À chaque itération, les étapes 1 à 3 sont recalculées alors que les données sont identiques. Seuls les paramètres de seuil de la stratégie changent (étape 4). C'est comme reconstruire une maison entière chaque fois que l'on veut simplement essayer une autre couleur de mur.
L'Idée : Calculer Une Fois, Sauvegarder, Réutiliser Plusieurs Fois
L'observation clé : les timeframes et les indicateurs dépendent uniquement des données minute et des paramètres des indicateurs, pas des paramètres de la stratégie. Si l'on fixe l'ensemble des indicateurs nécessaires, on peut les calculer une fois et les sauvegarder.
Le schéma :
Step 1 (once):
Minute candles -> Timeframe resampling -> Indicator computation -> Parquet file
Step 2 (many times):
Parquet file -> Strategy with different parameters -> Result
Émulation des Timeframes à Partir des Bougies Minute

Nous disposons d'une archive complète de bougies d'une minute. À partir de celle-ci, nous pouvons reproduire fidèlement n'importe quel timeframe supérieur. Mais il y a une nuance : avec un resample standard, on obtient une ligne par période (une ligne par heure, une par 4 heures, etc.). Cela ne fonctionne pas pour un backtesting minute par minute — il faut connaître la valeur de l'indicateur à chaque minute.
C'est pourquoi nous émulons les valeurs des timeframes supérieurs pour chaque bougie minute, en modélisant la façon dont le bot voit les données en temps réel :
- Le bot reçoit la bougie minute suivante
- Met à jour la barre actuelle (non clôturée) du timeframe supérieur — recalcule High, Low, Close, Volume
- Recalcule l'indicateur sur toutes les barres clôturées plus la barre partielle actuelle
- Lorsque la période se termine — la barre est finalisée et une nouvelle commence
Cette approche garantit que le backtest voit exactement les mêmes données que le bot en temps réel. Pas de regard vers le futur — chaque bougie minute est traitée strictement avec les données qui auraient été disponibles à ce moment-là.
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]
Un RunningCandleBuffer distinct est créé pour chaque timeframe supérieur. À chaque bougie minute, tous les buffers sont mis à jour, ce qui nous donne l'état actuel de chaque timeframe — comme si le bot fonctionnait en temps réel.
Structure du Cache Parquet
Le résultat du précalcul est un unique fichier parquet où chaque ligne correspond à une bougie minute, et les colonnes contiennent :
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
Chaque valeur reflète l'état réel de l'indicateur au moment de la bougie minute correspondante — en tenant compte des barres non clôturées des timeframes supérieurs.
Precompute : Construction du 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")
Utilisation du Cache Pendant l'Optimisation

Désormais, l'optimisation ressemble à ceci :
cache = pd.read_parquet("cache_ETHUSDT_2024_2026.parquet")
for params in parameter_grid:
result = run_strategy(cache, params)
La stratégie travaille avec des colonnes déjà construites — pas de passages répétés sur un million de barres, pas de recalculs de moyennes mobiles, pas d'émulation de timeframes. Juste la lecture d'un DataFrame et la vérification des conditions d'entrée/sortie.
Pourquoi Parquet
Parquet est un format de stockage de données en colonnes, optimal pour cette tâche :
- Compression. Parquet compresse les données numériques de 5 à 10 fois. Un cache de 1,1 million de lignes avec 30 colonnes occupe ~50 Mo au lieu de ~500 Mo en CSV.
- Lecture en colonnes. Si la stratégie n'utilise que
ma_20_4hetma_50_4h, parquet ne lit que ces colonnes, en ignorant le reste. - Préservation des types. Les types de données (float64, int64, string) sont préservés sans perte — pas besoin de parser les chaînes au chargement.
- Vitesse de lecture. Charger un fichier parquet dans pandas prend quelques dizaines de millisecondes, un ordre de grandeur plus rapide que le CSV.
Étendre le Cache : Ajouter de Nouveaux Indicateurs
Si la stratégie nécessite un nouvel indicateur (RSI, MACD, Bandes de Bollinger), il suffit de :
- Recalculer uniquement le nouvel indicateur à partir des mêmes données minute
- Ajouter les colonnes au fichier parquet existant
- Toutes les colonnes précédemment calculées restent intactes
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")
Résumé : Comparaison des Approches
| Approche Naïve | Cache Agrégé | |
|---|---|---|
| Resampling des timeframes | Chaque itération | Une fois |
| Calcul des indicateurs | Chaque itération | Une fois |
| Temps par itération | Minutes | Moins d'une seconde |
| 1000 itérations | Jours | Minutes |
| Consommation mémoire | Charger 1m + recalculer | Un seul DataFrame |
| Parité backtest-live | Dépend de l'implémentation | Garantie (émulation = temps réel) |
Conclusion
L'approche du cache parquet agrégé résout deux problèmes simultanément :
-
Exactitude. L'émulation des timeframes à partir des bougies minute via RunningCandleBuffer garantit que le backtest voit les mêmes données que le bot en temps réel — pas de regard vers le futur ni de délais artificiels.
-
Vitesse. Les timeframes et indicateurs précalculés permettent de tester des milliers de combinaisons de paramètres en quelques minutes au lieu de plusieurs jours.
L'idée est simple : calculer une fois — réutiliser plusieurs fois. Les bougies minute sont la donnée source. Tout le reste en est dérivé et peut être précalculé et mis en cache. Parquet rend ce cache compact, rapide et pratique.
Pour en savoir plus sur la façon d'améliorer la précision de la simulation des exécutions grâce à un drill-down adaptatif des minutes aux secondes et millisecondes, consultez l'article Drill-down adaptatif : backtest à granularité variable.
Liens Utiles
- Apache Parquet — data storage format
- pandas — working with parquet
- Lopez de Prado — Advances in Financial Machine Learning
- Ernest Chan — Quantitative Trading
Citation
@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.