एग्रीगेटेड पार्क्वेट कैश: मल्टी-टाइमफ्रेम बैकटेस्ट को सैकड़ों गुना तेज़ कैसे करें

मिनट कैंडल से टाइमफ्रेम और इंडिकेटर पहले से कैसे गणना करें, उन्हें parquet में सेव करें और बिना दोहराई गणनाओं के रणनीतियों के बड़े पैमाने पर परीक्षण के लिए उपयोग करें।

एक मल्टी-टाइमफ्रेम रणनीति एक साथ कई टाइमफ्रेम का उपयोग करती है: डेली ट्रेंड की दिशा तय करता है, ऑवरली एंट्री पॉइंट्स की पहचान करता है, और 5-मिनट एग्ज़िक्यूशन का सटीक समय निर्धारित करता है। हर टाइमफ्रेम को अपने खुद के इंडिकेटर चाहिए होते हैं: मूविंग एवरेज, ऑसिलेटर, लेवल।

एक अकेले बैकटेस्ट के लिए सब कुछ सीधा है — मिनट डेटा से टाइमफ्रेम को दोबारा गणना करना, इंडिकेटर कैलकुलेट करना, रणनीति चलाना। लेकिन मास ऑप्टिमाइज़ेशन के दौरान — जब हज़ारों पैरामीटर कॉम्बिनेशन का परीक्षण करना हो — हर इटरेशन पर टाइमफ्रेम और इंडिकेटर को दोबारा गणना करना एक बॉटलनेक बन जाता है। दो साल के मिनट डेटा से एक भी पास करने में दस लाख से अधिक बार को प्रोसेस करना पड़ता है, और इसे हज़ार बार दोहराना बर्बादी है।

समाधान: सब कुछ एक बार पहले से गणना करें और इसे parquet फ़ाइल में कैश करें।

समस्या: ऑप्टिमाइज़ेशन के दौरान दोहराई गई गणनाएँ

एक विशिष्ट मल्टी-टाइमफ्रेम बैकटेस्ट पाइपलाइन इस तरह दिखती है:

for params in parameter_grid:
    # 1. Load minute candles
    df_1m = load_candles("ETHUSDT", "1m", start, end)

    # 2. Resample to higher timeframes
    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")

    # 3. Compute indicators for each timeframe
    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"])

    # 4. Run the strategy
    result = run_strategy(df_1m, ma_1h, ma_4h, ma_1d, params)

हर इटरेशन पर, स्टेप्स 1-3 को दोबारा गणना किया जाता है भले ही डेटा वही हो। केवल रणनीति के थ्रेशोल्ड पैरामीटर बदलते हैं (स्टेप 4)। यह वैसा ही है जैसे हर बार सिर्फ दीवार का रंग बदलने के लिए पूरा घर फिर से बनाना।

विचार: एक बार गणना करें, सेव करें, कई बार पुनः उपयोग करें

मुख्य अवलोकन: टाइमफ्रेम और इंडिकेटर केवल मिनट डेटा और इंडिकेटर पैरामीटर पर निर्भर करते हैं, रणनीति के पैरामीटर पर नहीं। यदि हम आवश्यक इंडिकेटर के सेट को तय कर दें, तो हम उन्हें एक बार गणना करके सेव कर सकते हैं।

योजना:

Step 1 (once):
  Minute candles -> Timeframe resampling -> Indicator computation -> Parquet file

Step 2 (many times):
  Parquet file -> Strategy with different parameters -> Result

मिनट कैंडल से टाइमफ्रेम का एमुलेशन

Real-time timeframe emulation visualization

हमारे पास मिनट कैंडल का एक पूरा आर्काइव है। इससे हम किसी भी उच्च टाइमफ्रेम को सटीकता से पुनः बना सकते हैं। लेकिन एक बारीकी है: एक स्टैंडर्ड 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']:
            # New period — finalize the previous bar
            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:
            # Update the current (unclosed) bar
            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

        # All closed bars + current running bar
        return self.closed_bars + [self.current_bar]

हर उच्च टाइमफ्रेम के लिए एक अलग RunningCandleBuffer बनाया जाता है। हर मिनट कैंडल पर, सभी बफर अपडेट होते हैं, जिससे हमें हर टाइमफ्रेम की मौजूदा स्थिति मिलती है — जैसे बॉट रियल टाइम में चल रहा हो।

पार्क्वेट कैश की संरचना

पूर्व-गणना का परिणाम एक ही parquet फ़ाइल है जहाँ हर रो एक मिनट कैंडल के अनुरूप होती है, और कॉलम में शामिल हैं:

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

हर वैल्यू संबंधित मिनट कैंडल के पल पर इंडिकेटर की वास्तविक स्थिति को दर्शाती है — उच्च टाइमफ्रेम के अनक्लोज़्ड बार को ध्यान में रखते हुए।

Precompute: कैश बनाना

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.
    """
    # Create buffers for each timeframe
    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]

            # Compute indicators from emulated bars
            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
# Build and save the cache
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-based optimization speedup comparison

अब ऑप्टिमाइज़ेशन इस तरह दिखता है:

# Load the cache once
cache = pd.read_parquet("cache_ETHUSDT_2024_2026.parquet")

for params in parameter_grid:
    # No timeframe or indicator recalculations!
    # All data is already in the cache — only strategy logic remains
    result = run_strategy(cache, params)

रणनीति पहले से बने कॉलम के साथ काम करती है — दस लाख बार पर बार-बार पास करने की ज़रूरत नहीं, MA की दोबारा गणना नहीं, टाइमफ्रेम एमुलेशन नहीं। बस DataFrame से पढ़ना और एंट्री/एग्ज़िट कंडीशन की जांच करना।

Parquet क्यों

Parquet एक कॉलमनार डेटा स्टोरेज फॉर्मेट है, जो इस काम के लिए बेहतरीन है:

  • कम्प्रेशन। Parquet न्यूमेरिकल डेटा को 5-10 गुना कम्प्रेस करता है। 1.1 मिलियन रो और 30 कॉलम वाला कैश CSV में ~500 MB के बजाय ~50 MB लेता है।
  • कॉलमनार रीडिंग। अगर रणनीति केवल ma_20_4h और ma_50_4h का उपयोग करती है, तो parquet केवल उन्हीं कॉलम को पढ़ता है, बाकी को छोड़ते हुए।
  • टाइप संरक्षण। डेटा टाइप्स (float64, int64, string) बिना किसी नुकसान के संरक्षित रहते हैं — लोड करते समय स्ट्रिंग पार्स करने की ज़रूरत नहीं होती।
  • पढ़ने की गति। pandas में parquet को लोड करने में कुछ दस मिलीसेकंड लगते हैं, जो CSV से एक ऑर्डर ऑफ़ मैग्निट्यूड तेज़ है।

कैश को बढ़ाना: नए इंडिकेटर जोड़ना

अगर रणनीति को किसी नए इंडिकेटर की ज़रूरत है (RSI, MACD, Bollinger Bands), तो बस:

  1. उसी मिनट डेटा से केवल नए इंडिकेटर को दोबारा गणना करें
  2. मौजूदा parquet फ़ाइल में कॉलम जोड़ें
  3. पहले से गणना किए गए सभी कॉलम बिना छेड़े रहते हैं
# Add RSI to the existing cache
cache = pd.read_parquet("cache_ETHUSDT_2024_2026.parquet")

# Compute only RSI (requires a pass through 1m data)
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")

सारांश: दृष्टिकोणों की तुलना

नैव दृष्टिकोणएग्रीगेटेड कैश
टाइमफ्रेम रिसैंपलिंगहर इटरेशनएक बार
इंडिकेटर गणनाहर इटरेशनएक बार
प्रति इटरेशन समयमिनटएक सेकंड से कम
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

उद्धरण

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