집계 Parquet 캐시: 멀티 타임프레임 백테스트를 수백 배 빠르게 하는 방법
멀티 타임프레임 전략은 여러 타임프레임을 동시에 사용합니다: 일봉으로 추세 방향을 결정하고, 1시간봉으로 진입 시점을 파악하며, 5분봉으로 체결 타이밍을 정밀하게 잡습니다. 각 타임프레임에는 고유한 지표(이동평균, 오실레이터, 지지/저항)가 필요합니다.
단일 백테스트에서는 모든 것이 간단합니다 — 분봉 데이터에서 타임프레임을 재계산하고, 지표를 산출하고, 전략을 실행합니다. 하지만 대량 최적화 중에 — 수천 개의 파라미터 조합을 테스트해야 할 때 — 매 반복마다 타임프레임과 지표를 재계산하는 것이 병목이 됩니다. 2년간의 분봉 데이터를 한 번 통과하면 100만 개 이상의 바를 처리해야 하며, 이를 1000번 반복하는 것은 낭비입니다.
해결책: 모든 것을 한 번 계산하고 parquet 파일에 캐시합니다.
문제: 최적화 중 불필요한 계산
전형적인 멀티 타임프레임 백테스트 파이프라인:
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)
매 반복마다 데이터가 동일함에도 불구하고 1-3단계가 재계산됩니다. 변경되는 것은 전략의 임계값 파라미터(4단계)뿐입니다. 벽 색상만 바꿔보려 할 때마다 집 전체를 다시 짓는 것과 같습니다.
아이디어: 한 번 계산하고, 저장하고, 여러 번 재사용
핵심 관찰: 타임프레임과 지표는 분봉 데이터와 지표 파라미터에만 의존하며 전략 파라미터에는 의존하지 않습니다. 필요한 지표 세트를 고정하면 한 번 계산하고 저장할 수 있습니다.
스키마:
1단계 (한 번):
분봉 -> 타임프레임 리샘플링 -> 지표 계산 -> Parquet 파일
2단계 (여러 번):
Parquet 파일 -> 다른 파라미터의 전략 -> 결과
분봉에서 타임프레임 에뮬레이션

분봉의 완전한 아카이브가 있습니다. 이로부터 모든 상위 타임프레임을 정확하게 재현할 수 있습니다. 하지만 주의할 점이 있습니다: 표준 resample을 사용하면 기간당 하나의 행(시간당 하나, 4시간당 하나 등)을 얻습니다. 이는 분봉 단위 백테스트에는 적합하지 않습니다 — 매분의 지표 값을 알아야 합니다.
따라서 각 분봉에 대해 상위 타임프레임 값을 에뮬레이트하여 봇이 실시간으로 데이터를 보는 방식을 모델링합니다:
- 봇이 다음 분봉을 수신
- 상위 타임프레임의 현재(미확정) 바를 업데이트 — High, Low, Close, Volume 재계산
- 모든 확정 바와 현재 부분 바에 대해 지표 재계산
- 기간이 끝나면 — 바가 확정되고 새 바가 시작
이 접근 방식은 백테스트가 실시간 봇과 정확히 동일한 데이터를 보는 것을 보장합니다. 미래를 엿보지 않습니다 — 각 분봉은 해당 시점에 이용 가능했던 데이터만으로 엄격하게 처리됩니다.
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]
각 상위 타임프레임에 대해 별도의 RunningCandleBuffer가 생성됩니다. 매 분봉에서 모든 버퍼가 업데이트되어 각 타임프레임의 현재 상태를 제공합니다 — 봇이 실시간으로 동작하는 것처럼.
Parquet 캐시 구조
사전 계산 결과는 각 행이 하나의 분봉에 해당하고 열에 다음이 포함된 단일 parquet 파일입니다:
timestamp — 분봉 타임스탬프
open, high, low, — 분봉 OHLCV
close, volume
close_5m — 이 시점의 에뮬레이트된 5m봉 Close
close_1h — 에뮬레이트된 1h봉의 Close
close_4h — 에뮬레이트된 4h봉의 Close
close_1d — 에뮬레이트된 일봉의 Close
ma_20_1h — 1h의 MA(20), 이 분에 재계산
ma_50_1h — 1h의 MA(50)
ma_20_4h — 4h의 MA(20)
ma_50_4h — 4h의 MA(50)
ma_6_1d — 일봉의 MA(6)
ma_12_1d — 일봉의 MA(12)
cross_ma_1h — 1h의 MA 크로스오버 시그널 ('buy'/'sell'/None)
cross_ma_4h — 4h의 MA 크로스오버 시그널
cross_ma_1d — 일봉의 MA 크로스오버 시그널
separation_1h — 1h의 MA 이격도 (%)
separation_4h — 4h의 MA 이격도 (%)
separation_1d — 일봉의 MA 이격도 (%)
각 값은 해당 분봉 시점의 지표 실제 상태를 반영합니다 — 상위 타임프레임의 미확정 바를 고려합니다.
사전 계산: 캐시 구축
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")
최적화 시 캐시 사용

이제 최적화는 다음과 같습니다:
cache = pd.read_parquet("cache_ETHUSDT_2024_2026.parquet")
for params in parameter_grid:
result = run_strategy(cache, params)
전략은 사전 구축된 열로 작동합니다 — 100만 바의 반복 통과, MA 재계산, 타임프레임 에뮬레이션이 필요 없습니다. DataFrame에서 읽고 진입/청산 조건을 확인하기만 하면 됩니다.
왜 Parquet인가
Parquet는 이 작업에 최적인 컬럼형 데이터 저장 포맷입니다:
- 압축. Parquet는 수치 데이터를 5-10배 압축합니다. 30개 열에 110만 행의 캐시는 CSV의 ~500 MB 대신 ~50 MB를 차지합니다.
- 컬럼형 읽기. 전략이
ma_20_4h와ma_50_4h만 사용하면 parquet는 해당 열만 읽고 나머지는 건너뜁니다. - 타입 보존. 데이터 타입(float64, int64, string)이 손실 없이 보존됩니다 — 로드 시 문자열 파싱이 필요 없습니다.
- 읽기 속도. parquet를 pandas에 로딩하는 데 수십 밀리초가 걸리며, CSV보다 자릿수 단위로 빠릅니다.
캐시 확장: 새로운 지표 추가
전략에 새로운 지표(RSI, MACD, 볼린저 밴드)가 필요하면 간단히:
- 동일한 분봉 데이터에서 새 지표만 재계산
- 기존 parquet 파일에 열 추가
- 이전에 계산된 열은 그대로 유지
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")
요약: 접근 방식 비교
| 순진한 접근 | 집계 캐시 | |
|---|---|---|
| 타임프레임 리샘플링 | 매 반복 | 1회 |
| 지표 계산 | 매 반복 | 1회 |
| 반복당 시간 | 수 분 | 1초 미만 |
| 1000회 반복 | 수 일 | 수 분 |
| 메모리 소비 | 1m 로드 + 재계산 | 단일 DataFrame |
| 백테스트-라이브 일치 | 구현에 따라 다름 | 보장 (에뮬레이션 = 실시간) |
결론
집계 Parquet 캐시 접근 방식은 두 가지 문제를 동시에 해결합니다:
-
정확성. RunningCandleBuffer를 통한 분봉에서의 타임프레임 에뮬레이션은 백테스트가 실시간 봇과 동일한 데이터를 보는 것을 보장합니다 — 미래를 엿보거나 인위적인 지연이 없습니다.
-
속도. 사전 계산된 타임프레임과 지표를 통해 수천 개의 파라미터 조합을 수 일이 아닌 수 분 만에 테스트할 수 있습니다.
아이디어는 간단합니다: 한 번 계산 — 여러 번 재사용. 분봉이 소스 데이터입니다. 그 외 모든 것은 파생물이며 사전 계산하고 캐시할 수 있습니다. Parquet은 이 캐시를 컴팩트하고 빠르며 편리하게 만듭니다.
분봉에서 초봉 및 밀리초봉으로의 어댑티브 드릴다운을 통한 체결 시뮬레이션 정확도 향상에 대해서는 어댑티브 드릴다운: 가변 해상도 백테스트 기사를 참조하세요.
참고 링크
- 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.}
}
MarketMaker.cc Team
퀀트 리서치 및 전략