← Retour aux articles
March 16, 2026
5 min de lecture

Cache Parquet Agrégé : Comment Accélérer les Backtests Multi-Timeframe des Centaines de Fois

Cache Parquet Agrégé : Comment Accélérer les Backtests Multi-Timeframe des Centaines de Fois
#algotrading
#backtest
#multi-timeframe
#parquet
#optimization
#caching
Part 3 of 10 · Collection
High-Performance Backtest Engines

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

Real-time timeframe emulation visualization

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 :

  1. Le bot reçoit la bougie minute suivante
  2. Met à jour la barre actuelle (non clôturée) du timeframe supérieur — recalcule High, Low, Close, Volume
  3. Recalcule l'indicateur sur toutes les barres clôturées plus la barre partielle actuelle
  4. 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 :

timestampminute 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

Cache-based optimization speedup comparison

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_4h et ma_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 :

  1. Recalculer uniquement le nouvel indicateur à partir des mêmes données minute
  2. Ajouter les colonnes au fichier parquet existant
  3. 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 :

  1. 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.

  2. 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

  1. Apache Parquet — data storage format
  2. pandas — working with parquet
  3. Lopez de Prado — Advances in Financial Machine Learning
  4. 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.}
}
blog.disclaimer

Authors

Eugen Soloviov
Eugen Soloviov

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.

Newsletter

Gardez une longueur d'avance sur le marché

Abonnez-vous à notre newsletter pour des insights exclusifs sur le trading IA, des analyses de marché et des mises à jour de la plateforme.

Nous respectons votre vie privée. Désabonnement possible à tout moment.