← 기사 목록으로
March 16, 2026
5분 소요

집계 Parquet 캐시: 멀티 타임프레임 백테스트를 수백 배 빠르게 하는 방법

집계 Parquet 캐시: 멀티 타임프레임 백테스트를 수백 배 빠르게 하는 방법
#algotrading
#backtest
#multi-timeframe
#parquet
#optimization
#caching

멀티 타임프레임 전략은 여러 타임프레임을 동시에 사용합니다: 일봉으로 추세 방향을 결정하고, 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시간당 하나 등)을 얻습니다. 이는 분봉 단위 백테스트에는 적합하지 않습니다 — 매분의 지표 값을 알아야 합니다.

따라서 각 분봉에 대해 상위 타임프레임 값을 에뮬레이트하여 봇이 실시간으로 데이터를 보는 방식을 모델링합니다:

  1. 봇이 다음 분봉을 수신
  2. 상위 타임프레임의 현재(미확정) 바를 업데이트 — High, Low, Close, Volume 재계산
  3. 모든 확정 바와 현재 부분 바에 대해 지표 재계산
  4. 기간이 끝나면 — 바가 확정되고 새 바가 시작

이 접근 방식은 백테스트가 실시간 봇과 정확히 동일한 데이터를 보는 것을 보장합니다. 미래를 엿보지 않습니다 — 각 분봉은 해당 시점에 이용 가능했던 데이터만으로 엄격하게 처리됩니다.

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_4hma_50_4h만 사용하면 parquet는 해당 열만 읽고 나머지는 건너뜁니다.
  • 타입 보존. 데이터 타입(float64, int64, string)이 손실 없이 보존됩니다 — 로드 시 문자열 파싱이 필요 없습니다.
  • 읽기 속도. parquet를 pandas에 로딩하는 데 수십 밀리초가 걸리며, CSV보다 자릿수 단위로 빠릅니다.

캐시 확장: 새로운 지표 추가

전략에 새로운 지표(RSI, MACD, 볼린저 밴드)가 필요하면 간단히:

  1. 동일한 분봉 데이터에서 새 지표만 재계산
  2. 기존 parquet 파일에 열 추가
  3. 이전에 계산된 열은 그대로 유지
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 캐시 접근 방식은 두 가지 문제를 동시에 해결합니다:

  1. 정확성. RunningCandleBuffer를 통한 분봉에서의 타임프레임 에뮬레이션은 백테스트가 실시간 봇과 동일한 데이터를 보는 것을 보장합니다 — 미래를 엿보거나 인위적인 지연이 없습니다.

  2. 속도. 사전 계산된 타임프레임과 지표를 통해 수천 개의 파라미터 조합을 수 일이 아닌 수 분 만에 테스트할 수 있습니다.

아이디어는 간단합니다: 한 번 계산 — 여러 번 재사용. 분봉이 소스 데이터입니다. 그 외 모든 것은 파생물이며 사전 계산하고 캐시할 수 있습니다. Parquet은 이 캐시를 컴팩트하고 빠르며 편리하게 만듭니다.

분봉에서 초봉 및 밀리초봉으로의 어댑티브 드릴다운을 통한 체결 시뮬레이션 정확도 향상에 대해서는 어댑티브 드릴다운: 가변 해상도 백테스트 기사를 참조하세요.


참고 링크

  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

MarketMaker.cc Team

퀀트 리서치 및 전략

Telegram에서 토론하기
Newsletter

시장에서 앞서 나가세요

뉴스레터를 구독하여 독점적인 AI 트레이딩 통찰력, 시장 분석 및 플랫폼 업데이트를 받아보세요.

귀하의 개인정보를 존중합니다. 언제든지 구독을 취소할 수 있습니다.