← Zurück zu den Artikeln
March 16, 2026
5 min read

Aggregierter Parquet-Cache: Wie man Multi-Timeframe-Backtests um das Hundertfache beschleunigt

Aggregierter Parquet-Cache: Wie man Multi-Timeframe-Backtests um das Hundertfache beschleunigt
#algotrading
#backtest
#multi-timeframe
#parquet
#optimization
#caching
Part 3 of 10 · Collection
High-Performance Backtest Engines

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

Real-time timeframe emulation visualization

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:

  1. Der Bot empfängt die nächste Minutenkerze
  2. Aktualisiert den aktuellen (noch nicht geschlossenen) Balken des höheren Timeframes — berechnet High, Low, Close, Volume neu
  3. Berechnet den Indikator über alle geschlossenen Balken plus den aktuellen unvollständigen Balken neu
  4. 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:

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

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

Cache-based optimization speedup comparison

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_4h und ma_50_4h verwendet, 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:

  1. Nur den neuen Indikator aus denselben Minutendaten neu zu berechnen
  2. Die Spalten der bestehenden Parquet-Datei hinzuzufügen
  3. 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:

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

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

  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

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

Dem Markt einen Schritt voraus

Abonniere unseren Newsletter für exklusive KI-Trading-Einblicke, Marktanalysen und Plattform-Updates.

Wir respektieren deine Privatsphäre. Jederzeit abbestellbar.