Aggregierter Parquet-Cache: Wie man Multi-Timeframe-Backtests um das Hundertfache beschleunigt
Eine Multi-Timeframe-Strategie nutzt mehrere Zeitrahmen gleichzeitig: der Tages-Timeframe bestimmt die Trendrichtung, der Stunden-Timeframe identifiziert Einstiegspunkte, und der 5-Minuten-Timeframe legt den genauen Ausführungszeitpunkt fest. Jeder Timeframe benötigt eigene Indikatoren: gleitende Durchschnitte, Oszillatoren, Levels.
Für einen einzelnen Backtest ist alles unkompliziert — Timeframes aus Minutendaten berechnen, Indikatoren bestimmen, die Strategie ausführen. Aber bei der Massenoptimierung — wenn Tausende von Parameterkombinationen getestet werden müssen — wird die Neuberechnung von Timeframes und Indikatoren bei jeder Iteration zum Flaschenhals. Ein einzelner Durchlauf durch Minutendaten über zwei Jahre bedeutet die Verarbeitung von über einer Million Balken, und dies tausendfach zu wiederholen ist Verschwendung.
Die Lösung: alles einmal vorberechnen und in einer Parquet-Datei cachen.
Das Problem: Redundante Berechnungen bei der Optimierung
Eine typische Multi-Timeframe-Backtest-Pipeline sieht so aus:
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)
Bei jeder Iteration werden die Schritte 1-3 neu berechnet, obwohl die Daten identisch sind. Nur die Schwellenwertparameter der Strategie ändern sich (Schritt 4). Das ist so, als würde man jedes Mal ein ganzes Haus neu bauen, nur um eine andere Wandfarbe auszuprobieren.
Die Idee: Einmal berechnen, speichern, mehrfach wiederverwenden
Die entscheidende Beobachtung: Timeframes und Indikatoren hängen nur von den Minutendaten und den Indikatorparametern ab, nicht von den Strategieparametern. Wenn wir die Menge der benötigten Indikatoren festlegen, können wir sie einmal berechnen und speichern.
Das Schema:
Step 1 (once):
Minute candles -> Timeframe resampling -> Indicator computation -> Parquet file
Step 2 (many times):
Parquet file -> Strategy with different parameters -> Result
Emulation von Timeframes aus Minutenkerzen

Wir verfügen über ein vollständiges Archiv von Minutenkerzen. Daraus können wir jeden höheren Timeframe exakt reproduzieren. Es gibt jedoch eine Feinheit: Mit einem standardmäßigen resample erhalten wir eine Zeile pro Periode (eine Zeile pro Stunde, eine pro 4 Stunden usw.). Das funktioniert nicht für ein minutengenaues Backtesting — wir müssen den Indikatorwert zu jeder Minute kennen.
Deshalb emulieren wir die Werte des höheren Timeframes für jede Minutenkerze und modellieren, wie der Bot die Daten in Echtzeit sieht:
- Der Bot empfängt die nächste Minutenkerze
- Aktualisiert den aktuellen (noch nicht geschlossenen) Balken des höheren Timeframes — berechnet High, Low, Close, Volume neu
- Berechnet den Indikator über alle geschlossenen Balken plus den aktuellen unvollständigen Balken neu
- Wenn die Periode endet — wird der Balken abgeschlossen und ein neuer beginnt
Dieser Ansatz garantiert, dass der Backtest exakt dieselben Daten sieht wie der Bot in Echtzeit. Kein Blick in die Zukunft — jede Minutenkerze wird strikt mit den Daten verarbeitet, die zu diesem Zeitpunkt verfügbar gewesen wären.
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]
Für jeden höheren Timeframe wird ein separater RunningCandleBuffer erstellt. Bei jeder Minutenkerze werden alle Puffer aktualisiert, wodurch wir den aktuellen Zustand jedes Timeframes erhalten — als würde der Bot in Echtzeit laufen.
Struktur des Parquet-Caches
Das Ergebnis der Vorberechnung ist eine einzelne Parquet-Datei, in der jede Zeile einer Minutenkerze entspricht und die Spalten Folgendes enthalten:
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
Jeder Wert spiegelt den tatsächlichen Zustand des Indikators zum Zeitpunkt der entsprechenden Minutenkerze wider — unter Berücksichtigung nicht geschlossener Balken höherer Timeframes.
Precompute: Aufbau des Caches
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")
Verwendung des Caches bei der Optimierung

Nun sieht die Optimierung so aus:
cache = pd.read_parquet("cache_ETHUSDT_2024_2026.parquet")
for params in parameter_grid:
result = run_strategy(cache, params)
Die Strategie arbeitet mit vorgefertigten Spalten — keine wiederholten Durchläufe durch eine Million Balken, keine Neuberechnungen der gleitenden Durchschnitte, keine Timeframe-Emulation. Nur das Lesen aus einem DataFrame und die Prüfung der Ein- und Ausstiegsbedingungen.
Warum Parquet
Parquet ist ein spaltenorientiertes Datenspeicherformat, das für diese Aufgabe optimal ist:
- Kompression. Parquet komprimiert numerische Daten um das 5- bis 10-Fache. Ein Cache mit 1,1 Millionen Zeilen und 30 Spalten benötigt ~50 MB statt ~500 MB im CSV-Format.
- Spaltenweises Lesen. Wenn die Strategie nur
ma_20_4hundma_50_4hverwendet, liest Parquet nur diese Spalten und überspringt den Rest. - Erhaltung der Datentypen. Datentypen (float64, int64, string) bleiben verlustfrei erhalten — kein Parsen von Strings beim Laden nötig.
- Lesegeschwindigkeit. Das Laden von Parquet in Pandas dauert einige Dutzend Millisekunden — eine Größenordnung schneller als CSV.
Erweiterung des Caches: Hinzufügen neuer Indikatoren
Wenn die Strategie einen neuen Indikator benötigt (RSI, MACD, Bollinger Bands), genügt es:
- Nur den neuen Indikator aus denselben Minutendaten neu zu berechnen
- Die Spalten der bestehenden Parquet-Datei hinzuzufügen
- Alle zuvor berechneten Spalten bleiben unverändert
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")
Zusammenfassung: Vergleich der Ansätze
| Naiver Ansatz | Aggregierter Cache | |
|---|---|---|
| Timeframe-Resampling | Jede Iteration | Einmal |
| Indikatorberechnung | Jede Iteration | Einmal |
| Zeit pro Iteration | Minuten | Weniger als eine Sekunde |
| 1000 Iterationen | Tage | Minuten |
| Speicherverbrauch | 1m laden + neu berechnen | Ein einziger DataFrame |
| Backtest-Live-Parität | Abhängig von der Implementierung | Garantiert (Emulation = Echtzeit) |
Fazit
Der Ansatz des aggregierten Parquet-Caches löst zwei Probleme gleichzeitig:
-
Korrektheit. Die Timeframe-Emulation aus Minutenkerzen mittels RunningCandleBuffer garantiert, dass der Backtest dieselben Daten sieht wie der Bot in Echtzeit — kein Blick in die Zukunft und keine künstlichen Verzögerungen.
-
Geschwindigkeit. Vorberechnete Timeframes und Indikatoren ermöglichen es, Tausende von Parameterkombinationen in Minuten statt in Tagen zu testen.
Die Idee ist einfach: einmal berechnen — mehrfach wiederverwenden. Minutenkerzen sind die Ausgangsdaten. Alles andere ist abgeleitet und kann vorberechnet und gecacht werden. Parquet macht diesen Cache kompakt, schnell und praktisch.
Mehr darüber, wie man die Genauigkeit der Fill-Simulation mit adaptivem Drill-Down von Minuten zu Sekunden und Millisekunden verbessert, erfahren Sie im Artikel Adaptiver Drill-Down: Backtest mit variabler Granularität.
Nützliche Links
- Apache Parquet — data storage format
- pandas — working with parquet
- Lopez de Prado — Advances in Financial Machine Learning
- Ernest Chan — Quantitative Trading
Zitierung
@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.