Geaggregeerde Parquet-cache: hoe je multi-timeframe backtests honderden keren sneller maakt
Een multi-timeframe strategie gebruikt meerdere timeframes tegelijk: de dagelijkse bepaalt de trendrichting, de uurlijkse identificeert instappunten, en de 5-minuten geeft het precieze uitvoeringsmoment aan. Elk timeframe vereist zijn eigen indicatoren: voortschrijdende gemiddelden, oscillatoren, niveaus.
Voor een enkele backtest is alles eenvoudig — timeframes herberekenen uit minutendata, indicatoren berekenen, de strategie uitvoeren. Maar tijdens massale optimalisatie — wanneer je duizenden parametercombinaties moet testen — wordt het herberekenen van timeframes en indicatoren bij elke iteratie een knelpunt. Eén doorgang door minutendata over twee jaar betekent het verwerken van meer dan een miljoen bars, en dit duizend keer herhalen is verspilling.
De oplossing: alles één keer vooraf berekenen en cachen in een parquet-bestand.
Het probleem: overbodige berekeningen tijdens optimalisatie
Een typische multi-timeframe backtest-pipeline ziet er zo uit:
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)
Bij elke iteratie worden stappen 1-3 opnieuw berekend, ook al zijn de data hetzelfde. Alleen de drempelparameters van de strategie veranderen (stap 4). Het is alsof je elke keer een heel huis herbouwt terwijl je alleen een andere muurkleur wilt proberen.
Het idee: één keer berekenen, opslaan, vele malen hergebruiken
De belangrijkste observatie: timeframes en indicatoren hangen alleen af van minutendata en indicatorparameters, niet van strategieparameters. Als we de verzameling benodigde indicatoren vastleggen, kunnen we ze één keer berekenen en opslaan.
Het schema:
Step 1 (once):
Minute candles -> Timeframe resampling -> Indicator computation -> Parquet file
Step 2 (many times):
Parquet file -> Strategy with different parameters -> Result
Emulatie van timeframes uit minutencandles

We beschikken over een compleet archief van minutencandles. Daaruit kunnen we elk hoger timeframe nauwkeurig reconstrueren. Maar er is een nuance: met een standaard resample krijgen we één rij per periode (één rij per uur, één per 4 uur, enz.). Dit werkt niet voor minuut-voor-minuut backtesting — we moeten de indicatorwaarde op elke minuut kennen.
Daarom emuleren we de waarden van hogere timeframes voor elke minutencandle, waarbij we modelleren hoe de bot de data in real time ziet:
- De bot ontvangt de volgende minutencandle
- Werkt de huidige (nog niet gesloten) bar van het hogere timeframe bij — herberekent High, Low, Close, Volume
- Herberekent de indicator over alle gesloten bars plus de huidige gedeeltelijke bar
- Wanneer de periode eindigt — wordt de bar afgesloten en begint een nieuwe
Deze aanpak garandeert dat de backtest exact dezelfde data ziet als de bot in real time. Geen blik in de toekomst — elke minutencandle wordt strikt verwerkt met de data die op dat moment beschikbaar zou zijn geweest.
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]
Voor elk hoger timeframe wordt een aparte RunningCandleBuffer aangemaakt. Bij elke minutencandle worden alle buffers bijgewerkt, wat ons de huidige status van elk timeframe geeft — alsof de bot in real time draait.
Structuur van de Parquet-cache
Het resultaat van de vooraf uitgevoerde berekening is één parquet-bestand waarin elke rij overeenkomt met één minutencandle, en de kolommen bevatten:
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
Elke waarde weerspiegelt de werkelijke status van de indicator op het moment van de betreffende minutencandle — rekening houdend met niet-gesloten bars van hogere timeframes.
Precompute: de cache opbouwen
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")
De cache gebruiken tijdens optimalisatie

Nu ziet de optimalisatie er zo uit:
cache = pd.read_parquet("cache_ETHUSDT_2024_2026.parquet")
for params in parameter_grid:
result = run_strategy(cache, params)
De strategie werkt met vooraf opgebouwde kolommen — geen herhaalde doorgangen door een miljoen bars, geen herberekeningen van voortschrijdende gemiddelden, geen timeframe-emulatie. Alleen lezen uit een DataFrame en het controleren van instap-/uitstapvoorwaarden.
Waarom Parquet
Parquet is een kolomgeoriënteerd dataopslagformaat, optimaal voor deze taak:
- Compressie. Parquet comprimeert numerieke data 5-10 keer. Een cache van 1,1 miljoen rijen met 30 kolommen neemt ~50 MB in beslag in plaats van ~500 MB in CSV.
- Kolomsgewijs lezen. Als de strategie alleen
ma_20_4henma_50_4hgebruikt, leest parquet alleen die kolommen en slaat de rest over. - Behoud van typen. Datatypen (float64, int64, string) blijven verliesvrij behouden — geen noodzaak om strings te parsen bij het laden.
- Leessnelheid. Parquet laden in pandas duurt tientallen milliseconden, een orde van grootte sneller dan CSV.
De cache uitbreiden: nieuwe indicatoren toevoegen
Als de strategie een nieuwe indicator nodig heeft (RSI, MACD, Bollinger Bands), is het simpelweg:
- Alleen de nieuwe indicator herberekenen uit dezelfde minutendata
- De kolommen toevoegen aan het bestaande parquet-bestand
- Alle eerder berekende kolommen blijven ongewijzigd
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")
Samenvatting: vergelijking van aanpakken
| Naïeve aanpak | Geaggregeerde cache | |
|---|---|---|
| Timeframe-resampling | Elke iteratie | Eén keer |
| Indicatorberekening | Elke iteratie | Eén keer |
| Tijd per iteratie | Minuten | Minder dan een seconde |
| 1000 iteraties | Dagen | Minuten |
| Geheugenverbruik | 1m laden + herberekenen | Eén enkele DataFrame |
| Backtest-live pariteit | Afhankelijk van implementatie | Gegarandeerd (emulatie = real time) |
Conclusie
De aanpak met een geaggregeerde parquet-cache lost twee problemen tegelijk op:
-
Correctheid. Timeframe-emulatie uit minutencandles via RunningCandleBuffer garandeert dat de backtest dezelfde data ziet als de bot in real time — geen blik in de toekomst en geen kunstmatige vertragingen.
-
Snelheid. Vooraf berekende timeframes en indicatoren maken het mogelijk om duizenden parametercombinaties in minuten te testen in plaats van dagen.
Het idee is simpel: één keer berekenen — vele malen hergebruiken. Minutencandles zijn de brondata. Al het overige is afgeleid en kan vooraf berekend en gecached worden. Parquet maakt deze cache compact, snel en handig.
Voor meer informatie over het verbeteren van de nauwkeurigheid van fill-simulatie met adaptieve drill-down van minuten naar seconden en milliseconden, zie het artikel Adaptieve drill-down: backtest met variabele granulariteit.
Nuttige links
- Apache Parquet — data storage format
- pandas — working with parquet
- Lopez de Prado — Advances in Financial Machine Learning
- Ernest Chan — Quantitative Trading
Citatie
@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.}
}
Auteurs
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.